Files
claude-code/src/components/PromptInput/PromptInput.tsx
Dosion c2ac9a74c1 fix: resolve dependency audit findings precisely (#361)
* fix: harden ACP communication boundaries

Harden ACP communication boundaries

Remote ACP sessions now cannot widen permission mode through untrusted
metadata or client payloads. WebSocket ACP ingress measures payloads by bytes
before binary decode, and prompt queue handoff keeps exactly one prompt active
while queued prompts are drained FIFO.

Constraint: ACP remote clients must not be able to open bypassPermissions without local launch intent
Constraint: WebSocket payload limits must be byte-based and checked before binary decode
Rejected: Keep promptToQueryContent wrapper | no production consumers remained after prompt conversion single-sourcing
Confidence: high
Scope-risk: moderate
Directive: Do not re-enable remote bypassPermissions from _meta unless a local launch gate is verified in both acp-link and agent
Tested: targeted ACP/RCS/acp-link prompt queue, bridge, permission, payload, and prompt conversion tests; bun run typecheck; bun run build
Not-tested: Manual live ACP/RCS session against an external client

* fix: restore repository verification gates

Keep the full repository test, typecheck, build, and Biome lint gates usable
after the ACP fix pass. This commit is intentionally separate from the ACP
behavior change: it fixes Windows-safe Langfuse home redaction, removes stale
lint suppressions, resolves Biome warning/info diagnostics, and keeps env
expansion tests explicit without template-placeholder lint noise.

Constraint: The project completion contract requires full typecheck, lint, test, and build evidence
Rejected: Leave warning/info diagnostics as historical noise | they obscure future gate regressions and weaken flow-impact claims
Confidence: high
Scope-risk: narrow
Directive: Keep repository gate cleanup separate from feature fixes when it is not part of the same runtime path
Tested: bunx biome lint src/; bunx tsc --noEmit; bun test src/services/mcp/__tests__/envExpansion.test.ts src/utils/__tests__/sliceAnsi.test.ts src/utils/__tests__/stringUtils.test.ts; bun test; bun run build
Not-tested: Manual Langfuse export against a real external Langfuse service

* fix: harden ACP failure boundaries after review

Deep review found several paths that made ACP communication failures look normal: prompt errors could finish as end_turn, permission pipeline exceptions could fall through to client approval, tool rawInput was deep-copied with JSON, and acp-link accepted unbounded or unvalidated WebSocket payloads. This keeps the behavior fail-closed, validates WS payloads before dispatch, caps payload size before JSON parse, and preserves cancellation intent with a generation counter.

Constraint: User explicitly rejected pseudo-fixes, fallback behavior, and unbounded payload handling

Rejected: Keep JSON stringify/parse rawInput copy | duplicates large payloads and silently drops non-JSON inputs

Rejected: Delegate permission pipeline errors to client approval | allows a broken local permission check to be bypassed

Confidence: high

Scope-risk: moderate

Directive: Do not convert ACP errors into normal end_turn responses without a protocol-level reason and regression tests

Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts

Tested: bun test packages/acp-link/src/__tests__/server.test.ts

Tested: bunx tsc --noEmit

Tested: bunx biome lint src/ packages/acp-link/src/

Tested: bun run test:all

Tested: bun run build

Not-tested: Manual end-to-end ACP client session over a real editor WebSocket

* fix: prevent ACP coverage runs from seeing partial mocks

GitHub Actions failed under bun test --coverage because permissions.test.ts replaced ../bridge.js with a partial mock that omitted forwardSessionUpdates. Coverage worker ordering on Linux let sibling tests observe that incomplete module.

This isolates ACP test mocks by snapshotting real exports, overriding only requested symbols, and restoring mocks in LIFO order. The shared helper also keeps the same behavior in agent.test.ts without duplicating mock infrastructure.

Constraint: bun:test mock.module is process-global inside a worker.

Rejected: Add fallback exports or production guards | the bridge export exists; the failure was test mock pollution.

Rejected: Keep per-file helper copies | duplication would let restore semantics drift again.

Confidence: high

Scope-risk: narrow

Directive: Prefer safeMockModule for partial mocks of real modules in ACP tests; plain mock.module is only appropriate for fully synthetic modules or isolated tests.

Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts

Tested: bun test --coverage --coverage-reporter=lcov

Tested: bunx tsc --noEmit

Tested: bun run lint

Tested: git diff --check

Not-tested: Linux runner directly before push

* fix: normalize ACP bypass requests without warning noise

The previous CI repair removed the failing partial bridge mock, but it also added a shared safeMockModule helper and left the acp-link bypass normalization warning in the real new_session path.

This tightens the fix: acp-link now treats an unauthorized client bypass request as normal permission-mode normalization without emitting a warning, and the ACP permission test explicitly preserves the real bridge and permission exports instead of using a shared helper. The agent test keeps its local mock preservation but names it by behavior and restores mocks in LIFO order.

Constraint: CI output should not contain expected warning noise for covered policy branches.

Rejected: Silence the test only | the normal new_session path would still warn for an expected normalization branch.

Rejected: Keep the shared safeMockModule helper | the failing module was specific and should be fixed by preserving real exports at the mocking site.

Confidence: high

Scope-risk: narrow

Directive: Treat client-requested bypassPermissions as data to normalize unless the local default explicitly enables bypass.

Tested: bun test packages/acp-link/src/__tests__/server.test.ts

Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts

Tested: bun test --coverage --coverage-reporter=lcov with UPPER_WARN_COUNT=0

Tested: bun run test:all

Tested: bun run lint

Tested: bunx tsc --noEmit

Tested: git diff --check

* fix: harden ACP bypass and CI warning gates

ACP clients must not be able to enter bypassPermissions unless the local ACP gate and process environment both allow it. The same gate now controls session creation, explicit mode changes, and the ExitPlanMode option list, while session setup restores process.cwd so coverage and later work do not inherit ACP session state.

Constraint: CI must stay warning-clean without hiding real ACP permission failures

Rejected: Logging rejected bypass requests on the normal new_session path | it preserves audit text but reintroduces warning noise the runtime should not emit

Rejected: Broad CI=true postinstall skip | it hides explicit Chrome MCP setup checks outside the install path

Confidence: high

Scope-risk: moderate

Directive: Keep bypassPermissions gated through one ACP availability decision before exposing it to clients

Tested: bun test src/services/acp/__tests__/permissions.test.ts src/services/acp/__tests__/agent.test.ts packages/acp-link/src/__tests__/server.test.ts

Tested: bun run test:all

Tested: bun run lint

Tested: bun run build:vite with zero warning matches

Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage produced non-empty lcov with SF records and zero filtered warning matches

Not-tested: GitHub Actions result after this push

* fix: remove remaining CI warning noise

The CI log still had three non-failing warnings after the ACP hardening commit: git init default-branch advice from checkout, a Node 20 action-runtime deprecation, and one additional known Vite dynamic-import diagnostic that only surfaced on Linux. The workflow now provides explicit git config and opts actions into Node 24, while Vite keeps a narrow allowlist for acknowledged optimizer diagnostics.

Constraint: Do not use shell log filtering to hide warnings after they happen

Rejected: Grep warning lines out of CI output | it would make future diagnostics harder to find

Confidence: high

Scope-risk: narrow

Directive: Add new Vite warning allowlist entries only after checking that they are existing optimizer diagnostics, not new application defects

Tested: bunx tsc --noEmit --pretty false

Tested: bunx biome lint .github/workflows/ci.yml vite.config.ts

Tested: bun run build:vite with zero warning matches

Not-tested: GitHub Actions result after this push

* fix: reject unauthorized ACP bypass and harden CI actions

ACP clients now fail closed when permissionMode is malformed, unknown, or requests bypass without a local bypass opt-in. acp-link validates new_session input before forwarding to the agent and returns client error frames for expected unauthorized requests without logging create-failed noise. The direct AcpAgent path independently rejects invalid _meta.permissionMode and unauthorized bypass instead of falling back to settings.

CI workflows and generated GitHub App templates now use Node 24-compatible actions pinned to immutable commit SHAs, and acp-link startup output no longer prints the auth token.

Constraint: Must not hide warnings with test isolation or log filtering

Rejected: Silent fallback to local permission mode | accepts invalid client intent and masks boundary behavior

Rejected: Broad dependency churn from bun update | audit remained failing while package and lockfile churn expanded scope

Confidence: high

Scope-risk: moderate

Directive: Client-provided permissionMode must stay fail-closed before reaching AcpAgent; only local settings.defaultMode may fall back to default on invalid local config

Tested: bun test packages/acp-link/src/__tests__/server.test.ts src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/permissions.test.ts src/services/skillLearning/__tests__/skillLifecycle.test.ts src/utils/settings/__tests__/config.test.ts

Tested: bunx tsc -p packages/acp-link/tsconfig.json --noEmit --pretty false

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

Tested: bun run test:all

Tested: local CI equivalent install/typecheck/coverage/build with warning_scan=0

Not-tested: Pre-existing bun audit vulnerabilities require a separate dependency-hardening PR

* fix: resolve dependency audit findings precisely

Use dependency-native upgrades and lockfile resolution to close the audit findings without suppressions. Keep the chrome MCP setup aligned with the new dependency graph and add real integration coverage so the override behavior stays verified.

Constraint: no audit ignores or warning suppression
Rejected: broad google-auth/protobuf overrides | replaced with upstream-compatible resolution
Confidence: high
Scope-risk: moderate
Directive: keep dependency fixes upstream-compatible; do not reintroduce blanket overrides unless the audit surface changes materially
Tested: bun audit; bun audit --json; bun install --frozen-lockfile with CLAUDE_CODE_SKIP_CHROME_MCP_SETUP=1; bunx tsc --noEmit --pretty false; bun run lint; targeted tests; bun run test:all; bun test --coverage --coverage-reporter lcov --coverage-dir coverage; bun run build:vite
Not-tested: unrelated pre-existing ACP/CORS/token fallback residual risks

* fix: keep ACP auth tokens out of URLs

Replace the ad hoc URL-token flow with crypto UUID-backed transport identifiers so the bearer token stays in structured request data instead of query strings. Keep the server, web client, and transport helpers aligned so the ACP/RCS handshake remains compatible after the API shape change.

Constraint: token must not be embedded in the URL
Rejected: token-as-uuid query fallback | leaked bearer tokens in URLs
Confidence: high
Scope-risk: moderate
Directive: preserve the structured auth path; do not reintroduce query-token fallback when adjusting ACP transport code
Tested: targeted ACP/RCS transport tests
Not-tested: unrelated pre-existing ACP/CORS/token fallback residual risks

* fix: normalize WebFetch request headers

Normalize WebFetch headers before dispatch so canonicalization preserves auth semantics and duplicate forms do not slip through. Keep the behavior locked with a focused header test instead of broadening the request pipeline.

Constraint: preserve header semantics without widening the fetch surface
Rejected: ad hoc caller-side normalization | too easy to bypass in future call sites
Confidence: high
Scope-risk: narrow
Directive: keep header normalization close to the WebFetch utility so future callers inherit the same behavior automatically
Tested: targeted WebFetch header tests
Not-tested: unrelated fetch backend behavior beyond header normalization

* fix: harden ACP remote auth surfaces

Tighten the remaining Claude security artifact items by requiring API keys on ACP global reads and relay upgrades, moving WebSocket tokens out of URLs, and replacing open web CORS with an explicit allowlist.

Constraint: Browser WebSocket clients cannot set arbitrary Authorization headers, so the token is carried in a selected subprotocol instead of a query string.
Rejected: Keep UUID auth for ACP channel groups | any caller can mint a UUID and read global ACP data.
Rejected: Preserve ?token= compatibility | secrets leak into logs, history, referrers, and intermediaries.
Confidence: high
Scope-risk: moderate
Directive: Do not reintroduce query-string bearer tokens; use Authorization or rcs.auth.<base64url-token>.
Tested: bunx tsc --noEmit --pretty false
Tested: bun run typecheck in packages/remote-control-server
Tested: bun run build in packages/acp-link
Tested: bun run lint
Tested: bun audit
Tested: focused RCS/acp-link/web tests, 160 pass
Tested: Edge headless browser WebSocket subprotocol handshake
Tested: bun run test:all, 3669 pass
Tested: bun run build:vite
Tested: bun run build
Not-tested: Manual end-to-end relay with a live external ACP agent

* fix: resolve CI dependency override lookup

The CI runner does not expose @grpc/proto-loader as a root-resolvable package, and the test was relying on local hoisting rather than the real dependency owner. Resolve proto-loader through @opentelemetry/exporter-trace-otlp-grpc and @grpc/grpc-js so the smoke test follows the package graph it is validating.

Constraint: Do not add a new root dependency for a transitive smoke test.

Rejected: Skip or weaken the test | the test protects the protobuf 7 override path and should keep exercising loadSync.

Rejected: Add @grpc/proto-loader directly to root package.json | that hides the owning-package resolution issue and broadens dependency surface.

Confidence: high

Scope-risk: narrow

Directive: Dependency override smoke tests should resolve from the package that actually owns the dependency, not from incidental root hoisting.

Tested: bun test tests/integration/dependency-overrides.test.ts; bunx tsc --noEmit --pretty false; bun run lint; bun audit; bun run test:all; git diff --check

---------

Co-authored-by: unraid <local@unraid.local>
2026-04-26 19:49:54 +08:00

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')
?
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')
?
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)