mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
* 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>
3000 lines
96 KiB
TypeScript
3000 lines
96 KiB
TypeScript
import { feature } from 'bun:bundle'
|
|
import chalk from 'chalk'
|
|
import * as path from 'path'
|
|
import * as React from 'react'
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
useSyncExternalStore,
|
|
} from 'react'
|
|
import { useNotifications } from 'src/context/notifications.js'
|
|
import { useCommandQueue } from 'src/hooks/useCommandQueue.js'
|
|
import {
|
|
type IDEAtMentioned,
|
|
useIdeAtMentioned,
|
|
} from 'src/hooks/useIdeAtMentioned.js'
|
|
import {
|
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
logEvent,
|
|
} from 'src/services/analytics/index.js'
|
|
import {
|
|
type AppState,
|
|
useAppState,
|
|
useAppStateStore,
|
|
useSetAppState,
|
|
} from 'src/state/AppState.js'
|
|
import type { FooterItem } from 'src/state/AppStateStore.js'
|
|
import { getCwd } from 'src/utils/cwd.js'
|
|
import {
|
|
isQueuedCommandEditable,
|
|
popAllEditable,
|
|
} from 'src/utils/messageQueueManager.js'
|
|
import stripAnsi from 'strip-ansi'
|
|
import { companionReservedColumns } from '../../buddy/CompanionSprite.js'
|
|
import {
|
|
findBuddyTriggerPositions,
|
|
useBuddyNotification,
|
|
} from '../../buddy/useBuddyNotification.js'
|
|
import { FastModePicker } from '../../commands/fast/fast.js'
|
|
import { isUltrareviewEnabled } from '../../commands/review/ultrareviewEnabled.js'
|
|
import { getNativeCSIuTerminalDisplayName } from '../../commands/terminalSetup/terminalSetup.js'
|
|
import { type Command, hasCommand } from '../../commands.js'
|
|
import { useIsModalOverlayActive } from '../../context/overlayContext.js'
|
|
import { useSetPromptOverlayDialog } from '../../context/promptOverlayContext.js'
|
|
import {
|
|
formatImageRef,
|
|
formatPastedTextRef,
|
|
getPastedTextRefNumLines,
|
|
parseReferences,
|
|
} from '../../history.js'
|
|
import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js'
|
|
import {
|
|
type HistoryMode,
|
|
useArrowKeyHistory,
|
|
} from '../../hooks/useArrowKeyHistory.js'
|
|
import { useDoublePress } from '../../hooks/useDoublePress.js'
|
|
import { useHistorySearch } from '../../hooks/useHistorySearch.js'
|
|
import type { IDESelection } from '../../hooks/useIdeSelection.js'
|
|
import { useInputBuffer } from '../../hooks/useInputBuffer.js'
|
|
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'
|
|
import { usePromptSuggestion } from '../../hooks/usePromptSuggestion.js'
|
|
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
|
import { useTypeahead } from '../../hooks/useTypeahead.js'
|
|
import { Box, type BorderTextOptions, type ClickEvent, type Key, stringWidth, Text, useInput } from '@anthropic/ink'
|
|
import { useOptionalKeybindingContext } from '../../keybindings/KeybindingContext.js'
|
|
import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js'
|
|
import {
|
|
useKeybinding,
|
|
useKeybindings,
|
|
} from '../../keybindings/useKeybinding.js'
|
|
import type { MCPServerConnection } from '../../services/mcp/types.js'
|
|
import {
|
|
abortPromptSuggestion,
|
|
logSuggestionSuppressed,
|
|
} from '../../services/PromptSuggestion/promptSuggestion.js'
|
|
import {
|
|
type ActiveSpeculationState,
|
|
abortSpeculation,
|
|
} from '../../services/PromptSuggestion/speculation.js'
|
|
import {
|
|
getActiveAgentForInput,
|
|
getViewedTeammateTask,
|
|
} from '../../state/selectors.js'
|
|
import {
|
|
enterTeammateView,
|
|
exitTeammateView,
|
|
stopOrDismissAgent,
|
|
} from '../../state/teammateViewHelpers.js'
|
|
import type { ToolPermissionContext } from '../../Tool.js'
|
|
import { getRunningTeammatesSorted } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js'
|
|
import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'
|
|
import {
|
|
isPanelAgentTask,
|
|
type LocalAgentTaskState,
|
|
} from '../../tasks/LocalAgentTask/LocalAgentTask.js'
|
|
import { isBackgroundTask } from '../../tasks/types.js'
|
|
import {
|
|
AGENT_COLOR_TO_THEME_COLOR,
|
|
AGENT_COLORS,
|
|
type AgentColorName,
|
|
} from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'
|
|
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
|
import type { Message } from '../../types/message.js'
|
|
import type { PermissionMode } from '../../types/permissions.js'
|
|
import type {
|
|
BaseTextInputProps,
|
|
PromptInputMode,
|
|
VimMode,
|
|
} from '../../types/textInputTypes.js'
|
|
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
|
|
import { count } from '../../utils/array.js'
|
|
import type { AutoUpdaterResult } from '../../utils/autoUpdater.js'
|
|
import { Cursor } from '../../utils/Cursor.js'
|
|
import {
|
|
getGlobalConfig,
|
|
type PastedContent,
|
|
saveGlobalConfig,
|
|
} from '../../utils/config.js'
|
|
import { logForDebugging } from '../../utils/debug.js'
|
|
import {
|
|
parseDirectMemberMessage,
|
|
sendDirectMemberMessage,
|
|
} from '../../utils/directMemberMessage.js'
|
|
import type { EffortLevel } from '../../utils/effort.js'
|
|
import { env } from '../../utils/env.js'
|
|
import { errorMessage } from '../../utils/errors.js'
|
|
import { isBilledAsExtraUsage } from '../../utils/extraUsage.js'
|
|
import {
|
|
getFastModeUnavailableReason,
|
|
isFastModeAvailable,
|
|
isFastModeCooldown,
|
|
isFastModeEnabled,
|
|
isFastModeSupportedByModel,
|
|
} from '../../utils/fastMode.js'
|
|
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'
|
|
import type { PromptInputHelpers } from '../../utils/handlePromptSubmit.js'
|
|
import {
|
|
getImageFromClipboard,
|
|
PASTE_THRESHOLD,
|
|
} from '../../utils/imagePaste.js'
|
|
import type { ImageDimensions } from '../../utils/imageResizer.js'
|
|
import { cacheImagePath, storeImage } from '../../utils/imageStore.js'
|
|
import {
|
|
isMacosOptionChar,
|
|
MACOS_OPTION_SPECIAL_CHARS,
|
|
} from '../../utils/keyboardShortcuts.js'
|
|
import { logError } from '../../utils/log.js'
|
|
import {
|
|
isOpus1mMergeEnabled,
|
|
modelDisplayString,
|
|
} from '../../utils/model/model.js'
|
|
import {
|
|
cyclePermissionMode,
|
|
getNextPermissionMode,
|
|
} from '../../utils/permissions/getNextPermissionMode.js'
|
|
import { getPlatform } from '../../utils/platform.js'
|
|
import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'
|
|
import { editPromptInEditor } from '../../utils/promptEditor.js'
|
|
// hasAutoModeOptIn removed — auto mode is available to all users
|
|
import { findBtwTriggerPositions } from '../../utils/sideQuestion.js'
|
|
import { findSlashCommandPositions } from '../../utils/suggestions/commandSuggestions.js'
|
|
import {
|
|
findSlackChannelPositions,
|
|
getKnownChannelsVersion,
|
|
hasSlackMcpServer,
|
|
subscribeKnownChannels,
|
|
} from '../../utils/suggestions/slackChannelSuggestions.js'
|
|
import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js'
|
|
import { syncTeammateMode } from '../../utils/swarm/teamHelpers.js'
|
|
import type { TeamSummary } from '../../utils/teamDiscovery.js'
|
|
import { getTeammateColor } from '../../utils/teammate.js'
|
|
import { isInProcessTeammate } from '../../utils/teammateContext.js'
|
|
import { writeToMailbox } from '../../utils/teammateMailbox.js'
|
|
import type { TextHighlight } from '../../utils/textHighlighting.js'
|
|
import type { Theme } from '../../utils/theme.js'
|
|
import {
|
|
findThinkingTriggerPositions,
|
|
getRainbowColor,
|
|
isUltrathinkEnabled,
|
|
} from '../../utils/thinking.js'
|
|
import { findTokenBudgetPositions } from '../../utils/tokenBudget.js'
|
|
import {
|
|
findUltraplanTriggerPositions,
|
|
findUltrareviewTriggerPositions,
|
|
} from '../../utils/ultraplan/keyword.js'
|
|
// AutoModeOptInDialog removed — auto mode is available to all users
|
|
import { BridgeDialog } from '../BridgeDialog.js'
|
|
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
|
|
import {
|
|
getVisibleAgentTasks,
|
|
useCoordinatorTaskCount,
|
|
} from '../CoordinatorAgentStatus.js'
|
|
import { getEffortNotificationText } from '../EffortIndicator.js'
|
|
import { getFastIconString } from '../FastIcon.js'
|
|
import { GlobalSearchDialog } from '../GlobalSearchDialog.js'
|
|
import { HistorySearchDialog } from '../HistorySearchDialog.js'
|
|
import { ModelPicker } from '../ModelPicker.js'
|
|
import { QuickOpenDialog } from '../QuickOpenDialog.js'
|
|
import TextInput from '../TextInput.js'
|
|
import { ThinkingToggle } from '../ThinkingToggle.js'
|
|
import { BackgroundTasksDialog } from '../tasks/BackgroundTasksDialog.js'
|
|
import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js'
|
|
import { TeamsDialog } from '../teams/TeamsDialog.js'
|
|
import VimTextInput from '../VimTextInput.js'
|
|
import { getModeFromInput, getValueFromInput } from './inputModes.js'
|
|
import {
|
|
FOOTER_TEMPORARY_STATUS_TIMEOUT,
|
|
Notifications,
|
|
} from './Notifications.js'
|
|
import PromptInputFooter from './PromptInputFooter.js'
|
|
import type { SuggestionItem } from './PromptInputFooterSuggestions.js'
|
|
import { PromptInputModeIndicator } from './PromptInputModeIndicator.js'
|
|
import { PromptInputQueuedCommands } from './PromptInputQueuedCommands.js'
|
|
import { PromptInputStashNotice } from './PromptInputStashNotice.js'
|
|
import { useMaybeTruncateInput } from './useMaybeTruncateInput.js'
|
|
import { usePromptInputPlaceholder } from './usePromptInputPlaceholder.js'
|
|
import { useShowFastIconHint } from './useShowFastIconHint.js'
|
|
import { useSwarmBanner } from './useSwarmBanner.js'
|
|
import { isNonSpacePrintable, isVimModeEnabled } from './utils.js'
|
|
|
|
type Props = {
|
|
debug: boolean
|
|
ideSelection: IDESelection | undefined
|
|
toolPermissionContext: ToolPermissionContext
|
|
setToolPermissionContext: (ctx: ToolPermissionContext) => void
|
|
apiKeyStatus: VerificationStatus
|
|
commands: Command[]
|
|
agents: AgentDefinition[]
|
|
isLoading: boolean
|
|
verbose: boolean
|
|
messages: Message[]
|
|
onAutoUpdaterResult: (result: AutoUpdaterResult) => void
|
|
autoUpdaterResult: AutoUpdaterResult | null
|
|
input: string
|
|
onInputChange: (value: string) => void
|
|
mode: PromptInputMode
|
|
onModeChange: (mode: PromptInputMode) => void
|
|
stashedPrompt:
|
|
| {
|
|
text: string
|
|
cursorOffset: number
|
|
pastedContents: Record<number, PastedContent>
|
|
}
|
|
| undefined
|
|
setStashedPrompt: (
|
|
value:
|
|
| {
|
|
text: string
|
|
cursorOffset: number
|
|
pastedContents: Record<number, PastedContent>
|
|
}
|
|
| undefined,
|
|
) => void
|
|
submitCount: number
|
|
onShowMessageSelector: () => void
|
|
/** Fullscreen message actions: shift+↑ enters cursor. */
|
|
onMessageActionsEnter?: () => void
|
|
mcpClients: MCPServerConnection[]
|
|
pastedContents: Record<number, PastedContent>
|
|
setPastedContents: React.Dispatch<
|
|
React.SetStateAction<Record<number, PastedContent>>
|
|
>
|
|
vimMode: VimMode
|
|
setVimMode: (mode: VimMode) => void
|
|
showBashesDialog: string | boolean
|
|
setShowBashesDialog: (show: string | boolean) => void
|
|
onExit: () => void
|
|
getToolUseContext: (
|
|
messages: Message[],
|
|
newMessages: Message[],
|
|
abortController: AbortController,
|
|
mainLoopModel: string,
|
|
) => ProcessUserInputContext
|
|
onSubmit: (
|
|
input: string,
|
|
helpers: PromptInputHelpers,
|
|
speculationAccept?: {
|
|
state: ActiveSpeculationState
|
|
speculationSessionTimeSavedMs: number
|
|
setAppState: (f: (prev: AppState) => AppState) => void
|
|
},
|
|
options?: { fromKeybinding?: boolean },
|
|
) => Promise<void>
|
|
onAgentSubmit?: (
|
|
input: string,
|
|
task: InProcessTeammateTaskState | LocalAgentTaskState,
|
|
helpers: PromptInputHelpers,
|
|
) => Promise<void>
|
|
isSearchingHistory: boolean
|
|
setIsSearchingHistory: (isSearching: boolean) => void
|
|
onDismissSideQuestion?: () => void
|
|
isSideQuestionVisible?: boolean
|
|
helpOpen: boolean
|
|
setHelpOpen: React.Dispatch<React.SetStateAction<boolean>>
|
|
hasSuppressedDialogs?: boolean
|
|
isLocalJSXCommandActive?: boolean
|
|
insertTextRef?: React.MutableRefObject<{
|
|
insert: (text: string) => void
|
|
setInputWithCursor: (value: string, cursor: number) => void
|
|
cursorOffset: number
|
|
} | null>
|
|
voiceInterimRange?: { start: number; end: number } | null
|
|
}
|
|
|
|
// Bottom slot has maxHeight="50%"; reserve lines for footer, border, status.
|
|
const PROMPT_FOOTER_LINES = 5
|
|
const MIN_INPUT_VIEWPORT_LINES = 3
|
|
|
|
function PromptInput({
|
|
debug,
|
|
ideSelection,
|
|
toolPermissionContext,
|
|
setToolPermissionContext,
|
|
apiKeyStatus,
|
|
commands,
|
|
agents,
|
|
isLoading,
|
|
verbose,
|
|
messages,
|
|
onAutoUpdaterResult,
|
|
autoUpdaterResult,
|
|
input,
|
|
onInputChange,
|
|
mode,
|
|
onModeChange,
|
|
stashedPrompt,
|
|
setStashedPrompt,
|
|
submitCount,
|
|
onShowMessageSelector,
|
|
onMessageActionsEnter,
|
|
mcpClients,
|
|
pastedContents,
|
|
setPastedContents,
|
|
vimMode,
|
|
setVimMode,
|
|
showBashesDialog,
|
|
setShowBashesDialog,
|
|
onExit,
|
|
getToolUseContext,
|
|
onSubmit: onSubmitProp,
|
|
onAgentSubmit,
|
|
isSearchingHistory,
|
|
setIsSearchingHistory,
|
|
onDismissSideQuestion,
|
|
isSideQuestionVisible,
|
|
helpOpen,
|
|
setHelpOpen,
|
|
hasSuppressedDialogs,
|
|
isLocalJSXCommandActive = false,
|
|
insertTextRef,
|
|
voiceInterimRange,
|
|
}: Props): React.ReactNode {
|
|
const mainLoopModel = useMainLoopModel()
|
|
// A local-jsx command (e.g., /mcp while agent is running) renders a full-
|
|
// screen dialog on top of PromptInput via the immediate-command path with
|
|
// shouldHidePromptInput: false. Those dialogs don't register in the overlay
|
|
// system, so treat them as a modal overlay here to stop navigation keys from
|
|
// leaking into TextInput/footer handlers and stacking a second dialog.
|
|
const isModalOverlayActive =
|
|
useIsModalOverlayActive() || isLocalJSXCommandActive
|
|
const [isAutoUpdating, setIsAutoUpdating] = useState(false)
|
|
const [exitMessage, setExitMessage] = useState<{
|
|
show: boolean
|
|
key?: string
|
|
}>({ show: false })
|
|
const [cursorOffset, setCursorOffset] = useState<number>(input.length)
|
|
// Track the last input value set via internal handlers so we can detect
|
|
// external input changes (e.g. speech-to-text injection) and move cursor to end.
|
|
const lastInternalInputRef = React.useRef(input)
|
|
if (input !== lastInternalInputRef.current) {
|
|
// Input changed externally (not through any internal handler) — move cursor to end
|
|
setCursorOffset(input.length)
|
|
lastInternalInputRef.current = input
|
|
}
|
|
// Wrap onInputChange to track internal changes before they trigger re-render
|
|
const trackAndSetInput = React.useCallback(
|
|
(value: string) => {
|
|
lastInternalInputRef.current = value
|
|
onInputChange(value)
|
|
},
|
|
[onInputChange],
|
|
)
|
|
// Expose an insertText function so callers (e.g. STT) can splice text at the
|
|
// current cursor position instead of replacing the entire input.
|
|
if (insertTextRef) {
|
|
insertTextRef.current = {
|
|
cursorOffset,
|
|
insert: (text: string) => {
|
|
const needsSpace =
|
|
cursorOffset === input.length &&
|
|
input.length > 0 &&
|
|
!/\s$/.test(input)
|
|
const insertText = needsSpace ? ' ' + text : text
|
|
const newValue =
|
|
input.slice(0, cursorOffset) + insertText + input.slice(cursorOffset)
|
|
lastInternalInputRef.current = newValue
|
|
onInputChange(newValue)
|
|
setCursorOffset(cursorOffset + insertText.length)
|
|
},
|
|
setInputWithCursor: (value: string, cursor: number) => {
|
|
lastInternalInputRef.current = value
|
|
onInputChange(value)
|
|
setCursorOffset(cursor)
|
|
},
|
|
}
|
|
}
|
|
const store = useAppStateStore()
|
|
const setAppState = useSetAppState()
|
|
const tasks = useAppState(s => s.tasks)
|
|
const replBridgeConnected = useAppState(s => s.replBridgeConnected)
|
|
const replBridgeExplicit = useAppState(s => s.replBridgeExplicit)
|
|
const replBridgeReconnecting = useAppState(s => s.replBridgeReconnecting)
|
|
// Must match BridgeStatusIndicator's render condition (PromptInputFooter.tsx) —
|
|
// the pill returns null for implicit-and-not-reconnecting, so nav must too,
|
|
// otherwise bridge becomes an invisible selection stop.
|
|
const bridgeFooterVisible =
|
|
replBridgeConnected && (replBridgeExplicit || replBridgeReconnecting)
|
|
// Tmux pill (ant-only) — visible when there's an active tungsten session
|
|
const hasTungstenSession = useAppState(
|
|
s =>
|
|
process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined,
|
|
)
|
|
const tmuxFooterVisible =
|
|
process.env.USER_TYPE === 'ant' && hasTungstenSession
|
|
// WebBrowser pill — visible when a browser is open
|
|
const bagelFooterVisible = useAppState(s =>
|
|
false,
|
|
)
|
|
const teamContext = useAppState(s => s.teamContext)
|
|
const queuedCommands = useCommandQueue()
|
|
const promptSuggestionState = useAppState(s => s.promptSuggestion)
|
|
const speculation = useAppState(s => s.speculation)
|
|
const speculationSessionTimeSavedMs = useAppState(
|
|
s => s.speculationSessionTimeSavedMs,
|
|
)
|
|
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
|
|
const viewSelectionMode = useAppState(s => s.viewSelectionMode)
|
|
const showSpinnerTree = useAppState(s => s.expandedView) === 'teammates'
|
|
const { companion: _companion, companionMuted } = feature('BUDDY')
|
|
? getGlobalConfig()
|
|
: { companion: undefined, companionMuted: undefined }
|
|
const companionFooterVisible = !!_companion && !companionMuted
|
|
// Brief mode: BriefSpinner/BriefIdleStatus own the 2-row footprint above
|
|
// the input. Dropping marginTop here lets the spinner sit flush against
|
|
// the input bar. viewingAgentTaskId mirrors the gate on both (Spinner.tsx,
|
|
// REPL.tsx) — teammate view falls back to SpinnerWithVerbInner which has
|
|
// its own marginTop, so the gap stays even without ours.
|
|
const briefOwnsGap =
|
|
feature('KAIROS') || feature('KAIROS_BRIEF')
|
|
?
|
|
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)
|