refactor: 大规模迁移原有组件到 ink 包内

This commit is contained in:
claude-code-best
2026-04-07 22:26:45 +08:00
parent 52a9cc0414
commit 91b9366f64
44 changed files with 563 additions and 2480 deletions

1
.gitignore vendored
View File

@@ -26,3 +26,4 @@ src/utils/vendor/
# Python bytecode
__pycache__/
*.pyc
logs

View File

@@ -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<number>(0)
const timeoutRef = useRef<NodeJS.Timeout | undefined>(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])
}

View File

@@ -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<T>(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
}

View File

@@ -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
}

View File

@@ -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'

View File

@@ -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<void>
/** 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<KeybindingsLoadResult>(() => {
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<ParsedKeystroke[] | null>(null)
const [pendingChord, setPendingChordState] = useState<
ParsedKeystroke[] | null
>(null)
const chordTimeoutRef = useRef<NodeJS.Timeout | null>(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<Set<KeybindingContextName>>(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 (
<KeybindingProvider
bindings={bindings}
pendingChordRef={pendingChordRef}
pendingChord={pendingChord}
setPendingChord={setPendingChord}
activeContexts={activeContextsRef.current}
registerActiveContext={registerActiveContext}
unregisterActiveContext={unregisterActiveContext}
handlerRegistryRef={handlerRegistryRef}
>
<ChordInterceptor
bindings={bindings}
pendingChordRef={pendingChordRef}
setPendingChord={setPendingChord}
activeContexts={activeContextsRef.current}
handlerRegistryRef={handlerRegistryRef}
/>
{children}
</KeybindingProvider>
)
}
/**
* 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<ParsedKeystroke[] | null>
setPendingChord: (pending: ParsedKeystroke[] | null) => void
activeContexts: Set<KeybindingContextName>
handlerRegistryRef: React.RefObject<Map<string, Set<HandlerRegistration>>>
}): 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<KeybindingContextName>()
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
}

View File

@@ -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[]
}

View File

@@ -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 {

View File

@@ -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[]

View File

@@ -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'

View File

@@ -339,16 +339,6 @@ function NotificationContent({
{!isBriefOnly && (
<TokenWarning tokenUsage={tokenUsage} model={mainLoopModel} />
)}
{shouldShowAutoUpdater && (
<AutoUpdaterWrapper
verbose={verbose}
onAutoUpdaterResult={onAutoUpdaterResult}
autoUpdaterResult={autoUpdaterResult}
isUpdating={isAutoUpdating}
onChangeIsUpdating={onChangeIsUpdating}
showSuccessMessage={!isShowingCompactMessage}
/>
)}
{feature('VOICE_MODE')
? voiceEnabled &&
voiceError && (

View File

@@ -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 (
<Box
flexShrink={0}
borderStyle={borderless ? undefined : 'round'}
borderColor={isFocused ? 'suggestion' : undefined}
borderDimColor={!isFocused}
paddingX={borderless ? 0 : 1}
width={width}
>
<Text dimColor={!isFocused}>
{prefix}{' '}
{isFocused ? (
<>
{query ? (
isTerminalFocused ? (
<>
<Text>{query.slice(0, offset)}</Text>
<Text inverse>
{offset < query.length ? query[offset] : ' '}
</Text>
{offset < query.length && (
<Text>{query.slice(offset + 1)}</Text>
)}
</>
) : (
<Text>{query}</Text>
)
) : isTerminalFocused ? (
<>
<Text inverse>{placeholder.charAt(0)}</Text>
<Text dimColor>{placeholder.slice(1)}</Text>
</>
) : (
<Text dimColor>{placeholder}</Text>
)}
</>
) : query ? (
<Text>{query}</Text>
) : (
<Text>{placeholder}</Text>
)}
</Text>
</Box>
)
}
// Re-export from @anthropic/ink theme module
export { SearchBox } from '@anthropic/ink'

View File

@@ -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'

View File

@@ -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({
<Tab key="usage" title="Usage">
<Usage />
</Tab>,
...(process.env.USER_TYPE === 'ant'
? [
<Tab key="gates" title="Gates">
<Gates
onOwnsEscChange={setGatesOwnsEsc}
contentHeight={contentHeight}
/>
</Tab>,
]
: []),
]
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}
>

View File

@@ -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

View File

@@ -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'

View File

@@ -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"
* <Text dimColor>
* <Byline>
* <KeyboardShortcutHint shortcut="Enter" action="confirm" />
* <KeyboardShortcutHint shortcut="Esc" action="cancel" />
* </Byline>
* </Text>
*
* @example
* // With conditional children: "Esc to cancel" (only one item shown)
* <Text dimColor>
* <Byline>
* {showEnter && <KeyboardShortcutHint shortcut="Enter" action="confirm" />}
* <KeyboardShortcutHint shortcut="Esc" action="cancel" />
* </Byline>
* </Text>
*
*/
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) => (
<React.Fragment
key={isValidElement(child) ? (child.key ?? index) : index}
>
{index > 0 && <Text dimColor> · </Text>}
{child}
</React.Fragment>
))}
</>
)
}
export { Byline } from '@anthropic/ink'

