mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 05:45:51 +00:00
* fix: 修复settings.json内存状态溢出的问题 * fix: 修复auto mode gate check未处理的promise rejection 在 bypassPermissionsKillswitch.ts 的 useKickOffCheckAndDisableAutoModeIfNeeded 中,void fire-and-forget 调用缺少 .catch() 处理,导致 verifyAutoModeGateAccess 失败时产生 unhandled promise rejection。同时移除 permissionSetup.ts 中冗余的 null check。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: 开放 auto mode 和 bypass mode 给所有用户 通过 Shift+Tab 统一循环:default → acceptEdits → plan → auto → bypassPermissions → default - 移除 USER_TYPE 分支判断,所有用户使用同一循环路径 - isBypassPermissionsModeAvailable 始终为 true - isAutoModeAvailable 初始化直接为 true - 移除 AutoModeOptInDialog 确认流程 - 简化 isAutoModeGateEnabled 仅保留快模式熔断器 - 简化 verifyAutoModeGateAccess 仅检查快模式 - 移除 GrowthBook/Statsig 远程门控 - bypass permissions killswitch 改为 no-op - 新增 24 个测试覆盖循环逻辑和门控不变量 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: 为sideQuery添加Langfuse追踪 sideQuery 绕过了 claude.ts 的主 API 路径,导致所有走 sideQuery 的调用 (auto mode classifier、permission explainer、session search 等)都没有 Langfuse 记录。现在为每次 sideQuery 调用创建独立 trace 并记录 LLM observation, 未配置 Langfuse 时全部 no-op。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: ACP availableModes 补齐 bypassPermissions 并修正测试 import 路径 - ACP agent availableModes 按条件包含 bypassPermissions(非 root/sandbox) - 顺序对齐 REPL 循环:default → acceptEdits → plan → auto → bypassPermissions - 新增 2 个测试验证 availableModes 包含 bypassPermissions 及模式切换 - 修正 getNextPermissionMode.test.ts 和 permissionSetup.test.ts 的 import 路径 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
3000 lines
96 KiB
TypeScript
3000 lines
96 KiB
TypeScript
import { feature } from 'bun:bundle'
|
|
import chalk from 'chalk'
|
|
import * as path from 'path'
|
|
import * as React from 'react'
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
useSyncExternalStore,
|
|
} from 'react'
|
|
import { useNotifications } from 'src/context/notifications.js'
|
|
import { useCommandQueue } from 'src/hooks/useCommandQueue.js'
|
|
import {
|
|
type IDEAtMentioned,
|
|
useIdeAtMentioned,
|
|
} from 'src/hooks/useIdeAtMentioned.js'
|
|
import {
|
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
logEvent,
|
|
} from 'src/services/analytics/index.js'
|
|
import {
|
|
type AppState,
|
|
useAppState,
|
|
useAppStateStore,
|
|
useSetAppState,
|
|
} from 'src/state/AppState.js'
|
|
import type { FooterItem } from 'src/state/AppStateStore.js'
|
|
import { getCwd } from 'src/utils/cwd.js'
|
|
import {
|
|
isQueuedCommandEditable,
|
|
popAllEditable,
|
|
} from 'src/utils/messageQueueManager.js'
|
|
import stripAnsi from 'strip-ansi'
|
|
import { companionReservedColumns } from '../../buddy/CompanionSprite.js'
|
|
import {
|
|
findBuddyTriggerPositions,
|
|
useBuddyNotification,
|
|
} from '../../buddy/useBuddyNotification.js'
|
|
import { FastModePicker } from '../../commands/fast/fast.js'
|
|
import { isUltrareviewEnabled } from '../../commands/review/ultrareviewEnabled.js'
|
|
import { getNativeCSIuTerminalDisplayName } from '../../commands/terminalSetup/terminalSetup.js'
|
|
import { type Command, hasCommand } from '../../commands.js'
|
|
import { useIsModalOverlayActive } from '../../context/overlayContext.js'
|
|
import { useSetPromptOverlayDialog } from '../../context/promptOverlayContext.js'
|
|
import {
|
|
formatImageRef,
|
|
formatPastedTextRef,
|
|
getPastedTextRefNumLines,
|
|
parseReferences,
|
|
} from '../../history.js'
|
|
import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js'
|
|
import {
|
|
type HistoryMode,
|
|
useArrowKeyHistory,
|
|
} from '../../hooks/useArrowKeyHistory.js'
|
|
import { useDoublePress } from '../../hooks/useDoublePress.js'
|
|
import { useHistorySearch } from '../../hooks/useHistorySearch.js'
|
|
import type { IDESelection } from '../../hooks/useIdeSelection.js'
|
|
import { useInputBuffer } from '../../hooks/useInputBuffer.js'
|
|
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'
|
|
import { usePromptSuggestion } from '../../hooks/usePromptSuggestion.js'
|
|
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
|
import { useTypeahead } from '../../hooks/useTypeahead.js'
|
|
import { Box, type BorderTextOptions, type ClickEvent, type Key, stringWidth, Text, useInput } from '@anthropic/ink'
|
|
import { useOptionalKeybindingContext } from '../../keybindings/KeybindingContext.js'
|
|
import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js'
|
|
import {
|
|
useKeybinding,
|
|
useKeybindings,
|
|
} from '../../keybindings/useKeybinding.js'
|
|
import type { MCPServerConnection } from '../../services/mcp/types.js'
|
|
import {
|
|
abortPromptSuggestion,
|
|
logSuggestionSuppressed,
|
|
} from '../../services/PromptSuggestion/promptSuggestion.js'
|
|
import {
|
|
type ActiveSpeculationState,
|
|
abortSpeculation,
|
|
} from '../../services/PromptSuggestion/speculation.js'
|
|
import {
|
|
getActiveAgentForInput,
|
|
getViewedTeammateTask,
|
|
} from '../../state/selectors.js'
|
|
import {
|
|
enterTeammateView,
|
|
exitTeammateView,
|
|
stopOrDismissAgent,
|
|
} from '../../state/teammateViewHelpers.js'
|
|
import type { ToolPermissionContext } from '../../Tool.js'
|
|
import { getRunningTeammatesSorted } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js'
|
|
import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'
|
|
import {
|
|
isPanelAgentTask,
|
|
type LocalAgentTaskState,
|
|
} from '../../tasks/LocalAgentTask/LocalAgentTask.js'
|
|
import { isBackgroundTask } from '../../tasks/types.js'
|
|
import {
|
|
AGENT_COLOR_TO_THEME_COLOR,
|
|
AGENT_COLORS,
|
|
type AgentColorName,
|
|
} from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'
|
|
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
|
import type { Message } from '../../types/message.js'
|
|
import type { PermissionMode } from '../../types/permissions.js'
|
|
import type {
|
|
BaseTextInputProps,
|
|
PromptInputMode,
|
|
VimMode,
|
|
} from '../../types/textInputTypes.js'
|
|
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
|
|
import { count } from '../../utils/array.js'
|
|
import type { AutoUpdaterResult } from '../../utils/autoUpdater.js'
|
|
import { Cursor } from '../../utils/Cursor.js'
|
|
import {
|
|
getGlobalConfig,
|
|
type PastedContent,
|
|
saveGlobalConfig,
|
|
} from '../../utils/config.js'
|
|
import { logForDebugging } from '../../utils/debug.js'
|
|
import {
|
|
parseDirectMemberMessage,
|
|
sendDirectMemberMessage,
|
|
} from '../../utils/directMemberMessage.js'
|
|
import type { EffortLevel } from '../../utils/effort.js'
|
|
import { env } from '../../utils/env.js'
|
|
import { errorMessage } from '../../utils/errors.js'
|
|
import { isBilledAsExtraUsage } from '../../utils/extraUsage.js'
|
|
import {
|
|
getFastModeUnavailableReason,
|
|
isFastModeAvailable,
|
|
isFastModeCooldown,
|
|
isFastModeEnabled,
|
|
isFastModeSupportedByModel,
|
|
} from '../../utils/fastMode.js'
|
|
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'
|
|
import type { PromptInputHelpers } from '../../utils/handlePromptSubmit.js'
|
|
import {
|
|
getImageFromClipboard,
|
|
PASTE_THRESHOLD,
|
|
} from '../../utils/imagePaste.js'
|
|
import type { ImageDimensions } from '../../utils/imageResizer.js'
|
|
import { cacheImagePath, storeImage } from '../../utils/imageStore.js'
|
|
import {
|
|
isMacosOptionChar,
|
|
MACOS_OPTION_SPECIAL_CHARS,
|
|
} from '../../utils/keyboardShortcuts.js'
|
|
import { logError } from '../../utils/log.js'
|
|
import {
|
|
isOpus1mMergeEnabled,
|
|
modelDisplayString,
|
|
} from '../../utils/model/model.js'
|
|
import {
|
|
cyclePermissionMode,
|
|
getNextPermissionMode,
|
|
} from '../../utils/permissions/getNextPermissionMode.js'
|
|
import { getPlatform } from '../../utils/platform.js'
|
|
import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'
|
|
import { editPromptInEditor } from '../../utils/promptEditor.js'
|
|
// hasAutoModeOptIn removed — auto mode is available to all users
|
|
import { findBtwTriggerPositions } from '../../utils/sideQuestion.js'
|
|
import { findSlashCommandPositions } from '../../utils/suggestions/commandSuggestions.js'
|
|
import {
|
|
findSlackChannelPositions,
|
|
getKnownChannelsVersion,
|
|
hasSlackMcpServer,
|
|
subscribeKnownChannels,
|
|
} from '../../utils/suggestions/slackChannelSuggestions.js'
|
|
import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js'
|
|
import { syncTeammateMode } from '../../utils/swarm/teamHelpers.js'
|
|
import type { TeamSummary } from '../../utils/teamDiscovery.js'
|
|
import { getTeammateColor } from '../../utils/teammate.js'
|
|
import { isInProcessTeammate } from '../../utils/teammateContext.js'
|
|
import { writeToMailbox } from '../../utils/teammateMailbox.js'
|
|
import type { TextHighlight } from '../../utils/textHighlighting.js'
|
|
import type { Theme } from '../../utils/theme.js'
|
|
import {
|
|
findThinkingTriggerPositions,
|
|
getRainbowColor,
|
|
isUltrathinkEnabled,
|
|
} from '../../utils/thinking.js'
|
|
import { findTokenBudgetPositions } from '../../utils/tokenBudget.js'
|
|
import {
|
|
findUltraplanTriggerPositions,
|
|
findUltrareviewTriggerPositions,
|
|
} from '../../utils/ultraplan/keyword.js'
|
|
// AutoModeOptInDialog removed — auto mode is available to all users
|
|
import { BridgeDialog } from '../BridgeDialog.js'
|
|
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
|
|
import {
|
|
getVisibleAgentTasks,
|
|
useCoordinatorTaskCount,
|
|
} from '../CoordinatorAgentStatus.js'
|
|
import { getEffortNotificationText } from '../EffortIndicator.js'
|
|
import { getFastIconString } from '../FastIcon.js'
|
|
import { GlobalSearchDialog } from '../GlobalSearchDialog.js'
|
|
import { HistorySearchDialog } from '../HistorySearchDialog.js'
|
|
import { ModelPicker } from '../ModelPicker.js'
|
|
import { QuickOpenDialog } from '../QuickOpenDialog.js'
|
|
import TextInput from '../TextInput.js'
|
|
import { ThinkingToggle } from '../ThinkingToggle.js'
|
|
import { BackgroundTasksDialog } from '../tasks/BackgroundTasksDialog.js'
|
|
import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js'
|
|
import { TeamsDialog } from '../teams/TeamsDialog.js'
|
|
import VimTextInput from '../VimTextInput.js'
|
|
import { getModeFromInput, getValueFromInput } from './inputModes.js'
|
|
import {
|
|
FOOTER_TEMPORARY_STATUS_TIMEOUT,
|
|
Notifications,
|
|
} from './Notifications.js'
|
|
import PromptInputFooter from './PromptInputFooter.js'
|
|
import type { SuggestionItem } from './PromptInputFooterSuggestions.js'
|
|
import { PromptInputModeIndicator } from './PromptInputModeIndicator.js'
|
|
import { PromptInputQueuedCommands } from './PromptInputQueuedCommands.js'
|
|
import { PromptInputStashNotice } from './PromptInputStashNotice.js'
|
|
import { useMaybeTruncateInput } from './useMaybeTruncateInput.js'
|
|
import { usePromptInputPlaceholder } from './usePromptInputPlaceholder.js'
|
|
import { useShowFastIconHint } from './useShowFastIconHint.js'
|
|
import { useSwarmBanner } from './useSwarmBanner.js'
|
|
import { isNonSpacePrintable, isVimModeEnabled } from './utils.js'
|
|
|
|
type Props = {
|
|
debug: boolean
|
|
ideSelection: IDESelection | undefined
|
|
toolPermissionContext: ToolPermissionContext
|
|
setToolPermissionContext: (ctx: ToolPermissionContext) => void
|
|
apiKeyStatus: VerificationStatus
|
|
commands: Command[]
|
|
agents: AgentDefinition[]
|
|
isLoading: boolean
|
|
verbose: boolean
|
|
messages: Message[]
|
|
onAutoUpdaterResult: (result: AutoUpdaterResult) => void
|
|
autoUpdaterResult: AutoUpdaterResult | null
|
|
input: string
|
|
onInputChange: (value: string) => void
|
|
mode: PromptInputMode
|
|
onModeChange: (mode: PromptInputMode) => void
|
|
stashedPrompt:
|
|
| {
|
|
text: string
|
|
cursorOffset: number
|
|
pastedContents: Record<number, PastedContent>
|
|
}
|
|
| undefined
|
|
setStashedPrompt: (
|
|
value:
|
|
| {
|
|
text: string
|
|
cursorOffset: number
|
|
pastedContents: Record<number, PastedContent>
|
|
}
|
|
| undefined,
|
|
) => void
|
|
submitCount: number
|
|
onShowMessageSelector: () => void
|
|
/** Fullscreen message actions: shift+↑ enters cursor. */
|
|
onMessageActionsEnter?: () => void
|
|
mcpClients: MCPServerConnection[]
|
|
pastedContents: Record<number, PastedContent>
|
|
setPastedContents: React.Dispatch<
|
|
React.SetStateAction<Record<number, PastedContent>>
|
|
>
|
|
vimMode: VimMode
|
|
setVimMode: (mode: VimMode) => void
|
|
showBashesDialog: string | boolean
|
|
setShowBashesDialog: (show: string | boolean) => void
|
|
onExit: () => void
|
|
getToolUseContext: (
|
|
messages: Message[],
|
|
newMessages: Message[],
|
|
abortController: AbortController,
|
|
mainLoopModel: string,
|
|
) => ProcessUserInputContext
|
|
onSubmit: (
|
|
input: string,
|
|
helpers: PromptInputHelpers,
|
|
speculationAccept?: {
|
|
state: ActiveSpeculationState
|
|
speculationSessionTimeSavedMs: number
|
|
setAppState: (f: (prev: AppState) => AppState) => void
|
|
},
|
|
options?: { fromKeybinding?: boolean },
|
|
) => Promise<void>
|
|
onAgentSubmit?: (
|
|
input: string,
|
|
task: InProcessTeammateTaskState | LocalAgentTaskState,
|
|
helpers: PromptInputHelpers,
|
|
) => Promise<void>
|
|
isSearchingHistory: boolean
|
|
setIsSearchingHistory: (isSearching: boolean) => void
|
|
onDismissSideQuestion?: () => void
|
|
isSideQuestionVisible?: boolean
|
|
helpOpen: boolean
|
|
setHelpOpen: React.Dispatch<React.SetStateAction<boolean>>
|
|
hasSuppressedDialogs?: boolean
|
|
isLocalJSXCommandActive?: boolean
|
|
insertTextRef?: React.MutableRefObject<{
|
|
insert: (text: string) => void
|
|
setInputWithCursor: (value: string, cursor: number) => void
|
|
cursorOffset: number
|
|
} | null>
|
|
voiceInterimRange?: { start: number; end: number } | null
|
|
}
|
|
|
|
// Bottom slot has maxHeight="50%"; reserve lines for footer, border, status.
|
|
const PROMPT_FOOTER_LINES = 5
|
|
const MIN_INPUT_VIEWPORT_LINES = 3
|
|
|
|
function PromptInput({
|
|
debug,
|
|
ideSelection,
|
|
toolPermissionContext,
|
|
setToolPermissionContext,
|
|
apiKeyStatus,
|
|
commands,
|
|
agents,
|
|
isLoading,
|
|
verbose,
|
|
messages,
|
|
onAutoUpdaterResult,
|
|
autoUpdaterResult,
|
|
input,
|
|
onInputChange,
|
|
mode,
|
|
onModeChange,
|
|
stashedPrompt,
|
|
setStashedPrompt,
|
|
submitCount,
|
|
onShowMessageSelector,
|
|
onMessageActionsEnter,
|
|
mcpClients,
|
|
pastedContents,
|
|
setPastedContents,
|
|
vimMode,
|
|
setVimMode,
|
|
showBashesDialog,
|
|
setShowBashesDialog,
|
|
onExit,
|
|
getToolUseContext,
|
|
onSubmit: onSubmitProp,
|
|
onAgentSubmit,
|
|
isSearchingHistory,
|
|
setIsSearchingHistory,
|
|
onDismissSideQuestion,
|
|
isSideQuestionVisible,
|
|
helpOpen,
|
|
setHelpOpen,
|
|
hasSuppressedDialogs,
|
|
isLocalJSXCommandActive = false,
|
|
insertTextRef,
|
|
voiceInterimRange,
|
|
}: Props): React.ReactNode {
|
|
const mainLoopModel = useMainLoopModel()
|
|
// A local-jsx command (e.g., /mcp while agent is running) renders a full-
|
|
// screen dialog on top of PromptInput via the immediate-command path with
|
|
// shouldHidePromptInput: false. Those dialogs don't register in the overlay
|
|
// system, so treat them as a modal overlay here to stop navigation keys from
|
|
// leaking into TextInput/footer handlers and stacking a second dialog.
|
|
const isModalOverlayActive =
|
|
useIsModalOverlayActive() || isLocalJSXCommandActive
|
|
const [isAutoUpdating, setIsAutoUpdating] = useState(false)
|
|
const [exitMessage, setExitMessage] = useState<{
|
|
show: boolean
|
|
key?: string
|
|
}>({ show: false })
|
|
const [cursorOffset, setCursorOffset] = useState<number>(input.length)
|
|
// Track the last input value set via internal handlers so we can detect
|
|
// external input changes (e.g. speech-to-text injection) and move cursor to end.
|
|
const lastInternalInputRef = React.useRef(input)
|
|
if (input !== lastInternalInputRef.current) {
|
|
// Input changed externally (not through any internal handler) — move cursor to end
|
|
setCursorOffset(input.length)
|
|
lastInternalInputRef.current = input
|
|
}
|
|
// Wrap onInputChange to track internal changes before they trigger re-render
|
|
const trackAndSetInput = React.useCallback(
|
|
(value: string) => {
|
|
lastInternalInputRef.current = value
|
|
onInputChange(value)
|
|
},
|
|
[onInputChange],
|
|
)
|
|
// Expose an insertText function so callers (e.g. STT) can splice text at the
|
|
// current cursor position instead of replacing the entire input.
|
|
if (insertTextRef) {
|
|
insertTextRef.current = {
|
|
cursorOffset,
|
|
insert: (text: string) => {
|
|
const needsSpace =
|
|
cursorOffset === input.length &&
|
|
input.length > 0 &&
|
|
!/\s$/.test(input)
|
|
const insertText = needsSpace ? ' ' + text : text
|
|
const newValue =
|
|
input.slice(0, cursorOffset) + insertText + input.slice(cursorOffset)
|
|
lastInternalInputRef.current = newValue
|
|
onInputChange(newValue)
|
|
setCursorOffset(cursorOffset + insertText.length)
|
|
},
|
|
setInputWithCursor: (value: string, cursor: number) => {
|
|
lastInternalInputRef.current = value
|
|
onInputChange(value)
|
|
setCursorOffset(cursor)
|
|
},
|
|
}
|
|
}
|
|
const store = useAppStateStore()
|
|
const setAppState = useSetAppState()
|
|
const tasks = useAppState(s => s.tasks)
|
|
const replBridgeConnected = useAppState(s => s.replBridgeConnected)
|
|
const replBridgeExplicit = useAppState(s => s.replBridgeExplicit)
|
|
const replBridgeReconnecting = useAppState(s => s.replBridgeReconnecting)
|
|
// Must match BridgeStatusIndicator's render condition (PromptInputFooter.tsx) —
|
|
// the pill returns null for implicit-and-not-reconnecting, so nav must too,
|
|
// otherwise bridge becomes an invisible selection stop.
|
|
const bridgeFooterVisible =
|
|
replBridgeConnected && (replBridgeExplicit || replBridgeReconnecting)
|
|
// Tmux pill (ant-only) — visible when there's an active tungsten session
|
|
const hasTungstenSession = useAppState(
|
|
s =>
|
|
process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined,
|
|
)
|
|
const tmuxFooterVisible =
|
|
process.env.USER_TYPE === 'ant' && hasTungstenSession
|
|
// WebBrowser pill — visible when a browser is open
|
|
const bagelFooterVisible = useAppState(s =>
|
|
false,
|
|
)
|
|
const teamContext = useAppState(s => s.teamContext)
|
|
const queuedCommands = useCommandQueue()
|
|
const promptSuggestionState = useAppState(s => s.promptSuggestion)
|
|
const speculation = useAppState(s => s.speculation)
|
|
const speculationSessionTimeSavedMs = useAppState(
|
|
s => s.speculationSessionTimeSavedMs,
|
|
)
|
|
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
|
|
const viewSelectionMode = useAppState(s => s.viewSelectionMode)
|
|
const showSpinnerTree = useAppState(s => s.expandedView) === 'teammates'
|
|
const { companion: _companion, companionMuted } = feature('BUDDY')
|
|
? getGlobalConfig()
|
|
: { companion: undefined, companionMuted: undefined }
|
|
const companionFooterVisible = !!_companion && !companionMuted
|
|
// Brief mode: BriefSpinner/BriefIdleStatus own the 2-row footprint above
|
|
// the input. Dropping marginTop here lets the spinner sit flush against
|
|
// the input bar. viewingAgentTaskId mirrors the gate on both (Spinner.tsx,
|
|
// REPL.tsx) — teammate view falls back to SpinnerWithVerbInner which has
|
|
// its own marginTop, so the gap stays even without ours.
|
|
const briefOwnsGap =
|
|
feature('KAIROS') || feature('KAIROS_BRIEF')
|
|
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
|
useAppState(s => s.isBriefOnly) && !viewingAgentTaskId
|
|
: false
|
|
const mainLoopModel_ = useAppState(s => s.mainLoopModel)
|
|
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession)
|
|
const thinkingEnabled = useAppState(s => s.thinkingEnabled)
|
|
const isFastMode = useAppState(s =>
|
|
isFastModeEnabled() ? s.fastMode : false,
|
|
)
|
|
const effortValue = useAppState(s => s.effortValue)
|
|
const viewedTeammate = getViewedTeammateTask(store.getState())
|
|
const viewingAgentName = viewedTeammate?.identity.agentName
|
|
// identity.color is typed as `string | undefined` (not AgentColorName) because
|
|
// teammate identity comes from file-based config. Validate before casting to
|
|
// ensure we only use valid color names (falls back to cyan if invalid).
|
|
const viewingAgentColor =
|
|
viewedTeammate?.identity.color &&
|
|
AGENT_COLORS.includes(viewedTeammate.identity.color as AgentColorName)
|
|
? (viewedTeammate.identity.color as AgentColorName)
|
|
: undefined
|
|
// In-process teammates sorted alphabetically for footer team selector
|
|
const inProcessTeammates = useMemo(
|
|
() => getRunningTeammatesSorted(tasks),
|
|
[tasks],
|
|
)
|
|
|
|
// Team mode: all background tasks are in-process teammates
|
|
const isTeammateMode =
|
|
inProcessTeammates.length > 0 || viewedTeammate !== undefined
|
|
|
|
// When viewing a teammate, show their permission mode in the footer instead of the leader's
|
|
const effectiveToolPermissionContext = useMemo((): ToolPermissionContext => {
|
|
if (viewedTeammate) {
|
|
return {
|
|
...toolPermissionContext,
|
|
mode: viewedTeammate.permissionMode,
|
|
}
|
|
}
|
|
return toolPermissionContext
|
|
}, [viewedTeammate, toolPermissionContext])
|
|
const { historyQuery, setHistoryQuery, historyMatch, historyFailedMatch } =
|
|
useHistorySearch(
|
|
entry => {
|
|
setPastedContents(entry.pastedContents)
|
|
void onSubmit(entry.display)
|
|
},
|
|
input,
|
|
trackAndSetInput,
|
|
setCursorOffset,
|
|
cursorOffset,
|
|
onModeChange,
|
|
mode,
|
|
isSearchingHistory,
|
|
setIsSearchingHistory,
|
|
setPastedContents,
|
|
pastedContents,
|
|
)
|
|
// Counter for paste IDs (shared between images and text).
|
|
// Compute initial value once from existing messages (for --continue/--resume).
|
|
// useRef(fn()) evaluates fn() on every render and discards the result after
|
|
// mount — getInitialPasteId walks all messages + regex-scans text blocks,
|
|
// so guard with a lazy-init pattern to run it exactly once.
|
|
const nextPasteIdRef = useRef(-1)
|
|
if (nextPasteIdRef.current === -1) {
|
|
nextPasteIdRef.current = getInitialPasteId(messages)
|
|
}
|
|
// Armed by onImagePaste; if the very next keystroke is a non-space
|
|
// printable, inputFilter prepends a space before it. Any other input
|
|
// (arrow, escape, backspace, paste, space) disarms without inserting.
|
|
const pendingSpaceAfterPillRef = useRef(false)
|
|
|
|
const [showTeamsDialog, setShowTeamsDialog] = useState(false)
|
|
const [showBridgeDialog, setShowBridgeDialog] = useState(false)
|
|
const [teammateFooterIndex, setTeammateFooterIndex] = useState(0)
|
|
// -1 sentinel: tasks pill is selected but no specific agent row is selected yet.
|
|
// First ↓ selects the pill, second ↓ moves to row 0. Prevents double-select
|
|
// of pill + row when both bg tasks (pill) and forked agents (rows) are visible.
|
|
const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex)
|
|
const setCoordinatorTaskIndex = useCallback(
|
|
(v: number | ((prev: number) => number)) =>
|
|
setAppState(prev => {
|
|
const next = typeof v === 'function' ? v(prev.coordinatorTaskIndex) : v
|
|
if (next === prev.coordinatorTaskIndex) return prev
|
|
return { ...prev, coordinatorTaskIndex: next }
|
|
}),
|
|
[setAppState],
|
|
)
|
|
const coordinatorTaskCount = useCoordinatorTaskCount()
|
|
// The pill (BackgroundTaskStatus) only renders when non-local_agent bg tasks
|
|
// exist. When only local_agent tasks are running (coordinator/fork mode), the
|
|
// pill is absent, so the -1 sentinel would leave nothing visually selected.
|
|
// In that case, skip -1 and treat 0 as the minimum selectable index.
|
|
const hasBgTaskPill = useMemo(
|
|
() =>
|
|
Object.values(tasks).some(
|
|
t =>
|
|
isBackgroundTask(t) &&
|
|
!(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)),
|
|
),
|
|
[tasks],
|
|
)
|
|
const minCoordinatorIndex = hasBgTaskPill ? -1 : 0
|
|
// Clamp index when tasks complete and the list shrinks beneath the cursor
|
|
useEffect(() => {
|
|
if (coordinatorTaskIndex >= coordinatorTaskCount) {
|
|
setCoordinatorTaskIndex(
|
|
Math.max(minCoordinatorIndex, coordinatorTaskCount - 1),
|
|
)
|
|
} else if (coordinatorTaskIndex < minCoordinatorIndex) {
|
|
setCoordinatorTaskIndex(minCoordinatorIndex)
|
|
}
|
|
}, [coordinatorTaskCount, coordinatorTaskIndex, minCoordinatorIndex])
|
|
const [isPasting, setIsPasting] = useState(false)
|
|
const [isExternalEditorActive, setIsExternalEditorActive] = useState(false)
|
|
const [showModelPicker, setShowModelPicker] = useState(false)
|
|
const [showQuickOpen, setShowQuickOpen] = useState(false)
|
|
const [showGlobalSearch, setShowGlobalSearch] = useState(false)
|
|
const [showHistoryPicker, setShowHistoryPicker] = useState(false)
|
|
const [showFastModePicker, setShowFastModePicker] = useState(false)
|
|
const [showThinkingToggle, setShowThinkingToggle] = useState(false)
|
|
|
|
// Check if cursor is on the first line of input
|
|
const isCursorOnFirstLine = useMemo(() => {
|
|
const firstNewlineIndex = input.indexOf('\n')
|
|
if (firstNewlineIndex === -1) {
|
|
return true // No newlines, cursor is always on first line
|
|
}
|
|
return cursorOffset <= firstNewlineIndex
|
|
}, [input, cursorOffset])
|
|
|
|
const isCursorOnLastLine = useMemo(() => {
|
|
const lastNewlineIndex = input.lastIndexOf('\n')
|
|
if (lastNewlineIndex === -1) {
|
|
return true // No newlines, cursor is always on last line
|
|
}
|
|
return cursorOffset > lastNewlineIndex
|
|
}, [input, cursorOffset])
|
|
|
|
// Derive team info from teamContext (no filesystem I/O needed)
|
|
// A session can only lead one team at a time
|
|
const cachedTeams: TeamSummary[] = useMemo(() => {
|
|
if (!isAgentSwarmsEnabled()) return []
|
|
// In-process mode uses Shift+Down/Up navigation instead of footer menu
|
|
if (isInProcessEnabled()) return []
|
|
if (!teamContext) {
|
|
return []
|
|
}
|
|
const teammateCount = count(
|
|
Object.values(teamContext.teammates),
|
|
t => t.name !== 'team-lead',
|
|
)
|
|
return [
|
|
{
|
|
name: teamContext.teamName,
|
|
memberCount: teammateCount,
|
|
runningCount: 0,
|
|
idleCount: 0,
|
|
},
|
|
]
|
|
}, [teamContext])
|
|
|
|
// ─── Footer pill navigation ─────────────────────────────────────────────
|
|
// Which pills render below the input box. Order here IS the nav order
|
|
// (down/right = forward, up/left = back). Selection lives in AppState so
|
|
// pills rendered outside PromptInput (CompanionSprite) can read focus.
|
|
const runningTaskCount = useMemo(
|
|
() => count(Object.values(tasks), t => t.status === 'running'),
|
|
[tasks],
|
|
)
|
|
// Panel shows retained-completed agents too (getVisibleAgentTasks), so the
|
|
// pill must stay navigable whenever the panel has rows — not just when
|
|
// something is running.
|
|
const tasksFooterVisible =
|
|
(runningTaskCount > 0 ||
|
|
(process.env.USER_TYPE === 'ant' && coordinatorTaskCount > 0)) &&
|
|
!shouldHideTasksFooter(tasks, showSpinnerTree)
|
|
const teamsFooterVisible = cachedTeams.length > 0
|
|
|
|
const footerItems = useMemo(
|
|
() =>
|
|
[
|
|
tasksFooterVisible && 'tasks',
|
|
tmuxFooterVisible && 'tmux',
|
|
bagelFooterVisible && 'bagel',
|
|
teamsFooterVisible && 'teams',
|
|
bridgeFooterVisible && 'bridge',
|
|
companionFooterVisible && 'companion',
|
|
].filter(Boolean) as FooterItem[],
|
|
[
|
|
tasksFooterVisible,
|
|
tmuxFooterVisible,
|
|
bagelFooterVisible,
|
|
teamsFooterVisible,
|
|
bridgeFooterVisible,
|
|
companionFooterVisible,
|
|
],
|
|
)
|
|
|
|
// Effective selection: null if the selected pill stopped rendering (bridge
|
|
// disconnected, task finished). The derivation makes the UI correct
|
|
// immediately; the useEffect below clears the raw state so it doesn't
|
|
// resurrect when the same pill reappears (new task starts → focus stolen).
|
|
const rawFooterSelection = useAppState(s => s.footerSelection)
|
|
const footerItemSelected =
|
|
rawFooterSelection && footerItems.includes(rawFooterSelection)
|
|
? rawFooterSelection
|
|
: null
|
|
|
|
useEffect(() => {
|
|
if (rawFooterSelection && !footerItemSelected) {
|
|
setAppState(prev =>
|
|
prev.footerSelection === null
|
|
? prev
|
|
: { ...prev, footerSelection: null },
|
|
)
|
|
}
|
|
}, [rawFooterSelection, footerItemSelected, setAppState])
|
|
|
|
const tasksSelected = footerItemSelected === 'tasks'
|
|
const tmuxSelected = footerItemSelected === 'tmux'
|
|
const bagelSelected = footerItemSelected === 'bagel'
|
|
const teamsSelected = footerItemSelected === 'teams'
|
|
const bridgeSelected = footerItemSelected === 'bridge'
|
|
|
|
function selectFooterItem(item: FooterItem | null): void {
|
|
setAppState(prev =>
|
|
prev.footerSelection === item ? prev : { ...prev, footerSelection: item },
|
|
)
|
|
if (item === 'tasks') {
|
|
setTeammateFooterIndex(0)
|
|
setCoordinatorTaskIndex(minCoordinatorIndex)
|
|
}
|
|
}
|
|
|
|
// delta: +1 = down/right, -1 = up/left. Returns true if nav happened
|
|
// (including deselecting at the start), false if at a boundary.
|
|
function navigateFooter(delta: 1 | -1, exitAtStart = false): boolean {
|
|
const idx = footerItemSelected
|
|
? footerItems.indexOf(footerItemSelected)
|
|
: -1
|
|
const next = footerItems[idx + delta]
|
|
if (next) {
|
|
selectFooterItem(next)
|
|
return true
|
|
}
|
|
if (delta < 0 && exitAtStart) {
|
|
selectFooterItem(null)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Prompt suggestion hook - reads suggestions generated by forked agent in query loop
|
|
const {
|
|
suggestion: promptSuggestion,
|
|
markAccepted,
|
|
logOutcomeAtSubmission,
|
|
markShown,
|
|
} = usePromptSuggestion({
|
|
inputValue: input,
|
|
isAssistantResponding: isLoading,
|
|
})
|
|
|
|
const displayedValue = useMemo(
|
|
() =>
|
|
isSearchingHistory && historyMatch
|
|
? getValueFromInput(
|
|
typeof historyMatch === 'string'
|
|
? historyMatch
|
|
: historyMatch.display,
|
|
)
|
|
: input,
|
|
[isSearchingHistory, historyMatch, input],
|
|
)
|
|
|
|
const thinkTriggers = useMemo(
|
|
() => findThinkingTriggerPositions(displayedValue),
|
|
[displayedValue],
|
|
)
|
|
|
|
const ultraplanSessionUrl = useAppState(s => s.ultraplanSessionUrl)
|
|
const ultraplanLaunching = useAppState(s => s.ultraplanLaunching)
|
|
const ultraplanTriggers = useMemo(
|
|
() =>
|
|
feature('ULTRAPLAN') && !ultraplanSessionUrl && !ultraplanLaunching
|
|
? findUltraplanTriggerPositions(displayedValue)
|
|
: [],
|
|
[displayedValue, ultraplanSessionUrl, ultraplanLaunching],
|
|
)
|
|
|
|
const ultrareviewTriggers = useMemo(
|
|
() =>
|
|
isUltrareviewEnabled()
|
|
? findUltrareviewTriggerPositions(displayedValue)
|
|
: [],
|
|
[displayedValue],
|
|
)
|
|
|
|
const btwTriggers = useMemo(
|
|
() => findBtwTriggerPositions(displayedValue),
|
|
[displayedValue],
|
|
)
|
|
|
|
const buddyTriggers = useMemo(
|
|
() => findBuddyTriggerPositions(displayedValue),
|
|
[displayedValue],
|
|
)
|
|
|
|
const slashCommandTriggers = useMemo(() => {
|
|
const positions = findSlashCommandPositions(displayedValue)
|
|
// Only highlight valid commands
|
|
return positions.filter(pos => {
|
|
const commandName = displayedValue.slice(pos.start + 1, pos.end) // +1 to skip "/"
|
|
return hasCommand(commandName, commands)
|
|
})
|
|
}, [displayedValue, commands])
|
|
|
|
const tokenBudgetTriggers = useMemo(
|
|
() =>
|
|
feature('TOKEN_BUDGET') ? findTokenBudgetPositions(displayedValue) : [],
|
|
[displayedValue],
|
|
)
|
|
|
|
const knownChannelsVersion = useSyncExternalStore(
|
|
subscribeKnownChannels,
|
|
getKnownChannelsVersion,
|
|
)
|
|
const slackChannelTriggers = useMemo(
|
|
() =>
|
|
hasSlackMcpServer(store.getState().mcp.clients)
|
|
? findSlackChannelPositions(displayedValue)
|
|
: [],
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- store is a stable ref
|
|
[displayedValue, knownChannelsVersion],
|
|
)
|
|
|
|
// Find @name mentions and highlight with team member's color
|
|
const memberMentionHighlights = useMemo((): Array<{
|
|
start: number
|
|
end: number
|
|
themeColor: keyof Theme
|
|
}> => {
|
|
if (!isAgentSwarmsEnabled()) return []
|
|
if (!teamContext?.teammates) return []
|
|
|
|
const highlights: Array<{
|
|
start: number
|
|
end: number
|
|
themeColor: keyof Theme
|
|
}> = []
|
|
const members = teamContext.teammates
|
|
if (!members) return highlights
|
|
|
|
// Find all @name patterns in the input
|
|
const regex = /(^|\s)@([\w-]+)/g
|
|
const memberValues = Object.values(members)
|
|
let match
|
|
while ((match = regex.exec(displayedValue)) !== null) {
|
|
const leadingSpace = match[1] ?? ''
|
|
const nameStart = match.index + leadingSpace.length
|
|
const fullMatch = match[0].trimStart()
|
|
const name = match[2]
|
|
|
|
// Check if this name matches a team member
|
|
const member = memberValues.find(t => t.name === name)
|
|
if (member?.color) {
|
|
const themeColor =
|
|
AGENT_COLOR_TO_THEME_COLOR[member.color as AgentColorName]
|
|
if (themeColor) {
|
|
highlights.push({
|
|
start: nameStart,
|
|
end: nameStart + fullMatch.length,
|
|
themeColor,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return highlights
|
|
}, [displayedValue, teamContext])
|
|
|
|
const imageRefPositions = useMemo(
|
|
() =>
|
|
parseReferences(displayedValue)
|
|
.filter(r => r.match.startsWith('[Image'))
|
|
.map(r => ({ start: r.index, end: r.index + r.match.length })),
|
|
[displayedValue],
|
|
)
|
|
|
|
// chip.start is the "selected" state: the inverted chip IS the cursor.
|
|
// chip.end stays a normal position so you can park the cursor right after
|
|
// `]` like any other character.
|
|
const cursorAtImageChip = imageRefPositions.some(
|
|
r => r.start === cursorOffset,
|
|
)
|
|
|
|
// up/down movement or a fullscreen click can land the cursor strictly
|
|
// inside a chip; snap to the nearer boundary so it's never editable
|
|
// char-by-char.
|
|
useEffect(() => {
|
|
const inside = imageRefPositions.find(
|
|
r => cursorOffset > r.start && cursorOffset < r.end,
|
|
)
|
|
if (inside) {
|
|
const mid = (inside.start + inside.end) / 2
|
|
setCursorOffset(cursorOffset < mid ? inside.start : inside.end)
|
|
}
|
|
}, [cursorOffset, imageRefPositions, setCursorOffset])
|
|
|
|
const combinedHighlights = useMemo((): TextHighlight[] => {
|
|
const highlights: TextHighlight[] = []
|
|
|
|
// Invert the [Image #N] chip when the cursor is at chip.start (the
|
|
// "selected" state) so backspace-to-delete is visually obvious.
|
|
for (const ref of imageRefPositions) {
|
|
if (cursorOffset === ref.start) {
|
|
highlights.push({
|
|
start: ref.start,
|
|
end: ref.end,
|
|
color: undefined,
|
|
inverse: true,
|
|
priority: 8,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (isSearchingHistory && historyMatch && !historyFailedMatch) {
|
|
highlights.push({
|
|
start: cursorOffset,
|
|
end: cursorOffset + historyQuery.length,
|
|
color: 'warning',
|
|
priority: 20,
|
|
})
|
|
}
|
|
|
|
// Add "btw" highlighting (solid yellow)
|
|
for (const trigger of btwTriggers) {
|
|
highlights.push({
|
|
start: trigger.start,
|
|
end: trigger.end,
|
|
color: 'warning',
|
|
priority: 15,
|
|
})
|
|
}
|
|
|
|
// Add /command highlighting (blue)
|
|
for (const trigger of slashCommandTriggers) {
|
|
highlights.push({
|
|
start: trigger.start,
|
|
end: trigger.end,
|
|
color: 'suggestion',
|
|
priority: 5,
|
|
})
|
|
}
|
|
|
|
// Add token budget highlighting (blue)
|
|
for (const trigger of tokenBudgetTriggers) {
|
|
highlights.push({
|
|
start: trigger.start,
|
|
end: trigger.end,
|
|
color: 'suggestion',
|
|
priority: 5,
|
|
})
|
|
}
|
|
|
|
for (const trigger of slackChannelTriggers) {
|
|
highlights.push({
|
|
start: trigger.start,
|
|
end: trigger.end,
|
|
color: 'suggestion',
|
|
priority: 5,
|
|
})
|
|
}
|
|
|
|
// Add @name highlighting with team member's color
|
|
for (const mention of memberMentionHighlights) {
|
|
highlights.push({
|
|
start: mention.start,
|
|
end: mention.end,
|
|
color: mention.themeColor,
|
|
priority: 5,
|
|
})
|
|
}
|
|
|
|
// Dim interim voice dictation text
|
|
if (voiceInterimRange) {
|
|
highlights.push({
|
|
start: voiceInterimRange.start,
|
|
end: voiceInterimRange.end,
|
|
color: undefined,
|
|
dimColor: true,
|
|
priority: 1,
|
|
})
|
|
}
|
|
|
|
// Rainbow highlighting for ultrathink keyword (per-character cycling colors)
|
|
if (isUltrathinkEnabled()) {
|
|
for (const trigger of thinkTriggers) {
|
|
for (let i = trigger.start; i < trigger.end; i++) {
|
|
highlights.push({
|
|
start: i,
|
|
end: i + 1,
|
|
color: getRainbowColor(i - trigger.start),
|
|
shimmerColor: getRainbowColor(i - trigger.start, true),
|
|
priority: 10,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Same rainbow treatment for the ultraplan keyword
|
|
if (feature('ULTRAPLAN')) {
|
|
for (const trigger of ultraplanTriggers) {
|
|
for (let i = trigger.start; i < trigger.end; i++) {
|
|
highlights.push({
|
|
start: i,
|
|
end: i + 1,
|
|
color: getRainbowColor(i - trigger.start),
|
|
shimmerColor: getRainbowColor(i - trigger.start, true),
|
|
priority: 10,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Same rainbow treatment for the ultrareview keyword
|
|
for (const trigger of ultrareviewTriggers) {
|
|
for (let i = trigger.start; i < trigger.end; i++) {
|
|
highlights.push({
|
|
start: i,
|
|
end: i + 1,
|
|
color: getRainbowColor(i - trigger.start),
|
|
shimmerColor: getRainbowColor(i - trigger.start, true),
|
|
priority: 10,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Rainbow for /buddy
|
|
for (const trigger of buddyTriggers) {
|
|
for (let i = trigger.start; i < trigger.end; i++) {
|
|
highlights.push({
|
|
start: i,
|
|
end: i + 1,
|
|
color: getRainbowColor(i - trigger.start),
|
|
shimmerColor: getRainbowColor(i - trigger.start, true),
|
|
priority: 10,
|
|
})
|
|
}
|
|
}
|
|
|
|
return highlights
|
|
}, [
|
|
isSearchingHistory,
|
|
historyQuery,
|
|
historyMatch,
|
|
historyFailedMatch,
|
|
cursorOffset,
|
|
btwTriggers,
|
|
imageRefPositions,
|
|
memberMentionHighlights,
|
|
slashCommandTriggers,
|
|
tokenBudgetTriggers,
|
|
slackChannelTriggers,
|
|
displayedValue,
|
|
voiceInterimRange,
|
|
thinkTriggers,
|
|
ultraplanTriggers,
|
|
ultrareviewTriggers,
|
|
buddyTriggers,
|
|
])
|
|
|
|
const { addNotification, removeNotification } = useNotifications()
|
|
|
|
// Show ultrathink notification
|
|
useEffect(() => {
|
|
if (thinkTriggers.length && isUltrathinkEnabled()) {
|
|
addNotification({
|
|
key: 'ultrathink-active',
|
|
text: 'Effort set to high for this turn',
|
|
priority: 'immediate',
|
|
timeoutMs: 5000,
|
|
})
|
|
} else {
|
|
removeNotification('ultrathink-active')
|
|
}
|
|
}, [addNotification, removeNotification, thinkTriggers.length])
|
|
|
|
useEffect(() => {
|
|
if (feature('ULTRAPLAN') && ultraplanTriggers.length) {
|
|
addNotification({
|
|
key: 'ultraplan-active',
|
|
text: 'This prompt will launch an ultraplan session in Claude Code on the web',
|
|
priority: 'immediate',
|
|
timeoutMs: 5000,
|
|
})
|
|
} else {
|
|
removeNotification('ultraplan-active')
|
|
}
|
|
}, [addNotification, removeNotification, ultraplanTriggers.length])
|
|
|
|
useEffect(() => {
|
|
if (isUltrareviewEnabled() && ultrareviewTriggers.length) {
|
|
addNotification({
|
|
key: 'ultrareview-active',
|
|
text: 'Run /ultrareview after Claude finishes to review these changes in the cloud',
|
|
priority: 'immediate',
|
|
timeoutMs: 5000,
|
|
})
|
|
}
|
|
}, [addNotification, ultrareviewTriggers.length])
|
|
|
|
// Track input length for stash hint
|
|
const prevInputLengthRef = useRef(input.length)
|
|
const peakInputLengthRef = useRef(input.length)
|
|
|
|
// Dismiss stash hint when user makes any input change
|
|
const dismissStashHint = useCallback(() => {
|
|
removeNotification('stash-hint')
|
|
}, [removeNotification])
|
|
|
|
// Show stash hint when user gradually clears substantial input
|
|
useEffect(() => {
|
|
const prevLength = prevInputLengthRef.current
|
|
const peakLength = peakInputLengthRef.current
|
|
const currentLength = input.length
|
|
prevInputLengthRef.current = currentLength
|
|
|
|
// Update peak when input grows
|
|
if (currentLength > peakLength) {
|
|
peakInputLengthRef.current = currentLength
|
|
return
|
|
}
|
|
|
|
// Reset state when input is empty
|
|
if (currentLength === 0) {
|
|
peakInputLengthRef.current = 0
|
|
return
|
|
}
|
|
|
|
// Detect gradual clear: peak was high, current is low, but this wasn't a single big jump
|
|
// (rapid clears like esc-esc go from 20+ to 0 in one step)
|
|
const clearedSubstantialInput = peakLength >= 20 && currentLength <= 5
|
|
const wasRapidClear = prevLength >= 20 && currentLength <= 5
|
|
|
|
if (clearedSubstantialInput && !wasRapidClear) {
|
|
const config = getGlobalConfig()
|
|
if (!config.hasUsedStash) {
|
|
addNotification({
|
|
key: 'stash-hint',
|
|
jsx: (
|
|
<Text dimColor>
|
|
Tip:{' '}
|
|
<ConfigurableShortcutHint
|
|
action="chat:stash"
|
|
context="Chat"
|
|
fallback="ctrl+s"
|
|
description="stash"
|
|
/>
|
|
</Text>
|
|
),
|
|
priority: 'immediate',
|
|
timeoutMs: FOOTER_TEMPORARY_STATUS_TIMEOUT,
|
|
})
|
|
}
|
|
peakInputLengthRef.current = currentLength
|
|
}
|
|
}, [input.length, addNotification])
|
|
|
|
// Initialize input buffer for undo functionality
|
|
const { pushToBuffer, undo, canUndo, clearBuffer } = useInputBuffer({
|
|
maxBufferSize: 50,
|
|
debounceMs: 1000,
|
|
})
|
|
|
|
useMaybeTruncateInput({
|
|
input,
|
|
pastedContents,
|
|
onInputChange: trackAndSetInput,
|
|
setCursorOffset,
|
|
setPastedContents,
|
|
})
|
|
|
|
const defaultPlaceholder = usePromptInputPlaceholder({
|
|
input,
|
|
submitCount,
|
|
viewingAgentName,
|
|
})
|
|
|
|
const onChange = useCallback(
|
|
(value: string) => {
|
|
if (value === '?') {
|
|
logEvent('tengu_help_toggled', {})
|
|
setHelpOpen(v => !v)
|
|
return
|
|
}
|
|
setHelpOpen(false)
|
|
|
|
// Dismiss stash hint when user makes any input change
|
|
dismissStashHint()
|
|
|
|
// Cancel any pending prompt suggestion and speculation when user types
|
|
abortPromptSuggestion()
|
|
abortSpeculation(setAppState)
|
|
|
|
// Check if this is a single character insertion at the start
|
|
const isSingleCharInsertion = value.length === input.length + 1
|
|
const insertedAtStart = cursorOffset === 0
|
|
const mode = getModeFromInput(value)
|
|
|
|
if (insertedAtStart && mode !== 'prompt') {
|
|
if (isSingleCharInsertion) {
|
|
onModeChange(mode)
|
|
return
|
|
}
|
|
// Multi-char insertion into empty input (e.g. tab-accepting "! gcloud auth login")
|
|
if (input.length === 0) {
|
|
onModeChange(mode)
|
|
const valueWithoutMode = getValueFromInput(value).replaceAll(
|
|
'\t',
|
|
' ',
|
|
)
|
|
pushToBuffer(input, cursorOffset, pastedContents)
|
|
trackAndSetInput(valueWithoutMode)
|
|
setCursorOffset(valueWithoutMode.length)
|
|
return
|
|
}
|
|
}
|
|
|
|
const processedValue = value.replaceAll('\t', ' ')
|
|
|
|
// Push current state to buffer before making changes
|
|
if (input !== processedValue) {
|
|
pushToBuffer(input, cursorOffset, pastedContents)
|
|
}
|
|
|
|
// Deselect footer items when user types
|
|
setAppState(prev =>
|
|
prev.footerSelection === null
|
|
? prev
|
|
: { ...prev, footerSelection: null },
|
|
)
|
|
|
|
trackAndSetInput(processedValue)
|
|
},
|
|
[
|
|
trackAndSetInput,
|
|
onModeChange,
|
|
input,
|
|
cursorOffset,
|
|
pushToBuffer,
|
|
pastedContents,
|
|
dismissStashHint,
|
|
setAppState,
|
|
],
|
|
)
|
|
|
|
const {
|
|
resetHistory,
|
|
onHistoryUp,
|
|
onHistoryDown,
|
|
dismissSearchHint,
|
|
historyIndex,
|
|
} = useArrowKeyHistory(
|
|
(
|
|
value: string,
|
|
historyMode: HistoryMode,
|
|
pastedContents: Record<number, PastedContent>,
|
|
) => {
|
|
onChange(value)
|
|
onModeChange(historyMode)
|
|
setPastedContents(pastedContents)
|
|
},
|
|
input,
|
|
pastedContents,
|
|
setCursorOffset,
|
|
mode,
|
|
)
|
|
|
|
// Dismiss search hint when user starts searching
|
|
useEffect(() => {
|
|
if (isSearchingHistory) {
|
|
dismissSearchHint()
|
|
}
|
|
}, [isSearchingHistory, dismissSearchHint])
|
|
|
|
// Only use history navigation when there are 0 or 1 slash command suggestions.
|
|
// Footer nav is NOT here — when a pill is selected, TextInput focus=false so
|
|
// these never fire. The Footer keybinding context handles ↑/↓ instead.
|
|
function handleHistoryUp() {
|
|
if (suggestions.length > 1) {
|
|
return
|
|
}
|
|
|
|
// Only navigate history when cursor is on the first line.
|
|
// In multiline inputs, up arrow should move the cursor (handled by TextInput)
|
|
// and only trigger history when at the top of the input.
|
|
if (!isCursorOnFirstLine) {
|
|
return
|
|
}
|
|
|
|
// If there's an editable queued command, move it to the input for editing when UP is pressed
|
|
const hasEditableCommand = queuedCommands.some(isQueuedCommandEditable)
|
|
if (hasEditableCommand) {
|
|
void popAllCommandsFromQueue()
|
|
return
|
|
}
|
|
|
|
onHistoryUp()
|
|
}
|
|
|
|
function handleHistoryDown() {
|
|
if (suggestions.length > 1) {
|
|
return
|
|
}
|
|
|
|
// Only navigate history/footer when cursor is on the last line.
|
|
// In multiline inputs, down arrow should move the cursor (handled by TextInput)
|
|
// and only trigger navigation when at the bottom of the input.
|
|
if (!isCursorOnLastLine) {
|
|
return
|
|
}
|
|
|
|
// At bottom of history → enter footer at first visible pill
|
|
if (onHistoryDown() && footerItems.length > 0) {
|
|
const first = footerItems[0]!
|
|
selectFooterItem(first)
|
|
if (first === 'tasks' && !getGlobalConfig().hasSeenTasksHint) {
|
|
saveGlobalConfig(c =>
|
|
c.hasSeenTasksHint ? c : { ...c, hasSeenTasksHint: true },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create a suggestions state directly - we'll sync it with useTypeahead later
|
|
const [suggestionsState, setSuggestionsStateRaw] = useState<{
|
|
suggestions: SuggestionItem[]
|
|
selectedSuggestion: number
|
|
commandArgumentHint?: string
|
|
}>({
|
|
suggestions: [],
|
|
selectedSuggestion: -1,
|
|
commandArgumentHint: undefined,
|
|
})
|
|
|
|
// Setter for suggestions state
|
|
const setSuggestionsState = useCallback(
|
|
(
|
|
updater:
|
|
| typeof suggestionsState
|
|
| ((prev: typeof suggestionsState) => typeof suggestionsState),
|
|
) => {
|
|
setSuggestionsStateRaw(prev =>
|
|
typeof updater === 'function' ? updater(prev) : updater,
|
|
)
|
|
},
|
|
[],
|
|
)
|
|
|
|
const onSubmit = useCallback(
|
|
async (inputParam: string, isSubmittingSlashCommand = false) => {
|
|
inputParam = inputParam.trimEnd()
|
|
|
|
// Don't submit if a footer indicator is being opened. Read fresh from
|
|
// store — footer:openSelected calls selectFooterItem(null) then onSubmit
|
|
// in the same tick, and the closure value hasn't updated yet. Apply the
|
|
// same "still visible?" derivation as footerItemSelected so a stale
|
|
// selection (pill disappeared) doesn't swallow Enter.
|
|
const state = store.getState()
|
|
if (
|
|
state.footerSelection &&
|
|
footerItems.includes(state.footerSelection)
|
|
) {
|
|
return
|
|
}
|
|
|
|
// Enter in selection modes confirms selection (useBackgroundTaskNavigation).
|
|
// BaseTextInput's useInput registers before that hook (child effects fire first),
|
|
// so without this guard Enter would double-fire and auto-submit the suggestion.
|
|
if (state.viewSelectionMode === 'selecting-agent') {
|
|
return
|
|
}
|
|
|
|
// Check for images early - we need this for suggestion logic below
|
|
const hasImages = Object.values(pastedContents).some(
|
|
c => c.type === 'image',
|
|
)
|
|
|
|
// If input is empty OR matches the suggestion, submit it
|
|
// But if there are images attached, don't auto-accept the suggestion -
|
|
// the user wants to submit just the image(s).
|
|
// Only in leader view — promptSuggestion is leader-context, not teammate.
|
|
const suggestionText = promptSuggestionState.text
|
|
const inputMatchesSuggestion =
|
|
inputParam.trim() === '' || inputParam === suggestionText
|
|
if (
|
|
inputMatchesSuggestion &&
|
|
suggestionText &&
|
|
!hasImages &&
|
|
!state.viewingAgentTaskId
|
|
) {
|
|
// If speculation is active, inject messages immediately as they stream
|
|
if (speculation.status === 'active') {
|
|
markAccepted()
|
|
// skipReset: resetSuggestion would abort the speculation before we accept it
|
|
logOutcomeAtSubmission(suggestionText, { skipReset: true })
|
|
|
|
void onSubmitProp(
|
|
suggestionText,
|
|
{
|
|
setCursorOffset,
|
|
clearBuffer,
|
|
resetHistory,
|
|
},
|
|
{
|
|
state: speculation,
|
|
speculationSessionTimeSavedMs: speculationSessionTimeSavedMs,
|
|
setAppState,
|
|
},
|
|
)
|
|
return // Skip normal query - speculation handled it
|
|
}
|
|
|
|
// Regular suggestion acceptance (requires shownAt > 0)
|
|
if (promptSuggestionState.shownAt > 0) {
|
|
markAccepted()
|
|
inputParam = suggestionText
|
|
}
|
|
}
|
|
|
|
// Handle @name direct message
|
|
if (isAgentSwarmsEnabled()) {
|
|
const directMessage = parseDirectMemberMessage(inputParam)
|
|
if (directMessage) {
|
|
const result = await sendDirectMemberMessage(
|
|
directMessage.recipientName,
|
|
directMessage.message,
|
|
teamContext,
|
|
writeToMailbox,
|
|
)
|
|
|
|
if (result.success) {
|
|
addNotification({
|
|
key: 'direct-message-sent',
|
|
text: `Sent to @${result.recipientName}`,
|
|
priority: 'immediate',
|
|
timeoutMs: 3000,
|
|
})
|
|
trackAndSetInput('')
|
|
setCursorOffset(0)
|
|
clearBuffer()
|
|
resetHistory()
|
|
return
|
|
} else if (!result.success && (result as { error: string }).error === 'no_team_context') {
|
|
// No team context - fall through to normal prompt submission
|
|
} else {
|
|
// Unknown recipient - fall through to normal prompt submission
|
|
// This allows e.g. "@utils explain this code" to be sent as a prompt
|
|
}
|
|
}
|
|
}
|
|
|
|
// Allow submission if there are images attached, even without text
|
|
if (inputParam.trim() === '' && !hasImages) {
|
|
return
|
|
}
|
|
|
|
// PromptInput UX: Check if suggestions dropdown is showing
|
|
// For directory suggestions, allow submission (Tab is used for completion)
|
|
const hasDirectorySuggestions =
|
|
suggestionsState.suggestions.length > 0 &&
|
|
suggestionsState.suggestions.every(s => s.description === 'directory')
|
|
|
|
if (
|
|
suggestionsState.suggestions.length > 0 &&
|
|
!isSubmittingSlashCommand &&
|
|
!hasDirectorySuggestions
|
|
) {
|
|
logForDebugging(
|
|
`[onSubmit] early return: suggestions showing (count=${suggestionsState.suggestions.length})`,
|
|
)
|
|
return // Don't submit, user needs to clear suggestions first
|
|
}
|
|
|
|
// Log suggestion outcome if one exists
|
|
if (promptSuggestionState.text && promptSuggestionState.shownAt > 0) {
|
|
logOutcomeAtSubmission(inputParam)
|
|
}
|
|
|
|
// Clear stash hint notification on submit
|
|
removeNotification('stash-hint')
|
|
|
|
// Route input to viewed agent (in-process teammate or named local_agent).
|
|
const activeAgent = getActiveAgentForInput(store.getState())
|
|
if (activeAgent.type !== 'leader' && onAgentSubmit) {
|
|
logEvent('tengu_transcript_input_to_teammate', {})
|
|
await onAgentSubmit(inputParam, activeAgent.task, {
|
|
setCursorOffset,
|
|
clearBuffer,
|
|
resetHistory,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Normal leader submission
|
|
await onSubmitProp(inputParam, {
|
|
setCursorOffset,
|
|
clearBuffer,
|
|
resetHistory,
|
|
})
|
|
},
|
|
[
|
|
promptSuggestionState,
|
|
speculation,
|
|
speculationSessionTimeSavedMs,
|
|
teamContext,
|
|
store,
|
|
footerItems,
|
|
suggestionsState.suggestions,
|
|
onSubmitProp,
|
|
onAgentSubmit,
|
|
clearBuffer,
|
|
resetHistory,
|
|
logOutcomeAtSubmission,
|
|
setAppState,
|
|
markAccepted,
|
|
pastedContents,
|
|
removeNotification,
|
|
],
|
|
)
|
|
|
|
const {
|
|
suggestions,
|
|
selectedSuggestion,
|
|
commandArgumentHint,
|
|
inlineGhostText,
|
|
maxColumnWidth,
|
|
} = useTypeahead({
|
|
commands,
|
|
onInputChange: trackAndSetInput,
|
|
onSubmit,
|
|
setCursorOffset,
|
|
input,
|
|
cursorOffset,
|
|
mode,
|
|
agents,
|
|
setSuggestionsState,
|
|
suggestionsState,
|
|
suppressSuggestions: isSearchingHistory || historyIndex > 0,
|
|
markAccepted,
|
|
onModeChange,
|
|
})
|
|
|
|
// Track if prompt suggestion should be shown (computed later with terminal width).
|
|
// Hidden in teammate view — suggestion is leader-context only.
|
|
const showPromptSuggestion =
|
|
mode === 'prompt' &&
|
|
suggestions.length === 0 &&
|
|
promptSuggestion &&
|
|
!viewingAgentTaskId
|
|
if (showPromptSuggestion) {
|
|
markShown()
|
|
}
|
|
|
|
// If suggestion was generated but can't be shown due to timing, log suppression.
|
|
// Exclude teammate view: markShown() is gated above, so shownAt stays 0 there —
|
|
// but that's not a timing failure, the suggestion is valid when returning to leader.
|
|
if (
|
|
promptSuggestionState.text &&
|
|
!promptSuggestion &&
|
|
promptSuggestionState.shownAt === 0 &&
|
|
!viewingAgentTaskId
|
|
) {
|
|
logSuggestionSuppressed('timing', promptSuggestionState.text)
|
|
setAppState(prev => ({
|
|
...prev,
|
|
promptSuggestion: {
|
|
text: null,
|
|
promptId: null,
|
|
shownAt: 0,
|
|
acceptedAt: 0,
|
|
generationRequestId: null,
|
|
},
|
|
}))
|
|
}
|
|
|
|
function onImagePaste(
|
|
image: string,
|
|
mediaType?: string,
|
|
filename?: string,
|
|
dimensions?: ImageDimensions,
|
|
sourcePath?: string,
|
|
) {
|
|
logEvent('tengu_paste_image', {})
|
|
onModeChange('prompt')
|
|
|
|
const pasteId = nextPasteIdRef.current++
|
|
|
|
const newContent: PastedContent = {
|
|
id: pasteId,
|
|
type: 'image',
|
|
content: image,
|
|
mediaType: mediaType || 'image/png', // default to PNG if not provided
|
|
filename: filename || 'Pasted image',
|
|
dimensions,
|
|
sourcePath,
|
|
}
|
|
|
|
// Cache path immediately (fast) so links work on render
|
|
cacheImagePath(newContent)
|
|
|
|
// Store image to disk in background
|
|
void storeImage(newContent)
|
|
|
|
// Update UI
|
|
setPastedContents(prev => ({ ...prev, [pasteId]: newContent }))
|
|
// Multi-image paste calls onImagePaste in a loop. If the ref is already
|
|
// armed, the previous pill's lazy space fires now (before this pill)
|
|
// rather than being lost.
|
|
const prefix = pendingSpaceAfterPillRef.current ? ' ' : ''
|
|
insertTextAtCursor(prefix + formatImageRef(pasteId))
|
|
pendingSpaceAfterPillRef.current = true
|
|
}
|
|
|
|
// Prune images whose [Image #N] placeholder is no longer in the input text.
|
|
// Covers pill backspace, Ctrl+U, char-by-char deletion — any edit that drops
|
|
// the ref. onImagePaste batches setPastedContents + insertTextAtCursor in the
|
|
// same event, so this effect sees the placeholder already present.
|
|
useEffect(() => {
|
|
const referencedIds = new Set(parseReferences(input).map(r => r.id))
|
|
setPastedContents(prev => {
|
|
const orphaned = Object.values(prev).filter(
|
|
c => c.type === 'image' && !referencedIds.has(c.id),
|
|
)
|
|
if (orphaned.length === 0) return prev
|
|
const next = { ...prev }
|
|
for (const img of orphaned) delete next[img.id]
|
|
return next
|
|
})
|
|
}, [input, setPastedContents])
|
|
|
|
function onTextPaste(rawText: string) {
|
|
pendingSpaceAfterPillRef.current = false
|
|
// Clean up pasted text - strip ANSI escape codes and normalize line endings and tabs
|
|
let text = stripAnsi(rawText).replace(/\r/g, '\n').replaceAll('\t', ' ')
|
|
|
|
// Match typed/auto-suggest: `!cmd` pasted into empty input enters bash mode.
|
|
if (input.length === 0) {
|
|
const pastedMode = getModeFromInput(text)
|
|
if (pastedMode !== 'prompt') {
|
|
onModeChange(pastedMode)
|
|
text = getValueFromInput(text)
|
|
}
|
|
}
|
|
|
|
const numLines = getPastedTextRefNumLines(text)
|
|
// Limit the number of lines to show in the input
|
|
// If the overall layout is too high then Ink will repaint
|
|
// the entire terminal.
|
|
// The actual required height is dependent on the content, this
|
|
// is just an estimate.
|
|
const maxLines = Math.min(rows - 10, 2)
|
|
|
|
// Use special handling for long pasted text (>PASTE_THRESHOLD chars)
|
|
// or if it exceeds the number of lines we want to show
|
|
if (text.length > PASTE_THRESHOLD || numLines > maxLines) {
|
|
const pasteId = nextPasteIdRef.current++
|
|
|
|
const newContent: PastedContent = {
|
|
id: pasteId,
|
|
type: 'text',
|
|
content: text,
|
|
}
|
|
|
|
setPastedContents(prev => ({ ...prev, [pasteId]: newContent }))
|
|
|
|
insertTextAtCursor(formatPastedTextRef(pasteId, numLines))
|
|
} else {
|
|
// For shorter pastes, just insert the text normally
|
|
insertTextAtCursor(text)
|
|
}
|
|
}
|
|
|
|
const lazySpaceInputFilter = useCallback(
|
|
(input: string, key: Key): string => {
|
|
if (!pendingSpaceAfterPillRef.current) return input
|
|
pendingSpaceAfterPillRef.current = false
|
|
if (isNonSpacePrintable(input, key)) return ' ' + input
|
|
return input
|
|
},
|
|
[],
|
|
)
|
|
|
|
function insertTextAtCursor(text: string) {
|
|
// Push current state to buffer before inserting
|
|
pushToBuffer(input, cursorOffset, pastedContents)
|
|
|
|
const newInput =
|
|
input.slice(0, cursorOffset) + text + input.slice(cursorOffset)
|
|
trackAndSetInput(newInput)
|
|
setCursorOffset(cursorOffset + text.length)
|
|
}
|
|
|
|
const doublePressEscFromEmpty = useDoublePress(
|
|
() => {},
|
|
() => onShowMessageSelector(),
|
|
)
|
|
|
|
// Function to get the queued command for editing. Returns true if commands were popped.
|
|
const popAllCommandsFromQueue = useCallback((): boolean => {
|
|
const result = popAllEditable(input, cursorOffset)
|
|
if (!result) {
|
|
return false
|
|
}
|
|
|
|
trackAndSetInput(result.text)
|
|
onModeChange('prompt') // Always prompt mode for queued commands
|
|
setCursorOffset(result.cursorOffset)
|
|
|
|
// Restore images from queued commands to pastedContents
|
|
if (result.images.length > 0) {
|
|
setPastedContents(prev => {
|
|
const newContents = { ...prev }
|
|
for (const image of result.images) {
|
|
newContents[image.id] = image
|
|
}
|
|
return newContents
|
|
})
|
|
}
|
|
|
|
return true
|
|
}, [trackAndSetInput, onModeChange, input, cursorOffset, setPastedContents])
|
|
|
|
// Insert the at-mentioned reference (the file and, optionally, a line range) when
|
|
// we receive an at-mentioned notification the IDE.
|
|
const onIdeAtMentioned = function (atMentioned: IDEAtMentioned) {
|
|
logEvent('tengu_ext_at_mentioned', {})
|
|
let atMentionedText: string
|
|
const relativePath = path.relative(getCwd(), atMentioned.filePath)
|
|
if (atMentioned.lineStart && atMentioned.lineEnd) {
|
|
atMentionedText =
|
|
atMentioned.lineStart === atMentioned.lineEnd
|
|
? `@${relativePath}#L${atMentioned.lineStart} `
|
|
: `@${relativePath}#L${atMentioned.lineStart}-${atMentioned.lineEnd} `
|
|
} else {
|
|
atMentionedText = `@${relativePath} `
|
|
}
|
|
const cursorChar = input[cursorOffset - 1] ?? ' '
|
|
if (!/\s/.test(cursorChar)) {
|
|
atMentionedText = ` ${atMentionedText}`
|
|
}
|
|
insertTextAtCursor(atMentionedText)
|
|
}
|
|
useIdeAtMentioned(mcpClients, onIdeAtMentioned)
|
|
|
|
// Handler for chat:undo - undo last edit
|
|
const handleUndo = useCallback(() => {
|
|
if (canUndo) {
|
|
const previousState = undo()
|
|
if (previousState) {
|
|
trackAndSetInput(previousState.text)
|
|
setCursorOffset(previousState.cursorOffset)
|
|
setPastedContents(previousState.pastedContents)
|
|
}
|
|
}
|
|
}, [canUndo, undo, trackAndSetInput, setPastedContents])
|
|
|
|
// Handler for chat:newline - insert a newline at the cursor position
|
|
const handleNewline = useCallback(() => {
|
|
pushToBuffer(input, cursorOffset, pastedContents)
|
|
const newInput =
|
|
input.slice(0, cursorOffset) + '\n' + input.slice(cursorOffset)
|
|
trackAndSetInput(newInput)
|
|
setCursorOffset(cursorOffset + 1)
|
|
}, [
|
|
input,
|
|
cursorOffset,
|
|
trackAndSetInput,
|
|
setCursorOffset,
|
|
pushToBuffer,
|
|
pastedContents,
|
|
])
|
|
|
|
// Handler for chat:externalEditor - edit in $EDITOR
|
|
const handleExternalEditor = useCallback(async () => {
|
|
logEvent('tengu_external_editor_used', {})
|
|
setIsExternalEditorActive(true)
|
|
|
|
try {
|
|
// Pass pastedContents to expand collapsed text references
|
|
const result = await editPromptInEditor(input, pastedContents)
|
|
|
|
if (result.error) {
|
|
addNotification({
|
|
key: 'external-editor-error',
|
|
text: result.error,
|
|
color: 'warning',
|
|
priority: 'high',
|
|
})
|
|
}
|
|
|
|
if (result.content !== null && result.content !== input) {
|
|
// Push current state to buffer before making changes
|
|
pushToBuffer(input, cursorOffset, pastedContents)
|
|
|
|
trackAndSetInput(result.content)
|
|
setCursorOffset(result.content.length)
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof Error) {
|
|
logError(err)
|
|
}
|
|
addNotification({
|
|
key: 'external-editor-error',
|
|
text: `External editor failed: ${errorMessage(err)}`,
|
|
color: 'warning',
|
|
priority: 'high',
|
|
})
|
|
} finally {
|
|
setIsExternalEditorActive(false)
|
|
}
|
|
}, [
|
|
input,
|
|
cursorOffset,
|
|
pastedContents,
|
|
pushToBuffer,
|
|
trackAndSetInput,
|
|
addNotification,
|
|
])
|
|
|
|
// Handler for chat:stash - stash/unstash prompt
|
|
const handleStash = useCallback(() => {
|
|
if (input.trim() === '' && stashedPrompt !== undefined) {
|
|
// Pop stash when input is empty
|
|
trackAndSetInput(stashedPrompt.text)
|
|
setCursorOffset(stashedPrompt.cursorOffset)
|
|
setPastedContents(stashedPrompt.pastedContents)
|
|
setStashedPrompt(undefined)
|
|
} else if (input.trim() !== '') {
|
|
// Push to stash (save text, cursor position, and pasted contents)
|
|
setStashedPrompt({ text: input, cursorOffset, pastedContents })
|
|
trackAndSetInput('')
|
|
setCursorOffset(0)
|
|
setPastedContents({})
|
|
// Track usage for /discover and stop showing hint
|
|
saveGlobalConfig(c => {
|
|
if (c.hasUsedStash) return c
|
|
return { ...c, hasUsedStash: true }
|
|
})
|
|
}
|
|
}, [
|
|
input,
|
|
cursorOffset,
|
|
stashedPrompt,
|
|
trackAndSetInput,
|
|
setStashedPrompt,
|
|
pastedContents,
|
|
setPastedContents,
|
|
])
|
|
|
|
// Handler for chat:modelPicker - toggle model picker
|
|
const handleModelPicker = useCallback(() => {
|
|
setShowModelPicker(prev => !prev)
|
|
if (helpOpen) {
|
|
setHelpOpen(false)
|
|
}
|
|
}, [helpOpen])
|
|
|
|
// Handler for chat:fastMode - toggle fast mode picker
|
|
const handleFastModePicker = useCallback(() => {
|
|
setShowFastModePicker(prev => !prev)
|
|
if (helpOpen) {
|
|
setHelpOpen(false)
|
|
}
|
|
}, [helpOpen])
|
|
|
|
// Handler for chat:thinkingToggle - toggle thinking mode
|
|
const handleThinkingToggle = useCallback(() => {
|
|
setShowThinkingToggle(prev => !prev)
|
|
if (helpOpen) {
|
|
setHelpOpen(false)
|
|
}
|
|
}, [helpOpen])
|
|
|
|
// Handler for chat:cycleMode - cycle through permission modes
|
|
const handleCycleMode = useCallback(() => {
|
|
// When viewing a teammate, cycle their mode instead of the leader's
|
|
if (isAgentSwarmsEnabled() && viewedTeammate && viewingAgentTaskId) {
|
|
const teammateContext: ToolPermissionContext = {
|
|
...toolPermissionContext,
|
|
mode: viewedTeammate.permissionMode,
|
|
}
|
|
// Pass undefined for teamContext (unused but kept for API compatibility)
|
|
const nextMode = getNextPermissionMode(teammateContext, undefined)
|
|
|
|
logEvent('tengu_mode_cycle', {
|
|
to: nextMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
|
|
const teammateTaskId = viewingAgentTaskId
|
|
setAppState(prev => {
|
|
const task = prev.tasks[teammateTaskId]
|
|
if (!task || task.type !== 'in_process_teammate') {
|
|
return prev
|
|
}
|
|
if (task.permissionMode === nextMode) {
|
|
return prev
|
|
}
|
|
return {
|
|
...prev,
|
|
tasks: {
|
|
...prev.tasks,
|
|
[teammateTaskId]: {
|
|
...task,
|
|
permissionMode: nextMode,
|
|
},
|
|
},
|
|
}
|
|
})
|
|
|
|
if (helpOpen) {
|
|
setHelpOpen(false)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Compute the next mode without triggering side effects first
|
|
logForDebugging(
|
|
`[auto-mode] handleCycleMode: currentMode=${toolPermissionContext.mode}`,
|
|
)
|
|
const nextMode = getNextPermissionMode(toolPermissionContext, teamContext)
|
|
|
|
// Call cyclePermissionMode to apply side effects (e.g. strip
|
|
// dangerous permissions, activate classifier)
|
|
const { context: preparedContext } = cyclePermissionMode(
|
|
toolPermissionContext,
|
|
teamContext,
|
|
)
|
|
|
|
logEvent('tengu_mode_cycle', {
|
|
to: nextMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
|
|
// Track when user enters plan mode
|
|
if (nextMode === 'plan') {
|
|
saveGlobalConfig(current => ({
|
|
...current,
|
|
lastPlanModeUse: Date.now(),
|
|
}))
|
|
}
|
|
|
|
// Set the mode via setAppState directly because setToolPermissionContext
|
|
// intentionally preserves the existing mode (to prevent coordinator mode
|
|
// corruption from workers). Then call setToolPermissionContext to trigger
|
|
// recheck of queued permission prompts.
|
|
setAppState(prev => ({
|
|
...prev,
|
|
toolPermissionContext: {
|
|
...preparedContext,
|
|
mode: nextMode,
|
|
},
|
|
}))
|
|
setToolPermissionContext({
|
|
...preparedContext,
|
|
mode: nextMode,
|
|
})
|
|
|
|
// If this is a teammate, update config.json so team lead sees the change
|
|
syncTeammateMode(nextMode, teamContext?.teamName)
|
|
|
|
// Close help tips if they're open when mode is cycled
|
|
if (helpOpen) {
|
|
setHelpOpen(false)
|
|
}
|
|
}, [
|
|
toolPermissionContext,
|
|
teamContext,
|
|
viewedTeammate,
|
|
setAppState,
|
|
setToolPermissionContext,
|
|
helpOpen,
|
|
])
|
|
|
|
// Handler for chat:imagePaste - paste image from clipboard
|
|
const handleImagePaste = useCallback(() => {
|
|
void getImageFromClipboard().then(imageData => {
|
|
if (imageData) {
|
|
onImagePaste(imageData.base64, imageData.mediaType)
|
|
} else {
|
|
const shortcutDisplay = getShortcutDisplay(
|
|
'chat:imagePaste',
|
|
'Chat',
|
|
'ctrl+v',
|
|
)
|
|
const message = env.isSSH()
|
|
? "No image found in clipboard. You're SSH'd; try scp?"
|
|
: `No image found in clipboard. Use ${shortcutDisplay} to paste images.`
|
|
addNotification({
|
|
key: 'no-image-in-clipboard',
|
|
text: message,
|
|
priority: 'immediate',
|
|
timeoutMs: 1000,
|
|
})
|
|
}
|
|
})
|
|
}, [addNotification, onImagePaste])
|
|
|
|
// Register chat:submit handler directly in the handler registry (not via
|
|
// useKeybindings) so that only the ChordInterceptor can invoke it for chord
|
|
// completions (e.g., "ctrl+e s"). The default Enter binding for submit is
|
|
// handled by TextInput directly (via onSubmit prop) and useTypeahead (for
|
|
// autocomplete acceptance). Using useKeybindings would cause
|
|
// stopImmediatePropagation on Enter, blocking autocomplete from seeing the key.
|
|
const keybindingContext = useOptionalKeybindingContext()
|
|
useEffect(() => {
|
|
if (!keybindingContext || isModalOverlayActive) return
|
|
return keybindingContext.registerHandler({
|
|
action: 'chat:submit',
|
|
context: 'Chat',
|
|
handler: () => {
|
|
void onSubmit(input)
|
|
},
|
|
})
|
|
}, [keybindingContext, isModalOverlayActive, onSubmit, input])
|
|
|
|
// Chat context keybindings for editing shortcuts
|
|
// Note: history:previous/history:next are NOT handled here. They are passed as
|
|
// onHistoryUp/onHistoryDown props to TextInput, so that useTextInput's
|
|
// upOrHistoryUp/downOrHistoryDown can try cursor movement first and only
|
|
// fall through to history when the cursor can't move further.
|
|
const chatHandlers = useMemo(
|
|
() => ({
|
|
'chat:undo': handleUndo,
|
|
'chat:newline': handleNewline,
|
|
'chat:externalEditor': handleExternalEditor,
|
|
'chat:stash': handleStash,
|
|
'chat:modelPicker': handleModelPicker,
|
|
'chat:thinkingToggle': handleThinkingToggle,
|
|
'chat:cycleMode': handleCycleMode,
|
|
'chat:imagePaste': handleImagePaste,
|
|
}),
|
|
[
|
|
handleUndo,
|
|
handleNewline,
|
|
handleExternalEditor,
|
|
handleStash,
|
|
handleModelPicker,
|
|
handleThinkingToggle,
|
|
handleCycleMode,
|
|
handleImagePaste,
|
|
],
|
|
)
|
|
|
|
useKeybindings(chatHandlers, {
|
|
context: 'Chat',
|
|
isActive: !isModalOverlayActive,
|
|
})
|
|
|
|
// Shift+↑ enters message-actions cursor. Separate isActive so ctrl+r search
|
|
// doesn't leave stale isSearchingHistory on cursor-exit remount.
|
|
useKeybinding('chat:messageActions', () => onMessageActionsEnter?.(), {
|
|
context: 'Chat',
|
|
isActive: !isModalOverlayActive && !isSearchingHistory,
|
|
})
|
|
|
|
// Fast mode keybinding is only active when fast mode is enabled and available
|
|
useKeybinding('chat:fastMode', handleFastModePicker, {
|
|
context: 'Chat',
|
|
isActive:
|
|
!isModalOverlayActive && isFastModeEnabled() && isFastModeAvailable(),
|
|
})
|
|
|
|
// Handle help:dismiss keybinding (ESC closes help menu)
|
|
// This is registered separately from Chat context so it has priority over
|
|
// CancelRequestHandler when help menu is open
|
|
useKeybinding(
|
|
'help:dismiss',
|
|
() => {
|
|
setHelpOpen(false)
|
|
},
|
|
{ context: 'Help', isActive: helpOpen },
|
|
)
|
|
|
|
// Quick Open / Global Search. Hook calls are unconditional (Rules of Hooks);
|
|
// the handler body is feature()-gated so the setState calls and component
|
|
// references get tree-shaken in external builds.
|
|
const quickSearchActive = feature('QUICK_SEARCH')
|
|
? !isModalOverlayActive
|
|
: false
|
|
useKeybinding(
|
|
'app:quickOpen',
|
|
() => {
|
|
if (feature('QUICK_SEARCH')) {
|
|
setShowQuickOpen(true)
|
|
setHelpOpen(false)
|
|
}
|
|
},
|
|
{ context: 'Global', isActive: quickSearchActive },
|
|
)
|
|
useKeybinding(
|
|
'app:globalSearch',
|
|
() => {
|
|
if (feature('QUICK_SEARCH')) {
|
|
setShowGlobalSearch(true)
|
|
setHelpOpen(false)
|
|
}
|
|
},
|
|
{ context: 'Global', isActive: quickSearchActive },
|
|
)
|
|
|
|
useKeybinding(
|
|
'history:search',
|
|
() => {
|
|
if (feature('HISTORY_PICKER')) {
|
|
setShowHistoryPicker(true)
|
|
setHelpOpen(false)
|
|
}
|
|
},
|
|
{
|
|
context: 'Global',
|
|
isActive: feature('HISTORY_PICKER') ? !isModalOverlayActive : false,
|
|
},
|
|
)
|
|
|
|
// Handle Ctrl+C to abort speculation when idle (not loading)
|
|
// CancelRequestHandler only handles Ctrl+C during active tasks
|
|
useKeybinding(
|
|
'app:interrupt',
|
|
() => {
|
|
abortSpeculation(setAppState)
|
|
},
|
|
{
|
|
context: 'Global',
|
|
isActive: !isLoading && speculation.status === 'active',
|
|
},
|
|
)
|
|
|
|
// Footer indicator navigation keybindings. ↑/↓ live here (not in
|
|
// handleHistoryUp/Down) because TextInput focus=false when a pill is
|
|
// selected — its useInput is inactive, so this is the only path.
|
|
useKeybindings(
|
|
{
|
|
'footer:up': () => {
|
|
// ↑ scrolls within the coordinator task list before leaving the pill
|
|
if (
|
|
tasksSelected &&
|
|
process.env.USER_TYPE === 'ant' &&
|
|
coordinatorTaskCount > 0 &&
|
|
coordinatorTaskIndex > minCoordinatorIndex
|
|
) {
|
|
setCoordinatorTaskIndex(prev => prev - 1)
|
|
return
|
|
}
|
|
navigateFooter(-1, true)
|
|
},
|
|
'footer:down': () => {
|
|
// ↓ scrolls within the coordinator task list, never leaves the pill
|
|
if (
|
|
tasksSelected &&
|
|
process.env.USER_TYPE === 'ant' &&
|
|
coordinatorTaskCount > 0
|
|
) {
|
|
if (coordinatorTaskIndex < coordinatorTaskCount - 1) {
|
|
setCoordinatorTaskIndex(prev => prev + 1)
|
|
}
|
|
return
|
|
}
|
|
if (tasksSelected && !isTeammateMode) {
|
|
setShowBashesDialog(true)
|
|
selectFooterItem(null)
|
|
return
|
|
}
|
|
navigateFooter(1)
|
|
},
|
|
'footer:next': () => {
|
|
// Teammate mode: ←/→ cycles within the team member list
|
|
if (tasksSelected && isTeammateMode) {
|
|
const totalAgents = 1 + inProcessTeammates.length
|
|
setTeammateFooterIndex(prev => (prev + 1) % totalAgents)
|
|
return
|
|
}
|
|
navigateFooter(1)
|
|
},
|
|
'footer:previous': () => {
|
|
if (tasksSelected && isTeammateMode) {
|
|
const totalAgents = 1 + inProcessTeammates.length
|
|
setTeammateFooterIndex(prev => (prev - 1 + totalAgents) % totalAgents)
|
|
return
|
|
}
|
|
navigateFooter(-1)
|
|
},
|
|
'footer:openSelected': () => {
|
|
if (viewSelectionMode === 'selecting-agent') {
|
|
return
|
|
}
|
|
switch (footerItemSelected) {
|
|
case 'companion':
|
|
if (feature('BUDDY')) {
|
|
selectFooterItem(null)
|
|
void onSubmit('/buddy')
|
|
}
|
|
break
|
|
case 'tasks':
|
|
if (isTeammateMode) {
|
|
// Enter switches to the selected agent's view
|
|
if (teammateFooterIndex === 0) {
|
|
exitTeammateView(setAppState)
|
|
} else {
|
|
const teammate = inProcessTeammates[teammateFooterIndex - 1]
|
|
if (teammate) enterTeammateView(teammate.id, setAppState)
|
|
}
|
|
} else if (coordinatorTaskIndex === 0 && coordinatorTaskCount > 0) {
|
|
exitTeammateView(setAppState)
|
|
} else {
|
|
const selectedTaskId =
|
|
getVisibleAgentTasks(tasks)[coordinatorTaskIndex - 1]?.id
|
|
if (selectedTaskId) {
|
|
enterTeammateView(selectedTaskId, setAppState)
|
|
} else {
|
|
setShowBashesDialog(true)
|
|
selectFooterItem(null)
|
|
}
|
|
}
|
|
break
|
|
case 'tmux':
|
|
if (process.env.USER_TYPE === 'ant') {
|
|
setAppState(prev =>
|
|
prev.tungstenPanelAutoHidden
|
|
? { ...prev, tungstenPanelAutoHidden: false }
|
|
: {
|
|
...prev,
|
|
tungstenPanelVisible: !(
|
|
prev.tungstenPanelVisible ?? true
|
|
),
|
|
},
|
|
)
|
|
}
|
|
break
|
|
case 'bagel':
|
|
break
|
|
case 'teams':
|
|
setShowTeamsDialog(true)
|
|
selectFooterItem(null)
|
|
break
|
|
case 'bridge':
|
|
setShowBridgeDialog(true)
|
|
selectFooterItem(null)
|
|
break
|
|
}
|
|
},
|
|
'footer:clearSelection': () => {
|
|
selectFooterItem(null)
|
|
},
|
|
'footer:close': () => {
|
|
if (tasksSelected && coordinatorTaskIndex >= 1) {
|
|
const task = getVisibleAgentTasks(tasks)[coordinatorTaskIndex - 1]
|
|
if (!task) return false
|
|
// When the selected row IS the viewed agent, 'x' types into the
|
|
// steering input. Any other row — dismiss it.
|
|
if (
|
|
viewSelectionMode === 'viewing-agent' &&
|
|
task.id === viewingAgentTaskId
|
|
) {
|
|
onChange(
|
|
input.slice(0, cursorOffset) + 'x' + input.slice(cursorOffset),
|
|
)
|
|
setCursorOffset(cursorOffset + 1)
|
|
return
|
|
}
|
|
stopOrDismissAgent(task.id, setAppState)
|
|
if (task.status !== 'running') {
|
|
setCoordinatorTaskIndex(i => Math.max(minCoordinatorIndex, i - 1))
|
|
}
|
|
return
|
|
}
|
|
// Not handled — let 'x' fall through to type-to-exit
|
|
return false
|
|
},
|
|
},
|
|
{
|
|
context: 'Footer',
|
|
isActive: !!footerItemSelected && !isModalOverlayActive,
|
|
},
|
|
)
|
|
|
|
useInput((char, key) => {
|
|
// Skip all input handling when a full-screen dialog is open. These dialogs
|
|
// render via early return, but hooks run unconditionally — so without this
|
|
// guard, Escape inside a dialog leaks to the double-press message-selector.
|
|
if (
|
|
showTeamsDialog ||
|
|
showQuickOpen ||
|
|
showGlobalSearch ||
|
|
showHistoryPicker
|
|
) {
|
|
return
|
|
}
|
|
|
|
// Detect failed Alt shortcuts on macOS (Option key produces special characters)
|
|
if (getPlatform() === 'macos' && isMacosOptionChar(char)) {
|
|
const shortcut = MACOS_OPTION_SPECIAL_CHARS[char]
|
|
const terminalName = getNativeCSIuTerminalDisplayName()
|
|
const jsx = terminalName ? (
|
|
<Text dimColor>
|
|
To enable {shortcut}, set <Text bold>Option as Meta</Text> in{' '}
|
|
{terminalName} preferences (⌘,)
|
|
</Text>
|
|
) : (
|
|
<Text dimColor>To enable {shortcut}, run /terminal-setup</Text>
|
|
)
|
|
addNotification({
|
|
key: 'option-meta-hint',
|
|
jsx,
|
|
priority: 'immediate',
|
|
timeoutMs: 5000,
|
|
})
|
|
// Don't return - let the character be typed so user sees the issue
|
|
}
|
|
|
|
// Footer navigation is handled via useKeybindings above (Footer context)
|
|
|
|
// NOTE: ctrl+_, ctrl+g, ctrl+s are handled via Chat context keybindings above
|
|
|
|
// Type-to-exit footer: printable chars while a pill is selected refocus
|
|
// the input and type the char. Nav keys are captured by useKeybindings
|
|
// above, so anything reaching here is genuinely not a footer action.
|
|
// onChange clears footerSelection, so no explicit deselect.
|
|
if (
|
|
footerItemSelected &&
|
|
char &&
|
|
!key.ctrl &&
|
|
!key.meta &&
|
|
!key.escape &&
|
|
!key.return
|
|
) {
|
|
onChange(input.slice(0, cursorOffset) + char + input.slice(cursorOffset))
|
|
setCursorOffset(cursorOffset + char.length)
|
|
return
|
|
}
|
|
|
|
// Exit special modes when backspace/escape/delete/ctrl+u is pressed at cursor position 0
|
|
if (
|
|
cursorOffset === 0 &&
|
|
(key.escape || key.backspace || key.delete || (key.ctrl && char === 'u'))
|
|
) {
|
|
onModeChange('prompt')
|
|
setHelpOpen(false)
|
|
}
|
|
|
|
// Exit help mode when backspace is pressed and input is empty
|
|
if (helpOpen && input === '' && (key.backspace || key.delete)) {
|
|
setHelpOpen(false)
|
|
}
|
|
|
|
// esc is a little overloaded:
|
|
// - when we're loading a response, it's used to cancel the request
|
|
// - otherwise, it's used to show the message selector
|
|
// - when double pressed, it's used to clear the input
|
|
// - when input is empty, pop from command queue
|
|
|
|
// Handle ESC key press
|
|
if (key.escape) {
|
|
// Abort active speculation
|
|
if (speculation.status === 'active') {
|
|
abortSpeculation(setAppState)
|
|
return
|
|
}
|
|
|
|
// Dismiss side question response if visible
|
|
if (isSideQuestionVisible && onDismissSideQuestion) {
|
|
onDismissSideQuestion()
|
|
return
|
|
}
|
|
|
|
// Close help menu if open
|
|
if (helpOpen) {
|
|
setHelpOpen(false)
|
|
return
|
|
}
|
|
|
|
// Footer selection clearing is now handled via Footer context keybindings
|
|
// (footer:clearSelection action bound to escape)
|
|
// If a footer item is selected, let the Footer keybinding handle it
|
|
if (footerItemSelected) {
|
|
return
|
|
}
|
|
|
|
// If there's an editable queued command, move it to the input for editing when ESC is pressed
|
|
const hasEditableCommand = queuedCommands.some(isQueuedCommandEditable)
|
|
if (hasEditableCommand) {
|
|
void popAllCommandsFromQueue()
|
|
return
|
|
}
|
|
|
|
if (messages.length > 0 && !input && !isLoading) {
|
|
doublePressEscFromEmpty()
|
|
}
|
|
}
|
|
|
|
if (key.return && helpOpen) {
|
|
setHelpOpen(false)
|
|
}
|
|
})
|
|
|
|
const swarmBanner = useSwarmBanner()
|
|
|
|
const fastModeCooldown = isFastModeEnabled() ? isFastModeCooldown() : false
|
|
const showFastIcon = isFastModeEnabled()
|
|
? isFastMode && (isFastModeAvailable() || fastModeCooldown)
|
|
: false
|
|
|
|
const showFastIconHint = useShowFastIconHint(showFastIcon ?? false)
|
|
|
|
// Show effort notification on startup and when effort changes.
|
|
// Suppressed in brief/assistant mode — the value reflects the local
|
|
// client's effort, not the connected agent's.
|
|
const effortNotificationText = briefOwnsGap
|
|
? undefined
|
|
: getEffortNotificationText(effortValue, mainLoopModel)
|
|
useEffect(() => {
|
|
if (!effortNotificationText) {
|
|
removeNotification('effort-level')
|
|
return
|
|
}
|
|
addNotification({
|
|
key: 'effort-level',
|
|
text: effortNotificationText,
|
|
priority: 'high',
|
|
timeoutMs: 12_000,
|
|
})
|
|
}, [effortNotificationText, addNotification, removeNotification])
|
|
|
|
useBuddyNotification()
|
|
|
|
const companionSpeaking = feature('BUDDY')
|
|
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
|
useAppState(s => s.companionReaction !== undefined)
|
|
: false
|
|
const { columns, rows } = useTerminalSize()
|
|
const textInputColumns =
|
|
columns - 3 - companionReservedColumns(columns, companionSpeaking)
|
|
|
|
// POC: click-to-position-cursor. Mouse tracking is only enabled inside
|
|
// <AlternateScreen>, so this is dormant in the normal main-screen REPL.
|
|
// localCol/localRow are relative to the onClick Box's top-left; the Box
|
|
// tightly wraps the text input so they map directly to (column, line)
|
|
// in the Cursor wrap model. MeasuredText.getOffsetFromPosition handles
|
|
// wide chars, wrapped lines, and clamps past-end clicks to line end.
|
|
const maxVisibleLines = isFullscreenEnvEnabled()
|
|
? Math.max(
|
|
MIN_INPUT_VIEWPORT_LINES,
|
|
Math.floor(rows / 2) - PROMPT_FOOTER_LINES,
|
|
)
|
|
: undefined
|
|
|
|
const handleInputClick = useCallback(
|
|
(e: ClickEvent) => {
|
|
// During history search the displayed text is historyMatch, not
|
|
// input, and showCursor is false anyway — skip rather than
|
|
// compute an offset against the wrong string.
|
|
if (!input || isSearchingHistory) return
|
|
const c = Cursor.fromText(input, textInputColumns, cursorOffset)
|
|
const viewportStart = c.getViewportStartLine(maxVisibleLines)
|
|
const offset = c.measuredText.getOffsetFromPosition({
|
|
line: e.localRow + viewportStart,
|
|
column: e.localCol,
|
|
})
|
|
setCursorOffset(offset)
|
|
},
|
|
[
|
|
input,
|
|
textInputColumns,
|
|
isSearchingHistory,
|
|
cursorOffset,
|
|
maxVisibleLines,
|
|
],
|
|
)
|
|
|
|
const handleOpenTasksDialog = useCallback(
|
|
(taskId?: string) => setShowBashesDialog(taskId ?? true),
|
|
[setShowBashesDialog],
|
|
)
|
|
|
|
const placeholder =
|
|
showPromptSuggestion && promptSuggestion
|
|
? promptSuggestion
|
|
: defaultPlaceholder
|
|
|
|
// Calculate if input has multiple lines
|
|
const isInputWrapped = useMemo(() => input.includes('\n'), [input])
|
|
|
|
// Memoized callbacks for model picker to prevent re-renders when unrelated
|
|
// state (like notifications) changes. This prevents the inline model picker
|
|
// from visually "jumping" when notifications arrive.
|
|
const handleModelSelect = useCallback(
|
|
(model: string | null, _effort: EffortLevel | undefined) => {
|
|
let wasFastModeDisabled = false
|
|
setAppState(prev => {
|
|
wasFastModeDisabled =
|
|
isFastModeEnabled() &&
|
|
!isFastModeSupportedByModel(model) &&
|
|
!!prev.fastMode
|
|
return {
|
|
...prev,
|
|
mainLoopModel: model,
|
|
mainLoopModelForSession: null,
|
|
// Turn off fast mode if switching to a model that doesn't support it
|
|
...(wasFastModeDisabled && { fastMode: false }),
|
|
}
|
|
})
|
|
setShowModelPicker(false)
|
|
const effectiveFastMode = (isFastMode ?? false) && !wasFastModeDisabled
|
|
let message = `Model set to ${modelDisplayString(model)}`
|
|
if (
|
|
isBilledAsExtraUsage(model, effectiveFastMode, isOpus1mMergeEnabled())
|
|
) {
|
|
message += ' · Billed as extra usage'
|
|
}
|
|
if (wasFastModeDisabled) {
|
|
message += ' · Fast mode OFF'
|
|
}
|
|
addNotification({
|
|
key: 'model-switched',
|
|
jsx: <Text>{message}</Text>,
|
|
priority: 'immediate',
|
|
timeoutMs: 3000,
|
|
})
|
|
logEvent('tengu_model_picker_hotkey', {
|
|
model:
|
|
model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
},
|
|
[setAppState, addNotification, isFastMode],
|
|
)
|
|
|
|
const handleModelCancel = useCallback(() => {
|
|
setShowModelPicker(false)
|
|
}, [])
|
|
|
|
// Memoize the model picker element to prevent unnecessary re-renders
|
|
// when AppState changes for unrelated reasons (e.g., notifications arriving)
|
|
const modelPickerElement = useMemo(() => {
|
|
if (!showModelPicker) return null
|
|
return (
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<ModelPicker
|
|
initial={mainLoopModel_}
|
|
sessionModel={mainLoopModelForSession}
|
|
onSelect={handleModelSelect}
|
|
onCancel={handleModelCancel}
|
|
isStandaloneCommand
|
|
showFastModeNotice={
|
|
isFastModeEnabled() &&
|
|
isFastMode &&
|
|
isFastModeSupportedByModel(mainLoopModel_) &&
|
|
isFastModeAvailable()
|
|
}
|
|
/>
|
|
</Box>
|
|
)
|
|
}, [
|
|
showModelPicker,
|
|
mainLoopModel_,
|
|
mainLoopModelForSession,
|
|
handleModelSelect,
|
|
handleModelCancel,
|
|
])
|
|
|
|
const handleFastModeSelect = useCallback(
|
|
(result?: string) => {
|
|
setShowFastModePicker(false)
|
|
if (result) {
|
|
addNotification({
|
|
key: 'fast-mode-toggled',
|
|
jsx: <Text>{result}</Text>,
|
|
priority: 'immediate',
|
|
timeoutMs: 3000,
|
|
})
|
|
}
|
|
},
|
|
[addNotification],
|
|
)
|
|
|
|
// Memoize the fast mode picker element
|
|
const fastModePickerElement = useMemo(() => {
|
|
if (!showFastModePicker) return null
|
|
return (
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<FastModePicker
|
|
onDone={handleFastModeSelect}
|
|
unavailableReason={getFastModeUnavailableReason()}
|
|
/>
|
|
</Box>
|
|
)
|
|
}, [showFastModePicker, handleFastModeSelect])
|
|
|
|
// Memoized callbacks for thinking toggle
|
|
const handleThinkingSelect = useCallback(
|
|
(enabled: boolean) => {
|
|
setAppState(prev => ({
|
|
...prev,
|
|
thinkingEnabled: enabled,
|
|
}))
|
|
setShowThinkingToggle(false)
|
|
logEvent('tengu_thinking_toggled_hotkey', { enabled })
|
|
addNotification({
|
|
key: 'thinking-toggled-hotkey',
|
|
jsx: (
|
|
<Text color={enabled ? 'suggestion' : undefined} dimColor={!enabled}>
|
|
Thinking {enabled ? 'on' : 'off'}
|
|
</Text>
|
|
),
|
|
priority: 'immediate',
|
|
timeoutMs: 3000,
|
|
})
|
|
},
|
|
[setAppState, addNotification],
|
|
)
|
|
|
|
const handleThinkingCancel = useCallback(() => {
|
|
setShowThinkingToggle(false)
|
|
}, [])
|
|
|
|
// Memoize the thinking toggle element
|
|
const thinkingToggleElement = useMemo(() => {
|
|
if (!showThinkingToggle) return null
|
|
return (
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<ThinkingToggle
|
|
currentValue={thinkingEnabled ?? true}
|
|
onSelect={handleThinkingSelect}
|
|
onCancel={handleThinkingCancel}
|
|
isMidConversation={messages.some(m => m.type === 'assistant')}
|
|
/>
|
|
</Box>
|
|
)
|
|
}, [
|
|
showThinkingToggle,
|
|
thinkingEnabled,
|
|
handleThinkingSelect,
|
|
handleThinkingCancel,
|
|
messages.length,
|
|
])
|
|
|
|
// Portal dialog to DialogOverlay in fullscreen so it escapes the bottom
|
|
// slot's overflowY:hidden clip (same pattern as SuggestionsOverlay).
|
|
// Must be called before early returns below to satisfy rules-of-hooks.
|
|
useSetPromptOverlayDialog(null)
|
|
|
|
if (showBashesDialog) {
|
|
return (
|
|
<BackgroundTasksDialog
|
|
onDone={() => setShowBashesDialog(false)}
|
|
toolUseContext={getToolUseContext(
|
|
messages,
|
|
[],
|
|
new AbortController(),
|
|
mainLoopModel,
|
|
)}
|
|
initialDetailTaskId={
|
|
typeof showBashesDialog === 'string' ? showBashesDialog : undefined
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (isAgentSwarmsEnabled() && showTeamsDialog) {
|
|
return (
|
|
<TeamsDialog
|
|
initialTeams={cachedTeams}
|
|
onDone={() => {
|
|
setShowTeamsDialog(false)
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (feature('QUICK_SEARCH')) {
|
|
const insertWithSpacing = (text: string) => {
|
|
const cursorChar = input[cursorOffset - 1] ?? ' '
|
|
insertTextAtCursor(/\s/.test(cursorChar) ? text : ` ${text}`)
|
|
}
|
|
if (showQuickOpen) {
|
|
return (
|
|
<QuickOpenDialog
|
|
onDone={() => setShowQuickOpen(false)}
|
|
onInsert={insertWithSpacing}
|
|
/>
|
|
)
|
|
}
|
|
if (showGlobalSearch) {
|
|
return (
|
|
<GlobalSearchDialog
|
|
onDone={() => setShowGlobalSearch(false)}
|
|
onInsert={insertWithSpacing}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
|
|
if (feature('HISTORY_PICKER') && showHistoryPicker) {
|
|
return (
|
|
<HistorySearchDialog
|
|
initialQuery={input}
|
|
onSelect={entry => {
|
|
const entryMode = getModeFromInput(entry.display)
|
|
const value = getValueFromInput(entry.display)
|
|
onModeChange(entryMode)
|
|
trackAndSetInput(value)
|
|
setPastedContents(entry.pastedContents)
|
|
setCursorOffset(value.length)
|
|
setShowHistoryPicker(false)
|
|
}}
|
|
onCancel={() => setShowHistoryPicker(false)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Show loop mode menu when requested (ant-only, eliminated from external builds)
|
|
if (modelPickerElement) {
|
|
return modelPickerElement
|
|
}
|
|
|
|
if (fastModePickerElement) {
|
|
return fastModePickerElement
|
|
}
|
|
|
|
if (thinkingToggleElement) {
|
|
return thinkingToggleElement
|
|
}
|
|
|
|
if (showBridgeDialog) {
|
|
return (
|
|
<BridgeDialog
|
|
onDone={() => {
|
|
setShowBridgeDialog(false)
|
|
selectFooterItem(null)
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const baseProps: BaseTextInputProps = {
|
|
multiline: true,
|
|
onSubmit,
|
|
onChange,
|
|
value: historyMatch
|
|
? getValueFromInput(
|
|
typeof historyMatch === 'string'
|
|
? historyMatch
|
|
: historyMatch.display,
|
|
)
|
|
: input,
|
|
// History navigation is handled via TextInput props (onHistoryUp/onHistoryDown),
|
|
// NOT via useKeybindings. This allows useTextInput's upOrHistoryUp/downOrHistoryDown
|
|
// to try cursor movement first and only fall through to history navigation when the
|
|
// cursor can't move further (important for wrapped text and multi-line input).
|
|
onHistoryUp: handleHistoryUp,
|
|
onHistoryDown: handleHistoryDown,
|
|
onHistoryReset: resetHistory,
|
|
placeholder,
|
|
onExit,
|
|
onExitMessage: (show, key) => setExitMessage({ show, key }),
|
|
onImagePaste,
|
|
columns: textInputColumns,
|
|
maxVisibleLines,
|
|
disableCursorMovementForUpDownKeys:
|
|
suggestions.length > 0 || !!footerItemSelected,
|
|
disableEscapeDoublePress: suggestions.length > 0,
|
|
cursorOffset,
|
|
onChangeCursorOffset: setCursorOffset,
|
|
onPaste: onTextPaste,
|
|
onIsPastingChange: setIsPasting,
|
|
focus: !isSearchingHistory && !isModalOverlayActive && !footerItemSelected,
|
|
showCursor:
|
|
!footerItemSelected && !isSearchingHistory && !cursorAtImageChip,
|
|
argumentHint: commandArgumentHint,
|
|
onUndo: canUndo
|
|
? () => {
|
|
const previousState = undo()
|
|
if (previousState) {
|
|
trackAndSetInput(previousState.text)
|
|
setCursorOffset(previousState.cursorOffset)
|
|
setPastedContents(previousState.pastedContents)
|
|
}
|
|
}
|
|
: undefined,
|
|
highlights: combinedHighlights,
|
|
inlineGhostText,
|
|
inputFilter: lazySpaceInputFilter,
|
|
}
|
|
|
|
const getBorderColor = (): keyof Theme => {
|
|
const modeColors: Record<string, keyof Theme> = {
|
|
bash: 'bashBorder',
|
|
}
|
|
|
|
// Mode colors take priority, then teammate color, then default
|
|
if (modeColors[mode]) {
|
|
return modeColors[mode]
|
|
}
|
|
|
|
// In-process teammates run headless - don't apply teammate colors to leader UI
|
|
if (isInProcessTeammate()) {
|
|
return 'promptBorder'
|
|
}
|
|
|
|
// Check for teammate color from environment
|
|
const teammateColorName = getTeammateColor()
|
|
if (
|
|
teammateColorName &&
|
|
AGENT_COLORS.includes(teammateColorName as AgentColorName)
|
|
) {
|
|
return AGENT_COLOR_TO_THEME_COLOR[teammateColorName as AgentColorName]
|
|
}
|
|
|
|
return 'promptBorder'
|
|
}
|
|
|
|
if (isExternalEditorActive) {
|
|
return (
|
|
<Box
|
|
flexDirection="row"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
borderColor={getBorderColor()}
|
|
borderStyle="round"
|
|
borderLeft={false}
|
|
borderRight={false}
|
|
borderBottom
|
|
width="100%"
|
|
>
|
|
<Text dimColor italic>
|
|
Save and close editor to continue...
|
|
</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
const textInputElement = isVimModeEnabled() ? (
|
|
<VimTextInput
|
|
{...baseProps}
|
|
initialMode={vimMode}
|
|
onModeChange={setVimMode}
|
|
/>
|
|
) : (
|
|
<TextInput {...baseProps} />
|
|
)
|
|
|
|
return (
|
|
<Box flexDirection="column" marginTop={briefOwnsGap ? 0 : 1}>
|
|
{!isFullscreenEnvEnabled() && <PromptInputQueuedCommands />}
|
|
{hasSuppressedDialogs && (
|
|
<Box marginTop={1} marginLeft={2}>
|
|
<Text dimColor>Waiting for permission…</Text>
|
|
</Box>
|
|
)}
|
|
<PromptInputStashNotice hasStash={stashedPrompt !== undefined} />
|
|
{swarmBanner ? (
|
|
<>
|
|
<Text color={swarmBanner.bgColor}>
|
|
{swarmBanner.text ? (
|
|
<>
|
|
{'─'.repeat(
|
|
Math.max(0, columns - stringWidth(swarmBanner.text) - 4),
|
|
)}
|
|
<Text backgroundColor={swarmBanner.bgColor} color="inverseText">
|
|
{' '}
|
|
{swarmBanner.text}{' '}
|
|
</Text>
|
|
{'──'}
|
|
</>
|
|
) : (
|
|
'─'.repeat(columns)
|
|
)}
|
|
</Text>
|
|
<Box flexDirection="row" width="100%">
|
|
<PromptInputModeIndicator
|
|
mode={mode}
|
|
isLoading={isLoading}
|
|
viewingAgentName={viewingAgentName}
|
|
viewingAgentColor={viewingAgentColor}
|
|
/>
|
|
<Box flexGrow={1} flexShrink={1} onClick={handleInputClick}>
|
|
{textInputElement}
|
|
</Box>
|
|
</Box>
|
|
<Text color={swarmBanner.bgColor}>{'─'.repeat(columns)}</Text>
|
|
</>
|
|
) : (
|
|
<Box
|
|
flexDirection="row"
|
|
alignItems="flex-start"
|
|
justifyContent="flex-start"
|
|
borderColor={getBorderColor()}
|
|
borderStyle="round"
|
|
borderLeft={false}
|
|
borderRight={false}
|
|
borderBottom
|
|
width="100%"
|
|
borderText={buildBorderText(
|
|
showFastIcon ?? false,
|
|
showFastIconHint,
|
|
fastModeCooldown,
|
|
)}
|
|
>
|
|
<PromptInputModeIndicator
|
|
mode={mode}
|
|
isLoading={isLoading}
|
|
viewingAgentName={viewingAgentName}
|
|
viewingAgentColor={viewingAgentColor}
|
|
/>
|
|
<Box flexGrow={1} flexShrink={1} onClick={handleInputClick}>
|
|
{textInputElement}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
<PromptInputFooter
|
|
apiKeyStatus={apiKeyStatus}
|
|
debug={debug}
|
|
exitMessage={exitMessage}
|
|
vimMode={isVimModeEnabled() ? vimMode : undefined}
|
|
mode={mode}
|
|
autoUpdaterResult={autoUpdaterResult}
|
|
isAutoUpdating={isAutoUpdating}
|
|
verbose={verbose}
|
|
onAutoUpdaterResult={onAutoUpdaterResult}
|
|
onChangeIsUpdating={setIsAutoUpdating}
|
|
suggestions={suggestions}
|
|
selectedSuggestion={selectedSuggestion}
|
|
maxColumnWidth={maxColumnWidth}
|
|
toolPermissionContext={effectiveToolPermissionContext}
|
|
helpOpen={helpOpen}
|
|
suppressHint={input.length > 0}
|
|
isLoading={isLoading}
|
|
tasksSelected={tasksSelected}
|
|
teamsSelected={teamsSelected}
|
|
bridgeSelected={bridgeSelected}
|
|
tmuxSelected={tmuxSelected}
|
|
teammateFooterIndex={teammateFooterIndex}
|
|
ideSelection={ideSelection}
|
|
mcpClients={mcpClients}
|
|
isPasting={isPasting}
|
|
isInputWrapped={isInputWrapped}
|
|
messages={messages}
|
|
isSearching={isSearchingHistory}
|
|
historyQuery={historyQuery}
|
|
setHistoryQuery={setHistoryQuery}
|
|
historyFailedMatch={historyFailedMatch}
|
|
onOpenTasksDialog={
|
|
isFullscreenEnvEnabled() ? handleOpenTasksDialog : undefined
|
|
}
|
|
/>
|
|
{isFullscreenEnvEnabled() ? (
|
|
// position=absolute takes zero layout height so the spinner
|
|
// doesn't shift when a notification appears/disappears. Yoga
|
|
// anchors absolute children at the parent's content-box origin;
|
|
// marginTop=-1 pulls it into the marginTop=1 gap row above the
|
|
// prompt border. In brief mode there is no such gap (briefOwnsGap
|
|
// strips our marginTop) and BriefSpinner sits flush against the
|
|
// border — marginTop=-2 skips over the spinner content into
|
|
// BriefSpinner's own marginTop=1 blank row. height=1 +
|
|
// overflow=hidden clips multi-line notifications to a single row.
|
|
// flex-end anchors the bottom line so the visible row is always
|
|
// the most recent. Suppressed while the slash overlay or
|
|
// auto-mode opt-in dialog is up by height=0 (NOT unmount) — this
|
|
// Box renders later in tree order so it would paint over their
|
|
// bottom row. Keeping Notifications mounted prevents AutoUpdater's
|
|
// initial-check effect from re-firing on every slash-completion
|
|
// toggle (PR#22413).
|
|
<Box
|
|
position="absolute"
|
|
marginTop={briefOwnsGap ? -2 : -1}
|
|
height={suggestions.length === 0 ? 1 : 0}
|
|
width="100%"
|
|
paddingLeft={2}
|
|
paddingRight={1}
|
|
flexDirection="column"
|
|
justifyContent="flex-end"
|
|
overflow="hidden"
|
|
>
|
|
<Notifications
|
|
apiKeyStatus={apiKeyStatus}
|
|
autoUpdaterResult={autoUpdaterResult}
|
|
debug={debug}
|
|
isAutoUpdating={isAutoUpdating}
|
|
verbose={verbose}
|
|
messages={messages}
|
|
onAutoUpdaterResult={onAutoUpdaterResult}
|
|
onChangeIsUpdating={setIsAutoUpdating}
|
|
ideSelection={ideSelection}
|
|
mcpClients={mcpClients}
|
|
isInputWrapped={isInputWrapped}
|
|
/>
|
|
</Box>
|
|
) : null}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Compute the initial paste ID by finding the max ID used in existing messages.
|
|
* This handles --continue/--resume scenarios where we need to avoid ID collisions.
|
|
*/
|
|
function getInitialPasteId(messages: Message[]): number {
|
|
let maxId = 0
|
|
for (const message of messages) {
|
|
if (message.type === 'user') {
|
|
// Check image paste IDs
|
|
if (message.imagePasteIds) {
|
|
for (const id of message.imagePasteIds as number[]) {
|
|
if (id > maxId) maxId = id
|
|
}
|
|
}
|
|
// Check text paste references in message content
|
|
if (Array.isArray(message.message!.content)) {
|
|
for (const block of message.message!.content) {
|
|
if (block.type === 'text') {
|
|
const refs = parseReferences(block.text)
|
|
for (const ref of refs) {
|
|
if (ref.id > maxId) maxId = ref.id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return maxId + 1
|
|
}
|
|
|
|
function buildBorderText(
|
|
showFastIcon: boolean,
|
|
showFastIconHint: boolean,
|
|
fastModeCooldown: boolean,
|
|
): BorderTextOptions | undefined {
|
|
if (!showFastIcon) return undefined
|
|
const fastSeg = showFastIconHint
|
|
? `${getFastIconString(true, fastModeCooldown)} ${chalk.dim('/fast')}`
|
|
: getFastIconString(true, fastModeCooldown)
|
|
return {
|
|
content: ` ${fastSeg} `,
|
|
position: 'top',
|
|
align: 'end',
|
|
offset: 0,
|
|
}
|
|
}
|
|
|
|
export default React.memo(PromptInput)
|