From 91b9366f646a5055052f48ab395b50a921530751 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Tue, 7 Apr 2026 22:26:45 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=A4=A7=E8=A7=84=E6=A8=A1?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=8E=9F=E6=9C=89=E7=BB=84=E4=BB=B6=E5=88=B0?= =?UTF-8?q?=20ink=20=E5=8C=85=E5=86=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + packages/@ant/ink/src/hooks/useDoublePress.ts | 62 +++ .../@ant/ink/src/hooks/useMinDisplayTime.ts | 35 ++ packages/@ant/ink/src/hooks/useTimeout.ts | 14 + packages/@ant/ink/src/index.ts | 16 +- .../ink/src/keybindings/KeybindingSetup.tsx | 320 +++++++++++++++ packages/@ant/ink/src/keybindings/types.ts | 31 ++ src/commands/plugin/PluginSettings.tsx | 3 +- src/components/HelpV2/Commands.tsx | 1 - src/components/HelpV2/HelpV2.tsx | 4 +- src/components/PromptInput/Notifications.tsx | 10 - src/components/SearchBox.tsx | 73 +--- src/components/Settings/Config.tsx | 3 +- src/components/Settings/Settings.tsx | 23 +- src/components/Spinner.tsx | 14 +- src/components/Stats.tsx | 3 +- src/components/design-system/Byline.tsx | 58 +-- src/components/design-system/Dialog.tsx | 101 +---- src/components/design-system/Divider.tsx | 97 +---- src/components/design-system/FuzzyPicker.tsx | 349 +--------------- .../design-system/KeyboardShortcutHint.tsx | 59 +-- src/components/design-system/ListItem.tsx | 188 +-------- src/components/design-system/LoadingState.tsx | 67 +--- src/components/design-system/Pane.tsx | 58 +-- src/components/design-system/ProgressBar.tsx | 55 +-- src/components/design-system/Ratchet.tsx | 45 +-- src/components/design-system/StatusIcon.tsx | 72 +--- src/components/design-system/Tabs.tsx | 337 +--------------- .../design-system/ThemeProvider.tsx | 161 +------- src/components/design-system/ThemedBox.tsx | 108 +---- src/components/design-system/ThemedText.tsx | 133 +------ .../permissions/rules/PermissionRuleList.tsx | 3 +- .../permissions/rules/RecentDenialsTab.tsx | 1 - .../permissions/rules/WorkspaceTab.tsx | 1 - .../sandbox/SandboxOverridesTab.tsx | 4 +- src/components/sandbox/SandboxSettings.tsx | 4 +- src/hooks/useDoublePress.ts | 65 +-- src/hooks/useMinDisplayTime.ts | 37 +- src/hooks/useTerminalSize.ts | 14 +- src/hooks/useTimeout.ts | 16 +- src/keybindings/KeybindingProviderSetup.tsx | 374 ++---------------- src/main.tsx | 18 - src/tools/TungstenTool/TungstenTool.js | 3 + src/utils/computerUse/drainRunLoop.ts | 2 +- 44 files changed, 563 insertions(+), 2480 deletions(-) create mode 100644 packages/@ant/ink/src/hooks/useDoublePress.ts create mode 100644 packages/@ant/ink/src/hooks/useMinDisplayTime.ts create mode 100644 packages/@ant/ink/src/hooks/useTimeout.ts create mode 100644 packages/@ant/ink/src/keybindings/KeybindingSetup.tsx diff --git a/.gitignore b/.gitignore index 8b5e47a0e..9813a5d12 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ src/utils/vendor/ # Python bytecode __pycache__/ *.pyc +logs diff --git a/packages/@ant/ink/src/hooks/useDoublePress.ts b/packages/@ant/ink/src/hooks/useDoublePress.ts new file mode 100644 index 000000000..7844fbd66 --- /dev/null +++ b/packages/@ant/ink/src/hooks/useDoublePress.ts @@ -0,0 +1,62 @@ +// Creates a function that calls one function on the first call and another +// function on the second call within a certain timeout + +import { useCallback, useEffect, useRef } from 'react' + +export const DOUBLE_PRESS_TIMEOUT_MS = 800 + +export function useDoublePress( + setPending: (pending: boolean) => void, + onDoublePress: () => void, + onFirstPress?: () => void, +): () => void { + const lastPressRef = useRef(0) + const timeoutRef = useRef(undefined) + + const clearTimeoutSafe = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = undefined + } + }, []) + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + clearTimeoutSafe() + } + }, [clearTimeoutSafe]) + + return useCallback(() => { + const now = Date.now() + const timeSinceLastPress = now - lastPressRef.current + const isDoublePress = + timeSinceLastPress <= DOUBLE_PRESS_TIMEOUT_MS && + timeoutRef.current !== undefined + + if (isDoublePress) { + // Double press detected + clearTimeoutSafe() + setPending(false) + onDoublePress() + } else { + // First press + onFirstPress?.() + setPending(true) + + // Clear any existing timeout and set new one + clearTimeoutSafe() + timeoutRef.current = setTimeout( + (setPending, timeoutRef) => { + setPending(false) + timeoutRef.current = undefined + }, + DOUBLE_PRESS_TIMEOUT_MS, + setPending, + timeoutRef, + ) + } + + lastPressRef.current = now + }, [setPending, onDoublePress, onFirstPress, clearTimeoutSafe]) +} diff --git a/packages/@ant/ink/src/hooks/useMinDisplayTime.ts b/packages/@ant/ink/src/hooks/useMinDisplayTime.ts new file mode 100644 index 000000000..587b96938 --- /dev/null +++ b/packages/@ant/ink/src/hooks/useMinDisplayTime.ts @@ -0,0 +1,35 @@ +import { useEffect, useRef, useState } from 'react' + +/** + * Throttles a value so each distinct value stays visible for at least `minMs`. + * Prevents fast-cycling progress text from flickering past before it's readable. + * + * Unlike debounce (wait for quiet) or throttle (limit rate), this guarantees + * each value gets its minimum screen time before being replaced. + */ +export function useMinDisplayTime(value: T, minMs: number): T { + const [displayed, setDisplayed] = useState(value) + const lastShownAtRef = useRef(0) + + useEffect(() => { + const elapsed = Date.now() - lastShownAtRef.current + if (elapsed >= minMs) { + lastShownAtRef.current = Date.now() + setDisplayed(value) + return + } + const timer = setTimeout( + (shownAtRef, setFn, v) => { + shownAtRef.current = Date.now() + setFn(v) + }, + minMs - elapsed, + lastShownAtRef, + setDisplayed, + value, + ) + return () => clearTimeout(timer) + }, [value, minMs]) + + return displayed +} diff --git a/packages/@ant/ink/src/hooks/useTimeout.ts b/packages/@ant/ink/src/hooks/useTimeout.ts new file mode 100644 index 000000000..faed236af --- /dev/null +++ b/packages/@ant/ink/src/hooks/useTimeout.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from 'react' + +export function useTimeout(delay: number, resetTrigger?: number): boolean { + const [isElapsed, setIsElapsed] = useState(false) + + useEffect(() => { + setIsElapsed(false) + const timer = setTimeout(setIsElapsed, delay, true) + + return () => clearTimeout(timer) + }, [delay, resetTrigger]) + + return isElapsed +} diff --git a/packages/@ant/ink/src/index.ts b/packages/@ant/ink/src/index.ts index bc6388e31..9d9453002 100644 --- a/packages/@ant/ink/src/index.ts +++ b/packages/@ant/ink/src/index.ts @@ -49,6 +49,10 @@ export { matchesKeystroke, matchesBinding, } from './keybindings/match.js' +export { + KeybindingSetup, + type KeybindingSetupProps, +} from './keybindings/KeybindingSetup.js' export type { ParsedBinding, ParsedKeystroke, @@ -56,6 +60,9 @@ export type { KeybindingBlock, Chord, KeybindingAction, + KeybindingWarningType, + KeybindingWarning, + KeybindingsLoadResult, } from './keybindings/types.js' // ============================================================ @@ -130,6 +137,10 @@ export { useAnimationFrame } from './hooks/use-animation-frame.js' export { useAnimationTimer, useInterval } from './hooks/use-interval.js' export { useSelection, useHasSelection } from './hooks/use-selection.js' export { default as useStdin } from './hooks/use-stdin.js' +export { useTerminalSize } from './hooks/useTerminalSize.js' +export { useTimeout } from './hooks/useTimeout.js' +export { useMinDisplayTime } from './hooks/useMinDisplayTime.js' +export { useDoublePress, DOUBLE_PRESS_TIMEOUT_MS } from './hooks/useDoublePress.js' export { useTabStatus, type TabStatusKind } from './hooks/use-tab-status.js' export { useTerminalFocus } from './hooks/use-terminal-focus.js' export { useTerminalTitle } from './hooks/use-terminal-title.js' @@ -149,11 +160,12 @@ export { } from './theme/ThemeProvider.js' export { default as Box } from './theme/ThemedBox.js' export type { Props as BoxProps } from './theme/ThemedBox.js' -export { default as Text } from './theme/ThemedText.js' +export { default as Text, TextHoverColorContext } from './theme/ThemedText.js' export type { Props as TextProps } from './theme/ThemedText.js' export { color } from './theme/color.js' // Theme sub-components +export { SearchBox } from './theme/SearchBox.js' export { Dialog } from './theme/Dialog.js' export { Divider } from './theme/Divider.js' export { FuzzyPicker } from './theme/FuzzyPicker.js' @@ -163,6 +175,6 @@ export { Pane } from './theme/Pane.js' export { ProgressBar } from './theme/ProgressBar.js' export { Ratchet } from './theme/Ratchet.js' export { StatusIcon } from './theme/StatusIcon.js' -export { Tabs } from './theme/Tabs.js' +export { Tabs, Tab, useTabsWidth, useTabHeaderFocus } from './theme/Tabs.js' export { Byline } from './theme/Byline.js' export { KeyboardShortcutHint } from './theme/KeyboardShortcutHint.js' diff --git a/packages/@ant/ink/src/keybindings/KeybindingSetup.tsx b/packages/@ant/ink/src/keybindings/KeybindingSetup.tsx new file mode 100644 index 000000000..36acd9154 --- /dev/null +++ b/packages/@ant/ink/src/keybindings/KeybindingSetup.tsx @@ -0,0 +1,320 @@ +/** + * Generic keybinding setup component for integrating KeybindingProvider into an app. + * + * Provides chord state management, a ChordInterceptor, and the KeybindingProvider + * wrapper. App-specific dependencies (binding loading, change subscription, + * warning display, debug logging) are injected via props. + */ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import type { InputEvent } from '../core/events/input-event.js' +// ChordInterceptor intentionally uses useInput to intercept all keystrokes before +// other handlers process them - this is required for chord sequence support +// eslint-disable-next-line custom-rules/prefer-use-keybindings +import useInput, { type Key } from '../hooks/use-input.js' +import { KeybindingProvider } from './KeybindingContext.js' +import { resolveKeyWithChordState } from './resolver.js' +import type { + KeybindingContextName, + KeybindingsLoadResult, + ParsedBinding, + ParsedKeystroke, + KeybindingWarning, +} from './types.js' + +/** + * Timeout for chord sequences in milliseconds. + * If the user doesn't complete the chord within this time, it's cancelled. + */ +const CHORD_TIMEOUT_MS = 1000 + +export type KeybindingSetupProps = { + children: React.ReactNode + + /** Load bindings synchronously for initial render */ + loadBindings: () => KeybindingsLoadResult + + /** Subscribe to binding changes; return an unsubscribe function */ + subscribeToChanges: ( + callback: (result: KeybindingsLoadResult) => void, + ) => () => void + + /** Initialize any file watcher (idempotent). Called once on mount. */ + initWatcher?: () => void | Promise + + /** Optional callback when warnings are emitted (initial load or reload) */ + onWarnings?: (warnings: KeybindingWarning[], isReload: boolean) => void + + /** Optional debug logger */ + onDebugLog?: (message: string) => void +} + +export function KeybindingSetup({ + children, + loadBindings, + subscribeToChanges, + initWatcher, + onWarnings, + onDebugLog, +}: KeybindingSetupProps): React.ReactNode { + // Load bindings synchronously for initial render + const [loadResult, setLoadResult] = useState(() => { + const result = loadBindings() + onDebugLog?.( + `[keybindings] KeybindingSetup initialized with ${result.bindings.length} bindings, ${result.warnings.length} warnings`, + ) + return result + }) + + const { bindings, warnings } = loadResult + + // Track if this is a reload (not initial load) + const [isReload, setIsReload] = useState(false) + + // Notify about warnings + useEffect(() => { + onWarnings?.(warnings, isReload) + }, [warnings, isReload, onWarnings]) + + // Chord state management - use ref for immediate access, state for re-renders + const pendingChordRef = useRef(null) + const [pendingChord, setPendingChordState] = useState< + ParsedKeystroke[] | null + >(null) + const chordTimeoutRef = useRef(null) + + // Handler registry for action callbacks (used by ChordInterceptor to invoke handlers) + const handlerRegistryRef = useRef( + new Map< + string, + Set<{ + action: string + context: KeybindingContextName + handler: () => void + }> + >(), + ) + + // Active context tracking for keybinding priority resolution + const activeContextsRef = useRef>(new Set()) + + const registerActiveContext = useCallback( + (context: KeybindingContextName) => { + activeContextsRef.current.add(context) + }, + [], + ) + + const unregisterActiveContext = useCallback( + (context: KeybindingContextName) => { + activeContextsRef.current.delete(context) + }, + [], + ) + + // Clear chord timeout when component unmounts or chord changes + const clearChordTimeout = useCallback(() => { + if (chordTimeoutRef.current) { + clearTimeout(chordTimeoutRef.current) + chordTimeoutRef.current = null + } + }, []) + + // Wrapper for setPendingChord that manages timeout and syncs ref+state + const setPendingChord = useCallback( + (pending: ParsedKeystroke[] | null) => { + clearChordTimeout() + + if (pending !== null) { + // Set timeout to cancel chord if not completed + chordTimeoutRef.current = setTimeout( + (pendingChordRef, setPendingChordState) => { + onDebugLog?.('[keybindings] Chord timeout - cancelling') + pendingChordRef.current = null + setPendingChordState(null) + }, + CHORD_TIMEOUT_MS, + pendingChordRef, + setPendingChordState, + ) + } + + // Update ref immediately for synchronous access in resolve() + pendingChordRef.current = pending + // Update state to trigger re-renders for UI updates + setPendingChordState(pending) + }, + [clearChordTimeout, onDebugLog], + ) + + useEffect(() => { + // Initialize file watcher (idempotent - only runs once) + void initWatcher?.() + + // Subscribe to changes + const unsubscribe = subscribeToChanges(result => { + // Any callback invocation is a reload since initial load happens + // synchronously in useState, not via this subscription + setIsReload(true) + + setLoadResult(result) + onDebugLog?.( + `[keybindings] Reloaded: ${result.bindings.length} bindings, ${result.warnings.length} warnings`, + ) + }) + + return () => { + unsubscribe() + clearChordTimeout() + } + }, [subscribeToChanges, initWatcher, clearChordTimeout, onDebugLog]) + + return ( + + + {children} + + ) +} + +/** + * Global chord interceptor that registers useInput FIRST (before children). + * + * This component intercepts keystrokes that are part of chord sequences and + * stops propagation before other handlers (like PromptInput) can see them. + * + * Without this, the second key of a chord (e.g., 'r' in "ctrl+c r") would be + * captured by PromptInput and added to the input field before the keybinding + * system could recognize it as completing a chord. + */ +type HandlerRegistration = { + action: string + context: KeybindingContextName + handler: () => void +} + +function ChordInterceptor({ + bindings, + pendingChordRef, + setPendingChord, + activeContexts, + handlerRegistryRef, +}: { + bindings: ParsedBinding[] + pendingChordRef: React.RefObject + setPendingChord: (pending: ParsedKeystroke[] | null) => void + activeContexts: Set + handlerRegistryRef: React.RefObject>> +}): null { + const handleInput = useCallback( + (input: string, key: Key, event: InputEvent) => { + // Wheel events can never start chord sequences — scroll:lineUp/Down are + // single-key bindings handled by per-component useKeybindings hooks, not + // here. Skip the registry scan. Mid-chord wheel still falls through so + // scrolling cancels the pending chord like any other non-matching key. + if ((key.wheelUp || key.wheelDown) && pendingChordRef.current === null) { + return + } + + // Build context list from registered handlers + activeContexts + Global + const registry = handlerRegistryRef.current + const handlerContexts = new Set() + if (registry) { + for (const handlers of registry.values()) { + for (const registration of handlers) { + handlerContexts.add(registration.context) + } + } + } + const contexts: KeybindingContextName[] = [ + ...handlerContexts, + ...activeContexts, + 'Global', + ] + + // Track whether we're completing a chord (pending was non-null) + const wasInChord = pendingChordRef.current !== null + + // Check if this keystroke is part of a chord sequence + const result = resolveKeyWithChordState( + input, + key, + contexts, + bindings, + pendingChordRef.current, + ) + + switch (result.type) { + case 'chord_started': + // This key starts a chord - store pending state and stop propagation + setPendingChord(result.pending) + event.stopImmediatePropagation() + break + + case 'match': { + // Clear pending state + setPendingChord(null) + + // Only invoke handlers and stop propagation for chord completions + // (multi-keystroke sequences). Single-keystroke matches should propagate + // to per-hook handlers to avoid interfering with other input handling. + if (wasInChord) { + const contextsSet = new Set(contexts) + if (registry) { + const handlers = registry.get(result.action) + if (handlers && handlers.size > 0) { + for (const registration of handlers) { + if (contextsSet.has(registration.context)) { + registration.handler() + event.stopImmediatePropagation() + break + } + } + } + } + } + break + } + + case 'chord_cancelled': + setPendingChord(null) + event.stopImmediatePropagation() + break + + case 'unbound': + setPendingChord(null) + event.stopImmediatePropagation() + break + + case 'none': + // No chord involvement - let other handlers process + break + } + }, + [ + bindings, + pendingChordRef, + setPendingChord, + activeContexts, + handlerRegistryRef, + ], + ) + + useInput(handleInput) + + return null +} diff --git a/packages/@ant/ink/src/keybindings/types.ts b/packages/@ant/ink/src/keybindings/types.ts index b72090cbb..0482ca118 100644 --- a/packages/@ant/ink/src/keybindings/types.ts +++ b/packages/@ant/ink/src/keybindings/types.ts @@ -21,3 +21,34 @@ export type KeybindingBlock = { } export type Chord = ParsedKeystroke[] export type KeybindingAction = string + +/** + * Types of validation issues that can occur with keybindings. + */ +export type KeybindingWarningType = + | 'parse_error' + | 'duplicate' + | 'reserved' + | 'invalid_context' + | 'invalid_action' + +/** + * A warning or error about a keybinding configuration issue. + */ +export type KeybindingWarning = { + type: KeybindingWarningType + severity: 'error' | 'warning' + message: string + key?: string + context?: string + action?: string + suggestion?: string +} + +/** + * Result of loading keybindings, including any validation warnings. + */ +export type KeybindingsLoadResult = { + bindings: ParsedBinding[] + warnings: KeybindingWarning[] +} diff --git a/src/commands/plugin/PluginSettings.tsx b/src/commands/plugin/PluginSettings.tsx index 4d544ef47..444bf9761 100644 --- a/src/commands/plugin/PluginSettings.tsx +++ b/src/commands/plugin/PluginSettings.tsx @@ -2,8 +2,7 @@ import figures from 'figures' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js' -import { Byline, Pane, Tabs } from '@anthropic/ink' -import { Tab } from '../../components/design-system/Tabs.js' +import { Byline, Pane, Tab, Tabs } from '@anthropic/ink' import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' import { Box, Text } from '@anthropic/ink' import { diff --git a/src/components/HelpV2/Commands.tsx b/src/components/HelpV2/Commands.tsx index dd5eda75e..fcff85f72 100644 --- a/src/components/HelpV2/Commands.tsx +++ b/src/components/HelpV2/Commands.tsx @@ -2,7 +2,6 @@ import * as React from 'react' import { useMemo } from 'react' import { type Command, formatDescriptionWithSource } from '../../commands.js' import { Box, Text } from '@anthropic/ink' -import { useTabHeaderFocus } from '../design-system/Tabs.js' type Props = { commands: Command[] diff --git a/src/components/HelpV2/HelpV2.tsx b/src/components/HelpV2/HelpV2.tsx index aa077f649..2a3e0b4e2 100644 --- a/src/components/HelpV2/HelpV2.tsx +++ b/src/components/HelpV2/HelpV2.tsx @@ -9,10 +9,8 @@ import { } from '../../commands.js' import { useIsInsideModal } from '../../context/modalContext.js' import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Box, Link, Text } from '@anthropic/ink' +import { Box, Link, Text, Tab, Tabs, Pane } from '@anthropic/ink' import { useKeybinding } from '../../keybindings/useKeybinding.js' -import { Pane, Tabs } from '@anthropic/ink' -import { Tab } from '../design-system/Tabs.js' import { Commands } from './Commands.js' import { General } from './General.js' diff --git a/src/components/PromptInput/Notifications.tsx b/src/components/PromptInput/Notifications.tsx index fb905efda..6ddccb3cc 100644 --- a/src/components/PromptInput/Notifications.tsx +++ b/src/components/PromptInput/Notifications.tsx @@ -339,16 +339,6 @@ function NotificationContent({ {!isBriefOnly && ( )} - {shouldShowAutoUpdater && ( - - )} {feature('VOICE_MODE') ? voiceEnabled && voiceError && ( diff --git a/src/components/SearchBox.tsx b/src/components/SearchBox.tsx index 9fd4a7fb4..532e18294 100644 --- a/src/components/SearchBox.tsx +++ b/src/components/SearchBox.tsx @@ -1,71 +1,2 @@ -import React from 'react' -import { Box, Text } from '@anthropic/ink' - -type Props = { - query: string - placeholder?: string - isFocused: boolean - isTerminalFocused: boolean - prefix?: string - width?: number | string - cursorOffset?: number - borderless?: boolean -} - -export function SearchBox({ - query, - placeholder = 'Search…', - isFocused, - isTerminalFocused, - prefix = '⌕', - width, - cursorOffset, - borderless = false, -}: Props): React.ReactNode { - const offset = cursorOffset ?? query.length - - return ( - - - {prefix}{' '} - {isFocused ? ( - <> - {query ? ( - isTerminalFocused ? ( - <> - {query.slice(0, offset)} - - {offset < query.length ? query[offset] : ' '} - - {offset < query.length && ( - {query.slice(offset + 1)} - )} - - ) : ( - {query} - ) - ) : isTerminalFocused ? ( - <> - {placeholder.charAt(0)} - {placeholder.slice(1)} - - ) : ( - {placeholder} - )} - - ) : query ? ( - {query} - ) : ( - {placeholder} - )} - - - ) -} +// Re-export from @anthropic/ink theme module +export { SearchBox } from '@anthropic/ink' diff --git a/src/components/Settings/Config.tsx b/src/components/Settings/Config.tsx index 2a4bc73c0..79661e237 100644 --- a/src/components/Settings/Config.tsx +++ b/src/components/Settings/Config.tsx @@ -69,8 +69,7 @@ import { getMemoryFiles, hasExternalClaudeMdIncludes, } from 'src/utils/claudemd.js' -import { Byline, KeyboardShortcutHint } from '@anthropic/ink' -import { useTabHeaderFocus } from '../design-system/Tabs.js' +import { Byline, KeyboardShortcutHint, useTabHeaderFocus } from '@anthropic/ink' import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' import { useIsInsideModal } from '../../context/modalContext.js' import { SearchBox } from '../SearchBox.js' diff --git a/src/components/Settings/Settings.tsx b/src/components/Settings/Settings.tsx index b6a5516dd..5ac172891 100644 --- a/src/components/Settings/Settings.tsx +++ b/src/components/Settings/Settings.tsx @@ -8,8 +8,7 @@ import { useIsInsideModal, useModalOrTerminalSize, } from '../../context/modalContext.js' -import { Pane, Tabs } from '@anthropic/ink' -import { Tab } from '../design-system/Tabs.js' +import { Pane, Tab, Tabs } from '@anthropic/ink' import { Status, buildDiagnostics } from './Status.js' import { Config } from './Config.js' import { Usage } from './Usage.js' @@ -24,7 +23,7 @@ type Props = { options?: { display?: CommandResultDisplay }, ) => void context: LocalJSXCommandContext - defaultTab: 'Status' | 'Config' | 'Usage' | 'Gates' + defaultTab: 'Status' | 'Config' | 'Usage' } export function Settings({ @@ -37,7 +36,6 @@ export function Settings({ // True while Config's own Esc handler is active (search mode with content // focused). Settings must cede Esc so search can clear/exit first. const [configOwnsEsc, setConfigOwnsEsc] = useState(false) - const [gatesOwnsEsc, setGatesOwnsEsc] = useState(false) // Fixed content height so switching tabs doesn't shift the pane height. // Outside modals cap at min(80% viewport, 30). Inside a Modal the modal's // innerSize.rows IS the ScrollBox viewport — the 0.8 multiplier over- @@ -79,8 +77,7 @@ export function Settings({ context: 'Settings', isActive: !tabsHidden && - !(selectedTab === 'Config' && configOwnsEsc) && - !(selectedTab === 'Gates' && gatesOwnsEsc), + !(selectedTab === 'Config' && configOwnsEsc), }) const tabs = [ @@ -101,16 +98,6 @@ export function Settings({ , - ...(process.env.USER_TYPE === 'ant' - ? [ - - - , - ] - : []), ] return ( @@ -122,10 +109,10 @@ export function Settings({ hidden={tabsHidden} // Config has interactive content — start with header unfocused so // left/right/tab cycle option values instead of switching tabs. - initialHeaderFocused={defaultTab !== 'Config' && defaultTab !== 'Gates'} + initialHeaderFocused={defaultTab !== 'Config'} // Inside a Modal, skip the Tabs-level cap so tall tabs (Status's // MCP list) flow to their natural height for the Modal's ScrollBox - // to scroll. Config/Gates still get contentHeight above — they + // to scroll. Config still gets contentHeight above — it // paginate internally so this only affects Status/Usage. contentHeight={tabsHidden || insideModal ? undefined : contentHeight} > diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index 089bf4d8e..73239ce1c 100644 --- a/src/components/Spinner.tsx +++ b/src/components/Spinner.tsx @@ -267,19 +267,9 @@ function SpinnerWithVerbInner({ const messageColor = overrideColor ?? defaultColor const shimmerColor = overrideShimmerColor ?? defaultShimmerColor - // Compute TTFT string here (off the 50ms animation clock) and pass to - // SpinnerAnimationRow so it folds into the `(thought for Ns · ...)` status - // line instead of taking a separate row. apiMetricsRef is a ref so this - // doesn't trigger re-renders; we pick up updates on the parent's ~25x/turn - // re-render cadence, same as the old ApiMetricsLine did. + // TTFT display is gated to internal builds — apiMetricsRef was removed from + // props during a refactor, so skip this until it's re-threaded. let ttftText: string | null = null - if ( - process.env.USER_TYPE === 'ant' && - apiMetricsRef?.current && - apiMetricsRef.current.length > 0 - ) { - ttftText = computeTtftText(apiMetricsRef.current) - } // When leader is idle but teammates are running (and we're viewing the leader), // show a static dim idle display instead of the animated spinner — otherwise diff --git a/src/components/Stats.tsx b/src/components/Stats.tsx index 6d311259a..1d20359f1 100644 --- a/src/components/Stats.tsx +++ b/src/components/Stats.tsx @@ -14,8 +14,7 @@ import stripAnsi from 'strip-ansi' import type { CommandResultDisplay } from '../commands.js' import { useTerminalSize } from '../hooks/useTerminalSize.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow stats navigation -import { Ansi, applyColor, Box, Text, useInput, stringWidth as getStringWidth, type Color, Pane, Tabs } from '@anthropic/ink' -import { Tab, useTabHeaderFocus } from './design-system/Tabs.js' +import { Ansi, applyColor, Box, Text, useInput, stringWidth as getStringWidth, type Color, Pane, Tab, Tabs, useTabHeaderFocus } from '@anthropic/ink' import { useKeybinding } from '../keybindings/useKeybinding.js' import { getGlobalConfig } from '../utils/config.js' import { formatDuration, formatNumber } from '../utils/format.js' diff --git a/src/components/design-system/Byline.tsx b/src/components/design-system/Byline.tsx index d2b73b77a..eefab18cf 100644 --- a/src/components/design-system/Byline.tsx +++ b/src/components/design-system/Byline.tsx @@ -1,57 +1 @@ -import React, { Children, isValidElement } from 'react' -import { Text } from '@anthropic/ink' - -type Props = { - /** The items to join with a middot separator */ - children: React.ReactNode -} - -/** - * Joins children with a middot separator (" · ") for inline metadata display. - * - * Named after the publishing term "byline" - the line of metadata typically - * shown below a title (e.g., "John Doe · 5 min read · Mar 12"). - * - * Automatically filters out null/undefined/false children and only renders - * separators between valid elements. - * - * @example - * // Basic usage: "Enter to confirm · Esc to cancel" - * - * - * - * - * - * - * - * @example - * // With conditional children: "Esc to cancel" (only one item shown) - * - * - * {showEnter && } - * - * - * - * - */ -export function Byline({ children }: Props): React.ReactNode { - // Children.toArray already filters out null, undefined, and booleans - const validChildren = Children.toArray(children) - - if (validChildren.length === 0) { - return null - } - - return ( - <> - {validChildren.map((child, index) => ( - - {index > 0 && · } - {child} - - ))} - - ) -} +export { Byline } from '@anthropic/ink' diff --git a/src/components/design-system/Dialog.tsx b/src/components/design-system/Dialog.tsx index dfd65449e..bb531183c 100644 --- a/src/components/design-system/Dialog.tsx +++ b/src/components/design-system/Dialog.tsx @@ -1,100 +1 @@ -import React from 'react' -import { - type ExitState, - useExitOnCtrlCDWithKeybindings, -} from '../../hooks/useExitOnCtrlCDWithKeybindings.js' -import { Box, Text } from '@anthropic/ink' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import type { Theme } from '../../utils/theme.js' -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' -import { Byline } from './Byline.js' -import { KeyboardShortcutHint } from './KeyboardShortcutHint.js' -import { Pane } from './Pane.js' - -type DialogProps = { - title: React.ReactNode - subtitle?: React.ReactNode - children: React.ReactNode - onCancel: () => void - color?: keyof Theme - hideInputGuide?: boolean - hideBorder?: boolean - /** Custom input guide content. Receives exitState for Ctrl+C/D pending display. */ - inputGuide?: (exitState: ExitState) => React.ReactNode - /** - * Controls whether Dialog's built-in confirm:no (Esc/n) and app:exit/interrupt - * (Ctrl-C/D) keybindings are active. Set to `false` while an embedded text - * field is being edited so those keys reach the field instead of being - * consumed by Dialog. TextInput has its own ctrl+c/d handlers (cancel on - * press, delete-forward on ctrl+d with text). Defaults to `true`. - */ - isCancelActive?: boolean -} - -export function Dialog({ - title, - subtitle, - children, - onCancel, - color = 'permission', - hideInputGuide, - hideBorder, - inputGuide, - isCancelActive = true, -}: DialogProps): React.ReactNode { - const exitState = useExitOnCtrlCDWithKeybindings( - undefined, - undefined, - isCancelActive, - ) - - // Use configurable keybinding for ESC to cancel. - // isCancelActive lets consumers (e.g. ElicitationDialog) disable this while - // an embedded TextInput is focused, so that keys like 'n' reach the field - // instead of being consumed here. - useKeybinding('confirm:no', onCancel, { - context: 'Confirmation', - isActive: isCancelActive, - }) - - const defaultInputGuide = exitState.pending ? ( - Press {exitState.keyName} again to exit - ) : ( - - - - - ) - - const content = ( - <> - - - - {title} - - {subtitle && {subtitle}} - - {children} - - {!hideInputGuide && ( - - - {inputGuide ? inputGuide(exitState) : defaultInputGuide} - - - )} - - ) - - if (hideBorder) { - return content - } - - return {content} -} +export { Dialog } from '@anthropic/ink' diff --git a/src/components/design-system/Divider.tsx b/src/components/design-system/Divider.tsx index 2606a5d5b..a5b7f0e88 100644 --- a/src/components/design-system/Divider.tsx +++ b/src/components/design-system/Divider.tsx @@ -1,96 +1 @@ -import React from 'react' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Ansi, Text, stringWidth } from '@anthropic/ink' -import type { Theme } from '../../utils/theme.js' - -type DividerProps = { - /** - * Width of the divider in characters. - * Defaults to terminal width. - */ - width?: number - - /** - * Theme color for the divider. - * If not provided, dimColor is used. - */ - color?: keyof Theme - - /** - * Character to use for the divider line. - * @default '─' - */ - char?: string - - /** - * Padding to subtract from the width (e.g., for indentation). - * @default 0 - */ - padding?: number - - /** - * Title shown in the middle of the divider. - * May contain ANSI codes (e.g., chalk-styled text). - * - * @example - * // ─────────── Title ─────────── - * - */ - title?: string -} - -/** - * A horizontal divider line. - * - * @example - * // Full-width dimmed divider - * - * - * @example - * // Colored divider - * - * - * @example - * // Fixed width - * - * - * @example - * // Full width minus padding (for indented content) - * - * - * @example - * // With centered title - * - */ -export function Divider({ - width, - color, - char = '─', - padding = 0, - title, -}: DividerProps): React.ReactNode { - const { columns: terminalWidth } = useTerminalSize() - const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding) - - if (title) { - const titleWidth = stringWidth(title) + 2 // +2 for spaces around title - const sideWidth = Math.max(0, effectiveWidth - titleWidth) - const leftWidth = Math.floor(sideWidth / 2) - const rightWidth = sideWidth - leftWidth - return ( - - {char.repeat(leftWidth)}{' '} - - {title} - {' '} - {char.repeat(rightWidth)} - - ) - } - - return ( - - {char.repeat(effectiveWidth)} - - ) -} +export { Divider } from '@anthropic/ink' diff --git a/src/components/design-system/FuzzyPicker.tsx b/src/components/design-system/FuzzyPicker.tsx index 8218f4074..c034335e9 100644 --- a/src/components/design-system/FuzzyPicker.tsx +++ b/src/components/design-system/FuzzyPicker.tsx @@ -1,348 +1 @@ -import * as React from 'react' -import { useEffect, useState } from 'react' -import { useSearchInput } from '../../hooks/useSearchInput.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { type KeyboardEvent, Box, clamp, Text, useTerminalFocus } from '@anthropic/ink' -import { SearchBox } from '../SearchBox.js' -import { Byline } from './Byline.js' -import { KeyboardShortcutHint } from './KeyboardShortcutHint.js' -import { ListItem } from './ListItem.js' -import { Pane } from './Pane.js' - -type PickerAction = { - /** Hint label shown in the byline, e.g. "mention" → "Tab to mention". */ - action: string - handler: (item: T) => void -} - -type Props = { - title: string - placeholder?: string - initialQuery?: string - items: readonly T[] - getKey: (item: T) => string - /** Keep to one line — preview handles overflow. */ - renderItem: (item: T, isFocused: boolean) => React.ReactNode - renderPreview?: (item: T) => React.ReactNode - /** 'right' keeps hints stable (no bounce), but needs width. */ - previewPosition?: 'bottom' | 'right' - visibleCount?: number - /** - * 'up' puts items[0] at the bottom next to the input (atuin-style). Arrows - * always match screen direction — ↑ walks visually up regardless. - */ - direction?: 'down' | 'up' - /** Caller owns filtering: re-filter on each call and pass new items. */ - onQueryChange: (query: string) => void - /** Enter key. Primary action. */ - onSelect: (item: T) => void - /** - * Tab key. If provided, Tab no longer aliases Enter — it gets its own - * handler and hint. Shift+Tab falls through to this if onShiftTab is unset. - */ - onTab?: PickerAction - /** Shift+Tab key. Gets its own hint. */ - onShiftTab?: PickerAction - /** - * Fires when the focused item changes (via arrows or when items reset). - * Useful for async preview loading — keeps I/O out of renderPreview. - */ - onFocus?: (item: T | undefined) => void - onCancel: () => void - /** Shown when items is empty. Caller bakes loading/searching state into this. */ - emptyMessage?: string | ((query: string) => string) - /** - * Status line below the list, e.g. "500+ matches" or "42 matches…". - * Caller decides when to show it — pass undefined to hide. - */ - matchLabel?: string - selectAction?: string - extraHints?: React.ReactNode -} - -const DEFAULT_VISIBLE = 8 -// Pane (paddingTop + Divider) + title + 3 gaps + SearchBox (rounded border = 3 -// rows) + hints. matchLabel adds +1 when present, accounted for separately. -const CHROME_ROWS = 10 -const MIN_VISIBLE = 2 - -export function FuzzyPicker({ - title, - placeholder = 'Type to search…', - initialQuery, - items, - getKey, - renderItem, - renderPreview, - previewPosition = 'bottom', - visibleCount: requestedVisible = DEFAULT_VISIBLE, - direction = 'down', - onQueryChange, - onSelect, - onTab, - onShiftTab, - onFocus, - onCancel, - emptyMessage = 'No results', - matchLabel, - selectAction = 'select', - extraHints, -}: Props): React.ReactNode { - const isTerminalFocused = useTerminalFocus() - const { rows, columns } = useTerminalSize() - const [focusedIndex, setFocusedIndex] = useState(0) - - // Cap visibleCount so the picker never exceeds the terminal height. When it - // overflows, each re-render (arrow key, ctrl+p) mis-positions the cursor-up - // by the overflow amount and a previously-drawn line flashes blank. - const visibleCount = Math.max( - MIN_VISIBLE, - Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)), - ) - - // Full hint row with onTab+onShiftTab is ~100 chars and wraps inconsistently - // below that. Compact mode drops shift+tab and shortens labels. - const compact = columns < 120 - - const step = (delta: 1 | -1) => { - setFocusedIndex(i => clamp(i + delta, 0, items.length - 1)) - } - - // onKeyDown fires after useSearchInput's useInput, so onExit must be a - // no-op — return/downArrow are handled by handleKeyDown below. onCancel - // still covers escape/ctrl+c/ctrl+d. Backspace-on-empty is disabled so - // a held backspace doesn't eject the user from the dialog. - const { query, cursorOffset } = useSearchInput({ - isActive: true, - onExit: () => {}, - onCancel, - initialQuery, - backspaceExitsOnEmpty: false, - }) - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'up' || (e.ctrl && e.key === 'p')) { - e.preventDefault() - e.stopImmediatePropagation() - step(direction === 'up' ? 1 : -1) - return - } - if (e.key === 'down' || (e.ctrl && e.key === 'n')) { - e.preventDefault() - e.stopImmediatePropagation() - step(direction === 'up' ? -1 : 1) - return - } - if (e.key === 'return') { - e.preventDefault() - e.stopImmediatePropagation() - const selected = items[focusedIndex] - if (selected) onSelect(selected) - return - } - if (e.key === 'tab') { - e.preventDefault() - e.stopImmediatePropagation() - const selected = items[focusedIndex] - if (!selected) return - const tabAction = e.shift ? (onShiftTab ?? onTab) : onTab - if (tabAction) { - tabAction.handler(selected) - } else { - onSelect(selected) - } - } - } - - useEffect(() => { - onQueryChange(query) - setFocusedIndex(0) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query]) - - useEffect(() => { - setFocusedIndex(i => clamp(i, 0, items.length - 1)) - }, [items.length]) - - const focused = items[focusedIndex] - useEffect(() => { - onFocus?.(focused) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [focused]) - - const windowStart = clamp( - focusedIndex - visibleCount + 1, - 0, - items.length - visibleCount, - ) - const visible = items.slice(windowStart, windowStart + visibleCount) - - const emptyText = - typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage - - const searchBox = ( - - ) - - const listBlock = ( - - ) - - const preview = - renderPreview && focused ? ( - - {renderPreview(focused)} - - ) : null - - // Structure must not depend on preview truthiness — when focused goes - // undefined (e.g. delete clears matches), switching row→fragment would - // change both layout AND gap count, bouncing the searchBox below. - const listGroup = - renderPreview && previewPosition === 'right' ? ( - - - {listBlock} - {matchLabel && {matchLabel}} - - {preview ?? } - - ) : ( - // Box (not fragment) so the outer gap={1} doesn't insert a blank line - // between list/matchLabel/preview — that read as extra space above the - // prompt in direction='up'. - - {listBlock} - {matchLabel && {matchLabel}} - {preview} - - ) - - const inputAbove = direction !== 'up' - return ( - - - - {title} - - {inputAbove && searchBox} - {listGroup} - {!inputAbove && searchBox} - - - - - {onTab && ( - - )} - {onShiftTab && !compact && ( - - )} - - {extraHints} - - - - - ) -} - -type ListProps = Pick< - Props, - 'visibleCount' | 'direction' | 'getKey' | 'renderItem' -> & { - visible: readonly T[] - windowStart: number - total: number - focusedIndex: number - emptyText: string -} - -function List({ - visible, - windowStart, - visibleCount, - total, - focusedIndex, - direction, - getKey, - renderItem, - emptyText, -}: ListProps): React.ReactNode { - if (visible.length === 0) { - return ( - - {emptyText} - - ) - } - - const rows = visible.map((item, i) => { - const actualIndex = windowStart + i - const isFocused = actualIndex === focusedIndex - const atLowEdge = i === 0 && windowStart > 0 - const atHighEdge = - i === visible.length - 1 && windowStart + visibleCount! < total - return ( - - {renderItem(item, isFocused)} - - ) - }) - - return ( - - {rows} - - ) -} - -function firstWord(s: string): string { - const i = s.indexOf(' ') - return i === -1 ? s : s.slice(0, i) -} +export { FuzzyPicker } from '@anthropic/ink' diff --git a/src/components/design-system/KeyboardShortcutHint.tsx b/src/components/design-system/KeyboardShortcutHint.tsx index 45489dfac..ad61f636f 100644 --- a/src/components/design-system/KeyboardShortcutHint.tsx +++ b/src/components/design-system/KeyboardShortcutHint.tsx @@ -1,58 +1 @@ -import React from 'react' -import { Text } from '@anthropic/ink' - -type Props = { - /** The key or chord to display (e.g., "ctrl+o", "Enter", "↑/↓") */ - shortcut: string - /** The action the key performs (e.g., "expand", "select", "navigate") */ - action: string - /** Whether to wrap the hint in parentheses. Default: false */ - parens?: boolean - /** Whether to render the shortcut in bold. Default: false */ - bold?: boolean -} - -/** - * Renders a keyboard shortcut hint like "ctrl+o to expand" or "(tab to toggle)" - * - * Wrap in for the common dim styling. - * - * @example - * // Simple hint wrapped in dim Text - * - * - * // With parentheses: "(ctrl+o to expand)" - * - * - * // With bold shortcut: "Enter to confirm" (Enter is bold) - * - * - * // Multiple hints with middot separator - use Byline - * - * - * - * - * - * - */ -export function KeyboardShortcutHint({ - shortcut, - action, - parens = false, - bold = false, -}: Props): React.ReactNode { - const shortcutText = bold ? {shortcut} : shortcut - - if (parens) { - return ( - - ({shortcutText} to {action}) - - ) - } - return ( - - {shortcutText} to {action} - - ) -} +export { KeyboardShortcutHint } from '@anthropic/ink' diff --git a/src/components/design-system/ListItem.tsx b/src/components/design-system/ListItem.tsx index bfdbb2747..f309c9975 100644 --- a/src/components/design-system/ListItem.tsx +++ b/src/components/design-system/ListItem.tsx @@ -1,187 +1 @@ -import figures from 'figures' -import type { ReactNode } from 'react' -import React from 'react' -import { useDeclaredCursor, Box, Text } from '@anthropic/ink' - -type ListItemProps = { - /** - * Whether this item is currently focused (keyboard selection). - * Shows the pointer indicator (❯) when true. - */ - isFocused: boolean - - /** - * Whether this item is selected (chosen/checked). - * Shows the checkmark indicator (✓) when true. - * @default false - */ - isSelected?: boolean - - /** - * The content to display for this item. - */ - children: ReactNode - - /** - * Optional description text displayed below the main content. - */ - description?: string - - /** - * Show a down arrow indicator instead of pointer (for scroll hints). - * Only applies when not focused. - */ - showScrollDown?: boolean - - /** - * Show an up arrow indicator instead of pointer (for scroll hints). - * Only applies when not focused. - */ - showScrollUp?: boolean - - /** - * Whether to apply automatic styling to the children based on focus/selection state. - * - When true (default): children are wrapped in Text with state-based colors - * - When false: children are rendered as-is, allowing custom styling - * @default true - */ - styled?: boolean - - /** - * Whether this item is disabled. Disabled items show dimmed text and no indicators. - * @default false - */ - disabled?: boolean - - /** - * Whether this ListItem should declare the terminal cursor position. - * Set false when a child (e.g. BaseTextInput) declares its own cursor. - * @default true - */ - declareCursor?: boolean -} - -/** - * A list item component for selection UIs (dropdowns, multi-selects, menus). - * - * Handles the common pattern of: - * - Pointer indicator (❯) for focused items - * - Checkmark indicator (✓) for selected items - * - Scroll indicators (↓↑) for truncated lists - * - Color states for focus/selection - * - * @example - * // Basic usage in a selection list - * {options.map((option, i) => ( - * - * {option.label} - * - * ))} - * - * @example - * // With scroll indicators - * First visible item - * ... - * Last visible item - * - * @example - * // With description - * - * Primary text - * - * - * @example - * // Custom children styling (styled=false) - * - * Custom styled content - * - */ -export function ListItem({ - isFocused, - isSelected = false, - children, - description, - showScrollDown, - showScrollUp, - styled = true, - disabled = false, - declareCursor, -}: ListItemProps): React.ReactNode { - // Determine which indicator to show - function renderIndicator(): ReactNode { - if (disabled) { - return - } - - if (isFocused) { - return {figures.pointer} - } - - if (showScrollDown) { - return {figures.arrowDown} - } - - if (showScrollUp) { - return {figures.arrowUp} - } - - return - } - - // Determine text color based on state - function getTextColor(): 'success' | 'suggestion' | 'inactive' | undefined { - if (disabled) { - return 'inactive' - } - - if (!styled) { - return undefined - } - - if (isSelected) { - return 'success' - } - - if (isFocused) { - return 'suggestion' - } - - return undefined - } - - const textColor = getTextColor() - - // Park the native terminal cursor on the pointer indicator so screen - // readers / magnifiers track the focused item. (0,0) is the top-left of - // this Box, where the pointer renders. - const cursorRef = useDeclaredCursor({ - line: 0, - column: 0, - active: isFocused && !disabled && declareCursor !== false, - }) - - return ( - - - {renderIndicator()} - {styled ? ( - - {children} - - ) : ( - children - )} - {isSelected && !disabled && {figures.tick}} - - {description && ( - - {description} - - )} - - ) -} +export { ListItem } from '@anthropic/ink' diff --git a/src/components/design-system/LoadingState.tsx b/src/components/design-system/LoadingState.tsx index ff6de70ec..71dbdaa40 100644 --- a/src/components/design-system/LoadingState.tsx +++ b/src/components/design-system/LoadingState.tsx @@ -1,66 +1 @@ -import React from 'react' -import { Box, Text } from '@anthropic/ink' -import { Spinner } from '../Spinner.js' - -type LoadingStateProps = { - /** - * The loading message to display next to the spinner. - */ - message: string - - /** - * Display the message in bold. - * @default false - */ - bold?: boolean - - /** - * Display the message in dimmed color. - * @default false - */ - dimColor?: boolean - - /** - * Optional subtitle displayed below the main message. - */ - subtitle?: string -} - -/** - * A spinner with loading message for async operations. - * - * @example - * // Basic loading - * - * - * @example - * // Bold loading message - * - * - * @example - * // With subtitle - * - */ -export function LoadingState({ - message, - bold = false, - dimColor = false, - subtitle, -}: LoadingStateProps): React.ReactNode { - return ( - - - - - {' '} - {message} - - - {subtitle && {subtitle}} - - ) -} +export { LoadingState } from '@anthropic/ink' diff --git a/src/components/design-system/Pane.tsx b/src/components/design-system/Pane.tsx index e6641d18b..12eb3ed55 100644 --- a/src/components/design-system/Pane.tsx +++ b/src/components/design-system/Pane.tsx @@ -1,57 +1 @@ -import React from 'react' -import { useIsInsideModal } from '../../context/modalContext.js' -import { Box } from '@anthropic/ink' -import type { Theme } from '../../utils/theme.js' -import { Divider } from './Divider.js' - -type PaneProps = { - children: React.ReactNode - /** - * Theme color for the top border line. - */ - color?: keyof Theme -} - -/** - * A pane — a region of the terminal that appears below the REPL prompt, - * bounded by a colored top line with a one-row gap above and horizontal - * padding. Used by all slash-command screens: /config, /help, /plugins, - * /sandbox, /stats, /permissions. - * - * For confirm/cancel dialogs (Esc to dismiss, Enter to confirm), use - * `` instead — it registers its own keybindings. For a full - * rounded-border card, use ``. - * - * Submenus rendered inside a Pane should use `hideBorder` on their Dialog - * so the Pane's border remains the single frame. - * - * @example - * - * ... - * - */ -export function Pane({ children, color }: PaneProps): React.ReactNode { - // When rendered inside FullscreenLayout's modal slot, its ▔ divider IS - // the frame. Skip our own Divider (would double-frame) and the extra top - // padding. This lets slash-command screens that wrap in Pane (e.g. - // /model → ModelPicker) route through the modal slot unchanged. - if (useIsInsideModal()) { - // flexShrink=0: the modal slot's absolute Box has no explicit height - // (grows to fit, maxHeight cap). With flexGrow=1, re-renders cause - // yoga to resolve this Box's height to 0 against the undetermined - // parent — /permissions body blanks on Down arrow. See #23592. - return ( - - {children} - - ) - } - return ( - - - - {children} - - - ) -} +export { Pane } from '@anthropic/ink' diff --git a/src/components/design-system/ProgressBar.tsx b/src/components/design-system/ProgressBar.tsx index b69b47bcf..6aacc6129 100644 --- a/src/components/design-system/ProgressBar.tsx +++ b/src/components/design-system/ProgressBar.tsx @@ -1,54 +1 @@ -import React from 'react' -import { Text } from '@anthropic/ink' -import type { Theme } from '../../utils/theme.js' - -type Props = { - /** - * How much progress to display, between 0 and 1 inclusive - */ - ratio: number // [0, 1] - - /** - * How many characters wide to draw the progress bar - */ - width: number // how many characters wide - - /** - * Optional color for the filled portion of the bar - */ - fillColor?: keyof Theme - - /** - * Optional color for the empty portion of the bar - */ - emptyColor?: keyof Theme -} - -const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'] - -export function ProgressBar({ - ratio: inputRatio, - width, - fillColor, - emptyColor, -}: Props): React.ReactNode { - const ratio = Math.min(1, Math.max(0, inputRatio)) - const whole = Math.floor(ratio * width) - const segments = [BLOCKS[BLOCKS.length - 1]!.repeat(whole)] - if (whole < width) { - const remainder = ratio * width - whole - const middle = Math.floor(remainder * BLOCKS.length) - segments.push(BLOCKS[middle]!) - - const empty = width - whole - 1 - if (empty > 0) { - segments.push(BLOCKS[0]!.repeat(empty)) - } - } - - return ( - - {segments.join('')} - - ) -} +export { ProgressBar } from '@anthropic/ink' diff --git a/src/components/design-system/Ratchet.tsx b/src/components/design-system/Ratchet.tsx index d47caa017..5aaab3289 100644 --- a/src/components/design-system/Ratchet.tsx +++ b/src/components/design-system/Ratchet.tsx @@ -1,44 +1 @@ -import React, { useCallback, useLayoutEffect, useRef, useState } from 'react' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { useTerminalViewport, Box, type DOMElement, measureElement } from '@anthropic/ink' - -type Props = { - children: React.ReactNode - lock?: 'always' | 'offscreen' -} - -export function Ratchet({ children, lock = 'always' }: Props): React.ReactNode { - const [viewportRef, { isVisible }] = useTerminalViewport() - const { rows } = useTerminalSize() - const innerRef = useRef(null) - const maxHeight = useRef(0) - const [minHeight, setMinHeight] = useState(0) - - const outerRef = useCallback( - (el: DOMElement | null) => { - viewportRef(el) - }, - [viewportRef], - ) - - const engaged = lock === 'always' || !isVisible - - useLayoutEffect(() => { - if (!innerRef.current) { - return - } - const { height } = measureElement(innerRef.current) - if (height > maxHeight.current) { - maxHeight.current = Math.min(height, rows) - setMinHeight(maxHeight.current) - } - }) - - return ( - - - {children} - - - ) -} +export { Ratchet } from '@anthropic/ink' diff --git a/src/components/design-system/StatusIcon.tsx b/src/components/design-system/StatusIcon.tsx index d37d4d23b..962ea1ba6 100644 --- a/src/components/design-system/StatusIcon.tsx +++ b/src/components/design-system/StatusIcon.tsx @@ -1,71 +1 @@ -import figures from 'figures' -import React from 'react' -import { Text } from '@anthropic/ink' - -type Status = 'success' | 'error' | 'warning' | 'info' | 'pending' | 'loading' - -type Props = { - /** - * The status to display. Determines both the icon and color. - * - * - `success`: Green checkmark (✓) - * - `error`: Red cross (✗) - * - `warning`: Yellow warning symbol (⚠) - * - `info`: Blue info symbol (ℹ) - * - `pending`: Dimmed circle (○) - * - `loading`: Dimmed ellipsis (…) - */ - status: Status - /** - * Include a trailing space after the icon. Useful when followed by text. - * @default false - */ - withSpace?: boolean -} - -const STATUS_CONFIG: Record< - Status, - { - icon: string - color: 'success' | 'error' | 'warning' | 'suggestion' | undefined - } -> = { - success: { icon: figures.tick, color: 'success' }, - error: { icon: figures.cross, color: 'error' }, - warning: { icon: figures.warning, color: 'warning' }, - info: { icon: figures.info, color: 'suggestion' }, - pending: { icon: figures.circle, color: undefined }, - loading: { icon: '…', color: undefined }, -} - -/** - * Renders a status indicator icon with appropriate color. - * - * @example - * // Success indicator - * - * - * @example - * // Error with trailing space for text - * Failed to connect - * - * @example - * // Status line pattern - * - * - * Waiting for response - * - */ -export function StatusIcon({ - status, - withSpace = false, -}: Props): React.ReactNode { - const config = STATUS_CONFIG[status] - - return ( - - {config.icon} - {withSpace && ' '} - - ) -} +export { StatusIcon } from '@anthropic/ink' diff --git a/src/components/design-system/Tabs.tsx b/src/components/design-system/Tabs.tsx index 886b84673..e11de0a6b 100644 --- a/src/components/design-system/Tabs.tsx +++ b/src/components/design-system/Tabs.tsx @@ -1,336 +1 @@ -import React, { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from 'react' -import { - useIsInsideModal, - useModalScrollRef, -} from '../../context/modalContext.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Box, ScrollBox, Text, stringWidth, type KeyboardEvent } from '@anthropic/ink' -import { useKeybindings } from '../../keybindings/useKeybinding.js' -import type { Theme } from '../../utils/theme.js' - -type TabsProps = { - children: Array> - title?: string - color?: keyof Theme - defaultTab?: string - hidden?: boolean - useFullWidth?: boolean - /** Controlled mode: current selected tab id/title */ - selectedTab?: string - /** Controlled mode: callback when tab changes */ - onTabChange?: (tabId: string) => void - /** Optional banner to display below tabs header */ - banner?: React.ReactNode - /** Disable keyboard navigation (e.g. when a child component handles arrow keys) */ - disableNavigation?: boolean - /** - * Initial focus state for the tab header row. Defaults to true (header - * focused, nav always works). Keep the default for Select/list content — - * those only use up/down so there's no conflict; pass - * isDisabled={headerFocused} to the Select instead. Only set false when - * content actually binds left/right/tab (e.g. enum cycling), and show a - * "↑ tabs" footer hint — without it tabs look broken. - */ - initialHeaderFocused?: boolean - /** - * Fixed height for the content area. When set, all tabs render within the - * same height (overflow hidden) so switching tabs doesn't cause layout - * shifts. Shorter tabs get whitespace; taller tabs are clipped. - */ - contentHeight?: number - /** - * Let Tab/←/→ switch tabs from focused content. Opt-in since some - * content uses those keys; pass a reactive boolean to cede them when - * needed. Switching from content focuses the header. - */ - navFromContent?: boolean -} - -type TabsContextValue = { - selectedTab: string | undefined - width: number | undefined - headerFocused: boolean - focusHeader: () => void - blurHeader: () => void - registerOptIn: () => () => void -} - -const TabsContext = createContext({ - selectedTab: undefined, - width: undefined, - // Default for components rendered outside a Tabs (tests, standalone): - // content has focus, focusHeader is a no-op. - headerFocused: false, - focusHeader: () => {}, - blurHeader: () => {}, - registerOptIn: () => () => {}, -}) - -export function Tabs({ - title, - color, - defaultTab, - children, - hidden, - useFullWidth, - selectedTab: controlledSelectedTab, - onTabChange, - banner, - disableNavigation, - initialHeaderFocused = true, - contentHeight, - navFromContent = false, -}: TabsProps): React.ReactNode { - const { columns: terminalWidth } = useTerminalSize() - const tabs = children.map(child => [ - child.props.id ?? child.props.title, - child.props.title, - ]) - const defaultTabIndex = defaultTab - ? tabs.findIndex(tab => defaultTab === tab[0]) - : 0 - - // Support both controlled and uncontrolled modes - const isControlled = controlledSelectedTab !== undefined - const [internalSelectedTab, setInternalSelectedTab] = useState( - defaultTabIndex !== -1 ? defaultTabIndex : 0, - ) - - // In controlled mode, find the index of the controlled tab - const controlledTabIndex = isControlled - ? tabs.findIndex(tab => tab[0] === controlledSelectedTab) - : -1 - const selectedTabIndex = isControlled - ? controlledTabIndex !== -1 - ? controlledTabIndex - : 0 - : internalSelectedTab - - const modalScrollRef = useModalScrollRef() - - // Header focus: left/right/tab only switch tabs when the header row is - // focused. Children with interactive content call focusHeader() (via - // useTabHeaderFocus) on up-arrow to hand focus back here; down-arrow - // returns it. Tabs that never call the hook see no behavior change — - // initialHeaderFocused defaults to true so nav always works. - const [headerFocused, setHeaderFocused] = useState(initialHeaderFocused) - const focusHeader = useCallback(() => setHeaderFocused(true), []) - const blurHeader = useCallback(() => setHeaderFocused(false), []) - // Count of mounted children using useTabHeaderFocus(). Down-arrow blur and - // the ↓ hint only engage when at least one child has opted in — otherwise - // pressing down on a legacy tab would strand the user with nav disabled. - const [optInCount, setOptInCount] = useState(0) - const registerOptIn = useCallback(() => { - setOptInCount(n => n + 1) - return () => setOptInCount(n => n - 1) - }, []) - const optedIn = optInCount > 0 - - const handleTabChange = (offset: number) => { - const newIndex = (selectedTabIndex + tabs.length + offset) % tabs.length - const newTabId = tabs[newIndex]?.[0] - - if (isControlled && onTabChange && newTabId) { - onTabChange(newTabId) - } else { - setInternalSelectedTab(newIndex) - } - // Tab switching is a header action — stay focused so the user can keep - // cycling. The newly mounted tab can blur via its own interaction. - setHeaderFocused(true) - } - - useKeybindings( - { - 'tabs:next': () => handleTabChange(1), - 'tabs:previous': () => handleTabChange(-1), - }, - { - context: 'Tabs', - isActive: !hidden && !disableNavigation && headerFocused, - }, - ) - - // When the header is focused, down-arrow returns focus to content. Only - // active when the selected tab has opted in via useTabHeaderFocus() — - // legacy tabs have nowhere to return focus to. - const handleKeyDown = (e: KeyboardEvent) => { - if (!headerFocused || !optedIn || hidden) return - if (e.key === 'down') { - e.preventDefault() - setHeaderFocused(false) - } - } - - // Opt-in: same tabs:next/previous actions, active from content. Focuses - // the header so subsequent presses cycle via the handler above. - useKeybindings( - { - 'tabs:next': () => { - handleTabChange(1) - setHeaderFocused(true) - }, - 'tabs:previous': () => { - handleTabChange(-1) - setHeaderFocused(true) - }, - }, - { - context: 'Tabs', - isActive: - navFromContent && - !headerFocused && - optedIn && - !hidden && - !disableNavigation, - }, - ) - - // Calculate spacing to fill the available width. No keyboard hint in the - // header row — content footers own hints (see useTabHeaderFocus docs). - const titleWidth = title ? stringWidth(title) + 1 : 0 // +1 for gap - const tabsWidth = tabs.reduce( - (sum, [, tabTitle]) => sum + (tabTitle ? stringWidth(tabTitle) : 0) + 2 + 1, // +2 for padding, +1 for gap - 0, - ) - const usedWidth = titleWidth + tabsWidth - const spacerWidth = useFullWidth ? Math.max(0, terminalWidth - usedWidth) : 0 - - const contentWidth = useFullWidth ? terminalWidth : undefined - - return ( - - - {!hidden && ( - - {title !== undefined && ( - - {title} - - )} - {tabs.map(([id, title], i) => { - const isCurrent = selectedTabIndex === i - const hasColorCursor = color && isCurrent && headerFocused - return ( - - {' '} - {title}{' '} - - ) - })} - {spacerWidth > 0 && {' '.repeat(spacerWidth)}} - - )} - {banner} - {modalScrollRef ? ( - // Inside the modal slot: own the ScrollBox here so the tabs - // header row above sits OUTSIDE the scroll area — it can never - // scroll off. The ref reaches REPL's ScrollKeybindingHandler via - // ModalContext. Keyed by selectedTabIndex → remounts on tab - // switch, resetting scrollTop to 0 without scrollTo() timing games. - - - {children} - - - ) : ( - - {children} - - )} - - - ) -} - -type TabProps = { - title: string - id?: string - children: React.ReactNode -} - -export function Tab({ title, id, children }: TabProps): React.ReactNode { - const { selectedTab, width } = useContext(TabsContext) - const insideModal = useIsInsideModal() - if (selectedTab !== (id ?? title)) { - return null - } - - return ( - - {children} - - ) -} - -export function useTabsWidth(): number | undefined { - const { width } = useContext(TabsContext) - return width -} - -/** - * Opt into header-focus gating. Returns the current header focus state and a - * callback to hand focus back to the tab row. For a Select, pass - * `isDisabled={headerFocused}` and `onUpFromFirstItem={focusHeader}`; keep the - * parent Tabs' initialHeaderFocused at its default so tab/←/→ work on mount. - * - * Calling this hook registers a ↓-blurs-header opt-in on mount. Don't call it - * above an early return that renders static text — ↓ will blur the header with - * no onUpFromFirstItem to recover. Split the component so the hook only runs - * when the Select renders. - */ -export function useTabHeaderFocus(): { - headerFocused: boolean - focusHeader: () => void - blurHeader: () => void -} { - const { headerFocused, focusHeader, blurHeader, registerOptIn } = - useContext(TabsContext) - useEffect(registerOptIn, [registerOptIn]) - return { headerFocused, focusHeader, blurHeader } -} +export { Tab, Tabs, useTabHeaderFocus, useTabsWidth } from '@anthropic/ink' diff --git a/src/components/design-system/ThemeProvider.tsx b/src/components/design-system/ThemeProvider.tsx index 55eb90447..9d9bc9f66 100644 --- a/src/components/design-system/ThemeProvider.tsx +++ b/src/components/design-system/ThemeProvider.tsx @@ -1,160 +1 @@ -import { feature } from 'bun:bundle' -import React, { - createContext, - useContext, - useEffect, - useMemo, - useState, -} from 'react' -import { useStdin } from '@anthropic/ink' -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' -import { - getSystemThemeName, - type SystemTheme, -} from '../../utils/systemTheme.js' -import type { ThemeName, ThemeSetting } from '../../utils/theme.js' - -type ThemeContextValue = { - /** The saved user preference. May be 'auto'. */ - themeSetting: ThemeSetting - setThemeSetting: (setting: ThemeSetting) => void - setPreviewTheme: (setting: ThemeSetting) => void - savePreview: () => void - cancelPreview: () => void - /** The resolved theme to render with. Never 'auto'. */ - currentTheme: ThemeName -} - -// Non-'auto' default so useTheme() works without a provider (tests, tooling). -const DEFAULT_THEME: ThemeName = 'dark' - -const ThemeContext = createContext({ - themeSetting: DEFAULT_THEME, - setThemeSetting: () => {}, - setPreviewTheme: () => {}, - savePreview: () => {}, - cancelPreview: () => {}, - currentTheme: DEFAULT_THEME, -}) - -type Props = { - children: React.ReactNode - initialState?: ThemeSetting - onThemeSave?: (setting: ThemeSetting) => void -} - -function defaultInitialTheme(): ThemeSetting { - return getGlobalConfig().theme -} - -function defaultSaveTheme(setting: ThemeSetting): void { - saveGlobalConfig(current => ({ ...current, theme: setting })) -} - -export function ThemeProvider({ - children, - initialState, - onThemeSave = defaultSaveTheme, -}: Props) { - const [themeSetting, setThemeSetting] = useState( - initialState ?? defaultInitialTheme, - ) - const [previewTheme, setPreviewTheme] = useState(null) - - // Track terminal theme for 'auto' resolution. Seeds from $COLORFGBG (or - // 'dark' if unset); the OSC 11 watcher corrects it on first poll. - const [systemTheme, setSystemTheme] = useState(() => - (initialState ?? themeSetting) === 'auto' ? getSystemThemeName() : 'dark', - ) - - // The setting currently in effect (preview wins while picker is open) - const activeSetting = previewTheme ?? themeSetting - - const { internal_querier } = useStdin() - - // Watch for live terminal theme changes while 'auto' is active. - // Positive feature() pattern so the watcher import is dead-code-eliminated - // in external builds. - useEffect(() => { - if (feature('AUTO_THEME')) { - if (activeSetting !== 'auto' || !internal_querier) return - let cleanup: (() => void) | undefined - let cancelled = false - void import('../../utils/systemThemeWatcher.js').then( - ({ watchSystemTheme }) => { - if (cancelled) return - cleanup = watchSystemTheme(internal_querier, setSystemTheme) - }, - ) - return () => { - cancelled = true - cleanup?.() - } - } - }, [activeSetting, internal_querier]) - - const currentTheme: ThemeName = - activeSetting === 'auto' ? systemTheme : activeSetting - - const value = useMemo( - () => ({ - themeSetting, - setThemeSetting: (newSetting: ThemeSetting) => { - setThemeSetting(newSetting) - setPreviewTheme(null) - // Switching to 'auto' restarts the watcher (activeSetting dep), whose - // first poll fires immediately. Seed from the cache so the OSC - // round-trip doesn't flash the wrong palette. - if (newSetting === 'auto') { - setSystemTheme(getSystemThemeName()) - } - onThemeSave?.(newSetting) - }, - setPreviewTheme: (newSetting: ThemeSetting) => { - setPreviewTheme(newSetting) - if (newSetting === 'auto') { - setSystemTheme(getSystemThemeName()) - } - }, - savePreview: () => { - if (previewTheme !== null) { - setThemeSetting(previewTheme) - setPreviewTheme(null) - onThemeSave?.(previewTheme) - } - }, - cancelPreview: () => { - if (previewTheme !== null) { - setPreviewTheme(null) - } - }, - currentTheme, - }), - [themeSetting, previewTheme, currentTheme, onThemeSave], - ) - - return {children} -} - -/** - * Returns the resolved theme for rendering (never 'auto') and a setter that - * accepts any ThemeSetting (including 'auto'). - */ -export function useTheme(): [ThemeName, (setting: ThemeSetting) => void] { - const { currentTheme, setThemeSetting } = useContext(ThemeContext) - return [currentTheme, setThemeSetting] -} - -/** - * Returns the raw theme setting as stored in config. Use this in UI that - * needs to show 'auto' as a distinct choice (e.g., ThemePicker). - */ -export function useThemeSetting(): ThemeSetting { - return useContext(ThemeContext).themeSetting -} - -export function usePreviewTheme() { - const { setPreviewTheme, savePreview, cancelPreview } = - useContext(ThemeContext) - return { setPreviewTheme, savePreview, cancelPreview } -} +export { ThemeProvider, usePreviewTheme, useTheme, useThemeSetting } from '@anthropic/ink' diff --git a/src/components/design-system/ThemedBox.tsx b/src/components/design-system/ThemedBox.tsx index 008a2ab9e..30059f318 100644 --- a/src/components/design-system/ThemedBox.tsx +++ b/src/components/design-system/ThemedBox.tsx @@ -1,107 +1 @@ -import React, { type PropsWithChildren, type Ref } from 'react' -import { type ClickEvent, DOMElement, type FocusEvent, type KeyboardEvent, Color, type Styles, Box as BaseBox } from '@anthropic/ink' -import { getTheme, type Theme } from '../../utils/theme.js' -import { useTheme } from './ThemeProvider.js' - -// Color props that accept theme keys -type ThemedColorProps = { - readonly borderColor?: keyof Theme | Color - readonly borderTopColor?: keyof Theme | Color - readonly borderBottomColor?: keyof Theme | Color - readonly borderLeftColor?: keyof Theme | Color - readonly borderRightColor?: keyof Theme | Color - readonly backgroundColor?: keyof Theme | Color -} - -// Base Styles without color props (they'll be overridden) -type BaseStylesWithoutColors = Omit< - Styles, - | 'textWrap' - | 'borderColor' - | 'borderTopColor' - | 'borderBottomColor' - | 'borderLeftColor' - | 'borderRightColor' - | 'backgroundColor' -> - -export type Props = BaseStylesWithoutColors & - ThemedColorProps & { - ref?: Ref - tabIndex?: number - autoFocus?: boolean - onClick?: (event: ClickEvent) => void - onFocus?: (event: FocusEvent) => void - onFocusCapture?: (event: FocusEvent) => void - onBlur?: (event: FocusEvent) => void - onBlurCapture?: (event: FocusEvent) => void - onKeyDown?: (event: KeyboardEvent) => void - onKeyDownCapture?: (event: KeyboardEvent) => void - onMouseEnter?: () => void - onMouseLeave?: () => void - } - -/** - * Resolves a color value that may be a theme key to a raw Color. - */ -function resolveColor( - color: keyof Theme | Color | undefined, - theme: Theme, -): Color | undefined { - if (!color) return undefined - // Check if it's a raw color (starts with rgb(, #, ansi256(, or ansi:) - if ( - color.startsWith('rgb(') || - color.startsWith('#') || - color.startsWith('ansi256(') || - color.startsWith('ansi:') - ) { - return color as Color - } - // It's a theme key - resolve it - return theme[color as keyof Theme] as Color -} - -/** - * Theme-aware Box component that resolves theme color keys to raw colors. - * This wraps the base Box component with theme resolution for border colors. - */ -function ThemedBox({ - borderColor, - borderTopColor, - borderBottomColor, - borderLeftColor, - borderRightColor, - backgroundColor, - children, - ref, - ...rest -}: PropsWithChildren): React.ReactNode { - const [themeName] = useTheme() - const theme = getTheme(themeName) - - // Resolve theme keys to raw colors - const resolvedBorderColor = resolveColor(borderColor, theme) - const resolvedBorderTopColor = resolveColor(borderTopColor, theme) - const resolvedBorderBottomColor = resolveColor(borderBottomColor, theme) - const resolvedBorderLeftColor = resolveColor(borderLeftColor, theme) - const resolvedBorderRightColor = resolveColor(borderRightColor, theme) - const resolvedBackgroundColor = resolveColor(backgroundColor, theme) - - return ( - - {children} - - ) -} - -export default ThemedBox +export { Box as default } from '@anthropic/ink' diff --git a/src/components/design-system/ThemedText.tsx b/src/components/design-system/ThemedText.tsx index c89b1749a..792f0da4b 100644 --- a/src/components/design-system/ThemedText.tsx +++ b/src/components/design-system/ThemedText.tsx @@ -1,132 +1 @@ -import type { ReactNode } from 'react' -import React, { useContext } from 'react' -import { Text } from '@anthropic/ink' -import type { Color, Styles } from '@anthropic/ink' -import { getTheme, type Theme } from '../../utils/theme.js' -import { useTheme } from './ThemeProvider.js' - -/** Colors uncolored ThemedText in the subtree. Precedence: explicit `color` > - * this > dimColor. Crosses Box boundaries (Ink's style cascade doesn't). */ -export const TextHoverColorContext = React.createContext< - keyof Theme | undefined ->(undefined) - -export type Props = { - /** - * Change text color. Accepts a theme key or raw color value. - */ - readonly color?: keyof Theme | Color - - /** - * Same as `color`, but for background. Must be a theme key. - */ - readonly backgroundColor?: keyof Theme - - /** - * Dim the color using the theme's inactive color. - * This is compatible with bold (unlike ANSI dim). - */ - readonly dimColor?: boolean - - /** - * Make the text bold. - */ - readonly bold?: boolean - - /** - * Make the text italic. - */ - readonly italic?: boolean - - /** - * Make the text underlined. - */ - readonly underline?: boolean - - /** - * Make the text crossed with a line. - */ - readonly strikethrough?: boolean - - /** - * Inverse background and foreground colors. - */ - readonly inverse?: boolean - - /** - * This property tells Ink to wrap or truncate text if its width is larger than container. - * If `wrap` is passed (by default), Ink will wrap text and split it into multiple lines. - * If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off. - */ - readonly wrap?: Styles['textWrap'] - - readonly children?: ReactNode -} - -/** - * Resolves a color value that may be a theme key to a raw Color. - */ -function resolveColor( - color: keyof Theme | Color | undefined, - theme: Theme, -): Color | undefined { - if (!color) return undefined - // Check if it's a raw color (starts with rgb(, #, ansi256(, or ansi:) - if ( - color.startsWith('rgb(') || - color.startsWith('#') || - color.startsWith('ansi256(') || - color.startsWith('ansi:') - ) { - return color as Color - } - // It's a theme key - resolve it - return theme[color as keyof Theme] as Color -} - -/** - * Theme-aware Text component that resolves theme color keys to raw colors. - * This wraps the base Text component with theme resolution. - */ -export default function ThemedText({ - color, - backgroundColor, - dimColor = false, - bold = false, - italic = false, - underline = false, - strikethrough = false, - inverse = false, - wrap = 'wrap', - children, -}: Props): React.ReactNode { - const [themeName] = useTheme() - const theme = getTheme(themeName) - const hoverColor = useContext(TextHoverColorContext) - - // Resolve theme keys to raw colors - const resolvedColor = - !color && hoverColor - ? resolveColor(hoverColor, theme) - : dimColor - ? (theme.inactive as Color) - : resolveColor(color, theme) - const resolvedBackgroundColor = backgroundColor - ? (theme[backgroundColor] as Color) - : undefined - - return ( - - {children} - - ) -} +export { Text as default, TextHoverColorContext } from '@anthropic/ink' diff --git a/src/components/permissions/rules/PermissionRuleList.tsx b/src/components/permissions/rules/PermissionRuleList.tsx index 035c23932..a35f31115 100644 --- a/src/components/permissions/rules/PermissionRuleList.tsx +++ b/src/components/permissions/rules/PermissionRuleList.tsx @@ -33,8 +33,7 @@ import { } from '../../../utils/permissions/permissions.js' import type { UnreachableRule } from '../../../utils/permissions/shadowedRuleDetection.js' import { jsonStringify } from '../../../utils/slowOperations.js' -import { Pane, Tabs } from '@anthropic/ink' -import { Tab, useTabHeaderFocus, useTabsWidth } from '../../design-system/Tabs.js' +import { Pane, Tab, Tabs, useTabHeaderFocus, useTabsWidth } from '@anthropic/ink' import { SearchBox } from '../../SearchBox.js' import type { Option } from '../../ui/option.js' import { AddPermissionRules } from './AddPermissionRules.js' diff --git a/src/components/permissions/rules/RecentDenialsTab.tsx b/src/components/permissions/rules/RecentDenialsTab.tsx index 1006c98a7..4b4a281d2 100644 --- a/src/components/permissions/rules/RecentDenialsTab.tsx +++ b/src/components/permissions/rules/RecentDenialsTab.tsx @@ -8,7 +8,6 @@ import { } from '../../../utils/autoModeDenials.js' import { Select } from '../../CustomSelect/select.js' import { StatusIcon } from '@anthropic/ink' -import { useTabHeaderFocus } from '../../design-system/Tabs.js' type Props = { onHeaderFocusChange?: (focused: boolean) => void diff --git a/src/components/permissions/rules/WorkspaceTab.tsx b/src/components/permissions/rules/WorkspaceTab.tsx index 65a747893..e32899ea7 100644 --- a/src/components/permissions/rules/WorkspaceTab.tsx +++ b/src/components/permissions/rules/WorkspaceTab.tsx @@ -6,7 +6,6 @@ import type { CommandResultDisplay } from '../../../commands.js' import { Select } from '../../../components/CustomSelect/select.js' import { Box, Text } from '@anthropic/ink' import type { ToolPermissionContext } from '../../../Tool.js' -import { useTabHeaderFocus } from '../../design-system/Tabs.js' type Props = { onExit: ( diff --git a/src/components/sandbox/SandboxOverridesTab.tsx b/src/components/sandbox/SandboxOverridesTab.tsx index 2c14400b2..257dcc670 100644 --- a/src/components/sandbox/SandboxOverridesTab.tsx +++ b/src/components/sandbox/SandboxOverridesTab.tsx @@ -1,10 +1,8 @@ import React from 'react' -import { Box, color, Link, Text, useTheme } from '@anthropic/ink' +import { Box, color, Link, Text, useTheme, useTabHeaderFocus } from '@anthropic/ink' import type { CommandResultDisplay } from '../../types/command.js' import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' import { Select } from '../CustomSelect/select.js' -// useTabHeaderFocus not available in ink.js facade -import { useTabHeaderFocus } from '../design-system/Tabs.js' type Props = { onComplete: ( diff --git a/src/components/sandbox/SandboxSettings.tsx b/src/components/sandbox/SandboxSettings.tsx index 845540b07..005d071e1 100644 --- a/src/components/sandbox/SandboxSettings.tsx +++ b/src/components/sandbox/SandboxSettings.tsx @@ -1,13 +1,11 @@ import React from 'react' -import { Box, color, Link, Text, useTheme } from '@anthropic/ink' +import { Box, color, Link, Text, useTheme, Pane, Tab, Tabs, useTabHeaderFocus } from '@anthropic/ink' import { useKeybindings } from '../../keybindings/useKeybinding.js' import type { CommandResultDisplay } from '../../types/command.js' import type { SandboxDependencyCheck } from '../../utils/sandbox/sandbox-adapter.js' import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' import { getSettings_DEPRECATED } from '../../utils/settings/settings.js' import { Select } from '../CustomSelect/select.js' -import { Pane, Tabs } from '@anthropic/ink' -import { Tab, useTabHeaderFocus } from '../design-system/Tabs.js' import { SandboxConfigTab } from './SandboxConfigTab.js' import { SandboxDependenciesTab } from './SandboxDependenciesTab.js' import { SandboxOverridesTab } from './SandboxOverridesTab.js' diff --git a/src/hooks/useDoublePress.ts b/src/hooks/useDoublePress.ts index 7844fbd66..65fb00440 100644 --- a/src/hooks/useDoublePress.ts +++ b/src/hooks/useDoublePress.ts @@ -1,62 +1,3 @@ -// Creates a function that calls one function on the first call and another -// function on the second call within a certain timeout - -import { useCallback, useEffect, useRef } from 'react' - -export const DOUBLE_PRESS_TIMEOUT_MS = 800 - -export function useDoublePress( - setPending: (pending: boolean) => void, - onDoublePress: () => void, - onFirstPress?: () => void, -): () => void { - const lastPressRef = useRef(0) - const timeoutRef = useRef(undefined) - - const clearTimeoutSafe = useCallback(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current) - timeoutRef.current = undefined - } - }, []) - - // Cleanup timeout on unmount - useEffect(() => { - return () => { - clearTimeoutSafe() - } - }, [clearTimeoutSafe]) - - return useCallback(() => { - const now = Date.now() - const timeSinceLastPress = now - lastPressRef.current - const isDoublePress = - timeSinceLastPress <= DOUBLE_PRESS_TIMEOUT_MS && - timeoutRef.current !== undefined - - if (isDoublePress) { - // Double press detected - clearTimeoutSafe() - setPending(false) - onDoublePress() - } else { - // First press - onFirstPress?.() - setPending(true) - - // Clear any existing timeout and set new one - clearTimeoutSafe() - timeoutRef.current = setTimeout( - (setPending, timeoutRef) => { - setPending(false) - timeoutRef.current = undefined - }, - DOUBLE_PRESS_TIMEOUT_MS, - setPending, - timeoutRef, - ) - } - - lastPressRef.current = now - }, [setPending, onDoublePress, onFirstPress, clearTimeoutSafe]) -} +// Re-export from @anthropic/ink hooks module +export { useDoublePress } from '@anthropic/ink' +export { DOUBLE_PRESS_TIMEOUT_MS } from '@anthropic/ink' diff --git a/src/hooks/useMinDisplayTime.ts b/src/hooks/useMinDisplayTime.ts index 587b96938..92fc51005 100644 --- a/src/hooks/useMinDisplayTime.ts +++ b/src/hooks/useMinDisplayTime.ts @@ -1,35 +1,2 @@ -import { useEffect, useRef, useState } from 'react' - -/** - * Throttles a value so each distinct value stays visible for at least `minMs`. - * Prevents fast-cycling progress text from flickering past before it's readable. - * - * Unlike debounce (wait for quiet) or throttle (limit rate), this guarantees - * each value gets its minimum screen time before being replaced. - */ -export function useMinDisplayTime(value: T, minMs: number): T { - const [displayed, setDisplayed] = useState(value) - const lastShownAtRef = useRef(0) - - useEffect(() => { - const elapsed = Date.now() - lastShownAtRef.current - if (elapsed >= minMs) { - lastShownAtRef.current = Date.now() - setDisplayed(value) - return - } - const timer = setTimeout( - (shownAtRef, setFn, v) => { - shownAtRef.current = Date.now() - setFn(v) - }, - minMs - elapsed, - lastShownAtRef, - setDisplayed, - value, - ) - return () => clearTimeout(timer) - }, [value, minMs]) - - return displayed -} +// Re-export from @anthropic/ink hooks module +export { useMinDisplayTime } from '@anthropic/ink' diff --git a/src/hooks/useTerminalSize.ts b/src/hooks/useTerminalSize.ts index 944a5b0e0..ca3630ba6 100644 --- a/src/hooks/useTerminalSize.ts +++ b/src/hooks/useTerminalSize.ts @@ -1,12 +1,2 @@ -import { useContext } from 'react' -import { type TerminalSize, TerminalSizeContext } from '@anthropic/ink' - -export function useTerminalSize(): TerminalSize { - const size = useContext(TerminalSizeContext) - - if (!size) { - throw new Error('useTerminalSize must be used within an Ink App component') - } - - return size -} +// Re-export from @anthropic/ink hooks module +export { useTerminalSize } from '@anthropic/ink' diff --git a/src/hooks/useTimeout.ts b/src/hooks/useTimeout.ts index faed236af..6d5cb83a8 100644 --- a/src/hooks/useTimeout.ts +++ b/src/hooks/useTimeout.ts @@ -1,14 +1,2 @@ -import { useEffect, useState } from 'react' - -export function useTimeout(delay: number, resetTrigger?: number): boolean { - const [isElapsed, setIsElapsed] = useState(false) - - useEffect(() => { - setIsElapsed(false) - const timer = setTimeout(setIsElapsed, delay, true) - - return () => clearTimeout(timer) - }, [delay, resetTrigger]) - - return isElapsed -} +// Re-export from @anthropic/ink hooks module +export { useTimeout } from '@anthropic/ink' diff --git a/src/keybindings/KeybindingProviderSetup.tsx b/src/keybindings/KeybindingProviderSetup.tsx index bb0f7ddca..f288a7f65 100644 --- a/src/keybindings/KeybindingProviderSetup.tsx +++ b/src/keybindings/KeybindingProviderSetup.tsx @@ -1,41 +1,21 @@ /** - * Setup utilities for integrating KeybindingProvider into the app. + * App-specific wrapper around ink's KeybindingSetup. * - * This file provides the bindings and a composed provider that can be - * added to the app's component tree. It loads both default bindings and - * user-defined bindings from ~/.claude/keybindings.json, with hot-reload - * support when the file changes. + * Wires up app-specific dependencies (notification system, binding loading, + * file watching, debug logging) and re-exports as KeybindingSetup. */ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback } from 'react' import { useNotifications } from '../context/notifications.js' -import type { InputEvent } from '@anthropic/ink' -// ChordInterceptor intentionally uses useInput to intercept all keystrokes before -// other handlers process them - this is required for chord sequence support -// eslint-disable-next-line custom-rules/prefer-use-keybindings -import { type Key, useInput } from '@anthropic/ink' import { count } from '../utils/array.js' import { logForDebugging } from '../utils/debug.js' import { plural } from '../utils/stringUtils.js' -import { KeybindingProvider } from './KeybindingContext.js' +import { KeybindingSetup as InkKeybindingSetup } from '@anthropic/ink' +import type { KeybindingWarning } from '@anthropic/ink' import { initializeKeybindingWatcher, - type KeybindingsLoadResult, loadKeybindingsSyncWithWarnings, subscribeToKeybindingChanges, } from './loadUserBindings.js' -import { resolveKeyWithChordState } from './resolver.js' -import type { - KeybindingContextName, - ParsedBinding, - ParsedKeystroke, -} from './types.js' -import type { KeybindingWarning } from './validate.js' - -/** - * Timeout for chord sequences in milliseconds. - * If the user doesn't complete the chord within this time, it's cancelled. - */ -const CHORD_TIMEOUT_MS = 1000 type Props = { children: React.ReactNode @@ -61,321 +41,51 @@ type Props = { * - User bindings override defaults (later entries win) * - Chord support with automatic timeout */ -/** - * Display keybinding warnings to the user via notifications. - * Shows a brief message pointing to /doctor for details. - */ -function useKeybindingWarnings( - warnings: KeybindingWarning[], - isReload: boolean, -): void { +export function KeybindingSetup({ children }: Props): React.ReactNode { const { addNotification, removeNotification } = useNotifications() - useEffect(() => { - const notificationKey = 'keybinding-config-warning' + const handleWarnings = useCallback( + (warnings: KeybindingWarning[], _isReload: boolean) => { + const notificationKey = 'keybinding-config-warning' - if (warnings.length === 0) { - removeNotification(notificationKey) - return - } - - const errorCount = count(warnings, w => w.severity === 'error') - const warnCount = count(warnings, w => w.severity === 'warning') - - let message: string - if (errorCount > 0 && warnCount > 0) { - message = `Found ${errorCount} keybinding ${plural(errorCount, 'error')} and ${warnCount} ${plural(warnCount, 'warning')}` - } else if (errorCount > 0) { - message = `Found ${errorCount} keybinding ${plural(errorCount, 'error')}` - } else { - message = `Found ${warnCount} keybinding ${plural(warnCount, 'warning')}` - } - message += ' · /doctor for details' - - addNotification({ - key: notificationKey, - text: message, - color: errorCount > 0 ? 'error' : 'warning', - priority: errorCount > 0 ? 'immediate' : 'high', - // Keep visible for 60 seconds like settings errors - timeoutMs: 60000, - }) - }, [warnings, isReload, addNotification, removeNotification]) -} - -export function KeybindingSetup({ children }: Props): React.ReactNode { - // Load bindings synchronously for initial render - const [{ bindings, warnings }, setLoadResult] = - useState(() => { - const result = loadKeybindingsSyncWithWarnings() - logForDebugging( - `[keybindings] KeybindingSetup initialized with ${result.bindings.length} bindings, ${result.warnings.length} warnings`, - ) - return result - }) - - // Track if this is a reload (not initial load) - const [isReload, setIsReload] = useState(false) - - // Display warnings via notifications - useKeybindingWarnings(warnings, isReload) - - // Chord state management - use ref for immediate access, state for re-renders - // The ref is used by resolve() to get the current value without waiting for re-render - // The state is used to trigger re-renders when needed (e.g., for UI updates) - const pendingChordRef = useRef(null) - const [pendingChord, setPendingChordState] = useState< - ParsedKeystroke[] | null - >(null) - const chordTimeoutRef = useRef(null) - - // Handler registry for action callbacks (used by ChordInterceptor to invoke handlers) - const handlerRegistryRef = useRef( - new Map< - string, - Set<{ - action: string - context: KeybindingContextName - handler: () => void - }> - >(), - ) - - // Active context tracking for keybinding priority resolution - // Using a ref instead of state for synchronous updates - input handlers need - // to see the current value immediately, not after a React render cycle. - const activeContextsRef = useRef>(new Set()) - - const registerActiveContext = useCallback( - (context: KeybindingContextName) => { - activeContextsRef.current.add(context) - }, - [], - ) - - const unregisterActiveContext = useCallback( - (context: KeybindingContextName) => { - activeContextsRef.current.delete(context) - }, - [], - ) - - // Clear chord timeout when component unmounts or chord changes - const clearChordTimeout = useCallback(() => { - if (chordTimeoutRef.current) { - clearTimeout(chordTimeoutRef.current) - chordTimeoutRef.current = null - } - }, []) - - // Wrapper for setPendingChord that manages timeout and syncs ref+state - const setPendingChord = useCallback( - (pending: ParsedKeystroke[] | null) => { - clearChordTimeout() - - if (pending !== null) { - // Set timeout to cancel chord if not completed - chordTimeoutRef.current = setTimeout( - (pendingChordRef, setPendingChordState) => { - logForDebugging('[keybindings] Chord timeout - cancelling') - pendingChordRef.current = null - setPendingChordState(null) - }, - CHORD_TIMEOUT_MS, - pendingChordRef, - setPendingChordState, - ) - } - - // Update ref immediately for synchronous access in resolve() - pendingChordRef.current = pending - // Update state to trigger re-renders for UI updates - setPendingChordState(pending) - }, - [clearChordTimeout], - ) - - useEffect(() => { - // Initialize file watcher (idempotent - only runs once) - void initializeKeybindingWatcher() - - // Subscribe to changes - const unsubscribe = subscribeToKeybindingChanges(result => { - // Any callback invocation is a reload since initial load happens - // synchronously in useState, not via this subscription - setIsReload(true) - - setLoadResult(result) - logForDebugging( - `[keybindings] Reloaded: ${result.bindings.length} bindings, ${result.warnings.length} warnings`, - ) - }) - - return () => { - unsubscribe() - clearChordTimeout() - } - }, [clearChordTimeout]) - - return ( - - - {children} - - ) -} - -/** - * Global chord interceptor that registers useInput FIRST (before children). - * - * This component intercepts keystrokes that are part of chord sequences and - * stops propagation before other handlers (like PromptInput) can see them. - * - * Without this, the second key of a chord (e.g., 'r' in "ctrl+c r") would be - * captured by PromptInput and added to the input field before the keybinding - * system could recognize it as completing a chord. - */ -type HandlerRegistration = { - action: string - context: KeybindingContextName - handler: () => void -} - -function ChordInterceptor({ - bindings, - pendingChordRef, - setPendingChord, - activeContexts, - handlerRegistryRef, -}: { - bindings: ParsedBinding[] - pendingChordRef: React.RefObject - setPendingChord: (pending: ParsedKeystroke[] | null) => void - activeContexts: Set - handlerRegistryRef: React.RefObject>> -}): null { - const handleInput = useCallback( - (input: string, key: Key, event: InputEvent) => { - // Wheel events can never start chord sequences — scroll:lineUp/Down are - // single-key bindings handled by per-component useKeybindings hooks, not - // here. Skip the registry scan. Mid-chord wheel still falls through so - // scrolling cancels the pending chord like any other non-matching key. - if ((key.wheelUp || key.wheelDown) && pendingChordRef.current === null) { + if (warnings.length === 0) { + removeNotification(notificationKey) return } - // Build context list from registered handlers + activeContexts + Global - // This ensures we can resolve chords for all contexts that have handlers - const registry = handlerRegistryRef.current - const handlerContexts = new Set() - if (registry) { - for (const handlers of registry.values()) { - for (const registration of handlers) { - handlerContexts.add(registration.context) - } - } + const errorCount = count(warnings, w => w.severity === 'error') + const warnCount = count(warnings, w => w.severity === 'warning') + + let message: string + if (errorCount > 0 && warnCount > 0) { + message = `Found ${errorCount} keybinding ${plural(errorCount, 'error')} and ${warnCount} ${plural(warnCount, 'warning')}` + } else if (errorCount > 0) { + message = `Found ${errorCount} keybinding ${plural(errorCount, 'error')}` + } else { + message = `Found ${warnCount} keybinding ${plural(warnCount, 'warning')}` } - const contexts: KeybindingContextName[] = [ - ...handlerContexts, - ...activeContexts, - 'Global', - ] + message += ' · /doctor for details' - // Track whether we're completing a chord (pending was non-null) - const wasInChord = pendingChordRef.current !== null - - // Check if this keystroke is part of a chord sequence - const result = resolveKeyWithChordState( - input, - key, - contexts, - bindings, - pendingChordRef.current, - ) - - switch (result.type) { - case 'chord_started': - // This key starts a chord - store pending state and stop propagation - setPendingChord(result.pending) - event.stopImmediatePropagation() - break - - case 'match': { - // Clear pending state - setPendingChord(null) - - // Only invoke handlers and stop propagation for chord completions - // (multi-keystroke sequences). Single-keystroke matches should propagate - // to per-hook handlers to avoid interfering with other input handling - // (e.g., Enter needs to reach useTypeahead for autocomplete acceptance - // before the submit handler fires). - if (wasInChord) { - // Find and invoke the handler for this action - // We need to check that the handler's context is in our resolved contexts - // (which includes handlerContexts + activeContexts + Global) - const contextsSet = new Set(contexts) - if (registry) { - const handlers = registry.get(result.action) - if (handlers && handlers.size > 0) { - // Find handlers whose context is in our resolved contexts - for (const registration of handlers) { - if (contextsSet.has(registration.context)) { - registration.handler() - event.stopImmediatePropagation() - break // Only invoke the first matching handler - } - } - } - } - } - break - } - - case 'chord_cancelled': - // Invalid key during chord - clear pending state and swallow the - // keystroke so it doesn't propagate as a standalone action - // (e.g., ctrl+x ctrl+c should not fire app:interrupt). - setPendingChord(null) - event.stopImmediatePropagation() - break - - case 'unbound': - // Key is explicitly unbound - clear pending state and swallow - // the keystroke (it was part of a chord sequence). - setPendingChord(null) - event.stopImmediatePropagation() - break - - case 'none': - // No chord involvement - let other handlers process - break - } + addNotification({ + key: notificationKey, + text: message, + color: errorCount > 0 ? 'error' : 'warning', + priority: errorCount > 0 ? 'immediate' : 'high', + timeoutMs: 60000, + }) }, - [ - bindings, - pendingChordRef, - setPendingChord, - activeContexts, - handlerRegistryRef, - ], + [addNotification, removeNotification], ) - useInput(handleInput) - - return null + return ( + + {children} + + ) } diff --git a/src/main.tsx b/src/main.tsx index 68f58eaa3..16d31925a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -203,7 +203,6 @@ import { } from "./tools/AgentTool/loadAgentsDir.js"; import type { LogOption } from "./types/logs.js"; import type { Message as MessageType } from "./types/message.js"; -import { assertMinVersion } from "./utils/autoUpdater.js"; import { CLAUDE_IN_CHROME_SKILL_HINT, CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER, @@ -390,7 +389,6 @@ const autoModeStateModule = feature("TRANSCRIPT_CLASSIFIER") : null; // TeleportRepoMismatchDialog, TeleportResumeWrapper dynamically imported at call sites -import { migrateAutoUpdatesToSettings } from "./migrations/migrateAutoUpdatesToSettings.js"; import { migrateBypassPermissionsAcceptedToSettings } from "./migrations/migrateBypassPermissionsAcceptedToSettings.js"; import { migrateEnableAllProjectMcpServersToSettings } from "./migrations/migrateEnableAllProjectMcpServersToSettings.js"; import { migrateFennecToOpus } from "./migrations/migrateFennecToOpus.js"; @@ -587,7 +585,6 @@ async function logStartupTelemetry(): Promise { const CURRENT_MIGRATION_VERSION = 11; function runMigrations(): void { if (getGlobalConfig().migrationVersion !== CURRENT_MIGRATION_VERSION) { - migrateAutoUpdatesToSettings(); migrateBypassPermissionsAcceptedToSettings(); migrateEnableAllProjectMcpServersToSettings(); resetProToOpusDefault(); @@ -2731,7 +2728,6 @@ async function run(): Promise { console.error(warning); }); - void assertMinVersion(); // claude.ai config fetch: -p mode only (interactive uses useManageMCPConnections // two-phase loading). Kicked off here to overlap with setup(); awaited @@ -6497,20 +6493,6 @@ async function run(): Promise { await doctorHandler(root) }) - // claude update - // - // For SemVer-compliant versioning with build metadata (X.X.X+SHA): - // - We perform exact string comparison (including SHA) to detect any change - // - This ensures users always get the latest build, even when only the SHA changes - // - UI shows both versions including build metadata for clarity - program - .command("update") - .alias("upgrade") - .description("Check for updates and install if available") - .action(async () => { - const { update } = await import("src/cli/update.js"); - await update(); - }); // claude up — run the project's CLAUDE.md "# claude up" setup instructions. if (process.env.USER_TYPE === "ant") { diff --git a/src/tools/TungstenTool/TungstenTool.js b/src/tools/TungstenTool/TungstenTool.js index dbd53ee4a..3d6635d37 100644 --- a/src/tools/TungstenTool/TungstenTool.js +++ b/src/tools/TungstenTool/TungstenTool.js @@ -2,3 +2,6 @@ export const TungstenTool = { name: 'TungstenTool', isEnabled: () => false, } + +export const clearSessionsWithTungstenUsage = () => {} +export const resetInitializationState = () => {} diff --git a/src/utils/computerUse/drainRunLoop.ts b/src/utils/computerUse/drainRunLoop.ts index 28dac7c57..9788766e9 100644 --- a/src/utils/computerUse/drainRunLoop.ts +++ b/src/utils/computerUse/drainRunLoop.ts @@ -18,7 +18,7 @@ let pump: ReturnType | undefined let pending = 0 function drainTick(cu: ReturnType): void { - ;(cu as any)._drainMainRunLoop() + ;(cu as any)?._drainMainRunLoop?.() } function retain(): void {