View File

@@ -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 ? (
<Text>Press {exitState.keyName} again to exit</Text>
) : (
<Byline>
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
<ConfigurableShortcutHint
action="confirm:no"
context="Confirmation"
fallback="Esc"
description="cancel"
/>
</Byline>
)
const content = (
<>
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<Text bold color={color}>
{title}
</Text>
{subtitle && <Text dimColor>{subtitle}</Text>}
</Box>
{children}
</Box>
{!hideInputGuide && (
<Box marginTop={1}>
<Text dimColor italic>
{inputGuide ? inputGuide(exitState) : defaultInputGuide}
</Text>
</Box>
)}
</>
)
if (hideBorder) {
return content
}
return <Pane color={color}>{content}</Pane>
}
export { Dialog } from '@anthropic/ink'

View File

@@ -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 ───────────
* <Divider title="Title" />
*/
title?: string
}
/**
* A horizontal divider line.
*
* @example
* // Full-width dimmed divider
* <Divider />
*
* @example
* // Colored divider
* <Divider color="suggestion" />
*
* @example
* // Fixed width
* <Divider width={40} />
*
* @example
* // Full width minus padding (for indented content)
* <Divider padding={4} />
*
* @example
* // With centered title
* <Divider title="3 new messages" />
*/
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 (
<Text color={color} dimColor={!color}>
{char.repeat(leftWidth)}{' '}
<Text dimColor>
<Ansi>{title}</Ansi>
</Text>{' '}
{char.repeat(rightWidth)}
</Text>
)
}
return (
<Text color={color} dimColor={!color}>
{char.repeat(effectiveWidth)}
</Text>
)
}
export { Divider } from '@anthropic/ink'

View File

@@ -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<T> = {
/** Hint label shown in the byline, e.g. "mention" → "Tab to mention". */
action: string
handler: (item: T) => void
}
type Props<T> = {
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<T>
/** Shift+Tab key. Gets its own hint. */
onShiftTab?: PickerAction<T>
/**
* 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<T>({
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<T>): 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 = (
<SearchBox
query={query}
cursorOffset={cursorOffset}
placeholder={placeholder}
isFocused
isTerminalFocused={isTerminalFocused}
/>
)
const listBlock = (
<List
visible={visible}
windowStart={windowStart}
visibleCount={visibleCount}
total={items.length}
focusedIndex={focusedIndex}
direction={direction}
getKey={getKey}
renderItem={renderItem}
emptyText={emptyText}
/>
)
const preview =
renderPreview && focused ? (
<Box flexDirection="column" flexGrow={1}>
{renderPreview(focused)}
</Box>
) : 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' ? (
<Box
flexDirection="row"
gap={2}
height={visibleCount + (matchLabel ? 1 : 0)}
>
<Box flexDirection="column" flexShrink={0}>
{listBlock}
{matchLabel && <Text dimColor>{matchLabel}</Text>}
</Box>
{preview ?? <Box flexGrow={1} />}
</Box>
) : (
// 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'.
<Box flexDirection="column">
{listBlock}
{matchLabel && <Text dimColor>{matchLabel}</Text>}
{preview}
</Box>
)
const inputAbove = direction !== 'up'
return (
<Pane color="permission">
<Box
flexDirection="column"
gap={1}
tabIndex={0}
autoFocus
onKeyDown={handleKeyDown}
>
<Text bold color="permission">
{title}
</Text>
{inputAbove && searchBox}
{listGroup}
{!inputAbove && searchBox}
<Text dimColor>
<Byline>
<KeyboardShortcutHint
shortcut="↑/↓"
action={compact ? 'nav' : 'navigate'}
/>
<KeyboardShortcutHint
shortcut="Enter"
action={compact ? firstWord(selectAction) : selectAction}
/>
{onTab && (
<KeyboardShortcutHint shortcut="Tab" action={onTab.action} />
)}
{onShiftTab && !compact && (
<KeyboardShortcutHint
shortcut="shift+tab"
action={onShiftTab.action}
/>
)}
<KeyboardShortcutHint shortcut="Esc" action="cancel" />
{extraHints}
</Byline>
</Text>
</Box>
</Pane>
)
}
type ListProps<T> = Pick<
Props<T>,
'visibleCount' | 'direction' | 'getKey' | 'renderItem'
> & {
visible: readonly T[]
windowStart: number
total: number
focusedIndex: number
emptyText: string
}
function List<T>({
visible,
windowStart,
visibleCount,
total,
focusedIndex,
direction,
getKey,
renderItem,
emptyText,
}: ListProps<T>): React.ReactNode {
if (visible.length === 0) {
return (
<Box height={visibleCount} flexShrink={0}>
<Text dimColor>{emptyText}</Text>
</Box>
)
}
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 (
<ListItem
key={getKey(item)}
isFocused={isFocused}
showScrollUp={direction === 'up' ? atHighEdge : atLowEdge}
showScrollDown={direction === 'up' ? atLowEdge : atHighEdge}
styled={false}
>
{renderItem(item, isFocused)}
</ListItem>
)
})
return (
<Box
height={visibleCount}
flexShrink={0}
flexDirection={direction === 'up' ? 'column-reverse' : 'column'}
>
{rows}
</Box>
)
}
function firstWord(s: string): string {
const i = s.indexOf(' ')
return i === -1 ? s : s.slice(0, i)
}
export { FuzzyPicker } from '@anthropic/ink'

View File

@@ -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 <Text dimColor> for the common dim styling.
*
* @example
* // Simple hint wrapped in dim Text
* <Text dimColor><KeyboardShortcutHint shortcut="esc" action="cancel" /></Text>
*
* // With parentheses: "(ctrl+o to expand)"
* <Text dimColor><KeyboardShortcutHint shortcut="ctrl+o" action="expand" parens /></Text>
*
* // With bold shortcut: "Enter to confirm" (Enter is bold)
* <Text dimColor><KeyboardShortcutHint shortcut="Enter" action="confirm" bold /></Text>
*
* // Multiple hints with middot separator - use Byline
* <Text dimColor>
* <Byline>
* <KeyboardShortcutHint shortcut="Enter" action="confirm" />
* <KeyboardShortcutHint shortcut="Esc" action="cancel" />
* </Byline>
* </Text>
*/
export function KeyboardShortcutHint({
shortcut,
action,
parens = false,
bold = false,
}: Props): React.ReactNode {
const shortcutText = bold ? <Text bold>{shortcut}</Text> : shortcut
if (parens) {
return (
<Text>
({shortcutText} to {action})
</Text>
)
}
return (
<Text>
{shortcutText} to {action}
</Text>
)
}
export { KeyboardShortcutHint } from '@anthropic/ink'

View File

@@ -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) => (
* <ListItem
* key={option.id}
* isFocused={focusIndex === i}
* isSelected={selectedId === option.id}
* >
* {option.label}
* </ListItem>
* ))}
*
* @example
* // With scroll indicators
* <ListItem isFocused={false} showScrollUp>First visible item</ListItem>
* ...
* <ListItem isFocused={false} showScrollDown>Last visible item</ListItem>
*
* @example
* // With description
* <ListItem isFocused isSelected={false} description="Secondary text here">
* Primary text
* </ListItem>
*
* @example
* // Custom children styling (styled=false)
* <ListItem isFocused styled={false}>
* <Text color="claude">Custom styled content</Text>
* </ListItem>
*/
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 <Text> </Text>
}
if (isFocused) {
return <Text color="suggestion">{figures.pointer}</Text>
}
if (showScrollDown) {
return <Text dimColor>{figures.arrowDown}</Text>
}
if (showScrollUp) {
return <Text dimColor>{figures.arrowUp}</Text>
}
return <Text> </Text>
}
// 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 (
<Box ref={cursorRef} flexDirection="column">
<Box flexDirection="row" gap={1}>
{renderIndicator()}
{styled ? (
<Text color={textColor} dimColor={disabled}>
{children}
</Text>
) : (
children
)}
{isSelected && !disabled && <Text color="success">{figures.tick}</Text>}
</Box>
{description && (
<Box paddingLeft={2}>
<Text color="inactive">{description}</Text>
</Box>
)}
</Box>
)
}
export { ListItem } from '@anthropic/ink'

View File

@@ -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
* <LoadingState message="Loading..." />
*
* @example
* // Bold loading message
* <LoadingState message="Loading sessions" bold />
*
* @example
* // With subtitle
* <LoadingState
* message="Loading sessions"
* bold
* subtitle="Fetching your Claude Code sessions..."
* />
*/
export function LoadingState({
message,
bold = false,
dimColor = false,
subtitle,
}: LoadingStateProps): React.ReactNode {
return (
<Box flexDirection="column">
<Box flexDirection="row">
<Spinner />
<Text bold={bold} dimColor={dimColor}>
{' '}
{message}
</Text>
</Box>
{subtitle && <Text dimColor>{subtitle}</Text>}
</Box>
)
}
export { LoadingState } from '@anthropic/ink'

View File

@@ -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
* `<Dialog>` instead — it registers its own keybindings. For a full
* rounded-border card, use `<Panel>`.
*
* Submenus rendered inside a Pane should use `hideBorder` on their Dialog
* so the Pane's border remains the single frame.
*
* @example
* <Pane color="permission">
* <Tabs title="Sandbox:">...</Tabs>
* </Pane>
*/
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 (
<Box flexDirection="column" paddingX={1} flexShrink={0}>
{children}
</Box>
)
}
return (
<Box flexDirection="column" paddingTop={1}>
<Divider color={color} />
<Box flexDirection="column" paddingX={2}>
{children}
</Box>
</Box>
)
}
export { Pane } from '@anthropic/ink'

View File

@@ -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 (
<Text color={fillColor} backgroundColor={emptyColor}>
{segments.join('')}
</Text>
)
}
export { ProgressBar } from '@anthropic/ink'

View File

@@ -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<DOMElement | null>(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 (
<Box minHeight={engaged ? minHeight : undefined} ref={outerRef}>
<Box ref={innerRef} flexDirection="column">
{children}
</Box>
</Box>
)
}
export { Ratchet } from '@anthropic/ink'

View File

@@ -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
* <StatusIcon status="success" />
*
* @example
* // Error with trailing space for text
* <Text><StatusIcon status="error" withSpace />Failed to connect</Text>
*
* @example
* // Status line pattern
* <Text>
* <StatusIcon status="pending" withSpace />
* Waiting for response
* </Text>
*/
export function StatusIcon({
status,
withSpace = false,
}: Props): React.ReactNode {
const config = STATUS_CONFIG[status]
return (
<Text color={config.color} dimColor={!config.color}>
{config.icon}
{withSpace && ' '}
</Text>
)
}
export { StatusIcon } from '@anthropic/ink'

View File

@@ -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<React.ReactElement<TabProps>>
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<TabsContextValue>({
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 (
<TabsContext.Provider
value={{
selectedTab: tabs[selectedTabIndex]![0],
width: contentWidth,
headerFocused,
focusHeader,
blurHeader,
registerOptIn,
}}
>
<Box
flexDirection="column"
tabIndex={0}
autoFocus
onKeyDown={handleKeyDown}
// flexShrink=0 inside modal slot — the modal's absolute Box has no
// explicit height (grows to fit, maxHeight cap), so flexGrow=1 here
// resolves to 0 on re-render and the body blanks on Down arrow.
// See #23592. Outside modal, leave layout alone.
flexShrink={modalScrollRef ? 0 : undefined}
>
{!hidden && (
<Box
flexDirection="row"
gap={1}
flexShrink={modalScrollRef ? 0 : undefined}
>
{title !== undefined && (
<Text bold color={color}>
{title}
</Text>
)}
{tabs.map(([id, title], i) => {
const isCurrent = selectedTabIndex === i
const hasColorCursor = color && isCurrent && headerFocused
return (
<Text
key={id}
backgroundColor={hasColorCursor ? color : undefined}
color={hasColorCursor ? 'inverseText' : undefined}
inverse={isCurrent && !hasColorCursor}
bold={isCurrent}
>
{' '}
{title}{' '}
</Text>
)
})}
{spacerWidth > 0 && <Text>{' '.repeat(spacerWidth)}</Text>}
</Box>
)}
{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.
<Box width={contentWidth} marginTop={hidden ? 0 : 1} flexShrink={0}>
<ScrollBox
key={selectedTabIndex}
ref={modalScrollRef}
flexDirection="column"
flexShrink={0}
>
{children}
</ScrollBox>
</Box>
) : (
<Box
width={contentWidth}
marginTop={hidden ? 0 : 1}
height={contentHeight}
overflowY={contentHeight !== undefined ? 'hidden' : undefined}
>
{children}
</Box>
)}
</Box>
</TabsContext.Provider>
)
}
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 (
<Box width={width} flexShrink={insideModal ? 0 : undefined}>
{children}
</Box>
)
}
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'

View File

@@ -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<ThemeContextValue>({
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<ThemeSetting | null>(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<SystemTheme>(() =>
(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<ThemeContextValue>(
() => ({
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 <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
/**
* 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'

View File

@@ -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<DOMElement>
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<Props>): 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 (
<Box
ref={ref}
borderColor={resolvedBorderColor}
borderTopColor={resolvedBorderTopColor}
borderBottomColor={resolvedBorderBottomColor}
borderLeftColor={resolvedBorderLeftColor}
borderRightColor={resolvedBorderRightColor}
backgroundColor={resolvedBackgroundColor}
{...rest}
>
{children}
</Box>
)
}
export default ThemedBox
export { Box as default } from '@anthropic/ink'

View File

@@ -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 (
<Text
color={resolvedColor}
backgroundColor={resolvedBackgroundColor}
bold={bold}
italic={italic}
underline={underline}
strikethrough={strikethrough}
inverse={inverse}
wrap={wrap}
>
{children}
</Text>
)
}
export { Text as default, TextHoverColorContext } from '@anthropic/ink'

View File

@@ -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'

View File

@@ -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

View File

@@ -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: (

View File

@@ -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: (

View File

@@ -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'

View File

@@ -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<number>(0)
const timeoutRef = useRef<NodeJS.Timeout | undefined>(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'

View File

@@ -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<T>(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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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<KeybindingsLoadResult>(() => {
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<ParsedKeystroke[] | null>(null)
const [pendingChord, setPendingChordState] = useState<
ParsedKeystroke[] | null
>(null)
const chordTimeoutRef = useRef<NodeJS.Timeout | null>(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<Set<KeybindingContextName>>(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 (
<KeybindingProvider
bindings={bindings}
pendingChordRef={pendingChordRef}
pendingChord={pendingChord}
setPendingChord={setPendingChord}
activeContexts={activeContextsRef.current}
registerActiveContext={registerActiveContext}
unregisterActiveContext={unregisterActiveContext}
handlerRegistryRef={handlerRegistryRef}
>
<ChordInterceptor
bindings={bindings}
pendingChordRef={pendingChordRef}
setPendingChord={setPendingChord}
activeContexts={activeContextsRef.current}
handlerRegistryRef={handlerRegistryRef}
/>
{children}
</KeybindingProvider>
)
}
/**
* 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<ParsedKeystroke[] | null>
setPendingChord: (pending: ParsedKeystroke[] | null) => void
activeContexts: Set<KeybindingContextName>
handlerRegistryRef: React.RefObject<Map<string, Set<HandlerRegistration>>>
}): 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<KeybindingContextName>()
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 (
<InkKeybindingSetup
loadBindings={loadKeybindingsSyncWithWarnings}
subscribeToChanges={subscribeToKeybindingChanges}
initWatcher={initializeKeybindingWatcher}
onWarnings={handleWarnings}
onDebugLog={logForDebugging}
>
{children}
</InkKeybindingSetup>
)
}

View File

@@ -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<void> {
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<CommanderCommand> {
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<CommanderCommand> {
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") {

View File

@@ -2,3 +2,6 @@ export const TungstenTool = {
name: 'TungstenTool',
isEnabled: () => false,
}
export const clearSessionsWithTungstenUsage = () => {}
export const resetInitializationState = () => {}

View File

@@ -18,7 +18,7 @@ let pump: ReturnType<typeof setInterval> | undefined
let pending = 0
function drainTick(cu: ReturnType<typeof requireComputerUseSwift>): void {
;(cu as any)._drainMainRunLoop()
;(cu as any)?._drainMainRunLoop?.()
}
function retain(): void {