From a574ea205bd97e5ce04372af5c5ca18995bec9e4 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 4 Apr 2026 22:50:19 +0800 Subject: [PATCH] =?UTF-8?q?style(B1-5):=20=E6=A0=BC=E5=BC=8F=E5=8C=96=20co?= =?UTF-8?q?mponents=E5=85=B6=E4=BD=99=20+=20hooks=20+=20tools=20(232=20fil?= =?UTF-8?q?es)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 --- src/components/AgentProgressLine.tsx | 236 +- src/components/App.tsx | 78 +- src/components/ApproveApiKey.tsx | 197 +- src/components/AutoModeOptInDialog.tsx | 213 +- src/components/AutoUpdater.tsx | 279 +- src/components/AutoUpdaterWrapper.tsx | 176 +- src/components/AwsAuthStatusBox.tsx | 147 +- src/components/BaseTextInput.tsx | 253 +- src/components/BashModeProgress.tsx | 93 +- src/components/BridgeDialog.tsx | 550 +-- .../BypassPermissionsModeDialog.tsx | 151 +- src/components/ChannelDowngradeDialog.tsx | 140 +- .../ClaudeCodeHint/PluginHintMenu.tsx | 101 +- src/components/ClaudeInChromeOnboarding.tsx | 190 +- .../ClaudeMdExternalIncludesDialog.tsx | 221 +- src/components/ClickableImageRef.tsx | 107 +- src/components/CompactSummary.tsx | 214 +- src/components/ConfigurableShortcutHint.tsx | 67 +- src/components/ConsoleOAuthFlow.tsx | 1683 ++++---- src/components/ContextSuggestions.tsx | 78 +- src/components/ContextVisualization.tsx | 896 ++--- src/components/CoordinatorAgentStatus.tsx | 451 +-- src/components/CostThresholdDialog.tsx | 76 +- src/components/CtrlOToExpand.tsx | 79 +- src/components/CustomSelect/SelectMulti.tsx | 345 +- .../CustomSelect/select-input-option.tsx | 805 ++-- src/components/CustomSelect/select-option.tsx | 71 +- src/components/CustomSelect/select.tsx | 1368 ++++--- src/components/DesktopHandoff.tsx | 323 +- .../DesktopUpsell/DesktopUpsellStartup.tsx | 250 +- src/components/DevBar.tsx | 80 +- src/components/DevChannelsDialog.tsx | 164 +- src/components/DiagnosticsDisplay.tsx | 175 +- src/components/EffortCallout.tsx | 357 +- src/components/ExitFlow.tsx | 72 +- src/components/ExportDialog.tsx | 204 +- .../FallbackToolUseErrorMessage.tsx | 190 +- .../FallbackToolUseRejectedMessage.tsx | 24 +- src/components/FastIcon.tsx | 62 +- src/components/Feedback.tsx | 804 ++-- src/components/FileEditToolDiff.tsx | 272 +- src/components/FileEditToolUpdatedMessage.tsx | 199 +- .../FileEditToolUseRejectedMessage.tsx | 259 +- src/components/FilePathLink.tsx | 41 +- src/components/FullscreenLayout.tsx | 832 ++-- src/components/GlobalSearchDialog.tsx | 604 ++- src/components/HelpV2/Commands.tsx | 146 +- src/components/HelpV2/General.tsx | 42 +- src/components/HelpV2/HelpV2.tsx | 317 +- src/components/HighlightedCode.tsx | 312 +- src/components/HighlightedCode/Fallback.tsx | 259 +- src/components/HistorySearchDialog.tsx | 219 +- src/components/IdeAutoConnectDialog.tsx | 233 +- src/components/IdeOnboardingDialog.tsx | 248 +- src/components/IdeStatusIndicator.tsx | 86 +- src/components/IdleReturnDialog.tsx | 164 +- src/components/InterruptedByUser.tsx | 27 +- src/components/InvalidConfigDialog.tsx | 218 +- src/components/InvalidSettingsDialog.tsx | 121 +- src/components/KeybindingWarnings.tsx | 102 +- src/components/LanguagePicker.tsx | 133 +- src/components/LogSelector.tsx | 2756 +++++++------ src/components/LogoV2/AnimatedAsterisk.tsx | 70 +- src/components/LogoV2/AnimatedClawd.tsx | 155 +- src/components/LogoV2/ChannelsNotice.tsx | 373 +- src/components/LogoV2/Clawd.tsx | 279 +- src/components/LogoV2/CondensedLogo.tsx | 275 +- src/components/LogoV2/EmergencyTip.tsx | 76 +- src/components/LogoV2/Feed.tsx | 196 +- src/components/LogoV2/FeedColumn.tsx | 84 +- src/components/LogoV2/GuestPassesUpsell.tsx | 98 +- src/components/LogoV2/LogoV2.tsx | 998 +++-- src/components/LogoV2/Opus1mMergeNotice.tsx | 89 +- src/components/LogoV2/OverageCreditUpsell.tsx | 193 +- src/components/LogoV2/VoiceModeNotice.tsx | 114 +- src/components/LogoV2/WelcomeV2.tsx | 748 ++-- src/components/LogoV2/feedConfigs.tsx | 128 +- .../LspRecommendationMenu.tsx | 115 +- src/components/MCPServerApprovalDialog.tsx | 200 +- .../MCPServerDesktopImportDialog.tsx | 312 +- src/components/MCPServerDialogCopy.tsx | 24 +- src/components/MCPServerMultiselectDialog.tsx | 243 +- .../ManagedSettingsSecurityDialog.tsx | 230 +- src/components/Markdown.tsx | 264 +- src/components/MarkdownTable.tsx | 365 +- src/components/MemoryUsageIndicator.tsx | 38 +- src/components/Message.tsx | 1056 +++-- src/components/MessageModel.tsx | 64 +- src/components/MessageResponse.tsx | 104 +- src/components/MessageRow.tsx | 518 ++- src/components/MessageSelector.tsx | 1347 ++++--- src/components/MessageTimestamp.tsx | 93 +- src/components/Messages.tsx | 1271 +++--- src/components/ModelPicker.tsx | 757 ++-- src/components/NativeAutoUpdater.tsx | 227 +- .../NotebookEditToolUseRejectedMessage.tsx | 136 +- src/components/OffscreenFreeze.tsx | 32 +- src/components/Onboarding.tsx | 340 +- src/components/OutputStylePicker.tsx | 190 +- src/components/PackageManagerAutoUpdater.tsx | 218 +- src/components/Passes/Passes.tsx | 247 +- src/components/PrBadge.tsx | 132 +- src/components/PressEnterToContinue.tsx | 22 +- src/components/QuickOpenDialog.tsx | 405 +- src/components/RemoteCallout.tsx | 101 +- src/components/RemoteEnvironmentDialog.tsx | 546 ++- src/components/ResumeTask.tsx | 342 +- .../SandboxViolationExpandedView.tsx | 144 +- src/components/ScrollKeybindingHandler.tsx | 949 +++-- src/components/SearchBox.tsx | 138 +- src/components/SessionBackgroundHint.tsx | 178 +- src/components/SessionPreview.tsx | 313 +- src/components/Settings/Config.tsx | 3500 ++++++++++------- src/components/Settings/Settings.tsx | 264 +- src/components/Settings/Status.tsx | 402 +- src/components/Settings/Usage.tsx | 588 ++- src/components/ShowInIDEPrompt.tsx | 267 +- src/components/SkillImprovementSurvey.tsx | 245 +- src/components/Spinner.tsx | 783 ++-- src/components/Spinner/FlashingChar.tsx | 95 +- src/components/Spinner/GlimmerMessage.tsx | 465 +-- src/components/Spinner/ShimmerChar.tsx | 58 +- .../Spinner/SpinnerAnimationRow.tsx | 411 +- src/components/Spinner/SpinnerGlyph.tsx | 161 +- .../Spinner/TeammateSpinnerLine.tsx | 283 +- .../Spinner/TeammateSpinnerTree.tsx | 387 +- src/components/Stats.tsx | 1717 ++++---- src/components/StatusLine.tsx | 442 ++- src/components/StatusNotices.tsx | 75 +- src/components/StructuredDiff.tsx | 283 +- src/components/StructuredDiff/Fallback.tsx | 562 +-- src/components/StructuredDiffList.tsx | 49 +- src/components/TagTabs.tsx | 181 +- src/components/TaskListV2.tsx | 526 ++- src/components/TeammateViewHeader.tsx | 104 +- src/components/TeleportError.tsx | 299 +- src/components/TeleportProgress.tsx | 233 +- src/components/TeleportRepoMismatchDialog.tsx | 197 +- src/components/TeleportResumeWrapper.tsx | 244 +- src/components/TeleportStash.tsx | 167 +- src/components/TextInput.tsx | 157 +- src/components/ThemePicker.tsx | 541 ++- src/components/ThinkingToggle.tsx | 283 +- src/components/TokenWarning.tsx | 291 +- src/components/ToolUseLoader.tsx | 75 +- src/components/TrustDialog/TrustDialog.tsx | 509 ++- src/components/ValidationErrorsList.tsx | 249 +- src/components/VimTextInput.tsx | 202 +- src/components/VirtualMessageList.tsx | 1213 +++--- src/components/WorkflowMultiselectDialog.tsx | 218 +- src/components/WorktreeExitDialog.tsx | 362 +- src/components/diff/DiffDetailView.tsx | 386 +- src/components/diff/DiffDialog.tsx | 629 ++- src/components/diff/DiffFileList.tsx | 412 +- src/components/grove/Grove.tsx | 785 ++-- src/components/hooks/HooksConfigMenu.tsx | 844 ++-- src/components/hooks/PromptDialog.tsx | 128 +- src/components/hooks/SelectEventMode.tsx | 194 +- src/components/hooks/SelectHookMode.tsx | 177 +- src/components/hooks/SelectMatcherMode.tsx | 224 +- src/components/hooks/ViewHookMode.tsx | 246 +- src/components/memory/MemoryFileSelector.tsx | 701 ++-- .../memory/MemoryUpdateNotification.tsx | 68 +- src/components/messageActions.tsx | 673 ++-- src/components/teams/TeamStatus.tsx | 113 +- src/components/teams/TeamsDialog.tsx | 1110 +++--- src/components/ui/OrderedList.tsx | 106 +- src/components/ui/OrderedListItem.tsx | 61 +- src/components/ui/TreeSelect.tsx | 534 ++- .../useCanSwitchToExistingSubscription.tsx | 84 +- .../useDeprecationWarningNotification.tsx | 71 +- src/hooks/notifs/useFastModeNotification.tsx | 254 +- src/hooks/notifs/useIDEStatusIndicator.tsx | 336 +- src/hooks/notifs/useInstallMessages.tsx | 45 +- .../useLspInitializationNotification.tsx | 235 +- src/hooks/notifs/useMcpConnectivityStatus.tsx | 207 +- .../notifs/useModelMigrationNotifications.tsx | 84 +- .../notifs/useNpmDeprecationNotification.tsx | 50 +- .../usePluginAutoupdateNotification.tsx | 137 +- .../notifs/usePluginInstallationStatus.tsx | 191 +- .../useRateLimitWarningNotification.tsx | 191 +- src/hooks/notifs/useSettingsErrors.tsx | 107 +- src/hooks/useArrowKeyHistory.tsx | 316 +- src/hooks/useCanUseTool.tsx | 529 ++- src/hooks/useChromeExtensionNotification.tsx | 94 +- src/hooks/useClaudeCodeHintRecommendation.tsx | 210 +- src/hooks/useCommandKeybindings.tsx | 129 +- src/hooks/useGlobalKeybindings.tsx | 264 +- src/hooks/useIDEIntegration.tsx | 145 +- src/hooks/useLspPluginRecommendation.tsx | 324 +- .../useOfficialMarketplaceNotification.tsx | 98 +- src/hooks/usePluginRecommendationBase.tsx | 136 +- src/hooks/usePromptsFromClaudeInChrome.tsx | 181 +- src/hooks/useReplBridge.tsx | 966 +++-- src/hooks/useTeleportResume.tsx | 158 +- src/hooks/useTypeahead.tsx | 2181 ++++++---- src/hooks/useVoiceIntegration.tsx | 717 ++-- src/tools/AgentTool/AgentTool.tsx | 2241 ++++++----- src/tools/AgentTool/UI.tsx | 1383 ++++--- .../AskUserQuestionTool.tsx | 405 +- src/tools/BashTool/BashTool.tsx | 1397 ++++--- src/tools/BashTool/BashToolResultMessage.tsx | 249 +- src/tools/BashTool/UI.tsx | 321 +- src/tools/BriefTool/UI.tsx | 110 +- src/tools/ConfigTool/UI.tsx | 43 +- src/tools/EnterPlanModeTool/UI.tsx | 41 +- src/tools/EnterWorktreeTool/UI.tsx | 30 +- src/tools/ExitPlanModeTool/UI.tsx | 97 +- src/tools/ExitWorktreeTool/UI.tsx | 39 +- src/tools/FileEditTool/UI.tsx | 480 +-- src/tools/FileReadTool/UI.tsx | 229 +- src/tools/FileWriteTool/UI.tsx | 623 ++- src/tools/GlobTool/UI.tsx | 91 +- src/tools/GrepTool/UI.tsx | 362 +- src/tools/LSPTool/UI.tsx | 362 +- src/tools/ListMcpResourcesTool/UI.tsx | 49 +- src/tools/MCPTool/UI.tsx | 513 ++- src/tools/NotebookEditTool/UI.tsx | 152 +- src/tools/PowerShellTool/PowerShellTool.tsx | 1169 +++--- src/tools/PowerShellTool/UI.tsx | 237 +- src/tools/ReadMcpResourceTool/UI.tsx | 52 +- src/tools/RemoteTriggerTool/UI.tsx | 22 +- src/tools/ScheduleCronTool/UI.tsx | 74 +- src/tools/SendMessageTool/UI.tsx | 46 +- src/tools/SkillTool/UI.tsx | 230 +- src/tools/TaskOutputTool/TaskOutputTool.tsx | 963 +++-- src/tools/TaskStopTool/UI.tsx | 63 +- src/tools/TeamCreateTool/UI.tsx | 7 +- src/tools/TeamDeleteTool/UI.tsx | 32 +- src/tools/WebFetchTool/UI.tsx | 88 +- src/tools/WebSearchTool/UI.tsx | 143 +- src/tools/testing/TestingPermissionTool.tsx | 59 +- 232 files changed, 40275 insertions(+), 40143 deletions(-) diff --git a/src/components/AgentProgressLine.tsx b/src/components/AgentProgressLine.tsx index 8de2fb060..7580160e7 100644 --- a/src/components/AgentProgressLine.tsx +++ b/src/components/AgentProgressLine.tsx @@ -1,135 +1,105 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../ink.js'; -import { formatNumber } from '../utils/format.js'; -import type { Theme } from '../utils/theme.js'; +import * as React from 'react' +import { Box, Text } from '../ink.js' +import { formatNumber } from '../utils/format.js' +import type { Theme } from '../utils/theme.js' + type Props = { - agentType: string; - description?: string; - name?: string; - descriptionColor?: keyof Theme; - taskDescription?: string; - toolUseCount: number; - tokens: number | null; - color?: keyof Theme; - isLast: boolean; - isResolved: boolean; - isError: boolean; - isAsync?: boolean; - shouldAnimate: boolean; - lastToolInfo?: string | null; - hideType?: boolean; -}; -export function AgentProgressLine(t0) { - const $ = _c(32); - const { - agentType, - description, - name, - descriptionColor, - taskDescription, - toolUseCount, - tokens, - color, - isLast, - isResolved, - isAsync: t1, - lastToolInfo, - hideType: t2 - } = t0; - const isAsync = t1 === undefined ? false : t1; - const hideType = t2 === undefined ? false : t2; - const treeChar = isLast ? "\u2514\u2500" : "\u251C\u2500"; - const isBackgrounded = isAsync && isResolved; - let t3; - if ($[0] !== isBackgrounded || $[1] !== isResolved || $[2] !== lastToolInfo || $[3] !== taskDescription) { - t3 = () => { - if (!isResolved) { - return lastToolInfo || "Initializing\u2026"; - } - if (isBackgrounded) { - return taskDescription ?? "Running in the background"; - } - return "Done"; - }; - $[0] = isBackgrounded; - $[1] = isResolved; - $[2] = lastToolInfo; - $[3] = taskDescription; - $[4] = t3; - } else { - t3 = $[4]; - } - const getStatusText = t3; - let t4; - if ($[5] !== treeChar) { - t4 = {treeChar} ; - $[5] = treeChar; - $[6] = t4; - } else { - t4 = $[6]; - } - const t5 = !isResolved; - let t6; - if ($[7] !== agentType || $[8] !== color || $[9] !== description || $[10] !== descriptionColor || $[11] !== hideType || $[12] !== name) { - t6 = hideType ? <>{name ?? description ?? agentType}{name && description && : {description}} : <>{agentType}{description && <>{" ("}{description}{")"}}; - $[7] = agentType; - $[8] = color; - $[9] = description; - $[10] = descriptionColor; - $[11] = hideType; - $[12] = name; - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - if ($[14] !== isBackgrounded || $[15] !== tokens || $[16] !== toolUseCount) { - t7 = !isBackgrounded && <>{" \xB7 "}{toolUseCount} tool {toolUseCount === 1 ? "use" : "uses"}{tokens !== null && <> · {formatNumber(tokens)} tokens}; - $[14] = isBackgrounded; - $[15] = tokens; - $[16] = toolUseCount; - $[17] = t7; - } else { - t7 = $[17]; - } - let t8; - if ($[18] !== t5 || $[19] !== t6 || $[20] !== t7) { - t8 = {t6}{t7}; - $[18] = t5; - $[19] = t6; - $[20] = t7; - $[21] = t8; - } else { - t8 = $[21]; - } - let t9; - if ($[22] !== t4 || $[23] !== t8) { - t9 = {t4}{t8}; - $[22] = t4; - $[23] = t8; - $[24] = t9; - } else { - t9 = $[24]; - } - let t10; - if ($[25] !== getStatusText || $[26] !== isBackgrounded || $[27] !== isLast) { - t10 = !isBackgrounded && {isLast ? " \u23BF " : "\u2502 \u23BF "}{getStatusText()}; - $[25] = getStatusText; - $[26] = isBackgrounded; - $[27] = isLast; - $[28] = t10; - } else { - t10 = $[28]; - } - let t11; - if ($[29] !== t10 || $[30] !== t9) { - t11 = {t9}{t10}; - $[29] = t10; - $[30] = t9; - $[31] = t11; - } else { - t11 = $[31]; - } - return t11; + agentType: string + description?: string + name?: string + descriptionColor?: keyof Theme + taskDescription?: string + toolUseCount: number + tokens: number | null + color?: keyof Theme + isLast: boolean + isResolved: boolean + isError: boolean + isAsync?: boolean + shouldAnimate: boolean + lastToolInfo?: string | null + hideType?: boolean +} + +export function AgentProgressLine({ + agentType, + description, + name, + descriptionColor, + taskDescription, + toolUseCount, + tokens, + color, + isLast, + isResolved, + isError: _isError, + isAsync = false, + shouldAnimate: _shouldAnimate, + lastToolInfo, + hideType = false, +}: Props): React.ReactNode { + const treeChar = isLast ? '└─' : '├─' + const isBackgrounded = isAsync && isResolved + + // Determine the status text + const getStatusText = (): string => { + if (!isResolved) { + return lastToolInfo || 'Initializing…' + } + if (isBackgrounded) { + return taskDescription ?? 'Running in the background' + } + return 'Done' + } + + return ( + + + {treeChar} + + {hideType ? ( + <> + {name ?? description ?? agentType} + {name && description && : {description}} + + ) : ( + <> + + {agentType} + + {description && ( + <> + {' ('} + + {description} + + {')'} + + )} + + )} + {!isBackgrounded && ( + <> + {' · '} + {toolUseCount} tool {toolUseCount === 1 ? 'use' : 'uses'} + {tokens !== null && <> · {formatNumber(tokens)} tokens} + + )} + + + {!isBackgrounded && ( + + {isLast ? ' ⎿ ' : '│ ⎿ '} + {getStatusText()} + + )} + + ) } diff --git a/src/components/App.tsx b/src/components/App.tsx index dc83cdc29..45e97624a 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,55 +1,37 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { FpsMetricsProvider } from '../context/fpsMetrics.js'; -import { StatsProvider, type StatsStore } from '../context/stats.js'; -import { type AppState, AppStateProvider } from '../state/AppState.js'; -import { onChangeAppState } from '../state/onChangeAppState.js'; -import type { FpsMetrics } from '../utils/fpsTracker.js'; +import React from 'react' +import { FpsMetricsProvider } from '../context/fpsMetrics.js' +import { StatsProvider, type StatsStore } from '../context/stats.js' +import { type AppState, AppStateProvider } from '../state/AppState.js' +import { onChangeAppState } from '../state/onChangeAppState.js' +import type { FpsMetrics } from '../utils/fpsTracker.js' + type Props = { - getFpsMetrics: () => FpsMetrics | undefined; - stats?: StatsStore; - initialState: AppState; - children: React.ReactNode; -}; + getFpsMetrics: () => FpsMetrics | undefined + stats?: StatsStore + initialState: AppState + children: React.ReactNode +} /** * Top-level wrapper for interactive sessions. * Provides FPS metrics, stats context, and app state to the component tree. */ -export function App(t0) { - const $ = _c(9); - const { - getFpsMetrics, - stats, - initialState, - children - } = t0; - let t1; - if ($[0] !== children || $[1] !== initialState) { - t1 = {children}; - $[0] = children; - $[1] = initialState; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== stats || $[4] !== t1) { - t2 = {t1}; - $[3] = stats; - $[4] = t1; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] !== getFpsMetrics || $[7] !== t2) { - t3 = {t2}; - $[6] = getFpsMetrics; - $[7] = t2; - $[8] = t3; - } else { - t3 = $[8]; - } - return t3; +export function App({ + getFpsMetrics, + stats, + initialState, + children, +}: Props): React.ReactNode { + return ( + + + + {children} + + + + ) } diff --git a/src/components/ApproveApiKey.tsx b/src/components/ApproveApiKey.tsx index 1957075f9..990f0c14e 100644 --- a/src/components/ApproveApiKey.tsx +++ b/src/components/ApproveApiKey.tsx @@ -1,122 +1,79 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Text } from '../ink.js'; -import { saveGlobalConfig } from '../utils/config.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; +import React from 'react' +import { Text } from '../ink.js' +import { saveGlobalConfig } from '../utils/config.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + type Props = { - customApiKeyTruncated: string; - onDone(approved: boolean): void; -}; -export function ApproveApiKey(t0) { - const $ = _c(17); - const { - customApiKeyTruncated, - onDone - } = t0; - let t1; - if ($[0] !== customApiKeyTruncated || $[1] !== onDone) { - t1 = function onChange(value) { - bb2: switch (value) { - case "yes": - { - saveGlobalConfig(current_0 => ({ - ...current_0, - customApiKeyResponses: { - ...current_0.customApiKeyResponses, - approved: [...(current_0.customApiKeyResponses?.approved ?? []), customApiKeyTruncated] - } - })); - onDone(true); - break bb2; - } - case "no": - { - saveGlobalConfig(current => ({ - ...current, - customApiKeyResponses: { - ...current.customApiKeyResponses, - rejected: [...(current.customApiKeyResponses?.rejected ?? []), customApiKeyTruncated] - } - })); - onDone(false); - } - } - }; - $[0] = customApiKeyTruncated; - $[1] = onDone; - $[2] = t1; - } else { - t1 = $[2]; - } - const onChange = t1; - let t2; - if ($[3] !== onChange) { - t2 = () => onChange("no"); - $[3] = onChange; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t3 = ANTHROPIC_API_KEY; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== customApiKeyTruncated) { - t4 = {t3}: sk-ant-...{customApiKeyTruncated}; - $[6] = customApiKeyTruncated; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Do you want to use this API key?; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t6 = { - label: "Yes", - value: "yes" - }; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t7 = [t6, { - label: No (recommended), - value: "no" - }]; - $[10] = t7; - } else { - t7 = $[10]; - } - let t8; - if ($[11] !== onChange) { - t8 = + No (recommended) + + ), + value: 'no', + }, + ]} + onChange={value => onChange(value as 'yes' | 'no')} + onCancel={() => onChange('no')} + /> + + ) } diff --git a/src/components/AutoModeOptInDialog.tsx b/src/components/AutoModeOptInDialog.tsx index e1504a9f2..4aeee28e6 100644 --- a/src/components/AutoModeOptInDialog.tsx +++ b/src/components/AutoModeOptInDialog.tsx @@ -1,141 +1,86 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; -import { Box, Link, Text } from '../ink.js'; -import { updateSettingsForSource } from '../utils/settings/settings.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; +import React from 'react' +import { logEvent } from 'src/services/analytics/index.js' +import { Box, Link, Text } from '../ink.js' +import { updateSettingsForSource } from '../utils/settings/settings.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' // NOTE: This copy is legally reviewed — do not modify without Legal team approval. -export const AUTO_MODE_DESCRIPTION = "Auto mode lets Claude handle permission prompts automatically — Claude checks each tool call for risky actions and prompt injection before executing. Actions Claude identifies as safe are executed, while actions Claude identifies as risky are blocked and Claude may try a different approach. Ideal for long-running tasks. Sessions are slightly more expensive. Claude can make mistakes that allow harmful commands to run, it's recommended to only use in isolated environments. Shift+Tab to change mode."; +export const AUTO_MODE_DESCRIPTION = + "Auto mode lets Claude handle permission prompts automatically — Claude checks each tool call for risky actions and prompt injection before executing. Actions Claude identifies as safe are executed, while actions Claude identifies as risky are blocked and Claude may try a different approach. Ideal for long-running tasks. Sessions are slightly more expensive. Claude can make mistakes that allow harmful commands to run, it's recommended to only use in isolated environments. Shift+Tab to change mode." + type Props = { - onAccept(): void; - onDecline(): void; + onAccept(): void + onDecline(): void // Startup gate: decline exits the process, so relabel accordingly. - declineExits?: boolean; -}; -export function AutoModeOptInDialog(t0) { - const $ = _c(18); - const { - onAccept, - onDecline, - declineExits - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - React.useEffect(_temp, t1); - let t2; - if ($[1] !== onAccept || $[2] !== onDecline) { - t2 = function onChange(value) { - bb3: switch (value) { - case "accept": - { - logEvent("tengu_auto_mode_opt_in_dialog_accept", {}); - updateSettingsForSource("userSettings", { - skipAutoPermissionPrompt: true - }); - onAccept(); - break bb3; - } - case "accept-default": - { - logEvent("tengu_auto_mode_opt_in_dialog_accept_default", {}); - updateSettingsForSource("userSettings", { - skipAutoPermissionPrompt: true, - permissions: { - defaultMode: "auto" - } - }); - onAccept(); - break bb3; - } - case "decline": - { - logEvent("tengu_auto_mode_opt_in_dialog_decline", {}); - onDecline(); - } + declineExits?: boolean +} + +export function AutoModeOptInDialog({ + onAccept, + onDecline, + declineExits, +}: Props): React.ReactNode { + React.useEffect(() => { + logEvent('tengu_auto_mode_opt_in_dialog_shown', {}) + }, []) + + function onChange(value: 'accept' | 'accept-default' | 'decline') { + switch (value) { + case 'accept': { + logEvent('tengu_auto_mode_opt_in_dialog_accept', {}) + updateSettingsForSource('userSettings', { + skipAutoPermissionPrompt: true, + }) + onAccept() + break } - }; - $[1] = onAccept; - $[2] = onDecline; - $[3] = t2; - } else { - t2 = $[3]; + case 'accept-default': { + logEvent('tengu_auto_mode_opt_in_dialog_accept_default', {}) + updateSettingsForSource('userSettings', { + skipAutoPermissionPrompt: true, + permissions: { defaultMode: 'auto' }, + }) + onAccept() + break + } + case 'decline': { + logEvent('tengu_auto_mode_opt_in_dialog_decline', {}) + onDecline() + break + } + } } - const onChange = t2; - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = {AUTO_MODE_DESCRIPTION}; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = true ? [{ - label: "Yes, and make it my default mode", - value: "accept-default" as const - }] : []; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: "Yes, enable auto mode", - value: "accept" as const - }; - $[6] = t5; - } else { - t5 = $[6]; - } - const t6 = declineExits ? "No, exit" : "No, go back"; - let t7; - if ($[7] !== t6) { - t7 = [...t4, t5, { - label: t6, - value: "decline" as const - }]; - $[7] = t6; - $[8] = t7; - } else { - t7 = $[8]; - } - let t8; - if ($[9] !== onChange) { - t8 = value_0 => onChange(value_0 as 'accept' | 'accept-default' | 'decline'); - $[9] = onChange; - $[10] = t8; - } else { - t8 = $[10]; - } - let t9; - if ($[11] !== onDecline || $[12] !== t7 || $[13] !== t8) { - t9 = + onChange(value as 'accept' | 'accept-default' | 'decline') + } + onCancel={onDecline} + /> + + ) } diff --git a/src/components/AutoUpdater.tsx b/src/components/AutoUpdater.tsx index 3acbbfc89..09b523fc4 100644 --- a/src/components/AutoUpdater.tsx +++ b/src/components/AutoUpdater.tsx @@ -1,197 +1,264 @@ -import * as React from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { useInterval } from 'usehooks-ts'; -import { useUpdateNotification } from '../hooks/useUpdateNotification.js'; -import { Box, Text } from '../ink.js'; -import { type AutoUpdaterResult, getLatestVersion, getMaxVersion, type InstallStatus, installGlobalPackage, shouldSkipVersion } from '../utils/autoUpdater.js'; -import { getGlobalConfig, isAutoUpdaterDisabled } from '../utils/config.js'; -import { logForDebugging } from '../utils/debug.js'; -import { getCurrentInstallationType } from '../utils/doctorDiagnostic.js'; -import { installOrUpdateClaudePackage, localInstallationExists } from '../utils/localInstaller.js'; -import { removeInstalledSymlink } from '../utils/nativeInstaller/index.js'; -import { gt, gte } from '../utils/semver.js'; -import { getInitialSettings } from '../utils/settings/settings.js'; +import * as React from 'react' +import { useEffect, useRef, useState } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { useInterval } from 'usehooks-ts' +import { useUpdateNotification } from '../hooks/useUpdateNotification.js' +import { Box, Text } from '../ink.js' +import { + type AutoUpdaterResult, + getLatestVersion, + getMaxVersion, + type InstallStatus, + installGlobalPackage, + shouldSkipVersion, +} from '../utils/autoUpdater.js' +import { getGlobalConfig, isAutoUpdaterDisabled } from '../utils/config.js' +import { logForDebugging } from '../utils/debug.js' +import { getCurrentInstallationType } from '../utils/doctorDiagnostic.js' +import { + installOrUpdateClaudePackage, + localInstallationExists, +} from '../utils/localInstaller.js' +import { removeInstalledSymlink } from '../utils/nativeInstaller/index.js' +import { gt, gte } from '../utils/semver.js' +import { getInitialSettings } from '../utils/settings/settings.js' + type Props = { - isUpdating: boolean; - onChangeIsUpdating: (isUpdating: boolean) => void; - onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void; - autoUpdaterResult: AutoUpdaterResult | null; - showSuccessMessage: boolean; - verbose: boolean; -}; + isUpdating: boolean + onChangeIsUpdating: (isUpdating: boolean) => void + onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void + autoUpdaterResult: AutoUpdaterResult | null + showSuccessMessage: boolean + verbose: boolean +} + export function AutoUpdater({ isUpdating, onChangeIsUpdating, onAutoUpdaterResult, autoUpdaterResult, showSuccessMessage, - verbose + verbose, }: Props): React.ReactNode { const [versions, setVersions] = useState<{ - global?: string | null; - latest?: string | null; - }>({}); - const [hasLocalInstall, setHasLocalInstall] = useState(false); - const updateSemver = useUpdateNotification(autoUpdaterResult?.version); + global?: string | null + latest?: string | null + }>({}) + const [hasLocalInstall, setHasLocalInstall] = useState(false) + const updateSemver = useUpdateNotification(autoUpdaterResult?.version) + useEffect(() => { - void localInstallationExists().then(setHasLocalInstall); - }, []); + void localInstallationExists().then(setHasLocalInstall) + }, []) // Track latest isUpdating value in a ref so the memoized checkForUpdates // callback always sees the current value. Without this, the 30-minute // interval fires with a stale closure where isUpdating is false, allowing // a concurrent installGlobalPackage() to run while one is already in // progress. - const isUpdatingRef = useRef(isUpdating); - isUpdatingRef.current = isUpdating; + const isUpdatingRef = useRef(isUpdating) + isUpdatingRef.current = isUpdating + const checkForUpdates = React.useCallback(async () => { if (isUpdatingRef.current) { - return; + return } - if (("production" as string) === 'test' || ("production" as string) === 'development') { - logForDebugging('AutoUpdater: Skipping update check in test/dev environment'); - return; + + if ( + "production" === 'test' || + "production" === 'development' + ) { + logForDebugging( + 'AutoUpdater: Skipping update check in test/dev environment', + ) + return } - const currentVersion = MACRO.VERSION; - const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest'; - let latestVersion = await getLatestVersion(channel); - const isDisabled = isAutoUpdaterDisabled(); + + const currentVersion = MACRO.VERSION + const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest' + let latestVersion = await getLatestVersion(channel) + const isDisabled = isAutoUpdaterDisabled() // Check if max version is set (server-side kill switch for auto-updates) - const maxVersion = await getMaxVersion(); + const maxVersion = await getMaxVersion() if (maxVersion && latestVersion && gt(latestVersion, maxVersion)) { - logForDebugging(`AutoUpdater: maxVersion ${maxVersion} is set, capping update from ${latestVersion} to ${maxVersion}`); + logForDebugging( + `AutoUpdater: maxVersion ${maxVersion} is set, capping update from ${latestVersion} to ${maxVersion}`, + ) if (gte(currentVersion, maxVersion)) { - logForDebugging(`AutoUpdater: current version ${currentVersion} is already at or above maxVersion ${maxVersion}, skipping update`); - setVersions({ - global: currentVersion, - latest: latestVersion - }); - return; + logForDebugging( + `AutoUpdater: current version ${currentVersion} is already at or above maxVersion ${maxVersion}, skipping update`, + ) + setVersions({ global: currentVersion, latest: latestVersion }) + return } - latestVersion = maxVersion; + latestVersion = maxVersion } - setVersions({ - global: currentVersion, - latest: latestVersion - }); + + setVersions({ global: currentVersion, latest: latestVersion }) // Check if update needed and perform update - if (!isDisabled && currentVersion && latestVersion && !gte(currentVersion, latestVersion) && !shouldSkipVersion(latestVersion)) { - const startTime = Date.now(); - onChangeIsUpdating(true); + if ( + !isDisabled && + currentVersion && + latestVersion && + !gte(currentVersion, latestVersion) && + !shouldSkipVersion(latestVersion) + ) { + const startTime = Date.now() + onChangeIsUpdating(true) // Remove native installer symlink since we're using JS-based updates // But only if user hasn't migrated to native installation - const config = getGlobalConfig(); + const config = getGlobalConfig() if (config.installMethod !== 'native') { - await removeInstalledSymlink(); + await removeInstalledSymlink() } // Detect actual running installation type - const installationType = await getCurrentInstallationType(); - logForDebugging(`AutoUpdater: Detected installation type: ${installationType}`); + const installationType = await getCurrentInstallationType() + logForDebugging( + `AutoUpdater: Detected installation type: ${installationType}`, + ) // Skip update for development builds if (installationType === 'development') { - logForDebugging('AutoUpdater: Cannot auto-update development build'); - onChangeIsUpdating(false); - return; + logForDebugging('AutoUpdater: Cannot auto-update development build') + onChangeIsUpdating(false) + return } // Choose the appropriate update method based on what's actually running - let installStatus: InstallStatus; - let updateMethod: 'local' | 'global'; + let installStatus: InstallStatus + let updateMethod: 'local' | 'global' + if (installationType === 'npm-local') { // Use local update for local installations - logForDebugging('AutoUpdater: Using local update method'); - updateMethod = 'local'; - installStatus = await installOrUpdateClaudePackage(channel); + logForDebugging('AutoUpdater: Using local update method') + updateMethod = 'local' + installStatus = await installOrUpdateClaudePackage(channel) } else if (installationType === 'npm-global') { // Use global update for global installations - logForDebugging('AutoUpdater: Using global update method'); - updateMethod = 'global'; - installStatus = await installGlobalPackage(); + logForDebugging('AutoUpdater: Using global update method') + updateMethod = 'global' + installStatus = await installGlobalPackage() } else if (installationType === 'native') { // This shouldn't happen - native should use NativeAutoUpdater - logForDebugging('AutoUpdater: Unexpected native installation in non-native updater'); - onChangeIsUpdating(false); - return; + logForDebugging( + 'AutoUpdater: Unexpected native installation in non-native updater', + ) + onChangeIsUpdating(false) + return } else { // Fallback to config-based detection for unknown types - logForDebugging(`AutoUpdater: Unknown installation type, falling back to config`); - const isMigrated = config.installMethod === 'local'; - updateMethod = isMigrated ? 'local' : 'global'; + logForDebugging( + `AutoUpdater: Unknown installation type, falling back to config`, + ) + const isMigrated = config.installMethod === 'local' + updateMethod = isMigrated ? 'local' : 'global' + if (isMigrated) { - installStatus = await installOrUpdateClaudePackage(channel); + installStatus = await installOrUpdateClaudePackage(channel) } else { - installStatus = await installGlobalPackage(); + installStatus = await installGlobalPackage() } } - onChangeIsUpdating(false); + + onChangeIsUpdating(false) + if (installStatus === 'success') { logEvent('tengu_auto_updater_success', { - fromVersion: currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - toVersion: latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + fromVersion: + currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + toVersion: + latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, durationMs: Date.now() - startTime, wasMigrated: updateMethod === 'local', - installationType: installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + installationType: + installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } else { logEvent('tengu_auto_updater_fail', { - fromVersion: currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - attemptedVersion: latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - status: installStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + fromVersion: + currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + attemptedVersion: + latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + status: + installStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, durationMs: Date.now() - startTime, wasMigrated: updateMethod === 'local', - installationType: installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + installationType: + installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } + onAutoUpdaterResult({ version: latestVersion, - status: installStatus - }); + status: installStatus, + }) } // isUpdating intentionally omitted from deps; we read isUpdatingRef // instead so the guard is always current without changing callback // identity (which would re-trigger the initial-check useEffect below). // eslint-disable-next-line react-hooks/exhaustive-deps // biome-ignore lint/correctness/useExhaustiveDependencies: isUpdating read via ref - }, [onAutoUpdaterResult]); + }, [onAutoUpdaterResult]) // Initial check useEffect(() => { - void checkForUpdates(); - }, [checkForUpdates]); + void checkForUpdates() + }, [checkForUpdates]) // Check every 30 minutes - useInterval(checkForUpdates, 30 * 60 * 1000); + useInterval(checkForUpdates, 30 * 60 * 1000) + if (!autoUpdaterResult?.version && (!versions.global || !versions.latest)) { - return null; + return null } + if (!autoUpdaterResult?.version && !isUpdating) { - return null; + return null } - return - {verbose && + + return ( + + {verbose && ( + globalVersion: {versions.global} · latestVersion:{' '} {versions.latest} - } - {isUpdating ? <> + + )} + {isUpdating ? ( + <> Auto-updating… - : autoUpdaterResult?.status === 'success' && showSuccessMessage && updateSemver && + + ) : ( + autoUpdaterResult?.status === 'success' && + showSuccessMessage && + updateSemver && ( + ✓ Update installed · Restart to apply - } - {(autoUpdaterResult?.status === 'install_failed' || autoUpdaterResult?.status === 'no_permissions') && + + ) + )} + {(autoUpdaterResult?.status === 'install_failed' || + autoUpdaterResult?.status === 'no_permissions') && ( + ✗ Auto-update failed · Try claude doctor or{' '} - {hasLocalInstall ? `cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}` : `npm i -g ${MACRO.PACKAGE_URL}`} + {hasLocalInstall + ? `cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}` + : `npm i -g ${MACRO.PACKAGE_URL}`} - } - ; + + )} + + ) } diff --git a/src/components/AutoUpdaterWrapper.tsx b/src/components/AutoUpdaterWrapper.tsx index e229d09d2..709c776d2 100644 --- a/src/components/AutoUpdaterWrapper.tsx +++ b/src/components/AutoUpdaterWrapper.tsx @@ -1,90 +1,90 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import type { AutoUpdaterResult } from '../utils/autoUpdater.js'; -import { isAutoUpdaterDisabled } from '../utils/config.js'; -import { logForDebugging } from '../utils/debug.js'; -import { getCurrentInstallationType } from '../utils/doctorDiagnostic.js'; -import { AutoUpdater } from './AutoUpdater.js'; -import { NativeAutoUpdater } from './NativeAutoUpdater.js'; -import { PackageManagerAutoUpdater } from './PackageManagerAutoUpdater.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import type { AutoUpdaterResult } from '../utils/autoUpdater.js' +import { isAutoUpdaterDisabled } from '../utils/config.js' +import { logForDebugging } from '../utils/debug.js' +import { getCurrentInstallationType } from '../utils/doctorDiagnostic.js' +import { AutoUpdater } from './AutoUpdater.js' +import { NativeAutoUpdater } from './NativeAutoUpdater.js' +import { PackageManagerAutoUpdater } from './PackageManagerAutoUpdater.js' + type Props = { - isUpdating: boolean; - onChangeIsUpdating: (isUpdating: boolean) => void; - onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void; - autoUpdaterResult: AutoUpdaterResult | null; - showSuccessMessage: boolean; - verbose: boolean; -}; -export function AutoUpdaterWrapper(t0) { - const $ = _c(17); - const { - isUpdating, - onChangeIsUpdating, - onAutoUpdaterResult, - autoUpdaterResult, - showSuccessMessage, - verbose - } = t0; - const [useNativeInstaller, setUseNativeInstaller] = React.useState(null); - const [isPackageManager, setIsPackageManager] = React.useState(null); - let t1; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => { - const checkInstallation = async function checkInstallation() { - if (feature("SKIP_DETECTION_WHEN_AUTOUPDATES_DISABLED") && isAutoUpdaterDisabled()) { - logForDebugging("AutoUpdaterWrapper: Skipping detection, auto-updates disabled"); - return; - } - const installationType = await getCurrentInstallationType(); - logForDebugging(`AutoUpdaterWrapper: Installation type: ${installationType}`); - setUseNativeInstaller(installationType === "native"); - setIsPackageManager(installationType === "package-manager"); - }; - checkInstallation(); - }; - t2 = []; - $[0] = t1; - $[1] = t2; - } else { - t1 = $[0]; - t2 = $[1]; - } - React.useEffect(t1, t2); - if (useNativeInstaller === null || isPackageManager === null) { - return null; - } - if (isPackageManager) { - let t3; - if ($[2] !== autoUpdaterResult || $[3] !== isUpdating || $[4] !== onAutoUpdaterResult || $[5] !== onChangeIsUpdating || $[6] !== showSuccessMessage || $[7] !== verbose) { - t3 = ; - $[2] = autoUpdaterResult; - $[3] = isUpdating; - $[4] = onAutoUpdaterResult; - $[5] = onChangeIsUpdating; - $[6] = showSuccessMessage; - $[7] = verbose; - $[8] = t3; - } else { - t3 = $[8]; - } - return t3; - } - const Updater = useNativeInstaller ? NativeAutoUpdater : AutoUpdater; - let t3; - if ($[9] !== Updater || $[10] !== autoUpdaterResult || $[11] !== isUpdating || $[12] !== onAutoUpdaterResult || $[13] !== onChangeIsUpdating || $[14] !== showSuccessMessage || $[15] !== verbose) { - t3 = ; - $[9] = Updater; - $[10] = autoUpdaterResult; - $[11] = isUpdating; - $[12] = onAutoUpdaterResult; - $[13] = onChangeIsUpdating; - $[14] = showSuccessMessage; - $[15] = verbose; - $[16] = t3; - } else { - t3 = $[16]; - } - return t3; + isUpdating: boolean + onChangeIsUpdating: (isUpdating: boolean) => void + onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void + autoUpdaterResult: AutoUpdaterResult | null + showSuccessMessage: boolean + verbose: boolean +} + +export function AutoUpdaterWrapper({ + isUpdating, + onChangeIsUpdating, + onAutoUpdaterResult, + autoUpdaterResult, + showSuccessMessage, + verbose, +}: Props): React.ReactNode { + const [useNativeInstaller, setUseNativeInstaller] = React.useState< + boolean | null + >(null) + const [isPackageManager, setIsPackageManager] = React.useState< + boolean | null + >(null) + + React.useEffect(() => { + async function checkInstallation() { + // Skip installation type detection if auto-updates are disabled (ant-only) + // This avoids potentially slow package manager detection (spawnSync calls) + if ( + feature('SKIP_DETECTION_WHEN_AUTOUPDATES_DISABLED') && + isAutoUpdaterDisabled() + ) { + logForDebugging( + 'AutoUpdaterWrapper: Skipping detection, auto-updates disabled', + ) + return + } + + const installationType = await getCurrentInstallationType() + logForDebugging( + `AutoUpdaterWrapper: Installation type: ${installationType}`, + ) + setUseNativeInstaller(installationType === 'native') + setIsPackageManager(installationType === 'package-manager') + } + + void checkInstallation() + }, []) + + // Don't render until we know the installation type + if (useNativeInstaller === null || isPackageManager === null) { + return null + } + + if (isPackageManager) { + return ( + + ) + } + + const Updater = useNativeInstaller ? NativeAutoUpdater : AutoUpdater + + return ( + + ) } diff --git a/src/components/AwsAuthStatusBox.tsx b/src/components/AwsAuthStatusBox.tsx index 6f20bd497..ea2d1a5d3 100644 --- a/src/components/AwsAuthStatusBox.tsx +++ b/src/components/AwsAuthStatusBox.tsx @@ -1,81 +1,76 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useEffect, useState } from 'react'; -import { Box, Link, Text } from '../ink.js'; -import { type AwsAuthStatus, AwsAuthStatusManager } from '../utils/awsAuthStatusManager.js'; -const URL_RE = /https?:\/\/\S+/; -export function AwsAuthStatusBox() { - const $ = _c(11); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = AwsAuthStatusManager.getInstance().getStatus(); - $[0] = t0; - } else { - t0 = $[0]; - } - const [status, setStatus] = useState(t0); - let t1; - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => { - const unsubscribe = AwsAuthStatusManager.getInstance().subscribe(setStatus); - return unsubscribe; - }; - t2 = []; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; - } - useEffect(t1, t2); +import React, { useEffect, useState } from 'react' +import { Box, Link, Text } from '../ink.js' +import { + type AwsAuthStatus, + AwsAuthStatusManager, +} from '../utils/awsAuthStatusManager.js' + +const URL_RE = /https?:\/\/\S+/ + +export function AwsAuthStatusBox(): React.ReactNode { + const [status, setStatus] = useState( + AwsAuthStatusManager.getInstance().getStatus(), + ) + + useEffect(() => { + // Subscribe to status updates + const unsubscribe = AwsAuthStatusManager.getInstance().subscribe(setStatus) + return unsubscribe + }, []) + + // Don't show anything if not authenticating and no error if (!status.isAuthenticating && !status.error && status.output.length === 0) { - return null; + return null } + + // Don't show if authentication succeeded (no error and not authenticating) if (!status.isAuthenticating && !status.error) { - return null; + return null } - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Cloud Authentication; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] !== status.output) { - t4 = status.output.length > 0 && {status.output.slice(-5).map(_temp)}; - $[4] = status.output; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== status.error) { - t5 = status.error && {status.error}; - $[6] = status.error; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== t4 || $[9] !== t5) { - t6 = {t3}{t4}{t5}; - $[8] = t4; - $[9] = t5; - $[10] = t6; - } else { - t6 = $[10]; - } - return t6; -} -function _temp(line, index) { - const m = line.match(URL_RE); - if (!m) { - return {line}; - } - const url = m[0]; - const start = m.index ?? 0; - const before = line.slice(0, start); - const after = line.slice(start + url.length); - return {before}{url}{after}; + + return ( + + + Cloud Authentication + + + {status.output.length > 0 && ( + + {status.output.slice(-5).map((line, index) => { + const m = line.match(URL_RE) + if (!m) { + return ( + + {line} + + ) + } + const url = m[0] + const start = m.index ?? 0 + const before = line.slice(0, start) + const after = line.slice(start + url.length) + return ( + + {before} + {url} + {after} + + ) + })} + + )} + + {status.error && ( + + {status.error} + + )} + + ) } diff --git a/src/components/BaseTextInput.tsx b/src/components/BaseTextInput.tsx index c4f9e1f0d..07d12974b 100644 --- a/src/components/BaseTextInput.tsx +++ b/src/components/BaseTextInput.tsx @@ -1,135 +1,162 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { renderPlaceholder } from '../hooks/renderPlaceholder.js'; -import { usePasteHandler } from '../hooks/usePasteHandler.js'; -import { useDeclaredCursor } from '../ink/hooks/use-declared-cursor.js'; -import { Ansi, Box, Text, useInput } from '../ink.js'; -import type { BaseInputState, BaseTextInputProps } from '../types/textInputTypes.js'; -import type { TextHighlight } from '../utils/textHighlighting.js'; -import { HighlightedInput } from './PromptInput/ShimmeredInput.js'; +import React from 'react' +import { renderPlaceholder } from '../hooks/renderPlaceholder.js' +import { usePasteHandler } from '../hooks/usePasteHandler.js' +import { useDeclaredCursor } from '../ink/hooks/use-declared-cursor.js' +import { Ansi, Box, Text, useInput } from '../ink.js' +import type { + BaseInputState, + BaseTextInputProps, +} from '../types/textInputTypes.js' +import type { TextHighlight } from '../utils/textHighlighting.js' +import { HighlightedInput } from './PromptInput/ShimmeredInput.js' + type BaseTextInputComponentProps = BaseTextInputProps & { - inputState: BaseInputState; - children?: React.ReactNode; - terminalFocus: boolean; - highlights?: TextHighlight[]; - invert?: (text: string) => string; - hidePlaceholderText?: boolean; -}; + inputState: BaseInputState + children?: React.ReactNode + terminalFocus: boolean + highlights?: TextHighlight[] + invert?: (text: string) => string + hidePlaceholderText?: boolean +} /** * A base component for text inputs that handles rendering and basic input */ -export function BaseTextInput(t0) { - const $ = _c(14); - const { - inputState, - children, - terminalFocus, - invert, - hidePlaceholderText, - ...props - } = t0; - const { - onInput, - renderedValue, - cursorLine, - cursorColumn - } = inputState; - const t1 = Boolean(props.focus && props.showCursor && terminalFocus); - let t2; - if ($[0] !== cursorColumn || $[1] !== cursorLine || $[2] !== t1) { - t2 = { - line: cursorLine, - column: cursorColumn, - active: t1 - }; - $[0] = cursorColumn; - $[1] = cursorLine; - $[2] = t1; - $[3] = t2; - } else { - t2 = $[3]; - } - const cursorRef = useDeclaredCursor(t2); - const { - wrappedOnInput, - isPasting: t3 - } = usePasteHandler({ +export function BaseTextInput({ + inputState, + children, + terminalFocus, + invert, + hidePlaceholderText, + ...props +}: BaseTextInputComponentProps): React.ReactNode { + const { onInput, renderedValue, cursorLine, cursorColumn } = inputState + + // Park the native terminal cursor at the input caret. Terminal emulators + // position IME preedit text at the physical cursor, and screen readers / + // screen magnifiers track it — so parking here makes CJK input appear + // inline and lets accessibility tools follow the input. The Box ref below + // is the yoga layout origin; (cursorLine, cursorColumn) is relative to it. + // Only active when the input is focused, showing its cursor, and the + // terminal itself has focus. + const cursorRef = useDeclaredCursor({ + line: cursorLine, + column: cursorColumn, + active: Boolean(props.focus && props.showCursor && terminalFocus), + }) + + const { wrappedOnInput, isPasting } = usePasteHandler({ onPaste: props.onPaste, onInput: (input, key) => { + // Prevent Enter key from triggering submission during paste if (isPasting && key.return) { - return; + return } - onInput(input, key); + onInput(input, key) }, - onImagePaste: props.onImagePaste - }); - const isPasting = t3; - const { - onIsPastingChange - } = props; + onImagePaste: props.onImagePaste, + }) + + // Notify parent when paste state changes + const { onIsPastingChange } = props React.useEffect(() => { if (onIsPastingChange) { - onIsPastingChange(isPasting); + onIsPastingChange(isPasting) } - }, [isPasting, onIsPastingChange]); - const { - showPlaceholder, - renderedPlaceholder - } = renderPlaceholder({ + }, [isPasting, onIsPastingChange]) + + const { showPlaceholder, renderedPlaceholder } = renderPlaceholder({ placeholder: props.placeholder, value: props.value, showCursor: props.showCursor, focus: props.focus, terminalFocus, invert, - hidePlaceholderText - }); - useInput(wrappedOnInput, { - isActive: props.focus - }); - const commandWithoutArgs = props.value && props.value.trim().indexOf(" ") === -1 || props.value && props.value.endsWith(" "); - const showArgumentHint = Boolean(props.argumentHint && props.value && commandWithoutArgs && props.value.startsWith("/")); - const cursorFiltered = props.showCursor && props.highlights ? props.highlights.filter(h => h.dimColor || props.cursorOffset < h.start || props.cursorOffset >= h.end) : props.highlights; - const { - viewportCharOffset, - viewportCharEnd - } = inputState; - const filteredHighlights = cursorFiltered && viewportCharOffset > 0 ? cursorFiltered.filter(h_0 => h_0.end > viewportCharOffset && h_0.start < viewportCharEnd).map(h_1 => ({ - ...h_1, - start: Math.max(0, h_1.start - viewportCharOffset), - end: h_1.end - viewportCharOffset - })) : cursorFiltered; - const hasHighlights = filteredHighlights && filteredHighlights.length > 0; + hidePlaceholderText, + }) + + useInput(wrappedOnInput, { isActive: props.focus }) + + // Show argument hint only when we have a value and the hint is provided + // Only show the argument hint when: + // 1. We have a hint to show + // 2. We have a command typed (value is not empty) + // 3. The command doesn't have arguments yet (no text after the space) + // 4. We're actually typing a command (the value starts with /) + const commandWithoutArgs = + (props.value && props.value.trim().indexOf(' ') === -1) || + (props.value && props.value.endsWith(' ')) + + const showArgumentHint = Boolean( + props.argumentHint && + props.value && + commandWithoutArgs && + props.value.startsWith('/'), + ) + + // Filter out highlights that contain the cursor position + const cursorFiltered = + props.showCursor && props.highlights + ? props.highlights.filter( + h => + h.dimColor || + props.cursorOffset < h.start || + props.cursorOffset >= h.end, + ) + : props.highlights + + // Adjust highlights for viewport windowing: highlight positions reference the + // full input text, but renderedValue only contains the windowed subset. + const { viewportCharOffset, viewportCharEnd } = inputState + const filteredHighlights = + cursorFiltered && viewportCharOffset > 0 + ? cursorFiltered + .filter(h => h.end > viewportCharOffset && h.start < viewportCharEnd) + .map(h => ({ + ...h, + start: Math.max(0, h.start - viewportCharOffset), + end: h.end - viewportCharOffset, + })) + : cursorFiltered + + const hasHighlights = filteredHighlights && filteredHighlights.length > 0 + if (hasHighlights) { - return {showArgumentHint && {props.value?.endsWith(" ") ? "" : " "}{props.argumentHint}}{children}; + return ( + + + {showArgumentHint && ( + + {props.value?.endsWith(' ') ? '' : ' '} + {props.argumentHint} + + )} + {children} + + ) } - const T0 = Box; - const T1 = Text; - const t4 = "truncate-end"; - const t5 = showPlaceholder && props.placeholderElement ? props.placeholderElement : showPlaceholder && renderedPlaceholder ? {renderedPlaceholder} : {renderedValue}; - const t6 = showArgumentHint && {props.value?.endsWith(" ") ? "" : " "}{props.argumentHint}; - let t7; - if ($[4] !== T1 || $[5] !== children || $[6] !== props || $[7] !== t5 || $[8] !== t6) { - t7 = {t5}{t6}{children}; - $[4] = T1; - $[5] = children; - $[6] = props; - $[7] = t5; - $[8] = t6; - $[9] = t7; - } else { - t7 = $[9]; - } - let t8; - if ($[10] !== T0 || $[11] !== cursorRef || $[12] !== t7) { - t8 = {t7}; - $[10] = T0; - $[11] = cursorRef; - $[12] = t7; - $[13] = t8; - } else { - t8 = $[13]; - } - return t8; + + return ( + + + {showPlaceholder && props.placeholderElement ? ( + props.placeholderElement + ) : showPlaceholder && renderedPlaceholder ? ( + {renderedPlaceholder} + ) : ( + {renderedValue} + )} + {showArgumentHint && ( + + {props.value?.endsWith(' ') ? '' : ' '} + {props.argumentHint} + + )} + {children} + + + ) } diff --git a/src/components/BashModeProgress.tsx b/src/components/BashModeProgress.tsx index fc254e371..0b6d4b408 100644 --- a/src/components/BashModeProgress.tsx +++ b/src/components/BashModeProgress.tsx @@ -1,55 +1,42 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box } from '../ink.js'; -import { BashTool } from '../tools/BashTool/BashTool.js'; -import type { ShellProgress } from '../types/tools.js'; -import { UserBashInputMessage } from './messages/UserBashInputMessage.js'; -import { ShellProgressMessage } from './shell/ShellProgressMessage.js'; +import React from 'react' +import { Box } from '../ink.js' +import { BashTool } from '../tools/BashTool/BashTool.js' +import type { ShellProgress } from '../types/tools.js' +import { UserBashInputMessage } from './messages/UserBashInputMessage.js' +import { ShellProgressMessage } from './shell/ShellProgressMessage.js' + type Props = { - input: string; - progress: ShellProgress | null; - verbose: boolean; -}; -export function BashModeProgress(t0) { - const $ = _c(8); - const { - input, - progress, - verbose - } = t0; - const t1 = `${input}`; - let t2; - if ($[0] !== t1) { - t2 = ; - $[0] = t1; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== progress || $[3] !== verbose) { - t3 = progress ? : BashTool.renderToolUseProgressMessage?.([], { - verbose, - tools: [], - terminalSize: undefined - }); - $[2] = progress; - $[3] = verbose; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t2 || $[6] !== t3) { - t4 = {t2}{t3}; - $[5] = t2; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - return t4; + input: string + progress: ShellProgress | null + verbose: boolean +} + +export function BashModeProgress({ + input, + progress, + verbose, +}: Props): React.ReactNode { + return ( + + ${input}`, type: 'text' }} + /> + {progress ? ( + + ) : ( + BashTool.renderToolUseProgressMessage?.([], { + verbose, + tools: [], + terminalSize: undefined, + }) + )} + + ) } diff --git a/src/components/BridgeDialog.tsx b/src/components/BridgeDialog.tsx index 48cadc202..9a23311fb 100644 --- a/src/components/BridgeDialog.tsx +++ b/src/components/BridgeDialog.tsx @@ -1,400 +1,160 @@ -import { c as _c } from "react/compiler-runtime"; -import { basename } from 'path'; -import { toString as qrToString } from 'qrcode'; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { getOriginalCwd } from '../bootstrap/state.js'; -import { buildActiveFooterText, buildIdleFooterText, FAILED_FOOTER_TEXT, getBridgeStatus } from '../bridge/bridgeStatusUtil.js'; -import { BRIDGE_FAILED_INDICATOR, BRIDGE_READY_INDICATOR } from '../constants/figures.js'; -import { useRegisterOverlay } from '../context/overlayContext.js'; +import { basename } from 'path' +import { toString as qrToString } from 'qrcode' +import * as React from 'react' +import { useEffect, useState } from 'react' +import { getOriginalCwd } from '../bootstrap/state.js' +import { + buildActiveFooterText, + buildIdleFooterText, + FAILED_FOOTER_TEXT, + getBridgeStatus, +} from '../bridge/bridgeStatusUtil.js' +import { + BRIDGE_FAILED_INDICATOR, + BRIDGE_READY_INDICATOR, +} from '../constants/figures.js' +import { useRegisterOverlay } from '../context/overlayContext.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw 'd' key for disconnect, not a configurable keybinding action -import { Box, Text, useInput } from '../ink.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import { useAppState, useSetAppState } from '../state/AppState.js'; -import { saveGlobalConfig } from '../utils/config.js'; -import { getBranch } from '../utils/git.js'; -import { Dialog } from './design-system/Dialog.js'; +import { Box, Text, useInput } from '../ink.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import { useAppState, useSetAppState } from '../state/AppState.js' +import { saveGlobalConfig } from '../utils/config.js' +import { getBranch } from '../utils/git.js' +import { Dialog } from './design-system/Dialog.js' + type Props = { - onDone: () => void; -}; -export function BridgeDialog(t0) { - const $ = _c(87); - const { - onDone - } = t0; - useRegisterOverlay("bridge-dialog", undefined); - const connected = useAppState(_temp); - const sessionActive = useAppState(_temp2); - const reconnecting = useAppState(_temp3); - const connectUrl = useAppState(_temp4); - const sessionUrl = useAppState(_temp5); - const error = useAppState(_temp6); - const explicit = useAppState(_temp7); - const environmentId = useAppState(_temp8); - const sessionId = useAppState(_temp9); - const verbose = useAppState(_temp0); - const setAppState = useSetAppState(); - const [showQR, setShowQR] = useState(false); - const [qrText, setQrText] = useState(""); - const [branchName, setBranchName] = useState(""); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = basename(getOriginalCwd()); - $[0] = t1; - } else { - t1 = $[0]; - } - const repoName = t1; - let t2; - let t3; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => { - getBranch().then(setBranchName).catch(_temp1); - }; - t3 = []; - $[1] = t2; - $[2] = t3; - } else { - t2 = $[1]; - t3 = $[2]; - } - useEffect(t2, t3); - const displayUrl = sessionActive ? sessionUrl : connectUrl; - let t4; - let t5; - if ($[3] !== displayUrl || $[4] !== showQR) { - t4 = () => { - if (!showQR || !displayUrl) { - setQrText(""); - return; + onDone: () => void +} + +export function BridgeDialog({ onDone }: Props): React.ReactNode { + useRegisterOverlay('bridge-dialog') + + const connected = useAppState(s => s.replBridgeConnected) + const sessionActive = useAppState(s => s.replBridgeSessionActive) + const reconnecting = useAppState(s => s.replBridgeReconnecting) + const connectUrl = useAppState(s => s.replBridgeConnectUrl) + const sessionUrl = useAppState(s => s.replBridgeSessionUrl) + const error = useAppState(s => s.replBridgeError) + const explicit = useAppState(s => s.replBridgeExplicit) + const environmentId = useAppState(s => s.replBridgeEnvironmentId) + const sessionId = useAppState(s => s.replBridgeSessionId) + const verbose = useAppState(s => s.verbose) + const setAppState = useSetAppState() + + const [showQR, setShowQR] = useState(false) + const [qrText, setQrText] = useState('') + const [branchName, setBranchName] = useState('') + + const repoName = basename(getOriginalCwd()) + + // Fetch branch name on mount + useEffect(() => { + getBranch() + .then(setBranchName) + .catch(() => {}) + }, []) + + // The URL to display/QR: session URL when connected, connect URL when ready + const displayUrl = sessionActive ? sessionUrl : connectUrl + + // Generate QR code when URL changes or QR is toggled on + useEffect(() => { + if (!showQR || !displayUrl) { + setQrText('') + return + } + qrToString(displayUrl, { + type: 'utf8', + errorCorrectionLevel: 'L', + small: true, + }) + .then(setQrText) + .catch(() => setQrText('')) + }, [showQR, displayUrl]) + + useKeybindings( + { + 'confirm:yes': onDone, + 'confirm:toggle': () => { + setShowQR(prev => !prev) + }, + }, + { context: 'Confirmation' }, + ) + + useInput(input => { + if (input === 'd') { + // Persist opt-out only for CLI-flag/command-activated bridge. + // Config-driven and GB-auto-connect users get session-only disconnect + // — writing false would silently undo a Settings choice or opt a + // GB-rollout user out permanently. + if (explicit) { + saveGlobalConfig(current => { + if (current.remoteControlAtStartup === false) return current + return { ...current, remoteControlAtStartup: false } + }) } - qrToString(displayUrl, { - type: "utf8", - errorCorrectionLevel: "L", - small: true - }).then(setQrText).catch(() => setQrText("")); - }; - t5 = [showQR, displayUrl]; - $[3] = displayUrl; - $[4] = showQR; - $[5] = t4; - $[6] = t5; - } else { - t4 = $[5]; - t5 = $[6]; - } - useEffect(t4, t5); - let t6; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t6 = () => { - setShowQR(_temp10); - }; - $[7] = t6; - } else { - t6 = $[7]; - } - let t7; - if ($[8] !== onDone) { - t7 = { - "confirm:yes": onDone, - "confirm:toggle": t6 - }; - $[8] = onDone; - $[9] = t7; - } else { - t7 = $[9]; - } - let t8; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t8 = { - context: "Confirmation" - }; - $[10] = t8; - } else { - t8 = $[10]; - } - useKeybindings(t7, t8); - let t9; - if ($[11] !== explicit || $[12] !== onDone || $[13] !== setAppState) { - t9 = input => { - if (input === "d") { - if (explicit) { - saveGlobalConfig(_temp11); - } - setAppState(_temp12); - onDone(); - } - }; - $[11] = explicit; - $[12] = onDone; - $[13] = setAppState; - $[14] = t9; - } else { - t9 = $[14]; - } - useInput(t9); - let t10; - if ($[15] !== connected || $[16] !== error || $[17] !== reconnecting || $[18] !== sessionActive) { - t10 = getBridgeStatus({ - error, - connected, - sessionActive, - reconnecting - }); - $[15] = connected; - $[16] = error; - $[17] = reconnecting; - $[18] = sessionActive; - $[19] = t10; - } else { - t10 = $[19]; - } - const { - label: statusLabel, - color: statusColor - } = t10; - const indicator = error ? BRIDGE_FAILED_INDICATOR : BRIDGE_READY_INDICATOR; - let T0; - let T1; - let footerText; - let t11; - let t12; - let t13; - let t14; - let t15; - let t16; - let t17; - if ($[20] !== branchName || $[21] !== displayUrl || $[22] !== environmentId || $[23] !== error || $[24] !== indicator || $[25] !== onDone || $[26] !== qrText || $[27] !== sessionActive || $[28] !== sessionId || $[29] !== showQR || $[30] !== statusColor || $[31] !== statusLabel || $[32] !== verbose) { - const qrLines = qrText ? qrText.split("\n").filter(_temp13) : []; - let contextParts; - if ($[43] !== branchName) { - contextParts = []; - if (repoName) { - contextParts.push(repoName); - } - if (branchName) { - contextParts.push(branchName); - } - $[43] = branchName; - $[44] = contextParts; - } else { - contextParts = $[44]; + setAppState(prev => { + if (!prev.replBridgeEnabled) return prev + return { ...prev, replBridgeEnabled: false } + }) + onDone() } - const contextSuffix = contextParts.length > 0 ? " \xB7 " + contextParts.join(" \xB7 ") : ""; - let t18; - if ($[45] !== displayUrl || $[46] !== error || $[47] !== sessionActive) { - t18 = error ? FAILED_FOOTER_TEXT : displayUrl ? sessionActive ? buildActiveFooterText(displayUrl) : buildIdleFooterText(displayUrl) : undefined; - $[45] = displayUrl; - $[46] = error; - $[47] = sessionActive; - $[48] = t18; - } else { - t18 = $[48]; - } - footerText = t18; - T1 = Dialog; - t15 = "Remote Control"; - t16 = onDone; - t17 = true; - T0 = Box; - t11 = "column"; - t12 = 1; - let t19; - if ($[49] !== indicator || $[50] !== statusColor || $[51] !== statusLabel) { - t19 = {indicator} {statusLabel}; - $[49] = indicator; - $[50] = statusColor; - $[51] = statusLabel; - $[52] = t19; - } else { - t19 = $[52]; - } - let t20; - if ($[53] !== contextSuffix) { - t20 = {contextSuffix}; - $[53] = contextSuffix; - $[54] = t20; - } else { - t20 = $[54]; - } - let t21; - if ($[55] !== t19 || $[56] !== t20) { - t21 = {t19}{t20}; - $[55] = t19; - $[56] = t20; - $[57] = t21; - } else { - t21 = $[57]; - } - let t22; - if ($[58] !== error) { - t22 = error && {error}; - $[58] = error; - $[59] = t22; - } else { - t22 = $[59]; - } - let t23; - if ($[60] !== environmentId || $[61] !== verbose) { - t23 = verbose && environmentId && Environment: {environmentId}; - $[60] = environmentId; - $[61] = verbose; - $[62] = t23; - } else { - t23 = $[62]; - } - let t24; - if ($[63] !== sessionId || $[64] !== verbose) { - t24 = verbose && sessionId && Session: {sessionId}; - $[63] = sessionId; - $[64] = verbose; - $[65] = t24; - } else { - t24 = $[65]; - } - if ($[66] !== t21 || $[67] !== t22 || $[68] !== t23 || $[69] !== t24) { - t13 = {t21}{t22}{t23}{t24}; - $[66] = t21; - $[67] = t22; - $[68] = t23; - $[69] = t24; - $[70] = t13; - } else { - t13 = $[70]; - } - t14 = showQR && qrLines.length > 0 && {qrLines.map(_temp14)}; - $[20] = branchName; - $[21] = displayUrl; - $[22] = environmentId; - $[23] = error; - $[24] = indicator; - $[25] = onDone; - $[26] = qrText; - $[27] = sessionActive; - $[28] = sessionId; - $[29] = showQR; - $[30] = statusColor; - $[31] = statusLabel; - $[32] = verbose; - $[33] = T0; - $[34] = T1; - $[35] = footerText; - $[36] = t11; - $[37] = t12; - $[38] = t13; - $[39] = t14; - $[40] = t15; - $[41] = t16; - $[42] = t17; - } else { - T0 = $[33]; - T1 = $[34]; - footerText = $[35]; - t11 = $[36]; - t12 = $[37]; - t13 = $[38]; - t14 = $[39]; - t15 = $[40]; - t16 = $[41]; - t17 = $[42]; - } - let t18; - if ($[71] !== footerText) { - t18 = footerText && {footerText}; - $[71] = footerText; - $[72] = t18; - } else { - t18 = $[72]; - } - let t19; - if ($[73] === Symbol.for("react.memo_cache_sentinel")) { - t19 = d to disconnect · space for QR code · Enter/Esc to close; - $[73] = t19; - } else { - t19 = $[73]; - } - let t20; - if ($[74] !== T0 || $[75] !== t11 || $[76] !== t12 || $[77] !== t13 || $[78] !== t14 || $[79] !== t18) { - t20 = {t13}{t14}{t18}{t19}; - $[74] = T0; - $[75] = t11; - $[76] = t12; - $[77] = t13; - $[78] = t14; - $[79] = t18; - $[80] = t20; - } else { - t20 = $[80]; - } - let t21; - if ($[81] !== T1 || $[82] !== t15 || $[83] !== t16 || $[84] !== t17 || $[85] !== t20) { - t21 = {t20}; - $[81] = T1; - $[82] = t15; - $[83] = t16; - $[84] = t17; - $[85] = t20; - $[86] = t21; - } else { - t21 = $[86]; - } - return t21; -} -function _temp14(line, i) { - return {line}; -} -function _temp13(l) { - return l.length > 0; -} -function _temp12(prev_0) { - if (!prev_0.replBridgeEnabled) { - return prev_0; - } - return { - ...prev_0, - replBridgeEnabled: false - }; -} -function _temp11(current) { - if (current.remoteControlAtStartup === false) { - return current; - } - return { - ...current, - remoteControlAtStartup: false - }; -} -function _temp10(prev) { - return !prev; -} -function _temp1() {} -function _temp0(s_8) { - return s_8.verbose; -} -function _temp9(s_7) { - return s_7.replBridgeSessionId; -} -function _temp8(s_6) { - return s_6.replBridgeEnvironmentId; -} -function _temp7(s_5) { - return s_5.replBridgeExplicit; -} -function _temp6(s_4) { - return s_4.replBridgeError; -} -function _temp5(s_3) { - return s_3.replBridgeSessionUrl; -} -function _temp4(s_2) { - return s_2.replBridgeConnectUrl; -} -function _temp3(s_1) { - return s_1.replBridgeReconnecting; -} -function _temp2(s_0) { - return s_0.replBridgeSessionActive; -} -function _temp(s) { - return s.replBridgeConnected; + }) + + const { label: statusLabel, color: statusColor } = getBridgeStatus({ + error, + connected, + sessionActive, + reconnecting, + }) + const indicator = error ? BRIDGE_FAILED_INDICATOR : BRIDGE_READY_INDICATOR + const qrLines = qrText ? qrText.split('\n').filter(l => l.length > 0) : [] + + // Build suffix with repo and branch (matches standalone bridge format) + const contextParts: string[] = [] + if (repoName) contextParts.push(repoName) + if (branchName) contextParts.push(branchName) + const contextSuffix = + contextParts.length > 0 ? ' \u00b7 ' + contextParts.join(' \u00b7 ') : '' + + // Footer text matches standalone bridge + const footerText = error + ? FAILED_FOOTER_TEXT + : displayUrl + ? sessionActive + ? buildActiveFooterText(displayUrl) + : buildIdleFooterText(displayUrl) + : undefined + + return ( + + + + + + {indicator} {statusLabel} + + {contextSuffix} + + {error && {error}} + {verbose && environmentId && ( + Environment: {environmentId} + )} + {verbose && sessionId && Session: {sessionId}} + + {showQR && qrLines.length > 0 && ( + + {qrLines.map((line, i) => ( + {line} + ))} + + )} + {footerText && {footerText}} + + d to disconnect · space for QR code · Enter/Esc to close + + + + ) } diff --git a/src/components/BypassPermissionsModeDialog.tsx b/src/components/BypassPermissionsModeDialog.tsx index b3f8b199e..adc708c77 100644 --- a/src/components/BypassPermissionsModeDialog.tsx +++ b/src/components/BypassPermissionsModeDialog.tsx @@ -1,86 +1,73 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback } from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; -import { Box, Link, Newline, Text } from '../ink.js'; -import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'; -import { updateSettingsForSource } from '../utils/settings/settings.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; +import React, { useCallback } from 'react' +import { logEvent } from 'src/services/analytics/index.js' +import { Box, Link, Newline, Text } from '../ink.js' +import { gracefulShutdownSync } from '../utils/gracefulShutdown.js' +import { updateSettingsForSource } from '../utils/settings/settings.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + type Props = { - onAccept(): void; -}; -export function BypassPermissionsModeDialog(t0) { - const $ = _c(7); - const { - onAccept - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - React.useEffect(_temp, t1); - let t2; - if ($[1] !== onAccept) { - t2 = function onChange(value) { - bb3: switch (value) { - case "accept": - { - logEvent("tengu_bypass_permissions_mode_dialog_accept", {}); - updateSettingsForSource("userSettings", { - skipDangerousModePermissionPrompt: true - }); - onAccept(); - break bb3; - } - case "decline": - { - gracefulShutdownSync(1); - } + onAccept(): void +} + +export function BypassPermissionsModeDialog({ + onAccept, +}: Props): React.ReactNode { + React.useEffect(() => { + logEvent('tengu_bypass_permissions_mode_dialog_shown', {}) + }, []) + + function onChange(value: 'accept' | 'decline') { + switch (value) { + case 'accept': { + logEvent('tengu_bypass_permissions_mode_dialog_accept', {}) + + updateSettingsForSource('userSettings', { + skipDangerousModePermissionPrompt: true, + }) + onAccept() + break } - }; - $[1] = onAccept; - $[2] = t2; - } else { - t2 = $[2]; + case 'decline': { + gracefulShutdownSync(1) + break + } + } } - const onChange = t2; - const handleEscape = _temp2; - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = In Bypass Permissions mode, Claude Code will not ask for your approval before running potentially dangerous commands.This mode should only be used in a sandboxed container/VM that has restricted internet access and can easily be restored if damaged.By proceeding, you accept all responsibility for actions taken while running in Bypass Permissions mode.; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t4 = [{ - label: "No, exit", - value: "decline" - }, { - label: "Yes, I accept", - value: "accept" - }]; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] !== onChange) { - t5 = {t3} onChange(value as 'accept' | 'decline')} + /> + + ) } diff --git a/src/components/ChannelDowngradeDialog.tsx b/src/components/ChannelDowngradeDialog.tsx index d5e2129d9..54db87690 100644 --- a/src/components/ChannelDowngradeDialog.tsx +++ b/src/components/ChannelDowngradeDialog.tsx @@ -1,101 +1,57 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Text } from '../ink.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; -export type ChannelDowngradeChoice = 'downgrade' | 'stay' | 'cancel'; +import React from 'react' +import { Text } from '../ink.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + +export type ChannelDowngradeChoice = 'downgrade' | 'stay' | 'cancel' + type Props = { - currentVersion: string; - onChoice: (choice: ChannelDowngradeChoice) => void; -}; + currentVersion: string + onChoice: (choice: ChannelDowngradeChoice) => void +} /** * Dialog shown when switching from latest to stable channel. * Allows user to choose whether to downgrade or stay on current version. */ -export function ChannelDowngradeDialog(t0) { - const $ = _c(17); - const { - currentVersion, - onChoice - } = t0; - let t1; - if ($[0] !== onChoice) { - t1 = function handleSelect(value) { - onChoice(value); - }; - $[0] = onChoice; - $[1] = t1; - } else { - t1 = $[1]; +export function ChannelDowngradeDialog({ + currentVersion, + onChoice, +}: Props): React.ReactNode { + function handleSelect(value: ChannelDowngradeChoice): void { + onChoice(value) } - const handleSelect = t1; - let t2; - if ($[2] !== onChoice) { - t2 = function handleCancel() { - onChoice("cancel"); - }; - $[2] = onChoice; - $[3] = t2; - } else { - t2 = $[3]; + + function handleCancel(): void { + onChoice('cancel') } - const handleCancel = t2; - let t3; - if ($[4] !== currentVersion) { - t3 = The stable channel may have an older version than what you're currently running ({currentVersion}).; - $[4] = currentVersion; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t4 = How would you like to handle this?; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: "Allow possible downgrade to stable version", - value: "downgrade" as ChannelDowngradeChoice - }; - $[7] = t5; - } else { - t5 = $[7]; - } - const t6 = `Stay on current version (${currentVersion}) until stable catches up`; - let t7; - if ($[8] !== t6) { - t7 = [t5, { - label: t6, - value: "stay" as ChannelDowngradeChoice - }]; - $[8] = t6; - $[9] = t7; - } else { - t7 = $[9]; - } - let t8; - if ($[10] !== handleSelect || $[11] !== t7) { - t8 = + + ) } diff --git a/src/components/ClaudeCodeHint/PluginHintMenu.tsx b/src/components/ClaudeCodeHint/PluginHintMenu.tsx index 955a0dc66..8afd7cda8 100644 --- a/src/components/ClaudeCodeHint/PluginHintMenu.tsx +++ b/src/components/ClaudeCodeHint/PluginHintMenu.tsx @@ -1,53 +1,71 @@ -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { Select } from '../CustomSelect/select.js'; -import { PermissionDialog } from '../permissions/PermissionDialog.js'; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { Select } from '../CustomSelect/select.js' +import { PermissionDialog } from '../permissions/PermissionDialog.js' + type Props = { - pluginName: string; - pluginDescription?: string; - marketplaceName: string; - sourceCommand: string; - onResponse: (response: 'yes' | 'no' | 'disable') => void; -}; -const AUTO_DISMISS_MS = 30_000; + pluginName: string + pluginDescription?: string + marketplaceName: string + sourceCommand: string + onResponse: (response: 'yes' | 'no' | 'disable') => void +} + +const AUTO_DISMISS_MS = 30_000 + export function PluginHintMenu({ pluginName, pluginDescription, marketplaceName, sourceCommand, - onResponse + onResponse, }: Props): React.ReactNode { - const onResponseRef = React.useRef(onResponse); - onResponseRef.current = onResponse; + const onResponseRef = React.useRef(onResponse) + onResponseRef.current = onResponse + React.useEffect(() => { - const timeoutId = setTimeout(ref => ref.current('no'), AUTO_DISMISS_MS, onResponseRef); - return () => clearTimeout(timeoutId); - }, []); + const timeoutId = setTimeout( + ref => ref.current('no'), + AUTO_DISMISS_MS, + onResponseRef, + ) + return () => clearTimeout(timeoutId) + }, []) + function onSelect(value: string): void { switch (value) { case 'yes': - onResponse('yes'); - break; + onResponse('yes') + break case 'disable': - onResponse('disable'); - break; + onResponse('disable') + break default: - onResponse('no'); + onResponse('no') } } - const options = [{ - label: + + const options = [ + { + label: ( + Yes, install {pluginName} - , - value: 'yes' - }, { - label: 'No', - value: 'no' - }, { - label: "No, and don't show plugin installation hints again", - value: 'disable' - }]; - return + + ), + value: 'yes', + }, + { + label: 'No', + value: 'no', + }, + { + label: "No, and don't show plugin installation hints again", + value: 'disable', + }, + ] + + return ( + @@ -63,15 +81,22 @@ export function PluginHintMenu({ Marketplace: {marketplaceName} - {pluginDescription && + {pluginDescription && ( + {pluginDescription} - } + + )} Would you like to install it? - onResponse('no')} + /> - ; + + ) } diff --git a/src/components/ClaudeInChromeOnboarding.tsx b/src/components/ClaudeInChromeOnboarding.tsx index a7f0bf99e..7c420ad43 100644 --- a/src/components/ClaudeInChromeOnboarding.tsx +++ b/src/components/ClaudeInChromeOnboarding.tsx @@ -1,120 +1,78 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; +import React from 'react' +import { logEvent } from 'src/services/analytics/index.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- enter to continue -import { Box, Link, Newline, Text, useInput } from '../ink.js'; -import { isChromeExtensionInstalled } from '../utils/claudeInChrome/setup.js'; -import { saveGlobalConfig } from '../utils/config.js'; -import { Dialog } from './design-system/Dialog.js'; -const CHROME_EXTENSION_URL = 'https://claude.ai/chrome'; -const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions'; +import { Box, Link, Newline, Text, useInput } from '../ink.js' +import { isChromeExtensionInstalled } from '../utils/claudeInChrome/setup.js' +import { saveGlobalConfig } from '../utils/config.js' +import { Dialog } from './design-system/Dialog.js' + +const CHROME_EXTENSION_URL = 'https://claude.ai/chrome' +const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions' + type Props = { - onDone(): void; -}; -export function ClaudeInChromeOnboarding(t0) { - const $ = _c(20); - const { - onDone - } = t0; - const [isExtensionInstalled, setIsExtensionInstalled] = React.useState(false); - let t1; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => { - logEvent("tengu_claude_in_chrome_onboarding_shown", {}); - isChromeExtensionInstalled().then(setIsExtensionInstalled); - saveGlobalConfig(_temp); - }; - t2 = []; - $[0] = t1; - $[1] = t2; - } else { - t1 = $[0]; - t2 = $[1]; - } - React.useEffect(t1, t2); - let t3; - if ($[2] !== onDone) { - t3 = (_input, key) => { - if (key.return) { - onDone(); - } - }; - $[2] = onDone; - $[3] = t3; - } else { - t3 = $[3]; - } - useInput(t3); - let t4; - if ($[4] !== isExtensionInstalled) { - t4 = !isExtensionInstalled && <>Requires the Chrome extension. Get started at{" "}; - $[4] = isExtensionInstalled; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== t4) { - t5 = Claude in Chrome works with the Chrome extension to let you control your browser directly from Claude Code. You can navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and network requests.{t4}; - $[6] = t4; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== isExtensionInstalled) { - t6 = isExtensionInstalled && <>{" "}(); - $[8] = isExtensionInstalled; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] !== t6) { - t7 = Site-level permissions are inherited from the Chrome extension. Manage permissions in the Chrome extension settings to control which sites Claude can browse, click, and type on{t6}.; - $[10] = t6; - $[11] = t7; - } else { - t7 = $[11]; - } - let t8; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t8 = /chrome; - $[12] = t8; - } else { - t8 = $[12]; - } - let t9; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t9 = For more info, use{" "}{t8}{" "}or visit ; - $[13] = t9; - } else { - t9 = $[13]; - } - let t10; - if ($[14] !== t5 || $[15] !== t7) { - t10 = {t5}{t7}{t9}; - $[14] = t5; - $[15] = t7; - $[16] = t10; - } else { - t10 = $[16]; - } - let t11; - if ($[17] !== onDone || $[18] !== t10) { - t11 = {t10}; - $[17] = onDone; - $[18] = t10; - $[19] = t11; - } else { - t11 = $[19]; - } - return t11; + onDone(): void } -function _temp(current) { - return { - ...current, - hasCompletedClaudeInChromeOnboarding: true - }; + +export function ClaudeInChromeOnboarding({ onDone }: Props): React.ReactNode { + const [isExtensionInstalled, setIsExtensionInstalled] = React.useState(false) + + React.useEffect(() => { + logEvent('tengu_claude_in_chrome_onboarding_shown', {}) + void isChromeExtensionInstalled().then(setIsExtensionInstalled) + saveGlobalConfig(current => { + return { ...current, hasCompletedClaudeInChromeOnboarding: true } + }) + }, []) + + // Handle Enter to continue + useInput((_input, key) => { + if (key.return) { + onDone() + } + }) + + return ( + + + + Claude in Chrome works with the Chrome extension to let you control + your browser directly from Claude Code. You can navigate websites, + fill forms, capture screenshots, record GIFs, and debug with console + logs and network requests. + {!isExtensionInstalled && ( + <> + + + Requires the Chrome extension. Get started at{' '} + + + )} + + + + Site-level permissions are inherited from the Chrome extension. Manage + permissions in the Chrome extension settings to control which sites + Claude can browse, click, and type on + {isExtensionInstalled && ( + <> + {' '} + () + + )} + . + + + For more info, use{' '} + + /chrome + {' '} + or visit + + + + ) } diff --git a/src/components/ClaudeMdExternalIncludesDialog.tsx b/src/components/ClaudeMdExternalIncludesDialog.tsx index e7b0b93d0..1ca6fcd12 100644 --- a/src/components/ClaudeMdExternalIncludesDialog.tsx +++ b/src/components/ClaudeMdExternalIncludesDialog.tsx @@ -1,136 +1,93 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback } from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; -import { Box, Link, Text } from '../ink.js'; -import type { ExternalClaudeMdInclude } from '../utils/claudemd.js'; -import { saveCurrentProjectConfig } from '../utils/config.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; +import React, { useCallback } from 'react' +import { logEvent } from 'src/services/analytics/index.js' +import { Box, Link, Text } from '../ink.js' +import type { ExternalClaudeMdInclude } from '../utils/claudemd.js' +import { saveCurrentProjectConfig } from '../utils/config.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + type Props = { - onDone(): void; - isStandaloneDialog?: boolean; - externalIncludes?: ExternalClaudeMdInclude[]; -}; -export function ClaudeMdExternalIncludesDialog(t0) { - const $ = _c(18); - const { - onDone, - isStandaloneDialog, - externalIncludes - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - React.useEffect(_temp, t1); - let t2; - if ($[1] !== onDone) { - t2 = value => { - if (value === "no") { - logEvent("tengu_claude_md_external_includes_dialog_declined", {}); - saveCurrentProjectConfig(_temp2); + onDone(): void + isStandaloneDialog?: boolean + externalIncludes?: ExternalClaudeMdInclude[] +} + +export function ClaudeMdExternalIncludesDialog({ + onDone, + isStandaloneDialog, + externalIncludes, +}: Props): React.ReactNode { + React.useEffect(() => { + // Log when dialog is shown + logEvent('tengu_claude_md_includes_dialog_shown', {}) + }, []) + + const handleSelection = useCallback( + (value: 'yes' | 'no') => { + if (value === 'no') { + logEvent('tengu_claude_md_external_includes_dialog_declined', {}) + // Mark that we've shown the dialog but it was declined + saveCurrentProjectConfig(current => ({ + ...current, + hasClaudeMdExternalIncludesApproved: false, + hasClaudeMdExternalIncludesWarningShown: true, + })) } else { - logEvent("tengu_claude_md_external_includes_dialog_accepted", {}); - saveCurrentProjectConfig(_temp3); + logEvent('tengu_claude_md_external_includes_dialog_accepted', {}) + saveCurrentProjectConfig(current => ({ + ...current, + hasClaudeMdExternalIncludesApproved: true, + hasClaudeMdExternalIncludesWarningShown: true, + })) } - onDone(); - }; - $[1] = onDone; - $[2] = t2; - } else { - t2 = $[2]; - } - const handleSelection = t2; - let t3; - if ($[3] !== handleSelection) { - t3 = () => { - handleSelection("no"); - }; - $[3] = handleSelection; - $[4] = t3; - } else { - t3 = $[4]; - } - const handleEscape = t3; - const t4 = !isStandaloneDialog; - const t5 = !isStandaloneDialog; - let t6; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t6 = This project's CLAUDE.md imports files outside the current working directory. Never allow this for third-party repositories.; - $[5] = t6; - } else { - t6 = $[5]; - } - let t7; - if ($[6] !== externalIncludes) { - t7 = externalIncludes && externalIncludes.length > 0 && External imports:{externalIncludes.map(_temp4)}; - $[6] = externalIncludes; - $[7] = t7; - } else { - t7 = $[7]; - } - let t8; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Important: Only use Claude Code with files you trust. Accessing untrusted files may pose security risks{" "}{" "}; - $[8] = t8; - } else { - t8 = $[8]; - } - let t9; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t9 = [{ - label: "Yes, allow external imports", - value: "yes" - }, { - label: "No, disable external imports", - value: "no" - }]; - $[9] = t9; - } else { - t9 = $[9]; - } - let t10; - if ($[10] !== handleSelection) { - t10 = handleSelection(value as 'yes' | 'no')} + /> + + ) } diff --git a/src/components/ClickableImageRef.tsx b/src/components/ClickableImageRef.tsx index 6b61fea8a..51144a720 100644 --- a/src/components/ClickableImageRef.tsx +++ b/src/components/ClickableImageRef.tsx @@ -1,16 +1,16 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { pathToFileURL } from 'url'; -import Link from '../ink/components/Link.js'; -import { supportsHyperlinks } from '../ink/supports-hyperlinks.js'; -import { Text } from '../ink.js'; -import { getStoredImagePath } from '../utils/imageStore.js'; -import type { Theme } from '../utils/theme.js'; +import * as React from 'react' +import { pathToFileURL } from 'url' +import Link from '../ink/components/Link.js' +import { supportsHyperlinks } from '../ink/supports-hyperlinks.js' +import { Text } from '../ink.js' +import { getStoredImagePath } from '../utils/imageStore.js' +import type { Theme } from '../utils/theme.js' + type Props = { - imageId: number; - backgroundColor?: keyof Theme; - isSelected?: boolean; -}; + imageId: number + backgroundColor?: keyof Theme + isSelected?: boolean +} /** * Renders an image reference like [Image #1] as a clickable link. @@ -20,53 +20,42 @@ type Props = { * - Terminal doesn't support hyperlinks * - Image file is not found in the store */ -export function ClickableImageRef(t0) { - const $ = _c(13); - const { - imageId, - backgroundColor, - isSelected: t1 - } = t0; - const isSelected = t1 === undefined ? false : t1; - const imagePath = getStoredImagePath(imageId); - const displayText = `[Image #${imageId}]`; +export function ClickableImageRef({ + imageId, + backgroundColor, + isSelected = false, +}: Props): React.ReactNode { + const imagePath = getStoredImagePath(imageId) + const displayText = `[Image #${imageId}]` + + // If we have a stored image and terminal supports hyperlinks, make it clickable if (imagePath && supportsHyperlinks()) { - const fileUrl = pathToFileURL(imagePath).href; - let t2; - let t3; - if ($[0] !== backgroundColor || $[1] !== displayText || $[2] !== isSelected) { - t2 = {displayText}; - t3 = {displayText}; - $[0] = backgroundColor; - $[1] = displayText; - $[2] = isSelected; - $[3] = t2; - $[4] = t3; - } else { - t2 = $[3]; - t3 = $[4]; - } - let t4; - if ($[5] !== fileUrl || $[6] !== t2 || $[7] !== t3) { - t4 = {t3}; - $[5] = fileUrl; - $[6] = t2; - $[7] = t3; - $[8] = t4; - } else { - t4 = $[8]; - } - return t4; + const fileUrl = pathToFileURL(imagePath).href + + return ( + + {displayText} + + } + > + + {displayText} + + + ) } - let t2; - if ($[9] !== backgroundColor || $[10] !== displayText || $[11] !== isSelected) { - t2 = {displayText}; - $[9] = backgroundColor; - $[10] = displayText; - $[11] = isSelected; - $[12] = t2; - } else { - t2 = $[12]; - } - return t2; + + // Fallback: styled but not clickable + return ( + + {displayText} + + ) } diff --git a/src/components/CompactSummary.tsx b/src/components/CompactSummary.tsx index 082d08d1d..1cd1687fa 100644 --- a/src/components/CompactSummary.tsx +++ b/src/components/CompactSummary.tsx @@ -1,117 +1,101 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { BLACK_CIRCLE } from '../constants/figures.js'; -import { Box, Text } from '../ink.js'; -import type { Screen } from '../screens/REPL.js'; -import type { NormalizedUserMessage } from '../types/message.js'; -import { getUserMessageText } from '../utils/messages.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { MessageResponse } from './MessageResponse.js'; +import * as React from 'react' +import { BLACK_CIRCLE } from '../constants/figures.js' +import { Box, Text } from '../ink.js' +import type { Screen } from '../screens/REPL.js' +import type { NormalizedUserMessage } from '../types/message.js' +import { getUserMessageText } from '../utils/messages.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { MessageResponse } from './MessageResponse.js' + type Props = { - message: NormalizedUserMessage; - screen: Screen; -}; -export function CompactSummary(t0) { - const $ = _c(24); - const { - message, - screen - } = t0; - const isTranscriptMode = screen === "transcript"; - let t1; - if ($[0] !== message) { - t1 = getUserMessageText(message) || ""; - $[0] = message; - $[1] = t1; - } else { - t1 = $[1]; - } - const textContent = t1; - const metadata = message.summarizeMetadata; - if (metadata) { - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {BLACK_CIRCLE}; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Summarized conversation; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] !== isTranscriptMode || $[5] !== metadata) { - t4 = !isTranscriptMode && Summarized {metadata.messagesSummarized} messages{" "}{metadata.direction === "up_to" ? "up to this point" : "from this point"}{metadata.userContext && Context: {"\u201C"}{metadata.userContext}{"\u201D"}}; - $[4] = isTranscriptMode; - $[5] = metadata; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== isTranscriptMode || $[8] !== textContent) { - t5 = isTranscriptMode && {textContent}; - $[7] = isTranscriptMode; - $[8] = textContent; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== t4 || $[11] !== t5) { - t6 = {t2}{t3}{t4}{t5}; - $[10] = t4; - $[11] = t5; - $[12] = t6; - } else { - t6 = $[12]; - } - return t6; - } - let t2; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {BLACK_CIRCLE}; - $[13] = t2; - } else { - t2 = $[13]; - } - let t3; - if ($[14] !== isTranscriptMode) { - t3 = !isTranscriptMode && {" "}; - $[14] = isTranscriptMode; - $[15] = t3; - } else { - t3 = $[15]; - } - let t4; - if ($[16] !== t3) { - t4 = {t2}Compact summary{t3}; - $[16] = t3; - $[17] = t4; - } else { - t4 = $[17]; - } - let t5; - if ($[18] !== isTranscriptMode || $[19] !== textContent) { - t5 = isTranscriptMode && {textContent}; - $[18] = isTranscriptMode; - $[19] = textContent; - $[20] = t5; - } else { - t5 = $[20]; - } - let t6; - if ($[21] !== t4 || $[22] !== t5) { - t6 = {t4}{t5}; - $[21] = t4; - $[22] = t5; - $[23] = t6; - } else { - t6 = $[23]; - } - return t6; + message: NormalizedUserMessage + screen: Screen +} + +export function CompactSummary({ message, screen }: Props): React.ReactNode { + const isTranscriptMode = screen === 'transcript' + const textContent = getUserMessageText(message) || '' + const metadata = message.summarizeMetadata + + // "Summarize from here" with metadata + if (metadata) { + return ( + + + + {BLACK_CIRCLE} + + + Summarized conversation + {!isTranscriptMode && ( + + + + Summarized {metadata.messagesSummarized} messages{' '} + {metadata.direction === 'up_to' + ? 'up to this point' + : 'from this point'} + + {metadata.userContext && ( + + Context: {'\u201c'} + {metadata.userContext} + {'\u201d'} + + )} + + + + + + )} + {isTranscriptMode && ( + + {textContent} + + )} + + + + ) + } + + // Default compact summary (auto-compact) + return ( + + + + {BLACK_CIRCLE} + + + + Compact summary + {!isTranscriptMode && ( + + {' '} + + + )} + + + + {isTranscriptMode && ( + + {textContent} + + )} + + ) } diff --git a/src/components/ConfigurableShortcutHint.tsx b/src/components/ConfigurableShortcutHint.tsx index e6187349b..82aea15fa 100644 --- a/src/components/ConfigurableShortcutHint.tsx +++ b/src/components/ConfigurableShortcutHint.tsx @@ -1,22 +1,25 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import type { KeybindingAction, KeybindingContextName } from '../keybindings/types.js'; -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; +import * as React from 'react' +import type { + KeybindingAction, + KeybindingContextName, +} from '../keybindings/types.js' +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' + type Props = { /** The keybinding action (e.g., 'app:toggleTranscript') */ - action: KeybindingAction; + action: KeybindingAction /** The keybinding context (e.g., 'Global') */ - context: KeybindingContextName; + context: KeybindingContextName /** Default shortcut if keybinding not configured */ - fallback: string; + fallback: string /** The action description text (e.g., 'expand') */ - description: string; + description: string /** Whether to wrap in parentheses */ - parens?: boolean; + parens?: boolean /** Whether to show in bold */ - bold?: boolean; -}; + bold?: boolean +} /** * KeyboardShortcutHint that displays the user-configured shortcut. @@ -30,27 +33,21 @@ type Props = { * description="expand" * /> */ -export function ConfigurableShortcutHint(t0) { - const $ = _c(5); - const { - action, - context, - fallback, - description, - parens, - bold - } = t0; - const shortcut = useShortcutDisplay(action, context, fallback); - let t1; - if ($[0] !== bold || $[1] !== description || $[2] !== parens || $[3] !== shortcut) { - t1 = ; - $[0] = bold; - $[1] = description; - $[2] = parens; - $[3] = shortcut; - $[4] = t1; - } else { - t1 = $[4]; - } - return t1; +export function ConfigurableShortcutHint({ + action, + context, + fallback, + description, + parens, + bold, +}: Props): React.ReactNode { + const shortcut = useShortcutDisplay(action, context, fallback) + return ( + + ) } diff --git a/src/components/ConsoleOAuthFlow.tsx b/src/components/ConsoleOAuthFlow.tsx index 03b6e0e7a..bfaf6af20 100644 --- a/src/components/ConsoleOAuthFlow.tsx +++ b/src/components/ConsoleOAuthFlow.tsx @@ -1,333 +1,370 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { installOAuthTokens } from '../cli/handlers/auth.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { setClipboard } from '../ink/termio/osc.js'; -import { useTerminalNotification } from '../ink/useTerminalNotification.js'; -import { Box, Link, Text } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { getSSLErrorHint } from '../services/api/errorUtils.js'; -import { sendNotification } from '../services/notifier.js'; -import { OAuthService } from '../services/oauth/index.js'; -import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js'; -import { logError } from '../utils/log.js'; -import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'; -import { Select } from './CustomSelect/select.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { Spinner } from './Spinner.js'; -import TextInput from './TextInput.js'; +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { installOAuthTokens } from '../cli/handlers/auth.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { setClipboard } from '../ink/termio/osc.js' +import { useTerminalNotification } from '../ink/useTerminalNotification.js' +import { Box, Link, Text } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { getSSLErrorHint } from '../services/api/errorUtils.js' +import { sendNotification } from '../services/notifier.js' +import { OAuthService } from '../services/oauth/index.js' +import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js' +import { logError } from '../utils/log.js' +import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js' +import { Select } from './CustomSelect/select.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { Spinner } from './Spinner.js' +import TextInput from './TextInput.js' + type Props = { - onDone(): void; - startingMessage?: string; - mode?: 'login' | 'setup-token'; - forceLoginMethod?: 'claudeai' | 'console'; -}; -type OAuthStatus = { - state: 'idle'; -} // Initial state, waiting to select login method -| { - state: 'platform_setup'; -} // Show platform setup info (Bedrock/Vertex/Foundry) -| { - state: 'custom_platform'; - baseUrl: string; - apiKey: string; - haikuModel: string; - sonnetModel: string; - opusModel: string; - activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; -} // Custom platform: configure API endpoint and model names -| { - state: 'openai_chat_api'; - baseUrl: string; - apiKey: string; - haikuModel: string; - sonnetModel: string; - opusModel: string; - activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; -} // OpenAI Chat Completions API platform -| { - state: 'ready_to_start'; -} // Flow started, waiting for browser to open -| { - state: 'waiting_for_login'; - url: string; -} // Browser opened, waiting for user to login -| { - state: 'creating_api_key'; -} // Got access token, creating API key -| { - state: 'about_to_retry'; - nextState: OAuthStatus; -} | { - state: 'success'; - token?: string; -} | { - state: 'error'; - message: string; - toRetry?: OAuthStatus; -}; -const PASTE_HERE_MSG = 'Paste code here if prompted > '; + onDone(): void + startingMessage?: string + mode?: 'login' | 'setup-token' + forceLoginMethod?: 'claudeai' | 'console' +} + +type OAuthStatus = + | { state: 'idle' } // Initial state, waiting to select login method + | { state: 'platform_setup' } // Show platform setup info (Bedrock/Vertex/Foundry) + | { + state: 'custom_platform' + baseUrl: string + apiKey: string + haikuModel: string + sonnetModel: string + opusModel: string + activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' + } // Custom platform: configure API endpoint and model names + | { + state: 'openai_chat_api' + baseUrl: string + apiKey: string + haikuModel: string + sonnetModel: string + opusModel: string + activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' + } // OpenAI Chat Completions API platform + | { state: 'ready_to_start' } // Flow started, waiting for browser to open + | { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login + | { state: 'creating_api_key' } // Got access token, creating API key + | { state: 'about_to_retry'; nextState: OAuthStatus } + | { state: 'success'; token?: string } + | { + state: 'error' + message: string + toRetry?: OAuthStatus + } + +const PASTE_HERE_MSG = 'Paste code here if prompted > ' + export function ConsoleOAuthFlow({ onDone, startingMessage, mode = 'login', - forceLoginMethod: forceLoginMethodProp + forceLoginMethod: forceLoginMethodProp, }: Props): React.ReactNode { - const settings = getSettings_DEPRECATED() || {}; - const forceLoginMethod = forceLoginMethodProp ?? settings.forceLoginMethod; - const orgUUID = settings.forceLoginOrgUUID; - const forcedMethodMessage = forceLoginMethod === 'claudeai' ? 'Login method pre-selected: Subscription Plan (Claude Pro/Max)' : forceLoginMethod === 'console' ? 'Login method pre-selected: API Usage Billing (Anthropic Console)' : null; - const terminal = useTerminalNotification(); + const settings = getSettings_DEPRECATED() || {} + const forceLoginMethod = forceLoginMethodProp ?? settings.forceLoginMethod + const orgUUID = settings.forceLoginOrgUUID + const forcedMethodMessage = + forceLoginMethod === 'claudeai' + ? 'Login method pre-selected: Subscription Plan (Claude Pro/Max)' + : forceLoginMethod === 'console' + ? 'Login method pre-selected: API Usage Billing (Anthropic Console)' + : null + + const terminal = useTerminalNotification() + const [oauthStatus, setOAuthStatus] = useState(() => { if (mode === 'setup-token') { - return { - state: 'ready_to_start' - }; + return { state: 'ready_to_start' } } if (forceLoginMethod === 'claudeai' || forceLoginMethod === 'console') { - return { - state: 'ready_to_start' - }; + return { state: 'ready_to_start' } } - return { - state: 'idle' - }; - }); - const [pastedCode, setPastedCode] = useState(''); - const [cursorOffset, setCursorOffset] = useState(0); - const [oauthService] = useState(() => new OAuthService()); + return { state: 'idle' } + }) + + const [pastedCode, setPastedCode] = useState('') + const [cursorOffset, setCursorOffset] = useState(0) + const [oauthService] = useState(() => new OAuthService()) const [loginWithClaudeAi, setLoginWithClaudeAi] = useState(() => { // Use Claude AI auth for setup-token mode to support user:inference scope - return mode === 'setup-token' || forceLoginMethod === 'claudeai'; - }); + return mode === 'setup-token' || forceLoginMethod === 'claudeai' + }) // After a few seconds we suggest the user to copy/paste url if the // browser did not open automatically. In this flow we expect the user to // copy the code from the browser and paste it in the terminal - const [showPastePrompt, setShowPastePrompt] = useState(false); - const [urlCopied, setUrlCopied] = useState(false); - const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1; + const [showPastePrompt, setShowPastePrompt] = useState(false) + const [urlCopied, setUrlCopied] = useState(false) + + const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1 // Log forced login method on mount useEffect(() => { if (forceLoginMethod === 'claudeai') { - logEvent('tengu_oauth_claudeai_forced', {}); + logEvent('tengu_oauth_claudeai_forced', {}) } else if (forceLoginMethod === 'console') { - logEvent('tengu_oauth_console_forced', {}); + logEvent('tengu_oauth_console_forced', {}) } - }, [forceLoginMethod]); + }, [forceLoginMethod]) // Retry logic useEffect(() => { if (oauthStatus.state === 'about_to_retry') { - const timer = setTimeout(setOAuthStatus, 1000, oauthStatus.nextState); - return () => clearTimeout(timer); + const timer = setTimeout(setOAuthStatus, 1000, oauthStatus.nextState) + return () => clearTimeout(timer) } - }, [oauthStatus]); + }, [oauthStatus]) // Handle Enter to continue on success state - useKeybinding('confirm:yes', () => { - logEvent('tengu_oauth_success', { - loginWithClaudeAi - }); - onDone(); - }, { - context: 'Confirmation', - isActive: oauthStatus.state === 'success' && mode !== 'setup-token' - }); + useKeybinding( + 'confirm:yes', + () => { + logEvent('tengu_oauth_success', { loginWithClaudeAi }) + onDone() + }, + { + context: 'Confirmation', + isActive: oauthStatus.state === 'success' && mode !== 'setup-token', + }, + ) // Handle Enter to continue from platform setup - useKeybinding('confirm:yes', () => { - setOAuthStatus({ - state: 'idle' - }); - }, { - context: 'Confirmation', - isActive: oauthStatus.state === 'platform_setup' - }); + useKeybinding( + 'confirm:yes', + () => { + setOAuthStatus({ state: 'idle' }) + }, + { + context: 'Confirmation', + isActive: oauthStatus.state === 'platform_setup', + }, + ) // Handle Enter to retry on error state - useKeybinding('confirm:yes', () => { - if (oauthStatus.state === 'error' && oauthStatus.toRetry) { - setPastedCode(''); - setOAuthStatus({ - state: 'about_to_retry', - nextState: oauthStatus.toRetry - }); - } - }, { - context: 'Confirmation', - isActive: oauthStatus.state === 'error' && !!oauthStatus.toRetry - }); + useKeybinding( + 'confirm:yes', + () => { + if (oauthStatus.state === 'error' && oauthStatus.toRetry) { + setPastedCode('') + setOAuthStatus({ + state: 'about_to_retry', + nextState: oauthStatus.toRetry, + }) + } + }, + { + context: 'Confirmation', + isActive: oauthStatus.state === 'error' && !!oauthStatus.toRetry, + }, + ) + useEffect(() => { - if (pastedCode === 'c' && oauthStatus.state === 'waiting_for_login' && showPastePrompt && !urlCopied) { + if ( + pastedCode === 'c' && + oauthStatus.state === 'waiting_for_login' && + showPastePrompt && + !urlCopied + ) { void setClipboard(oauthStatus.url).then(raw => { - if (raw) process.stdout.write(raw); - setUrlCopied(true); - setTimeout(setUrlCopied, 2000, false); - }); - setPastedCode(''); + if (raw) process.stdout.write(raw) + setUrlCopied(true) + setTimeout(setUrlCopied, 2000, false) + }) + setPastedCode('') } - }, [pastedCode, oauthStatus, showPastePrompt, urlCopied]); + }, [pastedCode, oauthStatus, showPastePrompt, urlCopied]) + async function handleSubmitCode(value: string, url: string) { try { // Expecting format "authorizationCode#state" from the authorization callback URL - const [authorizationCode, state] = value.split('#'); + const [authorizationCode, state] = value.split('#') + if (!authorizationCode || !state) { setOAuthStatus({ state: 'error', message: 'Invalid code. Please make sure the full code was copied', - toRetry: { - state: 'waiting_for_login', - url - } - }); - return; + toRetry: { state: 'waiting_for_login', url }, + }) + return } // Track which path the user is taking (manual code entry) - logEvent('tengu_oauth_manual_entry', {}); + logEvent('tengu_oauth_manual_entry', {}) oauthService.handleManualAuthCodeInput({ authorizationCode, - state - }); + state, + }) } catch (err: unknown) { - logError(err); + logError(err) setOAuthStatus({ state: 'error', message: (err as Error).message, - toRetry: { - state: 'waiting_for_login', - url - } - }); + toRetry: { state: 'waiting_for_login', url }, + }) } } + const startOAuth = useCallback(async () => { try { - logEvent('tengu_oauth_flow_start', { - loginWithClaudeAi - }); - const result = await oauthService.startOAuthFlow(async url_0 => { - setOAuthStatus({ - state: 'waiting_for_login', - url: url_0 - }); - setTimeout(setShowPastePrompt, 3000, true); - }, { - loginWithClaudeAi, - inferenceOnly: mode === 'setup-token', - expiresIn: mode === 'setup-token' ? 365 * 24 * 60 * 60 : undefined, - // 1 year for setup-token - orgUUID - }).catch(err_1 => { - const isTokenExchangeError = err_1.message.includes('Token exchange failed'); - // Enterprise TLS proxies (Zscaler et al.) intercept the token - // exchange POST and cause cryptic SSL errors. Surface an - // actionable hint so the user isn't stuck in a login loop. - const sslHint_0 = getSSLErrorHint(err_1); - setOAuthStatus({ - state: 'error', - message: sslHint_0 ?? (isTokenExchangeError ? 'Failed to exchange authorization code for access token. Please try again.' : err_1.message), - toRetry: mode === 'setup-token' ? { - state: 'ready_to_start' - } : { - state: 'idle' - } - }); - logEvent('tengu_oauth_token_exchange_error', { - error: err_1.message, - ssl_error: sslHint_0 !== null - }); - throw err_1; - }); + logEvent('tengu_oauth_flow_start', { loginWithClaudeAi }) + + const result = await oauthService + .startOAuthFlow( + async url => { + setOAuthStatus({ state: 'waiting_for_login', url }) + setTimeout(setShowPastePrompt, 3000, true) + }, + { + loginWithClaudeAi, + inferenceOnly: mode === 'setup-token', + expiresIn: mode === 'setup-token' ? 365 * 24 * 60 * 60 : undefined, // 1 year for setup-token + orgUUID, + }, + ) + .catch(err => { + const isTokenExchangeError = err.message.includes( + 'Token exchange failed', + ) + // Enterprise TLS proxies (Zscaler et al.) intercept the token + // exchange POST and cause cryptic SSL errors. Surface an + // actionable hint so the user isn't stuck in a login loop. + const sslHint = getSSLErrorHint(err) + setOAuthStatus({ + state: 'error', + message: + sslHint ?? + (isTokenExchangeError + ? 'Failed to exchange authorization code for access token. Please try again.' + : err.message), + toRetry: + mode === 'setup-token' + ? { state: 'ready_to_start' } + : { state: 'idle' }, + }) + logEvent('tengu_oauth_token_exchange_error', { + error: err.message, + ssl_error: sslHint !== null, + }) + throw err + }) + if (mode === 'setup-token') { // For setup-token mode, return the OAuth access token directly (it can be used as an API key) // Don't save to keychain - the token is displayed for manual use with CLAUDE_CODE_OAUTH_TOKEN - setOAuthStatus({ - state: 'success', - token: result.accessToken - }); + setOAuthStatus({ state: 'success', token: result.accessToken }) } else { - await installOAuthTokens(result); - const orgResult = await validateForceLoginOrg(); + await installOAuthTokens(result) + + const orgResult = await validateForceLoginOrg() if (!orgResult.valid) { - throw new Error((orgResult as { valid: false; message: string }).message); + throw new Error(orgResult.message) } - // Reset modelType to anthropic when using OAuth login - updateSettingsForSource('userSettings', { modelType: 'anthropic' } as any); - setOAuthStatus({ - state: 'success' - }); - void sendNotification({ - message: 'Claude Code login successful', - notificationType: 'auth_success' - }, terminal); + + setOAuthStatus({ state: 'success' }) + void sendNotification( + { + message: 'Claude Code login successful', + notificationType: 'auth_success', + }, + terminal, + ) } - } catch (err_0) { - const errorMessage = (err_0 as Error).message; - const sslHint = getSSLErrorHint(err_0); + } catch (err) { + const errorMessage = (err as Error).message + const sslHint = getSSLErrorHint(err) setOAuthStatus({ state: 'error', message: sslHint ?? errorMessage, toRetry: { - state: mode === 'setup-token' ? 'ready_to_start' : 'idle' - } - }); + state: mode === 'setup-token' ? 'ready_to_start' : 'idle', + }, + }) logEvent('tengu_oauth_error', { - error: errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - ssl_error: sslHint !== null - }); + error: + errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ssl_error: sslHint !== null, + }) } - }, [oauthService, setShowPastePrompt, loginWithClaudeAi, mode, orgUUID]); - const pendingOAuthStartRef = useRef(false); + }, [oauthService, setShowPastePrompt, loginWithClaudeAi, mode, orgUUID]) + + const pendingOAuthStartRef = useRef(false) + useEffect(() => { - if (oauthStatus.state === 'ready_to_start' && !pendingOAuthStartRef.current) { - pendingOAuthStartRef.current = true; - process.nextTick((startOAuth_0: () => Promise, pendingOAuthStartRef_0: React.MutableRefObject) => { - void startOAuth_0(); - pendingOAuthStartRef_0.current = false; - }, startOAuth, pendingOAuthStartRef); + if ( + oauthStatus.state === 'ready_to_start' && + !pendingOAuthStartRef.current + ) { + pendingOAuthStartRef.current = true + process.nextTick( + ( + startOAuth: () => Promise, + pendingOAuthStartRef: React.MutableRefObject, + ) => { + void startOAuth() + pendingOAuthStartRef.current = false + }, + startOAuth, + pendingOAuthStartRef, + ) } - }, [oauthStatus.state, startOAuth]); + }, [oauthStatus.state, startOAuth]) // Auto-exit for setup-token mode useEffect(() => { if (mode === 'setup-token' && oauthStatus.state === 'success') { // Delay to ensure static content is fully rendered before exiting - const timer_0 = setTimeout((loginWithClaudeAi_0, onDone_0) => { - logEvent('tengu_oauth_success', { - loginWithClaudeAi: loginWithClaudeAi_0 - }); - // Don't clear terminal so the token remains visible - onDone_0(); - }, 500, loginWithClaudeAi, onDone); - return () => clearTimeout(timer_0); + const timer = setTimeout( + (loginWithClaudeAi, onDone) => { + logEvent('tengu_oauth_success', { loginWithClaudeAi }) + // Don't clear terminal so the token remains visible + onDone() + }, + 500, + loginWithClaudeAi, + onDone, + ) + return () => clearTimeout(timer) } - }, [mode, oauthStatus, loginWithClaudeAi, onDone]); + }, [mode, oauthStatus, loginWithClaudeAi, onDone]) // Cleanup OAuth service when component unmounts useEffect(() => { return () => { - oauthService.cleanup(); - }; - }, [oauthService]); - return - {oauthStatus.state === 'waiting_for_login' && showPastePrompt && + oauthService.cleanup() + } + }, [oauthService]) + + return ( + + {oauthStatus.state === 'waiting_for_login' && showPastePrompt && ( + Browser didn't open? Use the url below to sign in{' '} - {urlCopied ? (Copied!) : + {urlCopied ? ( + (Copied!) + ) : ( + - } + + )} {oauthStatus.url} - } - {mode === 'setup-token' && oauthStatus.state === 'success' && oauthStatus.token && + + )} + {mode === 'setup-token' && + oauthStatus.state === 'success' && + oauthStatus.token && ( + ✓ Long-lived authentication token created successfully! @@ -343,548 +380,730 @@ export function ConsoleOAuthFlow({ CLAUDE_CODE_OAUTH_TOKEN=<token> - } + + )} - + - ; + + ) } + type OAuthStatusMessageProps = { - oauthStatus: OAuthStatus; - mode: 'login' | 'setup-token'; - startingMessage: string | undefined; - forcedMethodMessage: string | null; - showPastePrompt: boolean; - pastedCode: string; - setPastedCode: (value: string) => void; - cursorOffset: number; - setCursorOffset: (offset: number) => void; - textInputColumns: number; - handleSubmitCode: (value: string, url: string) => void; - setOAuthStatus: (status: OAuthStatus) => void; - setLoginWithClaudeAi: (value: boolean) => void; - onDone: () => void; -}; -function OAuthStatusMessage(t0) { - const $ = _c(51); - const { - oauthStatus, - mode, - startingMessage, - forcedMethodMessage, - showPastePrompt, - pastedCode, - setPastedCode, - cursorOffset, - setCursorOffset, - textInputColumns, - handleSubmitCode, - setOAuthStatus, - setLoginWithClaudeAi, - onDone - } = t0; + oauthStatus: OAuthStatus + mode: 'login' | 'setup-token' + startingMessage: string | undefined + forcedMethodMessage: string | null + showPastePrompt: boolean + pastedCode: string + setPastedCode: (value: string) => void + cursorOffset: number + setCursorOffset: (offset: number) => void + textInputColumns: number + handleSubmitCode: (value: string, url: string) => void + setOAuthStatus: (status: OAuthStatus) => void + setLoginWithClaudeAi: (value: boolean) => void +} + +function OAuthStatusMessage({ + oauthStatus, + mode, + startingMessage, + forcedMethodMessage, + showPastePrompt, + pastedCode, + setPastedCode, + cursorOffset, + setCursorOffset, + textInputColumns, + handleSubmitCode, + setOAuthStatus, + setLoginWithClaudeAi, +}: OAuthStatusMessageProps): React.ReactNode { switch (oauthStatus.state) { - case "idle": - { - const t1 = startingMessage ? startingMessage : "Claude Code can be used with your Claude subscription or billed based on API usage through your Console account."; - let t2; - if ($[0] !== t1) { - t2 = {t1}; - $[0] = t1; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Select login method:; - $[2] = t3; - } else { - t3 = $[2]; - } - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { - label: Claude account with subscription ·{" "}Pro, Max, Team, or Enterprise{false && {"\n"}[ANT-ONLY]{" "}Please use this option unless you need to login to a special org for accessing sensitive data (e.g. customer data, HIPI data) with the Console option}{"\n"}, - value: "claudeai" - }; - $[3] = t4; - } else { - t4 = $[3]; - } - let t5; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: Anthropic Console account ·{" "}API usage billing{"\n"}, - value: "console" - }; - $[4] = t5; - } else { - t5 = $[4]; - } - let t6; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t6 = [{ - label: Custom Platform ·{" "}Configure your own API endpoint{"\n"}, - value: "custom_platform" - }, { - label: OpenAI Compatible ·{" "}Ollama, DeepSeek, vLLM, One API, etc.{"\n"}, - value: "openai_chat_api" - }, t4, t5, { - label: 3rd-party platform ·{" "}Amazon Bedrock, Microsoft Foundry, or Vertex AI{"\n"}, - value: "platform" - }]; - $[5] = t6; - } else { - t6 = $[5]; - } - let t7; - if ($[6] !== setLoginWithClaudeAi || $[7] !== setOAuthStatus) { - t7 = + Claude account with subscription ·{' '} + Pro, Max, Team, or Enterprise + {process.env.USER_TYPE === 'ant' && ( + + {'\n'} + [ANT-ONLY]{' '} + + Please use this option unless you need to login to a + special org for accessing sensitive data (e.g. + customer data, HIPI data) with the Console option + + + )} + {'\n'} + + ), + value: 'claudeai', + }, + { + label: ( + + Anthropic Console account ·{' '} + API usage billing + {'\n'} + + ), + value: 'console', + }, + { + label: ( + + Custom Platform ·{' '} + Configure your own API endpoint + {'\n'} + + ), + value: 'custom_platform', + }, + { + label: ( + + OpenAI Compatible ·{' '} + + Ollama, DeepSeek, vLLM, One API, etc. + + {'\n'} + + ), + value: 'openai_chat_api', + }, + { + label: ( + + 3rd-party platform ·{' '} + + Amazon Bedrock, Microsoft Foundry, or Vertex AI + + {'\n'} + + ), + value: 'platform', + }, + ]} + onChange={value => { + if (value === 'custom_platform') { + logEvent('tengu_custom_platform_selected', {}) + setOAuthStatus({ + state: 'custom_platform', + baseUrl: process.env.ANTHROPIC_BASE_URL ?? '', + apiKey: process.env.ANTHROPIC_AUTH_TOKEN ?? '', + haikuModel: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL ?? '', + sonnetModel: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? '', + opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? '', + activeField: 'base_url', + }) + } else if (value === 'openai_chat_api') { + logEvent('tengu_openai_chat_api_selected', {}) + setOAuthStatus({ + state: 'openai_chat_api', + baseUrl: process.env.OPENAI_BASE_URL ?? '', + apiKey: process.env.OPENAI_API_KEY ?? '', + haikuModel: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL ?? '', + sonnetModel: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? '', + opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? '', + activeField: 'base_url', + }) + } else if (value === 'platform') { + logEvent('tengu_oauth_platform_selected', {}) + setOAuthStatus({ state: 'platform_setup' }) } else { - logEvent("tengu_oauth_console_selected", {}); - setLoginWithClaudeAi(false); + setOAuthStatus({ state: 'ready_to_start' }) + if (value === 'claudeai') { + logEvent('tengu_oauth_claudeai_selected', {}) + setLoginWithClaudeAi(true) + } else { + logEvent('tengu_oauth_console_selected', {}) + setLoginWithClaudeAi(false) + } } - } - }} />; - $[6] = setLoginWithClaudeAi; - $[7] = setOAuthStatus; - $[8] = t7; - } else { - t7 = $[8]; - } - let t8; - if ($[9] !== t2 || $[10] !== t7) { - t8 = {t2}{t3}{t7}; - $[9] = t2; - $[10] = t7; - $[11] = t8; - } else { - t8 = $[11]; - } - return t8; - } - case "platform_setup": + }} + /> + + + ) + + case 'custom_platform': { - let t1; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Using 3rd-party platforms; - $[12] = t1; - } else { - t1 = $[12]; + type Field = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' + const FIELDS: Field[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model'] + const cp = oauthStatus as { + state: 'custom_platform' + activeField: Field + baseUrl: string + apiKey: string + haikuModel: string + sonnetModel: string + opusModel: string } - let t2; - let t3; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Claude Code supports Amazon Bedrock, Microsoft Foundry, and Vertex AI. Set the required environment variables, then restart Claude Code.; - t3 = If you are part of an enterprise organization, contact your administrator for setup instructions.; - $[13] = t2; - $[14] = t3; - } else { - t2 = $[13]; - t3 = $[14]; + const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = cp + const displayValues: Record = { + base_url: baseUrl, + api_key: apiKey, + haiku_model: haikuModel, + sonnet_model: sonnetModel, + opus_model: opusModel, } - let t4; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Documentation:; - $[15] = t4; - } else { - t4 = $[15]; - } - let t5; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t5 = · Amazon Bedrock:{" "}https://code.claude.com/docs/en/amazon-bedrock; - $[16] = t5; - } else { - t5 = $[16]; - } - let t6; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t6 = · Microsoft Foundry:{" "}https://code.claude.com/docs/en/microsoft-foundry; - $[17] = t6; - } else { - t6 = $[17]; - } - let t7; - if ($[18] === Symbol.for("react.memo_cache_sentinel")) { - t7 = {t4}{t5}{t6}· Vertex AI:{" "}https://code.claude.com/docs/en/google-vertex-ai; - $[18] = t7; - } else { - t7 = $[18]; - } - let t8; - if ($[19] === Symbol.for("react.memo_cache_sentinel")) { - t8 = {t1}{t2}{t3}{t7}Press Enter to go back to login options.; - $[19] = t8; - } else { - t8 = $[19]; - } - return t8; - } - case "custom_platform": - { - type Field = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; - const FIELDS: Field[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model']; - const cp = oauthStatus as { state: 'custom_platform'; activeField: Field; baseUrl: string; apiKey: string; haikuModel: string; sonnetModel: string; opusModel: string }; - const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = cp; - const displayValues: Record = { base_url: baseUrl, api_key: apiKey, haiku_model: haikuModel, sonnet_model: sonnetModel, opus_model: opusModel }; - const [inputValue, setInputValue] = useState(() => displayValues[activeField]); - const [inputCursorOffset, setInputCursorOffset] = useState(() => displayValues[activeField].length); + const [inputValue, setInputValue] = useState(() => displayValues[activeField]) + const [inputCursorOffset, setInputCursorOffset] = useState( + () => displayValues[activeField].length, + ) - // Build updated state with a given field changed - const buildState = useCallback((field: Field, value: string, newActive?: Field) => { - const s = { state: 'custom_platform' as const, activeField: newActive ?? activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel }; - switch (field) { - case 'base_url': return { ...s, baseUrl: value }; - case 'api_key': return { ...s, apiKey: value }; - case 'haiku_model': return { ...s, haikuModel: value }; - case 'sonnet_model': return { ...s, sonnetModel: value }; - case 'opus_model': return { ...s, opusModel: value }; - } - }, [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel]); + const buildState = useCallback( + (field: Field, value: string, newActive?: Field) => { + const s = { + state: 'custom_platform' as const, + activeField: newActive ?? activeField, + baseUrl, + apiKey, + haikuModel, + sonnetModel, + opusModel, + } + switch (field) { + case 'base_url': + return { ...s, baseUrl: value } + case 'api_key': + return { ...s, apiKey: value } + case 'haiku_model': + return { ...s, haikuModel: value } + case 'sonnet_model': + return { ...s, sonnetModel: value } + case 'opus_model': + return { ...s, opusModel: value } + } + }, + [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], + ) - // Tab switching: save current → update state → load target - const switchTo = useCallback((target: Field) => { - setOAuthStatus(buildState(activeField, inputValue, target)); - setInputValue(displayValues[target] ?? ''); - setInputCursorOffset((displayValues[target] ?? '').length); - }, [activeField, inputValue, displayValues, buildState, setOAuthStatus]); + const switchTo = useCallback( + (target: Field) => { + setOAuthStatus(buildState(activeField, inputValue, target)) + setInputValue(displayValues[target] ?? '') + setInputCursorOffset((displayValues[target] ?? '').length) + }, + [activeField, inputValue, displayValues, buildState, setOAuthStatus], + ) const doSave = useCallback(() => { - const finalVals = { ...displayValues, [activeField]: inputValue }; - const env: Record = {}; - if (finalVals.base_url) env.ANTHROPIC_BASE_URL = finalVals.base_url; - if (finalVals.api_key) env.ANTHROPIC_AUTH_TOKEN = finalVals.api_key; - if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model; - if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model; - if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model; - const { error } = updateSettingsForSource('userSettings', { modelType: 'anthropic' as any, env } as any); + const finalVals = { ...displayValues, [activeField]: inputValue } + const env: Record = {} + if (finalVals.base_url) env.ANTHROPIC_BASE_URL = finalVals.base_url + if (finalVals.api_key) env.ANTHROPIC_AUTH_TOKEN = finalVals.api_key + if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model + if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model + if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model + const { error } = updateSettingsForSource('userSettings', { + modelType: 'anthropic' as any, + env, + } as any) if (error) { - setOAuthStatus({ state: 'error', message: `Failed to save: ${error.message}`, toRetry: { state: 'custom_platform', baseUrl: '', apiKey: '', haikuModel: '', sonnetModel: '', opusModel: '', activeField: 'base_url' } }); + setOAuthStatus({ + state: 'error', + message: `Failed to save: ${error.message}`, + toRetry: { + state: 'custom_platform', + baseUrl: '', + apiKey: '', + haikuModel: '', + sonnetModel: '', + opusModel: '', + activeField: 'base_url', + }, + }) } else { - for (const [k, v] of Object.entries(env)) process.env[k] = v; - setOAuthStatus({ state: 'success' }); - void onDone(); + for (const [k, v] of Object.entries(env)) process.env[k] = v + setOAuthStatus({ state: 'success' }) + void onDone() } - }, [activeField, inputValue, displayValues, setOAuthStatus, onDone]); + }, [activeField, inputValue, displayValues, setOAuthStatus, onDone]) const handleEnter = useCallback(() => { - const idx = FIELDS.indexOf(activeField); - // Update current field value in state - setOAuthStatus(buildState(activeField, inputValue)); + const idx = FIELDS.indexOf(activeField) + setOAuthStatus(buildState(activeField, inputValue)) if (idx === FIELDS.length - 1) { - doSave(); + doSave() } else { - const next = FIELDS[idx + 1]!; - setInputValue(displayValues[next] ?? ''); - setInputCursorOffset((displayValues[next] ?? '').length); + const next = FIELDS[idx + 1]! + setInputValue(displayValues[next] ?? '') + setInputCursorOffset((displayValues[next] ?? '').length) } - }, [activeField, inputValue, buildState, doSave, displayValues, setOAuthStatus]); + }, [activeField, inputValue, buildState, doSave, displayValues, setOAuthStatus]) - useKeybinding('tabs:next', () => { - const idx = FIELDS.indexOf(activeField); - if (idx < FIELDS.length - 1) { - setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx + 1])); - setInputValue(displayValues[FIELDS[idx + 1]!] ?? ''); - setInputCursorOffset((displayValues[FIELDS[idx + 1]!] ?? '').length); - } - }, { context: 'Tabs' }); - useKeybinding('tabs:previous', () => { - const idx = FIELDS.indexOf(activeField); - if (idx > 0) { - setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx - 1])); - setInputValue(displayValues[FIELDS[idx - 1]!] ?? ''); - setInputCursorOffset((displayValues[FIELDS[idx - 1]!] ?? '').length); - } - }, { context: 'Tabs' }); - useKeybinding('confirm:no', () => { - setOAuthStatus({ state: 'idle' }); - }, { context: 'Confirmation' }); + useKeybinding( + 'tabs:next', + () => { + const idx = FIELDS.indexOf(activeField) + if (idx < FIELDS.length - 1) { + setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx + 1])) + setInputValue(displayValues[FIELDS[idx + 1]!] ?? '') + setInputCursorOffset((displayValues[FIELDS[idx + 1]!] ?? '').length) + } + }, + { context: 'Tabs' }, + ) + useKeybinding( + 'tabs:previous', + () => { + const idx = FIELDS.indexOf(activeField) + if (idx > 0) { + setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx - 1])) + setInputValue(displayValues[FIELDS[idx - 1]!] ?? '') + setInputCursorOffset((displayValues[FIELDS[idx - 1]!] ?? '').length) + } + }, + { context: 'Tabs' }, + ) + useKeybinding( + 'confirm:no', + () => { + setOAuthStatus({ state: 'idle' }) + }, + { context: 'Confirmation' }, + ) - const columns = useTerminalSize().columns - 20; + const columns = useTerminalSize().columns - 20 - const renderRow = (field: Field, label: string, opts?: { mask?: boolean; placeholder?: string }) => { - const active = activeField === field; - const val = displayValues[field]; - return - {` ${label} `} - - {active - ? - : (val - ? {opts?.mask ? val.slice(0, 8) + '·'.repeat(Math.max(0, val.length - 8)) : val} - : null)} - ; - }; + const renderRow = ( + field: Field, + label: string, + opts?: { mask?: boolean; placeholder?: string }, + ) => { + const active = activeField === field + const val = displayValues[field] + return ( + + + {` ${label} `} + + + {active ? ( + + ) : val ? ( + + {opts?.mask + ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) + : val} + + ) : null} + + ) + } - return - Custom Platform Setup + return ( - {renderRow('base_url', 'Base URL ')} - {renderRow('api_key', 'API Key ', { mask: true })} - {renderRow('haiku_model', 'Haiku ')} - {renderRow('sonnet_model', 'Sonnet ')} - {renderRow('opus_model', 'Opus ')} + Custom Platform Setup + + {renderRow('base_url', 'Base URL ')} + {renderRow('api_key', 'API Key ', { mask: true })} + {renderRow('haiku_model', 'Haiku ')} + {renderRow('sonnet_model', 'Sonnet ')} + {renderRow('opus_model', 'Opus ')} + + + Tab to switch · Enter on last field to save · Esc to go back + - Tab to switch · Enter on last field to save · Esc to go back - ; + ) } - case "openai_chat_api": + + case 'openai_chat_api': { - type OpenAIField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; - const OPENAI_FIELDS: OpenAIField[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model']; - const op = oauthStatus as { state: 'openai_chat_api'; activeField: OpenAIField; baseUrl: string; apiKey: string; haikuModel: string; sonnetModel: string; opusModel: string }; - const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = op; - const openaiDisplayValues: Record = { base_url: baseUrl, api_key: apiKey, haiku_model: haikuModel, sonnet_model: sonnetModel, opus_model: opusModel }; + type OpenAIField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' + const OPENAI_FIELDS: OpenAIField[] = [ + 'base_url', + 'api_key', + 'haiku_model', + 'sonnet_model', + 'opus_model', + ] + const op = oauthStatus as { + state: 'openai_chat_api' + activeField: OpenAIField + baseUrl: string + apiKey: string + haikuModel: string + sonnetModel: string + opusModel: string + } + const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = op + const openaiDisplayValues: Record = { + base_url: baseUrl, + api_key: apiKey, + haiku_model: haikuModel, + sonnet_model: sonnetModel, + opus_model: opusModel, + } - const [openaiInputValue, setOpenaiInputValue] = useState(() => openaiDisplayValues[activeField]); - const [openaiInputCursorOffset, setOpenaiInputCursorOffset] = useState(() => openaiDisplayValues[activeField].length); + const [openaiInputValue, setOpenaiInputValue] = useState( + () => openaiDisplayValues[activeField], + ) + const [openaiInputCursorOffset, setOpenaiInputCursorOffset] = useState( + () => openaiDisplayValues[activeField].length, + ) - const buildOpenAIState = useCallback((field: OpenAIField, value: string, newActive?: OpenAIField) => { - const s = { state: 'openai_chat_api' as const, activeField: newActive ?? activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel }; - switch (field) { - case 'base_url': return { ...s, baseUrl: value }; - case 'api_key': return { ...s, apiKey: value }; - case 'haiku_model': return { ...s, haikuModel: value }; - case 'sonnet_model': return { ...s, sonnetModel: value }; - case 'opus_model': return { ...s, opusModel: value }; - } - }, [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel]); + const buildOpenAIState = useCallback( + (field: OpenAIField, value: string, newActive?: OpenAIField) => { + const s = { + state: 'openai_chat_api' as const, + activeField: newActive ?? activeField, + baseUrl, + apiKey, + haikuModel, + sonnetModel, + opusModel, + } + switch (field) { + case 'base_url': + return { ...s, baseUrl: value } + case 'api_key': + return { ...s, apiKey: value } + case 'haiku_model': + return { ...s, haikuModel: value } + case 'sonnet_model': + return { ...s, sonnetModel: value } + case 'opus_model': + return { ...s, opusModel: value } + } + }, + [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], + ) const doOpenAISave = useCallback(() => { - const finalVals = { ...openaiDisplayValues, [activeField]: openaiInputValue }; - const env: Record = {}; - if (finalVals.base_url) env.OPENAI_BASE_URL = finalVals.base_url; - if (finalVals.api_key) env.OPENAI_API_KEY = finalVals.api_key; - if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model; - if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model; - if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model; - const { error } = updateSettingsForSource('userSettings', { modelType: 'openai' as any, env } as any); + const finalVals = { ...openaiDisplayValues, [activeField]: openaiInputValue } + const env: Record = {} + if (finalVals.base_url) env.OPENAI_BASE_URL = finalVals.base_url + if (finalVals.api_key) env.OPENAI_API_KEY = finalVals.api_key + if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model + if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model + if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model + const { error } = updateSettingsForSource('userSettings', { + modelType: 'openai' as any, + env, + } as any) if (error) { - setOAuthStatus({ state: 'error', message: `Failed to save: ${error.message}`, toRetry: { state: 'openai_chat_api', baseUrl: '', apiKey: '', haikuModel: '', sonnetModel: '', opusModel: '', activeField: 'base_url' } }); + setOAuthStatus({ + state: 'error', + message: `Failed to save: ${error.message}`, + toRetry: { + state: 'openai_chat_api', + baseUrl: '', + apiKey: '', + haikuModel: '', + sonnetModel: '', + opusModel: '', + activeField: 'base_url', + }, + }) } else { - for (const [k, v] of Object.entries(env)) process.env[k] = v; - setOAuthStatus({ state: 'success' }); - void onDone(); + for (const [k, v] of Object.entries(env)) process.env[k] = v + setOAuthStatus({ state: 'success' }) + void onDone() } - }, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone]); + }, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone]) const handleOpenAIEnter = useCallback(() => { - const idx = OPENAI_FIELDS.indexOf(activeField); - setOAuthStatus(buildOpenAIState(activeField, openaiInputValue)); + const idx = OPENAI_FIELDS.indexOf(activeField) + setOAuthStatus(buildOpenAIState(activeField, openaiInputValue)) if (idx === OPENAI_FIELDS.length - 1) { - doOpenAISave(); + doOpenAISave() } else { - const next = OPENAI_FIELDS[idx + 1]!; - setOpenaiInputValue(openaiDisplayValues[next] ?? ''); - setOpenaiInputCursorOffset((openaiDisplayValues[next] ?? '').length); + const next = OPENAI_FIELDS[idx + 1]! + setOpenaiInputValue(openaiDisplayValues[next] ?? '') + setOpenaiInputCursorOffset((openaiDisplayValues[next] ?? '').length) } - }, [activeField, openaiInputValue, buildOpenAIState, doOpenAISave, openaiDisplayValues, setOAuthStatus]); + }, [ + activeField, + openaiInputValue, + buildOpenAIState, + doOpenAISave, + openaiDisplayValues, + setOAuthStatus, + ]) - useKeybinding('tabs:next', () => { - const idx = OPENAI_FIELDS.indexOf(activeField); - if (idx < OPENAI_FIELDS.length - 1) { - setOAuthStatus(buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx + 1])); - setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? ''); - setOpenaiInputCursorOffset((openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? '').length); - } - }, { context: 'Tabs' }); - useKeybinding('tabs:previous', () => { - const idx = OPENAI_FIELDS.indexOf(activeField); - if (idx > 0) { - setOAuthStatus(buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx - 1])); - setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? ''); - setOpenaiInputCursorOffset((openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? '').length); - } - }, { context: 'Tabs' }); - useKeybinding('confirm:no', () => { - setOAuthStatus({ state: 'idle' }); - }, { context: 'Confirmation' }); + useKeybinding( + 'tabs:next', + () => { + const idx = OPENAI_FIELDS.indexOf(activeField) + if (idx < OPENAI_FIELDS.length - 1) { + setOAuthStatus( + buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx + 1]), + ) + setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? '') + setOpenaiInputCursorOffset( + (openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? '').length, + ) + } + }, + { context: 'Tabs' }, + ) + useKeybinding( + 'tabs:previous', + () => { + const idx = OPENAI_FIELDS.indexOf(activeField) + if (idx > 0) { + setOAuthStatus( + buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx - 1]), + ) + setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? '') + setOpenaiInputCursorOffset( + (openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? '').length, + ) + } + }, + { context: 'Tabs' }, + ) + useKeybinding( + 'confirm:no', + () => { + setOAuthStatus({ state: 'idle' }) + }, + { context: 'Confirmation' }, + ) - const openaiColumns = useTerminalSize().columns - 20; + const openaiColumns = useTerminalSize().columns - 20 - const renderOpenAIRow = (field: OpenAIField, label: string, opts?: { mask?: boolean }) => { - const active = activeField === field; - const val = openaiDisplayValues[field]; - return - {` ${label} `} - - {active - ? - : (val - ? {opts?.mask ? val.slice(0, 8) + '·'.repeat(Math.max(0, val.length - 8)) : val} - : null)} - ; - }; + const renderOpenAIRow = ( + field: OpenAIField, + label: string, + opts?: { mask?: boolean }, + ) => { + const active = activeField === field + const val = openaiDisplayValues[field] + return ( + + + {` ${label} `} + + + {active ? ( + + ) : val ? ( + + {opts?.mask + ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) + : val} + + ) : null} + + ) + } - return - OpenAI Compatible API Setup - Configure an OpenAI Chat Completions compatible endpoint (e.g. Ollama, DeepSeek, vLLM). + return ( - {renderOpenAIRow('base_url', 'Base URL ')} - {renderOpenAIRow('api_key', 'API Key ', { mask: true })} - {renderOpenAIRow('haiku_model', 'Haiku ')} - {renderOpenAIRow('sonnet_model', 'Sonnet ')} - {renderOpenAIRow('opus_model', 'Opus ')} + OpenAI Compatible API Setup + + Configure an OpenAI Chat Completions compatible endpoint (e.g. + Ollama, DeepSeek, vLLM). + + + {renderOpenAIRow('base_url', 'Base URL ')} + {renderOpenAIRow('api_key', 'API Key ', { mask: true })} + {renderOpenAIRow('haiku_model', 'Haiku ')} + {renderOpenAIRow('sonnet_model', 'Sonnet ')} + {renderOpenAIRow('opus_model', 'Opus ')} + + + Tab to switch · Enter on last field to save · Esc to go back + - Tab to switch · Enter on last field to save · Esc to go back - ; - } - case "waiting_for_login": - { - let t1; - if ($[20] !== forcedMethodMessage) { - t1 = forcedMethodMessage && {forcedMethodMessage}; - $[20] = forcedMethodMessage; - $[21] = t1; - } else { - t1 = $[21]; - } - let t2; - if ($[22] !== showPastePrompt) { - t2 = !showPastePrompt && Opening browser to sign in…; - $[22] = showPastePrompt; - $[23] = t2; - } else { - t2 = $[23]; - } - let t3; - if ($[24] !== cursorOffset || $[25] !== handleSubmitCode || $[26] !== oauthStatus.url || $[27] !== pastedCode || $[28] !== setCursorOffset || $[29] !== setPastedCode || $[30] !== showPastePrompt || $[31] !== textInputColumns) { - t3 = showPastePrompt && {PASTE_HERE_MSG} handleSubmitCode(value, oauthStatus.url)} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={textInputColumns} mask="*" />; - $[24] = cursorOffset; - $[25] = handleSubmitCode; - $[26] = oauthStatus.url; - $[27] = pastedCode; - $[28] = setCursorOffset; - $[29] = setPastedCode; - $[30] = showPastePrompt; - $[31] = textInputColumns; - $[32] = t3; - } else { - t3 = $[32]; - } - let t4; - if ($[33] !== t1 || $[34] !== t2 || $[35] !== t3) { - t4 = {t1}{t2}{t3}; - $[33] = t1; - $[34] = t2; - $[35] = t3; - $[36] = t4; - } else { - t4 = $[36]; - } - return t4; - } - case "creating_api_key": - { - let t1; - if ($[37] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Creating API key for Claude Code…; - $[37] = t1; - } else { - t1 = $[37]; - } - return t1; - } - case "about_to_retry": - { - let t1; - if ($[38] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Retrying…; - $[38] = t1; - } else { - t1 = $[38]; - } - return t1; - } - case "success": - { - let t1; - if ($[39] !== mode || $[40] !== oauthStatus.token) { - t1 = mode === "setup-token" && oauthStatus.token ? null : <>{getOauthAccountInfo()?.emailAddress ? Logged in as{" "}{getOauthAccountInfo()?.emailAddress} : null}Login successful. Press Enter to continue…; - $[39] = mode; - $[40] = oauthStatus.token; - $[41] = t1; - } else { - t1 = $[41]; - } - let t2; - if ($[42] !== t1) { - t2 = {t1}; - $[42] = t1; - $[43] = t2; - } else { - t2 = $[43]; - } - return t2; - } - case "error": - { - let t1; - if ($[44] !== oauthStatus.message) { - t1 = OAuth error: {oauthStatus.message}; - $[44] = oauthStatus.message; - $[45] = t1; - } else { - t1 = $[45]; - } - let t2; - if ($[46] !== oauthStatus.toRetry) { - t2 = oauthStatus.toRetry && Press Enter to retry.; - $[46] = oauthStatus.toRetry; - $[47] = t2; - } else { - t2 = $[47]; - } - let t3; - if ($[48] !== t1 || $[49] !== t2) { - t3 = {t1}{t2}; - $[48] = t1; - $[49] = t2; - $[50] = t3; - } else { - t3 = $[50]; - } - return t3; + ) } + + case 'platform_setup': + return ( + + Using 3rd-party platforms + + + + Claude Code supports Amazon Bedrock, Microsoft Foundry, and Vertex + AI. Set the required environment variables, then restart Claude + Code. + + + + If you are part of an enterprise organization, contact your + administrator for setup instructions. + + + + Documentation: + + · Amazon Bedrock:{' '} + + https://code.claude.com/docs/en/amazon-bedrock + + + + · Microsoft Foundry:{' '} + + https://code.claude.com/docs/en/microsoft-foundry + + + + · Vertex AI:{' '} + + https://code.claude.com/docs/en/google-vertex-ai + + + + + + + Press Enter to go back to login options. + + + + + ) + + case 'waiting_for_login': + return ( + + {forcedMethodMessage && ( + + {forcedMethodMessage} + + )} + + {!showPastePrompt && ( + + + Opening browser to sign in… + + )} + + {showPastePrompt && ( + + {PASTE_HERE_MSG} + + handleSubmitCode(value, oauthStatus.url) + } + cursorOffset={cursorOffset} + onChangeCursorOffset={setCursorOffset} + columns={textInputColumns} + mask="*" + /> + + )} + + ) + + case 'creating_api_key': + return ( + + + + Creating API key for Claude Code… + + + ) + + case 'about_to_retry': + return ( + + Retrying… + + ) + + case 'success': + return ( + + {mode === 'setup-token' && oauthStatus.token ? null : ( + <> + {getOauthAccountInfo()?.emailAddress ? ( + + Logged in as{' '} + {getOauthAccountInfo()?.emailAddress} + + ) : null} + + Login successful. Press Enter to continue… + + + )} + + ) + + case 'error': + return ( + + OAuth error: {oauthStatus.message} + + {oauthStatus.toRetry && ( + + + Press Enter to retry. + + + )} + + ) + default: - { - return null; - } + return null } } diff --git a/src/components/ContextSuggestions.tsx b/src/components/ContextSuggestions.tsx index 1fc9e3d5c..2eeafafe3 100644 --- a/src/components/ContextSuggestions.tsx +++ b/src/components/ContextSuggestions.tsx @@ -1,46 +1,38 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { Box, Text } from '../ink.js'; -import type { ContextSuggestion } from '../utils/contextSuggestions.js'; -import { formatTokens } from '../utils/format.js'; -import { StatusIcon } from './design-system/StatusIcon.js'; +import figures from 'figures' +import * as React from 'react' +import { Box, Text } from '../ink.js' +import type { ContextSuggestion } from '../utils/contextSuggestions.js' +import { formatTokens } from '../utils/format.js' +import { StatusIcon } from './design-system/StatusIcon.js' + type Props = { - suggestions: ContextSuggestion[]; -}; -export function ContextSuggestions(t0) { - const $ = _c(5); - const { - suggestions - } = t0; - if (suggestions.length === 0) { - return null; - } - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Suggestions; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== suggestions) { - t2 = suggestions.map(_temp); - $[1] = suggestions; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== t2) { - t3 = {t1}{t2}; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; + suggestions: ContextSuggestion[] } -function _temp(suggestion, i) { - return {suggestion.title}{suggestion.savingsTokens ? {" "}{figures.arrowRight} save ~{formatTokens(suggestion.savingsTokens)} : null}{suggestion.detail}; + +export function ContextSuggestions({ suggestions }: Props): React.ReactNode { + if (suggestions.length === 0) return null + + return ( + + Suggestions + {suggestions.map((suggestion, i) => ( + + + + {suggestion.title} + {suggestion.savingsTokens ? ( + + {' '} + {figures.arrowRight} save ~ + {formatTokens(suggestion.savingsTokens)} + + ) : null} + + + {suggestion.detail} + + + ))} + + ) } diff --git a/src/components/ContextVisualization.tsx b/src/components/ContextVisualization.tsx index 04a9c1374..e6fb57493 100644 --- a/src/components/ContextVisualization.tsx +++ b/src/components/ContextVisualization.tsx @@ -1,15 +1,18 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { Box, Text } from '../ink.js'; -import type { ContextData } from '../utils/analyzeContext.js'; -import { generateContextSuggestions } from '../utils/contextSuggestions.js'; -import { getDisplayPath } from '../utils/file.js'; -import { formatTokens } from '../utils/format.js'; -import { getSourceDisplayName, type SettingSource } from '../utils/settings/constants.js'; -import { plural } from '../utils/stringUtils.js'; -import { ContextSuggestions } from './ContextSuggestions.js'; -const RESERVED_CATEGORY_NAME = 'Autocompact buffer'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { Box, Text } from '../ink.js' +import type { ContextData } from '../utils/analyzeContext.js' +import { generateContextSuggestions } from '../utils/contextSuggestions.js' +import { getDisplayPath } from '../utils/file.js' +import { formatTokens } from '../utils/format.js' +import { + getSourceDisplayName, + type SettingSource, +} from '../utils/settings/constants.js' +import { plural } from '../utils/stringUtils.js' +import { ContextSuggestions } from './ContextSuggestions.js' + +const RESERVED_CATEGORY_NAME = 'Autocompact buffer' /** * One-liner for the legend header showing what context-collapse has done. @@ -18,95 +21,100 @@ const RESERVED_CATEGORY_NAME = 'Autocompact buffer'; * their context was rewritten — the placeholders are isMeta * and don't appear in the conversation view. */ -function CollapseStatus() { - const $ = _c(2); - if (feature("CONTEXT_COLLAPSE")) { - let t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Symbol.for("react.early_return_sentinel"); - bb0: { - const { - getStats, - isContextCollapseEnabled - } = require("../services/contextCollapse/index.js") as typeof import('../services/contextCollapse/index.js'); - if (!isContextCollapseEnabled()) { - t1 = null; - break bb0; - } - const s = getStats(); - const { - health: h - } = s; - const parts = []; - if (s.collapsedSpans > 0) { - parts.push(`${s.collapsedSpans} ${plural(s.collapsedSpans, "span")} summarized (${s.collapsedMessages} msgs)`); - } - if (s.stagedSpans > 0) { - parts.push(`${s.stagedSpans} staged`); - } - const summary = parts.length > 0 ? parts.join(", ") : h.totalSpawns > 0 ? `${h.totalSpawns} ${plural(h.totalSpawns, "spawn")}, nothing staged yet` : "waiting for first trigger"; - let line2 = null; - if (h.totalErrors > 0) { - line2 = Collapse errors: {h.totalErrors}/{h.totalSpawns} spawns failed{h.lastError ? ` (last: ${h.lastError.slice(0, 60)})` : ""}; - } else { - if (h.emptySpawnWarningEmitted) { - line2 = Collapse idle: {h.totalEmptySpawns} consecutive empty runs; - } - } - t0 = <>Context strategy: collapse ({summary}){line2}; - } - $[0] = t0; - $[1] = t1; - } else { - t0 = $[0]; - t1 = $[1]; +function CollapseStatus(): React.ReactNode { + if (feature('CONTEXT_COLLAPSE')) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { getStats, isContextCollapseEnabled } = + require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + if (!isContextCollapseEnabled()) return null + + const s = getStats() + const { health: h } = s + + const parts: string[] = [] + if (s.collapsedSpans > 0) { + parts.push( + `${s.collapsedSpans} ${plural(s.collapsedSpans, 'span')} summarized (${s.collapsedMessages} msgs)`, + ) } - if (t1 !== Symbol.for("react.early_return_sentinel")) { - return t1; + if (s.stagedSpans > 0) parts.push(`${s.stagedSpans} staged`) + const summary = + parts.length > 0 + ? parts.join(', ') + : h.totalSpawns > 0 + ? `${h.totalSpawns} ${plural(h.totalSpawns, 'spawn')}, nothing staged yet` + : 'waiting for first trigger' + + let line2: React.ReactNode = null + if (h.totalErrors > 0) { + line2 = ( + + Collapse errors: {h.totalErrors}/{h.totalSpawns} spawns failed + {h.lastError ? ` (last: ${h.lastError.slice(0, 60)})` : ''} + + ) + } else if (h.emptySpawnWarningEmitted) { + line2 = ( + + Collapse idle: {h.totalEmptySpawns} consecutive empty runs + + ) } - return t0; + + return ( + <> + Context strategy: collapse ({summary}) + {line2} + + ) } - return null; + return null } // Order for displaying source groups: Project > User > Managed > Plugin > Built-in -const SOURCE_DISPLAY_ORDER = ['Project', 'User', 'Managed', 'Plugin', 'Built-in']; +const SOURCE_DISPLAY_ORDER = [ + 'Project', + 'User', + 'Managed', + 'Plugin', + 'Built-in', +] /** Group items by source type for display, sorted by tokens descending within each group */ -function groupBySource(items: T[]): Map { - const groups = new Map(); +function groupBySource< + T extends { source: SettingSource | 'plugin' | 'built-in'; tokens: number }, +>(items: T[]): Map { + const groups = new Map() for (const item of items) { - const key = getSourceDisplayName(item.source); - const existing = groups.get(key) || []; - existing.push(item); - groups.set(key, existing); + const key = getSourceDisplayName(item.source) + const existing = groups.get(key) || [] + existing.push(item) + groups.set(key, existing) } // Sort each group by tokens descending for (const [key, group] of groups.entries()) { - groups.set(key, group.sort((a, b) => b.tokens - a.tokens)); + groups.set( + key, + group.sort((a, b) => b.tokens - a.tokens), + ) } // Return groups in consistent order - const orderedGroups = new Map(); + const orderedGroups = new Map() for (const source of SOURCE_DISPLAY_ORDER) { - const group = groups.get(source); + const group = groups.get(source) if (group) { - orderedGroups.set(source, group); + orderedGroups.set(source, group) } } - return orderedGroups; + return orderedGroups } + interface Props { - data: ContextData; + data: ContextData } -export function ContextVisualization(t0) { - const $ = _c(87); - const { - data - } = t0; + +export function ContextVisualization({ data }: Props): React.ReactNode { const { categories, totalTokens, @@ -116,373 +124,371 @@ export function ContextVisualization(t0) { model, memoryFiles, mcpTools, - deferredBuiltinTools: t1, + deferredBuiltinTools = [], systemTools, systemPromptSections, agents, skills, - messageBreakdown - } = data; - let T0; - let T1; - let t2; - let t3; - let t4; - let t5; - let t6; - let t7; - let t8; - let t9; - if ($[0] !== categories || $[1] !== gridRows || $[2] !== mcpTools || $[3] !== model || $[4] !== percentage || $[5] !== rawMaxTokens || $[6] !== systemTools || $[7] !== t1 || $[8] !== totalTokens) { - const deferredBuiltinTools = t1 === undefined ? [] : t1; - const visibleCategories = categories.filter(_temp); - let t10; - if ($[19] !== categories) { - t10 = categories.some(_temp2); - $[19] = categories; - $[20] = t10; - } else { - t10 = $[20]; - } - const hasDeferredMcpTools = t10; - const hasDeferredBuiltinTools = deferredBuiltinTools.length > 0; - const autocompactCategory = categories.find(_temp3); - T1 = Box; - t6 = "column"; - t7 = 1; - if ($[21] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Context Usage; - $[21] = t8; - } else { - t8 = $[21]; - } - let t11; - if ($[22] !== gridRows) { - t11 = gridRows.map(_temp5); - $[22] = gridRows; - $[23] = t11; - } else { - t11 = $[23]; - } - let t12; - if ($[24] !== t11) { - t12 = {t11}; - $[24] = t11; - $[25] = t12; - } else { - t12 = $[25]; - } - let t13; - if ($[26] !== totalTokens) { - t13 = formatTokens(totalTokens); - $[26] = totalTokens; - $[27] = t13; - } else { - t13 = $[27]; - } - let t14; - if ($[28] !== rawMaxTokens) { - t14 = formatTokens(rawMaxTokens); - $[28] = rawMaxTokens; - $[29] = t14; - } else { - t14 = $[29]; - } - let t15; - if ($[30] !== model || $[31] !== percentage || $[32] !== t13 || $[33] !== t14) { - t15 = {model} · {t13}/{t14}{" "}tokens ({percentage}%); - $[30] = model; - $[31] = percentage; - $[32] = t13; - $[33] = t14; - $[34] = t15; - } else { - t15 = $[34]; - } - let t16; - let t17; - let t18; - if ($[35] === Symbol.for("react.memo_cache_sentinel")) { - t16 = ; - t17 = ; - t18 = Estimated usage by category; - $[35] = t16; - $[36] = t17; - $[37] = t18; - } else { - t16 = $[35]; - t17 = $[36]; - t18 = $[37]; - } - let t19; - if ($[38] !== rawMaxTokens) { - t19 = (cat_2, index) => { - const tokenDisplay = formatTokens(cat_2.tokens); - const percentDisplay = cat_2.isDeferred ? "N/A" : `${(cat_2.tokens / rawMaxTokens * 100).toFixed(1)}%`; - const isReserved = cat_2.name === RESERVED_CATEGORY_NAME; - const displayName = cat_2.name; - const symbol = cat_2.isDeferred ? " " : isReserved ? "\u26DD" : "\u26C1"; - return {symbol} {displayName}: {tokenDisplay} tokens ({percentDisplay}); - }; - $[38] = rawMaxTokens; - $[39] = t19; - } else { - t19 = $[39]; - } - const t20 = visibleCategories.map(t19); - let t21; - if ($[40] !== categories || $[41] !== rawMaxTokens) { - t21 = (categories.find(_temp6)?.tokens ?? 0) > 0 && Free space: {formatTokens(categories.find(_temp7)?.tokens || 0)}{" "}({((categories.find(_temp8)?.tokens || 0) / rawMaxTokens * 100).toFixed(1)}%); - $[40] = categories; - $[41] = rawMaxTokens; - $[42] = t21; - } else { - t21 = $[42]; - } - const t22 = autocompactCategory && autocompactCategory.tokens > 0 && {autocompactCategory.name}: {formatTokens(autocompactCategory.tokens)} tokens ({(autocompactCategory.tokens / rawMaxTokens * 100).toFixed(1)}%); - let t23; - if ($[43] !== t15 || $[44] !== t20 || $[45] !== t21 || $[46] !== t22) { - t23 = {t15}{t16}{t17}{t18}{t20}{t21}{t22}; - $[43] = t15; - $[44] = t20; - $[45] = t21; - $[46] = t22; - $[47] = t23; - } else { - t23 = $[47]; - } - if ($[48] !== t12 || $[49] !== t23) { - t9 = {t12}{t23}; - $[48] = t12; - $[49] = t23; - $[50] = t9; - } else { - t9 = $[50]; - } - T0 = Box; - t2 = "column"; - t3 = -1; - if ($[51] !== hasDeferredMcpTools || $[52] !== mcpTools) { - t4 = mcpTools.length > 0 && MCP tools{" "}· /mcp{hasDeferredMcpTools ? " (loaded on-demand)" : ""}{mcpTools.some(_temp9) && Loaded{mcpTools.filter(_temp0).map(_temp1)}}{hasDeferredMcpTools && mcpTools.some(_temp10) && Available{mcpTools.filter(_temp11).map(_temp12)}}{!hasDeferredMcpTools && mcpTools.map(_temp13)}; - $[51] = hasDeferredMcpTools; - $[52] = mcpTools; - $[53] = t4; - } else { - t4 = $[53]; - } - t5 = (systemTools && systemTools.length > 0 || hasDeferredBuiltinTools) && false && [ANT-ONLY] System tools{hasDeferredBuiltinTools && (some loaded on-demand)}Loaded{systemTools?.map(_temp14)}{deferredBuiltinTools.filter(_temp15).map(_temp16)}{hasDeferredBuiltinTools && deferredBuiltinTools.some(_temp17) && Available{deferredBuiltinTools.filter(_temp18).map(_temp19)}}; - $[0] = categories; - $[1] = gridRows; - $[2] = mcpTools; - $[3] = model; - $[4] = percentage; - $[5] = rawMaxTokens; - $[6] = systemTools; - $[7] = t1; - $[8] = totalTokens; - $[9] = T0; - $[10] = T1; - $[11] = t2; - $[12] = t3; - $[13] = t4; - $[14] = t5; - $[15] = t6; - $[16] = t7; - $[17] = t8; - $[18] = t9; - } else { - T0 = $[9]; - T1 = $[10]; - t2 = $[11]; - t3 = $[12]; - t4 = $[13]; - t5 = $[14]; - t6 = $[15]; - t7 = $[16]; - t8 = $[17]; - t9 = $[18]; - } - let t10; - if ($[54] !== systemPromptSections) { - t10 = systemPromptSections && systemPromptSections.length > 0 && false && [ANT-ONLY] System prompt sections{systemPromptSections.map(_temp20)}; - $[54] = systemPromptSections; - $[55] = t10; - } else { - t10 = $[55]; - } - let t11; - if ($[56] !== agents) { - t11 = agents.length > 0 && Custom agents · /agents{Array.from(groupBySource(agents).entries()).map(_temp22)}; - $[56] = agents; - $[57] = t11; - } else { - t11 = $[57]; - } - let t12; - if ($[58] !== memoryFiles) { - t12 = memoryFiles.length > 0 && Memory files · /memory{memoryFiles.map(_temp23)}; - $[58] = memoryFiles; - $[59] = t12; - } else { - t12 = $[59]; - } - let t13; - if ($[60] !== skills) { - t13 = skills && skills.tokens > 0 && Skills · /skills{Array.from(groupBySource(skills.skillFrontmatter).entries()).map(_temp25)}; - $[60] = skills; - $[61] = t13; - } else { - t13 = $[61]; - } - let t14; - if ($[62] !== messageBreakdown) { - t14 = messageBreakdown && false && [ANT-ONLY] Message breakdownTool calls: {formatTokens(messageBreakdown.toolCallTokens)} tokensTool results: {formatTokens(messageBreakdown.toolResultTokens)} tokensAttachments: {formatTokens(messageBreakdown.attachmentTokens)} tokensAssistant messages (non-tool): {formatTokens(messageBreakdown.assistantMessageTokens)} tokensUser messages (non-tool-result): {formatTokens(messageBreakdown.userMessageTokens)} tokens{messageBreakdown.toolCallsByType.length > 0 && [ANT-ONLY] Top tools{messageBreakdown.toolCallsByType.slice(0, 5).map(_temp26)}}{messageBreakdown.attachmentsByType.length > 0 && [ANT-ONLY] Top attachments{messageBreakdown.attachmentsByType.slice(0, 5).map(_temp27)}}; - $[62] = messageBreakdown; - $[63] = t14; - } else { - t14 = $[63]; - } - let t15; - if ($[64] !== T0 || $[65] !== t10 || $[66] !== t11 || $[67] !== t12 || $[68] !== t13 || $[69] !== t14 || $[70] !== t2 || $[71] !== t3 || $[72] !== t4 || $[73] !== t5) { - t15 = {t4}{t5}{t10}{t11}{t12}{t13}{t14}; - $[64] = T0; - $[65] = t10; - $[66] = t11; - $[67] = t12; - $[68] = t13; - $[69] = t14; - $[70] = t2; - $[71] = t3; - $[72] = t4; - $[73] = t5; - $[74] = t15; - } else { - t15 = $[74]; - } - let t16; - if ($[75] !== data) { - t16 = generateContextSuggestions(data); - $[75] = data; - $[76] = t16; - } else { - t16 = $[76]; - } - let t17; - if ($[77] !== t16) { - t17 = ; - $[77] = t16; - $[78] = t17; - } else { - t17 = $[78]; - } - let t18; - if ($[79] !== T1 || $[80] !== t15 || $[81] !== t17 || $[82] !== t6 || $[83] !== t7 || $[84] !== t8 || $[85] !== t9) { - t18 = {t8}{t9}{t15}{t17}; - $[79] = T1; - $[80] = t15; - $[81] = t17; - $[82] = t6; - $[83] = t7; - $[84] = t8; - $[85] = t9; - $[86] = t18; - } else { - t18 = $[86]; - } - return t18; -} -function _temp27(attachment, i_10) { - return └ {attachment.name}: {formatTokens(attachment.tokens)} tokens; -} -function _temp26(tool_5, i_9) { - return └ {tool_5.name}: calls {formatTokens(tool_5.callTokens)}, results{" "}{formatTokens(tool_5.resultTokens)}; -} -function _temp25(t0) { - const [sourceDisplay_0, sourceSkills] = t0; - return {sourceDisplay_0}{sourceSkills.map(_temp24)}; -} -function _temp24(skill, i_8) { - return └ {skill.name}: {formatTokens(skill.tokens)} tokens; -} -function _temp23(file, i_7) { - return └ {getDisplayPath(file.path)}: {formatTokens(file.tokens)} tokens; -} -function _temp22(t0) { - const [sourceDisplay, sourceAgents] = t0; - return {sourceDisplay}{sourceAgents.map(_temp21)}; -} -function _temp21(agent, i_6) { - return └ {agent.agentType}: {formatTokens(agent.tokens)} tokens; -} -function _temp20(section, i_5) { - return └ {section.name}: {formatTokens(section.tokens)} tokens; -} -function _temp19(tool_4, i_4) { - return └ {tool_4.name}; -} -function _temp18(t_4) { - return !t_4.isLoaded; -} -function _temp17(t_5) { - return !t_5.isLoaded; -} -function _temp16(tool_3, i_3) { - return └ {tool_3.name}: {formatTokens(tool_3.tokens)} tokens; -} -function _temp15(t_3) { - return t_3.isLoaded; -} -function _temp14(tool_2, i_2) { - return └ {tool_2.name}: {formatTokens(tool_2.tokens)} tokens; -} -function _temp13(tool_1, i_1) { - return └ {tool_1.name}: {formatTokens(tool_1.tokens)} tokens; -} -function _temp12(tool_0, i_0) { - return └ {tool_0.name}; -} -function _temp11(t_1) { - return !t_1.isLoaded; -} -function _temp10(t_2) { - return !t_2.isLoaded; -} -function _temp1(tool, i) { - return └ {tool.name}: {formatTokens(tool.tokens)} tokens; -} -function _temp0(t) { - return t.isLoaded; -} -function _temp9(t_0) { - return t_0.isLoaded; -} -function _temp8(c_0) { - return c_0.name === "Free space"; -} -function _temp7(c) { - return c.name === "Free space"; -} -function _temp6(c_1) { - return c_1.name === "Free space"; -} -function _temp5(row, rowIndex) { - return {row.map(_temp4)}; -} -function _temp4(square, colIndex) { - if (square.categoryName === "Free space") { - return {"\u26F6 "}; - } - if (square.categoryName === RESERVED_CATEGORY_NAME) { - return {"\u26DD "}; - } - return {square.squareFullness >= 0.7 ? "\u26C1 " : "\u26C0 "}; -} -function _temp3(cat_1) { - return cat_1.name === RESERVED_CATEGORY_NAME; -} -function _temp2(cat_0) { - return cat_0.isDeferred && cat_0.name.includes("MCP"); -} -function _temp(cat) { - return cat.tokens > 0 && cat.name !== "Free space" && cat.name !== RESERVED_CATEGORY_NAME && !cat.isDeferred; + messageBreakdown, + } = data + + // Filter out categories with 0 tokens for the legend, and exclude Free space, Autocompact buffer, and deferred + const visibleCategories = categories.filter( + cat => + cat.tokens > 0 && + cat.name !== 'Free space' && + cat.name !== RESERVED_CATEGORY_NAME && + !cat.isDeferred, + ) + // Check if MCP tools are deferred (loaded on-demand via tool search) + const hasDeferredMcpTools = categories.some( + cat => cat.isDeferred && cat.name.includes('MCP'), + ) + // Check if builtin tools are deferred + const hasDeferredBuiltinTools = deferredBuiltinTools.length > 0 + const autocompactCategory = categories.find( + cat => cat.name === RESERVED_CATEGORY_NAME, + ) + + return ( + + Context Usage + + {/* Fixed size grid */} + + {gridRows.map((row, rowIndex) => ( + + {row.map((square, colIndex) => { + if (square.categoryName === 'Free space') { + return ( + + {'⛶ '} + + ) + } + if (square.categoryName === RESERVED_CATEGORY_NAME) { + return ( + + {'⛝ '} + + ) + } + return ( + + {square.squareFullness >= 0.7 ? '⛁ ' : '⛀ '} + + ) + })} + + ))} + + + {/* Legend to the right */} + + + {model} · {formatTokens(totalTokens)}/{formatTokens(rawMaxTokens)}{' '} + tokens ({percentage}%) + + + + + Estimated usage by category + + {visibleCategories.map((cat, index) => { + const tokenDisplay = formatTokens(cat.tokens) + // Show "N/A" for deferred categories since they don't count toward context + const percentDisplay = cat.isDeferred + ? 'N/A' + : `${((cat.tokens / rawMaxTokens) * 100).toFixed(1)}%` + const isReserved = cat.name === RESERVED_CATEGORY_NAME + const displayName = cat.name + // Deferred categories don't appear in grid, so show blank instead of symbol + const symbol = cat.isDeferred ? ' ' : isReserved ? '⛝' : '⛁' + + return ( + + {symbol} + {displayName}: + + {tokenDisplay} tokens ({percentDisplay}) + + + ) + })} + {(categories.find(c => c.name === 'Free space')?.tokens ?? 0) > 0 && ( + + + Free space: + + {formatTokens( + categories.find(c => c.name === 'Free space')?.tokens || 0, + )}{' '} + ( + {( + ((categories.find(c => c.name === 'Free space')?.tokens || + 0) / + rawMaxTokens) * + 100 + ).toFixed(1)} + %) + + + )} + {autocompactCategory && autocompactCategory.tokens > 0 && ( + + + {autocompactCategory.name}: + + {formatTokens(autocompactCategory.tokens)} tokens ( + {((autocompactCategory.tokens / rawMaxTokens) * 100).toFixed(1)} + %) + + + )} + + + + + {mcpTools.length > 0 && ( + + + MCP tools + + {' '} + · /mcp{hasDeferredMcpTools ? ' (loaded on-demand)' : ''} + + + {/* Show loaded tools first */} + {mcpTools.some(t => t.isLoaded) && ( + + Loaded + {mcpTools + .filter(t => t.isLoaded) + .map((tool, i) => ( + + └ {tool.name}: + {formatTokens(tool.tokens)} tokens + + ))} + + )} + {/* Show available (deferred) tools */} + {hasDeferredMcpTools && mcpTools.some(t => !t.isLoaded) && ( + + Available + {mcpTools + .filter(t => !t.isLoaded) + .map((tool, i) => ( + + └ {tool.name} + + ))} + + )} + {/* Show all tools normally when not deferred */} + {!hasDeferredMcpTools && + mcpTools.map((tool, i) => ( + + └ {tool.name}: + {formatTokens(tool.tokens)} tokens + + ))} + + )} + + {/* Show builtin tools: always-loaded + deferred (ant-only) */} + {((systemTools && systemTools.length > 0) || hasDeferredBuiltinTools) && + process.env.USER_TYPE === 'ant' && ( + + + [ANT-ONLY] System tools + {hasDeferredBuiltinTools && ( + (some loaded on-demand) + )} + + {/* Always-loaded + deferred-but-loaded tools */} + + Loaded + {systemTools?.map((tool, i) => ( + + └ {tool.name}: + {formatTokens(tool.tokens)} tokens + + ))} + {deferredBuiltinTools + .filter(t => t.isLoaded) + .map((tool, i) => ( + + └ {tool.name}: + {formatTokens(tool.tokens)} tokens + + ))} + + {/* Deferred (not yet loaded) tools */} + {hasDeferredBuiltinTools && + deferredBuiltinTools.some(t => !t.isLoaded) && ( + + Available + {deferredBuiltinTools + .filter(t => !t.isLoaded) + .map((tool, i) => ( + + └ {tool.name} + + ))} + + )} + + )} + + {systemPromptSections && + systemPromptSections.length > 0 && + process.env.USER_TYPE === 'ant' && ( + + [ANT-ONLY] System prompt sections + {systemPromptSections.map((section, i) => ( + + └ {section.name}: + {formatTokens(section.tokens)} tokens + + ))} + + )} + + {agents.length > 0 && ( + + + Custom agents + · /agents + + {Array.from(groupBySource(agents).entries()).map( + ([sourceDisplay, sourceAgents]) => ( + + {sourceDisplay} + {sourceAgents.map((agent, i) => ( + + └ {agent.agentType}: + {formatTokens(agent.tokens)} tokens + + ))} + + ), + )} + + )} + + {memoryFiles.length > 0 && ( + + + Memory files + · /memory + + {memoryFiles.map((file, i) => ( + + └ {getDisplayPath(file.path)}: + {formatTokens(file.tokens)} tokens + + ))} + + )} + + {skills && skills.tokens > 0 && ( + + + Skills + · /skills + + {Array.from(groupBySource(skills.skillFrontmatter).entries()).map( + ([sourceDisplay, sourceSkills]) => ( + + {sourceDisplay} + {sourceSkills.map((skill, i) => ( + + └ {skill.name}: + {formatTokens(skill.tokens)} tokens + + ))} + + ), + )} + + )} + + {messageBreakdown && process.env.USER_TYPE === 'ant' && ( + + [ANT-ONLY] Message breakdown + + + + Tool calls: + + {formatTokens(messageBreakdown.toolCallTokens)} tokens + + + + + Tool results: + + {formatTokens(messageBreakdown.toolResultTokens)} tokens + + + + + Attachments: + + {formatTokens(messageBreakdown.attachmentTokens)} tokens + + + + + Assistant messages (non-tool): + + {formatTokens(messageBreakdown.assistantMessageTokens)} tokens + + + + + User messages (non-tool-result): + + {formatTokens(messageBreakdown.userMessageTokens)} tokens + + + + + {messageBreakdown.toolCallsByType.length > 0 && ( + + [ANT-ONLY] Top tools + {messageBreakdown.toolCallsByType.slice(0, 5).map((tool, i) => ( + + └ {tool.name}: + + calls {formatTokens(tool.callTokens)}, results{' '} + {formatTokens(tool.resultTokens)} + + + ))} + + )} + + {messageBreakdown.attachmentsByType.length > 0 && ( + + [ANT-ONLY] Top attachments + {messageBreakdown.attachmentsByType + .slice(0, 5) + .map((attachment, i) => ( + + └ {attachment.name}: + + {formatTokens(attachment.tokens)} tokens + + + ))} + + )} + + )} + + + + ) } diff --git a/src/components/CoordinatorAgentStatus.tsx b/src/components/CoordinatorAgentStatus.tsx index 239095b5c..2aacb7d6a 100644 --- a/src/components/CoordinatorAgentStatus.tsx +++ b/src/components/CoordinatorAgentStatus.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * CoordinatorTaskPanel — Steerable list of background agents. * @@ -7,18 +6,28 @@ import { c as _c } from "react/compiler-runtime"; * always; a timestamp shows until passed. Enter to view/steer, x to dismiss. */ -import figures from 'figures'; -import * as React from 'react'; -import { BLACK_CIRCLE, PAUSE_ICON, PLAY_ICON } from '../constants/figures.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { stringWidth } from '../ink/stringWidth.js'; -import { Box, Text, wrapText } from '../ink.js'; -import { type AppState, useAppState, useSetAppState } from '../state/AppState.js'; -import { enterTeammateView, exitTeammateView } from '../state/teammateViewHelpers.js'; -import { isPanelAgentTask, type LocalAgentTaskState } from '../tasks/LocalAgentTask/LocalAgentTask.js'; -import { formatDuration, formatNumber } from '../utils/format.js'; -import { evictTerminalTask } from '../utils/task/framework.js'; -import { isTerminalStatus } from './tasks/taskStatusUtils.js'; +import figures from 'figures' +import * as React from 'react' +import { BLACK_CIRCLE, PAUSE_ICON, PLAY_ICON } from '../constants/figures.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { stringWidth } from '../ink/stringWidth.js' +import { Box, Text, wrapText } from '../ink.js' +import { + type AppState, + useAppState, + useSetAppState, +} from '../state/AppState.js' +import { + enterTeammateView, + exitTeammateView, +} from '../state/teammateViewHelpers.js' +import { + isPanelAgentTask, + type LocalAgentTaskState, +} from '../tasks/LocalAgentTask/LocalAgentTask.js' +import { formatDuration, formatNumber } from '../utils/format.js' +import { evictTerminalTask } from '../utils/task/framework.js' +import { isTerminalStatus } from './tasks/taskStatusUtils.js' /** * Which panel-managed tasks currently have a visible row. @@ -28,51 +37,83 @@ import { isTerminalStatus } from './tasks/taskStatusUtils.js'; * the filter time-dependent. Shared by panel render, useCoordinatorTaskCount, * and index resolvers so the math can't drift. */ -export function getVisibleAgentTasks(tasks: AppState['tasks']): LocalAgentTaskState[] { - return Object.values(tasks).filter((t): t is LocalAgentTaskState => isPanelAgentTask(t) && t.evictAfter !== 0).sort((a, b) => a.startTime - b.startTime); +export function getVisibleAgentTasks( + tasks: AppState['tasks'], +): LocalAgentTaskState[] { + return Object.values(tasks) + .filter( + (t): t is LocalAgentTaskState => + isPanelAgentTask(t) && t.evictAfter !== 0, + ) + .sort((a, b) => a.startTime - b.startTime) } + export function CoordinatorTaskPanel(): React.ReactNode { - const tasks = useAppState(s => s.tasks); - const viewingAgentTaskId = useAppState(s_0 => s_0.viewingAgentTaskId); - const agentNameRegistry = useAppState(s_1 => s_1.agentNameRegistry); - const coordinatorTaskIndex = useAppState(s_2 => s_2.coordinatorTaskIndex); - const tasksSelected = useAppState(s_3 => s_3.footerSelection === 'tasks'); - const selectedIndex = tasksSelected ? coordinatorTaskIndex : undefined; - const setAppState = useSetAppState(); - const visibleTasks = getVisibleAgentTasks(tasks); - const hasTasks = Object.values(tasks).some(isPanelAgentTask); + const tasks = useAppState(s => s.tasks) + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) + const agentNameRegistry = useAppState(s => s.agentNameRegistry) + const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex) + const tasksSelected = useAppState(s => s.footerSelection === 'tasks') + const selectedIndex = tasksSelected ? coordinatorTaskIndex : undefined + const setAppState = useSetAppState() + + const visibleTasks = getVisibleAgentTasks(tasks) + const hasTasks = Object.values(tasks).some(isPanelAgentTask) // 1s tick: re-render for elapsed time + evict tasks past their deadline. // The eviction deletes from prev.tasks, which makes useCoordinatorTaskCount // (and other consumers) see the updated count without their own tick. - const tasksRef = React.useRef(tasks); - tasksRef.current = tasks; - const [, setTick] = React.useState(0); + const tasksRef = React.useRef(tasks) + tasksRef.current = tasks + const [, setTick] = React.useState(0) React.useEffect(() => { - if (!hasTasks) return; - const interval = setInterval((tasksRef_0, setAppState_0, setTick_0) => { - const now = Date.now(); - for (const t of Object.values(tasksRef_0.current)) { - if (isPanelAgentTask(t) && (t.evictAfter ?? Infinity) <= now) { - evictTerminalTask(t.id, setAppState_0); + if (!hasTasks) return + const interval = setInterval( + (tasksRef, setAppState, setTick) => { + const now = Date.now() + for (const t of Object.values(tasksRef.current)) { + if (isPanelAgentTask(t) && (t.evictAfter ?? Infinity) <= now) { + evictTerminalTask(t.id, setAppState) + } } - } - setTick_0((prev: number) => prev + 1); - }, 1000, tasksRef, setAppState, setTick); - return () => clearInterval(interval); - }, [hasTasks, setAppState]); + setTick((prev: number) => prev + 1) + }, + 1000, + tasksRef, + setAppState, + setTick, + ) + return () => clearInterval(interval) + }, [hasTasks, setAppState]) const nameByAgentId = React.useMemo(() => { - const inv = new Map(); - for (const [n, id] of agentNameRegistry) inv.set(id, n); - return inv; - }, [agentNameRegistry]); + const inv = new Map() + for (const [n, id] of agentNameRegistry) inv.set(id, n) + return inv + }, [agentNameRegistry]) + if (visibleTasks.length === 0) { - return null; + return null } - return - exitTeammateView(setAppState)} /> - {visibleTasks.map((task, i) => enterTeammateView(task.id, setAppState)} />)} - ; + + return ( + + exitTeammateView(setAppState)} + /> + {visibleTasks.map((task, i) => ( + enterTeammateView(task.id, setAppState)} + /> + ))} + + ) } /** @@ -80,193 +121,137 @@ export function CoordinatorTaskPanel(): React.ReactNode { * The panel's 1s tick evicts expired tasks from prev.tasks, so this count * stays accurate without needing its own tick. */ -export function useCoordinatorTaskCount() { - const tasks = useAppState(_temp); - let t0; - t0 = 0; - return t0; +export function useCoordinatorTaskCount(): number { + const tasks = useAppState(s => s.tasks) + return React.useMemo(() => { + if ("external" !== 'ant') return 0 + const count = getVisibleAgentTasks(tasks).length + return count > 0 ? count + 1 : 0 + }, [tasks]) } -function _temp(s) { - return s.tasks; -} -function MainLine(t0) { - const $ = _c(10); - const { - isSelected, - isViewed, - onClick - } = t0; - const [hover, setHover] = React.useState(false); - const prefix = isSelected || hover ? figures.pointer + " " : " "; - const bullet = isViewed ? BLACK_CIRCLE : figures.circle; - let t1; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => setHover(true); - t2 = () => setHover(false); - $[0] = t1; - $[1] = t2; - } else { - t1 = $[0]; - t2 = $[1]; - } - const t3 = !isSelected && !isViewed && !hover; - let t4; - if ($[2] !== bullet || $[3] !== isViewed || $[4] !== prefix || $[5] !== t3) { - t4 = {prefix}{bullet} main; - $[2] = bullet; - $[3] = isViewed; - $[4] = prefix; - $[5] = t3; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== onClick || $[8] !== t4) { - t5 = {t4}; - $[7] = onClick; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; - } - return t5; + +function MainLine({ + isSelected, + isViewed, + onClick, +}: { + isSelected?: boolean + isViewed?: boolean + onClick: () => void +}): React.ReactNode { + const [hover, setHover] = React.useState(false) + const prefix = isSelected || hover ? figures.pointer + ' ' : ' ' + const bullet = isViewed ? BLACK_CIRCLE : figures.circle + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + > + + {prefix} + {bullet} main + + + ) } + type AgentLineProps = { - task: LocalAgentTaskState; - name?: string; - isSelected?: boolean; - isViewed?: boolean; - onClick?: () => void; -}; -function AgentLine(t0) { - const $ = _c(32); - const { - task, - name, - isSelected, - isViewed, - onClick - } = t0; - const { - columns - } = useTerminalSize(); - const [hover, setHover] = React.useState(false); - const isRunning = !isTerminalStatus(task.status); - const pausedMs = task.totalPausedMs ?? 0; - const elapsedMs = Math.max(0, isRunning ? Date.now() - task.startTime - pausedMs : (task.endTime ?? task.startTime) - task.startTime - pausedMs); - let t1; - if ($[0] !== elapsedMs) { - t1 = formatDuration(elapsedMs); - $[0] = elapsedMs; - $[1] = t1; - } else { - t1 = $[1]; - } - const elapsed = t1; - const tokenCount = task.progress?.tokenCount; - const lastActivity = task.progress?.lastActivity; - const arrow = lastActivity ? figures.arrowDown : figures.arrowUp; - let t2; - if ($[2] !== arrow || $[3] !== tokenCount) { - t2 = tokenCount !== undefined && tokenCount > 0 ? ` · ${arrow} ${formatNumber(tokenCount)} tokens` : ""; - $[2] = arrow; - $[3] = tokenCount; - $[4] = t2; - } else { - t2 = $[4]; - } - const tokenText = t2; - const queuedCount = task.pendingMessages.length; - const queuedText = queuedCount > 0 ? ` · ${queuedCount} queued` : ""; - const displayDescription = task.progress?.summary || task.description; - const highlighted = isSelected || hover; - const prefix = highlighted ? figures.pointer + " " : " "; - const bullet = isViewed ? BLACK_CIRCLE : figures.circle; - const dim = !highlighted && !isViewed; - const sep = isRunning ? PLAY_ICON : PAUSE_ICON; - const namePart = name ? `${name}: ` : ""; - const hintPart = isSelected && !isViewed ? ` · x to ${isRunning ? "stop" : "clear"}` : ""; - const suffixPart = ` ${sep} ${elapsed}${tokenText}${queuedText}${hintPart}`; - const availableForDesc = columns - stringWidth(prefix) - stringWidth(`${bullet} `) - stringWidth(namePart) - stringWidth(suffixPart); - const t3 = Math.max(0, availableForDesc); - let t4; - if ($[5] !== displayDescription || $[6] !== t3) { - t4 = wrapText(displayDescription, t3, "truncate-end"); - $[5] = displayDescription; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - const truncated = t4; - let t5; - if ($[8] !== name) { - t5 = name && <>{name}{": "}; - $[8] = name; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== queuedCount || $[11] !== queuedText) { - t6 = queuedCount > 0 && {queuedText}; - $[10] = queuedCount; - $[11] = queuedText; - $[12] = t6; - } else { - t6 = $[12]; - } - let t7; - if ($[13] !== hintPart) { - t7 = hintPart && {hintPart}; - $[13] = hintPart; - $[14] = t7; - } else { - t7 = $[14]; - } - let t8; - if ($[15] !== bullet || $[16] !== dim || $[17] !== elapsed || $[18] !== isViewed || $[19] !== prefix || $[20] !== sep || $[21] !== t5 || $[22] !== t6 || $[23] !== t7 || $[24] !== tokenText || $[25] !== truncated) { - t8 = {prefix}{bullet}{" "}{t5}{truncated} {sep} {elapsed}{tokenText}{t6}{t7}; - $[15] = bullet; - $[16] = dim; - $[17] = elapsed; - $[18] = isViewed; - $[19] = prefix; - $[20] = sep; - $[21] = t5; - $[22] = t6; - $[23] = t7; - $[24] = tokenText; - $[25] = truncated; - $[26] = t8; - } else { - t8 = $[26]; - } - const line = t8; - if (!onClick) { - return line; - } - let t10; - let t9; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t9 = () => setHover(true); - t10 = () => setHover(false); - $[27] = t10; - $[28] = t9; - } else { - t10 = $[27]; - t9 = $[28]; - } - let t11; - if ($[29] !== line || $[30] !== onClick) { - t11 = {line}; - $[29] = line; - $[30] = onClick; - $[31] = t11; - } else { - t11 = $[31]; - } - return t11; + task: LocalAgentTaskState + name?: string + isSelected?: boolean + isViewed?: boolean + onClick?: () => void +} + +function AgentLine({ + task, + name, + isSelected, + isViewed, + onClick, +}: AgentLineProps): React.ReactNode { + const { columns } = useTerminalSize() + const [hover, setHover] = React.useState(false) + const isRunning = !isTerminalStatus(task.status) + const pausedMs = task.totalPausedMs ?? 0 + const elapsedMs = Math.max( + 0, + isRunning + ? Date.now() - task.startTime - pausedMs + : (task.endTime ?? task.startTime) - task.startTime - pausedMs, + ) + + const elapsed = formatDuration(elapsedMs) + const tokenCount = task.progress?.tokenCount + + // Derive direction arrow from activity state, same logic as Spinner + const lastActivity = task.progress?.lastActivity + const arrow = lastActivity ? figures.arrowDown : figures.arrowUp + + const tokenText = + tokenCount !== undefined && tokenCount > 0 + ? ` · ${arrow} ${formatNumber(tokenCount)} tokens` + : '' + + const queuedCount = task.pendingMessages.length + const queuedText = queuedCount > 0 ? ` · ${queuedCount} queued` : '' + + // Precedence: AI summary > static description (no tool-call activity noise) + const displayDescription = task.progress?.summary || task.description + + const highlighted = isSelected || hover + const prefix = highlighted ? figures.pointer + ' ' : ' ' + const bullet = isViewed ? BLACK_CIRCLE : figures.circle + const dim = !highlighted && !isViewed + + const sep = isRunning ? PLAY_ICON : PAUSE_ICON + // Name is the steering handle — kept out of truncation and undimmed so it + // stays readable even when the row is inactive. Short by convention (the + // Agent tool prompt asks for "one or two words, lowercase"). + const namePart = name ? `${name}: ` : '' + const hintPart = + isSelected && !isViewed ? ` · x to ${isRunning ? 'stop' : 'clear'}` : '' + const suffixPart = ` ${sep} ${elapsed}${tokenText}${queuedText}${hintPart}` + const availableForDesc = + columns - + stringWidth(prefix) - + stringWidth(`${bullet} `) - + stringWidth(namePart) - + stringWidth(suffixPart) + const truncated = wrapText( + displayDescription, + Math.max(0, availableForDesc), + 'truncate-end', + ) + + const line = ( + + {prefix} + {bullet}{' '} + {name && ( + <> + + {name} + + {': '} + + )} + {truncated} {sep} {elapsed} + {tokenText} + {queuedCount > 0 && {queuedText}} + {hintPart && {hintPart}} + + ) + + if (!onClick) return line + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + > + {line} + + ) } diff --git a/src/components/CostThresholdDialog.tsx b/src/components/CostThresholdDialog.tsx index 8c9528073..584d864a3 100644 --- a/src/components/CostThresholdDialog.tsx +++ b/src/components/CostThresholdDialog.tsx @@ -1,49 +1,31 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Link, Text } from '../ink.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; +import React from 'react' +import { Box, Link, Text } from '../ink.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + type Props = { - onDone: () => void; -}; -export function CostThresholdDialog(t0) { - const $ = _c(7); - const { - onDone - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Learn more about how to monitor your spending:; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = [{ - value: "ok", - label: "Got it, thanks!" - }]; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== onDone) { - t3 = + + ) } diff --git a/src/components/CtrlOToExpand.tsx b/src/components/CtrlOToExpand.tsx index b1232479e..24b4add81 100644 --- a/src/components/CtrlOToExpand.tsx +++ b/src/components/CtrlOToExpand.tsx @@ -1,50 +1,49 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import React, { useContext } from 'react'; -import { Text } from '../ink.js'; -import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'; -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { InVirtualListContext } from './messageActions.js'; +import chalk from 'chalk' +import React, { useContext } from 'react' +import { Text } from '../ink.js' +import { getShortcutDisplay } from '../keybindings/shortcutFormat.js' +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { InVirtualListContext } from './messageActions.js' // Context to track if we're inside a sub agent // Similar to MessageResponseContext, this helps us avoid showing // too many "(ctrl+o to expand)" hints in sub agent output -const SubAgentContext = React.createContext(false); -export function SubAgentProvider(t0) { - const $ = _c(2); - const { - children - } = t0; - let t1; - if ($[0] !== children) { - t1 = {children}; - $[0] = children; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; +const SubAgentContext = React.createContext(false) + +export function SubAgentProvider({ + children, +}: { + children: React.ReactNode +}): React.ReactNode { + return ( + {children} + ) } -export function CtrlOToExpand() { - const $ = _c(2); - const isInSubAgent = useContext(SubAgentContext); - const inVirtualList = useContext(InVirtualListContext); - const expandShortcut = useShortcutDisplay("app:toggleTranscript", "Global", "ctrl+o"); + +export function CtrlOToExpand(): React.ReactNode { + const isInSubAgent = useContext(SubAgentContext) + const inVirtualList = useContext(InVirtualListContext) + const expandShortcut = useShortcutDisplay( + 'app:toggleTranscript', + 'Global', + 'ctrl+o', + ) if (isInSubAgent || inVirtualList) { - return null; + return null } - let t0; - if ($[0] !== expandShortcut) { - t0 = ; - $[0] = expandShortcut; - $[1] = t0; - } else { - t0 = $[1]; - } - return t0; + return ( + + + + ) } + export function ctrlOToExpand(): string { - const shortcut = getShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o'); - return chalk.dim(`(${shortcut} to expand)`); + const shortcut = getShortcutDisplay( + 'app:toggleTranscript', + 'Global', + 'ctrl+o', + ) + return chalk.dim(`(${shortcut} to expand)`) } diff --git a/src/components/CustomSelect/SelectMulti.tsx b/src/components/CustomSelect/SelectMulti.tsx index 581146a6b..bb43e9e1e 100644 --- a/src/components/CustomSelect/SelectMulti.tsx +++ b/src/components/CustomSelect/SelectMulti.tsx @@ -1,69 +1,97 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import type { PastedContent } from '../../utils/config.js'; -import type { ImageDimensions } from '../../utils/imageResizer.js'; -import type { OptionWithDescription } from './select.js'; -import { SelectInputOption } from './select-input-option.js'; -import { SelectOption } from './select-option.js'; -import { useMultiSelectState } from './use-multi-select-state.js'; +import figures from 'figures' +import React from 'react' +import { Box, Text } from '../../ink.js' +import type { PastedContent } from '../../utils/config.js' +import type { ImageDimensions } from '../../utils/imageResizer.js' +import type { OptionWithDescription } from './select.js' +import { SelectInputOption } from './select-input-option.js' +import { SelectOption } from './select-option.js' +import { useMultiSelectState } from './use-multi-select-state.js' + export type SelectMultiProps = { - readonly isDisabled?: boolean; - readonly visibleOptionCount?: number; - readonly options: OptionWithDescription[]; - readonly defaultValue?: T[]; - readonly onCancel: () => void; - readonly onChange?: (values: T[]) => void; - readonly onFocus?: (value: T) => void; - readonly focusValue?: T; + readonly isDisabled?: boolean + readonly visibleOptionCount?: number + readonly options: OptionWithDescription[] + readonly defaultValue?: T[] + readonly onCancel: () => void + readonly onChange?: (values: T[]) => void + readonly onFocus?: (value: T) => void + readonly focusValue?: T /** * Text for the submit button. When provided, a submit button is shown and * Enter toggles selection (submit only fires when the button is focused). * When omitted, Enter submits directly and Space toggles selection. */ - readonly submitButtonText?: string; + readonly submitButtonText?: string /** * Callback when user submits. Receives the currently selected values. */ - readonly onSubmit?: (values: T[]) => void; + readonly onSubmit?: (values: T[]) => void /** * When true, hides the numeric indexes next to each option. */ - readonly hideIndexes?: boolean; + readonly hideIndexes?: boolean /** * Callback when user presses down from the last item (submit button). * If provided, navigation will not wrap to the first item. */ - readonly onDownFromLastItem?: () => void; + readonly onDownFromLastItem?: () => void /** * Callback when user presses up from the first item. * If provided, navigation will not wrap to the last item. */ - readonly onUpFromFirstItem?: () => void; + readonly onUpFromFirstItem?: () => void /** * Focus the last option initially instead of the first. */ - readonly initialFocusLast?: boolean; + readonly initialFocusLast?: boolean /** * Callback to open external editor for editing input option values. * When provided, ctrl+g will trigger this callback in input options * with the current value and a setter function to update the internal state. */ - readonly onOpenEditor?: (currentValue: string, setValue: (value: string) => void) => void; - readonly onImagePaste?: (base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, sourcePath?: string) => void; - readonly pastedContents?: Record; - readonly onRemoveImage?: (id: number) => void; -}; -export function SelectMulti(t0) { - const $ = _c(44); - const { - isDisabled: t1, - visibleOptionCount: t2, + readonly onOpenEditor?: ( + currentValue: string, + setValue: (value: string) => void, + ) => void + readonly onImagePaste?: ( + base64Image: string, + mediaType?: string, + filename?: string, + dimensions?: ImageDimensions, + sourcePath?: string, + ) => void + readonly pastedContents?: Record + readonly onRemoveImage?: (id: number) => void +} + +export function SelectMulti({ + isDisabled = false, + visibleOptionCount = 5, + options, + defaultValue = [], + onCancel, + onChange, + onFocus, + focusValue, + submitButtonText, + onSubmit, + onDownFromLastItem, + onUpFromFirstItem, + initialFocusLast, + onOpenEditor, + hideIndexes = false, + onImagePaste, + pastedContents, + onRemoveImage, +}: SelectMultiProps): React.ReactNode { + const state = useMultiSelectState({ + isDisabled, + visibleOptionCount, options, - defaultValue: t3, - onCancel, + defaultValue, onChange, + onCancel, onFocus, focusValue, submitButtonText, @@ -71,142 +99,111 @@ export function SelectMulti(t0) { onDownFromLastItem, onUpFromFirstItem, initialFocusLast, - onOpenEditor, - hideIndexes: t4, - onImagePaste, - pastedContents, - onRemoveImage - } = t0; - const isDisabled = t1 === undefined ? false : t1; - const visibleOptionCount = t2 === undefined ? 5 : t2; - let t5; - if ($[0] !== t3) { - t5 = t3 === undefined ? [] : t3; - $[0] = t3; - $[1] = t5; - } else { - t5 = $[1]; - } - const defaultValue = t5; - const hideIndexes = t4 === undefined ? false : t4; - let t6; - if ($[2] !== defaultValue || $[3] !== focusValue || $[4] !== hideIndexes || $[5] !== initialFocusLast || $[6] !== isDisabled || $[7] !== onCancel || $[8] !== onChange || $[9] !== onDownFromLastItem || $[10] !== onFocus || $[11] !== onSubmit || $[12] !== onUpFromFirstItem || $[13] !== options || $[14] !== submitButtonText || $[15] !== visibleOptionCount) { - t6 = { - isDisabled, - visibleOptionCount, - options, - defaultValue, - onChange, - onCancel, - onFocus, - focusValue, - submitButtonText, - onSubmit, - onDownFromLastItem, - onUpFromFirstItem, - initialFocusLast, - hideIndexes - }; - $[2] = defaultValue; - $[3] = focusValue; - $[4] = hideIndexes; - $[5] = initialFocusLast; - $[6] = isDisabled; - $[7] = onCancel; - $[8] = onChange; - $[9] = onDownFromLastItem; - $[10] = onFocus; - $[11] = onSubmit; - $[12] = onUpFromFirstItem; - $[13] = options; - $[14] = submitButtonText; - $[15] = visibleOptionCount; - $[16] = t6; - } else { - t6 = $[16]; - } - const state = useMultiSelectState(t6); - let T0; - let T1; - let t7; - let t8; - let t9; - if ($[17] !== hideIndexes || $[18] !== isDisabled || $[19] !== onCancel || $[20] !== onImagePaste || $[21] !== onOpenEditor || $[22] !== onRemoveImage || $[23] !== options.length || $[24] !== pastedContents || $[25] !== state) { - const maxIndexWidth = options.length.toString().length; - T1 = Box; - t9 = "column"; - T0 = Box; - t7 = "column"; - t8 = state.visibleOptions.map((option, index) => { - const isOptionFocused = !isDisabled && state.focusedValue === option.value && !state.isSubmitFocused; - const isSelected = state.selectedValues.includes(option.value); - const isFirstVisibleOption = option.index === state.visibleFromIndex; - const isLastVisibleOption = option.index === state.visibleToIndex - 1; - const areMoreOptionsBelow = state.visibleToIndex < options.length; - const areMoreOptionsAbove = state.visibleFromIndex > 0; - const i = state.visibleFromIndex + index + 1; - if (option.type === "input") { - const inputValue = state.inputValues.get(option.value) || ""; - return { - state.updateInputValue(option.value, value); - }} onSubmit={_temp} onExit={() => { - onCancel(); - }} layout="compact" onOpenEditor={onOpenEditor} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage}>[{isSelected ? figures.tick : " "}]{" "}; - } - return {!hideIndexes && {`${i}.`.padEnd(maxIndexWidth)}}[{isSelected ? figures.tick : " "}]{option.label}; - }); - $[17] = hideIndexes; - $[18] = isDisabled; - $[19] = onCancel; - $[20] = onImagePaste; - $[21] = onOpenEditor; - $[22] = onRemoveImage; - $[23] = options.length; - $[24] = pastedContents; - $[25] = state; - $[26] = T0; - $[27] = T1; - $[28] = t7; - $[29] = t8; - $[30] = t9; - } else { - T0 = $[26]; - T1 = $[27]; - t7 = $[28]; - t8 = $[29]; - t9 = $[30]; - } - let t10; - if ($[31] !== T0 || $[32] !== t7 || $[33] !== t8) { - t10 = {t8}; - $[31] = T0; - $[32] = t7; - $[33] = t8; - $[34] = t10; - } else { - t10 = $[34]; - } - let t11; - if ($[35] !== onSubmit || $[36] !== state.isSubmitFocused || $[37] !== submitButtonText) { - t11 = submitButtonText && onSubmit && {state.isSubmitFocused ? {figures.pointer} : }{submitButtonText}; - $[35] = onSubmit; - $[36] = state.isSubmitFocused; - $[37] = submitButtonText; - $[38] = t11; - } else { - t11 = $[38]; - } - let t12; - if ($[39] !== T1 || $[40] !== t10 || $[41] !== t11 || $[42] !== t9) { - t12 = {t10}{t11}; - $[39] = T1; - $[40] = t10; - $[41] = t11; - $[42] = t9; - $[43] = t12; - } else { - t12 = $[43]; - } - return t12; + hideIndexes, + }) + + const maxIndexWidth = options.length.toString().length + + return ( + + + {state.visibleOptions.map((option, index) => { + const isOptionFocused = + !isDisabled && + state.focusedValue === option.value && + !state.isSubmitFocused + const isSelected = state.selectedValues.includes(option.value) + + const isFirstVisibleOption = option.index === state.visibleFromIndex + const isLastVisibleOption = option.index === state.visibleToIndex - 1 + const areMoreOptionsBelow = state.visibleToIndex < options.length + const areMoreOptionsAbove = state.visibleFromIndex > 0 + + const i = state.visibleFromIndex + index + 1 + + if (option.type === 'input') { + const inputValue = state.inputValues.get(option.value) || '' + + return ( + + { + state.updateInputValue(option.value, value) + }} + onSubmit={() => {}} /* We handle submit higher up */ + onExit={() => { + onCancel() + }} + layout="compact" + onOpenEditor={onOpenEditor} + onImagePaste={onImagePaste} + pastedContents={pastedContents} + onRemoveImage={onRemoveImage} + > + + [{isSelected ? figures.tick : ' '}]{' '} + + + + ) + } + + return ( + + + {!hideIndexes && ( + {`${i}.`.padEnd(maxIndexWidth)} + )} + + [{isSelected ? figures.tick : ' '}] + + + {option.label} + + + + ) + })} + + {submitButtonText && onSubmit && ( + + {state.isSubmitFocused ? ( + {figures.pointer} + ) : ( + + )} + + + {submitButtonText} + + + + )} + + ) } -function _temp() {} diff --git a/src/components/CustomSelect/select-input-option.tsx b/src/components/CustomSelect/select-input-option.tsx index cd959c8f0..0f3f9483f 100644 --- a/src/components/CustomSelect/select-input-option.tsx +++ b/src/components/CustomSelect/select-input-option.tsx @@ -1,487 +1,412 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode, useEffect, useRef, useState } from 'react'; +import React, { type ReactNode, useEffect, useRef, useState } from 'react' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- UP arrow exit not in Attachments bindings -import { Box, Text, useInput } from '../../ink.js'; -import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { PastedContent } from '../../utils/config.js'; -import { getImageFromClipboard } from '../../utils/imagePaste.js'; -import type { ImageDimensions } from '../../utils/imageResizer.js'; -import { ClickableImageRef } from '../ClickableImageRef.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Byline } from '../design-system/Byline.js'; -import TextInput from '../TextInput.js'; -import type { OptionWithDescription } from './select.js'; -import { SelectOption } from './select-option.js'; +import { Box, Text, useInput } from '../../ink.js' +import { + useKeybinding, + useKeybindings, +} from '../../keybindings/useKeybinding.js' +import type { PastedContent } from '../../utils/config.js' +import { getImageFromClipboard } from '../../utils/imagePaste.js' +import type { ImageDimensions } from '../../utils/imageResizer.js' +import { ClickableImageRef } from '../ClickableImageRef.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Byline } from '../design-system/Byline.js' +import TextInput from '../TextInput.js' +import type { OptionWithDescription } from './select.js' +import { SelectOption } from './select-option.js' + type Props = { - option: Extract, { - type: 'input'; - }>; - isFocused: boolean; - isSelected: boolean; - shouldShowDownArrow: boolean; - shouldShowUpArrow: boolean; - maxIndexWidth: number; - index: number; - inputValue: string; - onInputChange: (value: string) => void; - onSubmit: (value: string) => void; - onExit?: () => void; - layout: 'compact' | 'expanded'; - children?: ReactNode; + option: Extract, { type: 'input' }> + isFocused: boolean + isSelected: boolean + shouldShowDownArrow: boolean + shouldShowUpArrow: boolean + maxIndexWidth: number + index: number + inputValue: string + onInputChange: (value: string) => void + onSubmit: (value: string) => void + onExit?: () => void + layout: 'compact' | 'expanded' + children?: ReactNode /** * When true, shows the label before the input field. * When false (default), uses the label as the placeholder. */ - showLabel?: boolean; + showLabel?: boolean /** * Callback to open external editor for editing the input value. * When provided, ctrl+g will trigger this callback with the current value * and a setter function to update the internal state. */ - onOpenEditor?: (currentValue: string, setValue: (value: string) => void) => void; + onOpenEditor?: ( + currentValue: string, + setValue: (value: string) => void, + ) => void /** * When true, automatically reset cursor to end of line when: * - Option becomes focused * - Input value changes * This prevents cursor position bugs when the input value updates asynchronously. */ - resetCursorOnUpdate?: boolean; + resetCursorOnUpdate?: boolean /** * Optional callback when an image is pasted into the input. */ - onImagePaste?: (base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, sourcePath?: string) => void; + onImagePaste?: ( + base64Image: string, + mediaType?: string, + filename?: string, + dimensions?: ImageDimensions, + sourcePath?: string, + ) => void /** * Pasted content to display inline above the input when focused. */ - pastedContents?: Record; + pastedContents?: Record /** * Callback to remove a pasted image by its ID. */ - onRemoveImage?: (id: number) => void; + onRemoveImage?: (id: number) => void /** * Whether image selection mode is active. */ - imagesSelected?: boolean; + imagesSelected?: boolean /** * Currently selected image index within the image attachments array. */ - selectedImageIndex?: number; + selectedImageIndex?: number /** * Callback to set image selection mode on/off. */ - onImagesSelectedChange?: (selected: boolean) => void; + onImagesSelectedChange?: (selected: boolean) => void /** * Callback to change the selected image index. */ - onSelectedImageIndexChange?: (index: number) => void; -}; -export function SelectInputOption(t0) { - const $ = _c(100); - const { - option, - isFocused, - isSelected, - shouldShowDownArrow, - shouldShowUpArrow, - maxIndexWidth, - index, - inputValue, - onInputChange, - onSubmit, - onExit, - layout, - children, - showLabel: t1, - onOpenEditor, - resetCursorOnUpdate: t2, - onImagePaste, - pastedContents, - onRemoveImage, - imagesSelected, - selectedImageIndex: t3, - onImagesSelectedChange, - onSelectedImageIndexChange - } = t0; - const showLabelProp = t1 === undefined ? false : t1; - const resetCursorOnUpdate = t2 === undefined ? false : t2; - const selectedImageIndex = t3 === undefined ? 0 : t3; - let t4; - if ($[0] !== pastedContents) { - t4 = pastedContents ? Object.values(pastedContents).filter(_temp) : []; - $[0] = pastedContents; - $[1] = t4; - } else { - t4 = $[1]; - } - const imageAttachments = t4; - const showLabel = showLabelProp || option.showLabelWithValue === true; - const [cursorOffset, setCursorOffset] = useState(inputValue.length); - const isUserEditing = useRef(false); - let t5; - if ($[2] !== inputValue.length || $[3] !== isFocused || $[4] !== resetCursorOnUpdate) { - t5 = () => { - if (resetCursorOnUpdate && isFocused) { - if (isUserEditing.current) { - isUserEditing.current = false; - } else { - setCursorOffset(inputValue.length); - } + onSelectedImageIndexChange?: (index: number) => void +} + +export function SelectInputOption({ + option, + isFocused, + isSelected, + shouldShowDownArrow, + shouldShowUpArrow, + maxIndexWidth, + index, + inputValue, + onInputChange, + onSubmit, + onExit, + layout, + children, + showLabel: showLabelProp = false, + onOpenEditor, + resetCursorOnUpdate = false, + onImagePaste, + pastedContents, + onRemoveImage, + imagesSelected, + selectedImageIndex = 0, + onImagesSelectedChange, + onSelectedImageIndexChange, +}: Props): React.ReactNode { + const imageAttachments = pastedContents + ? Object.values(pastedContents).filter(c => c.type === 'image') + : [] + + // Allow individual options to force showing the label via showLabelWithValue + const showLabel = showLabelProp || option.showLabelWithValue === true + const [cursorOffset, setCursorOffset] = useState(inputValue.length) + + // Track whether the latest inputValue change was from user typing/pasting, + // so we can skip resetting cursor to end on user-initiated changes. + const isUserEditing = useRef(false) + + // Reset cursor to end of line when: + // 1. Option becomes focused (user navigates to it) + // 2. Input value changes externally (e.g., async classifier description updates) + // Skip reset when the change was from user typing (which sets isUserEditing ref) + // Only enabled when resetCursorOnUpdate prop is true + useEffect(() => { + if (resetCursorOnUpdate && isFocused) { + if (isUserEditing.current) { + isUserEditing.current = false + } else { + setCursorOffset(inputValue.length) } - }; - $[2] = inputValue.length; - $[3] = isFocused; - $[4] = resetCursorOnUpdate; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] !== inputValue || $[7] !== isFocused || $[8] !== resetCursorOnUpdate) { - t6 = [resetCursorOnUpdate, isFocused, inputValue]; - $[6] = inputValue; - $[7] = isFocused; - $[8] = resetCursorOnUpdate; - $[9] = t6; - } else { - t6 = $[9]; - } - useEffect(t5, t6); - let t7; - if ($[10] !== inputValue || $[11] !== onInputChange || $[12] !== onOpenEditor) { - t7 = () => { - onOpenEditor?.(inputValue, onInputChange); - }; - $[10] = inputValue; - $[11] = onInputChange; - $[12] = onOpenEditor; - $[13] = t7; - } else { - t7 = $[13]; - } - const t8 = isFocused && !!onOpenEditor; - let t9; - if ($[14] !== t8) { - t9 = { - context: "Chat", - isActive: t8 - }; - $[14] = t8; - $[15] = t9; - } else { - t9 = $[15]; - } - useKeybinding("chat:externalEditor", t7, t9); - let t10; - if ($[16] !== onImagePaste) { - t10 = () => { - if (!onImagePaste) { - return; - } - getImageFromClipboard().then(imageData => { + } + }, [resetCursorOnUpdate, isFocused, inputValue]) + + // ctrl+g to open external editor (reuses chat:externalEditor keybinding) + useKeybinding( + 'chat:externalEditor', + () => { + onOpenEditor?.(inputValue, onInputChange) + }, + { context: 'Chat', isActive: isFocused && !!onOpenEditor }, + ) + + // ctrl+v to paste image from clipboard (same as PromptInput) + useKeybinding( + 'chat:imagePaste', + () => { + if (!onImagePaste) return + void getImageFromClipboard().then(imageData => { if (imageData) { - onImagePaste(imageData.base64, imageData.mediaType, undefined, imageData.dimensions); + onImagePaste( + imageData.base64, + imageData.mediaType, + undefined, + imageData.dimensions, + ) } - }); - }; - $[16] = onImagePaste; - $[17] = t10; - } else { - t10 = $[17]; - } - const t11 = isFocused && !!onImagePaste; - let t12; - if ($[18] !== t11) { - t12 = { - context: "Chat", - isActive: t11 - }; - $[18] = t11; - $[19] = t12; - } else { - t12 = $[19]; - } - useKeybinding("chat:imagePaste", t10, t12); - let t13; - if ($[20] !== imageAttachments || $[21] !== onRemoveImage) { - t13 = () => { + }) + }, + { context: 'Chat', isActive: isFocused && !!onImagePaste }, + ) + + // Backspace with empty input removes the last pasted image (non-image-selection mode) + useKeybinding( + 'attachments:remove', + () => { if (imageAttachments.length > 0 && onRemoveImage) { - onRemoveImage(imageAttachments.at(-1).id); + onRemoveImage(imageAttachments.at(-1)!.id) } - }; - $[20] = imageAttachments; - $[21] = onRemoveImage; - $[22] = t13; - } else { - t13 = $[22]; - } - const t14 = isFocused && !imagesSelected && inputValue === "" && imageAttachments.length > 0 && !!onRemoveImage; - let t15; - if ($[23] !== t14) { - t15 = { - context: "Attachments", - isActive: t14 - }; - $[23] = t14; - $[24] = t15; - } else { - t15 = $[24]; - } - useKeybinding("attachments:remove", t13, t15); - let t16; - let t17; - if ($[25] !== imageAttachments.length || $[26] !== onSelectedImageIndexChange || $[27] !== selectedImageIndex) { - t16 = () => { - if (imageAttachments.length > 1) { - onSelectedImageIndexChange?.((selectedImageIndex + 1) % imageAttachments.length); - } - }; - t17 = () => { - if (imageAttachments.length > 1) { - onSelectedImageIndexChange?.((selectedImageIndex - 1 + imageAttachments.length) % imageAttachments.length); - } - }; - $[25] = imageAttachments.length; - $[26] = onSelectedImageIndexChange; - $[27] = selectedImageIndex; - $[28] = t16; - $[29] = t17; - } else { - t16 = $[28]; - t17 = $[29]; - } - let t18; - if ($[30] !== imageAttachments || $[31] !== onImagesSelectedChange || $[32] !== onRemoveImage || $[33] !== onSelectedImageIndexChange || $[34] !== selectedImageIndex) { - t18 = () => { - const img = imageAttachments[selectedImageIndex]; - if (img && onRemoveImage) { - onRemoveImage(img.id); - if (imageAttachments.length <= 1) { - onImagesSelectedChange?.(false); - } else { - onSelectedImageIndexChange?.(Math.min(selectedImageIndex, imageAttachments.length - 2)); + }, + { + context: 'Attachments', + isActive: + isFocused && + !imagesSelected && + inputValue === '' && + imageAttachments.length > 0 && + !!onRemoveImage, + }, + ) + + // Image selection mode keybindings — reuses existing Attachments actions + useKeybindings( + { + 'attachments:next': () => { + if (imageAttachments.length > 1) { + onSelectedImageIndexChange?.( + (selectedImageIndex + 1) % imageAttachments.length, + ) } - } - }; - $[30] = imageAttachments; - $[31] = onImagesSelectedChange; - $[32] = onRemoveImage; - $[33] = onSelectedImageIndexChange; - $[34] = selectedImageIndex; - $[35] = t18; - } else { - t18 = $[35]; - } - let t19; - if ($[36] !== onImagesSelectedChange) { - t19 = () => { - onImagesSelectedChange?.(false); - }; - $[36] = onImagesSelectedChange; - $[37] = t19; - } else { - t19 = $[37]; - } - let t20; - if ($[38] !== t16 || $[39] !== t17 || $[40] !== t18 || $[41] !== t19) { - t20 = { - "attachments:next": t16, - "attachments:previous": t17, - "attachments:remove": t18, - "attachments:exit": t19 - }; - $[38] = t16; - $[39] = t17; - $[40] = t18; - $[41] = t19; - $[42] = t20; - } else { - t20 = $[42]; - } - const t21 = isFocused && !!imagesSelected; - let t22; - if ($[43] !== t21) { - t22 = { - context: "Attachments", - isActive: t21 - }; - $[43] = t21; - $[44] = t22; - } else { - t22 = $[44]; - } - useKeybindings(t20, t22); - let t23; - if ($[45] !== onImagesSelectedChange) { - t23 = (_input, key) => { + }, + 'attachments:previous': () => { + if (imageAttachments.length > 1) { + onSelectedImageIndexChange?.( + (selectedImageIndex - 1 + imageAttachments.length) % + imageAttachments.length, + ) + } + }, + 'attachments:remove': () => { + const img = imageAttachments[selectedImageIndex] + if (img && onRemoveImage) { + onRemoveImage(img.id) + // If no images left after removal, exit image selection + if (imageAttachments.length <= 1) { + onImagesSelectedChange?.(false) + } else { + // Adjust index if we deleted the last image + onSelectedImageIndexChange?.( + Math.min(selectedImageIndex, imageAttachments.length - 2), + ) + } + } + }, + 'attachments:exit': () => { + onImagesSelectedChange?.(false) + }, + }, + { context: 'Attachments', isActive: isFocused && !!imagesSelected }, + ) + + // UP arrow exits image selection mode (UP isn't bound to attachments:exit) + useInput( + (_input, key) => { if (key.upArrow) { - onImagesSelectedChange?.(false); + onImagesSelectedChange?.(false) } - }; - $[45] = onImagesSelectedChange; - $[46] = t23; - } else { - t23 = $[46]; - } - const t24 = isFocused && !!imagesSelected; - let t25; - if ($[47] !== t24) { - t25 = { - isActive: t24 - }; - $[47] = t24; - $[48] = t25; - } else { - t25 = $[48]; - } - useInput(t23, t25); - let t26; - let t27; - if ($[49] !== imagesSelected || $[50] !== isFocused || $[51] !== onImagesSelectedChange) { - t26 = () => { - if (!isFocused && imagesSelected) { - onImagesSelectedChange?.(false); - } - }; - t27 = [isFocused, imagesSelected, onImagesSelectedChange]; - $[49] = imagesSelected; - $[50] = isFocused; - $[51] = onImagesSelectedChange; - $[52] = t26; - $[53] = t27; - } else { - t26 = $[52]; - t27 = $[53]; - } - useEffect(t26, t27); - const descriptionPaddingLeft = layout === "expanded" ? maxIndexWidth + 3 : maxIndexWidth + 4; - const t28 = layout === "compact" ? 0 : undefined; - const t29 = `${index}.`; - let t30; - if ($[54] !== maxIndexWidth || $[55] !== t29) { - t30 = t29.padEnd(maxIndexWidth + 2); - $[54] = maxIndexWidth; - $[55] = t29; - $[56] = t30; - } else { - t30 = $[56]; - } - let t31; - if ($[57] !== t30) { - t31 = {t30}; - $[57] = t30; - $[58] = t31; - } else { - t31 = $[58]; - } - let t32; - if ($[59] !== cursorOffset || $[60] !== imagesSelected || $[61] !== inputValue || $[62] !== isFocused || $[63] !== onExit || $[64] !== onImagePaste || $[65] !== onInputChange || $[66] !== onSubmit || $[67] !== option || $[68] !== showLabel) { - t32 = showLabel ? <>{option.label}{isFocused ? <>{option.labelValueSeparator ?? ", "} { - isUserEditing.current = true; - onInputChange(value); - option.onChange(value); - }} onSubmit={onSubmit} onExit={onExit} placeholder={option.placeholder} focus={!imagesSelected} showCursor={true} multiline={true} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={80} onImagePaste={onImagePaste} onPaste={pastedText => { - isUserEditing.current = true; - const before = inputValue.slice(0, cursorOffset); - const after = inputValue.slice(cursorOffset); - const newValue = before + pastedText + after; - onInputChange(newValue); - option.onChange(newValue); - setCursorOffset(before.length + pastedText.length); - }} /> : inputValue && {option.labelValueSeparator ?? ", "}{inputValue}} : isFocused ? { - isUserEditing.current = true; - onInputChange(value_0); - option.onChange(value_0); - }} onSubmit={onSubmit} onExit={onExit} placeholder={option.placeholder || (typeof option.label === "string" ? option.label : undefined)} focus={!imagesSelected} showCursor={true} multiline={true} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={80} onImagePaste={onImagePaste} onPaste={pastedText_0 => { - isUserEditing.current = true; - const before_0 = inputValue.slice(0, cursorOffset); - const after_0 = inputValue.slice(cursorOffset); - const newValue_0 = before_0 + pastedText_0 + after_0; - onInputChange(newValue_0); - option.onChange(newValue_0); - setCursorOffset(before_0.length + pastedText_0.length); - }} /> : {inputValue || option.placeholder || option.label}; - $[59] = cursorOffset; - $[60] = imagesSelected; - $[61] = inputValue; - $[62] = isFocused; - $[63] = onExit; - $[64] = onImagePaste; - $[65] = onInputChange; - $[66] = onSubmit; - $[67] = option; - $[68] = showLabel; - $[69] = t32; - } else { - t32 = $[69]; - } - let t33; - if ($[70] !== children || $[71] !== t28 || $[72] !== t31 || $[73] !== t32) { - t33 = {t31}{children}{t32}; - $[70] = children; - $[71] = t28; - $[72] = t31; - $[73] = t32; - $[74] = t33; - } else { - t33 = $[74]; - } - let t34; - if ($[75] !== isFocused || $[76] !== isSelected || $[77] !== shouldShowDownArrow || $[78] !== shouldShowUpArrow || $[79] !== t33) { - t34 = {t33}; - $[75] = isFocused; - $[76] = isSelected; - $[77] = shouldShowDownArrow; - $[78] = shouldShowUpArrow; - $[79] = t33; - $[80] = t34; - } else { - t34 = $[80]; - } - let t35; - if ($[81] !== descriptionPaddingLeft || $[82] !== isFocused || $[83] !== isSelected || $[84] !== option.description || $[85] !== option.dimDescription) { - t35 = option.description && {option.description}; - $[81] = descriptionPaddingLeft; - $[82] = isFocused; - $[83] = isSelected; - $[84] = option.description; - $[85] = option.dimDescription; - $[86] = t35; - } else { - t35 = $[86]; - } - let t36; - if ($[87] !== descriptionPaddingLeft || $[88] !== imageAttachments || $[89] !== imagesSelected || $[90] !== isFocused || $[91] !== selectedImageIndex) { - t36 = imageAttachments.length > 0 && {imageAttachments.map((img_0, idx) => )}{imagesSelected ? {imageAttachments.length > 1 && <>} : isFocused ? "(\u2193 to select)" : null}; - $[87] = descriptionPaddingLeft; - $[88] = imageAttachments; - $[89] = imagesSelected; - $[90] = isFocused; - $[91] = selectedImageIndex; - $[92] = t36; - } else { - t36 = $[92]; - } - let t37; - if ($[93] !== layout) { - t37 = layout === "expanded" && ; - $[93] = layout; - $[94] = t37; - } else { - t37 = $[94]; - } - let t38; - if ($[95] !== t34 || $[96] !== t35 || $[97] !== t36 || $[98] !== t37) { - t38 = {t34}{t35}{t36}{t37}; - $[95] = t34; - $[96] = t35; - $[97] = t36; - $[98] = t37; - $[99] = t38; - } else { - t38 = $[99]; - } - return t38; -} -function _temp(c) { - return c.type === "image"; + }, + { isActive: isFocused && !!imagesSelected }, + ) + + // Exit image mode when option loses focus + useEffect(() => { + if (!isFocused && imagesSelected) { + onImagesSelectedChange?.(false) + } + }, [isFocused, imagesSelected, onImagesSelectedChange]) + + const descriptionPaddingLeft = + layout === 'expanded' ? maxIndexWidth + 3 : maxIndexWidth + 4 + + return ( + + + + {`${index}.`.padEnd(maxIndexWidth + 2)} + {children} + {showLabel ? ( + <> + + {option.label} + + {isFocused ? ( + <> + + {option.labelValueSeparator ?? ', '} + + { + isUserEditing.current = true + onInputChange(value) + option.onChange(value) + }} + onSubmit={onSubmit} + onExit={onExit} + placeholder={option.placeholder} + focus={!imagesSelected} + showCursor={true} + multiline={true} + cursorOffset={cursorOffset} + onChangeCursorOffset={setCursorOffset} + columns={80} + onImagePaste={onImagePaste} + onPaste={(pastedText: string) => { + isUserEditing.current = true + const before = inputValue.slice(0, cursorOffset) + const after = inputValue.slice(cursorOffset) + const newValue = before + pastedText + after + onInputChange(newValue) + option.onChange(newValue) + setCursorOffset(before.length + pastedText.length) + }} + /> + + ) : ( + inputValue && ( + + {option.labelValueSeparator ?? ', '} + {inputValue} + + ) + )} + + ) : isFocused ? ( + { + isUserEditing.current = true + onInputChange(value) + option.onChange(value) + }} + onSubmit={onSubmit} + onExit={onExit} + placeholder={ + option.placeholder || + (typeof option.label === 'string' ? option.label : undefined) + } + focus={!imagesSelected} + showCursor={true} + multiline={true} + cursorOffset={cursorOffset} + onChangeCursorOffset={setCursorOffset} + columns={80} + onImagePaste={onImagePaste} + onPaste={(pastedText: string) => { + isUserEditing.current = true + const before = inputValue.slice(0, cursorOffset) + const after = inputValue.slice(cursorOffset) + const newValue = before + pastedText + after + onInputChange(newValue) + option.onChange(newValue) + setCursorOffset(before.length + pastedText.length) + }} + /> + ) : ( + + {inputValue || option.placeholder || option.label} + + )} + + + {option.description && ( + + + {option.description} + + + )} + {imageAttachments.length > 0 && ( + + {imageAttachments.map((img, idx) => ( + + ))} + + + {imagesSelected ? ( + + {imageAttachments.length > 1 && ( + <> + + + + )} + + + + ) : isFocused ? ( + '(↓ to select)' + ) : null} + + + + )} + {layout === 'expanded' && } + + ) } diff --git a/src/components/CustomSelect/select-option.tsx b/src/components/CustomSelect/select-option.tsx index 04903fcbb..2f84affd9 100644 --- a/src/components/CustomSelect/select-option.tsx +++ b/src/components/CustomSelect/select-option.tsx @@ -1,67 +1,64 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode } from 'react'; -import { ListItem } from '../design-system/ListItem.js'; +import React, { type ReactNode } from 'react' +import { ListItem } from '../design-system/ListItem.js' + export type SelectOptionProps = { /** * Determines if option is focused. */ - readonly isFocused: boolean; + readonly isFocused: boolean /** * Determines if option is selected. */ - readonly isSelected: boolean; + readonly isSelected: boolean /** * Option label. */ - readonly children: ReactNode; + readonly children: ReactNode /** * Optional description to display below the label. */ - readonly description?: string; + readonly description?: string /** * Determines if the down arrow should be shown. */ - readonly shouldShowDownArrow?: boolean; + readonly shouldShowDownArrow?: boolean /** * Determines if the up arrow should be shown. */ - readonly shouldShowUpArrow?: boolean; + readonly shouldShowUpArrow?: boolean /** * Whether ListItem should declare the terminal cursor position. * Set false when a child declares its own cursor (e.g. BaseTextInput). */ - readonly declareCursor?: boolean; -}; -export function SelectOption(t0) { - const $ = _c(8); - const { - isFocused, - isSelected, - children, - description, - shouldShowDownArrow, - shouldShowUpArrow, - declareCursor - } = t0; - let t1; - if ($[0] !== children || $[1] !== declareCursor || $[2] !== description || $[3] !== isFocused || $[4] !== isSelected || $[5] !== shouldShowDownArrow || $[6] !== shouldShowUpArrow) { - t1 = {children}; - $[0] = children; - $[1] = declareCursor; - $[2] = description; - $[3] = isFocused; - $[4] = isSelected; - $[5] = shouldShowDownArrow; - $[6] = shouldShowUpArrow; - $[7] = t1; - } else { - t1 = $[7]; - } - return t1; + readonly declareCursor?: boolean +} + +export function SelectOption({ + isFocused, + isSelected, + children, + description, + shouldShowDownArrow, + shouldShowUpArrow, + declareCursor, +}: SelectOptionProps): React.ReactNode { + return ( + + {children} + + ) } diff --git a/src/components/CustomSelect/select.tsx b/src/components/CustomSelect/select.tsx index a48114293..d3a144772 100644 --- a/src/components/CustomSelect/select.tsx +++ b/src/components/CustomSelect/select.tsx @@ -1,137 +1,139 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { type ReactNode, useEffect, useRef, useState } from 'react'; -import { useDeclaredCursor } from '../../ink/hooks/use-declared-cursor.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Ansi, Box, Text } from '../../ink.js'; -import { count } from '../../utils/array.js'; -import type { PastedContent } from '../../utils/config.js'; -import type { ImageDimensions } from '../../utils/imageResizer.js'; -import { SelectInputOption } from './select-input-option.js'; -import { SelectOption } from './select-option.js'; -import { useSelectInput } from './use-select-input.js'; -import { useSelectState } from './use-select-state.js'; +import figures from 'figures' +import React, { type ReactNode, useEffect, useRef, useState } from 'react' +import { useDeclaredCursor } from '../../ink/hooks/use-declared-cursor.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { Ansi, Box, Text } from '../../ink.js' +import { count } from '../../utils/array.js' +import type { PastedContent } from '../../utils/config.js' +import type { ImageDimensions } from '../../utils/imageResizer.js' +import { SelectInputOption } from './select-input-option.js' +import { SelectOption } from './select-option.js' +import { useSelectInput } from './use-select-input.js' +import { useSelectState } from './use-select-state.js' // Extract text content from ReactNode for width calculation function getTextContent(node: ReactNode): string { - if (typeof node === 'string') return node; - if (typeof node === 'number') return String(node); - if (!node) return ''; - if (Array.isArray(node)) return node.map(getTextContent).join(''); - if (React.isValidElement<{ - children?: ReactNode; - }>(node)) { - return getTextContent(node.props.children); + if (typeof node === 'string') return node + if (typeof node === 'number') return String(node) + if (!node) return '' + if (Array.isArray(node)) return node.map(getTextContent).join('') + if (React.isValidElement<{ children?: ReactNode }>(node)) { + return getTextContent(node.props.children) } - return ''; + return '' } + type BaseOption = { - description?: string; - dimDescription?: boolean; - label: ReactNode; - value: T; - disabled?: boolean; -}; -export type OptionWithDescription = (BaseOption & { - type?: 'text'; -}) | (BaseOption & { - type: 'input'; - onChange: (value: string) => void; - placeholder?: string; - initialValue?: string; - /** - * Controls behavior when submitting with empty input: - * - true: calls onChange (treats empty as valid submission) - * - false (default): calls onCancel (treats empty as cancellation) - * - * Also affects initial Enter press: when true, submits immediately; - * when false, enters input mode first so user can type. - */ - allowEmptySubmitToCancel?: boolean; - /** - * When true, always shows the label alongside the input value, regardless of - * the global inlineDescriptions/showLabel setting. Use this when the label - * provides important context that should always be visible (e.g., "Yes, and allow..."). - */ - showLabelWithValue?: boolean; - /** - * Custom separator between label and value when showLabel is true. - * Defaults to ", ". Use ": " for labels that read better with a colon. - */ - labelValueSeparator?: string; - /** - * When true, automatically reset cursor to end of line when: - * - Option becomes focused - * - Input value changes - * This prevents cursor position bugs when the input value updates asynchronously. - */ - resetCursorOnUpdate?: boolean; -}); + description?: string + dimDescription?: boolean + label: ReactNode + value: T + disabled?: boolean +} + +export type OptionWithDescription = + | (BaseOption & { + type?: 'text' + }) + | (BaseOption & { + type: 'input' + onChange: (value: string) => void + placeholder?: string + initialValue?: string + /** + * Controls behavior when submitting with empty input: + * - true: calls onChange (treats empty as valid submission) + * - false (default): calls onCancel (treats empty as cancellation) + * + * Also affects initial Enter press: when true, submits immediately; + * when false, enters input mode first so user can type. + */ + allowEmptySubmitToCancel?: boolean + /** + * When true, always shows the label alongside the input value, regardless of + * the global inlineDescriptions/showLabel setting. Use this when the label + * provides important context that should always be visible (e.g., "Yes, and allow..."). + */ + showLabelWithValue?: boolean + /** + * Custom separator between label and value when showLabel is true. + * Defaults to ", ". Use ": " for labels that read better with a colon. + */ + labelValueSeparator?: string + /** + * When true, automatically reset cursor to end of line when: + * - Option becomes focused + * - Input value changes + * This prevents cursor position bugs when the input value updates asynchronously. + */ + resetCursorOnUpdate?: boolean + }) + export type SelectProps = { /** * When disabled, user input is ignored. * * @default false */ - readonly isDisabled?: boolean; + readonly isDisabled?: boolean /** * When true, prevents selection on Enter but allows scrolling. * * @default false */ - readonly disableSelection?: boolean; + readonly disableSelection?: boolean /** * When true, hides the numeric indexes next to each option. * * @default false */ - readonly hideIndexes?: boolean; + readonly hideIndexes?: boolean /** * Number of visible options. * * @default 5 */ - readonly visibleOptionCount?: number; + readonly visibleOptionCount?: number /** * Highlight text in option labels. */ - readonly highlightText?: string; + readonly highlightText?: string /** * Options. */ - readonly options: OptionWithDescription[]; + readonly options: OptionWithDescription[] /** * Default value. */ - readonly defaultValue?: T; + readonly defaultValue?: T /** * Callback when cancel is pressed. */ - readonly onCancel?: () => void; + readonly onCancel?: () => void /** * Callback when selected option changes. */ - readonly onChange?: (value: T) => void; + readonly onChange?: (value: T) => void /** * Callback when focused option changes. * Note: This is for one-way notification only. Avoid combining with focusValue * for bidirectional sync, as this can cause feedback loops. */ - readonly onFocus?: (value: T) => void; + readonly onFocus?: (value: T) => void /** * Initial value to focus. This is used to set focus when the component mounts. */ - readonly defaultFocusValue?: T; + readonly defaultFocusValue?: T /** * Layout of the options. @@ -139,7 +141,7 @@ export type SelectProps = { * - `expanded` uses multiple lines and an empty line between options * - `compact-vertical` uses compact index formatting with descriptions below labels */ - readonly layout?: 'compact' | 'expanded' | 'compact-vertical'; + readonly layout?: 'compact' | 'expanded' | 'compact-vertical' /** * When true, descriptions are rendered inline after the label instead of @@ -147,543 +149,785 @@ export type SelectProps = { * * @default false */ - readonly inlineDescriptions?: boolean; + readonly inlineDescriptions?: boolean /** * Callback when user presses up from the first item. * If provided, navigation will not wrap to the last item. */ - readonly onUpFromFirstItem?: () => void; + readonly onUpFromFirstItem?: () => void /** * Callback when user presses down from the last item. * If provided, navigation will not wrap to the first item. */ - readonly onDownFromLastItem?: () => void; + readonly onDownFromLastItem?: () => void /** * Callback when input mode should be toggled for an option. * Called when Tab is pressed (to enter or exit input mode). */ - readonly onInputModeToggle?: (value: T) => void; + readonly onInputModeToggle?: (value: T) => void /** * Callback to open external editor for editing input option values. * When provided, ctrl+g will trigger this callback in input options * with the current value and a setter function to update the internal state. */ - readonly onOpenEditor?: (currentValue: string, setValue: (value: string) => void) => void; + readonly onOpenEditor?: ( + currentValue: string, + setValue: (value: string) => void, + ) => void /** * Optional callback when an image is pasted into an input option. */ - readonly onImagePaste?: (base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, sourcePath?: string) => void; + readonly onImagePaste?: ( + base64Image: string, + mediaType?: string, + filename?: string, + dimensions?: ImageDimensions, + sourcePath?: string, + ) => void /** * Pasted content to display inline in input options. */ - readonly pastedContents?: Record; + readonly pastedContents?: Record /** * Callback to remove a pasted image by its ID. */ - readonly onRemoveImage?: (id: number) => void; -}; -export function Select(t0) { - const $ = _c(72); - const { - isDisabled: t1, - hideIndexes: t2, - visibleOptionCount: t3, - highlightText, + readonly onRemoveImage?: (id: number) => void +} + +export function Select({ + isDisabled = false, + hideIndexes = false, + visibleOptionCount = 5, + highlightText, + options, + defaultValue, + onCancel, + onChange, + onFocus, + defaultFocusValue, + layout = 'compact', + disableSelection = false, + inlineDescriptions = false, + onUpFromFirstItem, + onDownFromLastItem, + onInputModeToggle, + onOpenEditor, + onImagePaste, + pastedContents, + onRemoveImage, +}: SelectProps): React.ReactNode { + // Image selection mode state + const [imagesSelected, setImagesSelected] = useState(false) + const [selectedImageIndex, setSelectedImageIndex] = useState(0) + + // State for input type options + const [inputValues, setInputValues] = useState>(() => { + const initialMap = new Map() + options.forEach(option => { + if (option.type === 'input' && option.initialValue) { + initialMap.set(option.value, option.initialValue) + } + }) + return initialMap + }) + + // Track the last initialValue we synced, so we can detect user edits + const lastInitialValues = useRef>(new Map()) + + // Sync initialValue changes to inputValues state, but only if user hasn't edited + useEffect(() => { + for (const option of options) { + if (option.type === 'input' && option.initialValue !== undefined) { + const lastInitial = lastInitialValues.current.get(option.value) ?? '' + const currentValue = inputValues.get(option.value) ?? '' + const newInitial = option.initialValue + + // Only update if: + // 1. The initialValue has changed + // 2. The user hasn't edited (current value still matches the last initialValue we set) + if (newInitial !== lastInitial && currentValue === lastInitial) { + setInputValues(prev => { + const next = new Map(prev) + next.set(option.value, newInitial) + return next + }) + } + + // Always track the latest initialValue + lastInitialValues.current.set(option.value, newInitial) + } + } + }, [options, inputValues]) + + const state = useSelectState({ + visibleOptionCount, options, defaultValue, - onCancel, onChange, + onCancel, onFocus, - defaultFocusValue, - layout: t4, - disableSelection: t5, - inlineDescriptions: t6, + focusValue: defaultFocusValue, + }) + + useSelectInput({ + isDisabled, + disableSelection: disableSelection || (hideIndexes ? 'numeric' : false), + state, + options, + isMultiSelect: false, // Select is always single-choice onUpFromFirstItem, onDownFromLastItem, onInputModeToggle, - onOpenEditor, - onImagePaste, - pastedContents, - onRemoveImage - } = t0; - const isDisabled = t1 === undefined ? false : t1; - const hideIndexes = t2 === undefined ? false : t2; - const visibleOptionCount = t3 === undefined ? 5 : t3; - const layout = t4 === undefined ? "compact" : t4; - const disableSelection = t5 === undefined ? false : t5; - const inlineDescriptions = t6 === undefined ? false : t6; - const [imagesSelected, setImagesSelected] = useState(false); - const [selectedImageIndex, setSelectedImageIndex] = useState(0); - let t7; - if ($[0] !== options) { - t7 = () => { - const initialMap = new Map(); - options.forEach(option => { - if (option.type === "input" && option.initialValue) { - initialMap.set(option.value, option.initialValue); - } - }); - return initialMap; - }; - $[0] = options; - $[1] = t7; - } else { - t7 = $[1]; + inputValues, + imagesSelected, + onEnterImageSelection: () => { + if ( + pastedContents && + Object.values(pastedContents).some(c => c.type === 'image') + ) { + const imageCount = count( + Object.values(pastedContents), + c => c.type === 'image', + ) + setImagesSelected(true) + setSelectedImageIndex(imageCount - 1) + return true + } + return false + }, + }) + + const styles = { + container: () => ({ flexDirection: 'column' as const }), + highlightedText: () => ({ bold: true }), } - const [inputValues, setInputValues] = useState(t7); - let t8; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t8 = new Map(); - $[2] = t8; - } else { - t8 = $[2]; - } - const lastInitialValues = useRef(t8); - let t10; - let t9; - if ($[3] !== inputValues || $[4] !== options) { - t9 = () => { - for (const option_0 of options) { - if (option_0.type === "input" && option_0.initialValue !== undefined) { - const lastInitial = lastInitialValues.current.get(option_0.value) ?? ""; - const currentValue = inputValues.get(option_0.value) ?? ""; - const newInitial = option_0.initialValue; - if (newInitial !== lastInitial && currentValue === lastInitial) { - setInputValues(prev => { - const next = new Map(prev); - next.set(option_0.value, newInitial); - return next; - }); + + if (layout === 'expanded') { + const maxIndexWidth = state.options.length.toString().length + + return ( + + {state.visibleOptions.map((option, index) => { + const isFirstVisibleOption = option.index === state.visibleFromIndex + const isLastVisibleOption = option.index === state.visibleToIndex - 1 + const areMoreOptionsBelow = state.visibleToIndex < options.length + const areMoreOptionsAbove = state.visibleFromIndex > 0 + + const i = state.visibleFromIndex + index + 1 + + const isFocused = !isDisabled && state.focusedValue === option.value + const isSelected = state.value === option.value + + // Handle input type options + if (option.type === 'input') { + const inputValue = inputValues.has(option.value) + ? inputValues.get(option.value)! + : option.initialValue || '' + + return ( + { + setInputValues(prev => { + const next = new Map(prev) + next.set(option.value, value) + return next + }) + }} + onSubmit={(value: string) => { + const hasImageAttachments = + pastedContents && + Object.values(pastedContents).some(c => c.type === 'image') + if ( + value.trim() || + hasImageAttachments || + option.allowEmptySubmitToCancel + ) { + onChange?.(option.value) + } else { + onCancel?.() + } + }} + onExit={onCancel} + layout="expanded" + showLabel={inlineDescriptions} + onOpenEditor={onOpenEditor} + resetCursorOnUpdate={option.resetCursorOnUpdate} + onImagePaste={onImagePaste} + pastedContents={pastedContents} + onRemoveImage={onRemoveImage} + imagesSelected={imagesSelected} + selectedImageIndex={selectedImageIndex} + onImagesSelectedChange={setImagesSelected} + onSelectedImageIndexChange={setSelectedImageIndex} + /> + ) } - lastInitialValues.current.set(option_0.value, newInitial); - } - } - }; - t10 = [options, inputValues]; - $[3] = inputValues; - $[4] = options; - $[5] = t10; - $[6] = t9; - } else { - t10 = $[5]; - t9 = $[6]; + + // Handle text type options + let label: ReactNode = option.label + + // Only apply highlight when label is a string + if ( + typeof option.label === 'string' && + highlightText && + option.label.includes(highlightText) + ) { + const labelText = option.label + const index = labelText.indexOf(highlightText) + + label = ( + <> + {labelText.slice(0, index)} + {highlightText} + {labelText.slice(index + highlightText.length)} + + ) + } + + const isOptionDisabled = option.disabled === true + const optionColor = isOptionDisabled + ? undefined + : isSelected + ? 'success' + : isFocused + ? 'suggestion' + : undefined + + return ( + + + + {label} + + + {option.description && ( + + + {option.description} + + + )} + + + ) + })} + + ) } - useEffect(t9, t10); - let t11; - if ($[7] !== defaultFocusValue || $[8] !== defaultValue || $[9] !== onCancel || $[10] !== onChange || $[11] !== onFocus || $[12] !== options || $[13] !== visibleOptionCount) { - t11 = { - visibleOptionCount, - options, - defaultValue, - onChange, - onCancel, - onFocus, - focusValue: defaultFocusValue - }; - $[7] = defaultFocusValue; - $[8] = defaultValue; - $[9] = onCancel; - $[10] = onChange; - $[11] = onFocus; - $[12] = options; - $[13] = visibleOptionCount; - $[14] = t11; - } else { - t11 = $[14]; + + if (layout === 'compact-vertical') { + const maxIndexWidth = hideIndexes + ? 0 + : state.options.length.toString().length + + return ( + + {state.visibleOptions.map((option, index) => { + const isFirstVisibleOption = option.index === state.visibleFromIndex + const isLastVisibleOption = option.index === state.visibleToIndex - 1 + const areMoreOptionsBelow = state.visibleToIndex < options.length + const areMoreOptionsAbove = state.visibleFromIndex > 0 + + const i = state.visibleFromIndex + index + 1 + + const isFocused = !isDisabled && state.focusedValue === option.value + const isSelected = state.value === option.value + + // Handle input type options + if (option.type === 'input') { + const inputValue = inputValues.has(option.value) + ? inputValues.get(option.value)! + : option.initialValue || '' + + return ( + { + setInputValues(prev => { + const next = new Map(prev) + next.set(option.value, value) + return next + }) + }} + onSubmit={(value: string) => { + const hasImageAttachments = + pastedContents && + Object.values(pastedContents).some(c => c.type === 'image') + if ( + value.trim() || + hasImageAttachments || + option.allowEmptySubmitToCancel + ) { + onChange?.(option.value) + } else { + onCancel?.() + } + }} + onExit={onCancel} + layout="compact" + showLabel={inlineDescriptions} + onOpenEditor={onOpenEditor} + resetCursorOnUpdate={option.resetCursorOnUpdate} + onImagePaste={onImagePaste} + pastedContents={pastedContents} + onRemoveImage={onRemoveImage} + imagesSelected={imagesSelected} + selectedImageIndex={selectedImageIndex} + onImagesSelectedChange={setImagesSelected} + onSelectedImageIndexChange={setSelectedImageIndex} + /> + ) + } + + // Handle text type options + let label: ReactNode = option.label + + // Only apply highlight when label is a string + if ( + typeof option.label === 'string' && + highlightText && + option.label.includes(highlightText) + ) { + const labelText = option.label + const index = labelText.indexOf(highlightText) + + label = ( + <> + {labelText.slice(0, index)} + {highlightText} + {labelText.slice(index + highlightText.length)} + + ) + } + + const isOptionDisabled = option.disabled === true + + return ( + + + <> + {!hideIndexes && ( + {`${i}.`.padEnd(maxIndexWidth + 1)} + )} + + {label} + + + + {option.description && ( + + + {option.description} + + + )} + + ) + })} + + ) } - const state = useSelectState(t11); - const t12 = disableSelection || (hideIndexes ? "numeric" : false); - let t13; - if ($[15] !== pastedContents) { - t13 = () => { - if (pastedContents && Object.values(pastedContents).some(_temp)) { - const imageCount = count(Object.values(pastedContents), _temp2); - setImagesSelected(true); - setSelectedImageIndex(imageCount - 1); - return true; - } - return false; - }; - $[15] = pastedContents; - $[16] = t13; - } else { - t13 = $[16]; - } - let t14; - if ($[17] !== imagesSelected || $[18] !== inputValues || $[19] !== isDisabled || $[20] !== onDownFromLastItem || $[21] !== onInputModeToggle || $[22] !== onUpFromFirstItem || $[23] !== options || $[24] !== state || $[25] !== t12 || $[26] !== t13) { - t14 = { - isDisabled, - disableSelection: t12, - state, - options, - isMultiSelect: false, - onUpFromFirstItem, - onDownFromLastItem, - onInputModeToggle, - inputValues, - imagesSelected, - onEnterImageSelection: t13 - }; - $[17] = imagesSelected; - $[18] = inputValues; - $[19] = isDisabled; - $[20] = onDownFromLastItem; - $[21] = onInputModeToggle; - $[22] = onUpFromFirstItem; - $[23] = options; - $[24] = state; - $[25] = t12; - $[26] = t13; - $[27] = t14; - } else { - t14 = $[27]; - } - useSelectInput(t14); - let T0; - let t15; - let t16; - let t17; - if ($[28] !== hideIndexes || $[29] !== highlightText || $[30] !== imagesSelected || $[31] !== inlineDescriptions || $[32] !== inputValues || $[33] !== isDisabled || $[34] !== layout || $[35] !== onCancel || $[36] !== onChange || $[37] !== onImagePaste || $[38] !== onOpenEditor || $[39] !== onRemoveImage || $[40] !== options.length || $[41] !== pastedContents || $[42] !== selectedImageIndex || $[43] !== state.focusedValue || $[44] !== state.options || $[45] !== state.value || $[46] !== state.visibleFromIndex || $[47] !== state.visibleOptions || $[48] !== state.visibleToIndex) { - t17 = Symbol.for("react.early_return_sentinel"); - bb0: { - const styles = { - container: _temp3, - highlightedText: _temp4 - }; - if (layout === "expanded") { - let t18; - if ($[53] !== state.options.length) { - t18 = state.options.length.toString(); - $[53] = state.options.length; - $[54] = t18; - } else { - t18 = $[54]; - } - const maxIndexWidth = t18.length; - t17 = {state.visibleOptions.map((option_1, index) => { - const isFirstVisibleOption = option_1.index === state.visibleFromIndex; - const isLastVisibleOption = option_1.index === state.visibleToIndex - 1; - const areMoreOptionsBelow = state.visibleToIndex < options.length; - const areMoreOptionsAbove = state.visibleFromIndex > 0; - const i = state.visibleFromIndex + index + 1; - const isFocused = !isDisabled && state.focusedValue === option_1.value; - const isSelected = state.value === option_1.value; - if (option_1.type === "input") { - const inputValue = inputValues.has(option_1.value) ? inputValues.get(option_1.value) : option_1.initialValue || ""; - return { - setInputValues(prev_0 => { - const next_0 = new Map(prev_0); - next_0.set(option_1.value, value); - return next_0; - }); - }} onSubmit={value_0 => { - const hasImageAttachments = pastedContents && Object.values(pastedContents).some(_temp5); - if (value_0.trim() || hasImageAttachments || option_1.allowEmptySubmitToCancel) { - onChange?.(option_1.value); - } else { - onCancel?.(); - } - }} onExit={onCancel} layout="expanded" showLabel={inlineDescriptions} onOpenEditor={onOpenEditor} resetCursorOnUpdate={option_1.resetCursorOnUpdate} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage} imagesSelected={imagesSelected} selectedImageIndex={selectedImageIndex} onImagesSelectedChange={setImagesSelected} onSelectedImageIndexChange={setSelectedImageIndex} />; - } - let label = option_1.label; - if (typeof option_1.label === "string" && highlightText && option_1.label.includes(highlightText)) { - const labelText = option_1.label; - const index_0 = labelText.indexOf(highlightText); - label = <>{labelText.slice(0, index_0)}{highlightText}{labelText.slice(index_0 + highlightText.length)}; - } - const isOptionDisabled = option_1.disabled === true; - const optionColor = isOptionDisabled ? undefined : isSelected ? "success" : isFocused ? "suggestion" : undefined; - return {label}{option_1.description && {option_1.description}} ; - })}; - break bb0; - } - if (layout === "compact-vertical") { - let t18; - if ($[55] !== hideIndexes || $[56] !== state.options) { - t18 = hideIndexes ? 0 : state.options.length.toString().length; - $[55] = hideIndexes; - $[56] = state.options; - $[57] = t18; - } else { - t18 = $[57]; - } - const maxIndexWidth_0 = t18; - t17 = {state.visibleOptions.map((option_2, index_1) => { - const isFirstVisibleOption_0 = option_2.index === state.visibleFromIndex; - const isLastVisibleOption_0 = option_2.index === state.visibleToIndex - 1; - const areMoreOptionsBelow_0 = state.visibleToIndex < options.length; - const areMoreOptionsAbove_0 = state.visibleFromIndex > 0; - const i_0 = state.visibleFromIndex + index_1 + 1; - const isFocused_0 = !isDisabled && state.focusedValue === option_2.value; - const isSelected_0 = state.value === option_2.value; - if (option_2.type === "input") { - const inputValue_0 = inputValues.has(option_2.value) ? inputValues.get(option_2.value) : option_2.initialValue || ""; - return { - setInputValues(prev_1 => { - const next_1 = new Map(prev_1); - next_1.set(option_2.value, value_1); - return next_1; - }); - }} onSubmit={value_2 => { - const hasImageAttachments_0 = pastedContents && Object.values(pastedContents).some(_temp6); - if (value_2.trim() || hasImageAttachments_0 || option_2.allowEmptySubmitToCancel) { - onChange?.(option_2.value); - } else { - onCancel?.(); - } - }} onExit={onCancel} layout="compact" showLabel={inlineDescriptions} onOpenEditor={onOpenEditor} resetCursorOnUpdate={option_2.resetCursorOnUpdate} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage} imagesSelected={imagesSelected} selectedImageIndex={selectedImageIndex} onImagesSelectedChange={setImagesSelected} onSelectedImageIndexChange={setSelectedImageIndex} />; - } - let label_0 = option_2.label; - if (typeof option_2.label === "string" && highlightText && option_2.label.includes(highlightText)) { - const labelText_0 = option_2.label; - const index_2 = labelText_0.indexOf(highlightText); - label_0 = <>{labelText_0.slice(0, index_2)}{highlightText}{labelText_0.slice(index_2 + highlightText.length)}; - } - const isOptionDisabled_0 = option_2.disabled === true; - return <>{!hideIndexes && {`${i_0}.`.padEnd(maxIndexWidth_0 + 1)}}{label_0}{option_2.description && {option_2.description}}; - })}; - break bb0; - } - let t18; - if ($[58] !== hideIndexes || $[59] !== state.options) { - t18 = hideIndexes ? 0 : state.options.length.toString().length; - $[58] = hideIndexes; - $[59] = state.options; - $[60] = t18; - } else { - t18 = $[60]; - } - const maxIndexWidth_1 = t18; - const hasInputOptions = state.visibleOptions.some(_temp7); - const hasDescriptions = !inlineDescriptions && !hasInputOptions && state.visibleOptions.some(_temp8); - const optionData = state.visibleOptions.map((option_3, index_3) => { - const isFirstVisibleOption_1 = option_3.index === state.visibleFromIndex; - const isLastVisibleOption_1 = option_3.index === state.visibleToIndex - 1; - const areMoreOptionsBelow_1 = state.visibleToIndex < options.length; - const areMoreOptionsAbove_1 = state.visibleFromIndex > 0; - const i_1 = state.visibleFromIndex + index_3 + 1; - const isFocused_1 = !isDisabled && state.focusedValue === option_3.value; - const isSelected_1 = state.value === option_3.value; - const isOptionDisabled_1 = option_3.disabled === true; - let label_1 = option_3.label; - if (typeof option_3.label === "string" && highlightText && option_3.label.includes(highlightText)) { - const labelText_1 = option_3.label; - const idx = labelText_1.indexOf(highlightText); - label_1 = <>{labelText_1.slice(0, idx)}{highlightText}{labelText_1.slice(idx + highlightText.length)}; - } - return { - option: option_3, - index: i_1, - label: label_1, - isFocused: isFocused_1, - isSelected: isSelected_1, - isOptionDisabled: isOptionDisabled_1, - shouldShowDownArrow: areMoreOptionsBelow_1 && isLastVisibleOption_1, - shouldShowUpArrow: areMoreOptionsAbove_1 && isFirstVisibleOption_1 - }; - }); - if (hasDescriptions) { - let t19; - if ($[61] !== hideIndexes || $[62] !== maxIndexWidth_1) { - t19 = data => { - if (data.option.type === "input") { - return 0; - } - const labelText_2 = getTextContent(data.option.label); - const indexWidth = hideIndexes ? 0 : maxIndexWidth_1 + 2; - const checkmarkWidth = data.isSelected ? 2 : 0; - return 2 + indexWidth + stringWidth(labelText_2) + checkmarkWidth; - }; - $[61] = hideIndexes; - $[62] = maxIndexWidth_1; - $[63] = t19; - } else { - t19 = $[63]; - } - const maxLabelWidth = Math.max(...optionData.map(t19) as number[]); - let t20; - if ($[64] !== hideIndexes || $[65] !== maxIndexWidth_1 || $[66] !== maxLabelWidth) { - t20 = data_0 => { - if (data_0.option.type === "input") { - return null; - } - const labelText_3 = getTextContent(data_0.option.label); - const indexWidth_0 = hideIndexes ? 0 : maxIndexWidth_1 + 2; - const checkmarkWidth_0 = data_0.isSelected ? 2 : 0; - const currentLabelWidth = 2 + indexWidth_0 + stringWidth(labelText_3) + checkmarkWidth_0; - const padding = maxLabelWidth - currentLabelWidth; - return {data_0.isFocused ? {figures.pointer} : data_0.shouldShowDownArrow ? {figures.arrowDown} : data_0.shouldShowUpArrow ? {figures.arrowUp} : } {!hideIndexes && {`${data_0.index}.`.padEnd(maxIndexWidth_1 + 2)}}{data_0.label}{data_0.isSelected && {figures.tick}}{padding > 0 && {" ".repeat(padding)}}{data_0.option.description || " "}; - }; - $[64] = hideIndexes; - $[65] = maxIndexWidth_1; - $[66] = maxLabelWidth; - $[67] = t20; - } else { - t20 = $[67]; - } - t17 = {optionData.map(t20)}; - break bb0; - } - T0 = Box; - t15 = styles.container(); - t16 = state.visibleOptions.map((option_4, index_4) => { - if (option_4.type === "input") { - const inputValue_1 = inputValues.has(option_4.value) ? inputValues.get(option_4.value) : option_4.initialValue || ""; - const isFirstVisibleOption_2 = option_4.index === state.visibleFromIndex; - const isLastVisibleOption_2 = option_4.index === state.visibleToIndex - 1; - const areMoreOptionsBelow_2 = state.visibleToIndex < options.length; - const areMoreOptionsAbove_2 = state.visibleFromIndex > 0; - const i_2 = state.visibleFromIndex + index_4 + 1; - const isFocused_2 = !isDisabled && state.focusedValue === option_4.value; - const isSelected_2 = state.value === option_4.value; - return { - setInputValues(prev_2 => { - const next_2 = new Map(prev_2); - next_2.set(option_4.value, value_3); - return next_2; - }); - }} onSubmit={value_4 => { - const hasImageAttachments_1 = pastedContents && Object.values(pastedContents).some(_temp9); - if (value_4.trim() || hasImageAttachments_1 || option_4.allowEmptySubmitToCancel) { - onChange?.(option_4.value); - } else { - onCancel?.(); - } - }} onExit={onCancel} layout="compact" showLabel={inlineDescriptions} onOpenEditor={onOpenEditor} resetCursorOnUpdate={option_4.resetCursorOnUpdate} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage} imagesSelected={imagesSelected} selectedImageIndex={selectedImageIndex} onImagesSelectedChange={setImagesSelected} onSelectedImageIndexChange={setSelectedImageIndex} />; - } - let label_2 = option_4.label; - if (typeof option_4.label === "string" && highlightText && option_4.label.includes(highlightText)) { - const labelText_4 = option_4.label; - const index_5 = labelText_4.indexOf(highlightText); - label_2 = <>{labelText_4.slice(0, index_5)}{highlightText}{labelText_4.slice(index_5 + highlightText.length)}; - } - const isFirstVisibleOption_3 = option_4.index === state.visibleFromIndex; - const isLastVisibleOption_3 = option_4.index === state.visibleToIndex - 1; - const areMoreOptionsBelow_3 = state.visibleToIndex < options.length; - const areMoreOptionsAbove_3 = state.visibleFromIndex > 0; - const i_3 = state.visibleFromIndex + index_4 + 1; - const isFocused_3 = !isDisabled && state.focusedValue === option_4.value; - const isSelected_3 = state.value === option_4.value; - const isOptionDisabled_2 = option_4.disabled === true; - return {!hideIndexes && {`${i_3}.`.padEnd(maxIndexWidth_1 + 2)}}{label_2}{inlineDescriptions && option_4.description && {" "}{option_4.description}}{!inlineDescriptions && option_4.description && {option_4.description}}; - }); + + const maxIndexWidth = hideIndexes ? 0 : state.options.length.toString().length + + // Check if any visible options have descriptions (for two-column layout) + // Also check that there are NO input options, since they're not supported in two-column layout + // Skip two-column layout when inlineDescriptions is enabled + const hasInputOptions = state.visibleOptions.some(opt => opt.type === 'input') + const hasDescriptions = + !inlineDescriptions && + !hasInputOptions && + state.visibleOptions.some(opt => opt.description) + + // Pre-compute option data for two-column layout + const optionData = state.visibleOptions.map((option, index) => { + const isFirstVisibleOption = option.index === state.visibleFromIndex + const isLastVisibleOption = option.index === state.visibleToIndex - 1 + const areMoreOptionsBelow = state.visibleToIndex < options.length + const areMoreOptionsAbove = state.visibleFromIndex > 0 + const i = state.visibleFromIndex + index + 1 + const isFocused = !isDisabled && state.focusedValue === option.value + const isSelected = state.value === option.value + const isOptionDisabled = option.disabled === true + + let label: ReactNode = option.label + if ( + typeof option.label === 'string' && + highlightText && + option.label.includes(highlightText) + ) { + const labelText = option.label + const idx = labelText.indexOf(highlightText) + label = ( + <> + {labelText.slice(0, idx)} + {highlightText} + {labelText.slice(idx + highlightText.length)} + + ) } - $[28] = hideIndexes; - $[29] = highlightText; - $[30] = imagesSelected; - $[31] = inlineDescriptions; - $[32] = inputValues; - $[33] = isDisabled; - $[34] = layout; - $[35] = onCancel; - $[36] = onChange; - $[37] = onImagePaste; - $[38] = onOpenEditor; - $[39] = onRemoveImage; - $[40] = options.length; - $[41] = pastedContents; - $[42] = selectedImageIndex; - $[43] = state.focusedValue; - $[44] = state.options; - $[45] = state.value; - $[46] = state.visibleFromIndex; - $[47] = state.visibleOptions; - $[48] = state.visibleToIndex; - $[49] = T0; - $[50] = t15; - $[51] = t16; - $[52] = t17; - } else { - T0 = $[49]; - t15 = $[50]; - t16 = $[51]; - t17 = $[52]; + + return { + option, + index: i, + label, + isFocused, + isSelected, + isOptionDisabled, + shouldShowDownArrow: areMoreOptionsBelow && isLastVisibleOption, + shouldShowUpArrow: areMoreOptionsAbove && isFirstVisibleOption, + } + }) + + // Calculate max label width for alignment when descriptions exist + if (hasDescriptions) { + const maxLabelWidth = Math.max( + ...optionData.map(data => { + if (data.option.type === 'input') return 0 + const labelText = getTextContent(data.option.label) + // Width: indicator (1) + space (1) + index + label + space + checkmark (1) + const indexWidth = hideIndexes ? 0 : maxIndexWidth + 2 + const checkmarkWidth = data.isSelected ? 2 : 0 + return 2 + indexWidth + stringWidth(labelText) + checkmarkWidth + }), + ) + + return ( + + {optionData.map(data => { + if (data.option.type === 'input') { + // Input options not supported in two-column layout + return null + } + const labelText = getTextContent(data.option.label) + const indexWidth = hideIndexes ? 0 : maxIndexWidth + 2 + const checkmarkWidth = data.isSelected ? 2 : 0 + const currentLabelWidth = + 2 + indexWidth + stringWidth(labelText) + checkmarkWidth + const padding = maxLabelWidth - currentLabelWidth + + return ( + + {/* Label part - no gap, handle spacing explicitly */} + + {data.isFocused ? ( + {figures.pointer} + ) : data.shouldShowDownArrow ? ( + {figures.arrowDown} + ) : data.shouldShowUpArrow ? ( + {figures.arrowUp} + ) : ( + + )} + + + {!hideIndexes && ( + + {`${data.index}.`.padEnd(maxIndexWidth + 2)} + + )} + {data.label} + + {data.isSelected && ( + {figures.tick} + )} + {/* Padding to align descriptions */} + {padding > 0 && {' '.repeat(padding)}} + + {/* Description part */} + + + {data.option.description || ' '} + + + + ) + })} + + ) } - if (t17 !== Symbol.for("react.early_return_sentinel")) { - return t17; - } - let t18; - if ($[68] !== T0 || $[69] !== t15 || $[70] !== t16) { - t18 = {t16}; - $[68] = T0; - $[69] = t15; - $[70] = t16; - $[71] = t18; - } else { - t18 = $[71]; - } - return t18; + + return ( + + {state.visibleOptions.map((option, index) => { + // Handle input type options + if (option.type === 'input') { + const inputValue = inputValues.has(option.value) + ? inputValues.get(option.value)! + : option.initialValue || '' + + const isFirstVisibleOption = option.index === state.visibleFromIndex + const isLastVisibleOption = option.index === state.visibleToIndex - 1 + const areMoreOptionsBelow = state.visibleToIndex < options.length + const areMoreOptionsAbove = state.visibleFromIndex > 0 + + const i = state.visibleFromIndex + index + 1 + + const isFocused = !isDisabled && state.focusedValue === option.value + const isSelected = state.value === option.value + + return ( + { + setInputValues(prev => { + const next = new Map(prev) + next.set(option.value, value) + return next + }) + }} + onSubmit={(value: string) => { + const hasImageAttachments = + pastedContents && + Object.values(pastedContents).some(c => c.type === 'image') + if ( + value.trim() || + hasImageAttachments || + option.allowEmptySubmitToCancel + ) { + onChange?.(option.value) + } else { + onCancel?.() + } + }} + onExit={onCancel} + layout="compact" + showLabel={inlineDescriptions} + onOpenEditor={onOpenEditor} + resetCursorOnUpdate={option.resetCursorOnUpdate} + onImagePaste={onImagePaste} + pastedContents={pastedContents} + onRemoveImage={onRemoveImage} + imagesSelected={imagesSelected} + selectedImageIndex={selectedImageIndex} + onImagesSelectedChange={setImagesSelected} + onSelectedImageIndexChange={setSelectedImageIndex} + /> + ) + } + + // Handle text type options + let label: ReactNode = option.label + + // Only apply highlight when label is a string + if ( + typeof option.label === 'string' && + highlightText && + option.label.includes(highlightText) + ) { + const labelText = option.label + const index = labelText.indexOf(highlightText) + + label = ( + <> + {labelText.slice(0, index)} + {highlightText} + {labelText.slice(index + highlightText.length)} + + ) + } + + const isFirstVisibleOption = option.index === state.visibleFromIndex + const isLastVisibleOption = option.index === state.visibleToIndex - 1 + const areMoreOptionsBelow = state.visibleToIndex < options.length + const areMoreOptionsAbove = state.visibleFromIndex > 0 + + const i = state.visibleFromIndex + index + 1 + + const isFocused = !isDisabled && state.focusedValue === option.value + const isSelected = state.value === option.value + const isOptionDisabled = option.disabled === true + + return ( + + + {!hideIndexes && ( + {`${i}.`.padEnd(maxIndexWidth + 2)} + )} + + {label} + {inlineDescriptions && option.description && ( + + {' '} + {option.description} + + )} + + + {!inlineDescriptions && option.description && ( + + + {option.description} + + + )} + + ) + })} + + ) } // Row container for the two-column (label + description) layout. Unlike // the other Select layouts, this one doesn't render through SelectOption → // ListItem, so it declares the native cursor directly. Parks the cursor // on the pointer indicator so screen readers / magnifiers track focus. -function _temp9(c_3) { - return c_3.type === "image"; -} -function _temp8(opt_0) { - return opt_0.description; -} -function _temp7(opt) { - return opt.type === "input"; -} -function _temp6(c_2) { - return c_2.type === "image"; -} -function _temp5(c_1) { - return c_1.type === "image"; -} -function _temp4() { - return { - bold: true - }; -} -function _temp3() { - return { - flexDirection: "column" as const - }; -} -function _temp2(c) { - return c.type === "image"; -} -function _temp(c_0) { - return c_0.type === "image"; -} -function TwoColumnRow(t0) { - const $ = _c(5); - const { - isFocused, - children - } = t0; - let t1; - if ($[0] !== isFocused) { - t1 = { - line: 0, - column: 0, - active: isFocused - }; - $[0] = isFocused; - $[1] = t1; - } else { - t1 = $[1]; - } - const cursorRef = useDeclaredCursor(t1); - let t2; - if ($[2] !== children || $[3] !== cursorRef) { - t2 = {children}; - $[2] = children; - $[3] = cursorRef; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; +function TwoColumnRow({ + isFocused, + children, +}: { + isFocused: boolean + children: ReactNode +}): React.ReactNode { + const cursorRef = useDeclaredCursor({ + line: 0, + column: 0, + active: isFocused, + }) + return ( + + {children} + + ) } diff --git a/src/components/DesktopHandoff.tsx b/src/components/DesktopHandoff.tsx index a305e70ec..8e0632fd4 100644 --- a/src/components/DesktopHandoff.tsx +++ b/src/components/DesktopHandoff.tsx @@ -1,192 +1,151 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useEffect, useState } from 'react'; -import type { CommandResultDisplay } from '../commands.js'; +import React, { useEffect, useState } from 'react' +import type { CommandResultDisplay } from '../commands.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw input for "any key" dismiss and y/n prompt -import { Box, Text, useInput } from '../ink.js'; -import { openBrowser } from '../utils/browser.js'; -import { getDesktopInstallStatus, openCurrentSessionInDesktop } from '../utils/desktopDeepLink.js'; -import { errorMessage } from '../utils/errors.js'; -import { gracefulShutdown } from '../utils/gracefulShutdown.js'; -import { flushSessionStorage } from '../utils/sessionStorage.js'; -import { LoadingState } from './design-system/LoadingState.js'; -const DESKTOP_DOCS_URL = 'https://clau.de/desktop'; +import { Box, Text, useInput } from '../ink.js' +import { openBrowser } from '../utils/browser.js' +import { + getDesktopInstallStatus, + openCurrentSessionInDesktop, +} from '../utils/desktopDeepLink.js' +import { errorMessage } from '../utils/errors.js' +import { gracefulShutdown } from '../utils/gracefulShutdown.js' +import { flushSessionStorage } from '../utils/sessionStorage.js' +import { LoadingState } from './design-system/LoadingState.js' + +const DESKTOP_DOCS_URL = 'https://clau.de/desktop' + export function getDownloadUrl(): string { switch (process.platform) { case 'win32': - return 'https://claude.ai/api/desktop/win32/x64/exe/latest/redirect'; + return 'https://claude.ai/api/desktop/win32/x64/exe/latest/redirect' default: - return 'https://claude.ai/api/desktop/darwin/universal/dmg/latest/redirect'; + return 'https://claude.ai/api/desktop/darwin/universal/dmg/latest/redirect' } } -type DesktopHandoffState = 'checking' | 'prompt-download' | 'flushing' | 'opening' | 'success' | 'error'; + +type DesktopHandoffState = + | 'checking' + | 'prompt-download' + | 'flushing' + | 'opening' + | 'success' + | 'error' + type Props = { - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -export function DesktopHandoff(t0) { - const $ = _c(20); - const { - onDone - } = t0; - const [state, setState] = useState("checking"); - const [error, setError] = useState(null); - const [downloadMessage, setDownloadMessage] = useState(""); - let t1; - if ($[0] !== error || $[1] !== onDone || $[2] !== state) { - t1 = input => { - if (state === "error") { - onDone(error ?? "Unknown error", { - display: "system" - }); - return; - } - if (state === "prompt-download") { - if (input === "y" || input === "Y") { - openBrowser(getDownloadUrl()).catch(_temp); - onDone(`Starting download. Re-run /desktop once you\u2019ve installed the app.\nLearn more at ${DESKTOP_DOCS_URL}`, { - display: "system" - }); - } else { - if (input === "n" || input === "N") { - onDone(`The desktop app is required for /desktop. Learn more at ${DESKTOP_DOCS_URL}`, { - display: "system" - }); - } - } - } - }; - $[0] = error; - $[1] = onDone; - $[2] = state; - $[3] = t1; - } else { - t1 = $[3]; - } - useInput(t1); - let t2; - let t3; - if ($[4] !== onDone) { - t2 = () => { - const performHandoff = async function performHandoff() { - setState("checking"); - const installStatus = await getDesktopInstallStatus(); - if (installStatus.status === "not-installed") { - setDownloadMessage("Claude Desktop is not installed."); - setState("prompt-download"); - return; - } - if (installStatus.status === "version-too-old") { - setDownloadMessage(`Claude Desktop needs to be updated (found v${installStatus.version}, need v1.1.2396+).`); - setState("prompt-download"); - return; - } - setState("flushing"); - await flushSessionStorage(); - setState("opening"); - const result = await openCurrentSessionInDesktop(); - if (!result.success) { - setError(result.error ?? "Failed to open Claude Desktop"); - setState("error"); - return; - } - setState("success"); - setTimeout(_temp2, 500, onDone); - }; - performHandoff().catch(err => { - setError(errorMessage(err)); - setState("error"); - }); - }; - t3 = [onDone]; - $[4] = onDone; - $[5] = t2; - $[6] = t3; - } else { - t2 = $[5]; - t3 = $[6]; - } - useEffect(t2, t3); - if (state === "error") { - let t4; - if ($[7] !== error) { - t4 = Error: {error}; - $[7] = error; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Press any key to continue…; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== t4) { - t6 = {t4}{t5}; - $[10] = t4; - $[11] = t6; - } else { - t6 = $[11]; - } - return t6; - } - if (state === "prompt-download") { - let t4; - if ($[12] !== downloadMessage) { - t4 = {downloadMessage}; - $[12] = downloadMessage; - $[13] = t4; - } else { - t4 = $[13]; - } - let t5; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Download now? (y/n); - $[14] = t5; - } else { - t5 = $[14]; - } - let t6; - if ($[15] !== t4) { - t6 = {t4}{t5}; - $[15] = t4; - $[16] = t6; - } else { - t6 = $[16]; - } - return t6; - } - let t4; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { - checking: "Checking for Claude Desktop\u2026", - flushing: "Saving session\u2026", - opening: "Opening Claude Desktop\u2026", - success: "Opening in Claude Desktop\u2026" - }; - $[17] = t4; - } else { - t4 = $[17]; - } - const messages = t4; - const t5 = messages[state]; - let t6; - if ($[18] !== t5) { - t6 = ; - $[18] = t5; - $[19] = t6; - } else { - t6 = $[19]; - } - return t6; + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void } -async function _temp2(onDone_0) { - onDone_0("Session transferred to Claude Desktop", { - display: "system" - }); - await gracefulShutdown(0, "other"); + +export function DesktopHandoff({ onDone }: Props): React.ReactNode { + const [state, setState] = useState('checking') + const [error, setError] = useState(null) + const [downloadMessage, setDownloadMessage] = useState('') + + // Handle keyboard input for error and prompt-download states + useInput(input => { + if (state === 'error') { + onDone(error ?? 'Unknown error', { display: 'system' }) + return + } + if (state === 'prompt-download') { + if (input === 'y' || input === 'Y') { + openBrowser(getDownloadUrl()).catch(() => {}) + onDone( + `Starting download. Re-run /desktop once you\u2019ve installed the app.\nLearn more at ${DESKTOP_DOCS_URL}`, + { display: 'system' }, + ) + } else if (input === 'n' || input === 'N') { + onDone( + `The desktop app is required for /desktop. Learn more at ${DESKTOP_DOCS_URL}`, + { display: 'system' }, + ) + } + } + }) + + useEffect(() => { + async function performHandoff(): Promise { + // Check Desktop install status + setState('checking') + const installStatus = await getDesktopInstallStatus() + + if (installStatus.status === 'not-installed') { + setDownloadMessage('Claude Desktop is not installed.') + setState('prompt-download') + return + } + + if (installStatus.status === 'version-too-old') { + setDownloadMessage( + `Claude Desktop needs to be updated (found v${installStatus.version}, need v1.1.2396+).`, + ) + setState('prompt-download') + return + } + + // Flush session storage to ensure transcript is fully written + setState('flushing') + await flushSessionStorage() + + // Open the deep link (uses claude-dev:// in dev mode) + setState('opening') + const result = await openCurrentSessionInDesktop() + + if (!result.success) { + setError(result.error ?? 'Failed to open Claude Desktop') + setState('error') + return + } + + // Success - exit the CLI + setState('success') + + // Give the user a moment to see the success message + setTimeout( + async (onDone: Props['onDone']) => { + onDone('Session transferred to Claude Desktop', { display: 'system' }) + await gracefulShutdown(0, 'other') + }, + 500, + onDone, + ) + } + + performHandoff().catch(err => { + setError(errorMessage(err)) + setState('error') + }) + }, [onDone]) + + if (state === 'error') { + return ( + + Error: {error} + Press any key to continue… + + ) + } + + if (state === 'prompt-download') { + return ( + + {downloadMessage} + Download now? (y/n) + + ) + } + + const messages: Record< + Exclude, + string + > = { + checking: 'Checking for Claude Desktop…', + flushing: 'Saving session…', + opening: 'Opening Claude Desktop…', + success: 'Opening in Claude Desktop…', + } + + return } -function _temp() {} diff --git a/src/components/DesktopUpsell/DesktopUpsellStartup.tsx b/src/components/DesktopUpsell/DesktopUpsellStartup.tsx index 1a48baca4..9f5f233a4 100644 --- a/src/components/DesktopUpsell/DesktopUpsellStartup.tsx +++ b/src/components/DesktopUpsell/DesktopUpsellStartup.tsx @@ -1,170 +1,108 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { Box, Text } from '../../ink.js'; -import { getDynamicConfig_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; -import { logEvent } from '../../services/analytics/index.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { Select } from '../CustomSelect/select.js'; -import { DesktopHandoff } from '../DesktopHandoff.js'; -import { PermissionDialog } from '../permissions/PermissionDialog.js'; +import * as React from 'react' +import { useEffect, useState } from 'react' +import { Box, Text } from '../../ink.js' +import { getDynamicConfig_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' +import { logEvent } from '../../services/analytics/index.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { Select } from '../CustomSelect/select.js' +import { DesktopHandoff } from '../DesktopHandoff.js' +import { PermissionDialog } from '../permissions/PermissionDialog.js' + type DesktopUpsellConfig = { - enable_shortcut_tip: boolean; - enable_startup_dialog: boolean; -}; + enable_shortcut_tip: boolean + enable_startup_dialog: boolean +} + const DESKTOP_UPSELL_DEFAULT: DesktopUpsellConfig = { enable_shortcut_tip: false, - enable_startup_dialog: false -}; + enable_startup_dialog: false, +} + export function getDesktopUpsellConfig(): DesktopUpsellConfig { - return getDynamicConfig_CACHED_MAY_BE_STALE('tengu_desktop_upsell', DESKTOP_UPSELL_DEFAULT); + return getDynamicConfig_CACHED_MAY_BE_STALE( + 'tengu_desktop_upsell', + DESKTOP_UPSELL_DEFAULT, + ) } + function isSupportedPlatform(): boolean { - return process.platform === 'darwin' || process.platform === 'win32' && process.arch === 'x64'; + return ( + process.platform === 'darwin' || + (process.platform === 'win32' && process.arch === 'x64') + ) } + export function shouldShowDesktopUpsellStartup(): boolean { - if (!isSupportedPlatform()) return false; - if (!getDesktopUpsellConfig().enable_startup_dialog) return false; - const config = getGlobalConfig(); - if (config.desktopUpsellDismissed) return false; - if ((config.desktopUpsellSeenCount ?? 0) >= 3) return false; - return true; + if (!isSupportedPlatform()) return false + if (!getDesktopUpsellConfig().enable_startup_dialog) return false + const config = getGlobalConfig() + if (config.desktopUpsellDismissed) return false + if ((config.desktopUpsellSeenCount ?? 0) >= 3) return false + return true } -type DesktopUpsellSelection = 'try' | 'not-now' | 'never'; + +type DesktopUpsellSelection = 'try' | 'not-now' | 'never' + type Props = { - onDone: () => void; -}; -export function DesktopUpsellStartup(t0) { - const $ = _c(14); - const { - onDone - } = t0; - const [showHandoff, setShowHandoff] = useState(false); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - useEffect(_temp, t1); + onDone: () => void +} + +export function DesktopUpsellStartup({ onDone }: Props): React.ReactNode { + const [showHandoff, setShowHandoff] = useState(false) + + // Increment seen count on mount (guard in updater for StrictMode safety) + useEffect(() => { + const newCount = (getGlobalConfig().desktopUpsellSeenCount ?? 0) + 1 + saveGlobalConfig(prev => { + if ((prev.desktopUpsellSeenCount ?? 0) >= newCount) return prev + return { ...prev, desktopUpsellSeenCount: newCount } + }) + logEvent('tengu_desktop_upsell_shown', { seen_count: newCount }) + }, []) + if (showHandoff) { - let t2; - if ($[1] !== onDone) { - t2 = onDone()} />; - $[1] = onDone; - $[2] = t2; - } else { - t2 = $[2]; + return onDone()} /> + } + + function handleSelect(value: DesktopUpsellSelection): void { + switch (value) { + case 'try': + setShowHandoff(true) + return + case 'never': + saveGlobalConfig(prev => { + if (prev.desktopUpsellDismissed) return prev + return { ...prev, desktopUpsellDismissed: true } + }) + onDone() + return + case 'not-now': + onDone() + return } - return t2; } - let t2; - if ($[3] !== onDone) { - t2 = function handleSelect(value) { - switch (value) { - case "try": - { - setShowHandoff(true); - return; - } - case "never": - { - saveGlobalConfig(_temp2); - onDone(); - return; - } - case "not-now": - { - onDone(); - return; - } - } - }; - $[3] = onDone; - $[4] = t2; - } else { - t2 = $[4]; - } - const handleSelect = t2; - let t3; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - label: "Open in Claude Code Desktop", - value: "try" as const - }; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { - label: "Not now", - value: "not-now" as const - }; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t5 = [t3, t4, { - label: "Don't ask again", - value: "never" as const - }]; - $[7] = t5; - } else { - t5 = $[7]; - } - const options = t5; - let t6; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t6 = Same Claude Code with visual diffs, live app preview, parallel sessions, and more.; - $[8] = t6; - } else { - t6 = $[8]; - } - let t7; - if ($[9] !== handleSelect) { - t7 = () => handleSelect("not-now"); - $[9] = handleSelect; - $[10] = t7; - } else { - t7 = $[10]; - } - let t8; - if ($[11] !== handleSelect || $[12] !== t7) { - t8 = {t6} handleSelect('not-now')} + /> + + + ) } diff --git a/src/components/DevBar.tsx b/src/components/DevBar.tsx index bf99f32ef..95ff6b983 100644 --- a/src/components/DevBar.tsx +++ b/src/components/DevBar.tsx @@ -1,48 +1,46 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useState } from 'react'; -import { getSlowOperations } from '../bootstrap/state.js'; -import { Text, useInterval } from '../ink.js'; +import * as React from 'react' +import { useState } from 'react' +import { getSlowOperations } from '../bootstrap/state.js' +import { Text, useInterval } from '../ink.js' // Show DevBar for dev builds or all ants function shouldShowDevBar(): boolean { - return ("production" as string) === 'development' || (process.env.USER_TYPE) === 'ant'; + return ( + "production" === 'development' || process.env.USER_TYPE === 'ant' + ) } -export function DevBar() { - const $ = _c(5); - const [slowOps, setSlowOps] = useState(getSlowOperations); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = () => { - setSlowOps(getSlowOperations()); - }; - $[0] = t0; - } else { - t0 = $[0]; - } - useInterval(t0, shouldShowDevBar() ? 500 : null); + +export function DevBar(): React.ReactNode { + const [slowOps, setSlowOps] = + useState< + ReadonlyArray<{ + operation: string + durationMs: number + timestamp: number + }> + >(getSlowOperations) + + useInterval( + () => { + setSlowOps(getSlowOperations()) + }, + shouldShowDevBar() ? 500 : null, + ) + + // Only show when there's something to display if (!shouldShowDevBar() || slowOps.length === 0) { - return null; + return null } - let t1; - if ($[1] !== slowOps) { - t1 = slowOps.slice(-3).map(_temp).join(" \xB7 "); - $[1] = slowOps; - $[2] = t1; - } else { - t1 = $[2]; - } - const recentOps = t1; - let t2; - if ($[3] !== recentOps) { - t2 = [ANT-ONLY] slow sync: {recentOps}; - $[3] = recentOps; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; -} -function _temp(op) { - return `${op.operation} (${Math.round(op.durationMs)}ms)`; + + // Single-line format so short terminals don't lose rows to dev noise. + const recentOps = slowOps + .slice(-3) + .map(op => `${op.operation} (${Math.round(op.durationMs)}ms)`) + .join(' · ') + + return ( + + [ANT-ONLY] slow sync: {recentOps} + + ) } diff --git a/src/components/DevChannelsDialog.tsx b/src/components/DevChannelsDialog.tsx index 820b62731..7dfc674cc 100644 --- a/src/components/DevChannelsDialog.tsx +++ b/src/components/DevChannelsDialog.tsx @@ -1,104 +1,66 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback } from 'react'; -import type { ChannelEntry } from '../bootstrap/state.js'; -import { Box, Text } from '../ink.js'; -import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; +import React, { useCallback } from 'react' +import type { ChannelEntry } from '../bootstrap/state.js' +import { Box, Text } from '../ink.js' +import { gracefulShutdownSync } from '../utils/gracefulShutdown.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + type Props = { - channels: ChannelEntry[]; - onAccept(): void; -}; -export function DevChannelsDialog(t0) { - const $ = _c(14); - const { - channels, - onAccept - } = t0; - let t1; - if ($[0] !== onAccept) { - t1 = function onChange(value) { - bb2: switch (value) { - case "accept": - { - onAccept(); - break bb2; - } - case "exit": - { - gracefulShutdownSync(1); - } - } - }; - $[0] = onAccept; - $[1] = t1; - } else { - t1 = $[1]; - } - const onChange = t1; - const handleEscape = _temp; - let t2; - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = --dangerously-load-development-channels is for local channel development only. Do not use this option to run channels you have downloaded off the internet.; - t3 = Please use --channels to run a list of approved channels.; - $[2] = t2; - $[3] = t3; - } else { - t2 = $[2]; - t3 = $[3]; - } - let t4; - if ($[4] !== channels) { - t4 = channels.map(_temp2).join(", "); - $[4] = channels; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== t4) { - t5 = {t2}{t3}Channels:{" "}{t4}; - $[6] = t4; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t6 = [{ - label: "I am using this for local development", - value: "accept" - }, { - label: "Exit", - value: "exit" - }]; - $[8] = t6; - } else { - t6 = $[8]; - } - let t7; - if ($[9] !== onChange) { - t7 = onChange(value as 'accept' | 'exit')} + /> + + ) } diff --git a/src/components/DiagnosticsDisplay.tsx b/src/components/DiagnosticsDisplay.tsx index 1a1027316..ad01c7756 100644 --- a/src/components/DiagnosticsDisplay.tsx +++ b/src/components/DiagnosticsDisplay.tsx @@ -1,94 +1,91 @@ -import { c as _c } from "react/compiler-runtime"; -import { relative } from 'path'; -import React from 'react'; -import { Box, Text } from '../ink.js'; -import { DiagnosticTrackingService } from '../services/diagnosticTracking.js'; -import type { Attachment } from '../utils/attachments.js'; -import { getCwd } from '../utils/cwd.js'; -import { CtrlOToExpand } from './CtrlOToExpand.js'; -import { MessageResponse } from './MessageResponse.js'; -type DiagnosticsAttachment = Extract; +import { relative } from 'path' +import React from 'react' +import { Box, Text } from '../ink.js' +import { DiagnosticTrackingService } from '../services/diagnosticTracking.js' +import type { Attachment } from '../utils/attachments.js' +import { getCwd } from '../utils/cwd.js' +import { CtrlOToExpand } from './CtrlOToExpand.js' +import { MessageResponse } from './MessageResponse.js' + +type DiagnosticsAttachment = Extract + type DiagnosticsDisplayProps = { - attachment: DiagnosticsAttachment; - verbose: boolean; -}; -export function DiagnosticsDisplay(t0) { - const $ = _c(14); - const { - attachment, - verbose - } = t0; - if (attachment.files.length === 0) { - return null; - } - let t1; - if ($[0] !== attachment.files) { - t1 = attachment.files.reduce(_temp, 0); - $[0] = attachment.files; - $[1] = t1; - } else { - t1 = $[1]; - } - const totalIssues = t1; - const fileCount = attachment.files.length; + attachment: DiagnosticsAttachment + verbose: boolean +} + +export function DiagnosticsDisplay({ + attachment, + verbose, +}: DiagnosticsDisplayProps): React.ReactNode { + // Only show if there are diagnostics to report + if (attachment.files.length === 0) return null + + // Count total issues + const totalIssues = attachment.files.reduce( + (sum, file) => sum + file.diagnostics.length, + 0, + ) + + const fileCount = attachment.files.length + if (verbose) { - let t2; - if ($[2] !== attachment.files) { - t2 = attachment.files.map(_temp3); - $[2] = attachment.files; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== t2) { - t3 = {t2}; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - return t3; + // Show all diagnostics in verbose mode (ctrl+o) + return ( + + {attachment.files.map((file, fileIndex) => ( + + + + + {relative( + getCwd(), + file.uri + .replace('file://', '') + .replace('_claude_fs_right:', ''), + )} + {' '} + + {file.uri.startsWith('file://') + ? '(file://)' + : file.uri.startsWith('_claude_fs_right:') + ? '(claude_fs_right)' + : `(${file.uri.split(':')[0]})`} + + : + + + {file.diagnostics.map((diagnostic, diagIndex) => ( + + + {' '} + {DiagnosticTrackingService.getSeveritySymbol( + diagnostic.severity, + )} + {' [Line '} + {diagnostic.range.start.line + 1}: + {diagnostic.range.start.character + 1} + {'] '} + {diagnostic.message} + {diagnostic.code ? ` [${diagnostic.code}]` : ''} + {diagnostic.source ? ` (${diagnostic.source})` : ''} + + + ))} + + ))} + + ) } else { - let t2; - if ($[6] !== totalIssues) { - t2 = {totalIssues}; - $[6] = totalIssues; - $[7] = t2; - } else { - t2 = $[7]; - } - const t3 = totalIssues === 1 ? "issue" : "issues"; - const t4 = fileCount === 1 ? "file" : "files"; - let t5; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t5 = ; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== fileCount || $[10] !== t2 || $[11] !== t3 || $[12] !== t4) { - t6 = Found {t2} new diagnostic{" "}{t3} in {fileCount}{" "}{t4} {t5}; - $[9] = fileCount; - $[10] = t2; - $[11] = t3; - $[12] = t4; - $[13] = t6; - } else { - t6 = $[13]; - } - return t6; + // Show summary in normal mode + return ( + + + Found {totalIssues} new diagnostic{' '} + {totalIssues === 1 ? 'issue' : 'issues'} in {fileCount}{' '} + {fileCount === 1 ? 'file' : 'files'} + + + ) } } -function _temp3(file_0, fileIndex) { - return {relative(getCwd(), file_0.uri.replace("file://", "").replace("_claude_fs_right:", ""))}{" "}{file_0.uri.startsWith("file://") ? "(file://)" : file_0.uri.startsWith("_claude_fs_right:") ? "(claude_fs_right)" : `(${file_0.uri.split(":")[0]})`}:{file_0.diagnostics.map(_temp2)}; -} -function _temp2(diagnostic, diagIndex) { - return {" "}{DiagnosticTrackingService.getSeveritySymbol(diagnostic.severity)}{" [Line "}{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}{"] "}{diagnostic.message}{diagnostic.code ? ` [${diagnostic.code}]` : ""}{diagnostic.source ? ` (${diagnostic.source})` : ""}; -} -function _temp(sum, file) { - return sum + file.diagnostics.length; -} diff --git a/src/components/EffortCallout.tsx b/src/components/EffortCallout.tsx index 5b352fd4d..00feda439 100644 --- a/src/components/EffortCallout.tsx +++ b/src/components/EffortCallout.tsx @@ -1,211 +1,125 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useEffect, useRef } from 'react'; -import { Box, Text } from '../ink.js'; -import { isMaxSubscriber, isProSubscriber, isTeamSubscriber } from '../utils/auth.js'; -import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'; -import type { EffortLevel } from '../utils/effort.js'; -import { convertEffortValueToLevel, getDefaultEffortForModel, getOpusDefaultEffortConfig, toPersistableEffort } from '../utils/effort.js'; -import { parseUserSpecifiedModel } from '../utils/model/model.js'; -import { updateSettingsForSource } from '../utils/settings/settings.js'; -import type { OptionWithDescription } from './CustomSelect/select.js'; -import { Select } from './CustomSelect/select.js'; -import { effortLevelToSymbol } from './EffortIndicator.js'; -import { PermissionDialog } from './permissions/PermissionDialog.js'; -type EffortCalloutSelection = EffortLevel | undefined | 'dismiss'; +import React, { useCallback, useEffect, useRef } from 'react' +import { Box, Text } from '../ink.js' +import { + isMaxSubscriber, + isProSubscriber, + isTeamSubscriber, +} from '../utils/auth.js' +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' +import type { EffortLevel } from '../utils/effort.js' +import { + convertEffortValueToLevel, + getDefaultEffortForModel, + getOpusDefaultEffortConfig, + toPersistableEffort, +} from '../utils/effort.js' +import { parseUserSpecifiedModel } from '../utils/model/model.js' +import { updateSettingsForSource } from '../utils/settings/settings.js' +import type { OptionWithDescription } from './CustomSelect/select.js' +import { Select } from './CustomSelect/select.js' +import { effortLevelToSymbol } from './EffortIndicator.js' +import { PermissionDialog } from './permissions/PermissionDialog.js' + +type EffortCalloutSelection = EffortLevel | undefined | 'dismiss' + type Props = { - model: string; - onDone: (selection: EffortCalloutSelection) => void; -}; -const AUTO_DISMISS_MS = 30_000; -export function EffortCallout(t0) { - const $ = _c(18); - const { - model, - onDone - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getOpusDefaultEffortConfig(); - $[0] = t1; - } else { - t1 = $[0]; - } - const defaultEffortConfig = t1; - const onDoneRef = useRef(onDone); - let t2; - if ($[1] !== onDone) { - t2 = () => { - onDoneRef.current = onDone; - }; - $[1] = onDone; - $[2] = t2; - } else { - t2 = $[2]; - } - useEffect(t2); - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = () => { - onDoneRef.current("dismiss"); - }; - $[3] = t3; - } else { - t3 = $[3]; - } - const handleCancel = t3; - let t4; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t4 = []; - $[4] = t4; - } else { - t4 = $[4]; - } - useEffect(_temp, t4); - let t5; - let t6; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t5 = () => { - const timeoutId = setTimeout(handleCancel, AUTO_DISMISS_MS); - return () => clearTimeout(timeoutId); - }; - t6 = [handleCancel]; - $[5] = t5; - $[6] = t6; - } else { - t5 = $[5]; - t6 = $[6]; - } - useEffect(t5, t6); - let t7; - if ($[7] !== model) { - const defaultEffort = getDefaultEffortForModel(model); - t7 = defaultEffort ? convertEffortValueToLevel(defaultEffort) : "high"; - $[7] = model; - $[8] = t7; - } else { - t7 = $[8]; - } - const defaultLevel = t7; - let t8; - if ($[9] !== defaultLevel) { - t8 = value => { - const effortLevel = value === defaultLevel ? undefined : value; - updateSettingsForSource("userSettings", { - effortLevel: toPersistableEffort(effortLevel) - }); - onDoneRef.current(value); - }; - $[9] = defaultLevel; - $[10] = t8; - } else { - t8 = $[10]; - } - const handleSelect = t8; - let t9; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t9 = [{ + model: string + onDone: (selection: EffortCalloutSelection) => void +} + +const AUTO_DISMISS_MS = 30_000 + +export function EffortCallout({ model, onDone }: Props): React.ReactNode { + const defaultEffortConfig = getOpusDefaultEffortConfig() + // Latest-ref pattern — write via effect so React Compiler can memoize. + const onDoneRef = useRef(onDone) + useEffect(() => { + onDoneRef.current = onDone + }) + + const handleCancel = useCallback((): void => { + onDoneRef.current('dismiss') + }, []) + + // Permanently dismiss on mount so it only shows once + useEffect(() => { + markV2Dismissed() + }, []) + + // 30-second auto-dismiss timer + useEffect(() => { + const timeoutId = setTimeout(handleCancel, AUTO_DISMISS_MS) + return () => clearTimeout(timeoutId) + }, [handleCancel]) + + const defaultEffort = getDefaultEffortForModel(model) + const defaultLevel = defaultEffort + ? convertEffortValueToLevel(defaultEffort) + : 'high' + + const handleSelect = useCallback( + (value: EffortLevel): void => { + const effortLevel = value === defaultLevel ? undefined : value + updateSettingsForSource('userSettings', { + effortLevel: toPersistableEffort(effortLevel), + }) + onDoneRef.current(value) + }, + [defaultLevel], + ) + + const options: OptionWithDescription[] = [ + { label: , - value: "medium" - }, { - label: , - value: "high" - }, { - label: , - value: "low" - }]; - $[11] = t9; - } else { - t9 = $[11]; - } - const options = t9; - let t10; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t10 = {defaultEffortConfig.dialogDescription}; - $[12] = t10; - } else { - t10 = $[12]; - } - let t11; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t11 = ; - $[13] = t11; - } else { - t11 = $[13]; - } - let t12; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t12 = ; - $[14] = t12; - } else { - t12 = $[14]; - } - let t13; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t13 = {t11} low {"\xB7"}{" "}{t12} medium {"\xB7"}{" "} high; - $[15] = t13; - } else { - t13 = $[15]; - } - let t14; - if ($[16] !== handleSelect) { - t14 = {t10}{t13} + + + ) } -function _temp() { - markV2Dismissed(); + +function EffortIndicatorSymbol({ + level, +}: { + level: EffortLevel +}): React.ReactNode { + return {effortLevelToSymbol(level)} } -function EffortIndicatorSymbol(t0) { - const $ = _c(4); - const { - level - } = t0; - let t1; - if ($[0] !== level) { - t1 = effortLevelToSymbol(level); - $[0] = level; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== t1) { - t2 = {t1}; - $[2] = t1; - $[3] = t2; - } else { - t2 = $[3]; - } - return t2; -} -function EffortOptionLabel(t0) { - const $ = _c(5); - const { - level, - text - } = t0; - let t1; - if ($[0] !== level) { - t1 = ; - $[0] = level; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== t1 || $[3] !== text) { - t2 = <>{t1} {text}; - $[2] = t1; - $[3] = text; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; + +function EffortOptionLabel({ + level, + text, +}: { + level: EffortLevel + text: string +}): React.ReactNode { + return ( + <> + {text} + + ) } /** @@ -218,47 +132,46 @@ function EffortOptionLabel(t0) { */ export function shouldShowEffortCallout(model: string): boolean { // Only show for Opus 4.6 for now - const parsed = parseUserSpecifiedModel(model); + const parsed = parseUserSpecifiedModel(model) if (!parsed.toLowerCase().includes('opus-4-6')) { - return false; + return false } - const config = getGlobalConfig(); - if (config.effortCalloutV2Dismissed) return false; + + const config = getGlobalConfig() + if (config.effortCalloutV2Dismissed) return false // Don't show to brand-new users — they never knew the old default, so this // isn't a change for them. Mark as dismissed so it stays suppressed. if (config.numStartups <= 1) { - markV2Dismissed(); - return false; + markV2Dismissed() + return false } // Pro users already had medium default before this PR. Show the new copy, // but skip if they already saw the v1 dialog — no point nagging twice. if (isProSubscriber()) { if (config.effortCalloutDismissed) { - markV2Dismissed(); - return false; + markV2Dismissed() + return false } - return getOpusDefaultEffortConfig().enabled; + return getOpusDefaultEffortConfig().enabled } // Max/Team are the target of the tengu_grey_step2 config. // Don't mark dismissed when config is disabled — they should see the dialog // once it's enabled for them. if (isMaxSubscriber() || isTeamSubscriber()) { - return getOpusDefaultEffortConfig().enabled; + return getOpusDefaultEffortConfig().enabled } // Everyone else (free tier, API key, non-subscribers): not in scope. - markV2Dismissed(); - return false; + markV2Dismissed() + return false } + function markV2Dismissed(): void { saveGlobalConfig(current => { - if (current.effortCalloutV2Dismissed) return current; - return { - ...current, - effortCalloutV2Dismissed: true - }; - }); + if (current.effortCalloutV2Dismissed) return current + return { ...current, effortCalloutV2Dismissed: true } + }) } diff --git a/src/components/ExitFlow.tsx b/src/components/ExitFlow.tsx index c4e5cff52..c2e527054 100644 --- a/src/components/ExitFlow.tsx +++ b/src/components/ExitFlow.tsx @@ -1,47 +1,33 @@ -import { c as _c } from "react/compiler-runtime"; -import sample from 'lodash-es/sample.js'; -import React from 'react'; -import { gracefulShutdown } from '../utils/gracefulShutdown.js'; -import { WorktreeExitDialog } from './WorktreeExitDialog.js'; -const GOODBYE_MESSAGES = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!']; +import sample from 'lodash-es/sample.js' +import React from 'react' +import { gracefulShutdown } from '../utils/gracefulShutdown.js' +import { WorktreeExitDialog } from './WorktreeExitDialog.js' + +const GOODBYE_MESSAGES = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!'] + function getRandomGoodbyeMessage(): string { - return sample(GOODBYE_MESSAGES) ?? 'Goodbye!'; + return sample(GOODBYE_MESSAGES) ?? 'Goodbye!' } + type Props = { - onDone: (message?: string) => void; - onCancel?: () => void; - showWorktree: boolean; -}; -export function ExitFlow(t0) { - const $ = _c(5); - const { - showWorktree, - onDone, - onCancel - } = t0; - let t1; - if ($[0] !== onDone) { - t1 = async function onExit(resultMessage) { - onDone(resultMessage ?? getRandomGoodbyeMessage()); - await gracefulShutdown(0, "prompt_input_exit"); - }; - $[0] = onDone; - $[1] = t1; - } else { - t1 = $[1]; - } - const onExit = t1; - if (showWorktree) { - let t2; - if ($[2] !== onCancel || $[3] !== onExit) { - t2 = ; - $[2] = onCancel; - $[3] = onExit; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; - } - return null; + onDone: (message?: string) => void + onCancel?: () => void + showWorktree: boolean +} + +export function ExitFlow({ + showWorktree, + onDone, + onCancel, +}: Props): React.ReactNode { + async function onExit(resultMessage?: string) { + onDone(resultMessage ?? getRandomGoodbyeMessage()) + await gracefulShutdown(0, 'prompt_input_exit') + } + + if (showWorktree) { + return + } + + return null } diff --git a/src/components/ExportDialog.tsx b/src/components/ExportDialog.tsx index 872481760..f4f1560a4 100644 --- a/src/components/ExportDialog.tsx +++ b/src/components/ExportDialog.tsx @@ -1,127 +1,173 @@ -import { join } from 'path'; -import React, { useCallback, useState } from 'react'; -import type { ExitState } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { setClipboard } from '../ink/termio/osc.js'; -import { Box, Text } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { getCwd } from '../utils/cwd.js'; -import { writeFileSync_DEPRECATED } from '../utils/slowOperations.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { Select } from './CustomSelect/select.js'; -import { Byline } from './design-system/Byline.js'; -import { Dialog } from './design-system/Dialog.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import TextInput from './TextInput.js'; +import { join } from 'path' +import React, { useCallback, useState } from 'react' +import type { ExitState } from '../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { setClipboard } from '../ink/termio/osc.js' +import { Box, Text } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { getCwd } from '../utils/cwd.js' +import { writeFileSync_DEPRECATED } from '../utils/slowOperations.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { Select } from './CustomSelect/select.js' +import { Byline } from './design-system/Byline.js' +import { Dialog } from './design-system/Dialog.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import TextInput from './TextInput.js' + type ExportDialogProps = { - content: string; - defaultFilename: string; - onDone: (result: { - success: boolean; - message: string; - }) => void; -}; -type ExportOption = 'clipboard' | 'file'; + content: string + defaultFilename: string + onDone: (result: { success: boolean; message: string }) => void +} + +type ExportOption = 'clipboard' | 'file' + export function ExportDialog({ content, defaultFilename, - onDone + onDone, }: ExportDialogProps): React.ReactNode { - const [, setSelectedOption] = useState(null); - const [filename, setFilename] = useState(defaultFilename); - const [cursorOffset, setCursorOffset] = useState(defaultFilename.length); - const [showFilenameInput, setShowFilenameInput] = useState(false); - const { - columns - } = useTerminalSize(); + const [, setSelectedOption] = useState(null) + const [filename, setFilename] = useState(defaultFilename) + const [cursorOffset, setCursorOffset] = useState( + defaultFilename.length, + ) + const [showFilenameInput, setShowFilenameInput] = useState(false) + const { columns } = useTerminalSize() // Handle going back from filename input to option selection const handleGoBack = useCallback(() => { - setShowFilenameInput(false); - setSelectedOption(null); - }, []); + setShowFilenameInput(false) + setSelectedOption(null) + }, []) + const handleSelectOption = async (value: string): Promise => { if (value === 'clipboard') { // Copy to clipboard immediately - const raw = await setClipboard(content); - if (raw) process.stdout.write(raw); - onDone({ - success: true, - message: 'Conversation copied to clipboard' - }); + const raw = await setClipboard(content) + if (raw) process.stdout.write(raw) + onDone({ success: true, message: 'Conversation copied to clipboard' }) } else if (value === 'file') { - setSelectedOption('file'); - setShowFilenameInput(true); + setSelectedOption('file') + setShowFilenameInput(true) } - }; + } + const handleFilenameSubmit = () => { - const finalFilename = filename.endsWith('.txt') ? filename : filename.replace(/\.[^.]+$/, '') + '.txt'; - const filepath = join(getCwd(), finalFilename); + const finalFilename = filename.endsWith('.txt') + ? filename + : filename.replace(/\.[^.]+$/, '') + '.txt' + const filepath = join(getCwd(), finalFilename) + try { writeFileSync_DEPRECATED(filepath, content, { encoding: 'utf-8', - flush: true - }); + flush: true, + }) onDone({ success: true, - message: `Conversation exported to: ${filepath}` - }); + message: `Conversation exported to: ${filepath}`, + }) } catch (error) { onDone({ success: false, - message: `Failed to export conversation: ${error instanceof Error ? error.message : 'Unknown error'}` - }); + message: `Failed to export conversation: ${error instanceof Error ? error.message : 'Unknown error'}`, + }) } - }; + } // Dialog calls onCancel when Escape is pressed. If we are in the filename // input sub-screen, go back to the option list instead of closing entirely. const handleCancel = useCallback(() => { if (showFilenameInput) { - handleGoBack(); + handleGoBack() } else { - onDone({ - success: false, - message: 'Export cancelled' - }); + onDone({ success: false, message: 'Export cancelled' }) } - }, [showFilenameInput, handleGoBack, onDone]); - const options = [{ - label: 'Copy to clipboard', - value: 'clipboard', - description: 'Copy the conversation to your system clipboard' - }, { - label: 'Save to file', - value: 'file', - description: 'Save the conversation to a file in the current directory' - }]; + }, [showFilenameInput, handleGoBack, onDone]) + + const options = [ + { + label: 'Copy to clipboard', + value: 'clipboard', + description: 'Copy the conversation to your system clipboard', + }, + { + label: 'Save to file', + value: 'file', + description: 'Save the conversation to a file in the current directory', + }, + ] // Custom input guide that changes based on dialog state function renderInputGuide(exitState: ExitState): React.ReactNode { if (showFilenameInput) { - return + return ( + - - ; + + + ) } + if (exitState.pending) { - return Press {exitState.keyName} again to exit; + return Press {exitState.keyName} again to exit } - return ; + + return ( + + ) } // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in filename input) useKeybinding('confirm:no', handleCancel, { context: 'Settings', - isActive: showFilenameInput - }); - return - {!showFilenameInput ? + ) : ( + Enter filename: > - + - } - ; + + )} + + ) } diff --git a/src/components/FallbackToolUseErrorMessage.tsx b/src/components/FallbackToolUseErrorMessage.tsx index 0f38b351e..d86ac2b7c 100644 --- a/src/components/FallbackToolUseErrorMessage.tsx +++ b/src/components/FallbackToolUseErrorMessage.tsx @@ -1,115 +1,79 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/messages/messages.mjs'; -import * as React from 'react'; -import { stripUnderlineAnsi } from 'src/components/shell/OutputLine.js'; -import { extractTag } from 'src/utils/messages.js'; -import { removeSandboxViolationTags } from 'src/utils/sandbox/sandbox-ui-utils.js'; -import { Box, Text } from '../ink.js'; -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; -import { countCharInString } from '../utils/stringUtils.js'; -import { MessageResponse } from './MessageResponse.js'; -const MAX_RENDERED_LINES = 10; +import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/messages/messages.mjs' +import * as React from 'react' +import { stripUnderlineAnsi } from 'src/components/shell/OutputLine.js' +import { extractTag } from 'src/utils/messages.js' +import { removeSandboxViolationTags } from 'src/utils/sandbox/sandbox-ui-utils.js' +import { Box, Text } from '../ink.js' +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' +import { countCharInString } from '../utils/stringUtils.js' +import { MessageResponse } from './MessageResponse.js' + +const MAX_RENDERED_LINES = 10 + type Props = { - result: ToolResultBlockParam['content']; - verbose: boolean; -}; -export function FallbackToolUseErrorMessage(t0) { - const $ = _c(25); - const { - result, - verbose - } = t0; - const transcriptShortcut = useShortcutDisplay("app:toggleTranscript", "Global", "ctrl+o"); - let T0; - let T1; - let T2; - let plusLines; - let t1; - let t2; - let t3; - if ($[0] !== result || $[1] !== verbose) { - let error; - if (typeof result !== "string") { - error = "Tool execution failed"; - } else { - const extractedError = extractTag(result, "tool_use_error") ?? result; - const withoutSandboxViolations = removeSandboxViolationTags(extractedError); - const withoutErrorTags = withoutSandboxViolations.replace(/<\/?error>/g, ""); - const trimmed = withoutErrorTags.trim(); - if (!verbose && trimmed.includes("InputValidationError: ")) { - error = "Invalid tool parameters"; - } else { - if (trimmed.startsWith("Error: ") || trimmed.startsWith("Cancelled: ")) { - error = trimmed; - } else { - error = `Error: ${trimmed}`; - } - } - } - plusLines = countCharInString(error, "\n") + 1 - MAX_RENDERED_LINES; - T2 = MessageResponse; - T1 = Box; - t3 = "column"; - T0 = Text; - t1 = "error"; - t2 = stripUnderlineAnsi(verbose ? error : error.split("\n").slice(0, MAX_RENDERED_LINES).join("\n")); - $[0] = result; - $[1] = verbose; - $[2] = T0; - $[3] = T1; - $[4] = T2; - $[5] = plusLines; - $[6] = t1; - $[7] = t2; - $[8] = t3; - } else { - T0 = $[2]; - T1 = $[3]; - T2 = $[4]; - plusLines = $[5]; - t1 = $[6]; - t2 = $[7]; - t3 = $[8]; - } - let t4; - if ($[9] !== T0 || $[10] !== t1 || $[11] !== t2) { - t4 = {t2}; - $[9] = T0; - $[10] = t1; - $[11] = t2; - $[12] = t4; - } else { - t4 = $[12]; - } - let t5; - if ($[13] !== plusLines || $[14] !== transcriptShortcut || $[15] !== verbose) { - t5 = !verbose && plusLines > 0 && … +{plusLines} {plusLines === 1 ? "line" : "lines"} ({transcriptShortcut} to see all); - $[13] = plusLines; - $[14] = transcriptShortcut; - $[15] = verbose; - $[16] = t5; - } else { - t5 = $[16]; - } - let t6; - if ($[17] !== T1 || $[18] !== t3 || $[19] !== t4 || $[20] !== t5) { - t6 = {t4}{t5}; - $[17] = T1; - $[18] = t3; - $[19] = t4; - $[20] = t5; - $[21] = t6; - } else { - t6 = $[21]; - } - let t7; - if ($[22] !== T2 || $[23] !== t6) { - t7 = {t6}; - $[22] = T2; - $[23] = t6; - $[24] = t7; - } else { - t7 = $[24]; - } - return t7; + result: ToolResultBlockParam['content'] + verbose: boolean +} + +export function FallbackToolUseErrorMessage({ + result, + verbose, +}: Props): React.ReactNode { + const transcriptShortcut = useShortcutDisplay( + 'app:toggleTranscript', + 'Global', + 'ctrl+o', + ) + let error: string + + if (typeof result !== 'string') { + error = 'Tool execution failed' + } else { + const extractedError = extractTag(result, 'tool_use_error') ?? result + // Remove sandbox_violations tags from error display (Claude still sees them in the tool result) + const withoutSandboxViolations = removeSandboxViolationTags(extractedError) + // Strip tags but keep their content (tags are for the model, not the UI) + const withoutErrorTags = withoutSandboxViolations.replace(/<\/?error>/g, '') + const trimmed = withoutErrorTags.trim() + if (!verbose && trimmed.includes('InputValidationError: ')) { + error = 'Invalid tool parameters' + } else if ( + trimmed.startsWith('Error: ') || + trimmed.startsWith('Cancelled: ') + ) { + error = trimmed + } else { + error = `Error: ${trimmed}` + } + } + + const plusLines = countCharInString(error, '\n') + 1 - MAX_RENDERED_LINES + + return ( + + + + {stripUnderlineAnsi( + verbose + ? error + : error.split('\n').slice(0, MAX_RENDERED_LINES).join('\n'), + )} + + {!verbose && plusLines > 0 && ( + // The careful layout is a workaround for the dim-bold + // rendering bug + + + … +{plusLines} {plusLines === 1 ? 'line' : 'lines'} ( + + + {transcriptShortcut} + + + to see all) + + )} + + + ) } diff --git a/src/components/FallbackToolUseRejectedMessage.tsx b/src/components/FallbackToolUseRejectedMessage.tsx index 0c7252527..95ec389cc 100644 --- a/src/components/FallbackToolUseRejectedMessage.tsx +++ b/src/components/FallbackToolUseRejectedMessage.tsx @@ -1,15 +1,11 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { InterruptedByUser } from './InterruptedByUser.js'; -import { MessageResponse } from './MessageResponse.js'; -export function FallbackToolUseRejectedMessage() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = ; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +import * as React from 'react' +import { InterruptedByUser } from './InterruptedByUser.js' +import { MessageResponse } from './MessageResponse.js' + +export function FallbackToolUseRejectedMessage(): React.ReactNode { + return ( + + + + ) } diff --git a/src/components/FastIcon.tsx b/src/components/FastIcon.tsx index 127d4e562..956c6290a 100644 --- a/src/components/FastIcon.tsx +++ b/src/components/FastIcon.tsx @@ -1,45 +1,33 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import * as React from 'react'; -import { LIGHTNING_BOLT } from '../constants/figures.js'; -import { Text } from '../ink.js'; -import { getGlobalConfig } from '../utils/config.js'; -import { resolveThemeSetting } from '../utils/systemTheme.js'; -import { color } from './design-system/color.js'; +import chalk from 'chalk' +import * as React from 'react' +import { LIGHTNING_BOLT } from '../constants/figures.js' +import { Text } from '../ink.js' +import { getGlobalConfig } from '../utils/config.js' +import { resolveThemeSetting } from '../utils/systemTheme.js' +import { color } from './design-system/color.js' + type Props = { - cooldown?: boolean; -}; -export function FastIcon(t0) { - const $ = _c(2); - const { - cooldown - } = t0; - if (cooldown) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {LIGHTNING_BOLT}; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {LIGHTNING_BOLT}; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; + cooldown?: boolean } + +export function FastIcon({ cooldown }: Props): React.ReactNode { + if (cooldown) { + return ( + + {LIGHTNING_BOLT} + + ) + } + return {LIGHTNING_BOLT} +} + export function getFastIconString(applyColor = true, cooldown = false): string { if (!applyColor) { - return LIGHTNING_BOLT; + return LIGHTNING_BOLT } - const themeName = resolveThemeSetting(getGlobalConfig().theme); + const themeName = resolveThemeSetting(getGlobalConfig().theme) if (cooldown) { - return chalk.dim(color('promptBorder', themeName)(LIGHTNING_BOLT)); + return chalk.dim(color('promptBorder', themeName)(LIGHTNING_BOLT)) } - return color('fastMode', themeName)(LIGHTNING_BOLT); + return color('fastMode', themeName)(LIGHTNING_BOLT) } diff --git a/src/components/Feedback.tsx b/src/components/Feedback.tsx index 18603d613..5d3a3678f 100644 --- a/src/components/Feedback.tsx +++ b/src/components/Feedback.tsx @@ -1,213 +1,242 @@ -import axios from 'axios'; -import { readFile, stat } from 'fs/promises'; -import * as React from 'react'; -import { useCallback, useEffect, useState } from 'react'; -import { getLastAPIRequest } from 'src/bootstrap/state.js'; -import { logEventTo1P } from 'src/services/analytics/firstPartyEventLogger.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { getLastAssistantMessage, normalizeMessagesForAPI } from 'src/utils/messages.js'; -import type { CommandResultDisplay } from '../commands.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { Box, Text, useInput } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { queryHaiku } from '../services/api/claude.js'; -import { startsWithApiErrorPrefix } from '../services/api/errors.js'; -import type { Message } from '../types/message.js'; -import { checkAndRefreshOAuthTokenIfNeeded } from '../utils/auth.js'; -import { openBrowser } from '../utils/browser.js'; -import { logForDebugging } from '../utils/debug.js'; -import { env } from '../utils/env.js'; -import { type GitRepoState, getGitState, getIsGit } from '../utils/git.js'; -import { getAuthHeaders, getUserAgent } from '../utils/http.js'; -import { getInMemoryErrors, logError } from '../utils/log.js'; -import { isEssentialTrafficOnly } from '../utils/privacyLevel.js'; -import { extractTeammateTranscriptsFromTasks, getTranscriptPath, loadAllSubagentTranscriptsFromDisk, MAX_TRANSCRIPT_READ_BYTES } from '../utils/sessionStorage.js'; -import { jsonStringify } from '../utils/slowOperations.js'; -import { asSystemPrompt } from '../utils/systemPromptType.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { Byline } from './design-system/Byline.js'; -import { Dialog } from './design-system/Dialog.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import TextInput from './TextInput.js'; +import axios from 'axios' +import { readFile, stat } from 'fs/promises' +import * as React from 'react' +import { useCallback, useEffect, useState } from 'react' +import { getLastAPIRequest } from 'src/bootstrap/state.js' +import { logEventTo1P } from 'src/services/analytics/firstPartyEventLogger.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { + getLastAssistantMessage, + normalizeMessagesForAPI, +} from 'src/utils/messages.js' +import type { CommandResultDisplay } from '../commands.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { Box, Text, useInput } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { queryHaiku } from '../services/api/claude.js' +import { startsWithApiErrorPrefix } from '../services/api/errors.js' +import type { Message } from '../types/message.js' +import { checkAndRefreshOAuthTokenIfNeeded } from '../utils/auth.js' +import { openBrowser } from '../utils/browser.js' +import { logForDebugging } from '../utils/debug.js' +import { env } from '../utils/env.js' +import { type GitRepoState, getGitState, getIsGit } from '../utils/git.js' +import { getAuthHeaders, getUserAgent } from '../utils/http.js' +import { getInMemoryErrors, logError } from '../utils/log.js' +import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' +import { + extractTeammateTranscriptsFromTasks, + getTranscriptPath, + loadAllSubagentTranscriptsFromDisk, + MAX_TRANSCRIPT_READ_BYTES, +} from '../utils/sessionStorage.js' +import { jsonStringify } from '../utils/slowOperations.js' +import { asSystemPrompt } from '../utils/systemPromptType.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { Byline } from './design-system/Byline.js' +import { Dialog } from './design-system/Dialog.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import TextInput from './TextInput.js' // This value was determined experimentally by testing the URL length limit -const GITHUB_URL_LIMIT = 7250; -const GITHUB_ISSUES_REPO_URL = (process.env.USER_TYPE) === 'ant' ? 'https://github.com/anthropics/claude-cli-internal/issues' : 'https://github.com/anthropics/claude-code/issues'; +const GITHUB_URL_LIMIT = 7250 +const GITHUB_ISSUES_REPO_URL = + process.env.USER_TYPE === 'ant' + ? 'https://github.com/anthropics/claude-cli-internal/issues' + : 'https://github.com/anthropics/claude-code/issues' + type Props = { - abortSignal: AbortSignal; - messages: Message[]; - initialDescription?: string; - onDone(result: string, options?: { - display?: CommandResultDisplay; - }): void; + abortSignal: AbortSignal + messages: Message[] + initialDescription?: string + onDone(result: string, options?: { display?: CommandResultDisplay }): void backgroundTasks?: { [taskId: string]: { - type: string; - identity?: { - agentId: string; - }; - messages?: Message[]; - }; - }; -}; -type Step = 'userInput' | 'consent' | 'submitting' | 'done'; + type: string + identity?: { agentId: string } + messages?: Message[] + } + } +} + +type Step = 'userInput' | 'consent' | 'submitting' | 'done' + type FeedbackData = { // latestAssistantMessageId is the message ID from the latest main model call - latestAssistantMessageId: string | null; - message_count: number; - datetime: string; - description: string; - platform: string; - gitRepo: boolean; - terminal: string; - version: string | null; - transcript: Message[]; - errors: unknown; - lastApiRequest: unknown; - subagentTranscripts?: { - [agentId: string]: Message[]; - }; - rawTranscriptJsonl?: string; -}; + latestAssistantMessageId: string | null + message_count: number + datetime: string + description: string + platform: string + gitRepo: boolean + version: string | null + transcript: Message[] + subagentTranscripts?: { [agentId: string]: Message[] } + rawTranscriptJsonl?: string +} // Utility function to redact sensitive information from strings export function redactSensitiveInfo(text: string): string { - let redacted = text; + let redacted = text // Anthropic API keys (sk-ant...) with or without quotes // First handle the case with quotes - redacted = redacted.replace(/"(sk-ant[^\s"']{24,})"/g, '"[REDACTED_API_KEY]"'); + redacted = redacted.replace(/"(sk-ant[^\s"']{24,})"/g, '"[REDACTED_API_KEY]"') // Then handle the cases without quotes - more general pattern redacted = redacted.replace( - // eslint-disable-next-line custom-rules/no-lookbehind-regex -- .replace(re, string) on /bug path: no-match returns same string (Object.is) - /(? { // Sanitize error logs to remove any API keys return getInMemoryErrors().map(errorInfo => { // Create a copy of the error info to avoid modifying the original - const errorCopy = { - ...errorInfo - } as { - error?: string; - timestamp?: string; - }; + const errorCopy = { ...errorInfo } as { error?: string; timestamp?: string } // Sanitize error if present and is a string if (errorCopy && typeof errorCopy.error === 'string') { - errorCopy.error = redactSensitiveInfo(errorCopy.error); + errorCopy.error = redactSensitiveInfo(errorCopy.error) } - return errorCopy; - }); + + return errorCopy + }) } + async function loadRawTranscriptJsonl(): Promise { try { - const transcriptPath = getTranscriptPath(); - const { - size - } = await stat(transcriptPath); + const transcriptPath = getTranscriptPath() + const { size } = await stat(transcriptPath) if (size > MAX_TRANSCRIPT_READ_BYTES) { - logForDebugging(`Skipping raw transcript read: file too large (${size} bytes)`, { - level: 'warn' - }); - return null; + logForDebugging( + `Skipping raw transcript read: file too large (${size} bytes)`, + { level: 'warn' }, + ) + return null } - return await readFile(transcriptPath, 'utf-8'); + return await readFile(transcriptPath, 'utf-8') } catch { - return null; + return null } } + export function Feedback({ abortSignal, messages, initialDescription, onDone, - backgroundTasks = {} + backgroundTasks = {}, }: Props): React.ReactNode { - const [step, setStep] = useState('userInput'); - const [cursorOffset, setCursorOffset] = useState(0); - const [description, setDescription] = useState(initialDescription ?? ''); - const [feedbackId, setFeedbackId] = useState(null); - const [error, setError] = useState(null); + const [step, setStep] = useState('userInput') + const [cursorOffset, setCursorOffset] = useState(0) + const [description, setDescription] = useState(initialDescription ?? '') + const [feedbackId, setFeedbackId] = useState(null) + const [error, setError] = useState(null) const [envInfo, setEnvInfo] = useState<{ - isGit: boolean; - gitState: GitRepoState | null; - }>({ - isGit: false, - gitState: null - }); - const [title, setTitle] = useState(null); - const textInputColumns = useTerminalSize().columns - 4; + isGit: boolean + gitState: GitRepoState | null + }>({ isGit: false, gitState: null }) + const [title, setTitle] = useState(null) + const textInputColumns = useTerminalSize().columns - 4 + useEffect(() => { async function loadEnvInfo() { - const isGit = await getIsGit(); - let gitState: GitRepoState | null = null; + const isGit = await getIsGit() + let gitState: GitRepoState | null = null if (isGit) { - gitState = await getGitState(); + gitState = await getGitState() } - setEnvInfo({ - isGit, - gitState - }); + setEnvInfo({ isGit, gitState }) } - void loadEnvInfo(); - }, []); + void loadEnvInfo() + }, []) + const submitReport = useCallback(async () => { - setStep('submitting'); - setError(null); - setFeedbackId(null); + setStep('submitting') + setError(null) + setFeedbackId(null) // Get sanitized errors for the report - const sanitizedErrors = getSanitizedErrorLogs(); + const sanitizedErrors = getSanitizedErrorLogs() // Extract last assistant message ID from messages array - const lastAssistantMessage = getLastAssistantMessage(messages); - const lastAssistantMessageId = lastAssistantMessage?.requestId ?? null; - const [diskTranscripts, rawTranscriptJsonl] = await Promise.all([loadAllSubagentTranscriptsFromDisk(), loadRawTranscriptJsonl()]); - const teammateTranscripts = extractTeammateTranscriptsFromTasks(backgroundTasks); - const subagentTranscripts = { - ...diskTranscripts, - ...teammateTranscripts - }; - const reportData: FeedbackData = { - latestAssistantMessageId: lastAssistantMessageId as string | null, + const lastAssistantMessage = getLastAssistantMessage(messages) + const lastAssistantMessageId = lastAssistantMessage?.requestId ?? null + + const [diskTranscripts, rawTranscriptJsonl] = await Promise.all([ + loadAllSubagentTranscriptsFromDisk(), + loadRawTranscriptJsonl(), + ]) + const teammateTranscripts = + extractTeammateTranscriptsFromTasks(backgroundTasks) + const subagentTranscripts = { ...diskTranscripts, ...teammateTranscripts } + + const reportData = { + latestAssistantMessageId: lastAssistantMessageId, message_count: messages.length, datetime: new Date().toISOString(), description, @@ -219,38 +248,49 @@ export function Feedback({ errors: sanitizedErrors, lastApiRequest: getLastAPIRequest(), ...(Object.keys(subagentTranscripts).length > 0 && { - subagentTranscripts + subagentTranscripts, }), - ...(rawTranscriptJsonl && { - rawTranscriptJsonl - }) - }; - const [result, t] = await Promise.all([submitFeedback(reportData, abortSignal), generateTitle(description, abortSignal)]); - setTitle(t); + ...(rawTranscriptJsonl && { rawTranscriptJsonl }), + } + + const [result, t] = await Promise.all([ + submitFeedback(reportData, abortSignal), + generateTitle(description, abortSignal), + ]) + + setTitle(t) + if (result.success) { if (result.feedbackId) { - setFeedbackId(result.feedbackId); + setFeedbackId(result.feedbackId) logEvent('tengu_bug_report_submitted', { - feedback_id: result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - last_assistant_message_id: lastAssistantMessageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + feedback_id: + result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + last_assistant_message_id: + lastAssistantMessageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) // 1P-only: freeform text approved for BQ. Join on feedback_id. logEventTo1P('tengu_bug_report_description', { - feedback_id: result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - description: redactSensitiveInfo(description) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + feedback_id: + result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + description: redactSensitiveInfo( + description, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } - setStep('done'); + setStep('done') } else { if (result.isZdrOrg) { - setError('Feedback collection is not available for organizations with custom data retention policies.'); + setError( + 'Feedback collection is not available for organizations with custom data retention policies.', + ) } else { - setError('Could not submit feedback. Please try again later.'); + setError('Could not submit feedback. Please try again later.') } // Stay on userInput step so user can retry with their content preserved - setStep('userInput'); + setStep('userInput') } - }, [description, envInfo.isGit, messages]); + }, [description, envInfo.isGit, messages]) // Handle cancel - this will be called by Dialog's automatic Esc handling const handleCancel = useCallback(() => { @@ -258,85 +298,125 @@ export function Feedback({ if (step === 'done') { if (error) { onDone('Error submitting feedback / bug report', { - display: 'system' - }); + display: 'system', + }) } else { - onDone('Feedback / bug report submitted', { - display: 'system' - }); + onDone('Feedback / bug report submitted', { display: 'system' }) } - return; + return } - onDone('Feedback / bug report cancelled', { - display: 'system' - }); - }, [step, error, onDone]); + onDone('Feedback / bug report cancelled', { display: 'system' }) + }, [step, error, onDone]) // During text input, use Settings context where only Escape (not 'n') triggers confirm:no. // This allows typing 'n' in the text field while still supporting Escape to cancel. useKeybinding('confirm:no', handleCancel, { context: 'Settings', - isActive: step === 'userInput' - }); + isActive: step === 'userInput', + }) + useInput((input, key) => { // Allow any key press to close the dialog when done or when there's an error if (step === 'done') { if (key.return && title) { // Open GitHub issue URL when Enter is pressed - const issueUrl = createGitHubIssueUrl(feedbackId ?? '', title, description, getSanitizedErrorLogs()); - void openBrowser(issueUrl); + const issueUrl = createGitHubIssueUrl( + feedbackId ?? '', + title, + description, + getSanitizedErrorLogs(), + ) + void openBrowser(issueUrl) } if (error) { onDone('Error submitting feedback / bug report', { - display: 'system' - }); + display: 'system', + }) } else { - onDone('Feedback / bug report submitted', { - display: 'system' - }); + onDone('Feedback / bug report submitted', { display: 'system' }) } - return; + return } // When in userInput step with error, allow user to edit and retry // (don't close on any keypress - they can still press Esc to cancel) if (error && step !== 'userInput') { onDone('Error submitting feedback / bug report', { - display: 'system' - }); - return; + display: 'system', + }) + return } + if (step === 'consent' && (key.return || input === ' ')) { - void submitReport(); + void submitReport() } - }); - return exitState.pending ? Press {exitState.keyName} again to exit : step === 'userInput' ? + }) + + return ( + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : step === 'userInput' ? ( + - - : step === 'consent' ? + + + ) : step === 'consent' ? ( + - - : null}> - {step === 'userInput' && + + + ) : null + } + > + {step === 'userInput' && ( + Describe the issue below: - { - setDescription(value); - // Clear error when user starts editing to allow retry - if (error) { - setError(null); - } - }} columns={textInputColumns} onSubmit={() => setStep('consent')} onExitMessage={() => onDone('Feedback cancelled', { - display: 'system' - })} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} showCursor /> - {error && + { + setDescription(value) + // Clear error when user starts editing to allow retry + if (error) { + setError(null) + } + }} + columns={textInputColumns} + onSubmit={() => setStep('consent')} + onExitMessage={() => + onDone('Feedback cancelled', { display: 'system' }) + } + cursorOffset={cursorOffset} + onChangeCursorOffset={setCursorOffset} + showCursor + /> + {error && ( + {error} Edit and press Enter to retry, or Esc to cancel - } - } + + )} + + )} - {step === 'consent' && + {step === 'consent' && ( + This report will include: @@ -349,16 +429,22 @@ export function Feedback({ {env.platform}, {env.terminal}, v{MACRO.VERSION} - {envInfo.gitState && + {envInfo.gitState && ( + - Git repo metadata:{' '} {envInfo.gitState.branchName} - {envInfo.gitState.commitHash ? `, ${envInfo.gitState.commitHash.slice(0, 7)}` : ''} - {envInfo.gitState.remoteUrl ? ` @ ${envInfo.gitState.remoteUrl}` : ''} + {envInfo.gitState.commitHash + ? `, ${envInfo.gitState.commitHash.slice(0, 7)}` + : ''} + {envInfo.gitState.remoteUrl + ? ` @ ${envInfo.gitState.remoteUrl}` + : ''} {!envInfo.gitState.isHeadOnRemote && ', not synced'} {!envInfo.gitState.isClean && ', has local changes'} - } + + )} - Current session transcript @@ -373,14 +459,22 @@ export function Feedback({ Press Enter to confirm and submit. - } + + )} - {step === 'submitting' && + {step === 'submitting' && ( + Submitting report… - } + + )} - {step === 'done' && - {error ? {error} : Thank you for your report!} + {step === 'done' && ( + + {error ? ( + {error} + ) : ( + Thank you for your report! + )} {feedbackId && Feedback ID: {feedbackId}} Press @@ -390,67 +484,125 @@ export function Feedback({ close. - } - ; + + )} + + ) } -export function createGitHubIssueUrl(feedbackId: string, title: string, description: string, errors: Array<{ - error?: string; - timestamp?: string; -}>): string { - const sanitizedTitle = redactSensitiveInfo(title); - const sanitizedDescription = redactSensitiveInfo(description); - const bodyPrefix = `**Bug Description**\n${sanitizedDescription}\n\n` + `**Environment Info**\n` + `- Platform: ${env.platform}\n` + `- Terminal: ${env.terminal}\n` + `- Version: ${MACRO.VERSION || 'unknown'}\n` + `- Feedback ID: ${feedbackId}\n` + `\n**Errors**\n\`\`\`json\n`; - const errorSuffix = `\n\`\`\`\n`; - const errorsJson = jsonStringify(errors); - const baseUrl = `${GITHUB_ISSUES_REPO_URL}/new?title=${encodeURIComponent(sanitizedTitle)}&labels=user-reported,bug&body=`; - const truncationNote = `\n**Note:** Content was truncated.\n`; - const encodedPrefix = encodeURIComponent(bodyPrefix); - const encodedSuffix = encodeURIComponent(errorSuffix); - const encodedNote = encodeURIComponent(truncationNote); - const encodedErrors = encodeURIComponent(errorsJson); + +export function createGitHubIssueUrl( + feedbackId: string, + title: string, + description: string, + errors: Array<{ + error?: string + timestamp?: string + }>, +): string { + const sanitizedTitle = redactSensitiveInfo(title) + const sanitizedDescription = redactSensitiveInfo(description) + + const bodyPrefix = + `**Bug Description**\n${sanitizedDescription}\n\n` + + `**Environment Info**\n` + + `- Platform: ${env.platform}\n` + + `- Terminal: ${env.terminal}\n` + + `- Version: ${MACRO.VERSION || 'unknown'}\n` + + `- Feedback ID: ${feedbackId}\n` + + `\n**Errors**\n\`\`\`json\n` + const errorSuffix = `\n\`\`\`\n` + const errorsJson = jsonStringify(errors) + + const baseUrl = `${GITHUB_ISSUES_REPO_URL}/new?title=${encodeURIComponent(sanitizedTitle)}&labels=user-reported,bug&body=` + const truncationNote = `\n**Note:** Content was truncated.\n` + + const encodedPrefix = encodeURIComponent(bodyPrefix) + const encodedSuffix = encodeURIComponent(errorSuffix) + const encodedNote = encodeURIComponent(truncationNote) + const encodedErrors = encodeURIComponent(errorsJson) // Calculate space available for errors - const spaceForErrors = GITHUB_URL_LIMIT - baseUrl.length - encodedPrefix.length - encodedSuffix.length - encodedNote.length; + const spaceForErrors = + GITHUB_URL_LIMIT - + baseUrl.length - + encodedPrefix.length - + encodedSuffix.length - + encodedNote.length // If description alone exceeds limit, truncate everything if (spaceForErrors <= 0) { - const ellipsis = encodeURIComponent('…'); - const buffer = 50; // Extra safety margin - const maxEncodedLength = GITHUB_URL_LIMIT - baseUrl.length - ellipsis.length - encodedNote.length - buffer; - const fullBody = bodyPrefix + errorsJson + errorSuffix; - let encodedFullBody = encodeURIComponent(fullBody); + const ellipsis = encodeURIComponent('…') + const buffer = 50 // Extra safety margin + const maxEncodedLength = + GITHUB_URL_LIMIT - + baseUrl.length - + ellipsis.length - + encodedNote.length - + buffer + const fullBody = bodyPrefix + errorsJson + errorSuffix + let encodedFullBody = encodeURIComponent(fullBody) + if (encodedFullBody.length > maxEncodedLength) { - encodedFullBody = encodedFullBody.slice(0, maxEncodedLength); + encodedFullBody = encodedFullBody.slice(0, maxEncodedLength) // Don't cut in middle of %XX sequence - const lastPercent = encodedFullBody.lastIndexOf('%'); + const lastPercent = encodedFullBody.lastIndexOf('%') if (lastPercent >= encodedFullBody.length - 2) { - encodedFullBody = encodedFullBody.slice(0, lastPercent); + encodedFullBody = encodedFullBody.slice(0, lastPercent) } } - return baseUrl + encodedFullBody + ellipsis + encodedNote; + + return baseUrl + encodedFullBody + ellipsis + encodedNote } // If errors fit, no truncation needed if (encodedErrors.length <= spaceForErrors) { - return baseUrl + encodedPrefix + encodedErrors + encodedSuffix; + return baseUrl + encodedPrefix + encodedErrors + encodedSuffix } // Truncate errors to fit (prioritize keeping description) // Slice encoded errors directly, then trim to avoid cutting %XX sequences - const ellipsis = encodeURIComponent('…'); - const buffer = 50; // Extra safety margin - let truncatedEncodedErrors = encodedErrors.slice(0, spaceForErrors - ellipsis.length - buffer); + const ellipsis = encodeURIComponent('…') + const buffer = 50 // Extra safety margin + let truncatedEncodedErrors = encodedErrors.slice( + 0, + spaceForErrors - ellipsis.length - buffer, + ) // If we cut in middle of %XX, back up to before the % - const lastPercent = truncatedEncodedErrors.lastIndexOf('%'); + const lastPercent = truncatedEncodedErrors.lastIndexOf('%') if (lastPercent >= truncatedEncodedErrors.length - 2) { - truncatedEncodedErrors = truncatedEncodedErrors.slice(0, lastPercent); + truncatedEncodedErrors = truncatedEncodedErrors.slice(0, lastPercent) } - return baseUrl + encodedPrefix + truncatedEncodedErrors + ellipsis + encodedSuffix + encodedNote; + + return ( + baseUrl + + encodedPrefix + + truncatedEncodedErrors + + ellipsis + + encodedSuffix + + encodedNote + ) } -async function generateTitle(description: string, abortSignal: AbortSignal): Promise { + +async function generateTitle( + description: string, + abortSignal: AbortSignal, +): Promise { try { const response = await queryHaiku({ - systemPrompt: asSystemPrompt(['Generate a concise, technical issue title (max 80 chars) for a public GitHub issue based on this bug report for Claude Code.', 'Claude Code is an agentic coding CLI based on the Anthropic API.', 'The title should:', '- Include the type of issue [Bug] or [Feature Request] as the first thing in the title', '- Be concise, specific and descriptive of the actual problem', '- Use technical terminology appropriate for a software issue', '- For error messages, extract the key error (e.g., "Missing Tool Result Block" rather than the full message)', '- Be direct and clear for developers to understand the problem', '- If you cannot determine a clear issue, use "Bug Report: [brief description]"', '- Any LLM API errors are from the Anthropic API, not from any other model provider', 'Your response will be directly used as the title of the Github issue, and as such should not contain any other commentary or explaination', 'Examples of good titles include: "[Bug] Auto-Compact triggers to soon", "[Bug] Anthropic API Error: Missing Tool Result Block", "[Bug] Error: Invalid Model Name for Opus"']), + systemPrompt: asSystemPrompt([ + 'Generate a concise, technical issue title (max 80 chars) for a public GitHub issue based on this bug report for Claude Code.', + 'Claude Code is an agentic coding CLI based on the Anthropic API.', + 'The title should:', + '- Include the type of issue [Bug] or [Feature Request] as the first thing in the title', + '- Be concise, specific and descriptive of the actual problem', + '- Use technical terminology appropriate for a software issue', + '- For error messages, extract the key error (e.g., "Missing Tool Result Block" rather than the full message)', + '- Be direct and clear for developers to understand the problem', + '- If you cannot determine a clear issue, use "Bug Report: [brief description]"', + '- Any LLM API errors are from the Anthropic API, not from any other model provider', + 'Your response will be directly used as the title of the Github issue, and as such should not contain any other commentary or explaination', + 'Examples of good titles include: "[Bug] Auto-Compact triggers to soon", "[Bug] Anthropic API Error: Missing Tool Result Block", "[Bug] Error: Invalid Model Name for Opus"', + ]), userPrompt: description, signal: abortSignal, options: { @@ -459,137 +611,149 @@ async function generateTitle(description: string, abortSignal: AbortSignal): Pro isNonInteractiveSession: false, agents: [], querySource: 'feedback', - mcpTools: [] - } - }); - const firstBlock = Array.isArray(response.message.content) ? response.message.content[0] : undefined; - const title = firstBlock && typeof firstBlock !== 'string' && firstBlock.type === 'text' ? firstBlock.text : 'Bug Report'; + mcpTools: [], + }, + }) + + const title = + response.message.content[0]?.type === 'text' + ? response.message.content[0].text + : 'Bug Report' // Check if the title contains an API error message if (startsWithApiErrorPrefix(title)) { - return createFallbackTitle(description); + return createFallbackTitle(description) } - return title; + + return title } catch (error) { // If there's any error in title generation, use a fallback title - logError(error); - return createFallbackTitle(description); + logError(error) + return createFallbackTitle(description) } } + function createFallbackTitle(description: string): string { // Create a safe fallback title based on the bug description // Try to extract a meaningful title from the first line - const firstLine = description.split('\n')[0] || ''; + const firstLine = description.split('\n')[0] || '' // If the first line is very short, use it directly if (firstLine.length <= 60 && firstLine.length > 5) { - return firstLine; + return firstLine } // For longer descriptions, create a truncated version // Truncate at word boundaries when possible - let truncated = firstLine.slice(0, 60); + let truncated = firstLine.slice(0, 60) if (firstLine.length > 60) { // Find the last space before the 60 char limit - const lastSpace = truncated.lastIndexOf(' '); + const lastSpace = truncated.lastIndexOf(' ') if (lastSpace > 30) { // Only trim at word if we're not cutting too much - truncated = truncated.slice(0, lastSpace); + truncated = truncated.slice(0, lastSpace) } - truncated += '...'; + truncated += '...' } - return truncated.length < 10 ? 'Bug Report' : truncated; + + return truncated.length < 10 ? 'Bug Report' : truncated } // Helper function to sanitize and log errors without exposing API keys function sanitizeAndLogError(err: unknown): void { if (err instanceof Error) { // Create a copy with potentially sensitive info redacted - const safeError = new Error(redactSensitiveInfo(err.message)); + const safeError = new Error(redactSensitiveInfo(err.message)) // Also redact the stack trace if present if (err.stack) { - safeError.stack = redactSensitiveInfo(err.stack); + safeError.stack = redactSensitiveInfo(err.stack) } - logError(safeError); + + logError(safeError) } else { // For non-Error objects, convert to string and redact sensitive info - const errorString = redactSensitiveInfo(String(err)); - logError(new Error(errorString)); + const errorString = redactSensitiveInfo(String(err)) + logError(new Error(errorString)) } } -async function submitFeedback(data: FeedbackData, signal?: AbortSignal): Promise<{ - success: boolean; - feedbackId?: string; - isZdrOrg?: boolean; -}> { + +async function submitFeedback( + data: FeedbackData, + signal?: AbortSignal, +): Promise<{ success: boolean; feedbackId?: string; isZdrOrg?: boolean }> { if (isEssentialTrafficOnly()) { - return { - success: false - }; + return { success: false } } + try { // Ensure OAuth token is fresh before getting auth headers // This prevents 401 errors from stale cached tokens - await checkAndRefreshOAuthTokenIfNeeded(); - const authResult = getAuthHeaders(); + await checkAndRefreshOAuthTokenIfNeeded() + + const authResult = getAuthHeaders() if (authResult.error) { - return { - success: false - }; + return { success: false } } + const headers: Record = { 'Content-Type': 'application/json', 'User-Agent': getUserAgent(), - ...authResult.headers - }; - const response = await axios.post('https://api.anthropic.com/api/claude_cli_feedback', { - content: jsonStringify(data) - }, { - headers, - timeout: 30000, - // 30 second timeout to prevent hanging - signal - }); - if (response.status === 200) { - const result = response.data; - if (result?.feedback_id) { - return { - success: true, - feedbackId: result.feedback_id - }; - } - sanitizeAndLogError(new Error('Failed to submit feedback: request did not return feedback_id')); - return { - success: false - }; + ...authResult.headers, } - sanitizeAndLogError(new Error('Failed to submit feedback:' + response.status)); - return { - success: false - }; + + const response = await axios.post( + 'https://api.anthropic.com/api/claude_cli_feedback', + { + content: jsonStringify(data), + }, + { + headers, + timeout: 30000, // 30 second timeout to prevent hanging + signal, + }, + ) + + if (response.status === 200) { + const result = response.data + if (result?.feedback_id) { + return { success: true, feedbackId: result.feedback_id } + } + sanitizeAndLogError( + new Error( + 'Failed to submit feedback: request did not return feedback_id', + ), + ) + return { success: false } + } + + sanitizeAndLogError( + new Error('Failed to submit feedback:' + response.status), + ) + return { success: false } } catch (err) { // Handle cancellation/abort - don't log as error if (axios.isCancel(err)) { - return { - success: false - }; + return { success: false } } + if (axios.isAxiosError(err) && err.response?.status === 403) { - const errorData = err.response.data; - if (errorData?.error?.type === 'permission_error' && errorData?.error?.message?.includes('Custom data retention settings')) { - sanitizeAndLogError(new Error('Cannot submit feedback because custom data retention settings are enabled')); - return { - success: false, - isZdrOrg: true - }; + const errorData = err.response.data + if ( + errorData?.error?.type === 'permission_error' && + errorData?.error?.message?.includes('Custom data retention settings') + ) { + sanitizeAndLogError( + new Error( + 'Cannot submit feedback because custom data retention settings are enabled', + ), + ) + return { success: false, isZdrOrg: true } } } // Use our safe error logging function to avoid leaking API keys - sanitizeAndLogError(err); - return { - success: false - }; + sanitizeAndLogError(err) + return { success: false } } } diff --git a/src/components/FileEditToolDiff.tsx b/src/components/FileEditToolDiff.tsx index 7b3219563..a5146d3c8 100644 --- a/src/components/FileEditToolDiff.tsx +++ b/src/components/FileEditToolDiff.tsx @@ -1,180 +1,180 @@ -import { c as _c } from "react/compiler-runtime"; -import type { StructuredPatchHunk } from 'diff'; -import * as React from 'react'; -import { Suspense, use, useState } from 'react'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { Box, Text } from '../ink.js'; -import type { FileEdit } from '../tools/FileEditTool/types.js'; -import { findActualString, preserveQuoteStyle } from '../tools/FileEditTool/utils.js'; -import { adjustHunkLineNumbers, CONTEXT_LINES, getPatchForDisplay } from '../utils/diff.js'; -import { logError } from '../utils/log.js'; -import { CHUNK_SIZE, openForScan, readCapped, scanForContext } from '../utils/readEditContext.js'; -import { firstLineOf } from '../utils/stringUtils.js'; -import { StructuredDiffList } from './StructuredDiffList.js'; +import type { StructuredPatchHunk } from 'diff' +import * as React from 'react' +import { Suspense, use, useState } from 'react' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { Box, Text } from '../ink.js' +import type { FileEdit } from '../tools/FileEditTool/types.js' +import { + findActualString, + preserveQuoteStyle, +} from '../tools/FileEditTool/utils.js' +import { + adjustHunkLineNumbers, + CONTEXT_LINES, + getPatchForDisplay, +} from '../utils/diff.js' +import { logError } from '../utils/log.js' +import { + CHUNK_SIZE, + openForScan, + readCapped, + scanForContext, +} from '../utils/readEditContext.js' +import { firstLineOf } from '../utils/stringUtils.js' +import { StructuredDiffList } from './StructuredDiffList.js' + type Props = { - file_path: string; - edits: FileEdit[]; -}; + file_path: string + edits: FileEdit[] +} + type DiffData = { - patch: StructuredPatchHunk[]; - firstLine: string | null; - fileContent: string | undefined; -}; -export function FileEditToolDiff(props) { - const $ = _c(7); - let t0; - if ($[0] !== props.edits || $[1] !== props.file_path) { - t0 = () => loadDiffData(props.file_path, props.edits); - $[0] = props.edits; - $[1] = props.file_path; - $[2] = t0; - } else { - t0 = $[2]; - } - const [dataPromise] = useState(t0); - let t1; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[3] = t1; - } else { - t1 = $[3]; - } - let t2; - if ($[4] !== dataPromise || $[5] !== props.file_path) { - t2 = ; - $[4] = dataPromise; - $[5] = props.file_path; - $[6] = t2; - } else { - t2 = $[6]; - } - return t2; + patch: StructuredPatchHunk[] + firstLine: string | null + fileContent: string | undefined } -function DiffBody(t0: { promise: Promise; file_path: string }) { - const $ = _c(6); - const { - promise, - file_path - } = t0; - const { - patch, - firstLine, - fileContent - } = use(promise); - const { - columns - } = useTerminalSize(); - let t1; - if ($[0] !== columns || $[1] !== fileContent || $[2] !== file_path || $[3] !== firstLine || $[4] !== patch) { - t1 = ; - $[0] = columns; - $[1] = fileContent; - $[2] = file_path; - $[3] = firstLine; - $[4] = patch; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; + +export function FileEditToolDiff(props: Props): React.ReactNode { + // Snapshot on mount — the diff must stay consistent even if the file changes + // while the dialog is open. useMemo on props.edits would re-read the file on + // every render because callers pass fresh array literals. + const [dataPromise] = useState(() => + loadDiffData(props.file_path, props.edits), + ) + return ( + }> + + + ) } -function DiffFrame(t0) { - const $ = _c(5); - const { - children, - placeholder - } = t0; - let t1; - if ($[0] !== children || $[1] !== placeholder) { - t1 = placeholder ? : children; - $[0] = children; - $[1] = placeholder; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== t1) { - t2 = {t1}; - $[3] = t1; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; + +function DiffBody({ + promise, + file_path, +}: { + promise: Promise + file_path: string +}): React.ReactNode { + const { patch, firstLine, fileContent } = use(promise) + const { columns } = useTerminalSize() + return ( + + + + ) } -async function loadDiffData(file_path: string, edits: FileEdit[]): Promise { - const valid = edits.filter(e => e.old_string != null && e.new_string != null); - const single = valid.length === 1 ? valid[0]! : undefined; + +function DiffFrame({ + children, + placeholder, +}: { + children?: React.ReactNode + placeholder?: boolean +}): React.ReactNode { + return ( + + + {placeholder ? : children} + + + ) +} + +async function loadDiffData( + file_path: string, + edits: FileEdit[], +): Promise { + const valid = edits.filter(e => e.old_string != null && e.new_string != null) + const single = valid.length === 1 ? valid[0]! : undefined // SedEditPermissionRequest passes the entire file as old_string. Scanning for // a needle ≥ CHUNK_SIZE allocates O(needle) for the overlap buffer — skip the // file read entirely and diff the inputs we already have. if (single && single.old_string.length >= CHUNK_SIZE) { - return diffToolInputsOnly(file_path, [single]); + return diffToolInputsOnly(file_path, [single]) } + try { - const handle = await openForScan(file_path); - if (handle === null) return diffToolInputsOnly(file_path, valid); + const handle = await openForScan(file_path) + if (handle === null) return diffToolInputsOnly(file_path, valid) try { // Multi-edit and empty old_string genuinely need full-file for sequential // replacements — structuredPatch needs before/after strings. replace_all // routes through the chunked path below (shows first-occurrence window; // matches within the slice still replace via edit.replace_all). if (!single || single.old_string === '') { - const file = await readCapped(handle); - if (file === null) return diffToolInputsOnly(file_path, valid); - const normalized = valid.map(e => normalizeEdit(file, e)); + const file = await readCapped(handle) + if (file === null) return diffToolInputsOnly(file_path, valid) + const normalized = valid.map(e => normalizeEdit(file, e)) return { patch: getPatchForDisplay({ filePath: file_path, fileContents: file, - edits: normalized + edits: normalized, }), firstLine: firstLineOf(file), - fileContent: file - }; + fileContent: file, + } } - const ctx = await scanForContext(handle, single.old_string, CONTEXT_LINES); + + const ctx = await scanForContext(handle, single.old_string, CONTEXT_LINES) if (ctx.truncated || ctx.content === '') { - return diffToolInputsOnly(file_path, [single]); + return diffToolInputsOnly(file_path, [single]) } - const normalized = normalizeEdit(ctx.content, single); + const normalized = normalizeEdit(ctx.content, single) const hunks = getPatchForDisplay({ filePath: file_path, fileContents: ctx.content, - edits: [normalized] - }); + edits: [normalized], + }) return { patch: adjustHunkLineNumbers(hunks, ctx.lineOffset - 1), firstLine: ctx.lineOffset === 1 ? firstLineOf(ctx.content) : null, - fileContent: ctx.content - }; + fileContent: ctx.content, + } } finally { - await handle.close(); + await handle.close() } } catch (e) { - logError(e as Error); - return diffToolInputsOnly(file_path, valid); + logError(e as Error) + return diffToolInputsOnly(file_path, valid) } } + function diffToolInputsOnly(filePath: string, edits: FileEdit[]): DiffData { return { - patch: edits.flatMap(e => getPatchForDisplay({ - filePath, - fileContents: e.old_string, - edits: [e] - })), + patch: edits.flatMap(e => + getPatchForDisplay({ + filePath, + fileContents: e.old_string, + edits: [e], + }), + ), firstLine: null, - fileContent: undefined - }; + fileContent: undefined, + } } + function normalizeEdit(fileContent: string, edit: FileEdit): FileEdit { - const actualOld = findActualString(fileContent, edit.old_string) || edit.old_string; - const actualNew = preserveQuoteStyle(edit.old_string, actualOld, edit.new_string); - return { - ...edit, - old_string: actualOld, - new_string: actualNew - }; + const actualOld = + findActualString(fileContent, edit.old_string) || edit.old_string + const actualNew = preserveQuoteStyle( + edit.old_string, + actualOld, + edit.new_string, + ) + return { ...edit, old_string: actualOld, new_string: actualNew } } diff --git a/src/components/FileEditToolUpdatedMessage.tsx b/src/components/FileEditToolUpdatedMessage.tsx index 6685ee857..0248583af 100644 --- a/src/components/FileEditToolUpdatedMessage.tsx +++ b/src/components/FileEditToolUpdatedMessage.tsx @@ -1,123 +1,86 @@ -import { c as _c } from "react/compiler-runtime"; -import type { StructuredPatchHunk } from 'diff'; -import * as React from 'react'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { Box, Text } from '../ink.js'; -import { count } from '../utils/array.js'; -import { MessageResponse } from './MessageResponse.js'; -import { StructuredDiffList } from './StructuredDiffList.js'; +import type { StructuredPatchHunk } from 'diff' +import * as React from 'react' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { Box, Text } from '../ink.js' +import { count } from '../utils/array.js' +import { MessageResponse } from './MessageResponse.js' +import { StructuredDiffList } from './StructuredDiffList.js' + type Props = { - filePath: string; - structuredPatch: StructuredPatchHunk[]; - firstLine: string | null; - fileContent?: string; - style?: 'condensed'; - verbose: boolean; - previewHint?: string; -}; -export function FileEditToolUpdatedMessage(t0) { - const $ = _c(22); - const { - filePath, - structuredPatch, - firstLine, - fileContent, - style, - verbose, - previewHint - } = t0; - const { - columns - } = useTerminalSize(); - const numAdditions = structuredPatch.reduce(_temp2, 0); - const numRemovals = structuredPatch.reduce(_temp4, 0); - let t1; - if ($[0] !== numAdditions) { - t1 = numAdditions > 0 ? <>Added {numAdditions}{" "}{numAdditions > 1 ? "lines" : "line"} : null; - $[0] = numAdditions; - $[1] = t1; - } else { - t1 = $[1]; - } - const t2 = numAdditions > 0 && numRemovals > 0 ? ", " : null; - let t3; - if ($[2] !== numAdditions || $[3] !== numRemovals) { - t3 = numRemovals > 0 ? <>{numAdditions === 0 ? "R" : "r"}emoved {numRemovals}{" "}{numRemovals > 1 ? "lines" : "line"} : null; - $[2] = numAdditions; - $[3] = numRemovals; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t1 || $[6] !== t2 || $[7] !== t3) { - t4 = {t1}{t2}{t3}; - $[5] = t1; - $[6] = t2; - $[7] = t3; - $[8] = t4; - } else { - t4 = $[8]; - } - const text = t4; + filePath: string + structuredPatch: StructuredPatchHunk[] + firstLine: string | null + fileContent?: string + style?: 'condensed' + verbose: boolean + previewHint?: string +} + +export function FileEditToolUpdatedMessage({ + filePath, + structuredPatch, + firstLine, + fileContent, + style, + verbose, + previewHint, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + const numAdditions = structuredPatch.reduce( + (acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('+')), + 0, + ) + const numRemovals = structuredPatch.reduce( + (acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('-')), + 0, + ) + + const text = ( + + {numAdditions > 0 ? ( + <> + Added {numAdditions}{' '} + {numAdditions > 1 ? 'lines' : 'line'} + + ) : null} + {numAdditions > 0 && numRemovals > 0 ? ', ' : null} + {numRemovals > 0 ? ( + <> + {numAdditions === 0 ? 'R' : 'r'}emoved {numRemovals}{' '} + {numRemovals > 1 ? 'lines' : 'line'} + + ) : null} + + ) + + // Plan files: invert condensed behavior + // - Regular mode: just show the hint (user can type /plan to see full content) + // - Condensed mode (subagent view): show the diff if (previewHint) { - if (style !== "condensed" && !verbose) { - let t5; - if ($[9] !== previewHint) { - t5 = {previewHint}; - $[9] = previewHint; - $[10] = t5; - } else { - t5 = $[10]; - } - return t5; - } - } else { - if (style === "condensed" && !verbose) { - return text; + if (style !== 'condensed' && !verbose) { + return ( + + {previewHint} + + ) } + } else if (style === 'condensed' && !verbose) { + return text } - let t5; - if ($[11] !== text) { - t5 = {text}; - $[11] = text; - $[12] = t5; - } else { - t5 = $[12]; - } - const t6 = columns - 12; - let t7; - if ($[13] !== fileContent || $[14] !== filePath || $[15] !== firstLine || $[16] !== structuredPatch || $[17] !== t6) { - t7 = ; - $[13] = fileContent; - $[14] = filePath; - $[15] = firstLine; - $[16] = structuredPatch; - $[17] = t6; - $[18] = t7; - } else { - t7 = $[18]; - } - let t8; - if ($[19] !== t5 || $[20] !== t7) { - t8 = {t5}{t7}; - $[19] = t5; - $[20] = t7; - $[21] = t8; - } else { - t8 = $[21]; - } - return t8; -} -function _temp4(acc_0, hunk_0) { - return acc_0 + count(hunk_0.lines, _temp3); -} -function _temp3(__0) { - return __0.startsWith("-"); -} -function _temp2(acc, hunk) { - return acc + count(hunk.lines, _temp); -} -function _temp(_) { - return _.startsWith("+"); + + return ( + + + {text} + + + + ) } diff --git a/src/components/FileEditToolUseRejectedMessage.tsx b/src/components/FileEditToolUseRejectedMessage.tsx index 09d4524f9..6171b0f65 100644 --- a/src/components/FileEditToolUseRejectedMessage.tsx +++ b/src/components/FileEditToolUseRejectedMessage.tsx @@ -1,169 +1,98 @@ -import { c as _c } from "react/compiler-runtime"; -import type { StructuredPatchHunk } from 'diff'; -import { relative } from 'path'; -import * as React from 'react'; -import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; -import { getCwd } from 'src/utils/cwd.js'; -import { Box, Text } from '../ink.js'; -import { HighlightedCode } from './HighlightedCode.js'; -import { MessageResponse } from './MessageResponse.js'; -import { StructuredDiffList } from './StructuredDiffList.js'; -const MAX_LINES_TO_RENDER = 10; +import type { StructuredPatchHunk } from 'diff' +import { relative } from 'path' +import * as React from 'react' +import { useTerminalSize } from 'src/hooks/useTerminalSize.js' +import { getCwd } from 'src/utils/cwd.js' +import { Box, Text } from '../ink.js' +import { HighlightedCode } from './HighlightedCode.js' +import { MessageResponse } from './MessageResponse.js' +import { StructuredDiffList } from './StructuredDiffList.js' + +const MAX_LINES_TO_RENDER = 10 + type Props = { - file_path: string; - operation: 'write' | 'update'; + file_path: string + operation: 'write' | 'update' // For updates - show diff - patch?: StructuredPatchHunk[]; - firstLine: string | null; - fileContent?: string; + patch?: StructuredPatchHunk[] + firstLine: string | null + fileContent?: string // For new file creation - show content preview - content?: string; - style?: 'condensed'; - verbose: boolean; -}; -export function FileEditToolUseRejectedMessage(t0) { - const $ = _c(38); - const { - file_path, - operation, - patch, - firstLine, - fileContent, - content, - style, - verbose - } = t0; - const { - columns - } = useTerminalSize(); - let t1; - if ($[0] !== operation) { - t1 = User rejected {operation} to ; - $[0] = operation; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== file_path || $[3] !== verbose) { - t2 = verbose ? file_path : relative(getCwd(), file_path); - $[2] = file_path; - $[3] = verbose; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== t2) { - t3 = {t2}; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] !== t1 || $[8] !== t3) { - t4 = {t1}{t3}; - $[7] = t1; - $[8] = t3; - $[9] = t4; - } else { - t4 = $[9]; - } - const text = t4; - if (style === "condensed" && !verbose) { - let t5; - if ($[10] !== text) { - t5 = {text}; - $[10] = text; - $[11] = t5; - } else { - t5 = $[11]; - } - return t5; - } - if (operation === "write" && content !== undefined) { - let plusLines; - let t5; - if ($[12] !== content || $[13] !== verbose) { - const lines = content.split("\n"); - const numLines = lines.length; - plusLines = numLines - MAX_LINES_TO_RENDER; - t5 = verbose ? content : lines.slice(0, MAX_LINES_TO_RENDER).join("\n"); - $[12] = content; - $[13] = verbose; - $[14] = plusLines; - $[15] = t5; - } else { - plusLines = $[14]; - t5 = $[15]; - } - const truncatedContent = t5; - const t6 = truncatedContent || "(No content)"; - const t7 = columns - 12; - let t8; - if ($[16] !== file_path || $[17] !== t6 || $[18] !== t7) { - t8 = ; - $[16] = file_path; - $[17] = t6; - $[18] = t7; - $[19] = t8; - } else { - t8 = $[19]; - } - let t9; - if ($[20] !== plusLines || $[21] !== verbose) { - t9 = !verbose && plusLines > 0 && … +{plusLines} lines; - $[20] = plusLines; - $[21] = verbose; - $[22] = t9; - } else { - t9 = $[22]; - } - let t10; - if ($[23] !== t8 || $[24] !== t9 || $[25] !== text) { - t10 = {text}{t8}{t9}; - $[23] = t8; - $[24] = t9; - $[25] = text; - $[26] = t10; - } else { - t10 = $[26]; - } - return t10; - } - if (!patch || patch.length === 0) { - let t5; - if ($[27] !== text) { - t5 = {text}; - $[27] = text; - $[28] = t5; - } else { - t5 = $[28]; - } - return t5; - } - const t5 = columns - 12; - let t6; - if ($[29] !== fileContent || $[30] !== file_path || $[31] !== firstLine || $[32] !== patch || $[33] !== t5) { - t6 = ; - $[29] = fileContent; - $[30] = file_path; - $[31] = firstLine; - $[32] = patch; - $[33] = t5; - $[34] = t6; - } else { - t6 = $[34]; - } - let t7; - if ($[35] !== t6 || $[36] !== text) { - t7 = {text}{t6}; - $[35] = t6; - $[36] = text; - $[37] = t7; - } else { - t7 = $[37]; - } - return t7; + content?: string + style?: 'condensed' + verbose: boolean +} + +export function FileEditToolUseRejectedMessage({ + file_path, + operation, + patch, + firstLine, + fileContent, + content, + style, + verbose, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + const text = ( + + User rejected {operation} to + + {verbose ? file_path : relative(getCwd(), file_path)} + + + ) + + // For condensed style, just show the text + if (style === 'condensed' && !verbose) { + return {text} + } + + // For new file creation, show content preview (dimmed) + if (operation === 'write' && content !== undefined) { + const lines = content.split('\n') + const numLines = lines.length + const plusLines = numLines - MAX_LINES_TO_RENDER + const truncatedContent = verbose + ? content + : lines.slice(0, MAX_LINES_TO_RENDER).join('\n') + + return ( + + + {text} + + {!verbose && plusLines > 0 && ( + … +{plusLines} lines + )} + + + ) + } + + // For updates, show diff + if (!patch || patch.length === 0) { + return {text} + } + + return ( + + + {text} + + + + ) } diff --git a/src/components/FilePathLink.tsx b/src/components/FilePathLink.tsx index 42a6adcfe..05a6167a2 100644 --- a/src/components/FilePathLink.tsx +++ b/src/components/FilePathLink.tsx @@ -1,42 +1,19 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { pathToFileURL } from 'url'; -import Link from '../ink/components/Link.js'; +import React from 'react' +import { pathToFileURL } from 'url' +import Link from '../ink/components/Link.js' + type Props = { /** The absolute file path */ - filePath: string; + filePath: string /** Optional display text (defaults to filePath) */ - children?: React.ReactNode; -}; + children?: React.ReactNode +} /** * Renders a file path as an OSC 8 hyperlink. * This helps terminals like iTerm correctly identify file paths * even when they appear inside parentheses or other text. */ -export function FilePathLink(t0) { - const $ = _c(5); - const { - filePath, - children - } = t0; - let t1; - if ($[0] !== filePath) { - t1 = pathToFileURL(filePath); - $[0] = filePath; - $[1] = t1; - } else { - t1 = $[1]; - } - const t2 = children ?? filePath; - let t3; - if ($[2] !== t1.href || $[3] !== t2) { - t3 = {t2}; - $[2] = t1.href; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; +export function FilePathLink({ filePath, children }: Props): React.ReactNode { + return {children ?? filePath} } diff --git a/src/components/FullscreenLayout.tsx b/src/components/FullscreenLayout.tsx index 9842ef505..8502e46de 100644 --- a/src/components/FullscreenLayout.tsx +++ b/src/components/FullscreenLayout.tsx @@ -1,70 +1,83 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { createContext, type ReactNode, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; -import { fileURLToPath } from 'url'; -import { ModalContext } from '../context/modalContext.js'; -import { PromptOverlayProvider, usePromptOverlay, usePromptOverlayDialog } from '../context/promptOverlayContext.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import ScrollBox, { type ScrollBoxHandle } from '../ink/components/ScrollBox.js'; -import instances from '../ink/instances.js'; -import { Box, Text } from '../ink.js'; -import type { Message } from '../types/message.js'; -import { openBrowser, openPath } from '../utils/browser.js'; -import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; -import { plural } from '../utils/stringUtils.js'; -import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js'; -import PromptInputFooterSuggestions from './PromptInput/PromptInputFooterSuggestions.js'; -import type { StickyPrompt } from './VirtualMessageList.js'; +import figures from 'figures' +import React, { + createContext, + type ReactNode, + type RefObject, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react' +import { fileURLToPath } from 'url' +import { ModalContext } from '../context/modalContext.js' +import { + PromptOverlayProvider, + usePromptOverlay, + usePromptOverlayDialog, +} from '../context/promptOverlayContext.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import ScrollBox, { type ScrollBoxHandle } from '../ink/components/ScrollBox.js' +import instances from '../ink/instances.js' +import { Box, Text } from '../ink.js' +import type { Message } from '../types/message.js' +import { openBrowser, openPath } from '../utils/browser.js' +import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' +import { plural } from '../utils/stringUtils.js' +import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js' +import PromptInputFooterSuggestions from './PromptInput/PromptInputFooterSuggestions.js' +import type { StickyPrompt } from './VirtualMessageList.js' /** Rows of transcript context kept visible above the modal pane's ▔ divider. */ -const MODAL_TRANSCRIPT_PEEK = 2; +const MODAL_TRANSCRIPT_PEEK = 2 /** Context for scroll-derived chrome (sticky header, pill). StickyTracker * in VirtualMessageList writes via this instead of threading a callback * up through Messages → REPL → FullscreenLayout. The setter is stable so * consuming this context never causes re-renders. */ export const ScrollChromeContext = createContext<{ - setStickyPrompt: (p: StickyPrompt | null) => void; -}>({ - setStickyPrompt: () => {} -}); + setStickyPrompt: (p: StickyPrompt | null) => void +}>({ setStickyPrompt: () => {} }) + type Props = { /** Content that scrolls (messages, tool output) */ - scrollable: ReactNode; + scrollable: ReactNode /** Content pinned to the bottom (spinner, prompt, permissions) */ - bottom: ReactNode; + bottom: ReactNode /** Content rendered inside the ScrollBox after messages — user can scroll * up to see context while it's showing (used by PermissionRequest). */ - overlay?: ReactNode; + overlay?: ReactNode /** Absolute-positioned content anchored at the bottom-right of the * ScrollBox area, floating over scrollback. Rendered inside the flexGrow * region (not the bottom slot) so the overflowY:hidden cap doesn't clip * it. Fullscreen only — used for the companion speech bubble. */ - bottomFloat?: ReactNode; + bottomFloat?: ReactNode /** Slash-command dialog content. Rendered in an absolute-positioned * bottom-anchored pane (▔ divider, paddingX=2) that paints over the * ScrollBox AND bottom slot. Provides ModalContext so Pane/Dialog inside * skip their own frame. Fullscreen only; inline after overlay otherwise. */ - modal?: ReactNode; + modal?: ReactNode /** Ref passed via ModalContext so Tabs (or any scroll-owning descendant) * can attach it to their own ScrollBox for tall content. */ - modalScrollRef?: React.RefObject; + modalScrollRef?: React.RefObject /** Ref to the scroll box for keyboard scrolling. RefObject (not Ref) so * pillVisible's useSyncExternalStore can subscribe to scroll changes. */ - scrollRef?: RefObject; + scrollRef?: RefObject /** Y-position (scrollHeight at snapshot) of the unseen-divider. Pill * shows while viewport bottom hasn't reached this. Ref so REPL doesn't * re-render on the one-shot snapshot write. */ - dividerYRef?: RefObject; + dividerYRef?: RefObject /** Force-hide the pill (e.g. viewing a sub-agent task). */ - hidePill?: boolean; + hidePill?: boolean /** Force-hide the sticky prompt header (e.g. viewing a teammate task). */ - hideSticky?: boolean; + hideSticky?: boolean /** Count for the pill text. 0 → "Jump to bottom", >0 → "N new messages". */ - newMessageCount?: number; + newMessageCount?: number /** Called when the user clicks the "N new" pill. */ - onPillClick?: () => void; -}; + onPillClick?: () => void +} /** * Tracks the in-transcript "N new messages" divider position while the @@ -87,41 +100,43 @@ export function useUnseenDivider(messageCount: number): { /** Index into messages[] where the divider line renders. Cleared on * sticky-resume (scroll back to bottom) so the "N new" line doesn't * linger once everything is visible. */ - dividerIndex: number | null; + dividerIndex: number | null /** scrollHeight snapshot at first scroll-away — the divider's y-position. * FullscreenLayout subscribes to ScrollBox and compares viewport bottom * against this for pillVisible. Ref so writes don't re-render REPL. */ - dividerYRef: RefObject; - onScrollAway: (handle: ScrollBoxHandle) => void; - onRepin: () => void; + dividerYRef: RefObject + onScrollAway: (handle: ScrollBoxHandle) => void + onRepin: () => void /** Scroll the handle so the divider line is at the top of the viewport. */ - jumpToNew: (handle: ScrollBoxHandle | null) => void; + jumpToNew: (handle: ScrollBoxHandle | null) => void /** Shift dividerIndex and dividerYRef when messages are prepended * (infinite scroll-back). indexDelta = number of messages prepended; * heightDelta = content height growth in rows. */ - shiftDivider: (indexDelta: number, heightDelta: number) => void; + shiftDivider: (indexDelta: number, heightDelta: number) => void } { - const [dividerIndex, setDividerIndex] = useState(null); + const [dividerIndex, setDividerIndex] = useState(null) // Ref holds the current count for onScrollAway to snapshot. Written in // the render body (not useEffect) so wheel events arriving between a // message-append render and its effect flush don't capture a stale // count (off-by-one in the baseline). React Compiler bails out here — // acceptable for a hook instantiated once in REPL. - const countRef = useRef(messageCount); - countRef.current = messageCount; + const countRef = useRef(messageCount) + countRef.current = messageCount // scrollHeight snapshot — the divider's y in content coords. Ref-only: // read synchronously in onScrollAway (setState is batched, can't // read-then-write in the same callback) AND by FullscreenLayout's // pillVisible subscription. null = pinned to bottom. - const dividerYRef = useRef(null); + const dividerYRef = useRef(null) + const onRepin = useCallback(() => { // Don't clear dividerYRef here — a trackpad momentum wheel event // racing in the same stdin batch would see null and re-snapshot, // overriding the setDividerIndex(null) below. The useEffect below // clears the ref after React commits the null dividerIndex, so the // ref stays non-null until the state settles. - setDividerIndex(null); - }, []); + setDividerIndex(null) + }, []) + const onScrollAway = useCallback((handle: ScrollBoxHandle) => { // Nothing below the viewport → nothing to jump to. Covers both: // • empty/short session: scrollUp calls scrollTo(0) which breaks sticky @@ -132,20 +147,24 @@ export function useUnseenDivider(messageCount: number): { // at max (Sarah Deaton, #claude-code-feedback 2026-03-15) // pendingDelta: scrollBy accumulates without updating scrollTop. Without // it, wheeling up from max would see scrollTop==max and suppress the pill. - const max = Math.max(0, handle.getScrollHeight() - handle.getViewportHeight()); - if (handle.getScrollTop() + handle.getPendingDelta() >= max) return; + const max = Math.max( + 0, + handle.getScrollHeight() - handle.getViewportHeight(), + ) + if (handle.getScrollTop() + handle.getPendingDelta() >= max) return // Snapshot only on the FIRST scroll-away. onScrollAway fires on EVERY // scroll action (not just the initial break from sticky) — this guard // preserves the original baseline so the count doesn't reset on the // second PageUp. Subsequent calls are ref-only no-ops (no REPL re-render). if (dividerYRef.current === null) { - dividerYRef.current = handle.getScrollHeight(); + dividerYRef.current = handle.getScrollHeight() // New scroll-away session → move the divider here (replaces old one) - setDividerIndex(countRef.current); + setDividerIndex(countRef.current) } - }, []); - const jumpToNew = useCallback((handle_0: ScrollBoxHandle | null) => { - if (!handle_0) return; + }, []) + + const jumpToNew = useCallback((handle: ScrollBoxHandle | null) => { + if (!handle) return // scrollToBottom (not scrollTo(dividerY)): sets stickyScroll=true so // useVirtualScroll mounts the tail and render-node-to-output pins // scrollTop=maxScroll. scrollTo sets stickyScroll=false → the clamp @@ -153,8 +172,8 @@ export function useUnseenDivider(messageCount: number): { // back, stopping short. The divider stays rendered (dividerIndex // unchanged) so users see where new messages started; the clear on // next submit/explicit scroll-to-bottom handles cleanup. - handle_0.scrollToBottom(); - }, []); + handle.scrollToBottom() + }, []) // Sync dividerYRef with dividerIndex. When onRepin fires (submit, // scroll-to-bottom), it sets dividerIndex=null but leaves the ref @@ -167,26 +186,31 @@ export function useUnseenDivider(messageCount: number): { // below the divider index, the divider would point at nothing. useEffect(() => { if (dividerIndex === null) { - dividerYRef.current = null; + dividerYRef.current = null } else if (messageCount < dividerIndex) { - dividerYRef.current = null; - setDividerIndex(null); + dividerYRef.current = null + setDividerIndex(null) } - }, [messageCount, dividerIndex]); - const shiftDivider = useCallback((indexDelta: number, heightDelta: number) => { - setDividerIndex(idx => idx === null ? null : idx + indexDelta); - if (dividerYRef.current !== null) { - dividerYRef.current += heightDelta; - } - }, []); + }, [messageCount, dividerIndex]) + + const shiftDivider = useCallback( + (indexDelta: number, heightDelta: number) => { + setDividerIndex(idx => (idx === null ? null : idx + indexDelta)) + if (dividerYRef.current !== null) { + dividerYRef.current += heightDelta + } + }, + [], + ) + return { dividerIndex, dividerYRef, onScrollAway, onRepin, jumpToNew, - shiftDivider - }; + shiftDivider, + } } /** @@ -197,35 +221,37 @@ export function useUnseenDivider(messageCount: number): { * carry text — tool-use-only entries are skipped (like progress messages) * so "⏺ Searched for 13 patterns, read 6 files" doesn't tick the pill. */ -export function countUnseenAssistantTurns(messages: readonly Message[], dividerIndex: number): number { - let count = 0; - let prevWasAssistant = false; +export function countUnseenAssistantTurns( + messages: readonly Message[], + dividerIndex: number, +): number { + let count = 0 + let prevWasAssistant = false for (let i = dividerIndex; i < messages.length; i++) { - const m = messages[i]!; - if (m.type === 'progress') continue; + const m = messages[i]! + if (m.type === 'progress') continue // Tool-use-only assistant entries aren't "new messages" to the user — // skip them the same way we skip progress. prevWasAssistant is NOT // updated, so a text block immediately following still counts as the // same turn (tool_use + text from one API response = 1). - if (m.type === 'assistant' && !assistantHasVisibleText(m)) continue; - const isAssistant = m.type === 'assistant'; - if (isAssistant && !prevWasAssistant) count++; - prevWasAssistant = isAssistant; + if (m.type === 'assistant' && !assistantHasVisibleText(m)) continue + const isAssistant = m.type === 'assistant' + if (isAssistant && !prevWasAssistant) count++ + prevWasAssistant = isAssistant } - return count; + return count } + function assistantHasVisibleText(m: Message): boolean { - if (m.type !== 'assistant') return false; - if (!Array.isArray(m.message.content)) return false; + if (m.type !== 'assistant') return false + if (!Array.isArray(m.message.content)) return false for (const b of m.message.content) { - if (typeof b !== 'string' && b.type === 'text' && b.text.trim() !== '') return true; + if (typeof b !== 'string' && b.type === 'text' && b.text.trim() !== '') return true } - return false; + return false } -export type UnseenDivider = { - firstUnseenUuid: Message['uuid']; - count: number; -}; + +export type UnseenDivider = { firstUnseenUuid: Message['uuid']; count: number } /** * Builds the unseenDivider object REPL passes to Messages + the pill. @@ -237,23 +263,27 @@ export type UnseenDivider = { * the pill stays "Jump to bottom" through an entire tool-call sequence * until Claude's text response lands. */ -export function computeUnseenDivider(messages: readonly Message[], dividerIndex: number | null): UnseenDivider | undefined { - if (dividerIndex === null) return undefined; +export function computeUnseenDivider( + messages: readonly Message[], + dividerIndex: number | null, +): UnseenDivider | undefined { + if (dividerIndex === null) return undefined // Skip progress and null-rendering attachments when picking the divider // anchor — Messages.tsx filters these out of renderableMessages before the // dividerBeforeIndex search, so their UUID wouldn't be found (CC-724). // Hook attachments use randomUUID() so nothing shares their 24-char prefix. - let anchorIdx = dividerIndex; - while (anchorIdx < messages.length && (messages[anchorIdx]?.type === 'progress' || isNullRenderingAttachment(messages[anchorIdx]!))) { - anchorIdx++; + let anchorIdx = dividerIndex + while ( + anchorIdx < messages.length && + (messages[anchorIdx]?.type === 'progress' || + isNullRenderingAttachment(messages[anchorIdx]!)) + ) { + anchorIdx++ } - const uuid = messages[anchorIdx]?.uuid; - if (!uuid) return undefined; - const count = countUnseenAssistantTurns(messages, dividerIndex); - return { - firstUnseenUuid: uuid, - count: Math.max(1, count) - }; + const uuid = messages[anchorIdx]?.uuid + if (!uuid) return undefined + const count = countUnseenAssistantTurns(messages, dividerIndex) + return { firstUnseenUuid: uuid, count: Math.max(1, count) } } /** @@ -268,195 +298,198 @@ export function computeUnseenDivider(messages: readonly Message[], dividerIndex: * (alt buffer + mouse tracking + height constraint) lives at REPL's root * so nothing can accidentally render outside it. */ -export function FullscreenLayout(t0) { - const $ = _c(47); - const { - scrollable, - bottom, - overlay, - bottomFloat, - modal, - modalScrollRef, - scrollRef, - dividerYRef, - hidePill: t1, - hideSticky: t2, - newMessageCount: t3, - onPillClick - } = t0; - const hidePill = t1 === undefined ? false : t1; - const hideSticky = t2 === undefined ? false : t2; - const newMessageCount = t3 === undefined ? 0 : t3; - const { - rows: terminalRows, - columns - } = useTerminalSize(); - const [stickyPrompt, setStickyPrompt] = useState(null); - let t4; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { - setStickyPrompt - }; - $[0] = t4; - } else { - t4 = $[0]; - } - const chromeCtx = t4; - let t5; - if ($[1] !== scrollRef) { - t5 = listener => scrollRef?.current?.subscribe(listener) ?? _temp; - $[1] = scrollRef; - $[2] = t5; - } else { - t5 = $[2]; - } - const subscribe = t5; - let t6; - if ($[3] !== dividerYRef || $[4] !== scrollRef) { - t6 = () => { - const s = scrollRef?.current; - const dividerY = dividerYRef?.current; - if (!s || dividerY == null) { - return false; +export function FullscreenLayout({ + scrollable, + bottom, + overlay, + bottomFloat, + modal, + modalScrollRef, + scrollRef, + dividerYRef, + hidePill = false, + hideSticky = false, + newMessageCount = 0, + onPillClick, +}: Props): React.ReactNode { + const { rows: terminalRows, columns } = useTerminalSize() + // Scroll-derived chrome state lives HERE, not in REPL. StickyTracker + // writes via ScrollChromeContext; pillVisible subscribes directly to + // ScrollBox. Both change rarely (pill flips once per threshold crossing, + // sticky changes ~5-20×/transcript) — re-rendering FullscreenLayout on + // those is fine; re-rendering the 6966-line REPL + its 22+ useAppState + // selectors per-scroll-frame was not. + const [stickyPrompt, setStickyPrompt] = useState(null) + const chromeCtx = useMemo(() => ({ setStickyPrompt }), []) + // Boolean-quantized scroll subscription. Snapshot is "is viewport bottom + // above the divider y?" — Object.is on a boolean → FullscreenLayout only + // re-renders when the pill should actually flip, not per-frame. + const subscribe = useCallback( + (listener: () => void) => + scrollRef?.current?.subscribe(listener) ?? (() => {}), + [scrollRef], + ) + const pillVisible = useSyncExternalStore(subscribe, () => { + const s = scrollRef?.current + const dividerY = dividerYRef?.current + if (!s || dividerY == null) return false + return ( + s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY + ) + }) + // Wire up hyperlink click handling — in fullscreen mode, mouse tracking + // intercepts clicks before the terminal can open OSC 8 links natively. + useLayoutEffect(() => { + if (!isFullscreenEnvEnabled()) return + const ink = instances.get(process.stdout) + if (!ink) return + ink.onHyperlinkClick = url => { + // Most OSC 8 links emitted by Claude Code are file:// URLs from + // FilePathLink (FileEdit/FileWrite/FileRead tool output). openBrowser + // rejects non-http(s) protocols — route file: to openPath instead. + if (url.startsWith('file:')) { + try { + void openPath(fileURLToPath(url)) + } catch { + // Malformed file: URLs (e.g. file://host/path from plain-text + // detection) cause fileURLToPath to throw — ignore silently. + } + } else { + void openBrowser(url) } - return s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY; - }; - $[3] = dividerYRef; - $[4] = scrollRef; - $[5] = t6; - } else { - t6 = $[5]; - } - const pillVisible = useSyncExternalStore(subscribe, t6); - let t7; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t7 = []; - $[6] = t7; - } else { - t7 = $[6]; - } - useLayoutEffect(_temp3, t7); + } + return () => { + ink.onHyperlinkClick = undefined + } + }, []) + if (isFullscreenEnvEnabled()) { - const sticky = hideSticky ? null : stickyPrompt; - const headerPrompt = sticky != null && sticky !== "clicked" && overlay == null ? sticky : null; - const padCollapsed = sticky != null && overlay == null; - let t8; - if ($[7] !== headerPrompt) { - t8 = headerPrompt && ; - $[7] = headerPrompt; - $[8] = t8; - } else { - t8 = $[8]; - } - const t9 = padCollapsed ? 0 : 1; - let t10; - if ($[9] !== scrollable) { - t10 = {scrollable}; - $[9] = scrollable; - $[10] = t10; - } else { - t10 = $[10]; - } - let t11; - if ($[11] !== overlay || $[12] !== scrollRef || $[13] !== t10 || $[14] !== t9) { - t11 = {t10}{overlay}; - $[11] = overlay; - $[12] = scrollRef; - $[13] = t10; - $[14] = t9; - $[15] = t11; - } else { - t11 = $[15]; - } - let t12; - if ($[16] !== hidePill || $[17] !== newMessageCount || $[18] !== onPillClick || $[19] !== overlay || $[20] !== pillVisible) { - t12 = !hidePill && pillVisible && overlay == null && ; - $[16] = hidePill; - $[17] = newMessageCount; - $[18] = onPillClick; - $[19] = overlay; - $[20] = pillVisible; - $[21] = t12; - } else { - t12 = $[21]; - } - let t13; - if ($[22] !== bottomFloat) { - t13 = bottomFloat != null && {bottomFloat}; - $[22] = bottomFloat; - $[23] = t13; - } else { - t13 = $[23]; - } - let t14; - if ($[24] !== t11 || $[25] !== t12 || $[26] !== t13 || $[27] !== t8) { - t14 = {t8}{t11}{t12}{t13}; - $[24] = t11; - $[25] = t12; - $[26] = t13; - $[27] = t8; - $[28] = t14; - } else { - t14 = $[28]; - } - let t15; - let t16; - if ($[29] === Symbol.for("react.memo_cache_sentinel")) { - t15 = ; - t16 = ; - $[29] = t15; - $[30] = t16; - } else { - t15 = $[29]; - t16 = $[30]; - } - let t17; - if ($[31] !== bottom) { - t17 = {t15}{t16}{bottom}; - $[31] = bottom; - $[32] = t17; - } else { - t17 = $[32]; - } - let t18; - if ($[33] !== columns || $[34] !== modal || $[35] !== modalScrollRef || $[36] !== terminalRows) { - t18 = modal != null && {"\u2594".repeat(columns)}{modal}; - $[33] = columns; - $[34] = modal; - $[35] = modalScrollRef; - $[36] = terminalRows; - $[37] = t18; - } else { - t18 = $[37]; - } - let t19; - if ($[38] !== t14 || $[39] !== t17 || $[40] !== t18) { - t19 = {t14}{t17}{t18}; - $[38] = t14; - $[39] = t17; - $[40] = t18; - $[41] = t19; - } else { - t19 = $[41]; - } - return t19; + // Overlay renders BELOW messages inside the same ScrollBox — user can + // scroll up to see prior context while a permission dialog is showing. + // The ScrollBox never unmounts across overlay transitions, so scroll + // position is preserved without save/restore. stickyScroll auto-scrolls + // to the appended overlay when it mounts (if user was already at + // bottom); REPL re-pins on the overlay appear/dismiss transition for + // the case where sticky was broken. Tall dialogs (FileEdit diffs) still + // get PgUp/PgDn/wheel — same scrollRef drives the same ScrollBox. + // Three sticky states: null (at bottom), {text,scrollTo} (scrolled up, + // header shows), 'clicked' (just clicked header — hide it so the + // content ❯ takes row 0). padCollapsed covers the latter two: once + // scrolled away from bottom, padding drops to 0 and stays there until + // repin. headerVisible is only the middle state. After click: + // scrollBox_y=0 (header gone) + padding=0 → viewportTop=0 → ❯ at + // row 0. On next scroll the onChange fires with a fresh {text} and + // header comes back (viewportTop 0→1, a single 1-row shift — + // acceptable since user explicitly scrolled). + const sticky = hideSticky ? null : stickyPrompt + const headerPrompt = + sticky != null && sticky !== 'clicked' && overlay == null ? sticky : null + const padCollapsed = sticky != null && overlay == null + return ( + + + {headerPrompt && ( + + )} + + + {scrollable} + + {overlay} + + {!hidePill && pillVisible && overlay == null && ( + + )} + {bottomFloat != null && ( + + {bottomFloat} + + )} + + + + + + {bottom} + + + {modal != null && ( + + {/* Bottom-anchored, grows upward to fit content. maxHeight keeps a + few rows of transcript peek above the ▔ divider. Short modals + (/model) sit small at the bottom with lots of transcript above; + tall modals (/buddy Card) grow as needed, clipped by overflow. + Previously fixed-height (top+bottom anchored) — any fixed cap + either clipped tall content or left short content floating in + a mostly-empty pane. + + flexShrink=0 on the inner Box is load-bearing: with Shrink=1, + yoga squeezes deep children to h=0 when content > maxHeight, + and sibling Texts land on the same row → ghost overlap + ("5 serversP servers"). Clipping at the outer Box's maxHeight + keeps children at natural size. + + Divider wrapped in flexShrink=0: when the inner box overflows + (tall /config option list), yoga shrinks the divider Text to + h=0 to absorb the deficit — it's the only shrinkable sibling. + The wrapper keeps it at 1 row; overflow past maxHeight is + clipped at the bottom by overflow=hidden instead. */} + + + {'▔'.repeat(columns)} + + + {modal} + + + + )} + + ) } - let t8; - if ($[42] !== bottom || $[43] !== modal || $[44] !== overlay || $[45] !== scrollable) { - t8 = <>{scrollable}{bottom}{overlay}{modal}; - $[42] = bottom; - $[43] = modal; - $[44] = overlay; - $[45] = scrollable; - $[46] = t8; - } else { - t8 = $[46]; - } - return t8; + + return ( + <> + {scrollable} + {bottom} + {overlay} + {modal} + + ) } // Slack-style pill. Absolute overlay at bottom={0} of the scrollwrap — floats @@ -466,75 +499,42 @@ export function FullscreenLayout(t0) { // (absoluteRectsPrev third-pass in render-node-to-output.ts, #23939). Shows // "Jump to bottom" when count is 0 (scrolled away but no new messages yet — // the dead zone where users previously thought chat stalled). -function _temp3() { - if (!isFullscreenEnvEnabled()) { - return; - } - const ink = instances.get(process.stdout); - if (!ink) { - return; - } - ink.onHyperlinkClick = _temp2; - return () => { - ink.onHyperlinkClick = undefined; - }; -} -function _temp2(url) { - if (url.startsWith("file:")) { - try { - openPath(fileURLToPath(url)); - } catch {} - } else { - openBrowser(url); - } -} -function _temp() {} -function NewMessagesPill(t0) { - const $ = _c(10); - const { - count, - onClick - } = t0; - const [hover, setHover] = useState(false); - let t1; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => setHover(true); - t2 = () => setHover(false); - $[0] = t1; - $[1] = t2; - } else { - t1 = $[0]; - t2 = $[1]; - } - const t3 = hover ? "userMessageBackgroundHover" : "userMessageBackground"; - let t4; - if ($[2] !== count) { - t4 = count > 0 ? `${count} new ${plural(count, "message")}` : "Jump to bottom"; - $[2] = count; - $[3] = t4; - } else { - t4 = $[3]; - } - let t5; - if ($[4] !== t3 || $[5] !== t4) { - t5 = {" "}{t4}{" "}{figures.arrowDown}{" "}; - $[4] = t3; - $[5] = t4; - $[6] = t5; - } else { - t5 = $[6]; - } - let t6; - if ($[7] !== onClick || $[8] !== t5) { - t6 = {t5}; - $[7] = onClick; - $[8] = t5; - $[9] = t6; - } else { - t6 = $[9]; - } - return t6; +function NewMessagesPill({ + count, + onClick, +}: { + count: number + onClick?: () => void +}): React.ReactNode { + const [hover, setHover] = useState(false) + return ( + + setHover(true)} + onMouseLeave={() => setHover(false)} + > + + {' '} + {count > 0 + ? `${count} new ${plural(count, 'message')}` + : 'Jump to bottom'}{' '} + {figures.arrowDown}{' '} + + + + ) } // Context breadcrumb: when scrolled up into history, pin the current @@ -549,44 +549,32 @@ function NewMessagesPill(t0) { // even with scrollTop unchanged (the DECSTBM region top shifts with the // ScrollBox, and the diff engine sees "everything moved"). Fixed height // keeps the ScrollBox anchored; only the header TEXT changes, not its box. -function StickyPromptHeader(t0) { - const $ = _c(8); - const { - text, - onClick - } = t0; - const [hover, setHover] = useState(false); - const t1 = hover ? "userMessageBackgroundHover" : "userMessageBackground"; - let t2; - let t3; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => setHover(true); - t3 = () => setHover(false); - $[0] = t2; - $[1] = t3; - } else { - t2 = $[0]; - t3 = $[1]; - } - let t4; - if ($[2] !== text) { - t4 = {figures.pointer} {text}; - $[2] = text; - $[3] = t4; - } else { - t4 = $[3]; - } - let t5; - if ($[4] !== onClick || $[5] !== t1 || $[6] !== t4) { - t5 = {t4}; - $[4] = onClick; - $[5] = t1; - $[6] = t4; - $[7] = t5; - } else { - t5 = $[7]; - } - return t5; +function StickyPromptHeader({ + text, + onClick, +}: { + text: string + onClick: () => void +}): React.ReactNode { + const [hover, setHover] = useState(false) + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + > + + {figures.pointer} {text} + + + ) } // Slash-command suggestion overlay — see promptOverlayContext.tsx for why @@ -597,41 +585,39 @@ function StickyPromptHeader(t0) { // even when the overlay extends above the viewport. We omit minHeight and // flex-end here: they would create empty padding rows that shift visible // items down into the prompt area when the list has fewer items than max. -function SuggestionsOverlay() { - const $ = _c(4); - const data = usePromptOverlay(); - if (!data || data.suggestions.length === 0) { - return null; - } - let t0; - if ($[0] !== data.maxColumnWidth || $[1] !== data.selectedSuggestion || $[2] !== data.suggestions) { - t0 = ; - $[0] = data.maxColumnWidth; - $[1] = data.selectedSuggestion; - $[2] = data.suggestions; - $[3] = t0; - } else { - t0 = $[3]; - } - return t0; +function SuggestionsOverlay(): React.ReactNode { + const data = usePromptOverlay() + if (!data || data.suggestions.length === 0) return null + return ( + + + + ) } // Dialog portaled from PromptInput (AutoModeOptInDialog) — same clip-escape // pattern as SuggestionsOverlay. Renders later in tree order so it paints // over suggestions if both are ever up (they shouldn't be). -function DialogOverlay() { - const $ = _c(2); - const node = usePromptOverlayDialog(); - if (!node) { - return null; - } - let t0; - if ($[0] !== node) { - t0 = {node}; - $[0] = node; - $[1] = t0; - } else { - t0 = $[1]; - } - return t0; +function DialogOverlay(): React.ReactNode { + const node = usePromptOverlayDialog() + if (!node) return null + return ( + + {node} + + ) } diff --git a/src/components/GlobalSearchDialog.tsx b/src/components/GlobalSearchDialog.tsx index 6598a8b48..0df3231ce 100644 --- a/src/components/GlobalSearchDialog.tsx +++ b/src/components/GlobalSearchDialog.tsx @@ -1,324 +1,308 @@ -import { c as _c } from "react/compiler-runtime"; -import { resolve as resolvePath } from 'path'; -import * as React from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { useRegisterOverlay } from '../context/overlayContext.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { Text } from '../ink.js'; -import { logEvent } from '../services/analytics/index.js'; -import { getCwd } from '../utils/cwd.js'; -import { openFileInExternalEditor } from '../utils/editor.js'; -import { truncatePathMiddle, truncateToWidth } from '../utils/format.js'; -import { highlightMatch } from '../utils/highlightMatch.js'; -import { relativePath } from '../utils/permissions/filesystem.js'; -import { readFileInRange } from '../utils/readFileInRange.js'; -import { ripGrepStream } from '../utils/ripgrep.js'; -import { FuzzyPicker } from './design-system/FuzzyPicker.js'; -import { LoadingState } from './design-system/LoadingState.js'; +import { resolve as resolvePath } from 'path' +import * as React from 'react' +import { useEffect, useRef, useState } from 'react' +import { useRegisterOverlay } from '../context/overlayContext.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { Text } from '../ink.js' +import { logEvent } from '../services/analytics/index.js' +import { getCwd } from '../utils/cwd.js' +import { openFileInExternalEditor } from '../utils/editor.js' +import { truncatePathMiddle, truncateToWidth } from '../utils/format.js' +import { highlightMatch } from '../utils/highlightMatch.js' +import { relativePath } from '../utils/permissions/filesystem.js' +import { readFileInRange } from '../utils/readFileInRange.js' +import { ripGrepStream } from '../utils/ripgrep.js' +import { FuzzyPicker } from './design-system/FuzzyPicker.js' +import { LoadingState } from './design-system/LoadingState.js' + type Props = { - onDone: () => void; - onInsert: (text: string) => void; -}; + onDone: () => void + onInsert: (text: string) => void +} + type Match = { - file: string; - line: number; - text: string; -}; -const VISIBLE_RESULTS = 12; -const DEBOUNCE_MS = 100; -const PREVIEW_CONTEXT_LINES = 4; + file: string + line: number + text: string +} + +const VISIBLE_RESULTS = 12 +const DEBOUNCE_MS = 100 +const PREVIEW_CONTEXT_LINES = 4 // rg -m is per-file; we also cap the parsed array to keep memory bounded. -const MAX_MATCHES_PER_FILE = 10; -const MAX_TOTAL_MATCHES = 500; +const MAX_MATCHES_PER_FILE = 10 +const MAX_TOTAL_MATCHES = 500 /** * Global Search dialog (ctrl+shift+f / cmd+shift+f). * Debounced ripgrep search across the workspace. */ -export function GlobalSearchDialog(t0) { - const $ = _c(40); - const { - onDone, - onInsert - } = t0; - useRegisterOverlay("global-search", undefined); - const { - columns, - rows - } = useTerminalSize(); - const previewOnRight = columns >= 140; - const visibleResults = Math.min(VISIBLE_RESULTS, Math.max(4, rows - 14)); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - const [matches, setMatches] = useState(t1); - const [truncated, setTruncated] = useState(false); - const [isSearching, setIsSearching] = useState(false); - const [query, setQuery] = useState(""); - const [focused, setFocused] = useState(undefined); - const [preview, setPreview] = useState(null); - const abortRef = useRef(null); - const timeoutRef = useRef(null); - let t2; - let t3; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - abortRef.current?.abort(); - }; - t3 = []; - $[1] = t2; - $[2] = t3; - } else { - t2 = $[1]; - t3 = $[2]; - } - useEffect(t2, t3); - let t4; - let t5; - if ($[3] !== focused) { - t4 = () => { - if (!focused) { - setPreview(null); - return; - } - const controller = new AbortController(); - const absolute = resolvePath(getCwd(), focused.file); - const start = Math.max(0, focused.line - PREVIEW_CONTEXT_LINES - 1); - readFileInRange(absolute, start, PREVIEW_CONTEXT_LINES * 2 + 1, undefined, controller.signal).then(r => { - if (controller.signal.aborted) { - return; - } +export function GlobalSearchDialog({ + onDone, + onInsert, +}: Props): React.ReactNode { + useRegisterOverlay('global-search') + const { columns, rows } = useTerminalSize() + const previewOnRight = columns >= 140 + // Chrome (title + search + matchLabel + hints + pane border + gaps) eats + // ~14 rows. Shrink the list on short terminals so the dialog doesn't clip. + const visibleResults = Math.min(VISIBLE_RESULTS, Math.max(4, rows - 14)) + + const [matches, setMatches] = useState([]) + const [truncated, setTruncated] = useState(false) + const [isSearching, setIsSearching] = useState(false) + const [query, setQuery] = useState('') + const [focused, setFocused] = useState(undefined) + const [preview, setPreview] = useState<{ + file: string + line: number + content: string + } | null>(null) + const abortRef = useRef(null) + const timeoutRef = useRef | null>(null) + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current) + abortRef.current?.abort() + } + }, []) + + // Load context lines around the focused match. AbortController prevents + // holding ↓ from piling up reads. + useEffect(() => { + if (!focused) { + setPreview(null) + return + } + const controller = new AbortController() + const absolute = resolvePath(getCwd(), focused.file) + const start = Math.max(0, focused.line - PREVIEW_CONTEXT_LINES - 1) + void readFileInRange( + absolute, + start, + PREVIEW_CONTEXT_LINES * 2 + 1, + undefined, + controller.signal, + ) + .then(r => { + if (controller.signal.aborted) return setPreview({ file: focused.file, line: focused.line, - content: r.content - }); - }).catch(() => { - if (controller.signal.aborted) { - return; - } + content: r.content, + }) + }) + .catch(() => { + if (controller.signal.aborted) return setPreview({ file: focused.file, line: focused.line, - content: "(preview unavailable)" - }); - }); - return () => controller.abort(); - }; - t5 = [focused]; - $[3] = focused; - $[4] = t4; - $[5] = t5; - } else { - t4 = $[4]; - t5 = $[5]; + content: '(preview unavailable)', + }) + }) + return () => controller.abort() + }, [focused]) + + const handleQueryChange = (q: string) => { + setQuery(q) + if (timeoutRef.current) clearTimeout(timeoutRef.current) + abortRef.current?.abort() + + if (!q.trim()) { + setMatches(m => (m.length ? [] : m)) + setIsSearching(false) + setTruncated(false) + return + } + const controller = new AbortController() + abortRef.current = controller + setIsSearching(true) + setTruncated(false) + // Client-filter existing results while rg walks — keeps something on + // screen instead of flashing blank. rg results are merged in (deduped by + // file:line) rather than replaced, so the count is monotonic within a + // query: it only grows as rg streams, never dips to the first chunk's + // size. Narrowing (new query extends old): filter is exact — any line + // that matched the old -F -i literal contains the new one iff its text + // includes the new query lowered. Non-narrowing (broadening/different): + // filter is best-effort — may briefly show a subset until rg fills in + // the rest. + const queryLower = q.toLowerCase() + setMatches(m => { + const filtered = m.filter(match => + match.text.toLowerCase().includes(queryLower), + ) + return filtered.length === m.length ? m : filtered + }) + + timeoutRef.current = setTimeout( + (query, controller, setMatches, setTruncated, setIsSearching) => { + // ripgrep outputs absolute paths when given an absolute target, so + // relativize against cwd to preserve directory context in the truncated + // display (otherwise the cwd prefix eats the width budget). + // relativePath() returns POSIX-normalized output so truncatePathMiddle + // (which uses lastIndexOf('/')) works on Windows too. + const cwd = getCwd() + let collected = 0 + void ripGrepStream( + // -e disambiguates pattern from options when the query starts with '-' + // (e.g. searching for "--verbose" or "-rf"). See GrepTool.ts for the + // same precaution. + [ + '-n', + '--no-heading', + '-i', + '-m', + String(MAX_MATCHES_PER_FILE), + '-F', + '-e', + query, + ], + cwd, + controller.signal, + lines => { + if (controller.signal.aborted) return + const parsed: Match[] = [] + for (const line of lines) { + const m = parseRipgrepLine(line) + if (!m) continue + const rel = relativePath(cwd, m.file) + parsed.push({ ...m, file: rel.startsWith('..') ? m.file : rel }) + } + if (!parsed.length) return + collected += parsed.length + setMatches(prev => { + // Append+dedupe instead of replace: prev may hold client- + // filtered results that are valid matches for this query. + // Replacing would drop the count to this chunk's size then + // grow it back — visible as a flicker. + const seen = new Set(prev.map(matchKey)) + const fresh = parsed.filter(p => !seen.has(matchKey(p))) + if (!fresh.length) return prev + const next = prev.concat(fresh) + return next.length > MAX_TOTAL_MATCHES + ? next.slice(0, MAX_TOTAL_MATCHES) + : next + }) + if (collected >= MAX_TOTAL_MATCHES) { + controller.abort() + setTruncated(true) + setIsSearching(false) + } + }, + ) + .catch(() => {}) + // Stream closed with zero chunks — clear stale results so + // "No matches" renders instead of the previous query's list. + .finally(() => { + if (controller.signal.aborted) return + if (collected === 0) setMatches(m => (m.length ? [] : m)) + setIsSearching(false) + }) + }, + DEBOUNCE_MS, + q, + controller, + setMatches, + setTruncated, + setIsSearching, + ) } - useEffect(t4, t5); - let t6; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t6 = q => { - setQuery(q); - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); + + const listWidth = previewOnRight + ? Math.floor((columns - 10) * 0.5) + : columns - 8 + const maxPathWidth = Math.max(20, Math.floor(listWidth * 0.4)) + const maxTextWidth = Math.max(20, listWidth - maxPathWidth - 4) + const previewWidth = previewOnRight + ? Math.max(40, columns - listWidth - 14) + : columns - 6 + + const handleOpen = (m: Match) => { + const opened = openFileInExternalEditor( + resolvePath(getCwd(), m.file), + m.line, + ) + logEvent('tengu_global_search_select', { + result_count: matches.length, + opened_editor: opened, + }) + onDone() + } + + const handleInsert = (m: Match, mention: boolean) => { + onInsert(mention ? `@${m.file}#L${m.line} ` : `${m.file}:${m.line} `) + logEvent('tengu_global_search_insert', { + result_count: matches.length, + mention, + }) + onDone() + } + + // Always pass a non-empty string so the line is reserved — prevents the + // searchBox from bouncing when the count appears/disappears. + const matchLabel = + matches.length > 0 + ? `${matches.length}${truncated ? '+' : ''} matches${isSearching ? '…' : ''}` + : ' ' + + return ( + handleInsert(m, true) }} + onShiftTab={{ + action: 'insert path', + handler: m => handleInsert(m, false), + }} + onCancel={onDone} + emptyMessage={q => + isSearching ? 'Searching…' : q ? 'No matches' : 'Type to search…' } - abortRef.current?.abort(); - if (!q.trim()) { - setMatches(_temp); - setIsSearching(false); - setTruncated(false); - return; + matchLabel={matchLabel} + selectAction="open in editor" + renderItem={(m, isFocused) => ( + + + {truncatePathMiddle(m.file, maxPathWidth)}:{m.line} + {' '} + {highlightMatch( + truncateToWidth(m.text.trimStart(), maxTextWidth), + query, + )} + + )} + renderPreview={m => + preview?.file === m.file && preview.line === m.line ? ( + <> + + {truncatePathMiddle(m.file, previewWidth)}:{m.line} + + {preview.content.split('\n').map((line, i) => ( + + {highlightMatch(truncateToWidth(line, previewWidth), query)} + + ))} + + ) : ( + + ) } - const controller_0 = new AbortController(); - abortRef.current = controller_0; - setIsSearching(true); - setTruncated(false); - const queryLower = q.toLowerCase(); - setMatches(m_0 => { - const filtered = m_0.filter(match => match.text.toLowerCase().includes(queryLower)); - return filtered.length === m_0.length ? m_0 : filtered; - }); - timeoutRef.current = setTimeout(_temp4, DEBOUNCE_MS, q, controller_0, setMatches, setTruncated, setIsSearching); - }; - $[6] = t6; - } else { - t6 = $[6]; - } - const handleQueryChange = t6; - const listWidth = previewOnRight ? Math.floor((columns - 10) * 0.5) : columns - 8; - const maxPathWidth = Math.max(20, Math.floor(listWidth * 0.4)); - const maxTextWidth = Math.max(20, listWidth - maxPathWidth - 4); - const previewWidth = previewOnRight ? Math.max(40, columns - listWidth - 14) : columns - 6; - let t7; - if ($[7] !== matches.length || $[8] !== onDone) { - t7 = m_3 => { - const opened = openFileInExternalEditor(resolvePath(getCwd(), m_3.file), m_3.line); - logEvent("tengu_global_search_select", { - result_count: matches.length, - opened_editor: opened - }); - onDone(); - }; - $[7] = matches.length; - $[8] = onDone; - $[9] = t7; - } else { - t7 = $[9]; - } - const handleOpen = t7; - let t8; - if ($[10] !== matches.length || $[11] !== onDone || $[12] !== onInsert) { - t8 = (m_4, mention) => { - onInsert(mention ? `@${m_4.file}#L${m_4.line} ` : `${m_4.file}:${m_4.line} `); - logEvent("tengu_global_search_insert", { - result_count: matches.length, - mention - }); - onDone(); - }; - $[10] = matches.length; - $[11] = onDone; - $[12] = onInsert; - $[13] = t8; - } else { - t8 = $[13]; - } - const handleInsert = t8; - const matchLabel = matches.length > 0 ? `${matches.length}${truncated ? "+" : ""} matches${isSearching ? "\u2026" : ""}` : " "; - const t9 = previewOnRight ? "right" : "bottom"; - let t10; - if ($[14] !== handleInsert) { - t10 = { - action: "mention", - handler: m_5 => handleInsert(m_5, true) - }; - $[14] = handleInsert; - $[15] = t10; - } else { - t10 = $[15]; - } - let t11; - if ($[16] !== handleInsert) { - t11 = { - action: "insert path", - handler: m_6 => handleInsert(m_6, false) - }; - $[16] = handleInsert; - $[17] = t11; - } else { - t11 = $[17]; - } - let t12; - if ($[18] !== isSearching) { - t12 = q_0 => isSearching ? "Searching\u2026" : q_0 ? "No matches" : "Type to search\u2026"; - $[18] = isSearching; - $[19] = t12; - } else { - t12 = $[19]; - } - let t13; - if ($[20] !== maxPathWidth || $[21] !== maxTextWidth || $[22] !== query) { - t13 = (m_7, isFocused) => {truncatePathMiddle(m_7.file, maxPathWidth)}:{m_7.line}{" "}{highlightMatch(truncateToWidth(m_7.text.trimStart(), maxTextWidth), query)}; - $[20] = maxPathWidth; - $[21] = maxTextWidth; - $[22] = query; - $[23] = t13; - } else { - t13 = $[23]; - } - let t14; - if ($[24] !== preview || $[25] !== previewWidth || $[26] !== query) { - t14 = m_8 => preview?.file === m_8.file && preview.line === m_8.line ? <>{truncatePathMiddle(m_8.file, previewWidth)}:{m_8.line}{preview.content.split("\n").map((line_0, i) => {highlightMatch(truncateToWidth(line_0, previewWidth), query)})} : ; - $[24] = preview; - $[25] = previewWidth; - $[26] = query; - $[27] = t14; - } else { - t14 = $[27]; - } - let t15; - if ($[28] !== handleOpen || $[29] !== matchLabel || $[30] !== matches || $[31] !== onDone || $[32] !== t10 || $[33] !== t11 || $[34] !== t12 || $[35] !== t13 || $[36] !== t14 || $[37] !== t9 || $[38] !== visibleResults) { - t15 = ; - $[28] = handleOpen; - $[29] = matchLabel; - $[30] = matches; - $[31] = onDone; - $[32] = t10; - $[33] = t11; - $[34] = t12; - $[35] = t13; - $[36] = t14; - $[37] = t9; - $[38] = visibleResults; - $[39] = t15; - } else { - t15 = $[39]; - } - return t15; -} -function _temp4(query_0, controller_1, setMatches_0, setTruncated_0, setIsSearching_0) { - const cwd = getCwd(); - let collected = 0; - ripGrepStream(["-n", "--no-heading", "-i", "-m", String(MAX_MATCHES_PER_FILE), "-F", "-e", query_0], cwd, controller_1.signal, lines => { - if (controller_1.signal.aborted) { - return; - } - const parsed = []; - for (const line of lines) { - const m_1 = parseRipgrepLine(line); - if (!m_1) { - continue; - } - const rel = relativePath(cwd, m_1.file); - parsed.push({ - ...m_1, - file: rel.startsWith("..") ? m_1.file : rel - }); - } - if (!parsed.length) { - return; - } - collected = collected + parsed.length; - collected; - setMatches_0(prev => { - const seen = new Set(prev.map(matchKey)); - const fresh = parsed.filter(p => !seen.has(matchKey(p))); - if (!fresh.length) { - return prev; - } - const next = prev.concat(fresh); - return next.length > MAX_TOTAL_MATCHES ? next.slice(0, MAX_TOTAL_MATCHES) : next; - }); - if (collected >= MAX_TOTAL_MATCHES) { - controller_1.abort(); - setTruncated_0(true); - setIsSearching_0(false); - } - }).catch(_temp2).finally(() => { - if (controller_1.signal.aborted) { - return; - } - if (collected === 0) { - setMatches_0(_temp3); - } - setIsSearching_0(false); - }); -} -function _temp3(m_2) { - return m_2.length ? [] : m_2; -} -function _temp2() {} -function _temp(m) { - return m.length ? [] : m; + /> + ) } + function matchKey(m: Match): string { - return `${m.file}:${m.line}`; + return `${m.file}:${m.line}` } /** @@ -329,14 +313,10 @@ function matchKey(m: Match): string { * @internal exported for testing */ export function parseRipgrepLine(line: string): Match | null { - const m = /^(.*?):(\d+):(.*)$/.exec(line); - if (!m) return null; - const [, file, lineStr, text] = m; - const lineNum = Number(lineStr); - if (!file || !Number.isFinite(lineNum)) return null; - return { - file, - line: lineNum, - text: text ?? '' - }; + const m = /^(.*?):(\d+):(.*)$/.exec(line) + if (!m) return null + const [, file, lineStr, text] = m + const lineNum = Number(lineStr) + if (!file || !Number.isFinite(lineNum)) return null + return { file, line: lineNum, text: text ?? '' } } diff --git a/src/components/HelpV2/Commands.tsx b/src/components/HelpV2/Commands.tsx index 0490e5ed2..9e34e5539 100644 --- a/src/components/HelpV2/Commands.tsx +++ b/src/components/HelpV2/Commands.tsx @@ -1,81 +1,71 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useMemo } from 'react'; -import { type Command, formatDescriptionWithSource } from '../../commands.js'; -import { Box, Text } from '../../ink.js'; -import { truncate } from '../../utils/format.js'; -import { Select } from '../CustomSelect/select.js'; -import { useTabHeaderFocus } from '../design-system/Tabs.js'; +import * as React from 'react' +import { useMemo } from 'react' +import { type Command, formatDescriptionWithSource } from '../../commands.js' +import { Box, Text } from '../../ink.js' +import { truncate } from '../../utils/format.js' +import { Select } from '../CustomSelect/select.js' +import { useTabHeaderFocus } from '../design-system/Tabs.js' + type Props = { - commands: Command[]; - maxHeight: number; - columns: number; - title: string; - onCancel: () => void; - emptyMessage?: string; -}; -export function Commands(t0) { - const $ = _c(14); - const { - commands, - maxHeight, - columns, - title, - onCancel, - emptyMessage - } = t0; - const { - headerFocused, - focusHeader - } = useTabHeaderFocus(); - const maxWidth = Math.max(1, columns - 10); - const visibleCount = Math.max(1, Math.floor((maxHeight - 10) / 2)); - let t1; - if ($[0] !== commands || $[1] !== maxWidth) { - const seen = new Set(); - let t2; - if ($[3] !== maxWidth) { - t2 = cmd_0 => ({ - label: `/${cmd_0.name}`, - value: cmd_0.name, - description: truncate(formatDescriptionWithSource(cmd_0), maxWidth, true) - }); - $[3] = maxWidth; - $[4] = t2; - } else { - t2 = $[4]; - } - t1 = commands.filter(cmd => { - if (seen.has(cmd.name)) { - return false; - } - seen.add(cmd.name); - return true; - }).sort(_temp).map(t2); - $[0] = commands; - $[1] = maxWidth; - $[2] = t1; - } else { - t1 = $[2]; - } - const options = t1; - let t2; - if ($[5] !== commands.length || $[6] !== emptyMessage || $[7] !== focusHeader || $[8] !== headerFocused || $[9] !== onCancel || $[10] !== options || $[11] !== title || $[12] !== visibleCount) { - t2 = {commands.length === 0 && emptyMessage ? {emptyMessage} : <>{title} + + + )} + + ) } diff --git a/src/components/HelpV2/General.tsx b/src/components/HelpV2/General.tsx index 15a844e47..69b5c6509 100644 --- a/src/components/HelpV2/General.tsx +++ b/src/components/HelpV2/General.tsx @@ -1,22 +1,22 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { PromptInputHelpMenu } from '../PromptInput/PromptInputHelpMenu.js'; -export function General() { - const $ = _c(2); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = Claude understands your codebase, makes edits with your permission, and executes commands — right from your terminal.; - $[0] = t0; - } else { - t0 = $[0]; - } - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {t0}Shortcuts; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { PromptInputHelpMenu } from '../PromptInput/PromptInputHelpMenu.js' + +export function General(): React.ReactNode { + return ( + + + + Claude understands your codebase, makes edits with your permission, + and executes commands — right from your terminal. + + + + + Shortcuts + + + + + ) } diff --git a/src/components/HelpV2/HelpV2.tsx b/src/components/HelpV2/HelpV2.tsx index e81421fb9..9e2b0ce27 100644 --- a/src/components/HelpV2/HelpV2.tsx +++ b/src/components/HelpV2/HelpV2.tsx @@ -1,183 +1,138 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useShortcutDisplay } from 'src/keybindings/useShortcutDisplay.js'; -import { builtInCommandNames, type Command, type CommandResultDisplay, INTERNAL_ONLY_COMMANDS } from '../../commands.js'; -import { useIsInsideModal } from '../../context/modalContext.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { Box, Link, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { Pane } from '../design-system/Pane.js'; -import { Tab, Tabs } from '../design-system/Tabs.js'; -import { Commands } from './Commands.js'; -import { General } from './General.js'; +import * as React from 'react' +import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js' +import { useShortcutDisplay } from 'src/keybindings/useShortcutDisplay.js' +import { + builtInCommandNames, + type Command, + type CommandResultDisplay, + INTERNAL_ONLY_COMMANDS, +} from '../../commands.js' +import { useIsInsideModal } from '../../context/modalContext.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { Box, Link, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { Pane } from '../design-system/Pane.js' +import { Tab, Tabs } from '../design-system/Tabs.js' +import { Commands } from './Commands.js' +import { General } from './General.js' + type Props = { - onClose: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - commands: Command[]; -}; -export function HelpV2(t0) { - const $ = _c(44); - const { - onClose, - commands - } = t0; - const { - rows, - columns - } = useTerminalSize(); - const maxHeight = Math.floor(rows / 2); - const insideModal = useIsInsideModal(); - let t1; - if ($[0] !== onClose) { - t1 = () => onClose("Help dialog dismissed", { - display: "system" - }); - $[0] = onClose; - $[1] = t1; - } else { - t1 = $[1]; - } - const close = t1; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Help" - }; - $[2] = t2; - } else { - t2 = $[2]; - } - useKeybinding("help:dismiss", close, t2); - const exitState = useExitOnCtrlCDWithKeybindings(close); - const dismissShortcut = useShortcutDisplay("help:dismiss", "Help", "esc"); - let antOnlyCommands; - let builtinCommands; - let t3; - if ($[3] !== commands) { - const builtinNames = builtInCommandNames(); - builtinCommands = commands.filter(cmd => builtinNames.has(cmd.name) && !cmd.isHidden); - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = []; - $[7] = t4; - } else { - t4 = $[7]; - } - antOnlyCommands = t4; - t3 = commands.filter(cmd_2 => !builtinNames.has(cmd_2.name) && !cmd_2.isHidden); - $[3] = commands; - $[4] = antOnlyCommands; - $[5] = builtinCommands; - $[6] = t3; - } else { - antOnlyCommands = $[4]; - builtinCommands = $[5]; - t3 = $[6]; - } - const customCommands = t3; - let t4; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[8] = t4; - } else { - t4 = $[8]; - } - let tabs; - if ($[9] !== antOnlyCommands || $[10] !== builtinCommands || $[11] !== close || $[12] !== columns || $[13] !== customCommands || $[14] !== maxHeight) { - tabs = [t4]; - let t5; - if ($[16] !== builtinCommands || $[17] !== close || $[18] !== columns || $[19] !== maxHeight) { - t5 = ; - $[16] = builtinCommands; - $[17] = close; - $[18] = columns; - $[19] = maxHeight; - $[20] = t5; - } else { - t5 = $[20]; - } - tabs.push(t5); - let t6; - if ($[21] !== close || $[22] !== columns || $[23] !== customCommands || $[24] !== maxHeight) { - t6 = ; - $[21] = close; - $[22] = columns; - $[23] = customCommands; - $[24] = maxHeight; - $[25] = t6; - } else { - t6 = $[25]; - } - tabs.push(t6); - if (false && antOnlyCommands.length > 0) { - let t7; - if ($[26] !== antOnlyCommands || $[27] !== close || $[28] !== columns || $[29] !== maxHeight) { - t7 = ; - $[26] = antOnlyCommands; - $[27] = close; - $[28] = columns; - $[29] = maxHeight; - $[30] = t7; - } else { - t7 = $[30]; - } - tabs.push(t7); - } - $[9] = antOnlyCommands; - $[10] = builtinCommands; - $[11] = close; - $[12] = columns; - $[13] = customCommands; - $[14] = maxHeight; - $[15] = tabs; - } else { - tabs = $[15]; - } - const t5 = insideModal ? undefined : maxHeight; - let t6; - if ($[31] !== tabs) { - t6 = {tabs}; - $[31] = tabs; - $[32] = t6; - } else { - t6 = $[32]; - } - let t7; - if ($[33] === Symbol.for("react.memo_cache_sentinel")) { - t7 = For more help:{" "}; - $[33] = t7; - } else { - t7 = $[33]; - } - let t8; - if ($[34] !== dismissShortcut || $[35] !== exitState.keyName || $[36] !== exitState.pending) { - t8 = {exitState.pending ? <>Press {exitState.keyName} again to exit : {dismissShortcut} to cancel}; - $[34] = dismissShortcut; - $[35] = exitState.keyName; - $[36] = exitState.pending; - $[37] = t8; - } else { - t8 = $[37]; - } - let t9; - if ($[38] !== t6 || $[39] !== t8) { - t9 = {t6}{t7}{t8}; - $[38] = t6; - $[39] = t8; - $[40] = t9; - } else { - t9 = $[40]; - } - let t10; - if ($[41] !== t5 || $[42] !== t9) { - t10 = {t9}; - $[41] = t5; - $[42] = t9; - $[43] = t10; - } else { - t10 = $[43]; - } - return t10; + onClose: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + commands: Command[] +} + +export function HelpV2({ onClose, commands }: Props): React.ReactNode { + const { rows, columns } = useTerminalSize() + const maxHeight = Math.floor(rows / 2) + // Inside the modal slot, FullscreenLayout already caps height and Pane/Tabs + // use flexShrink=0 (see #23592) — our own height= constraint would clip the + // footer since Tabs won't shrink to fit. Let the modal slot handle sizing. + const insideModal = useIsInsideModal() + + const close = () => onClose('Help dialog dismissed', { display: 'system' }) + useKeybinding('help:dismiss', close, { context: 'Help' }) + const exitState = useExitOnCtrlCDWithKeybindings(close) + const dismissShortcut = useShortcutDisplay('help:dismiss', 'Help', 'esc') + + const builtinNames = builtInCommandNames() + let builtinCommands = commands.filter( + cmd => builtinNames.has(cmd.name) && !cmd.isHidden, + ) + let antOnlyCommands: Command[] = [] + + // We have to do this in an `if` to help treeshaking + if (process.env.USER_TYPE === 'ant') { + const internalOnlyNames = new Set(INTERNAL_ONLY_COMMANDS.map(_ => _.name)) + builtinCommands = builtinCommands.filter( + cmd => !internalOnlyNames.has(cmd.name), + ) + antOnlyCommands = commands.filter( + cmd => internalOnlyNames.has(cmd.name) && !cmd.isHidden, + ) + } + + const customCommands = commands.filter( + cmd => !builtinNames.has(cmd.name) && !cmd.isHidden, + ) + + const tabs = [ + + + , + ] + + tabs.push( + + + , + ) + + tabs.push( + + + , + ) + + if (process.env.USER_TYPE === 'ant' && antOnlyCommands.length > 0) { + tabs.push( + + + , + ) + } + + return ( + + + + {tabs} + + + + For more help:{' '} + + + + + + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + {dismissShortcut} to cancel + )} + + + + + ) } diff --git a/src/components/HighlightedCode.tsx b/src/components/HighlightedCode.tsx index 302002b25..47f7271bc 100644 --- a/src/components/HighlightedCode.tsx +++ b/src/components/HighlightedCode.tsx @@ -1,189 +1,127 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { memo, useEffect, useMemo, useRef, useState } from 'react'; -import { useSettings } from '../hooks/useSettings.js'; -import { Ansi, Box, type DOMElement, measureElement, NoSelect, Text, useTheme } from '../ink.js'; -import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; -import sliceAnsi from '../utils/sliceAnsi.js'; -import { countCharInString } from '../utils/stringUtils.js'; -import { HighlightedCodeFallback } from './HighlightedCode/Fallback.js'; -import { expectColorFile } from './StructuredDiff/colorDiff.js'; +import * as React from 'react' +import { memo, useEffect, useMemo, useRef, useState } from 'react' +import { useSettings } from '../hooks/useSettings.js' +import { + Ansi, + Box, + type DOMElement, + measureElement, + NoSelect, + Text, + useTheme, +} from '../ink.js' +import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' +import sliceAnsi from '../utils/sliceAnsi.js' +import { countCharInString } from '../utils/stringUtils.js' +import { HighlightedCodeFallback } from './HighlightedCode/Fallback.js' +import { expectColorFile } from './StructuredDiff/colorDiff.js' + type Props = { - code: string; - filePath: string; - width?: number; - dim?: boolean; -}; -const DEFAULT_WIDTH = 80; -export const HighlightedCode = memo(function HighlightedCode(t0: Props) { - const $ = _c(21); - const { - code, - filePath, - width, - dim: t1 - } = t0; - const dim = t1 === undefined ? false : t1; - const ref = useRef(null); - const [measuredWidth, setMeasuredWidth] = useState(width || DEFAULT_WIDTH); - const [theme] = useTheme(); - const settings = useSettings(); - const syntaxHighlightingDisabled = settings.syntaxHighlightingDisabled ?? false; - let t2; - bb0: { - if (syntaxHighlightingDisabled) { - t2 = null; - break bb0; - } - let t3; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t3 = expectColorFile(); - $[0] = t3; - } else { - t3 = $[0]; - } - const ColorFile = t3; - if (!ColorFile) { - t2 = null; - break bb0; - } - let t4; - if ($[1] !== code || $[2] !== filePath) { - t4 = new ColorFile(code, filePath); - $[1] = code; - $[2] = filePath; - $[3] = t4; - } else { - t4 = $[3]; - } - t2 = t4; - } - const colorFile = t2; - let t3; - let t4; - if ($[4] !== width) { - t3 = () => { - if (!width && ref.current) { - const { - width: elementWidth - } = measureElement(ref.current); - if (elementWidth > 0) { - setMeasuredWidth(elementWidth - 2); - } - } - }; - t4 = [width]; - $[4] = width; - $[5] = t3; - $[6] = t4; - } else { - t3 = $[5]; - t4 = $[6]; - } - useEffect(t3, t4); - let t5; - bb1: { - if (colorFile === null) { - t5 = null; - break bb1; - } - let t6; - if ($[7] !== colorFile || $[8] !== dim || $[9] !== measuredWidth || $[10] !== theme) { - t6 = colorFile.render(theme, measuredWidth, dim); - $[7] = colorFile; - $[8] = dim; - $[9] = measuredWidth; - $[10] = theme; - $[11] = t6; - } else { - t6 = $[11]; - } - t5 = t6; - } - const lines = t5; - let t6; - bb2: { - if (!isFullscreenEnvEnabled()) { - t6 = 0; - break bb2; - } - const lineCount = countCharInString(code, "\n") + 1; - let t7; - if ($[12] !== lineCount) { - t7 = lineCount.toString(); - $[12] = lineCount; - $[13] = t7; - } else { - t7 = $[13]; - } - t6 = t7.length + 2; - } - const gutterWidth = t6; - let t7; - if ($[14] !== code || $[15] !== dim || $[16] !== filePath || $[17] !== gutterWidth || $[18] !== lines || $[19] !== syntaxHighlightingDisabled) { - t7 = {lines ? {lines.map((line, i) => gutterWidth > 0 ? : {line})} : }; - $[14] = code; - $[15] = dim; - $[16] = filePath; - $[17] = gutterWidth; - $[18] = lines; - $[19] = syntaxHighlightingDisabled; - $[20] = t7; - } else { - t7 = $[20]; - } - return t7; -}); -function CodeLine(t0) { - const $ = _c(13); - const { - line, - gutterWidth - } = t0; - let t1; - if ($[0] !== gutterWidth || $[1] !== line) { - t1 = sliceAnsi(line, 0, gutterWidth); - $[0] = gutterWidth; - $[1] = line; - $[2] = t1; - } else { - t1 = $[2]; - } - const gutter = t1; - let t2; - if ($[3] !== gutterWidth || $[4] !== line) { - t2 = sliceAnsi(line, gutterWidth); - $[3] = gutterWidth; - $[4] = line; - $[5] = t2; - } else { - t2 = $[5]; - } - const content = t2; - let t3; - if ($[6] !== gutter) { - t3 = {gutter}; - $[6] = gutter; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] !== content) { - t4 = {content}; - $[8] = content; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== t3 || $[11] !== t4) { - t5 = {t3}{t4}; - $[10] = t3; - $[11] = t4; - $[12] = t5; - } else { - t5 = $[12]; - } - return t5; + code: string + filePath: string + width?: number + dim?: boolean +} + +const DEFAULT_WIDTH = 80 + +export const HighlightedCode = memo(function HighlightedCode({ + code, + filePath, + width, + dim = false, +}: Props): React.ReactElement { + const ref = useRef(null) + const [measuredWidth, setMeasuredWidth] = useState(width || DEFAULT_WIDTH) + const [theme] = useTheme() + const settings = useSettings() + const syntaxHighlightingDisabled = + settings.syntaxHighlightingDisabled ?? false + + const colorFile = useMemo(() => { + if (syntaxHighlightingDisabled) { + return null + } + const ColorFile = expectColorFile() + if (!ColorFile) { + return null + } + return new ColorFile(code, filePath) + }, [code, filePath, syntaxHighlightingDisabled]) + + useEffect(() => { + if (!width && ref.current) { + const { width: elementWidth } = measureElement(ref.current) + if (elementWidth > 0) { + setMeasuredWidth(elementWidth - 2) + } + } + }, [width]) + + const lines = useMemo(() => { + if (colorFile === null) { + return null + } + return colorFile.render(theme, measuredWidth, dim) + }, [colorFile, theme, measuredWidth, dim]) + + // Gutter width matches ColorFile's layout in lib.rs: space + right-aligned + // line number (max_digits = lineCount.toString().length) + space. No marker + // column like the diff path. Wrap in so fullscreen selection + // yields clean code without line numbers. Only split in fullscreen mode + // (~4× DOM nodes + sliceAnsi cost); non-fullscreen uses terminal-native + // selection where noSelect is meaningless. + const gutterWidth = useMemo(() => { + if (!isFullscreenEnvEnabled()) return 0 + const lineCount = countCharInString(code, '\n') + 1 + return lineCount.toString().length + 2 + }, [code]) + + return ( + + {lines ? ( + + {lines.map((line, i) => + gutterWidth > 0 ? ( + + ) : ( + + {line} + + ), + )} + + ) : ( + + )} + + ) +}) + +function CodeLine({ + line, + gutterWidth, +}: { + line: string + gutterWidth: number +}): React.ReactNode { + const gutter = sliceAnsi(line, 0, gutterWidth) + const content = sliceAnsi(line, gutterWidth) + return ( + + + + {gutter} + + + + {content} + + + ) } diff --git a/src/components/HighlightedCode/Fallback.tsx b/src/components/HighlightedCode/Fallback.tsx index 2a20c9ad2..3d1f70112 100644 --- a/src/components/HighlightedCode/Fallback.tsx +++ b/src/components/HighlightedCode/Fallback.tsx @@ -1,192 +1,99 @@ -import { c as _c } from "react/compiler-runtime"; -import { extname } from 'path'; -import React, { Suspense, use, useMemo } from 'react'; -import { Ansi, Text } from '../../ink.js'; -import { getCliHighlightPromise } from '../../utils/cliHighlight.js'; -import { logForDebugging } from '../../utils/debug.js'; -import { convertLeadingTabsToSpaces } from '../../utils/file.js'; -import { hashPair } from '../../utils/hash.js'; +import { extname } from 'path' +import React, { Suspense, use, useMemo } from 'react' +import { Ansi, Text } from '../../ink.js' +import { getCliHighlightPromise } from '../../utils/cliHighlight.js' +import { logForDebugging } from '../../utils/debug.js' +import { convertLeadingTabsToSpaces } from '../../utils/file.js' +import { hashPair } from '../../utils/hash.js' + type Props = { - code: string; - filePath: string; - dim?: boolean; - skipColoring?: boolean; -}; + code: string + filePath: string + dim?: boolean + skipColoring?: boolean +} // Module-level highlight cache — hl.highlight() is the hot cost on virtual- // scroll remounts. useMemo doesn't survive unmount→remount. Keyed by hash // of code+language to avoid retaining full source strings (#24180 RSS fix). -const HL_CACHE_MAX = 500; -const hlCache = new Map(); -function cachedHighlight(hl: NonNullable>>, code: string, language: string): string { - const key = hashPair(language, code); - const hit = hlCache.get(key); +const HL_CACHE_MAX = 500 +const hlCache = new Map() +function cachedHighlight( + hl: NonNullable>>, + code: string, + language: string, +): string { + const key = hashPair(language, code) + const hit = hlCache.get(key) if (hit !== undefined) { - hlCache.delete(key); - hlCache.set(key, hit); - return hit; + hlCache.delete(key) + hlCache.set(key, hit) + return hit } - const out = hl.highlight(code, { - language - }); + const out = hl.highlight(code, { language }) if (hlCache.size >= HL_CACHE_MAX) { - const first = hlCache.keys().next().value; - if (first !== undefined) hlCache.delete(first); + const first = hlCache.keys().next().value + if (first !== undefined) hlCache.delete(first) } - hlCache.set(key, out); - return out; + hlCache.set(key, out) + return out } -export function HighlightedCodeFallback(t0) { - const $ = _c(20); - const { - code, - filePath, - dim: t1, - skipColoring: t2 - } = t0; - const dim = t1 === undefined ? false : t1; - const skipColoring = t2 === undefined ? false : t2; - let t3; - if ($[0] !== code) { - t3 = convertLeadingTabsToSpaces(code); - $[0] = code; - $[1] = t3; - } else { - t3 = $[1]; - } - const codeWithSpaces = t3; + +export function HighlightedCodeFallback({ + code, + filePath, + dim = false, + skipColoring = false, +}: Props): React.ReactElement { + const codeWithSpaces = convertLeadingTabsToSpaces(code) if (skipColoring) { - let t4; - if ($[2] !== codeWithSpaces) { - t4 = {codeWithSpaces}; - $[2] = codeWithSpaces; - $[3] = t4; - } else { - t4 = $[3]; - } - let t5; - if ($[4] !== dim || $[5] !== t4) { - t5 = {t4}; - $[4] = dim; - $[5] = t4; - $[6] = t5; - } else { - t5 = $[6]; - } - return t5; + return ( + + {codeWithSpaces} + + ) } - let t4; - if ($[7] !== filePath) { - t4 = extname(filePath).slice(1); - $[7] = filePath; - $[8] = t4; - } else { - t4 = $[8]; - } - const language = t4; - let t5; - if ($[9] !== codeWithSpaces) { - t5 = {codeWithSpaces}; - $[9] = codeWithSpaces; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== codeWithSpaces || $[12] !== language) { - t6 = ; - $[11] = codeWithSpaces; - $[12] = language; - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - if ($[14] !== t5 || $[15] !== t6) { - t7 = {t6}; - $[14] = t5; - $[15] = t6; - $[16] = t7; - } else { - t7 = $[16]; - } - let t8; - if ($[17] !== dim || $[18] !== t7) { - t8 = {t7}; - $[17] = dim; - $[18] = t7; - $[19] = t8; - } else { - t8 = $[19]; - } - return t8; + const language = extname(filePath).slice(1) + return ( + + {codeWithSpaces}}> + + + + ) } -function Highlighted(t0) { - const $ = _c(10); - const { - codeWithSpaces, - language - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getCliHighlightPromise(); - $[0] = t1; - } else { - t1 = $[0]; - } - const hl = use(t1) as NonNullable>> | null; - let t2; - if ($[1] !== codeWithSpaces || $[2] !== hl || $[3] !== language) { - bb0: { - if (!hl) { - t2 = codeWithSpaces; - break bb0; - } - let highlightLang = "markdown"; - if (language) { - if (hl.supportsLanguage(language)) { - highlightLang = language; - } else { - logForDebugging(`Language not supported while highlighting code, falling back to markdown: ${language}`); - } - } - ; - try { - t2 = cachedHighlight(hl, codeWithSpaces, highlightLang); - } catch (t3) { - const e = t3; - if (e instanceof Error && e.message.includes("Unknown language")) { - logForDebugging(`Language not supported while highlighting code, falling back to markdown: ${e}`); - let t4; - if ($[5] !== codeWithSpaces || $[6] !== hl) { - t4 = cachedHighlight(hl, codeWithSpaces, "markdown"); - $[5] = codeWithSpaces; - $[6] = hl; - $[7] = t4; - } else { - t4 = $[7]; - } - t2 = t4; - break bb0; - } - t2 = codeWithSpaces; + +function Highlighted({ + codeWithSpaces, + language, +}: { + codeWithSpaces: string + language: string +}): React.ReactElement { + const hl = use(getCliHighlightPromise()) + const out = useMemo(() => { + if (!hl) return codeWithSpaces + let highlightLang = 'markdown' + if (language) { + if (hl.supportsLanguage(language)) { + highlightLang = language + } else { + logForDebugging( + `Language not supported while highlighting code, falling back to markdown: ${language}`, + ) } } - $[1] = codeWithSpaces; - $[2] = hl; - $[3] = language; - $[4] = t2; - } else { - t2 = $[4]; - } - const out = t2; - let t3; - if ($[8] !== out) { - t3 = {out}; - $[8] = out; - $[9] = t3; - } else { - t3 = $[9]; - } - return t3; + try { + return cachedHighlight(hl, codeWithSpaces, highlightLang) + } catch (e) { + if (e instanceof Error && e.message.includes('Unknown language')) { + logForDebugging( + `Language not supported while highlighting code, falling back to markdown: ${e}`, + ) + return cachedHighlight(hl, codeWithSpaces, 'markdown') + } + return codeWithSpaces + } + }, [codeWithSpaces, language, hl]) + return {out} } diff --git a/src/components/HistorySearchDialog.tsx b/src/components/HistorySearchDialog.tsx index 9ec63d5ca..dd2e02da5 100644 --- a/src/components/HistorySearchDialog.tsx +++ b/src/components/HistorySearchDialog.tsx @@ -1,117 +1,170 @@ -import * as React from 'react'; -import { useEffect, useMemo, useState } from 'react'; -import { useRegisterOverlay } from '../context/overlayContext.js'; -import { getTimestampedHistory, type TimestampedHistoryEntry } from '../history.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { stringWidth } from '../ink/stringWidth.js'; -import { wrapAnsi } from '../ink/wrapAnsi.js'; -import { Box, Text } from '../ink.js'; -import { logEvent } from '../services/analytics/index.js'; -import type { HistoryEntry } from '../utils/config.js'; -import { formatRelativeTimeAgo, truncateToWidth } from '../utils/format.js'; -import { FuzzyPicker } from './design-system/FuzzyPicker.js'; +import * as React from 'react' +import { useEffect, useMemo, useState } from 'react' +import { useRegisterOverlay } from '../context/overlayContext.js' +import { + getTimestampedHistory, + type TimestampedHistoryEntry, +} from '../history.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { stringWidth } from '../ink/stringWidth.js' +import { wrapAnsi } from '../ink/wrapAnsi.js' +import { Box, Text } from '../ink.js' +import { logEvent } from '../services/analytics/index.js' +import type { HistoryEntry } from '../utils/config.js' +import { formatRelativeTimeAgo, truncateToWidth } from '../utils/format.js' +import { FuzzyPicker } from './design-system/FuzzyPicker.js' + type Props = { - initialQuery?: string; - onSelect: (entry: HistoryEntry) => void; - onCancel: () => void; -}; -const PREVIEW_ROWS = 6; -const AGE_WIDTH = 8; + initialQuery?: string + onSelect: (entry: HistoryEntry) => void + onCancel: () => void +} + +const PREVIEW_ROWS = 6 +const AGE_WIDTH = 8 + type Item = { - entry: TimestampedHistoryEntry; - display: string; - lower: string; - firstLine: string; - age: string; -}; + entry: TimestampedHistoryEntry + display: string + lower: string + firstLine: string + age: string +} + export function HistorySearchDialog({ initialQuery, onSelect, - onCancel + onCancel, }: Props): React.ReactNode { - useRegisterOverlay('history-search', undefined); - const { - columns - } = useTerminalSize(); - const [items, setItems] = useState(null); - const [query, setQuery] = useState(initialQuery ?? ''); + useRegisterOverlay('history-search') + const { columns } = useTerminalSize() + + const [items, setItems] = useState(null) + const [query, setQuery] = useState(initialQuery ?? '') + useEffect(() => { - let cancelled = false; + let cancelled = false void (async () => { - const reader = getTimestampedHistory(); - const loaded: Item[] = []; + const reader = getTimestampedHistory() + const loaded: Item[] = [] for await (const entry of reader) { if (cancelled) { - void reader.return(undefined); - return; + void reader.return(undefined) + return } - const display = entry.display; - const nl = display.indexOf('\n'); - const age = formatRelativeTimeAgo(new Date(entry.timestamp)); + const display = entry.display + const nl = display.indexOf('\n') + const age = formatRelativeTimeAgo(new Date(entry.timestamp)) loaded.push({ entry, display, lower: display.toLowerCase(), firstLine: nl === -1 ? display : display.slice(0, nl), - age: age + ' '.repeat(Math.max(0, AGE_WIDTH - stringWidth(age))) - }); + age: age + ' '.repeat(Math.max(0, AGE_WIDTH - stringWidth(age))), + }) } - if (!cancelled) setItems(loaded); - })(); + if (!cancelled) setItems(loaded) + })() return () => { - cancelled = true; - }; - }, []); + cancelled = true + } + }, []) + const filtered = useMemo(() => { - if (!items) return []; - const q = query.trim().toLowerCase(); - if (!q) return items; - const exact: Item[] = []; - const fuzzy: Item[] = []; + if (!items) return [] + const q = query.trim().toLowerCase() + if (!q) return items + const exact: Item[] = [] + const fuzzy: Item[] = [] for (const item of items) { if (item.lower.includes(q)) { - exact.push(item); + exact.push(item) } else if (isSubsequence(item.lower, q)) { - fuzzy.push(item); + fuzzy.push(item) } } - return exact.concat(fuzzy); - }, [items, query]); - const previewOnRight = columns >= 100; - const listWidth = previewOnRight ? Math.floor((columns - 6) * 0.5) : columns - 6; - const rowWidth = Math.max(20, listWidth - AGE_WIDTH - 1); - const previewWidth = previewOnRight ? Math.max(20, columns - listWidth - 12) : Math.max(20, columns - 10); - return String(item_0.entry.timestamp)} onQueryChange={setQuery} onSelect={item_1 => { - logEvent('tengu_history_picker_select', { - result_count: filtered.length, - query_length: query.length - }); - void item_1.entry.resolve().then(onSelect); - }} onCancel={onCancel} emptyMessage={q_0 => items === null ? 'Loading…' : q_0 ? 'No matching prompts' : 'No history yet'} selectAction="use" direction="up" previewPosition={previewOnRight ? 'right' : 'bottom'} renderItem={(item_2, isFocused) => - {item_2.age} + return exact.concat(fuzzy) + }, [items, query]) + + const previewOnRight = columns >= 100 + const listWidth = previewOnRight + ? Math.floor((columns - 6) * 0.5) + : columns - 6 + const rowWidth = Math.max(20, listWidth - AGE_WIDTH - 1) + const previewWidth = previewOnRight + ? Math.max(20, columns - listWidth - 12) + : Math.max(20, columns - 10) + + return ( + String(item.entry.timestamp)} + onQueryChange={setQuery} + onSelect={item => { + logEvent('tengu_history_picker_select', { + result_count: filtered.length, + query_length: query.length, + }) + void item.entry.resolve().then(onSelect) + }} + onCancel={onCancel} + emptyMessage={q => + items === null + ? 'Loading…' + : q + ? 'No matching prompts' + : 'No history yet' + } + selectAction="use" + direction="up" + previewPosition={previewOnRight ? 'right' : 'bottom'} + renderItem={(item, isFocused) => ( + + {item.age} {' '} - {truncateToWidth(item_2.firstLine, rowWidth)} + {truncateToWidth(item.firstLine, rowWidth)} - } renderPreview={item_3 => { - const wrapped = wrapAnsi(item_3.display, previewWidth, { - hard: true - }).split('\n').filter(l => l.trim() !== ''); - const overflow = wrapped.length > PREVIEW_ROWS; - const shown = wrapped.slice(0, overflow ? PREVIEW_ROWS - 1 : PREVIEW_ROWS); - const more = wrapped.length - shown.length; - return - {shown.map((row, i) => + + )} + renderPreview={item => { + const wrapped = wrapAnsi(item.display, previewWidth, { hard: true }) + .split('\n') + .filter(l => l.trim() !== '') + const overflow = wrapped.length > PREVIEW_ROWS + const shown = wrapped.slice( + 0, + overflow ? PREVIEW_ROWS - 1 : PREVIEW_ROWS, + ) + const more = wrapped.length - shown.length + return ( + + {shown.map((row, i) => ( + {row} - )} + + ))} {more > 0 && {`… +${more} more lines`}} - ; - }} />; + + ) + }} + /> + ) } + function isSubsequence(text: string, query: string): boolean { - let j = 0; + let j = 0 for (let i = 0; i < text.length && j < query.length; i++) { - if (text[i] === query[j]) j++; + if (text[i] === query[j]) j++ } - return j === query.length; + return j === query.length } diff --git a/src/components/IdeAutoConnectDialog.tsx b/src/components/IdeAutoConnectDialog.tsx index 623f3bf4b..2377cfb3a 100644 --- a/src/components/IdeAutoConnectDialog.tsx +++ b/src/components/IdeAutoConnectDialog.tsx @@ -1,153 +1,106 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback } from 'react'; -import { Text } from '../ink.js'; -import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'; -import { isSupportedTerminal } from '../utils/ide.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; +import React, { useCallback } from 'react' +import { Text } from '../ink.js' +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' +import { isSupportedTerminal } from '../utils/ide.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + type IdeAutoConnectDialogProps = { - onComplete: () => void; -}; -export function IdeAutoConnectDialog(t0) { - const $ = _c(9); - const { - onComplete - } = t0; - let t1; - if ($[0] !== onComplete) { - t1 = async value => { - const autoConnect = value === "yes"; + onComplete: () => void +} + +export function IdeAutoConnectDialog({ + onComplete, +}: IdeAutoConnectDialogProps): React.ReactNode { + const handleSelect = useCallback( + async (value: string) => { + const autoConnect = value === 'yes' + + // Save the preference and mark dialog as shown saveGlobalConfig(current => ({ ...current, autoConnectIde: autoConnect, - hasIdeAutoConnectDialogBeenShown: true - })); - onComplete(); - }; - $[0] = onComplete; - $[1] = t1; - } else { - t1 = $[1]; - } - const handleSelect = t1; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = [{ - label: "Yes", - value: "yes" - }, { - label: "No", - value: "no" - }]; - $[2] = t2; - } else { - t2 = $[2]; - } - const options = t2; - let t3; - if ($[3] !== handleSelect) { - t3 = + + You can also configure this in /config or with the --ide flag + + + ) } + export function shouldShowAutoConnectDialog(): boolean { - const config = getGlobalConfig(); - return !isSupportedTerminal() && config.autoConnectIde !== true && config.hasIdeAutoConnectDialogBeenShown !== true; + const config = getGlobalConfig() + return ( + !isSupportedTerminal() && + config.autoConnectIde !== true && + config.hasIdeAutoConnectDialogBeenShown !== true + ) } + type IdeDisableAutoConnectDialogProps = { - onComplete: (disableAutoConnect: boolean) => void; -}; -export function IdeDisableAutoConnectDialog(t0) { - const $ = _c(10); - const { - onComplete - } = t0; - let t1; - if ($[0] !== onComplete) { - t1 = value => { - const disableAutoConnect = value === "yes"; + onComplete: (disableAutoConnect: boolean) => void +} + +export function IdeDisableAutoConnectDialog({ + onComplete, +}: IdeDisableAutoConnectDialogProps): React.ReactNode { + const handleSelect = useCallback( + (value: string) => { + const disableAutoConnect = value === 'yes' + if (disableAutoConnect) { - saveGlobalConfig(_temp); + saveGlobalConfig(current => ({ + ...current, + autoConnectIde: false, + })) } - onComplete(disableAutoConnect); - }; - $[0] = onComplete; - $[1] = t1; - } else { - t1 = $[1]; - } - const handleSelect = t1; - let t2; - if ($[2] !== onComplete) { - t2 = () => { - onComplete(false); - }; - $[2] = onComplete; - $[3] = t2; - } else { - t2 = $[3]; - } - const handleCancel = t2; - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = [{ - label: "No", - value: "no" - }, { - label: "Yes", - value: "yes" - }]; - $[4] = t3; - } else { - t3 = $[4]; - } - const options = t3; - let t4; - if ($[5] !== handleSelect) { - t4 = + + ) } + export function shouldShowDisableAutoConnectDialog(): boolean { - const config = getGlobalConfig(); - return !isSupportedTerminal() && config.autoConnectIde === true; + const config = getGlobalConfig() + return !isSupportedTerminal() && config.autoConnectIde === true } diff --git a/src/components/IdeOnboardingDialog.tsx b/src/components/IdeOnboardingDialog.tsx index e47120b40..86f03018e 100644 --- a/src/components/IdeOnboardingDialog.tsx +++ b/src/components/IdeOnboardingDialog.tsx @@ -1,166 +1,108 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { envDynamic } from 'src/utils/envDynamic.js'; -import { Box, Text } from '../ink.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'; -import { env } from '../utils/env.js'; -import { getTerminalIdeType, type IDEExtensionInstallationStatus, isJetBrainsIde, toIDEDisplayName } from '../utils/ide.js'; -import { Dialog } from './design-system/Dialog.js'; +import React from 'react' +import { envDynamic } from 'src/utils/envDynamic.js' +import { Box, Text } from '../ink.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' +import { env } from '../utils/env.js' +import { + getTerminalIdeType, + type IDEExtensionInstallationStatus, + isJetBrainsIde, + toIDEDisplayName, +} from '../utils/ide.js' +import { Dialog } from './design-system/Dialog.js' + interface Props { - onDone: () => void; - installationStatus: IDEExtensionInstallationStatus | null; + onDone: () => void + installationStatus: IDEExtensionInstallationStatus | null } -export function IdeOnboardingDialog(t0) { - const $ = _c(23); - const { - onDone, - installationStatus - } = t0; - markDialogAsShown(); - let t1; - if ($[0] !== onDone) { - t1 = { - "confirm:yes": onDone, - "confirm:no": onDone - }; - $[0] = onDone; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Confirmation" - }; - $[2] = t2; - } else { - t2 = $[2]; - } - useKeybindings(t1, t2); - let t3; - if ($[3] !== installationStatus?.ideType) { - t3 = installationStatus?.ideType ?? getTerminalIdeType(); - $[3] = installationStatus?.ideType; - $[4] = t3; - } else { - t3 = $[4]; - } - const ideType = t3; - const isJetBrains = isJetBrainsIde(ideType); - let t4; - if ($[5] !== ideType) { - t4 = toIDEDisplayName(ideType); - $[5] = ideType; - $[6] = t4; - } else { - t4 = $[6]; - } - const ideName = t4; - const installedVersion = installationStatus?.installedVersion; - const pluginOrExtension = isJetBrains ? "plugin" : "extension"; - const mentionShortcut = env.platform === "darwin" ? "Cmd+Option+K" : "Ctrl+Alt+K"; - let t5; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t5 = ; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== ideName) { - t6 = <>{t5}Welcome to Claude Code for {ideName}; - $[8] = ideName; - $[9] = t6; - } else { - t6 = $[9]; - } - const t7 = installedVersion ? `installed ${pluginOrExtension} v${installedVersion}` : undefined; - let t8; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t8 = ⧉ open files; - $[10] = t8; - } else { - t8 = $[10]; - } - let t9; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t9 = • Claude has context of {t8}{" "}and ⧉ selected lines; - $[11] = t9; - } else { - t9 = $[11]; - } - let t10; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t10 = +11; - $[12] = t10; - } else { - t10 = $[12]; - } - let t11; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t11 = • Review Claude Code's changes{" "}{t10}{" "}-22 in the comfort of your IDE; - $[13] = t11; - } else { - t11 = $[13]; - } - let t12; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t12 = • Cmd+Esc for Quick Launch; - $[14] = t12; - } else { - t12 = $[14]; - } - let t13; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t13 = {t9}{t11}{t12}• {mentionShortcut} to reference files or lines in your input; - $[15] = t13; - } else { - t13 = $[15]; - } - let t14; - if ($[16] !== onDone || $[17] !== t6 || $[18] !== t7) { - t14 = {t13}; - $[16] = onDone; - $[17] = t6; - $[18] = t7; - $[19] = t14; - } else { - t14 = $[19]; - } - let t15; - if ($[20] === Symbol.for("react.memo_cache_sentinel")) { - t15 = Press Enter to continue; - $[20] = t15; - } else { - t15 = $[20]; - } - let t16; - if ($[21] !== t14) { - t16 = <>{t14}{t15}; - $[21] = t14; - $[22] = t16; - } else { - t16 = $[22]; - } - return t16; + +export function IdeOnboardingDialog({ + onDone, + installationStatus, +}: Props): React.ReactNode { + markDialogAsShown() + + // Handle Enter/Escape to dismiss + useKeybindings( + { + 'confirm:yes': onDone, + 'confirm:no': onDone, + }, + { context: 'Confirmation' }, + ) + + const ideType = installationStatus?.ideType ?? getTerminalIdeType() + const isJetBrains = isJetBrainsIde(ideType) + + const ideName = toIDEDisplayName(ideType) + const installedVersion = installationStatus?.installedVersion + const pluginOrExtension = isJetBrains ? 'plugin' : 'extension' + const mentionShortcut = + env.platform === 'darwin' ? 'Cmd+Option+K' : 'Ctrl+Alt+K' + + return ( + <> + + + Welcome to Claude Code for {ideName} + + } + subtitle={ + installedVersion + ? `installed ${pluginOrExtension} v${installedVersion}` + : undefined + } + color="ide" + onCancel={onDone} + hideInputGuide + > + + + • Claude has context of ⧉ open files{' '} + and ⧉ selected lines + + + • Review Claude Code's changes{' '} + +11{' '} + -22 in the comfort of your IDE + + + • Cmd+Esc for Quick Launch + + + • {mentionShortcut} + to reference files or lines in your input + + + + + + Press Enter to continue + + + + ) } + export function hasIdeOnboardingDialogBeenShown(): boolean { - const config = getGlobalConfig(); - const terminal = envDynamic.terminal || 'unknown'; - return config.hasIdeOnboardingBeenShown?.[terminal] === true; + const config = getGlobalConfig() + const terminal = envDynamic.terminal || 'unknown' + return config.hasIdeOnboardingBeenShown?.[terminal] === true } + function markDialogAsShown(): void { if (hasIdeOnboardingDialogBeenShown()) { - return; + return } - const terminal = envDynamic.terminal || 'unknown'; + const terminal = envDynamic.terminal || 'unknown' saveGlobalConfig(current => ({ ...current, hasIdeOnboardingBeenShown: { ...current.hasIdeOnboardingBeenShown, - [terminal]: true - } - })); + [terminal]: true, + }, + })) } diff --git a/src/components/IdeStatusIndicator.tsx b/src/components/IdeStatusIndicator.tsx index 7de51479a..13c1846c0 100644 --- a/src/components/IdeStatusIndicator.tsx +++ b/src/components/IdeStatusIndicator.tsx @@ -1,57 +1,45 @@ -import { c as _c } from "react/compiler-runtime"; -import { basename } from 'path'; -import * as React from 'react'; -import { useIdeConnectionStatus } from '../hooks/useIdeConnectionStatus.js'; -import type { IDESelection } from '../hooks/useIdeSelection.js'; -import { Text } from '../ink.js'; -import type { MCPServerConnection } from '../services/mcp/types.js'; +import { basename } from 'path' +import * as React from 'react' +import { useIdeConnectionStatus } from '../hooks/useIdeConnectionStatus.js' +import type { IDESelection } from '../hooks/useIdeSelection.js' +import { Text } from '../ink.js' +import type { MCPServerConnection } from '../services/mcp/types.js' + type IdeStatusIndicatorProps = { - ideSelection: IDESelection | undefined; - mcpClients?: MCPServerConnection[]; -}; -export function IdeStatusIndicator(t0) { - const $ = _c(7); - const { - ideSelection, - mcpClients - } = t0; - const { - status: ideStatus - } = useIdeConnectionStatus(mcpClients); - const shouldShowIdeSelection = ideStatus === "connected" && (ideSelection?.filePath || ideSelection?.text && ideSelection.lineCount > 0); + ideSelection: IDESelection | undefined + mcpClients?: MCPServerConnection[] +} + +export function IdeStatusIndicator({ + ideSelection, + mcpClients, +}: IdeStatusIndicatorProps): React.ReactNode { + const { status: ideStatus } = useIdeConnectionStatus(mcpClients) + + // Check if we should show the IDE selection indicator + const shouldShowIdeSelection = + ideStatus === 'connected' && + (ideSelection?.filePath || + (ideSelection?.text && ideSelection.lineCount > 0)) + if (ideStatus === null || !shouldShowIdeSelection || !ideSelection) { - return null; + return null } + if (ideSelection.text && ideSelection.lineCount > 0) { - const t1 = ideSelection.lineCount === 1 ? "line" : "lines"; - let t2; - if ($[0] !== ideSelection.lineCount || $[1] !== t1) { - t2 = ⧉ {ideSelection.lineCount}{" "}{t1} selected; - $[0] = ideSelection.lineCount; - $[1] = t1; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; + return ( + + ⧉ {ideSelection.lineCount}{' '} + {ideSelection.lineCount === 1 ? 'line' : 'lines'} selected + + ) } + if (ideSelection.filePath) { - let t1; - if ($[3] !== ideSelection.filePath) { - t1 = basename(ideSelection.filePath); - $[3] = ideSelection.filePath; - $[4] = t1; - } else { - t1 = $[4]; - } - let t2; - if ($[5] !== t1) { - t2 = ⧉ In {t1}; - $[5] = t1; - $[6] = t2; - } else { - t2 = $[6]; - } - return t2; + return ( + + ⧉ In {basename(ideSelection.filePath)} + + ) } } diff --git a/src/components/IdleReturnDialog.tsx b/src/components/IdleReturnDialog.tsx index b7d0de851..d651cfe38 100644 --- a/src/components/IdleReturnDialog.tsx +++ b/src/components/IdleReturnDialog.tsx @@ -1,117 +1,67 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../ink.js'; -import { formatTokens } from '../utils/format.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; -type IdleReturnAction = 'continue' | 'clear' | 'dismiss' | 'never'; +import React from 'react' +import { Box, Text } from '../ink.js' +import { formatTokens } from '../utils/format.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + +type IdleReturnAction = 'continue' | 'clear' | 'dismiss' | 'never' + type Props = { - idleMinutes: number; - totalInputTokens: number; - onDone: (action: IdleReturnAction) => void; -}; -export function IdleReturnDialog(t0) { - const $ = _c(16); - const { - idleMinutes, - totalInputTokens, - onDone - } = t0; - let t1; - if ($[0] !== idleMinutes) { - t1 = formatIdleDuration(idleMinutes); - $[0] = idleMinutes; - $[1] = t1; - } else { - t1 = $[1]; - } - const formattedIdle = t1; - let t2; - if ($[2] !== totalInputTokens) { - t2 = formatTokens(totalInputTokens); - $[2] = totalInputTokens; - $[3] = t2; - } else { - t2 = $[3]; - } - const formattedTokens = t2; - const t3 = `You've been away ${formattedIdle} and this conversation is ${formattedTokens} tokens.`; - let t4; - if ($[4] !== onDone) { - t4 = () => onDone("dismiss"); - $[4] = onDone; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t5 = If this is a new task, clearing context will save usage and be faster.; - $[6] = t5; - } else { - t5 = $[6]; - } - let t6; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t6 = { - value: "continue" as const, - label: "Continue this conversation" - }; - $[7] = t6; - } else { - t6 = $[7]; - } - let t7; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t7 = { - value: "clear" as const, - label: "Send message as a new conversation" - }; - $[8] = t7; - } else { - t7 = $[8]; - } - let t8; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t8 = [t6, t7, { - value: "never" as const, - label: "Don't ask me again" - }]; - $[9] = t8; - } else { - t8 = $[9]; - } - let t9; - if ($[10] !== onDone) { - t9 = onDone(value)} + /> + + ) +} + function formatIdleDuration(minutes: number): string { if (minutes < 1) { - return '< 1m'; + return '< 1m' } if (minutes < 60) { - return `${Math.floor(minutes)}m`; + return `${Math.floor(minutes)}m` } - const hours = Math.floor(minutes / 60); - const remainingMinutes = Math.floor(minutes % 60); + const hours = Math.floor(minutes / 60) + const remainingMinutes = Math.floor(minutes % 60) if (remainingMinutes === 0) { - return `${hours}h`; + return `${hours}h` } - return `${hours}h ${remainingMinutes}m`; + return `${hours}h ${remainingMinutes}m` } diff --git a/src/components/InterruptedByUser.tsx b/src/components/InterruptedByUser.tsx index ecea3556a..0a77c7153 100644 --- a/src/components/InterruptedByUser.tsx +++ b/src/components/InterruptedByUser.tsx @@ -1,14 +1,15 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Text } from '../ink.js'; -export function InterruptedByUser() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = <>Interrupted {false ? · [ANT-ONLY] /issue to report a model issue : · What should Claude do instead?}; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +import * as React from 'react' +import { Text } from '../ink.js' + +export function InterruptedByUser(): React.ReactNode { + return ( + <> + Interrupted + {process.env.USER_TYPE === 'ant' ? ( + · [ANT-ONLY] /issue to report a model issue + ) : ( + · What should Claude do instead? + )} + + ) } diff --git a/src/components/InvalidConfigDialog.tsx b/src/components/InvalidConfigDialog.tsx index e038d04e4..8fa3bba97 100644 --- a/src/components/InvalidConfigDialog.tsx +++ b/src/components/InvalidConfigDialog.tsx @@ -1,155 +1,115 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, render, Text } from '../ink.js'; -import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'; -import { AppStateProvider } from '../state/AppState.js'; -import type { ConfigParseError } from '../utils/errors.js'; -import { getBaseRenderOptions } from '../utils/renderOptions.js'; -import { jsonStringify, writeFileSync_DEPRECATED } from '../utils/slowOperations.js'; -import type { ThemeName } from '../utils/theme.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; +import React from 'react' +import { Box, render, Text } from '../ink.js' +import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' +import { AppStateProvider } from '../state/AppState.js' +import type { ConfigParseError } from '../utils/errors.js' +import { getBaseRenderOptions } from '../utils/renderOptions.js' +import { + jsonStringify, + writeFileSync_DEPRECATED, +} from '../utils/slowOperations.js' +import type { ThemeName } from '../utils/theme.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' + interface InvalidConfigHandlerProps { - error: ConfigParseError; + error: ConfigParseError } + interface InvalidConfigDialogProps { - filePath: string; - errorDescription: string; - onExit: () => void; - onReset: () => void; + filePath: string + errorDescription: string + onExit: () => void + onReset: () => void } /** * Dialog shown when the Claude config file contains invalid JSON */ -function InvalidConfigDialog(t0) { - const $ = _c(19); - const { - filePath, - errorDescription, - onExit, - onReset - } = t0; - let t1; - if ($[0] !== onExit || $[1] !== onReset) { - t1 = value => { - if (value === "exit") { - onExit(); - } else { - onReset(); - } - }; - $[0] = onExit; - $[1] = onReset; - $[2] = t1; - } else { - t1 = $[2]; +function InvalidConfigDialog({ + filePath, + errorDescription, + onExit, + onReset, +}: InvalidConfigDialogProps): React.ReactNode { + // Handler for Select onChange + const handleSelect = (value: string) => { + if (value === 'exit') { + onExit() + } else { + onReset() + } } - const handleSelect = t1; - let t2; - if ($[3] !== filePath) { - t2 = The configuration file at {filePath} contains invalid JSON.; - $[3] = filePath; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== errorDescription) { - t3 = {errorDescription}; - $[5] = errorDescription; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] !== t2 || $[8] !== t3) { - t4 = {t2}{t3}; - $[7] = t2; - $[8] = t3; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Choose an option:; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t6 = [{ - label: "Exit and fix manually", - value: "exit" - }, { - label: "Reset with default configuration", - value: "reset" - }]; - $[11] = t6; - } else { - t6 = $[11]; - } - let t7; - if ($[12] !== handleSelect || $[13] !== onExit) { - t7 = {t5} + + + ) } /** * Safe fallback theme name for error dialogs to avoid circular dependency. * Uses a hardcoded dark theme that doesn't require reading from config. */ -const SAFE_ERROR_THEME_NAME: ThemeName = 'dark'; +const SAFE_ERROR_THEME_NAME: ThemeName = 'dark' + export async function showInvalidConfigDialog({ - error + error, }: InvalidConfigHandlerProps): Promise { // Extend RenderOptions with theme property for this specific usage - type SafeRenderOptions = Parameters[1] & { - theme?: ThemeName; - }; + type SafeRenderOptions = Parameters[1] & { theme?: ThemeName } + const renderOptions: SafeRenderOptions = { ...getBaseRenderOptions(false), // IMPORTANT: Use hardcoded theme name to avoid circular dependency with getGlobalConfig() // This allows the error dialog to show even when config file has JSON syntax errors - theme: SAFE_ERROR_THEME_NAME - }; + theme: SAFE_ERROR_THEME_NAME, + } + await new Promise(async resolve => { - const { - unmount - } = await render( + const { unmount } = await render( + - { - unmount(); - void resolve(); - process.exit(1); - }} onReset={() => { - writeFileSync_DEPRECATED(error.filePath, jsonStringify(error.defaultConfig, null, 2), { - flush: false, - encoding: 'utf8' - }); - unmount(); - void resolve(); - process.exit(0); - }} /> + { + unmount() + void resolve() + process.exit(1) + }} + onReset={() => { + writeFileSync_DEPRECATED( + error.filePath, + jsonStringify(error.defaultConfig, null, 2), + { flush: false, encoding: 'utf8' }, + ) + unmount() + void resolve() + process.exit(0) + }} + /> - , renderOptions); - }); + , + renderOptions, + ) + }) } diff --git a/src/components/InvalidSettingsDialog.tsx b/src/components/InvalidSettingsDialog.tsx index 097293ebf..c1fddf96a 100644 --- a/src/components/InvalidSettingsDialog.tsx +++ b/src/components/InvalidSettingsDialog.tsx @@ -1,88 +1,49 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Text } from '../ink.js'; -import type { ValidationError } from '../utils/settings/validation.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; -import { ValidationErrorsList } from './ValidationErrorsList.js'; +import React from 'react' +import { Text } from '../ink.js' +import type { ValidationError } from '../utils/settings/validation.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' +import { ValidationErrorsList } from './ValidationErrorsList.js' + type Props = { - settingsErrors: ValidationError[]; - onContinue: () => void; - onExit: () => void; -}; + settingsErrors: ValidationError[] + onContinue: () => void + onExit: () => void +} /** * Dialog shown when settings files have validation errors. * User must choose to continue (skipping invalid files) or exit to fix them. */ -export function InvalidSettingsDialog(t0) { - const $ = _c(13); - const { - settingsErrors, - onContinue, - onExit - } = t0; - let t1; - if ($[0] !== onContinue || $[1] !== onExit) { - t1 = function handleSelect(value) { - if (value === "exit") { - onExit(); - } else { - onContinue(); - } - }; - $[0] = onContinue; - $[1] = onExit; - $[2] = t1; - } else { - t1 = $[2]; +export function InvalidSettingsDialog({ + settingsErrors, + onContinue, + onExit, +}: Props): React.ReactNode { + function handleSelect(value: string): void { + if (value === 'exit') { + onExit() + } else { + onContinue() + } } - const handleSelect = t1; - let t2; - if ($[3] !== settingsErrors) { - t2 = ; - $[3] = settingsErrors; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Files with errors are skipped entirely, not just the invalid settings.; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t4 = [{ - label: "Exit and fix manually", - value: "exit" - }, { - label: "Continue without these settings", - value: "continue" - }]; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== handleSelect) { - t5 = + + ) } diff --git a/src/components/KeybindingWarnings.tsx b/src/components/KeybindingWarnings.tsx index 0ce351d72..8f6957c3e 100644 --- a/src/components/KeybindingWarnings.tsx +++ b/src/components/KeybindingWarnings.tsx @@ -1,7 +1,10 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../ink.js'; -import { getCachedKeybindingWarnings, getKeybindingsPath, isKeybindingCustomizationEnabled } from '../keybindings/loadUserBindings.js'; +import React from 'react' +import { Box, Text } from '../ink.js' +import { + getCachedKeybindingWarnings, + getKeybindingsPath, + isKeybindingCustomizationEnabled, +} from '../keybindings/loadUserBindings.js' /** * Displays keybinding validation warnings in the UI. @@ -10,45 +13,60 @@ import { getCachedKeybindingWarnings, getKeybindingsPath, isKeybindingCustomizat * * Only shown when keybinding customization is enabled (ant users + feature gate). */ -export function KeybindingWarnings() { - const $ = _c(2); +export function KeybindingWarnings(): React.ReactNode { + // Only show warnings when keybinding customization is enabled if (!isKeybindingCustomizationEnabled()) { - return null; + return null } - let t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Symbol.for("react.early_return_sentinel"); - bb0: { - const warnings = getCachedKeybindingWarnings(); - if (warnings.length === 0) { - t1 = null; - break bb0; - } - const errors = warnings.filter(_temp); - const warns = warnings.filter(_temp2); - t0 = 0 ? "error" : "warning"}>Keybinding Configuration IssuesLocation: {getKeybindingsPath()}{errors.map(_temp3)}{warns.map(_temp4)}; - } - $[0] = t0; - $[1] = t1; - } else { - t0 = $[0]; - t1 = $[1]; + + const warnings = getCachedKeybindingWarnings() + + if (warnings.length === 0) { + return null } - if (t1 !== Symbol.for("react.early_return_sentinel")) { - return t1; - } - return t0; -} -function _temp4(warning, i_0) { - return [Warning] {warning.message}{warning.suggestion && → {warning.suggestion}}; -} -function _temp3(error, i) { - return [Error] {error.message}{error.suggestion && → {error.suggestion}}; -} -function _temp2(w_0) { - return w_0.severity === "warning"; -} -function _temp(w) { - return w.severity === "error"; + + const errors = warnings.filter(w => w.severity === 'error') + const warns = warnings.filter(w => w.severity === 'warning') + + return ( + + 0 ? 'error' : 'warning'}> + Keybinding Configuration Issues + + + Location: + {getKeybindingsPath()} + + + {errors.map((error, i) => ( + + + + [Error] + {error.message} + + {error.suggestion && ( + + → {error.suggestion} + + )} + + ))} + {warns.map((warning, i) => ( + + + + [Warning] + {warning.message} + + {warning.suggestion && ( + + → {warning.suggestion} + + )} + + ))} + + + ) } diff --git a/src/components/LanguagePicker.tsx b/src/components/LanguagePicker.tsx index c28dc53c2..53be69d48 100644 --- a/src/components/LanguagePicker.tsx +++ b/src/components/LanguagePicker.tsx @@ -1,85 +1,52 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useState } from 'react'; -import { Box, Text } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import TextInput from './TextInput.js'; +import figures from 'figures' +import React, { useState } from 'react' +import { Box, Text } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import TextInput from './TextInput.js' + type Props = { - initialLanguage: string | undefined; - onComplete: (language: string | undefined) => void; - onCancel: () => void; -}; -export function LanguagePicker(t0) { - const $ = _c(13); - const { - initialLanguage, - onComplete, - onCancel - } = t0; - const [language, setLanguage] = useState(initialLanguage); - const [cursorOffset, setCursorOffset] = useState((initialLanguage ?? "").length); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - context: "Settings" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - useKeybinding("confirm:no", onCancel, t1); - let t2; - if ($[1] !== language || $[2] !== onComplete) { - t2 = function handleSubmit() { - const trimmed = language?.trim(); - onComplete(trimmed || undefined); - }; - $[1] = language; - $[2] = onComplete; - $[3] = t2; - } else { - t2 = $[3]; - } - const handleSubmit = t2; - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Enter your preferred response and voice language:; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = {figures.pointer}; - $[5] = t4; - } else { - t4 = $[5]; - } - const t5 = language ?? ""; - let t6; - if ($[6] !== cursorOffset || $[7] !== handleSubmit || $[8] !== t5) { - t6 = {t4}; - $[6] = cursorOffset; - $[7] = handleSubmit; - $[8] = t5; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t7 = Leave empty for default (English); - $[10] = t7; - } else { - t7 = $[10]; - } - let t8; - if ($[11] !== t6) { - t8 = {t3}{t6}{t7}; - $[11] = t6; - $[12] = t8; - } else { - t8 = $[12]; - } - return t8; + initialLanguage: string | undefined + onComplete: (language: string | undefined) => void + onCancel: () => void +} + +export function LanguagePicker({ + initialLanguage, + onComplete, + onCancel, +}: Props): React.ReactNode { + const [language, setLanguage] = useState(initialLanguage) + const [cursorOffset, setCursorOffset] = useState( + (initialLanguage ?? '').length, + ) + + // Use configurable keybinding for ESC to cancel + // Use Settings context so 'n' key doesn't trigger cancel (allows typing 'n' in input) + useKeybinding('confirm:no', onCancel, { context: 'Settings' }) + + function handleSubmit(): void { + const trimmed = language?.trim() + onComplete(trimmed || undefined) + } + + return ( + + Enter your preferred response and voice language: + + {figures.pointer} + + + Leave empty for default (English) + + ) } diff --git a/src/components/LogSelector.tsx b/src/components/LogSelector.tsx index bb206feda..d1fb9f607 100644 --- a/src/components/LogSelector.tsx +++ b/src/components/LogSelector.tsx @@ -1,1533 +1,1373 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import figures from 'figures'; -import Fuse from 'fuse.js'; -import React from 'react'; -import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'; -import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useSearchInput } from '../hooks/useSearchInput.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { applyColor } from '../ink/colorize.js'; -import type { Color } from '../ink/styles.js'; -import { Box, Text, useInput, useTerminalFocus, useTheme } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { logEvent } from '../services/analytics/index.js'; -import type { LogOption, SerializedMessage } from '../types/logs.js'; -import { formatLogMetadata, truncateToWidth } from '../utils/format.js'; -import { getWorktreePaths } from '../utils/getWorktreePaths.js'; -import { getBranch } from '../utils/git.js'; -import { getLogDisplayTitle } from '../utils/log.js'; -import { getFirstMeaningfulUserMessageTextContent, getSessionIdFromLog, isCustomTitleEnabled, saveCustomTitle } from '../utils/sessionStorage.js'; -import { getTheme } from '../utils/theme.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { Select } from './CustomSelect/select.js'; -import { Byline } from './design-system/Byline.js'; -import { Divider } from './design-system/Divider.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { SearchBox } from './SearchBox.js'; -import { SessionPreview } from './SessionPreview.js'; -import { Spinner } from './Spinner.js'; -import { TagTabs } from './TagTabs.js'; -import TextInput from './TextInput.js'; -import { type TreeNode, TreeSelect } from './ui/TreeSelect.js'; -type AgenticSearchState = { - status: 'idle'; -} | { - status: 'searching'; -} | { - status: 'results'; - results: LogOption[]; - query: string; -} | { - status: 'error'; - message: string; -}; +import chalk from 'chalk' +import figures from 'figures' +import Fuse from 'fuse.js' +import React from 'react' +import { getOriginalCwd, getSessionId } from '../bootstrap/state.js' +import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useSearchInput } from '../hooks/useSearchInput.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { applyColor } from '../ink/colorize.js' +import type { Color } from '../ink/styles.js' +import { Box, Text, useInput, useTerminalFocus, useTheme } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { logEvent } from '../services/analytics/index.js' +import type { LogOption, SerializedMessage } from '../types/logs.js' +import { formatLogMetadata, truncateToWidth } from '../utils/format.js' +import { getWorktreePaths } from '../utils/getWorktreePaths.js' +import { getBranch } from '../utils/git.js' +import { getLogDisplayTitle } from '../utils/log.js' +import { + getFirstMeaningfulUserMessageTextContent, + getSessionIdFromLog, + isCustomTitleEnabled, + saveCustomTitle, +} from '../utils/sessionStorage.js' +import { getTheme } from '../utils/theme.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { Select } from './CustomSelect/select.js' +import { Byline } from './design-system/Byline.js' +import { Divider } from './design-system/Divider.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { SearchBox } from './SearchBox.js' +import { SessionPreview } from './SessionPreview.js' +import { Spinner } from './Spinner.js' +import { TagTabs } from './TagTabs.js' +import TextInput from './TextInput.js' +import { type TreeNode, TreeSelect } from './ui/TreeSelect.js' + +type AgenticSearchState = + | { status: 'idle' } + | { status: 'searching' } + | { status: 'results'; results: LogOption[]; query: string } + | { status: 'error'; message: string } + export type LogSelectorProps = { - logs: LogOption[]; - maxHeight?: number; - forceWidth?: number; - onCancel?: () => void; - onSelect: (log: LogOption) => void; - onLogsChanged?: () => void; - onLoadMore?: (count: number) => void; - initialSearchQuery?: string; - showAllProjects?: boolean; - onToggleAllProjects?: () => void; - onAgenticSearch?: (query: string, logs: LogOption[], signal?: AbortSignal) => Promise; -}; -type LogTreeNode = TreeNode<{ - log: LogOption; - indexInFiltered: number; -}>; + logs: LogOption[] + maxHeight?: number + forceWidth?: number + onCancel?: () => void + onSelect: (log: LogOption) => void + onLogsChanged?: () => void + onLoadMore?: (count: number) => void + initialSearchQuery?: string + showAllProjects?: boolean + onToggleAllProjects?: () => void + onAgenticSearch?: ( + query: string, + logs: LogOption[], + signal?: AbortSignal, + ) => Promise +} + +type LogTreeNode = TreeNode<{ log: LogOption; indexInFiltered: number }> + function normalizeAndTruncateToWidth(text: string, maxWidth: number): string { - const normalized = text.replace(/\s+/g, ' ').trim(); - return truncateToWidth(normalized, maxWidth); + const normalized = text.replace(/\s+/g, ' ').trim() + return truncateToWidth(normalized, maxWidth) } // Width of prefixes that TreeSelect will add -const PARENT_PREFIX_WIDTH = 2; // '▼ ' or '▶ ' -const CHILD_PREFIX_WIDTH = 4; // ' ▸ ' +const PARENT_PREFIX_WIDTH = 2 // '▼ ' or '▶ ' +const CHILD_PREFIX_WIDTH = 4 // ' ▸ ' // Deep search constants -const DEEP_SEARCH_MAX_MESSAGES = 2000; -const DEEP_SEARCH_CROP_SIZE = 1000; -const DEEP_SEARCH_MAX_TEXT_LENGTH = 50000; // Cap searchable text per session -const FUSE_THRESHOLD = 0.3; -const DATE_TIE_THRESHOLD_MS = 60 * 1000; // 1 minute - use relevance as tie-breaker within this window -const SNIPPET_CONTEXT_CHARS = 50; // Characters to show before/after match +const DEEP_SEARCH_MAX_MESSAGES = 2000 +const DEEP_SEARCH_CROP_SIZE = 1000 +const DEEP_SEARCH_MAX_TEXT_LENGTH = 50000 // Cap searchable text per session +const FUSE_THRESHOLD = 0.3 +const DATE_TIE_THRESHOLD_MS = 60 * 1000 // 1 minute - use relevance as tie-breaker within this window +const SNIPPET_CONTEXT_CHARS = 50 // Characters to show before/after match -type Snippet = { - before: string; - match: string; - after: string; -}; -function formatSnippet({ - before, - match, - after -}: Snippet, highlightColor: (text: string) => string): string { - return chalk.dim(before) + highlightColor(match) + chalk.dim(after); +type Snippet = { before: string; match: string; after: string } + +function formatSnippet( + { before, match, after }: Snippet, + highlightColor: (text: string) => string, +): string { + return chalk.dim(before) + highlightColor(match) + chalk.dim(after) } -function extractSnippet(text: string, query: string, contextChars: number): Snippet | null { + +function extractSnippet( + text: string, + query: string, + contextChars: number, +): Snippet | null { // Find exact query occurrence (case-insensitive). // Note: Fuse does fuzzy matching, so this may miss some fuzzy matches. // This is acceptable for now - in the future we could use Fuse's includeMatches // option and work with the match indices directly. - const matchIndex = text.toLowerCase().indexOf(query.toLowerCase()); - if (matchIndex === -1) return null; - const matchEnd = matchIndex + query.length; - const snippetStart = Math.max(0, matchIndex - contextChars); - const snippetEnd = Math.min(text.length, matchEnd + contextChars); - const beforeRaw = text.slice(snippetStart, matchIndex); - const matchText = text.slice(matchIndex, matchEnd); - const afterRaw = text.slice(matchEnd, snippetEnd); + const matchIndex = text.toLowerCase().indexOf(query.toLowerCase()) + if (matchIndex === -1) return null + + const matchEnd = matchIndex + query.length + const snippetStart = Math.max(0, matchIndex - contextChars) + const snippetEnd = Math.min(text.length, matchEnd + contextChars) + + const beforeRaw = text.slice(snippetStart, matchIndex) + const matchText = text.slice(matchIndex, matchEnd) + const afterRaw = text.slice(matchEnd, snippetEnd) + return { - before: (snippetStart > 0 ? '…' : '') + beforeRaw.replace(/\s+/g, ' ').trimStart(), + before: + (snippetStart > 0 ? '…' : '') + + beforeRaw.replace(/\s+/g, ' ').trimStart(), match: matchText.trim(), - after: afterRaw.replace(/\s+/g, ' ').trimEnd() + (snippetEnd < text.length ? '…' : '') - }; + after: + afterRaw.replace(/\s+/g, ' ').trimEnd() + + (snippetEnd < text.length ? '…' : ''), + } } -function buildLogLabel(log: LogOption, maxLabelWidth: number, options?: { - isGroupHeader?: boolean; - isChild?: boolean; - forkCount?: number; -}): string { + +function buildLogLabel( + log: LogOption, + maxLabelWidth: number, + options?: { + isGroupHeader?: boolean + isChild?: boolean + forkCount?: number + }, +): string { const { isGroupHeader = false, isChild = false, - forkCount = 0 - } = options || {}; + forkCount = 0, + } = options || {} // TreeSelect will add the prefix, so we just need to account for its width - const prefixWidth = isGroupHeader && forkCount > 0 ? PARENT_PREFIX_WIDTH : isChild ? CHILD_PREFIX_WIDTH : 0; - const sessionCountSuffix = isGroupHeader && forkCount > 0 ? ` (+${forkCount} other ${forkCount === 1 ? 'session' : 'sessions'})` : ''; - const sidechainSuffix = log.isSidechain ? ' (sidechain)' : ''; - const maxSummaryWidth = maxLabelWidth - prefixWidth - sidechainSuffix.length - sessionCountSuffix.length; - const truncatedSummary = normalizeAndTruncateToWidth(getLogDisplayTitle(log), maxSummaryWidth); - return `${truncatedSummary}${sidechainSuffix}${sessionCountSuffix}`; + const prefixWidth = + isGroupHeader && forkCount > 0 + ? PARENT_PREFIX_WIDTH + : isChild + ? CHILD_PREFIX_WIDTH + : 0 + + const sessionCountSuffix = + isGroupHeader && forkCount > 0 + ? ` (+${forkCount} other ${forkCount === 1 ? 'session' : 'sessions'})` + : '' + + const sidechainSuffix = log.isSidechain ? ' (sidechain)' : '' + + const maxSummaryWidth = + maxLabelWidth - + prefixWidth - + sidechainSuffix.length - + sessionCountSuffix.length + const truncatedSummary = normalizeAndTruncateToWidth( + getLogDisplayTitle(log), + maxSummaryWidth, + ) + return `${truncatedSummary}${sidechainSuffix}${sessionCountSuffix}` } -function buildLogMetadata(log: LogOption, options?: { - isChild?: boolean; - showProjectPath?: boolean; -}): string { - const { - isChild = false, - showProjectPath = false - } = options || {}; + +function buildLogMetadata( + log: LogOption, + options?: { isChild?: boolean; showProjectPath?: boolean }, +): string { + const { isChild = false, showProjectPath = false } = options || {} // Match the child prefix width for proper alignment - const childPadding = isChild ? ' ' : ''; // 4 spaces to match ' ▸ ' - const baseMetadata = formatLogMetadata(log); - const projectSuffix = showProjectPath && log.projectPath ? ` · ${log.projectPath}` : ''; - return childPadding + baseMetadata + projectSuffix; + const childPadding = isChild ? ' ' : '' // 4 spaces to match ' ▸ ' + const baseMetadata = formatLogMetadata(log) + const projectSuffix = + showProjectPath && log.projectPath ? ` · ${log.projectPath}` : '' + return childPadding + baseMetadata + projectSuffix } -export function LogSelector(t0) { - const $ = _c(247); - const { - logs, - maxHeight: t1, - forceWidth, - onCancel, - onSelect, - onLogsChanged, - onLoadMore, - initialSearchQuery, - showAllProjects: t2, - onToggleAllProjects, - onAgenticSearch - } = t0; - const maxHeight = t1 === undefined ? Infinity : t1; - const showAllProjects = t2 === undefined ? false : t2; - const terminalSize = useTerminalSize(); - const columns = forceWidth === undefined ? terminalSize.columns : forceWidth; - const exitState = useExitOnCtrlCDWithKeybindings(onCancel); - const isTerminalFocused = useTerminalFocus(); - let t3; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t3 = isCustomTitleEnabled(); - $[0] = t3; - } else { - t3 = $[0]; - } - const isResumeWithRenameEnabled = t3; - const isDeepSearchEnabled = false; - const [themeName] = useTheme(); - let t4; - if ($[1] !== themeName) { - t4 = getTheme(themeName); - $[1] = themeName; - $[2] = t4; - } else { - t4 = $[2]; - } - const theme = t4; - let t5; - if ($[3] !== theme.warning) { - t5 = text => applyColor(text, theme.warning as Color); - $[3] = theme.warning; - $[4] = t5; - } else { - t5 = $[4]; - } - const highlightColor = t5; - const isAgenticSearchEnabled = false; - const [currentBranch, setCurrentBranch] = React.useState(null); - const [branchFilterEnabled, setBranchFilterEnabled] = React.useState(false); - const [showAllWorktrees, setShowAllWorktrees] = React.useState(false); - const [hasMultipleWorktrees, setHasMultipleWorktrees] = React.useState(false); - let t6; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t6 = getOriginalCwd(); - $[5] = t6; - } else { - t6 = $[5]; - } - const currentCwd = t6; - const [renameValue, setRenameValue] = React.useState(""); - const [renameCursorOffset, setRenameCursorOffset] = React.useState(0); - let t7; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t7 = new Set(); - $[6] = t7; - } else { - t7 = $[6]; - } - const [expandedGroupSessionIds, setExpandedGroupSessionIds] = React.useState(t7); - const [focusedNode, setFocusedNode] = React.useState(null); - const [focusedIndex, setFocusedIndex] = React.useState(1); - const [viewMode, setViewMode] = React.useState("list"); - const [previewLog, setPreviewLog] = React.useState(null); - const prevFocusedIdRef = React.useRef(null); - const [selectedTagIndex, setSelectedTagIndex] = React.useState(0); - let t8; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t8 = { - status: "idle" - }; - $[7] = t8; - } else { - t8 = $[7]; - } - const [agenticSearchState, setAgenticSearchState] = React.useState(t8); - const [isAgenticSearchOptionFocused, setIsAgenticSearchOptionFocused] = React.useState(false); - const agenticSearchAbortRef = React.useRef(null); - const t9 = viewMode === "search" && agenticSearchState.status !== "searching"; - let t10; - let t11; - let t12; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t10 = () => { - setViewMode("list"); - logEvent("tengu_session_search_toggled", { - enabled: false - }); - }; - t11 = () => { - setViewMode("list"); - logEvent("tengu_session_search_toggled", { - enabled: false - }); - }; - t12 = ["n"]; - $[8] = t10; - $[9] = t11; - $[10] = t12; - } else { - t10 = $[8]; - t11 = $[9]; - t12 = $[10]; - } - const t13 = initialSearchQuery || ""; - let t14; - if ($[11] !== t13 || $[12] !== t9) { - t14 = { - isActive: t9, - onExit: t10, - onExitUp: t11, - passthroughCtrlKeys: t12, - initialQuery: t13 - }; - $[11] = t13; - $[12] = t9; - $[13] = t14; - } else { - t14 = $[13]; - } + +export function LogSelector({ + logs, + maxHeight = Infinity, + forceWidth, + onCancel, + onSelect, + onLogsChanged, + onLoadMore, + initialSearchQuery, + showAllProjects = false, + onToggleAllProjects, + onAgenticSearch, +}: LogSelectorProps): React.ReactNode { + const terminalSize = useTerminalSize() + const columns = forceWidth === undefined ? terminalSize.columns : forceWidth + const exitState = useExitOnCtrlCDWithKeybindings(onCancel) + const isTerminalFocused = useTerminalFocus() + const isResumeWithRenameEnabled = isCustomTitleEnabled() + const isDeepSearchEnabled = process.env.USER_TYPE === 'ant' + const [themeName] = useTheme() + const theme = getTheme(themeName) + const highlightColor = React.useMemo( + () => (text: string) => applyColor(text, theme.warning as Color), + [theme.warning], + ) + const isAgenticSearchEnabled = process.env.USER_TYPE === 'ant' + + const [currentBranch, setCurrentBranch] = React.useState(null) + const [branchFilterEnabled, setBranchFilterEnabled] = React.useState(false) + const [showAllWorktrees, setShowAllWorktrees] = React.useState(false) + const [hasMultipleWorktrees, setHasMultipleWorktrees] = React.useState(false) + const currentCwd = React.useMemo(() => getOriginalCwd(), []) + const [renameValue, setRenameValue] = React.useState('') + const [renameCursorOffset, setRenameCursorOffset] = React.useState(0) + const [expandedGroupSessionIds, setExpandedGroupSessionIds] = React.useState< + Set + >(new Set()) + const [focusedNode, setFocusedNode] = React.useState(null) + // Track focused index for scroll position display in title + const [focusedIndex, setFocusedIndex] = React.useState(1) + const [viewMode, setViewMode] = React.useState< + 'list' | 'preview' | 'rename' | 'search' + >('list') + const [previewLog, setPreviewLog] = React.useState(null) + const prevFocusedIdRef = React.useRef(null) + const [selectedTagIndex, setSelectedTagIndex] = React.useState(0) + + // Agentic search state + const [agenticSearchState, setAgenticSearchState] = + React.useState({ status: 'idle' }) + // Track if the "Search deeply using Claude" option is focused + const [isAgenticSearchOptionFocused, setIsAgenticSearchOptionFocused] = + React.useState(false) + // AbortController for cancelling agentic search + const agenticSearchAbortRef = React.useRef(null) + const { query: searchQuery, setQuery: setSearchQuery, - cursorOffset: searchCursorOffset - } = useSearchInput(t14); - const deferredSearchQuery = React.useDeferredValue(searchQuery); - const [debouncedDeepSearchQuery, setDebouncedDeepSearchQuery] = React.useState(""); - let t15; - let t16; - if ($[14] !== deferredSearchQuery) { - t15 = () => { - if (!deferredSearchQuery) { - setDebouncedDeepSearchQuery(""); - return; - } - const timeoutId = setTimeout(setDebouncedDeepSearchQuery, 300, deferredSearchQuery); - return () => clearTimeout(timeoutId); - }; - t16 = [deferredSearchQuery]; - $[14] = deferredSearchQuery; - $[15] = t15; - $[16] = t16; - } else { - t15 = $[15]; - t16 = $[16]; - } - React.useEffect(t15, t16); - const [deepSearchResults, setDeepSearchResults] = React.useState(null); - const [isSearching, setIsSearching] = React.useState(false); - let t17; - let t18; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t17 = () => { - getBranch().then(branch => setCurrentBranch(branch)); - getWorktreePaths(currentCwd).then(paths => { - setHasMultipleWorktrees(paths.length > 1); - }); - }; - t18 = [currentCwd]; - $[17] = t17; - $[18] = t18; - } else { - t17 = $[17]; - t18 = $[18]; - } - React.useEffect(t17, t18); - const searchableTextByLog = new Map(logs.map(_temp)); - let t19; - t19 = null; - let t20; - if ($[19] !== logs) { - t20 = getUniqueTags(logs); - $[19] = logs; - $[20] = t20; - } else { - t20 = $[20]; - } - const uniqueTags = t20; - const hasTags = uniqueTags.length > 0; - let t21; - if ($[21] !== hasTags || $[22] !== uniqueTags) { - t21 = hasTags ? ["All", ...uniqueTags] : []; - $[21] = hasTags; - $[22] = uniqueTags; - $[23] = t21; - } else { - t21 = $[23]; - } - const tagTabs = t21; - const effectiveTagIndex = tagTabs.length > 0 && selectedTagIndex < tagTabs.length ? selectedTagIndex : 0; - const selectedTab = tagTabs[effectiveTagIndex]; - const tagFilter = selectedTab === "All" ? undefined : selectedTab; - const tagTabsLines = hasTags ? 1 : 0; - let filtered = logs; - if (isResumeWithRenameEnabled) { - let t22; - if ($[24] !== logs) { - t22 = logs.filter(_temp2); - $[24] = logs; - $[25] = t22; - } else { - t22 = $[25]; + cursorOffset: searchCursorOffset, + } = useSearchInput({ + isActive: + viewMode === 'search' && agenticSearchState.status !== 'searching', + onExit: () => { + setViewMode('list') + logEvent('tengu_session_search_toggled', { enabled: false }) + }, + onExitUp: () => { + setViewMode('list') + logEvent('tengu_session_search_toggled', { enabled: false }) + }, + passthroughCtrlKeys: ['n'], + initialQuery: initialSearchQuery || '', + }) + + // Debounce transcript search for performance (title search is instant) + const deferredSearchQuery = React.useDeferredValue(searchQuery) + + // Additional debounce for deep search - wait 300ms after typing stops + const [debouncedDeepSearchQuery, setDebouncedDeepSearchQuery] = + React.useState('') + React.useEffect(() => { + if (!deferredSearchQuery) { + setDebouncedDeepSearchQuery('') + return } - filtered = t22; - } - if (tagFilter !== undefined) { - let t22; - if ($[26] !== filtered || $[27] !== tagFilter) { - let t23; - if ($[29] !== tagFilter) { - t23 = log_2 => log_2.tag === tagFilter; - $[29] = tagFilter; - $[30] = t23; - } else { - t23 = $[30]; - } - t22 = filtered.filter(t23); - $[26] = filtered; - $[27] = tagFilter; - $[28] = t22; - } else { - t22 = $[28]; + const timeoutId = setTimeout( + setDebouncedDeepSearchQuery, + 300, + deferredSearchQuery, + ) + return () => clearTimeout(timeoutId) + }, [deferredSearchQuery]) + + // State for async deep search results + const [deepSearchResults, setDeepSearchResults] = React.useState<{ + results: Array<{ log: LogOption; score?: number; searchableText: string }> + query: string + } | null>(null) + const [isSearching, setIsSearching] = React.useState(false) + + React.useEffect(() => { + void getBranch().then(branch => setCurrentBranch(branch)) + void getWorktreePaths(currentCwd).then(paths => { + setHasMultipleWorktrees(paths.length > 1) + }) + }, [currentCwd]) + + // Memoize searchable text extraction - only recompute when logs change + const searchableTextByLog = React.useMemo( + () => new Map(logs.map(log => [log, buildSearchableText(log)])), + [logs], + ) + + // Pre-build Fuse index once when logs change (not on every search query) + const fuseIndex = React.useMemo(() => { + if (!isDeepSearchEnabled) return null + + const logsWithText = logs + .map(log => ({ + log, + searchableText: searchableTextByLog.get(log) ?? '', + })) + .filter(item => item.searchableText) + + return new Fuse(logsWithText, { + keys: ['searchableText'], + threshold: FUSE_THRESHOLD, + ignoreLocation: true, + includeScore: true, + }) + }, [logs, searchableTextByLog, isDeepSearchEnabled]) + + // Compute unique tags from logs (before any filtering) + const uniqueTags = React.useMemo(() => getUniqueTags(logs), [logs]) + const hasTags = uniqueTags.length > 0 + const tagTabs = React.useMemo( + () => (hasTags ? ['All', ...uniqueTags] : []), + [hasTags, uniqueTags], + ) + + // Clamp out-of-bounds index (e.g., after logs change) without an extra render + const effectiveTagIndex = + tagTabs.length > 0 && selectedTagIndex < tagTabs.length + ? selectedTagIndex + : 0 + const selectedTab = tagTabs[effectiveTagIndex] + const tagFilter = selectedTab === 'All' ? undefined : selectedTab + + // Tag tabs are now a single line with horizontal scrolling + const tagTabsLines = hasTags ? 1 : 0 + + // Base filtering (instant) - applies tag, branch, and resume filters + const baseFilteredLogs = React.useMemo(() => { + let filtered = logs + if (isResumeWithRenameEnabled) { + filtered = logs.filter(log => { + const currentSessionId = getSessionId() + const logSessionId = getSessionIdFromLog(log) + const isCurrentSession = + currentSessionId && logSessionId === currentSessionId + // Always show current session + if (isCurrentSession) { + return true + } + // Always show sessions with custom titles (e.g., loop mode sessions) + if (log.customTitle) { + return true + } + // For full logs, check messages array + const fromMessages = getFirstMeaningfulUserMessageTextContent( + log.messages, + ) + if (fromMessages) { + return true + } + // All logs reaching this component are enriched — include if + // they have a prompt or custom title + if (log.firstPrompt || log.customTitle) { + return true + } + return false + }) } - filtered = t22; - } - if (branchFilterEnabled && currentBranch) { - let t22; - if ($[31] !== currentBranch || $[32] !== filtered) { - let t23; - if ($[34] !== currentBranch) { - t23 = log_3 => log_3.gitBranch === currentBranch; - $[34] = currentBranch; - $[35] = t23; - } else { - t23 = $[35]; - } - t22 = filtered.filter(t23); - $[31] = currentBranch; - $[32] = filtered; - $[33] = t22; - } else { - t22 = $[33]; + + // Apply tag filter if specified + if (tagFilter !== undefined) { + filtered = filtered.filter(log => log.tag === tagFilter) } - filtered = t22; - } - if (hasMultipleWorktrees && !showAllWorktrees) { - let t22; - if ($[36] !== filtered) { - let t23; - if ($[38] === Symbol.for("react.memo_cache_sentinel")) { - t23 = log_4 => log_4.projectPath === currentCwd; - $[38] = t23; - } else { - t23 = $[38]; - } - t22 = filtered.filter(t23); - $[36] = filtered; - $[37] = t22; - } else { - t22 = $[37]; + + if (branchFilterEnabled && currentBranch) { + filtered = filtered.filter(log => log.gitBranch === currentBranch) } - filtered = t22; - } - const baseFilteredLogs = filtered; - let t22; - bb0: { + + if (hasMultipleWorktrees && !showAllWorktrees) { + filtered = filtered.filter(log => log.projectPath === currentCwd) + } + + return filtered + }, [ + logs, + isResumeWithRenameEnabled, + tagFilter, + branchFilterEnabled, + currentBranch, + hasMultipleWorktrees, + showAllWorktrees, + currentCwd, + ]) + + // Instant title/branch/tag/PR filtering (runs on every keystroke, but is fast) + const titleFilteredLogs = React.useMemo(() => { if (!searchQuery) { - t22 = baseFilteredLogs; - break bb0; + return baseFilteredLogs } - let t23; - if ($[39] !== baseFilteredLogs || $[40] !== searchQuery) { - const query = searchQuery.toLowerCase(); - t23 = baseFilteredLogs.filter(log_5 => { - const displayedTitle = getLogDisplayTitle(log_5).toLowerCase(); - const branch_0 = (log_5.gitBranch || "").toLowerCase(); - const tag = (log_5.tag || "").toLowerCase(); - const prInfo = log_5.prNumber ? `pr #${log_5.prNumber} ${log_5.prRepository || ""}`.toLowerCase() : ""; - return displayedTitle.includes(query) || branch_0.includes(query) || tag.includes(query) || prInfo.includes(query); - }); - $[39] = baseFilteredLogs; - $[40] = searchQuery; - $[41] = t23; - } else { - t23 = $[41]; + const query = searchQuery.toLowerCase() + return baseFilteredLogs.filter(log => { + const displayedTitle = getLogDisplayTitle(log).toLowerCase() + const branch = (log.gitBranch || '').toLowerCase() + const tag = (log.tag || '').toLowerCase() + const prInfo = log.prNumber + ? `pr #${log.prNumber} ${log.prRepository || ''}`.toLowerCase() + : '' + return ( + displayedTitle.includes(query) || + branch.includes(query) || + tag.includes(query) || + prInfo.includes(query) + ) + }) + }, [baseFilteredLogs, searchQuery]) + + // Show searching indicator when query is pending debounce + React.useEffect(() => { + if ( + isDeepSearchEnabled && + deferredSearchQuery && + deferredSearchQuery !== debouncedDeepSearchQuery + ) { + setIsSearching(true) } - t22 = t23; - } - const titleFilteredLogs = t22; - let t23; - let t24; - if ($[42] !== debouncedDeepSearchQuery || $[43] !== deferredSearchQuery) { - t23 = () => { - if (false && deferredSearchQuery && deferredSearchQuery !== debouncedDeepSearchQuery) { - setIsSearching(true); - } - }; - t24 = [deferredSearchQuery, debouncedDeepSearchQuery, false]; - $[42] = debouncedDeepSearchQuery; - $[43] = deferredSearchQuery; - $[44] = t23; - $[45] = t24; - } else { - t23 = $[44]; - t24 = $[45]; - } - React.useEffect(t23, t24); - let t25; - let t26; - if ($[46] !== debouncedDeepSearchQuery) { - t25 = () => { - if (true || !debouncedDeepSearchQuery || true) { - setDeepSearchResults(null); - setIsSearching(false); - return; - } - const timeoutId_0 = setTimeout(_temp5, 0, null, debouncedDeepSearchQuery, setDeepSearchResults, setIsSearching); - return () => { - clearTimeout(timeoutId_0); - }; - }; - t26 = [debouncedDeepSearchQuery, null, false]; - $[46] = debouncedDeepSearchQuery; - $[47] = t25; - $[48] = t26; - } else { - t25 = $[47]; - t26 = $[48]; - } - React.useEffect(t25, t26); - let filtered_0; - let snippetMap; - if ($[49] !== debouncedDeepSearchQuery || $[50] !== deepSearchResults || $[51] !== titleFilteredLogs) { - snippetMap = new Map(); - filtered_0 = titleFilteredLogs; - if (deepSearchResults && debouncedDeepSearchQuery && deepSearchResults.query === debouncedDeepSearchQuery) { + }, [deferredSearchQuery, debouncedDeepSearchQuery, isDeepSearchEnabled]) + + // Async deep search effect - runs after 300ms debounce + React.useEffect(() => { + if (!isDeepSearchEnabled || !debouncedDeepSearchQuery || !fuseIndex) { + setDeepSearchResults(null) + setIsSearching(false) + return + } + + // Use setTimeout(0) to yield to the event loop - prevents UI freeze + const timeoutId = setTimeout( + ( + fuseIndex, + debouncedDeepSearchQuery, + setDeepSearchResults, + setIsSearching, + ) => { + const results = fuseIndex.search(debouncedDeepSearchQuery) + + // Sort by date (newest first), with relevance as tie-breaker within same minute + results.sort((a, b) => { + const aTime = new Date(a.item.log.modified).getTime() + const bTime = new Date(b.item.log.modified).getTime() + const timeDiff = bTime - aTime + if (Math.abs(timeDiff) > DATE_TIE_THRESHOLD_MS) { + return timeDiff + } + // Within same minute window, use relevance score (lower is better) + return (a.score ?? 1) - (b.score ?? 1) + }) + + setDeepSearchResults({ + results: results.map(r => ({ + log: r.item.log, + score: r.score, + searchableText: r.item.searchableText, + })), + query: debouncedDeepSearchQuery, + }) + setIsSearching(false) + }, + 0, + fuseIndex, + debouncedDeepSearchQuery, + setDeepSearchResults, + setIsSearching, + ) + + return () => { + clearTimeout(timeoutId) + } + }, [debouncedDeepSearchQuery, fuseIndex, isDeepSearchEnabled]) + + // Merge title matches with async deep search results + const { filteredLogs, snippets } = React.useMemo(() => { + const snippetMap = new Map() + + // Start with instant title matches + let filtered = titleFilteredLogs + + // Merge in deep search results if available and query matches + if ( + deepSearchResults && + debouncedDeepSearchQuery && + deepSearchResults.query === debouncedDeepSearchQuery + ) { + // Extract snippets from deep search results for (const result of deepSearchResults.results) { if (result.searchableText) { - const snippet = extractSnippet(result.searchableText, debouncedDeepSearchQuery, SNIPPET_CONTEXT_CHARS); + const snippet = extractSnippet( + result.searchableText, + debouncedDeepSearchQuery, + SNIPPET_CONTEXT_CHARS, + ) if (snippet) { - snippetMap.set(result.log, snippet); + snippetMap.set(result.log, snippet) } } } - let t27; - if ($[54] !== filtered_0) { - t27 = new Set(filtered_0.map(_temp6)); - $[54] = filtered_0; - $[55] = t27; - } else { - t27 = $[55]; - } - const titleMatchIds = t27; - let t28; - if ($[56] !== deepSearchResults.results || $[57] !== filtered_0 || $[58] !== titleMatchIds) { - let t29; - if ($[60] !== titleMatchIds) { - t29 = log_7 => !titleMatchIds.has(log_7.messages[0]?.uuid); - $[60] = titleMatchIds; - $[61] = t29; - } else { - t29 = $[61]; - } - const transcriptOnlyMatches = deepSearchResults.results.map(_temp7).filter(t29); - t28 = [...filtered_0, ...transcriptOnlyMatches]; - $[56] = deepSearchResults.results; - $[57] = filtered_0; - $[58] = titleMatchIds; - $[59] = t28; - } else { - t28 = $[59]; - } - filtered_0 = t28; + + // Add transcript-only matches (not already in title matches) + const titleMatchIds = new Set(filtered.map(log => log.messages[0]?.uuid)) + const transcriptOnlyMatches = deepSearchResults.results + .map(r => r.log) + .filter(log => !titleMatchIds.has(log.messages[0]?.uuid)) + filtered = [...filtered, ...transcriptOnlyMatches] } - $[49] = debouncedDeepSearchQuery; - $[50] = deepSearchResults; - $[51] = titleFilteredLogs; - $[52] = filtered_0; - $[53] = snippetMap; - } else { - filtered_0 = $[52]; - snippetMap = $[53]; - } - let t27; - if ($[62] !== filtered_0 || $[63] !== snippetMap) { - t27 = { - filteredLogs: filtered_0, - snippets: snippetMap - }; - $[62] = filtered_0; - $[63] = snippetMap; - $[64] = t27; - } else { - t27 = $[64]; - } - const { - filteredLogs, - snippets - } = t27; - let t28; - bb1: { - if (agenticSearchState.status === "results" && agenticSearchState.results.length > 0) { - t28 = agenticSearchState.results; - break bb1; + + return { filteredLogs: filtered, snippets: snippetMap } + }, [titleFilteredLogs, deepSearchResults, debouncedDeepSearchQuery]) + + // Use agentic search results when available and non-empty, otherwise use regular filtered logs + const displayedLogs = React.useMemo(() => { + if ( + agenticSearchState.status === 'results' && + agenticSearchState.results.length > 0 + ) { + return agenticSearchState.results } - t28 = filteredLogs; - } - const displayedLogs = t28; - const maxLabelWidth = Math.max(30, columns - 4); - let t29; - bb2: { + return filteredLogs + }, [agenticSearchState, filteredLogs]) + + // Calculate available width for the summary text + const maxLabelWidth = Math.max(30, columns - 4) + + // Build tree nodes for grouped view + const treeNodes = React.useMemo(() => { if (!isResumeWithRenameEnabled) { - let t30; - if ($[65] === Symbol.for("react.memo_cache_sentinel")) { - t30 = []; - $[65] = t30; - } else { - t30 = $[65]; - } - t29 = t30; - break bb2; + return [] } - let t30; - if ($[66] !== displayedLogs || $[67] !== highlightColor || $[68] !== maxLabelWidth || $[69] !== showAllProjects || $[70] !== snippets) { - const sessionGroups = groupLogsBySessionId(displayedLogs); - t30 = Array.from(sessionGroups.entries()).map(t31 => { - const [sessionId, groupLogs] = t31; - const latestLog = groupLogs[0]; - const indexInFiltered = displayedLogs.indexOf(latestLog); - const snippet_0 = snippets.get(latestLog); - const snippetStr = snippet_0 ? formatSnippet(snippet_0, highlightColor) : null; + + const sessionGroups = groupLogsBySessionId(displayedLogs) + + return Array.from(sessionGroups.entries()).map( + ([sessionId, groupLogs]): LogTreeNode => { + const latestLog = groupLogs[0]! + const indexInFiltered = displayedLogs.indexOf(latestLog) + const snippet = snippets.get(latestLog) + const snippetStr = snippet + ? formatSnippet(snippet, highlightColor) + : null + if (groupLogs.length === 1) { + // Single log - no children const metadata = buildLogMetadata(latestLog, { - showProjectPath: showAllProjects - }); + showProjectPath: showAllProjects, + }) return { id: `log:${sessionId}:0`, - value: { - log: latestLog, - indexInFiltered - }, + value: { log: latestLog, indexInFiltered }, label: buildLogLabel(latestLog, maxLabelWidth), description: snippetStr ? `${metadata}\n ${snippetStr}` : metadata, - dimDescription: true - }; + dimDescription: true, + } } - const forkCount = groupLogs.length - 1; - const children = groupLogs.slice(1).map((log_8, index) => { - const childIndexInFiltered = displayedLogs.indexOf(log_8); - const childSnippet = snippets.get(log_8); - const childSnippetStr = childSnippet ? formatSnippet(childSnippet, highlightColor) : null; - const childMetadata = buildLogMetadata(log_8, { + + // Multiple logs - parent with children + const forkCount = groupLogs.length - 1 + const children: LogTreeNode[] = groupLogs.slice(1).map((log, index) => { + const childIndexInFiltered = displayedLogs.indexOf(log) + const childSnippet = snippets.get(log) + const childSnippetStr = childSnippet + ? formatSnippet(childSnippet, highlightColor) + : null + const childMetadata = buildLogMetadata(log, { isChild: true, - showProjectPath: showAllProjects - }); + showProjectPath: showAllProjects, + }) return { id: `log:${sessionId}:${index + 1}`, - value: { - log: log_8, - indexInFiltered: childIndexInFiltered - }, - label: buildLogLabel(log_8, maxLabelWidth, { - isChild: true - }), - description: childSnippetStr ? `${childMetadata}\n ${childSnippetStr}` : childMetadata, - dimDescription: true - }; - }); + value: { log, indexInFiltered: childIndexInFiltered }, + label: buildLogLabel(log, maxLabelWidth, { isChild: true }), + description: childSnippetStr + ? `${childMetadata}\n ${childSnippetStr}` + : childMetadata, + dimDescription: true, + } + }) + const parentMetadata = buildLogMetadata(latestLog, { - showProjectPath: showAllProjects - }); + showProjectPath: showAllProjects, + }) return { id: `group:${sessionId}`, - value: { - log: latestLog, - indexInFiltered - }, + value: { log: latestLog, indexInFiltered }, label: buildLogLabel(latestLog, maxLabelWidth, { isGroupHeader: true, - forkCount + forkCount, }), - description: snippetStr ? `${parentMetadata}\n ${snippetStr}` : parentMetadata, + description: snippetStr + ? `${parentMetadata}\n ${snippetStr}` + : parentMetadata, dimDescription: true, - children - }; - }); - $[66] = displayedLogs; - $[67] = highlightColor; - $[68] = maxLabelWidth; - $[69] = showAllProjects; - $[70] = snippets; - $[71] = t30; - } else { - t30 = $[71]; - } - t29 = t30; - } - const treeNodes = t29; - let t30; - bb3: { + children, + } + }, + ) + }, [ + isResumeWithRenameEnabled, + displayedLogs, + maxLabelWidth, + showAllProjects, + snippets, + highlightColor, + ]) + + // Build options for old flat list view + const flatOptions = React.useMemo(() => { if (isResumeWithRenameEnabled) { - let t31; - if ($[72] === Symbol.for("react.memo_cache_sentinel")) { - t31 = []; - $[72] = t31; - } else { - t31 = $[72]; - } - t30 = t31; - break bb3; + return [] } - let t31; - if ($[73] !== displayedLogs || $[74] !== highlightColor || $[75] !== maxLabelWidth || $[76] !== showAllProjects || $[77] !== snippets) { - let t32; - if ($[79] !== highlightColor || $[80] !== maxLabelWidth || $[81] !== showAllProjects || $[82] !== snippets) { - t32 = (log_9, index_0) => { - const rawSummary = getLogDisplayTitle(log_9); - const summaryWithSidechain = rawSummary + (log_9.isSidechain ? " (sidechain)" : ""); - const summary = normalizeAndTruncateToWidth(summaryWithSidechain, maxLabelWidth); - const baseDescription = formatLogMetadata(log_9); - const projectSuffix = showAllProjects && log_9.projectPath ? ` · ${log_9.projectPath}` : ""; - const snippet_1 = snippets.get(log_9); - const snippetStr_0 = snippet_1 ? formatSnippet(snippet_1, highlightColor) : null; - return { - label: summary, - description: snippetStr_0 ? `${baseDescription}${projectSuffix}\n ${snippetStr_0}` : baseDescription + projectSuffix, - dimDescription: true, - value: index_0.toString() - }; - }; - $[79] = highlightColor; - $[80] = maxLabelWidth; - $[81] = showAllProjects; - $[82] = snippets; - $[83] = t32; - } else { - t32 = $[83]; + + return displayedLogs.map((log, index) => { + const rawSummary = getLogDisplayTitle(log) + const summaryWithSidechain = + rawSummary + (log.isSidechain ? ' (sidechain)' : '') + const summary = normalizeAndTruncateToWidth( + summaryWithSidechain, + maxLabelWidth, + ) + + const baseDescription = formatLogMetadata(log) + const projectSuffix = + showAllProjects && log.projectPath ? ` · ${log.projectPath}` : '' + const snippet = snippets.get(log) + const snippetStr = snippet ? formatSnippet(snippet, highlightColor) : null + + return { + label: summary, + description: snippetStr + ? `${baseDescription}${projectSuffix}\n ${snippetStr}` + : baseDescription + projectSuffix, + dimDescription: true, + value: index.toString(), } - t31 = displayedLogs.map(t32); - $[73] = displayedLogs; - $[74] = highlightColor; - $[75] = maxLabelWidth; - $[76] = showAllProjects; - $[77] = snippets; - $[78] = t31; - } else { - t31 = $[78]; + }) + }, [ + isResumeWithRenameEnabled, + displayedLogs, + highlightColor, + maxLabelWidth, + showAllProjects, + snippets, + ]) + + // Derive the focused log from focusedNode + const focusedLog = focusedNode?.value.log ?? null + + const getExpandCollapseHint = (): string => { + if (!isResumeWithRenameEnabled || !focusedLog) return '' + const sessionId = getSessionIdFromLog(focusedLog) + if (!sessionId) return '' + + const sessionLogs = displayedLogs.filter( + log => getSessionIdFromLog(log) === sessionId, + ) + const hasMultipleLogs = sessionLogs.length > 1 + + if (!hasMultipleLogs) return '' + + const isExpanded = expandedGroupSessionIds.has(sessionId) + const isChildNode = sessionLogs.indexOf(focusedLog) > 0 + + if (isChildNode) { + return '← to collapse' } - t30 = t31; + + return isExpanded ? '← to collapse' : '→ to expand' } - const flatOptions = t30; - const focusedLog = focusedNode?.value.log ?? null; - let t31; - if ($[84] !== displayedLogs || $[85] !== expandedGroupSessionIds || $[86] !== focusedLog) { - t31 = () => { - if (!isResumeWithRenameEnabled || !focusedLog) { - return ""; + + const handleRenameSubmit = React.useCallback(async () => { + const sessionId = focusedLog ? getSessionIdFromLog(focusedLog) : undefined + if (!focusedLog || !sessionId) { + setViewMode('list') + setRenameValue('') + return + } + + if (renameValue.trim()) { + // Pass fullPath for cross-project sessions (different worktrees) + await saveCustomTitle(sessionId, renameValue.trim(), focusedLog.fullPath) + if (isResumeWithRenameEnabled && onLogsChanged) { + onLogsChanged() } - const sessionId_0 = getSessionIdFromLog(focusedLog); - if (!sessionId_0) { - return ""; + } + setViewMode('list') + setRenameValue('') + }, [focusedLog, renameValue, onLogsChanged, isResumeWithRenameEnabled]) + + const exitSearchMode = React.useCallback(() => { + setViewMode('list') + logEvent('tengu_session_search_toggled', { enabled: false }) + }, []) + + const enterSearchMode = React.useCallback(() => { + setViewMode('search') + logEvent('tengu_session_search_toggled', { enabled: true }) + }, []) + + // Handler for triggering agentic search + const handleAgenticSearch = React.useCallback(async () => { + if (!searchQuery.trim() || !onAgenticSearch || !isAgenticSearchEnabled) { + return + } + + // Abort any previous search + agenticSearchAbortRef.current?.abort() + const abortController = new AbortController() + agenticSearchAbortRef.current = abortController + + setAgenticSearchState({ status: 'searching' }) + logEvent('tengu_agentic_search_started', { + query_length: searchQuery.length, + }) + + try { + const results = await onAgenticSearch( + searchQuery, + logs, + abortController.signal, + ) + // Check if aborted before updating state + if (abortController.signal.aborted) { + return } - const sessionLogs = displayedLogs.filter(log_10 => getSessionIdFromLog(log_10) === sessionId_0); - const hasMultipleLogs = sessionLogs.length > 1; - if (!hasMultipleLogs) { - return ""; + setAgenticSearchState({ status: 'results', results, query: searchQuery }) + logEvent('tengu_agentic_search_completed', { + query_length: searchQuery.length, + results_count: results.length, + }) + } catch (error) { + // Don't show error for aborted requests + if (abortController.signal.aborted) { + return } - const isExpanded = expandedGroupSessionIds.has(sessionId_0); - const isChildNode = sessionLogs.indexOf(focusedLog) > 0; - if (isChildNode) { - return "\u2190 to collapse"; - } - return isExpanded ? "\u2190 to collapse" : "\u2192 to expand"; - }; - $[84] = displayedLogs; - $[85] = expandedGroupSessionIds; - $[86] = focusedLog; - $[87] = t31; - } else { - t31 = $[87]; - } - const getExpandCollapseHint = t31; - let t32; - if ($[88] !== focusedLog || $[89] !== onLogsChanged || $[90] !== renameValue) { - t32 = async () => { - const sessionId_1 = focusedLog ? getSessionIdFromLog(focusedLog) : undefined; - if (!focusedLog || !sessionId_1) { - setViewMode("list"); - setRenameValue(""); - return; - } - if (renameValue.trim()) { - await saveCustomTitle(sessionId_1, renameValue.trim(), focusedLog.fullPath); - if (isResumeWithRenameEnabled && onLogsChanged) { - onLogsChanged(); - } - } - setViewMode("list"); - setRenameValue(""); - }; - $[88] = focusedLog; - $[89] = onLogsChanged; - $[90] = renameValue; - $[91] = t32; - } else { - t32 = $[91]; - } - const handleRenameSubmit = t32; - let t33; - if ($[92] === Symbol.for("react.memo_cache_sentinel")) { - t33 = () => { - setViewMode("list"); - logEvent("tengu_session_search_toggled", { - enabled: false - }); - }; - $[92] = t33; - } else { - t33 = $[92]; - } - const exitSearchMode = t33; - let t34; - if ($[93] === Symbol.for("react.memo_cache_sentinel")) { - t34 = () => { - setViewMode("search"); - logEvent("tengu_session_search_toggled", { - enabled: true - }); - }; - $[93] = t34; - } else { - t34 = $[93]; - } - const enterSearchMode = t34; - let t35; - if ($[94] !== logs || $[95] !== onAgenticSearch || $[96] !== searchQuery) { - t35 = async () => { - if (!searchQuery.trim() || !onAgenticSearch || true) { - return; - } - agenticSearchAbortRef.current?.abort(); - const abortController = new AbortController(); - agenticSearchAbortRef.current = abortController; setAgenticSearchState({ - status: "searching" - }); - logEvent("tengu_agentic_search_started", { - query_length: searchQuery.length - }); - ; - try { - const results_0 = await onAgenticSearch(searchQuery, logs, abortController.signal); - if (abortController.signal.aborted) { - return; - } - setAgenticSearchState({ - status: "results", - results: results_0, - query: searchQuery - }); - logEvent("tengu_agentic_search_completed", { - query_length: searchQuery.length, - results_count: results_0.length - }); - } catch (t36) { - const error = t36; - if (abortController.signal.aborted) { - return; - } - setAgenticSearchState({ - status: "error", - message: error instanceof Error ? error.message : "Search failed" - }); - logEvent("tengu_agentic_search_error", { - query_length: searchQuery.length - }); + status: 'error', + message: error instanceof Error ? error.message : 'Search failed', + }) + logEvent('tengu_agentic_search_error', { + query_length: searchQuery.length, + }) + } + }, [searchQuery, onAgenticSearch, isAgenticSearchEnabled, logs]) + + // Clear agentic search results/error when query changes + React.useEffect(() => { + if ( + agenticSearchState.status !== 'idle' && + agenticSearchState.status !== 'searching' + ) { + // Clear if the query has changed from the one used for results/error + if ( + (agenticSearchState.status === 'results' && + agenticSearchState.query !== searchQuery) || + agenticSearchState.status === 'error' + ) { + setAgenticSearchState({ status: 'idle' }) } - }; - $[94] = logs; - $[95] = onAgenticSearch; - $[96] = searchQuery; - $[97] = t35; - } else { - t35 = $[97]; - } - const handleAgenticSearch = t35; - let t36; - if ($[98] !== agenticSearchState.query || $[99] !== agenticSearchState.status || $[100] !== searchQuery) { - t36 = () => { - if (agenticSearchState.status !== "idle" && agenticSearchState.status !== "searching") { - if (agenticSearchState.status === "results" && agenticSearchState.query !== searchQuery || agenticSearchState.status === "error") { - setAgenticSearchState({ - status: "idle" - }); - } + } + }, [searchQuery, agenticSearchState]) + + // Cleanup: abort any in-progress agentic search on unmount + React.useEffect(() => { + return () => { + agenticSearchAbortRef.current?.abort() + } + }, []) + + // Focus first item when agentic search completes with results + const prevAgenticStatusRef = React.useRef(agenticSearchState.status) + React.useEffect(() => { + const prevStatus = prevAgenticStatusRef.current + prevAgenticStatusRef.current = agenticSearchState.status + + // When search just completed, focus the first item in the list + if (prevStatus === 'searching' && agenticSearchState.status === 'results') { + if (isResumeWithRenameEnabled && treeNodes.length > 0) { + setFocusedNode(treeNodes[0]!) + } else if (!isResumeWithRenameEnabled && displayedLogs.length > 0) { + const firstLog = displayedLogs[0]! + setFocusedNode({ + id: '0', + value: { log: firstLog, indexInFiltered: 0 }, + label: '', + }) } - }; - $[98] = agenticSearchState.query; - $[99] = agenticSearchState.status; - $[100] = searchQuery; - $[101] = t36; - } else { - t36 = $[101]; - } - let t37; - if ($[102] !== agenticSearchState || $[103] !== searchQuery) { - t37 = [searchQuery, agenticSearchState]; - $[102] = agenticSearchState; - $[103] = searchQuery; - $[104] = t37; - } else { - t37 = $[104]; - } - React.useEffect(t36, t37); - let t38; - let t39; - if ($[105] === Symbol.for("react.memo_cache_sentinel")) { - t38 = () => () => { - agenticSearchAbortRef.current?.abort(); - }; - t39 = []; - $[105] = t38; - $[106] = t39; - } else { - t38 = $[105]; - t39 = $[106]; - } - React.useEffect(t38, t39); - const prevAgenticStatusRef = React.useRef(agenticSearchState.status); - let t40; - if ($[107] !== agenticSearchState.status || $[108] !== displayedLogs[0] || $[109] !== displayedLogs.length || $[110] !== treeNodes) { - t40 = () => { - const prevStatus = prevAgenticStatusRef.current; - prevAgenticStatusRef.current = agenticSearchState.status; - if (prevStatus === "searching" && agenticSearchState.status === "results") { - if (isResumeWithRenameEnabled && treeNodes.length > 0) { - setFocusedNode(treeNodes[0]); - } else { - if (!isResumeWithRenameEnabled && displayedLogs.length > 0) { - const firstLog = displayedLogs[0]; - setFocusedNode({ - id: "0", - value: { - log: firstLog, - indexInFiltered: 0 - }, - label: "" - }); - } - } + } + }, [ + agenticSearchState.status, + isResumeWithRenameEnabled, + treeNodes, + displayedLogs, + ]) + + const handleFlatOptionsSelectFocus = React.useCallback( + (value: string) => { + const index = parseInt(value, 10) + const log = displayedLogs[index] + if (!log || prevFocusedIdRef.current === index.toString()) { + return } - }; - $[107] = agenticSearchState.status; - $[108] = displayedLogs[0]; - $[109] = displayedLogs.length; - $[110] = treeNodes; - $[111] = t40; - } else { - t40 = $[111]; - } - let t41; - if ($[112] !== agenticSearchState.status || $[113] !== displayedLogs || $[114] !== treeNodes) { - t41 = [agenticSearchState.status, isResumeWithRenameEnabled, treeNodes, displayedLogs]; - $[112] = agenticSearchState.status; - $[113] = displayedLogs; - $[114] = treeNodes; - $[115] = t41; - } else { - t41 = $[115]; - } - React.useEffect(t40, t41); - let t42; - if ($[116] !== displayedLogs) { - t42 = value => { - const index_1 = parseInt(value, 10); - const log_11 = displayedLogs[index_1]; - if (!log_11 || prevFocusedIdRef.current === index_1.toString()) { - return; - } - prevFocusedIdRef.current = index_1.toString(); + prevFocusedIdRef.current = index.toString() setFocusedNode({ - id: index_1.toString(), - value: { - log: log_11, - indexInFiltered: index_1 - }, - label: "" - }); - setFocusedIndex(index_1 + 1); - }; - $[116] = displayedLogs; - $[117] = t42; - } else { - t42 = $[117]; - } - const handleFlatOptionsSelectFocus = t42; - let t43; - if ($[118] !== displayedLogs) { - t43 = node => { - setFocusedNode(node); - const index_2 = displayedLogs.findIndex(log_12 => getSessionIdFromLog(log_12) === getSessionIdFromLog(node.value.log)); - if (index_2 >= 0) { - setFocusedIndex(index_2 + 1); + id: index.toString(), + value: { log, indexInFiltered: index }, + label: '', + }) + setFocusedIndex(index + 1) + }, + [displayedLogs], + ) + + const handleTreeSelectFocus = React.useCallback( + (node: LogTreeNode) => { + setFocusedNode(node) + // Update focused index for scroll position display + const index = displayedLogs.findIndex( + log => getSessionIdFromLog(log) === getSessionIdFromLog(node.value.log), + ) + if (index >= 0) { + setFocusedIndex(index + 1) } - }; - $[118] = displayedLogs; - $[119] = t43; - } else { - t43 = $[119]; - } - const handleTreeSelectFocus = t43; - let t44; - if ($[120] === Symbol.for("react.memo_cache_sentinel")) { - t44 = () => { - agenticSearchAbortRef.current?.abort(); - setAgenticSearchState({ - status: "idle" - }); - logEvent("tengu_agentic_search_cancelled", {}); - }; - $[120] = t44; - } else { - t44 = $[120]; - } - const t45 = viewMode !== "preview" && agenticSearchState.status === "searching"; - let t46; - if ($[121] !== t45) { - t46 = { - context: "Confirmation", - isActive: t45 - }; - $[121] = t45; - $[122] = t46; - } else { - t46 = $[122]; - } - useKeybinding("confirm:no", t44, t46); - let t47; - if ($[123] === Symbol.for("react.memo_cache_sentinel")) { - t47 = () => { - setViewMode("list"); - setRenameValue(""); - }; - $[123] = t47; - } else { - t47 = $[123]; - } - const t48 = viewMode === "rename" && agenticSearchState.status !== "searching"; - let t49; - if ($[124] !== t48) { - t49 = { - context: "Settings", - isActive: t48 - }; - $[124] = t48; - $[125] = t49; - } else { - t49 = $[125]; - } - useKeybinding("confirm:no", t47, t49); - let t50; - if ($[126] !== onCancel || $[127] !== setSearchQuery) { - t50 = () => { - setSearchQuery(""); - setIsAgenticSearchOptionFocused(false); - onCancel?.(); - }; - $[126] = onCancel; - $[127] = setSearchQuery; - $[128] = t50; - } else { - t50 = $[128]; - } - const t51 = viewMode !== "preview" && viewMode !== "rename" && viewMode !== "search" && isAgenticSearchOptionFocused && agenticSearchState.status !== "searching"; - let t52; - if ($[129] !== t51) { - t52 = { - context: "Confirmation", - isActive: t51 - }; - $[129] = t51; - $[130] = t52; - } else { - t52 = $[130]; - } - useKeybinding("confirm:no", t50, t52); - let t53; - if ($[131] !== agenticSearchState.status || $[132] !== branchFilterEnabled || $[133] !== focusedLog || $[134] !== handleAgenticSearch || $[135] !== hasMultipleWorktrees || $[136] !== hasTags || $[137] !== isAgenticSearchOptionFocused || $[138] !== onAgenticSearch || $[139] !== onToggleAllProjects || $[140] !== searchQuery || $[141] !== setSearchQuery || $[142] !== showAllProjects || $[143] !== showAllWorktrees || $[144] !== tagTabs || $[145] !== uniqueTags || $[146] !== viewMode) { - t53 = (input, key) => { - if (viewMode === "preview") { - return; + }, + [displayedLogs], + ) + + // Escape to abort agentic search in progress + useKeybinding( + 'confirm:no', + () => { + agenticSearchAbortRef.current?.abort() + setAgenticSearchState({ status: 'idle' }) + logEvent('tengu_agentic_search_cancelled', {}) + }, + { + context: 'Confirmation', + isActive: + viewMode !== 'preview' && agenticSearchState.status === 'searching', + }, + ) + + // Escape in rename mode - exit rename mode + // Use Settings context so 'n' key doesn't exit (allows typing 'n' in rename input) + useKeybinding( + 'confirm:no', + () => { + setViewMode('list') + setRenameValue('') + }, + { + context: 'Settings', + isActive: + viewMode === 'rename' && agenticSearchState.status !== 'searching', + }, + ) + + // Escape when agentic search option focused - clear and cancel + useKeybinding( + 'confirm:no', + () => { + setSearchQuery('') + setIsAgenticSearchOptionFocused(false) + onCancel?.() + }, + { + context: 'Confirmation', + isActive: + viewMode !== 'preview' && + viewMode !== 'rename' && + viewMode !== 'search' && + isAgenticSearchOptionFocused && + agenticSearchState.status !== 'searching', + }, + ) + + // Handle non-escape input + useInput( + (input, key) => { + if (viewMode === 'preview') { + // Preview mode handles its own input + return } - if (agenticSearchState.status === "searching") { - return; + + // Agentic search abort is now handled via keybinding + if (agenticSearchState.status === 'searching') { + return } - if (viewMode === "rename") {} else { - if (viewMode === "search") { - if (input.toLowerCase() === "n" && key.ctrl) { - exitSearchMode(); - } else { - if (key.return || key.downArrow) { - if (searchQuery.trim() && onAgenticSearch && false && agenticSearchState.status !== "results") { - setIsAgenticSearchOptionFocused(true); - } - } - } - } else { - if (isAgenticSearchOptionFocused) { - if (key.return) { - handleAgenticSearch(); - setIsAgenticSearchOptionFocused(false); - return; - } else { - if (key.downArrow) { - setIsAgenticSearchOptionFocused(false); - return; - } else { - if (key.upArrow) { - setViewMode("search"); - setIsAgenticSearchOptionFocused(false); - return; - } - } - } - } - if (hasTags && key.tab) { - const offset = key.shift ? -1 : 1; - setSelectedTagIndex(prev => { - const current = prev < tagTabs.length ? prev : 0; - const newIndex = (current + tagTabs.length + offset) % tagTabs.length; - const newTab = tagTabs[newIndex]; - logEvent("tengu_session_tag_filter_changed", { - is_all: newTab === "All", - tag_count: uniqueTags.length - }); - return newIndex; - }); - return; - } - const keyIsNotCtrlOrMeta = !key.ctrl && !key.meta; - const lowerInput = input.toLowerCase(); - if (lowerInput === "a" && key.ctrl && onToggleAllProjects) { - onToggleAllProjects(); - logEvent("tengu_session_all_projects_toggled", { - enabled: !showAllProjects - }); - } else { - if (lowerInput === "b" && key.ctrl) { - const newEnabled = !branchFilterEnabled; - setBranchFilterEnabled(newEnabled); - logEvent("tengu_session_branch_filter_toggled", { - enabled: newEnabled - }); - } else { - if (lowerInput === "w" && key.ctrl && hasMultipleWorktrees) { - const newValue = !showAllWorktrees; - setShowAllWorktrees(newValue); - logEvent("tengu_session_worktree_filter_toggled", { - enabled: newValue - }); - } else { - if (lowerInput === "/" && keyIsNotCtrlOrMeta) { - setViewMode("search"); - logEvent("tengu_session_search_toggled", { - enabled: true - }); - } else { - if (lowerInput === "r" && key.ctrl && focusedLog) { - setViewMode("rename"); - setRenameValue(""); - logEvent("tengu_session_rename_started", {}); - } else { - if (lowerInput === "v" && key.ctrl && focusedLog) { - setPreviewLog(focusedLog); - setViewMode("preview"); - logEvent("tengu_session_preview_opened", { - messageCount: focusedLog.messageCount - }); - } else { - if (focusedLog && keyIsNotCtrlOrMeta && input.length > 0 && !/^\s+$/.test(input)) { - setViewMode("search"); - setSearchQuery(input); - logEvent("tengu_session_search_toggled", { - enabled: true - }); - } - } - } - } - } - } + + if (viewMode === 'rename') { + // Rename mode escape is now handled via keybinding + // This branch only handles non-escape input in rename mode (via TextInput) + } else if (viewMode === 'search') { + // Text input is handled by useSearchInput hook + if (input.toLowerCase() === 'n' && key.ctrl) { + exitSearchMode() + } else if (key.return || key.downArrow) { + // Focus agentic search option if applicable + if ( + searchQuery.trim() && + onAgenticSearch && + isAgenticSearchEnabled && + agenticSearchState.status !== 'results' + ) { + setIsAgenticSearchOptionFocused(true) } } + } else { + // Handle agentic search option when focused (escape handled via keybinding) + if (isAgenticSearchOptionFocused) { + if (key.return) { + // Trigger agentic search + void handleAgenticSearch() + setIsAgenticSearchOptionFocused(false) + return + } else if (key.downArrow) { + // Move focus to the session list + setIsAgenticSearchOptionFocused(false) + return + } else if (key.upArrow) { + // Go back to search mode + setViewMode('search') + setIsAgenticSearchOptionFocused(false) + return + } + } + + // Handle tab cycling for tag tabs + if (hasTags && key.tab) { + const offset = key.shift ? -1 : 1 + setSelectedTagIndex(prev => { + const current = prev < tagTabs.length ? prev : 0 + const newIndex = + (current + tagTabs.length + offset) % tagTabs.length + const newTab = tagTabs[newIndex] + logEvent('tengu_session_tag_filter_changed', { + is_all: newTab === 'All', + tag_count: uniqueTags.length, + }) + return newIndex + }) + return + } + + const keyIsNotCtrlOrMeta = !key.ctrl && !key.meta + const lowerInput = input.toLowerCase() + // Ctrl+letter shortcuts for actions (freeing up plain letters for type-to-search) + if (lowerInput === 'a' && key.ctrl && onToggleAllProjects) { + onToggleAllProjects() + logEvent('tengu_session_all_projects_toggled', { + enabled: !showAllProjects, + }) + } else if (lowerInput === 'b' && key.ctrl) { + const newEnabled = !branchFilterEnabled + setBranchFilterEnabled(newEnabled) + logEvent('tengu_session_branch_filter_toggled', { + enabled: newEnabled, + }) + } else if (lowerInput === 'w' && key.ctrl && hasMultipleWorktrees) { + const newValue = !showAllWorktrees + setShowAllWorktrees(newValue) + logEvent('tengu_session_worktree_filter_toggled', { + enabled: newValue, + }) + } else if (lowerInput === '/' && keyIsNotCtrlOrMeta) { + setViewMode('search') + logEvent('tengu_session_search_toggled', { enabled: true }) + } else if (lowerInput === 'r' && key.ctrl && focusedLog) { + setViewMode('rename') + setRenameValue('') + logEvent('tengu_session_rename_started', {}) + } else if (lowerInput === 'v' && key.ctrl && focusedLog) { + setPreviewLog(focusedLog) + setViewMode('preview') + logEvent('tengu_session_preview_opened', { + messageCount: focusedLog.messageCount, + }) + } else if ( + focusedLog && + keyIsNotCtrlOrMeta && + input.length > 0 && + !/^\s+$/.test(input) + ) { + // Any printable character enters search mode and starts typing + setViewMode('search') + setSearchQuery(input) + logEvent('tengu_session_search_toggled', { enabled: true }) + } } - }; - $[131] = agenticSearchState.status; - $[132] = branchFilterEnabled; - $[133] = focusedLog; - $[134] = handleAgenticSearch; - $[135] = hasMultipleWorktrees; - $[136] = hasTags; - $[137] = isAgenticSearchOptionFocused; - $[138] = onAgenticSearch; - $[139] = onToggleAllProjects; - $[140] = searchQuery; - $[141] = setSearchQuery; - $[142] = showAllProjects; - $[143] = showAllWorktrees; - $[144] = tagTabs; - $[145] = uniqueTags; - $[146] = viewMode; - $[147] = t53; - } else { - t53 = $[147]; + }, + { isActive: true }, + ) + + const filterIndicators = [] + if (branchFilterEnabled && currentBranch) { + filterIndicators.push(currentBranch) } - let t54; - if ($[148] === Symbol.for("react.memo_cache_sentinel")) { - t54 = { - isActive: true - }; - $[148] = t54; - } else { - t54 = $[148]; + if (hasMultipleWorktrees && !showAllWorktrees) { + filterIndicators.push('current worktree') } - useInput(t53, t54); - let filterIndicators; - if ($[149] !== branchFilterEnabled || $[150] !== currentBranch || $[151] !== hasMultipleWorktrees || $[152] !== showAllWorktrees) { - filterIndicators = []; - if (branchFilterEnabled && currentBranch) { - filterIndicators.push(currentBranch); + + const showAdditionalFilterLine = + filterIndicators.length > 0 && viewMode !== 'search' + + // Search box takes 3 lines (border top, content, border bottom) + const searchBoxLines = 3 + const headerLines = + 5 + searchBoxLines + (showAdditionalFilterLine ? 1 : 0) + tagTabsLines + const footerLines = 2 + const visibleCount = Math.max( + 1, + Math.floor((maxHeight - headerLines - footerLines) / 3), + ) + + // Progressive loading: request more logs when user scrolls near the end + React.useEffect(() => { + if (!onLoadMore) return + const buffer = visibleCount * 2 + if (focusedIndex + buffer >= displayedLogs.length) { + onLoadMore(visibleCount * 3) } - if (hasMultipleWorktrees && !showAllWorktrees) { - filterIndicators.push("current worktree"); - } - $[149] = branchFilterEnabled; - $[150] = currentBranch; - $[151] = hasMultipleWorktrees; - $[152] = showAllWorktrees; - $[153] = filterIndicators; - } else { - filterIndicators = $[153]; - } - const showAdditionalFilterLine = filterIndicators.length > 0 && viewMode !== "search"; - const headerLines = 8 + (showAdditionalFilterLine ? 1 : 0) + tagTabsLines; - const visibleCount = Math.max(1, Math.floor((maxHeight - headerLines - 2) / 3)); - let t55; - let t56; - if ($[154] !== displayedLogs.length || $[155] !== focusedIndex || $[156] !== onLoadMore || $[157] !== visibleCount) { - t55 = () => { - if (!onLoadMore) { - return; - } - const buffer = visibleCount * 2; - if (focusedIndex + buffer >= displayedLogs.length) { - onLoadMore(visibleCount * 3); - } - }; - t56 = [focusedIndex, visibleCount, displayedLogs.length, onLoadMore]; - $[154] = displayedLogs.length; - $[155] = focusedIndex; - $[156] = onLoadMore; - $[157] = visibleCount; - $[158] = t55; - $[159] = t56; - } else { - t55 = $[158]; - t56 = $[159]; - } - React.useEffect(t55, t56); + }, [focusedIndex, visibleCount, displayedLogs.length, onLoadMore]) + + // Early return if no logs if (logs.length === 0) { - return null; + return null } - if (viewMode === "preview" && previewLog && isResumeWithRenameEnabled) { - let t57; - if ($[160] === Symbol.for("react.memo_cache_sentinel")) { - t57 = () => { - setViewMode("list"); - setPreviewLog(null); - }; - $[160] = t57; - } else { - t57 = $[160]; - } - let t58; - if ($[161] !== onSelect || $[162] !== previewLog) { - t58 = ; - $[161] = onSelect; - $[162] = previewLog; - $[163] = t58; - } else { - t58 = $[163]; - } - return t58; + + // Show preview mode if active + if (viewMode === 'preview' && previewLog && isResumeWithRenameEnabled) { + return ( + { + setViewMode('list') + setPreviewLog(null) + }} + onSelect={onSelect} + /> + ) } - const t57 = maxHeight - 1; - let t58; - if ($[164] === Symbol.for("react.memo_cache_sentinel")) { - t58 = ; - $[164] = t58; - } else { - t58 = $[164]; - } - let t59; - if ($[165] === Symbol.for("react.memo_cache_sentinel")) { - t59 = ; - $[165] = t59; - } else { - t59 = $[165]; - } - let t60; - if ($[166] !== columns || $[167] !== displayedLogs.length || $[168] !== effectiveTagIndex || $[169] !== focusedIndex || $[170] !== hasTags || $[171] !== showAllProjects || $[172] !== tagTabs || $[173] !== viewMode || $[174] !== visibleCount) { - t60 = hasTags ? : Resume Session{viewMode === "list" && displayedLogs.length > visibleCount && {" "}({focusedIndex} of {displayedLogs.length})}; - $[166] = columns; - $[167] = displayedLogs.length; - $[168] = effectiveTagIndex; - $[169] = focusedIndex; - $[170] = hasTags; - $[171] = showAllProjects; - $[172] = tagTabs; - $[173] = viewMode; - $[174] = visibleCount; - $[175] = t60; - } else { - t60 = $[175]; - } - const t61 = viewMode === "search"; - let t62; - if ($[176] !== isTerminalFocused || $[177] !== searchCursorOffset || $[178] !== searchQuery || $[179] !== t61) { - t62 = ; - $[176] = isTerminalFocused; - $[177] = searchCursorOffset; - $[178] = searchQuery; - $[179] = t61; - $[180] = t62; - } else { - t62 = $[180]; - } - let t63; - if ($[181] !== filterIndicators || $[182] !== viewMode) { - t63 = filterIndicators.length > 0 && viewMode !== "search" && {filterIndicators}; - $[181] = filterIndicators; - $[182] = viewMode; - $[183] = t63; - } else { - t63 = $[183]; - } - let t64; - if ($[184] === Symbol.for("react.memo_cache_sentinel")) { - t64 = ; - $[184] = t64; - } else { - t64 = $[184]; - } - let t65; - if ($[185] !== agenticSearchState.status) { - t65 = agenticSearchState.status === "searching" && Searching…; - $[185] = agenticSearchState.status; - $[186] = t65; - } else { - t65 = $[186]; - } - let t66; - if ($[187] !== agenticSearchState.results || $[188] !== agenticSearchState.status) { - t66 = agenticSearchState.status === "results" && agenticSearchState.results.length > 0 && Claude found these results:; - $[187] = agenticSearchState.results; - $[188] = agenticSearchState.status; - $[189] = t66; - } else { - t66 = $[189]; - } - let t67; - if ($[190] !== agenticSearchState.results || $[191] !== agenticSearchState.status || $[192] !== filteredLogs) { - t67 = agenticSearchState.status === "results" && agenticSearchState.results.length === 0 && filteredLogs.length === 0 && No matching sessions found.; - $[190] = agenticSearchState.results; - $[191] = agenticSearchState.status; - $[192] = filteredLogs; - $[193] = t67; - } else { - t67 = $[193]; - } - let t68; - if ($[194] !== agenticSearchState.status || $[195] !== filteredLogs) { - t68 = agenticSearchState.status === "error" && filteredLogs.length === 0 && No matching sessions found.; - $[194] = agenticSearchState.status; - $[195] = filteredLogs; - $[196] = t68; - } else { - t68 = $[196]; - } - let t69; - if ($[197] !== agenticSearchState.status || $[198] !== isAgenticSearchOptionFocused || $[199] !== onAgenticSearch || $[200] !== searchQuery) { - t69 = Boolean(searchQuery.trim()) && onAgenticSearch && false && agenticSearchState.status !== "searching" && agenticSearchState.status !== "results" && agenticSearchState.status !== "error" && {isAgenticSearchOptionFocused ? figures.pointer : " "}Search deeply using Claude →; - $[197] = agenticSearchState.status; - $[198] = isAgenticSearchOptionFocused; - $[199] = onAgenticSearch; - $[200] = searchQuery; - $[201] = t69; - } else { - t69 = $[201]; - } - let t70; - if ($[202] !== agenticSearchState.status || $[203] !== branchFilterEnabled || $[204] !== columns || $[205] !== displayedLogs || $[206] !== expandedGroupSessionIds || $[207] !== flatOptions || $[208] !== focusedLog || $[209] !== focusedNode?.id || $[210] !== handleFlatOptionsSelectFocus || $[211] !== handleRenameSubmit || $[212] !== handleTreeSelectFocus || $[213] !== isAgenticSearchOptionFocused || $[214] !== onCancel || $[215] !== onSelect || $[216] !== renameCursorOffset || $[217] !== renameValue || $[218] !== treeNodes || $[219] !== viewMode || $[220] !== visibleCount) { - t70 = agenticSearchState.status === "searching" ? null : viewMode === "rename" && focusedLog ? Rename session: : isResumeWithRenameEnabled ? { - onSelect(node_0.value.log); - }} onFocus={handleTreeSelectFocus} onCancel={onCancel} focusNodeId={focusedNode?.id} visibleOptionCount={visibleCount} layout="expanded" isDisabled={viewMode === "search" || isAgenticSearchOptionFocused} hideIndexes={false} isNodeExpanded={nodeId => { - if (viewMode === "search" || branchFilterEnabled) { - return true; - } - const sessionId_2 = typeof nodeId === "string" && nodeId.startsWith("group:") ? nodeId.substring(6) : null; - return sessionId_2 ? expandedGroupSessionIds.has(sessionId_2) : false; - }} onExpand={nodeId_0 => { - const sessionId_3 = typeof nodeId_0 === "string" && nodeId_0.startsWith("group:") ? nodeId_0.substring(6) : null; - if (sessionId_3) { - setExpandedGroupSessionIds(prev_0 => new Set(prev_0).add(sessionId_3)); - logEvent("tengu_session_group_expanded", {}); - } - }} onCollapse={nodeId_1 => { - const sessionId_4 = typeof nodeId_1 === "string" && nodeId_1.startsWith("group:") ? nodeId_1.substring(6) : null; - if (sessionId_4) { - setExpandedGroupSessionIds(prev_1 => { - const newSet = new Set(prev_1); - newSet.delete(sessionId_4); - return newSet; - }); - } - }} onUpFromFirstItem={enterSearchMode} /> : { + // Old flat list mode - index directly maps to displayedLogs + const itemIndex = parseInt(value, 10) + const log = displayedLogs[itemIndex] + if (log) { + onSelect(log) + } + }} + visibleOptionCount={visibleCount} + onCancel={onCancel} + onFocus={handleFlatOptionsSelectFocus} + defaultFocusValue={focusedNode?.id.toString()} + layout="expanded" + isDisabled={viewMode === 'search' || isAgenticSearchOptionFocused} + onUpFromFirstItem={enterSearchMode} + /> + )} + + {exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : viewMode === 'rename' ? ( + + + + + + + ) : agenticSearchState.status === 'searching' ? ( + + + Searching with Claude… + + + + ) : isAgenticSearchOptionFocused ? ( + + + + + + + + ) : viewMode === 'search' ? ( + + + + {isSearching && isDeepSearchEnabled + ? 'Searching…' + : 'Type to Search'} + + + + + + ) : ( + + + {onToggleAllProjects && ( + + )} + {currentBranch && ( + + )} + {hasMultipleWorktrees && ( + + )} + + + Type to search + + {getExpandCollapseHint() && ( + {getExpandCollapseHint()} + )} + + + )} + + + ) } /** * Extracts searchable text content from a message. * Handles both string content and structured content blocks. */ -function _temp7(r_0) { - return r_0.log; -} -function _temp6(log_6) { - return log_6.messages[0]?.uuid; -} -function _temp5(fuseIndex_0, debouncedDeepSearchQuery_0, setDeepSearchResults_0, setIsSearching_0) { - const results = fuseIndex_0.search(debouncedDeepSearchQuery_0); - results.sort(_temp3); - setDeepSearchResults_0({ - results: results.map(_temp4), - query: debouncedDeepSearchQuery_0 - }); - setIsSearching_0(false); -} -function _temp4(r) { - return { - log: r.item.log, - score: r.score, - searchableText: r.item.searchableText - }; -} -function _temp3(a, b) { - const aTime = new Date(a.item.log.modified).getTime(); - const bTime = new Date(b.item.log.modified).getTime(); - const timeDiff = bTime - aTime; - if (Math.abs(timeDiff) > DATE_TIE_THRESHOLD_MS) { - return timeDiff; - } - return (a.score ?? 1) - (b.score ?? 1); -} -function _temp2(log_1) { - const currentSessionId = getSessionId(); - const logSessionId = getSessionIdFromLog(log_1); - const isCurrentSession = currentSessionId && logSessionId === currentSessionId; - if (isCurrentSession) { - return true; - } - if (log_1.customTitle) { - return true; - } - const fromMessages = getFirstMeaningfulUserMessageTextContent(log_1.messages); - if (fromMessages) { - return true; - } - if (log_1.firstPrompt || log_1.customTitle) { - return true; - } - return false; -} -function _temp(log) { - return [log, buildSearchableText(log)]; -} function extractSearchableText(message: SerializedMessage): string { // Only extract from user and assistant messages that have content if (message.type !== 'user' && message.type !== 'assistant') { - return ''; + return '' } - const content = 'message' in message ? message.message?.content : undefined; - if (!content) return ''; + + const content = 'message' in message ? message.message?.content : undefined + if (!content) return '' // Handle string content (simple messages) if (typeof content === 'string') { - return content; + return content } // Handle array of content blocks if (Array.isArray(content)) { - return content.map(block => { - if (typeof block === 'string') return block; - if ('text' in block && typeof block.text === 'string') return block.text; - return ''; - // we don't return thinking blocks and tool names here; - // they're not useful for search, as they can add noise to the fuzzy matching - }).filter(Boolean).join(' '); + return content + .map(block => { + if (typeof block === 'string') return block + if ('text' in block && typeof block.text === 'string') return block.text + return '' + // we don't return thinking blocks and tool names here; + // they're not useful for search, as they can add noise to the fuzzy matching + }) + .filter(Boolean) + .join(' ') } - return ''; + + return '' } /** @@ -1535,40 +1375,72 @@ function extractSearchableText(message: SerializedMessage): string { * Crops long transcripts to first/last N messages for performance. */ function buildSearchableText(log: LogOption): string { - const searchableMessages = log.messages.length <= DEEP_SEARCH_MAX_MESSAGES ? log.messages : [...log.messages.slice(0, DEEP_SEARCH_CROP_SIZE), ...log.messages.slice(-DEEP_SEARCH_CROP_SIZE)]; - const messageText = searchableMessages.map(extractSearchableText).filter(Boolean).join(' '); - const metadata = [log.customTitle, log.summary, log.firstPrompt, log.gitBranch, log.tag, log.prNumber ? `PR #${log.prNumber}` : undefined, log.prRepository].filter(Boolean).join(' '); - const fullText = `${metadata} ${messageText}`.trim(); - return fullText.length > DEEP_SEARCH_MAX_TEXT_LENGTH ? fullText.slice(0, DEEP_SEARCH_MAX_TEXT_LENGTH) : fullText; + const searchableMessages = + log.messages.length <= DEEP_SEARCH_MAX_MESSAGES + ? log.messages + : [ + ...log.messages.slice(0, DEEP_SEARCH_CROP_SIZE), + ...log.messages.slice(-DEEP_SEARCH_CROP_SIZE), + ] + const messageText = searchableMessages + .map(extractSearchableText) + .filter(Boolean) + .join(' ') + + const metadata = [ + log.customTitle, + log.summary, + log.firstPrompt, + log.gitBranch, + log.tag, + log.prNumber ? `PR #${log.prNumber}` : undefined, + log.prRepository, + ] + .filter(Boolean) + .join(' ') + + const fullText = `${metadata} ${messageText}`.trim() + return fullText.length > DEEP_SEARCH_MAX_TEXT_LENGTH + ? fullText.slice(0, DEEP_SEARCH_MAX_TEXT_LENGTH) + : fullText } -function groupLogsBySessionId(filteredLogs: LogOption[]): Map { - const groups = new Map(); + +function groupLogsBySessionId( + filteredLogs: LogOption[], +): Map { + const groups = new Map() + for (const log of filteredLogs) { - const sessionId = getSessionIdFromLog(log); + const sessionId = getSessionIdFromLog(log) if (sessionId) { - const existing = groups.get(sessionId); + const existing = groups.get(sessionId) if (existing) { - existing.push(log); + existing.push(log) } else { - groups.set(sessionId, [log]); + groups.set(sessionId, [log]) } } } // Sort logs within each group by modified date (newest first) - groups.forEach(logs => logs.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime())); - return groups; + groups.forEach(logs => + logs.sort( + (a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime(), + ), + ) + + return groups } /** * Get unique tags from a list of logs, sorted alphabetically */ function getUniqueTags(logs: LogOption[]): string[] { - const tags = new Set(); + const tags = new Set() for (const log of logs) { if (log.tag) { - tags.add(log.tag); + tags.add(log.tag) } } - return Array.from(tags).sort((a, b) => a.localeCompare(b)); + return Array.from(tags).sort((a, b) => a.localeCompare(b)) } diff --git a/src/components/LogoV2/AnimatedAsterisk.tsx b/src/components/LogoV2/AnimatedAsterisk.tsx index 94463c436..1c5adcf06 100644 --- a/src/components/LogoV2/AnimatedAsterisk.tsx +++ b/src/components/LogoV2/AnimatedAsterisk.tsx @@ -1,49 +1,57 @@ -import * as React from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { TEARDROP_ASTERISK } from '../../constants/figures.js'; -import { Box, Text, useAnimationFrame } from '../../ink.js'; -import { getInitialSettings } from '../../utils/settings/settings.js'; -import { hueToRgb, toRGBColor } from '../Spinner/utils.js'; -const SWEEP_DURATION_MS = 1500; -const SWEEP_COUNT = 2; -const TOTAL_ANIMATION_MS = SWEEP_DURATION_MS * SWEEP_COUNT; -const SETTLED_GREY = toRGBColor({ - r: 153, - g: 153, - b: 153 -}); +import * as React from 'react' +import { useEffect, useRef, useState } from 'react' +import { TEARDROP_ASTERISK } from '../../constants/figures.js' +import { Box, Text, useAnimationFrame } from '../../ink.js' +import { getInitialSettings } from '../../utils/settings/settings.js' +import { hueToRgb, toRGBColor } from '../Spinner/utils.js' + +const SWEEP_DURATION_MS = 1500 +const SWEEP_COUNT = 2 +const TOTAL_ANIMATION_MS = SWEEP_DURATION_MS * SWEEP_COUNT +const SETTLED_GREY = toRGBColor({ r: 153, g: 153, b: 153 }) + export function AnimatedAsterisk({ - char = TEARDROP_ASTERISK + char = TEARDROP_ASTERISK, }: { - char?: string; + char?: string }): React.ReactNode { // Read prefersReducedMotion once at mount — no useSettings() subscription, // since that would re-render whenever settings change. - const [reducedMotion] = useState(() => getInitialSettings().prefersReducedMotion ?? false); - const [done, setDone] = useState(reducedMotion); + const [reducedMotion] = useState( + () => getInitialSettings().prefersReducedMotion ?? false, + ) + const [done, setDone] = useState(reducedMotion) // useAnimationFrame's clock is shared — capture our start offset so the // sweep always begins at hue 0 regardless of when we mount. - const startTimeRef = useRef(null); + const startTimeRef = useRef(null) // Wire the ref so useAnimationFrame's viewport-pause kicks in: if the // user submits a message before the sweep finishes, the clock stops // automatically once this row enters scrollback (prevents flicker). - const [ref, time] = useAnimationFrame(done ? null : 50); + const [ref, time] = useAnimationFrame(done ? null : 50) + useEffect(() => { - if (done) return; - const t = setTimeout(setDone, TOTAL_ANIMATION_MS, true); - return () => clearTimeout(t); - }, [done]); + if (done) return + const t = setTimeout(setDone, TOTAL_ANIMATION_MS, true) + return () => clearTimeout(t) + }, [done]) + if (done) { - return + return ( + {char} - ; + + ) } + if (startTimeRef.current === null) { - startTimeRef.current = time; + startTimeRef.current = time } - const elapsed = time - startTimeRef.current; - const hue = elapsed / SWEEP_DURATION_MS * 360 % 360; - return + const elapsed = time - startTimeRef.current + const hue = ((elapsed / SWEEP_DURATION_MS) * 360) % 360 + + return ( + {char} - ; + + ) } diff --git a/src/components/LogoV2/AnimatedClawd.tsx b/src/components/LogoV2/AnimatedClawd.tsx index 877a811ab..ed3060066 100644 --- a/src/components/LogoV2/AnimatedClawd.tsx +++ b/src/components/LogoV2/AnimatedClawd.tsx @@ -1,22 +1,14 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { Box } from '../../ink.js'; -import { getInitialSettings } from '../../utils/settings/settings.js'; -import { Clawd, type ClawdPose } from './Clawd.js'; -type Frame = { - pose: ClawdPose; - offset: number; -}; +import * as React from 'react' +import { useEffect, useRef, useState } from 'react' +import { Box } from '../../ink.js' +import { getInitialSettings } from '../../utils/settings/settings.js' +import { Clawd, type ClawdPose } from './Clawd.js' + +type Frame = { pose: ClawdPose; offset: number } /** Hold a pose for n frames (60ms each). */ function hold(pose: ClawdPose, offset: number, frames: number): Frame[] { - return Array.from({ - length: frames - }, () => ({ - pose, - offset - })); + return Array.from({ length: frames }, () => ({ pose, offset })) } // Offset semantics: marginTop in a fixed-height-3 container. 0 = normal, @@ -25,26 +17,28 @@ function hold(pose: ClawdPose, offset: number, frames: number): Frame[] { // clipped — reads as "ducking below the frame" before springing back up. // Click animation: crouch, then spring up with both arms raised. Twice. -const JUMP_WAVE: readonly Frame[] = [...hold('default', 1, 2), -// crouch -...hold('arms-up', 0, 3), -// spring! -...hold('default', 0, 1), ...hold('default', 1, 2), -// crouch again -...hold('arms-up', 0, 3), -// spring! -...hold('default', 0, 1)]; +const JUMP_WAVE: readonly Frame[] = [ + ...hold('default', 1, 2), // crouch + ...hold('arms-up', 0, 3), // spring! + ...hold('default', 0, 1), + ...hold('default', 1, 2), // crouch again + ...hold('arms-up', 0, 3), // spring! + ...hold('default', 0, 1), +] // Click animation: glance right, then left, then back. -const LOOK_AROUND: readonly Frame[] = [...hold('look-right', 0, 5), ...hold('look-left', 0, 5), ...hold('default', 0, 1)]; -const CLICK_ANIMATIONS: readonly (readonly Frame[])[] = [JUMP_WAVE, LOOK_AROUND]; -const IDLE: Frame = { - pose: 'default', - offset: 0 -}; -const FRAME_MS = 60; -const incrementFrame = (i: number) => i + 1; -const CLAWD_HEIGHT = 3; +const LOOK_AROUND: readonly Frame[] = [ + ...hold('look-right', 0, 5), + ...hold('look-left', 0, 5), + ...hold('default', 0, 1), +] + +const CLICK_ANIMATIONS: readonly (readonly Frame[])[] = [JUMP_WAVE, LOOK_AROUND] + +const IDLE: Frame = { pose: 'default', offset: 0 } +const FRAME_MS = 60 +const incrementFrame = (i: number) => i + 1 +const CLAWD_HEIGHT = 3 /** * Clawd with click-triggered animations (crouch-jump with arms up, or @@ -54,70 +48,49 @@ const CLAWD_HEIGHT = 3; * mouse tracking is enabled (i.e. inside `` / fullscreen); * elsewhere this renders and behaves identically to plain ``. */ -export function AnimatedClawd() { - const $ = _c(8); - const { - pose, - bounceOffset, - onClick - } = useClawdAnimation(); - let t0; - if ($[0] !== pose) { - t0 = ; - $[0] = pose; - $[1] = t0; - } else { - t0 = $[1]; - } - let t1; - if ($[2] !== bounceOffset || $[3] !== t0) { - t1 = {t0}; - $[2] = bounceOffset; - $[3] = t0; - $[4] = t1; - } else { - t1 = $[4]; - } - let t2; - if ($[5] !== onClick || $[6] !== t1) { - t2 = {t1}; - $[5] = onClick; - $[6] = t1; - $[7] = t2; - } else { - t2 = $[7]; - } - return t2; +export function AnimatedClawd(): React.ReactNode { + const { pose, bounceOffset, onClick } = useClawdAnimation() + return ( + + + + + + ) } + function useClawdAnimation(): { - pose: ClawdPose; - bounceOffset: number; - onClick: () => void; + pose: ClawdPose + bounceOffset: number + onClick: () => void } { // Read once at mount — no useSettings() subscription, since that would // re-render on any settings change. - const [reducedMotion] = useState(() => getInitialSettings().prefersReducedMotion ?? false); - const [frameIndex, setFrameIndex] = useState(-1); - const sequenceRef = useRef(JUMP_WAVE); + const [reducedMotion] = useState( + () => getInitialSettings().prefersReducedMotion ?? false, + ) + const [frameIndex, setFrameIndex] = useState(-1) + const sequenceRef = useRef(JUMP_WAVE) + const onClick = () => { - if (reducedMotion || frameIndex !== -1) return; - sequenceRef.current = CLICK_ANIMATIONS[Math.floor(Math.random() * CLICK_ANIMATIONS.length)]!; - setFrameIndex(0); - }; + if (reducedMotion || frameIndex !== -1) return + sequenceRef.current = + CLICK_ANIMATIONS[Math.floor(Math.random() * CLICK_ANIMATIONS.length)]! + setFrameIndex(0) + } + useEffect(() => { - if (frameIndex === -1) return; + if (frameIndex === -1) return if (frameIndex >= sequenceRef.current.length) { - setFrameIndex(-1); - return; + setFrameIndex(-1) + return } - const timer = setTimeout(setFrameIndex, FRAME_MS, incrementFrame); - return () => clearTimeout(timer); - }, [frameIndex]); - const seq = sequenceRef.current; - const current = frameIndex >= 0 && frameIndex < seq.length ? seq[frameIndex]! : IDLE; - return { - pose: current.pose, - bounceOffset: current.offset, - onClick - }; + const timer = setTimeout(setFrameIndex, FRAME_MS, incrementFrame) + return () => clearTimeout(timer) + }, [frameIndex]) + + const seq = sequenceRef.current + const current = + frameIndex >= 0 && frameIndex < seq.length ? seq[frameIndex]! : IDLE + return { pose: current.pose, bounceOffset: current.offset, onClick } } diff --git a/src/components/LogoV2/ChannelsNotice.tsx b/src/components/LogoV2/ChannelsNotice.tsx index 9c24e4a67..66f41b303 100644 --- a/src/components/LogoV2/ChannelsNotice.tsx +++ b/src/components/LogoV2/ChannelsNotice.tsx @@ -1,265 +1,208 @@ -import { c as _c } from "react/compiler-runtime"; // Conditionally require()'d in LogoV2.tsx behind feature('KAIROS') || // feature('KAIROS_CHANNELS'). No feature() guard here — the whole file // tree-shakes via the require pattern when both flags are false (see // docs/feature-gating.md). Do NOT import this module statically from // unguarded code. -import * as React from 'react'; -import { useState } from 'react'; -import { type ChannelEntry, getAllowedChannels, getHasDevChannels } from '../../bootstrap/state.js'; -import { Box, Text } from '../../ink.js'; -import { isChannelsEnabled } from '../../services/mcp/channelAllowlist.js'; -import { getEffectiveChannelAllowlist } from '../../services/mcp/channelNotification.js'; -import { getMcpConfigsByScope } from '../../services/mcp/config.js'; -import { getClaudeAIOAuthTokens, getSubscriptionType } from '../../utils/auth.js'; -import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'; -import { getSettingsForSource } from '../../utils/settings/settings.js'; -export function ChannelsNotice() { - const $ = _c(32); - const [t0] = useState(_temp); - const { - channels, - disabled, - noAuth, - policyBlocked, - list, - unmatched - } = t0; - if (channels.length === 0) { - return null; - } - const hasNonDev = channels.some(_temp2); - const flag = getHasDevChannels() && hasNonDev ? "Channels" : getHasDevChannels() ? "--dangerously-load-development-channels" : "--channels"; +import * as React from 'react' +import { useState } from 'react' +import { + type ChannelEntry, + getAllowedChannels, + getHasDevChannels, +} from '../../bootstrap/state.js' +import { Box, Text } from '../../ink.js' +import { isChannelsEnabled } from '../../services/mcp/channelAllowlist.js' +import { getEffectiveChannelAllowlist } from '../../services/mcp/channelNotification.js' +import { getMcpConfigsByScope } from '../../services/mcp/config.js' +import { + getClaudeAIOAuthTokens, + getSubscriptionType, +} from '../../utils/auth.js' +import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js' +import { getSettingsForSource } from '../../utils/settings/settings.js' + +export function ChannelsNotice(): React.ReactNode { + // Snapshot all reads at mount. This notice enters scrollback immediately + // after the logo; any re-render past that point forces a full terminal + // reset. getAllowedChannels (bootstrap state), getSettingsForSource + // (session cache updated by background polling / /login), and + // isChannelsEnabled (GrowthBook 5-min refresh) must be captured once + // so a later re-render cannot flip branches. + const [{ channels, disabled, noAuth, policyBlocked, list, unmatched }] = + useState(() => { + const ch = getAllowedChannels() + if (ch.length === 0) + return { + channels: ch, + disabled: false, + noAuth: false, + policyBlocked: false, + list: '', + unmatched: [] as Unmatched[], + } + const l = ch.map(formatEntry).join(', ') + const sub = getSubscriptionType() + const managed = sub === 'team' || sub === 'enterprise' + const policy = getSettingsForSource('policySettings') + const allowlist = getEffectiveChannelAllowlist( + sub, + policy?.allowedChannelPlugins, + ) + return { + channels: ch, + disabled: !isChannelsEnabled(), + noAuth: !getClaudeAIOAuthTokens()?.accessToken, + policyBlocked: managed && policy?.channelsEnabled !== true, + list: l, + unmatched: findUnmatched(ch, allowlist), + } + }) + if (channels.length === 0) return null + + // When both flags are passed, the list mixes entries and a single flag + // name would be wrong for half of it. entry.dev distinguishes origin. + const hasNonDev = channels.some(c => !c.dev) + const flag = + getHasDevChannels() && hasNonDev + ? 'Channels' + : getHasDevChannels() + ? '--dangerously-load-development-channels' + : '--channels' + if (disabled) { - let t1; - if ($[0] !== flag || $[1] !== list) { - t1 = {flag} ignored ({list}); - $[0] = flag; - $[1] = list; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Channels are not currently available; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== t1) { - t3 = {t1}{t2}; - $[4] = t1; - $[5] = t3; - } else { - t3 = $[5]; - } - return t3; + return ( + + + {flag} ignored ({list}) + + Channels are not currently available + + ) } + if (noAuth) { - let t1; - if ($[6] !== flag || $[7] !== list) { - t1 = {flag} ignored ({list}); - $[6] = flag; - $[7] = list; - $[8] = t1; - } else { - t1 = $[8]; - } - let t2; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Channels require claude.ai authentication · run /login, then restart; - $[9] = t2; - } else { - t2 = $[9]; - } - let t3; - if ($[10] !== t1) { - t3 = {t1}{t2}; - $[10] = t1; - $[11] = t3; - } else { - t3 = $[11]; - } - return t3; + return ( + + + {flag} ignored ({list}) + + + Channels require claude.ai authentication · run /login, then restart + + + ) } + if (policyBlocked) { - let t1; - if ($[12] !== flag || $[13] !== list) { - t1 = {flag} blocked by org policy ({list}); - $[12] = flag; - $[13] = list; - $[14] = t1; - } else { - t1 = $[14]; - } - let t2; - let t3; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Inbound messages will be silently dropped; - t3 = Have an administrator set channelsEnabled: true in managed settings to enable; - $[15] = t2; - $[16] = t3; - } else { - t2 = $[15]; - t3 = $[16]; - } - let t4; - if ($[17] !== unmatched) { - t4 = unmatched.map(_temp3); - $[17] = unmatched; - $[18] = t4; - } else { - t4 = $[18]; - } - let t5; - if ($[19] !== t1 || $[20] !== t4) { - t5 = {t1}{t2}{t3}{t4}; - $[19] = t1; - $[20] = t4; - $[21] = t5; - } else { - t5 = $[21]; - } - return t5; + return ( + + + {flag} blocked by org policy ({list}) + + Inbound messages will be silently dropped + + Have an administrator set channelsEnabled: true in managed settings to + enable + + {unmatched.map(u => ( + + {formatEntry(u.entry)} · {u.why} + + ))} + + ) } - let t1; - if ($[22] !== list) { - t1 = Listening for channel messages from: {list}; - $[22] = list; - $[23] = t1; - } else { - t1 = $[23]; - } - let t2; - if ($[24] !== flag) { - t2 = Experimental · inbound messages will be pushed into this session, this carries prompt injection risks. Restart Claude Code without {flag} to disable.; - $[24] = flag; - $[25] = t2; - } else { - t2 = $[25]; - } - let t3; - if ($[26] !== unmatched) { - t3 = unmatched.map(_temp4); - $[26] = unmatched; - $[27] = t3; - } else { - t3 = $[27]; - } - let t4; - if ($[28] !== t1 || $[29] !== t2 || $[30] !== t3) { - t4 = {t1}{t2}{t3}; - $[28] = t1; - $[29] = t2; - $[30] = t3; - $[31] = t4; - } else { - t4 = $[31]; - } - return t4; -} -function _temp4(u_0) { - return {formatEntry(u_0.entry)} · {u_0.why}; -} -function _temp3(u) { - return {formatEntry(u.entry)} · {u.why}; -} -function _temp2(c) { - return !c.dev; -} -function _temp() { - const ch = getAllowedChannels(); - if (ch.length === 0) { - return { - channels: ch, - disabled: false, - noAuth: false, - policyBlocked: false, - list: "", - unmatched: [] as Unmatched[] - }; - } - const l = ch.map(formatEntry).join(", "); - const sub = getSubscriptionType(); - const managed = sub === "team" || sub === "enterprise"; - const policy = getSettingsForSource("policySettings"); - const allowlist = getEffectiveChannelAllowlist(sub, policy?.allowedChannelPlugins); - return { - channels: ch, - disabled: !isChannelsEnabled(), - noAuth: !getClaudeAIOAuthTokens()?.accessToken, - policyBlocked: managed && policy?.channelsEnabled !== true, - list: l, - unmatched: findUnmatched(ch, allowlist) - }; + + // "Listening for" not "active" — at this point we only know the allowlist + // was set. Server connection, capability declaration, and whether the name + // even matches a configured MCP server are all still unknown. + return ( + + Listening for channel messages from: {list} + + Experimental · inbound messages will be pushed into this session, this + carries prompt injection risks. Restart Claude Code without {flag} to + disable. + + {unmatched.map(u => ( + + {formatEntry(u.entry)} · {u.why} + + ))} + + ) } + function formatEntry(c: ChannelEntry): string { - return c.kind === 'plugin' ? `plugin:${c.name}@${c.marketplace}` : `server:${c.name}`; + return c.kind === 'plugin' + ? `plugin:${c.name}@${c.marketplace}` + : `server:${c.name}` } -type Unmatched = { - entry: ChannelEntry; - why: string; -}; -function findUnmatched(entries: readonly ChannelEntry[], allowlist: ReturnType): Unmatched[] { + +type Unmatched = { entry: ChannelEntry; why: string } + +function findUnmatched( + entries: readonly ChannelEntry[], + allowlist: ReturnType, +): Unmatched[] { // Server-kind: build one Set from all scopes up front. getMcpConfigsByScope // is not cached (project scope walks the dir tree); getMcpConfigByName would // redo that walk per entry. - const scopes = ['enterprise', 'user', 'project', 'local'] as const; - const configured = new Set(); + const scopes = ['enterprise', 'user', 'project', 'local'] as const + const configured = new Set() for (const scope of scopes) { for (const name of Object.keys(getMcpConfigsByScope(scope).servers)) { - configured.add(name); + configured.add(name) } } // Plugin-kind installed check: installed_plugins.json keys are // `name@marketplace`. loadInstalledPluginsV2 is cached. - const installedPluginIds = new Set(Object.keys(loadInstalledPluginsV2().plugins)); + const installedPluginIds = new Set( + Object.keys(loadInstalledPluginsV2().plugins), + ) // Plugin-kind allowlist check: same {marketplace, plugin} test as the // gate at channelNotification.ts. entry.dev bypasses (dev flag opts out // of the allowlist). Org list replaces ledger when set (team/enterprise). // GrowthBook _CACHED_MAY_BE_STALE — cold cache yields [] so every plugin // entry warns; same tradeoff the gate already accepts. - const { - entries: allowed, - source - } = allowlist; + const { entries: allowed, source } = allowlist // Independent ifs — a plugin entry that's both uninstalled AND // unlisted shows two lines. Server kind checks config + dev flag. - const out: Unmatched[] = []; + const out: Unmatched[] = [] for (const entry of entries) { if (entry.kind === 'server') { if (!configured.has(entry.name)) { - out.push({ - entry, - why: 'no MCP server configured with that name' - }); + out.push({ entry, why: 'no MCP server configured with that name' }) } if (!entry.dev) { out.push({ entry, - why: 'server: entries need --dangerously-load-development-channels' - }); + why: 'server: entries need --dangerously-load-development-channels', + }) } - continue; + continue } if (!installedPluginIds.has(`${entry.name}@${entry.marketplace}`)) { - out.push({ - entry, - why: 'plugin not installed' - }); + out.push({ entry, why: 'plugin not installed' }) } - if (!entry.dev && !allowed.some(e => e.plugin === entry.name && e.marketplace === entry.marketplace)) { + if ( + !entry.dev && + !allowed.some( + e => e.plugin === entry.name && e.marketplace === entry.marketplace, + ) + ) { out.push({ entry, - why: source === 'org' ? "not on your org's approved channels list" : 'not on the approved channels allowlist' - }); + why: + source === 'org' + ? "not on your org's approved channels list" + : 'not on the approved channels allowlist', + }) } } - return out; + return out } diff --git a/src/components/LogoV2/Clawd.tsx b/src/components/LogoV2/Clawd.tsx index ba48bf833..8ddc1bf8e 100644 --- a/src/components/LogoV2/Clawd.tsx +++ b/src/components/LogoV2/Clawd.tsx @@ -1,14 +1,16 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { env } from '../../utils/env.js'; -export type ClawdPose = 'default' | 'arms-up' // both arms raised (used during jump) -| 'look-left' // both pupils shifted left -| 'look-right'; // both pupils shifted right +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { env } from '../../utils/env.js' + +export type ClawdPose = + | 'default' + | 'arms-up' // both arms raised (used during jump) + | 'look-left' // both pupils shifted left + | 'look-right' // both pupils shifted right type Props = { - pose?: ClawdPose; -}; + pose?: ClawdPose +} // Standard-terminal pose fragments. Each row is split into segments so we can // vary only the parts that change (eyes, arms) while keeping the body/bg spans @@ -21,46 +23,23 @@ type Props = { // default (▛/▜, bottom pupils) — otherwise only one eye would appear to move. type Segments = { /** row 1 left (no bg): optional raised arm + side */ - r1L: string; + r1L: string /** row 1 eyes (with bg): left-eye, forehead, right-eye */ - r1E: string; + r1E: string /** row 1 right (no bg): side + optional raised arm */ - r1R: string; + r1R: string /** row 2 left (no bg): arm + body curve */ - r2L: string; + r2L: string /** row 2 right (no bg): body curve + arm */ - r2R: string; -}; + r2R: string +} + const POSES: Record = { - default: { - r1L: ' ▐', - r1E: '▛███▜', - r1R: '▌', - r2L: '▝▜', - r2R: '▛▘' - }, - 'look-left': { - r1L: ' ▐', - r1E: '▟███▟', - r1R: '▌', - r2L: '▝▜', - r2R: '▛▘' - }, - 'look-right': { - r1L: ' ▐', - r1E: '▙███▙', - r1R: '▌', - r2L: '▝▜', - r2R: '▛▘' - }, - 'arms-up': { - r1L: '▗▟', - r1E: '▛███▜', - r1R: '▙▖', - r2L: ' ▜', - r2R: '▛ ' - } -}; + default: { r1L: ' ▐', r1E: '▛███▜', r1R: '▌', r2L: '▝▜', r2R: '▛▘' }, + 'look-left': { r1L: ' ▐', r1E: '▟███▟', r1R: '▌', r2L: '▝▜', r2R: '▛▘' }, + 'look-right': { r1L: ' ▐', r1E: '▙███▙', r1R: '▌', r2L: '▝▜', r2R: '▛▘' }, + 'arms-up': { r1L: '▗▟', r1E: '▛███▜', r1R: '▙▖', r2L: ' ▜', r2R: '▛ ' }, +} // Apple Terminal uses a bg-fill trick (see below), so only eye poses make // sense. Arm poses fall back to default. @@ -68,172 +47,52 @@ const APPLE_EYES: Record = { default: ' ▗ ▖ ', 'look-left': ' ▘ ▘ ', 'look-right': ' ▝ ▝ ', - 'arms-up': ' ▗ ▖ ' -}; -export function Clawd(t0) { - const $ = _c(26); - let t1; - if ($[0] !== t0) { - t1 = t0 === undefined ? {} : t0; - $[0] = t0; - $[1] = t1; - } else { - t1 = $[1]; - } - const { - pose: t2 - } = t1; - const pose = t2 === undefined ? "default" : t2; - if (env.terminal === "Apple_Terminal") { - let t3; - if ($[2] !== pose) { - t3 = ; - $[2] = pose; - $[3] = t3; - } else { - t3 = $[3]; - } - return t3; - } - const p = POSES[pose]; - let t3; - if ($[4] !== p.r1L) { - t3 = {p.r1L}; - $[4] = p.r1L; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== p.r1E) { - t4 = {p.r1E}; - $[6] = p.r1E; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== p.r1R) { - t5 = {p.r1R}; - $[8] = p.r1R; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== t3 || $[11] !== t4 || $[12] !== t5) { - t6 = {t3}{t4}{t5}; - $[10] = t3; - $[11] = t4; - $[12] = t5; - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - if ($[14] !== p.r2L) { - t7 = {p.r2L}; - $[14] = p.r2L; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t8 = █████; - $[16] = t8; - } else { - t8 = $[16]; - } - let t9; - if ($[17] !== p.r2R) { - t9 = {p.r2R}; - $[17] = p.r2R; - $[18] = t9; - } else { - t9 = $[18]; - } - let t10; - if ($[19] !== t7 || $[20] !== t9) { - t10 = {t7}{t8}{t9}; - $[19] = t7; - $[20] = t9; - $[21] = t10; - } else { - t10 = $[21]; - } - let t11; - if ($[22] === Symbol.for("react.memo_cache_sentinel")) { - t11 = {" "}▘▘ ▝▝{" "}; - $[22] = t11; - } else { - t11 = $[22]; - } - let t12; - if ($[23] !== t10 || $[24] !== t6) { - t12 = {t6}{t10}{t11}; - $[23] = t10; - $[24] = t6; - $[25] = t12; - } else { - t12 = $[25]; - } - return t12; + 'arms-up': ' ▗ ▖ ', } -function AppleTerminalClawd(t0) { - const $ = _c(10); - const { - pose - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[0] = t1; - } else { - t1 = $[0]; + +export function Clawd({ pose = 'default' }: Props = {}): React.ReactNode { + if (env.terminal === 'Apple_Terminal') { + return } - const t2 = APPLE_EYES[pose]; - let t3; - if ($[1] !== t2) { - t3 = {t2}; - $[1] = t2; - $[2] = t3; - } else { - t3 = $[2]; - } - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[3] = t4; - } else { - t4 = $[3]; - } - let t5; - if ($[4] !== t3) { - t5 = {t1}{t3}{t4}; - $[4] = t3; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - let t7; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {" ".repeat(7)}; - t7 = ▘▘ ▝▝; - $[6] = t6; - $[7] = t7; - } else { - t6 = $[6]; - t7 = $[7]; - } - let t8; - if ($[8] !== t5) { - t8 = {t5}{t6}{t7}; - $[8] = t5; - $[9] = t8; - } else { - t8 = $[9]; - } - return t8; + const p = POSES[pose] + return ( + + + {p.r1L} + + {p.r1E} + + {p.r1R} + + + {p.r2L} + + █████ + + {p.r2R} + + + {' '}▘▘ ▝▝{' '} + + + ) +} + +function AppleTerminalClawd({ pose }: { pose: ClawdPose }): React.ReactNode { + // Apple's Terminal renders vertical space between chars by default. + // It does NOT render vertical space between background colors + // so we use background color to draw the main shape. + return ( + + + + + {APPLE_EYES[pose]} + + + + {' '.repeat(7)} + ▘▘ ▝▝ + + ) } diff --git a/src/components/LogoV2/CondensedLogo.tsx b/src/components/LogoV2/CondensedLogo.tsx index 2f2d6307b..be587bf83 100644 --- a/src/components/LogoV2/CondensedLogo.tsx +++ b/src/components/LogoV2/CondensedLogo.tsx @@ -1,160 +1,119 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { type ReactNode, useEffect } from 'react'; -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Box, Text } from '../../ink.js'; -import { useAppState } from '../../state/AppState.js'; -import { getEffortSuffix } from '../../utils/effort.js'; -import { truncate } from '../../utils/format.js'; -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; -import { formatModelAndBilling, getLogoDisplayData, truncatePath } from '../../utils/logoV2Utils.js'; -import { renderModelSetting } from '../../utils/model/model.js'; -import { OffscreenFreeze } from '../OffscreenFreeze.js'; -import { AnimatedClawd } from './AnimatedClawd.js'; -import { Clawd } from './Clawd.js'; -import { GuestPassesUpsell, incrementGuestPassesSeenCount, useShowGuestPassesUpsell } from './GuestPassesUpsell.js'; -import { incrementOverageCreditUpsellSeenCount, OverageCreditUpsell, useShowOverageCreditUpsell } from './OverageCreditUpsell.js'; -export function CondensedLogo() { - const $ = _c(29); - const { - columns - } = useTerminalSize(); - const agent = useAppState(_temp); - const effortValue = useAppState(_temp2); - const model = useMainLoopModel(); - const modelDisplayName = renderModelSetting(model); - const { +import * as React from 'react' +import { type ReactNode, useEffect } from 'react' +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { Box, Text } from '../../ink.js' +import { useAppState } from '../../state/AppState.js' +import { getEffortSuffix } from '../../utils/effort.js' +import { truncate } from '../../utils/format.js' +import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' +import { + formatModelAndBilling, + getLogoDisplayData, + truncatePath, +} from '../../utils/logoV2Utils.js' +import { renderModelSetting } from '../../utils/model/model.js' +import { OffscreenFreeze } from '../OffscreenFreeze.js' +import { AnimatedClawd } from './AnimatedClawd.js' +import { Clawd } from './Clawd.js' +import { + GuestPassesUpsell, + incrementGuestPassesSeenCount, + useShowGuestPassesUpsell, +} from './GuestPassesUpsell.js' +import { + incrementOverageCreditUpsellSeenCount, + OverageCreditUpsell, + useShowOverageCreditUpsell, +} from './OverageCreditUpsell.js' + +export function CondensedLogo(): ReactNode { + const { columns } = useTerminalSize() + const agent = useAppState(s => s.agent) + const effortValue = useAppState(s => s.effortValue) + const model = useMainLoopModel() + const modelDisplayName = renderModelSetting(model) + const { version, cwd, billingType, agentName: agentNameFromSettings } = getLogoDisplayData() + + // Prefer AppState.agent (set from --agent CLI flag) over settings + const agentName = agent ?? agentNameFromSettings + const showGuestPassesUpsell = useShowGuestPassesUpsell() + const showOverageCreditUpsell = useShowOverageCreditUpsell() + + useEffect(() => { + if (showGuestPassesUpsell) { + incrementGuestPassesSeenCount() + } + }, [showGuestPassesUpsell]) + + useEffect(() => { + if (showOverageCreditUpsell && !showGuestPassesUpsell) { + incrementOverageCreditUpsellSeenCount() + } + }, [showOverageCreditUpsell, showGuestPassesUpsell]) + + // Calculate available width for text content + // Account for: condensed clawd width (11 chars) + gap (2) + padding (2) = 15 chars + const textWidth = Math.max(columns - 15, 20) + + // Truncate version to fit within available width, accounting for "Claude Code v" prefix + const versionPrefix = 'Claude Code v' + const truncatedVersion = truncate( version, - cwd, - billingType, - agentName: agentNameFromSettings - } = getLogoDisplayData(); - const agentName = agent ?? agentNameFromSettings; - const showGuestPassesUpsell = useShowGuestPassesUpsell(); - const showOverageCreditUpsell = useShowOverageCreditUpsell(); - let t0; - let t1; - if ($[0] !== showGuestPassesUpsell) { - t0 = () => { - if (showGuestPassesUpsell) { - incrementGuestPassesSeenCount(); - } - }; - t1 = [showGuestPassesUpsell]; - $[0] = showGuestPassesUpsell; - $[1] = t0; - $[2] = t1; - } else { - t0 = $[1]; - t1 = $[2]; - } - useEffect(t0, t1); - let t2; - let t3; - if ($[3] !== showGuestPassesUpsell || $[4] !== showOverageCreditUpsell) { - t2 = () => { - if (showOverageCreditUpsell && !showGuestPassesUpsell) { - incrementOverageCreditUpsellSeenCount(); - } - }; - t3 = [showOverageCreditUpsell, showGuestPassesUpsell]; - $[3] = showGuestPassesUpsell; - $[4] = showOverageCreditUpsell; - $[5] = t2; - $[6] = t3; - } else { - t2 = $[5]; - t3 = $[6]; - } - useEffect(t2, t3); - const textWidth = Math.max(columns - 15, 20); - const truncatedVersion = truncate(version, Math.max(textWidth - 13, 6)); - const effortSuffix = getEffortSuffix(model, effortValue); - const { - shouldSplit, - truncatedModel, - truncatedBilling - } = formatModelAndBilling(modelDisplayName + effortSuffix, billingType, textWidth); - const cwdAvailableWidth = agentName ? textWidth - 1 - stringWidth(agentName) - 3 : textWidth; - const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)); - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = isFullscreenEnvEnabled() ? : ; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Claude Code; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== truncatedVersion) { - t6 = {t5}{" "}v{truncatedVersion}; - $[9] = truncatedVersion; - $[10] = t6; - } else { - t6 = $[10]; - } - let t7; - if ($[11] !== shouldSplit || $[12] !== truncatedBilling || $[13] !== truncatedModel) { - t7 = shouldSplit ? <>{truncatedModel}{truncatedBilling} : {truncatedModel} · {truncatedBilling}; - $[11] = shouldSplit; - $[12] = truncatedBilling; - $[13] = truncatedModel; - $[14] = t7; - } else { - t7 = $[14]; - } - const t8 = agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd; - let t9; - if ($[15] !== t8) { - t9 = {t8}; - $[15] = t8; - $[16] = t9; - } else { - t9 = $[16]; - } - let t10; - if ($[17] !== showGuestPassesUpsell) { - t10 = showGuestPassesUpsell && ; - $[17] = showGuestPassesUpsell; - $[18] = t10; - } else { - t10 = $[18]; - } - let t11; - if ($[19] !== showGuestPassesUpsell || $[20] !== showOverageCreditUpsell || $[21] !== textWidth) { - t11 = !showGuestPassesUpsell && showOverageCreditUpsell && ; - $[19] = showGuestPassesUpsell; - $[20] = showOverageCreditUpsell; - $[21] = textWidth; - $[22] = t11; - } else { - t11 = $[22]; - } - let t12; - if ($[23] !== t10 || $[24] !== t11 || $[25] !== t6 || $[26] !== t7 || $[27] !== t9) { - t12 = {t4}{t6}{t7}{t9}{t10}{t11}; - $[23] = t10; - $[24] = t11; - $[25] = t6; - $[26] = t7; - $[27] = t9; - $[28] = t12; - } else { - t12 = $[28]; - } - return t12; -} -function _temp2(s_0) { - return s_0.effortValue; -} -function _temp(s) { - return s.agent; + Math.max(textWidth - versionPrefix.length, 6), + ) + + const effortSuffix = getEffortSuffix(model, effortValue) + const { shouldSplit, truncatedModel, truncatedBilling } = + formatModelAndBilling( + modelDisplayName + effortSuffix, + billingType, + textWidth, + ) + + // Truncate path, accounting for agent name if present + const separator = ' · ' + const atPrefix = '@' + const cwdAvailableWidth = agentName + ? textWidth - atPrefix.length - stringWidth(agentName) - separator.length + : textWidth + const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)) + + // OffscreenFreeze: the logo sits at the top of the message list and is the + // first thing to enter scrollback. useMainLoopModel() subscribes to model + // changes and getLogoDisplayData() reads getCwd()/subscription state — any + // of which changing while in scrollback would force a full terminal reset. + return ( + + + {isFullscreenEnvEnabled() ? : } + + {/* Info */} + + + Claude Code{' '} + v{truncatedVersion} + + {shouldSplit ? ( + <> + {truncatedModel} + {truncatedBilling} + + ) : ( + + {truncatedModel} · {truncatedBilling} + + )} + + {agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd} + + {showGuestPassesUpsell && } + {!showGuestPassesUpsell && showOverageCreditUpsell && ( + + )} + + + + ) } diff --git a/src/components/LogoV2/EmergencyTip.tsx b/src/components/LogoV2/EmergencyTip.tsx index 014ef6f2b..c0a8235ba 100644 --- a/src/components/LogoV2/EmergencyTip.tsx +++ b/src/components/LogoV2/EmergencyTip.tsx @@ -1,57 +1,65 @@ -import * as React from 'react'; -import { useEffect, useMemo } from 'react'; -import { Box, Text } from 'src/ink.js'; -import { getDynamicConfig_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; -import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js'; -const CONFIG_NAME = 'tengu-top-of-feed-tip'; +import * as React from 'react' +import { useEffect, useMemo } from 'react' +import { Box, Text } from 'src/ink.js' +import { getDynamicConfig_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' +import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js' + +const CONFIG_NAME = 'tengu-top-of-feed-tip' + export function EmergencyTip(): React.ReactNode { - const tip = useMemo(getTipOfFeed, []); + const tip = useMemo(getTipOfFeed, []) // Memoize to prevent re-reads after we save - we want the value at mount time - const lastShownTip = useMemo(() => getGlobalConfig().lastShownEmergencyTip, []); + const lastShownTip = useMemo( + () => getGlobalConfig().lastShownEmergencyTip, + [], + ) // Only show if this is a new/different tip - const shouldShow = tip.tip && tip.tip !== lastShownTip; + const shouldShow = tip.tip && tip.tip !== lastShownTip // Save the tip we're showing so we don't show it again useEffect(() => { if (shouldShow) { saveGlobalConfig(current => { - if (current.lastShownEmergencyTip === tip.tip) return current; - return { - ...current, - lastShownEmergencyTip: tip.tip - }; - }); + if (current.lastShownEmergencyTip === tip.tip) return current + return { ...current, lastShownEmergencyTip: tip.tip } + }) } - }, [shouldShow, tip.tip]); + }, [shouldShow, tip.tip]) + if (!shouldShow) { - return null; + return null } - return - + + return ( + + {tip.tip} - ; + + ) } + type TipOfFeed = { - tip: string; - color?: 'dim' | 'warning' | 'error'; -}; -const DEFAULT_TIP: TipOfFeed = { - tip: '', - color: 'dim' -}; + tip: string + color?: 'dim' | 'warning' | 'error' +} + +const DEFAULT_TIP: TipOfFeed = { tip: '', color: 'dim' } /** * Get the tip of the feed from dynamic config with caching * Returns cached value immediately, updates in background */ function getTipOfFeed(): TipOfFeed { - return getDynamicConfig_CACHED_MAY_BE_STALE(CONFIG_NAME, DEFAULT_TIP); + return getDynamicConfig_CACHED_MAY_BE_STALE( + CONFIG_NAME, + DEFAULT_TIP, + ) } diff --git a/src/components/LogoV2/Feed.tsx b/src/components/LogoV2/Feed.tsx index 853aae361..15a7d84d6 100644 --- a/src/components/LogoV2/Feed.tsx +++ b/src/components/LogoV2/Feed.tsx @@ -1,111 +1,113 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { Box, Text } from '../../ink.js'; -import { truncate } from '../../utils/format.js'; +import * as React from 'react' +import { stringWidth } from '../../ink/stringWidth.js' +import { Box, Text } from '../../ink.js' +import { truncate } from '../../utils/format.js' + export type FeedLine = { - text: string; - timestamp?: string; -}; + text: string + timestamp?: string +} + export type FeedConfig = { - title: string; - lines: FeedLine[]; - footer?: string; - emptyMessage?: string; - customContent?: { - content: React.ReactNode; - width: number; - }; -}; + title: string + lines: FeedLine[] + footer?: string + emptyMessage?: string + customContent?: { content: React.ReactNode; width: number } +} + type FeedProps = { - config: FeedConfig; - actualWidth: number; -}; + config: FeedConfig + actualWidth: number +} + export function calculateFeedWidth(config: FeedConfig): number { - const { - title, - lines, - footer, - emptyMessage, - customContent - } = config; - let maxWidth = stringWidth(title); + const { title, lines, footer, emptyMessage, customContent } = config + + let maxWidth = stringWidth(title) + if (customContent !== undefined) { - maxWidth = Math.max(maxWidth, customContent.width); + maxWidth = Math.max(maxWidth, customContent.width) } else if (lines.length === 0 && emptyMessage) { - maxWidth = Math.max(maxWidth, stringWidth(emptyMessage)); + maxWidth = Math.max(maxWidth, stringWidth(emptyMessage)) } else { - const gap = ' '; - const maxTimestampWidth = Math.max(0, ...lines.map(line => line.timestamp ? stringWidth(line.timestamp) : 0)); + const gap = ' ' + const maxTimestampWidth = Math.max( + 0, + ...lines.map(line => (line.timestamp ? stringWidth(line.timestamp) : 0)), + ) + for (const line of lines) { - const timestampWidth = maxTimestampWidth > 0 ? maxTimestampWidth : 0; - const lineWidth = stringWidth(line.text) + (timestampWidth > 0 ? timestampWidth + gap.length : 0); - maxWidth = Math.max(maxWidth, lineWidth); + const timestampWidth = maxTimestampWidth > 0 ? maxTimestampWidth : 0 + const lineWidth = + stringWidth(line.text) + + (timestampWidth > 0 ? timestampWidth + gap.length : 0) + maxWidth = Math.max(maxWidth, lineWidth) } } + if (footer) { - maxWidth = Math.max(maxWidth, stringWidth(footer)); + maxWidth = Math.max(maxWidth, stringWidth(footer)) } - return maxWidth; + + return maxWidth } -export function Feed(t0) { - const $ = _c(15); - const { - config, - actualWidth - } = t0; - const { - title, - lines, - footer, - emptyMessage, - customContent - } = config; - let t1; - if ($[0] !== lines) { - t1 = Math.max(0, ...lines.map(_temp)); - $[0] = lines; - $[1] = t1; - } else { - t1 = $[1]; - } - const maxTimestampWidth = t1; - let t2; - if ($[2] !== title) { - t2 = {title}; - $[2] = title; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== actualWidth || $[5] !== customContent || $[6] !== emptyMessage || $[7] !== footer || $[8] !== lines || $[9] !== maxTimestampWidth) { - t3 = customContent ? <>{customContent.content}{footer && {truncate(footer, actualWidth)}} : lines.length === 0 && emptyMessage ? {truncate(emptyMessage, actualWidth)} : <>{lines.map((line_0, index) => { - const textWidth = Math.max(10, actualWidth - (maxTimestampWidth > 0 ? maxTimestampWidth + 2 : 0)); - return {maxTimestampWidth > 0 && <>{(line_0.timestamp || "").padEnd(maxTimestampWidth)}{" "}}{truncate(line_0.text, textWidth)}; - })}{footer && {truncate(footer, actualWidth)}}; - $[4] = actualWidth; - $[5] = customContent; - $[6] = emptyMessage; - $[7] = footer; - $[8] = lines; - $[9] = maxTimestampWidth; - $[10] = t3; - } else { - t3 = $[10]; - } - let t4; - if ($[11] !== actualWidth || $[12] !== t2 || $[13] !== t3) { - t4 = {t2}{t3}; - $[11] = actualWidth; - $[12] = t2; - $[13] = t3; - $[14] = t4; - } else { - t4 = $[14]; - } - return t4; -} -function _temp(line) { - return line.timestamp ? stringWidth(line.timestamp) : 0; + +export function Feed({ config, actualWidth }: FeedProps): React.ReactNode { + const { title, lines, footer, emptyMessage, customContent } = config + + const gap = ' ' + const maxTimestampWidth = Math.max( + 0, + ...lines.map(line => (line.timestamp ? stringWidth(line.timestamp) : 0)), + ) + + return ( + + + {title} + + {customContent ? ( + <> + {customContent.content} + {footer && ( + + {truncate(footer, actualWidth)} + + )} + + ) : lines.length === 0 && emptyMessage ? ( + {truncate(emptyMessage, actualWidth)} + ) : ( + <> + {lines.map((line, index) => { + const textWidth = Math.max( + 10, + actualWidth - + (maxTimestampWidth > 0 ? maxTimestampWidth + gap.length : 0), + ) + + return ( + + {maxTimestampWidth > 0 && ( + <> + + {(line.timestamp || '').padEnd(maxTimestampWidth)} + + {gap} + + )} + {truncate(line.text, textWidth)} + + ) + })} + {footer && ( + + {truncate(footer, actualWidth)} + + )} + + )} + + ) } diff --git a/src/components/LogoV2/FeedColumn.tsx b/src/components/LogoV2/FeedColumn.tsx index bc68bc864..0b08ec84a 100644 --- a/src/components/LogoV2/FeedColumn.tsx +++ b/src/components/LogoV2/FeedColumn.tsx @@ -1,58 +1,32 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box } from '../../ink.js'; -import { Divider } from '../design-system/Divider.js'; -import type { FeedConfig } from './Feed.js'; -import { calculateFeedWidth, Feed } from './Feed.js'; +import * as React from 'react' +import { Box } from '../../ink.js' +import { Divider } from '../design-system/Divider.js' +import type { FeedConfig } from './Feed.js' +import { calculateFeedWidth, Feed } from './Feed.js' + type FeedColumnProps = { - feeds: FeedConfig[]; - maxWidth: number; -}; -export function FeedColumn(t0) { - const $ = _c(10); - const { - feeds, - maxWidth - } = t0; - let t1; - if ($[0] !== feeds) { - const feedWidths = feeds.map(_temp); - t1 = Math.max(...feedWidths); - $[0] = feeds; - $[1] = t1; - } else { - t1 = $[1]; - } - const maxOfAllFeeds = t1; - const actualWidth = Math.min(maxOfAllFeeds, maxWidth); - let t2; - if ($[2] !== actualWidth || $[3] !== feeds) { - let t3; - if ($[5] !== actualWidth || $[6] !== feeds.length) { - t3 = (feed_0, index) => {index < feeds.length - 1 && }; - $[5] = actualWidth; - $[6] = feeds.length; - $[7] = t3; - } else { - t3 = $[7]; - } - t2 = feeds.map(t3); - $[2] = actualWidth; - $[3] = feeds; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[8] !== t2) { - t3 = {t2}; - $[8] = t2; - $[9] = t3; - } else { - t3 = $[9]; - } - return t3; + feeds: FeedConfig[] + maxWidth: number } -function _temp(feed) { - return calculateFeedWidth(feed); + +export function FeedColumn({ + feeds, + maxWidth, +}: FeedColumnProps): React.ReactNode { + const feedWidths = feeds.map(feed => calculateFeedWidth(feed)) + const maxOfAllFeeds = Math.max(...feedWidths) + const actualWidth = Math.min(maxOfAllFeeds, maxWidth) + + return ( + + {feeds.map((feed, index) => ( + + + {index < feeds.length - 1 && ( + + )} + + ))} + + ) } diff --git a/src/components/LogoV2/GuestPassesUpsell.tsx b/src/components/LogoV2/GuestPassesUpsell.tsx index a4ad5eb65..12796e43b 100644 --- a/src/components/LogoV2/GuestPassesUpsell.tsx +++ b/src/components/LogoV2/GuestPassesUpsell.tsx @@ -1,69 +1,73 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useState } from 'react'; -import { Text } from '../../ink.js'; -import { logEvent } from '../../services/analytics/index.js'; -import { checkCachedPassesEligibility, formatCreditAmount, getCachedReferrerReward, getCachedRemainingPasses } from '../../services/api/referral.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; +import * as React from 'react' +import { useState } from 'react' +import { Text } from '../../ink.js' +import { logEvent } from '../../services/analytics/index.js' +import { + checkCachedPassesEligibility, + formatCreditAmount, + getCachedReferrerReward, + getCachedRemainingPasses, +} from '../../services/api/referral.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' + function resetIfPassesRefreshed(): void { - const remaining = getCachedRemainingPasses(); - if (remaining == null || remaining <= 0) return; - const config = getGlobalConfig(); - const lastSeen = config.passesLastSeenRemaining ?? 0; + const remaining = getCachedRemainingPasses() + if (remaining == null || remaining <= 0) return + const config = getGlobalConfig() + const lastSeen = config.passesLastSeenRemaining ?? 0 if (remaining > lastSeen) { saveGlobalConfig(prev => ({ ...prev, passesUpsellSeenCount: 0, hasVisitedPasses: false, - passesLastSeenRemaining: remaining - })); + passesLastSeenRemaining: remaining, + })) } } + function shouldShowGuestPassesUpsell(): boolean { - const { - eligible, - hasCache - } = checkCachedPassesEligibility(); + const { eligible, hasCache } = checkCachedPassesEligibility() // Only show if eligible and cache exists (don't block on fetch) - if (!eligible || !hasCache) return false; + if (!eligible || !hasCache) return false // Reset upsell counters if passes were refreshed (covers both campaign change and pass refresh) - resetIfPassesRefreshed(); - const config = getGlobalConfig(); - if ((config.passesUpsellSeenCount ?? 0) >= 3) return false; - if (config.hasVisitedPasses) return false; - return true; + resetIfPassesRefreshed() + + const config = getGlobalConfig() + if ((config.passesUpsellSeenCount ?? 0) >= 3) return false + if (config.hasVisitedPasses) return false + + return true } -export function useShowGuestPassesUpsell() { - const [show] = useState(_temp); - return show; -} -function _temp() { - return shouldShowGuestPassesUpsell(); + +export function useShowGuestPassesUpsell(): boolean { + const [show] = useState(() => shouldShowGuestPassesUpsell()) + return show } + export function incrementGuestPassesSeenCount(): void { - let newCount = 0; + let newCount = 0 saveGlobalConfig(prev => { - newCount = (prev.passesUpsellSeenCount ?? 0) + 1; + newCount = (prev.passesUpsellSeenCount ?? 0) + 1 return { ...prev, - passesUpsellSeenCount: newCount - }; - }); + passesUpsellSeenCount: newCount, + } + }) logEvent('tengu_guest_passes_upsell_shown', { - seen_count: newCount - }); + seen_count: newCount, + }) } // Condensed layout for mini welcome screen -export function GuestPassesUpsell() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - const reward = getCachedReferrerReward(); - t0 = [✻] [✻]{" "}[✻] ·{" "}{reward ? `Share Claude Code and earn ${formatCreditAmount(reward)} of extra usage · /passes` : "3 guest passes at /passes"}; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +export function GuestPassesUpsell(): React.ReactNode { + const reward = getCachedReferrerReward() + return ( + + [✻] [✻]{' '} + [✻] ·{' '} + {reward + ? `Share Claude Code and earn ${formatCreditAmount(reward)} of extra usage · /passes` + : '3 guest passes at /passes'} + + ) } diff --git a/src/components/LogoV2/LogoV2.tsx b/src/components/LogoV2/LogoV2.tsx index 3d3359838..d65c24fe3 100644 --- a/src/components/LogoV2/LogoV2.tsx +++ b/src/components/LogoV2/LogoV2.tsx @@ -1,31 +1,55 @@ -import { c as _c } from "react/compiler-runtime"; // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import * as React from 'react'; -import { Box, Text, color } from '../../ink.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { stringWidth } from '../../ink/stringWidth.js'; -import { getLayoutMode, calculateLayoutDimensions, calculateOptimalLeftWidth, formatWelcomeMessage, truncatePath, getRecentActivitySync, getRecentReleaseNotesSync, getLogoDisplayData } from '../../utils/logoV2Utils.js'; -import { truncate } from '../../utils/format.js'; -import { getDisplayPath } from '../../utils/file.js'; -import { Clawd } from './Clawd.js'; -import { FeedColumn } from './FeedColumn.js'; -import { createRecentActivityFeed, createWhatsNewFeed, createProjectOnboardingFeed, createGuestPassesFeed } from './feedConfigs.js'; -import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js'; -import { resolveThemeSetting } from 'src/utils/systemTheme.js'; -import { getInitialSettings } from 'src/utils/settings/settings.js'; -import { isDebugMode, isDebugToStdErr, getDebugLogPath } from 'src/utils/debug.js'; -import { useEffect, useState } from 'react'; -import { getSteps, shouldShowProjectOnboarding, incrementProjectOnboardingSeenCount } from '../../projectOnboardingState.js'; -import { CondensedLogo } from './CondensedLogo.js'; -import { OffscreenFreeze } from '../OffscreenFreeze.js'; -import { checkForReleaseNotesSync } from '../../utils/releaseNotes.js'; -import { getDumpPromptsPath } from 'src/services/api/dumpPrompts.js'; -import { isEnvTruthy } from 'src/utils/envUtils.js'; -import { getStartupPerfLogPath, isDetailedProfilingEnabled } from 'src/utils/startupProfiler.js'; -import { EmergencyTip } from './EmergencyTip.js'; -import { VoiceModeNotice } from './VoiceModeNotice.js'; -import { Opus1mMergeNotice } from './Opus1mMergeNotice.js'; -import { feature } from 'bun:bundle'; +import * as React from 'react' +import { Box, Text, color } from '../../ink.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { stringWidth } from '../../ink/stringWidth.js' +import { + getLayoutMode, + calculateLayoutDimensions, + calculateOptimalLeftWidth, + formatWelcomeMessage, + truncatePath, + getRecentActivitySync, + getRecentReleaseNotesSync, + getLogoDisplayData, +} from '../../utils/logoV2Utils.js' +import { truncate } from '../../utils/format.js' +import { getDisplayPath } from '../../utils/file.js' +import { Clawd } from './Clawd.js' +import { FeedColumn } from './FeedColumn.js' +import { + createRecentActivityFeed, + createWhatsNewFeed, + createProjectOnboardingFeed, + createGuestPassesFeed, +} from './feedConfigs.js' +import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js' +import { resolveThemeSetting } from 'src/utils/systemTheme.js' +import { getInitialSettings } from 'src/utils/settings/settings.js' +import { + isDebugMode, + isDebugToStdErr, + getDebugLogPath, +} from 'src/utils/debug.js' +import { useEffect, useState } from 'react' +import { + getSteps, + shouldShowProjectOnboarding, + incrementProjectOnboardingSeenCount, +} from '../../projectOnboardingState.js' +import { CondensedLogo } from './CondensedLogo.js' +import { OffscreenFreeze } from '../OffscreenFreeze.js' +import { checkForReleaseNotesSync } from '../../utils/releaseNotes.js' +import { getDumpPromptsPath } from 'src/services/api/dumpPrompts.js' +import { isEnvTruthy } from 'src/utils/envUtils.js' +import { + getStartupPerfLogPath, + isDetailedProfilingEnabled, +} from 'src/utils/startupProfiler.js' +import { EmergencyTip } from './EmergencyTip.js' +import { VoiceModeNotice } from './VoiceModeNotice.js' +import { Opus1mMergeNotice } from './Opus1mMergeNotice.js' +import { feature } from 'bun:bundle' // Conditional require so ChannelsNotice.tsx tree-shakes when both flags are // false. A module-scope helper component inside a feature() ternary does NOT @@ -33,510 +57,444 @@ import { feature } from 'bun:bundle'; // whole file. VoiceModeNotice uses the unsafe helper pattern but VOICE_MODE // is external: true so it's moot there. /* eslint-disable @typescript-eslint/no-require-imports */ -const ChannelsNoticeModule = feature('KAIROS') || feature('KAIROS_CHANNELS') ? require('./ChannelsNotice.js') as typeof import('./ChannelsNotice.js') : null; +const ChannelsNoticeModule = + feature('KAIROS') || feature('KAIROS_CHANNELS') + ? (require('./ChannelsNotice.js') as typeof import('./ChannelsNotice.js')) + : null /* eslint-enable @typescript-eslint/no-require-imports */ -import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'; -import { useShowGuestPassesUpsell, incrementGuestPassesSeenCount } from './GuestPassesUpsell.js'; -import { useShowOverageCreditUpsell, incrementOverageCreditUpsellSeenCount, createOverageCreditFeed } from './OverageCreditUpsell.js'; -import { plural } from '../../utils/stringUtils.js'; -import { useAppState } from '../../state/AppState.js'; -import { getEffortSuffix } from '../../utils/effort.js'; -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; -import { renderModelSetting } from '../../utils/model/model.js'; -const LEFT_PANEL_MAX_WIDTH = 50; -export function LogoV2() { - const $ = _c(94); - const activities = getRecentActivitySync(); - const username = getGlobalConfig().oauthAccount?.displayName ?? ""; - const { - columns - } = useTerminalSize(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = shouldShowProjectOnboarding(); - $[0] = t0; - } else { - t0 = $[0]; - } - const showOnboarding = t0; - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = SandboxManager.isSandboxingEnabled(); - $[1] = t1; - } else { - t1 = $[1]; - } - const showSandboxStatus = t1; - const showGuestPassesUpsell = useShowGuestPassesUpsell(); - const showOverageCreditUpsell = useShowOverageCreditUpsell(); - const agent = useAppState(_temp); - const effortValue = useAppState(_temp2); - const config = getGlobalConfig(); - let changelog; +import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js' +import { + useShowGuestPassesUpsell, + incrementGuestPassesSeenCount, +} from './GuestPassesUpsell.js' +import { + useShowOverageCreditUpsell, + incrementOverageCreditUpsellSeenCount, + createOverageCreditFeed, +} from './OverageCreditUpsell.js' +import { plural } from '../../utils/stringUtils.js' +import { useAppState } from '../../state/AppState.js' +import { getEffortSuffix } from '../../utils/effort.js' +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' +import { renderModelSetting } from '../../utils/model/model.js' + +const LEFT_PANEL_MAX_WIDTH = 50 + +export function LogoV2(): React.ReactNode { + const activities = getRecentActivitySync() + const username = getGlobalConfig().oauthAccount?.displayName ?? '' + + const { columns } = useTerminalSize() + const showOnboarding = shouldShowProjectOnboarding() + const showSandboxStatus = SandboxManager.isSandboxingEnabled() + const showGuestPassesUpsell = useShowGuestPassesUpsell() + const showOverageCreditUpsell = useShowOverageCreditUpsell() + const agent = useAppState(s => s.agent) + const effortValue = useAppState(s => s.effortValue) + + const config = getGlobalConfig() + + let changelog: string[] try { - changelog = getRecentReleaseNotesSync(3); + changelog = getRecentReleaseNotesSync(3) } catch { - changelog = []; + changelog = [] } + + // Get company announcements and select one: + // - First startup (numStartups === 1): show first announcement + // - All other startups: randomly select from announcements const [announcement] = useState(() => { - const announcements = getInitialSettings().companyAnnouncements; - if (!announcements || announcements.length === 0) { - return; + const announcements = getInitialSettings().companyAnnouncements + if (!announcements || announcements.length === 0) return undefined + return config.numStartups === 1 + ? announcements[0] + : announcements[Math.floor(Math.random() * announcements.length)] + }) + const { hasReleaseNotes } = checkForReleaseNotesSync( + config.lastReleaseNotesSeen, + ) + + useEffect(() => { + const currentConfig = getGlobalConfig() + if (currentConfig.lastReleaseNotesSeen === MACRO.VERSION) { + return } - return config.numStartups === 1 ? announcements[0] : announcements[Math.floor(Math.random() * announcements.length)]; - }); - const { - hasReleaseNotes - } = checkForReleaseNotesSync(config.lastReleaseNotesSeen); - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => { - const currentConfig = getGlobalConfig(); - if (currentConfig.lastReleaseNotesSeen === MACRO.VERSION) { - return; - } - saveGlobalConfig(_temp3); - if (showOnboarding) { - incrementProjectOnboardingSeenCount(); - } - }; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== config) { - t3 = [config, showOnboarding]; - $[3] = config; - $[4] = t3; - } else { - t3 = $[4]; - } - useEffect(t2, t3); - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = !hasReleaseNotes && !showOnboarding && !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO); - $[5] = t4; - } else { - t4 = $[5]; - } - const isCondensedMode = t4; - let t5; - let t6; - if ($[6] !== showGuestPassesUpsell) { - t5 = () => { - if (showGuestPassesUpsell && !showOnboarding && !isCondensedMode) { - incrementGuestPassesSeenCount(); - } - }; - t6 = [showGuestPassesUpsell, showOnboarding, isCondensedMode]; - $[6] = showGuestPassesUpsell; - $[7] = t5; - $[8] = t6; - } else { - t5 = $[7]; - t6 = $[8]; - } - useEffect(t5, t6); - let t7; - let t8; - if ($[9] !== showGuestPassesUpsell || $[10] !== showOverageCreditUpsell) { - t7 = () => { - if (showOverageCreditUpsell && !showOnboarding && !showGuestPassesUpsell && !isCondensedMode) { - incrementOverageCreditUpsellSeenCount(); - } - }; - t8 = [showOverageCreditUpsell, showOnboarding, showGuestPassesUpsell, isCondensedMode]; - $[9] = showGuestPassesUpsell; - $[10] = showOverageCreditUpsell; - $[11] = t7; - $[12] = t8; - } else { - t7 = $[11]; - t8 = $[12]; - } - useEffect(t7, t8); - const model = useMainLoopModel(); - const fullModelDisplayName = renderModelSetting(model); + saveGlobalConfig(current => { + if (current.lastReleaseNotesSeen === MACRO.VERSION) return current + return { ...current, lastReleaseNotesSeen: MACRO.VERSION } + }) + if (showOnboarding) { + incrementProjectOnboardingSeenCount() + } + }, [config, showOnboarding]) + + // In condensed mode (early-return below renders ), + // CondensedLogo's own useEffect handles the impression count. Skipping + // here avoids double-counting since hooks fire before the early return. + const isCondensedMode = + !hasReleaseNotes && + !showOnboarding && + !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO) + + useEffect(() => { + if (showGuestPassesUpsell && !showOnboarding && !isCondensedMode) { + incrementGuestPassesSeenCount() + } + }, [showGuestPassesUpsell, showOnboarding, isCondensedMode]) + + useEffect(() => { + if ( + showOverageCreditUpsell && + !showOnboarding && + !showGuestPassesUpsell && + !isCondensedMode + ) { + incrementOverageCreditUpsellSeenCount() + } + }, [ + showOverageCreditUpsell, + showOnboarding, + showGuestPassesUpsell, + isCondensedMode, + ]) + + const model = useMainLoopModel() + const fullModelDisplayName = renderModelSetting(model) const { version, cwd, billingType, - agentName: agentNameFromSettings - } = getLogoDisplayData(); - const agentName = agent ?? agentNameFromSettings; - const effortSuffix = getEffortSuffix(model, effortValue); - const t9 = fullModelDisplayName + effortSuffix; - let t10; - if ($[13] !== t9) { - t10 = truncate(t9, LEFT_PANEL_MAX_WIDTH - 20); - $[13] = t9; - $[14] = t10; - } else { - t10 = $[14]; + agentName: agentNameFromSettings, + } = getLogoDisplayData() + // Prefer AppState.agent (set from --agent CLI flag) over settings + const agentName = agent ?? agentNameFromSettings + // -20 to account for the max length of subscription name " · Claude Enterprise". + const effortSuffix = getEffortSuffix(model, effortValue) + const modelDisplayName = truncate( + fullModelDisplayName + effortSuffix, + LEFT_PANEL_MAX_WIDTH - 20, + ) + + // Show condensed logo if no new changelog and not showing onboarding and not forcing full logo + if ( + !hasReleaseNotes && + !showOnboarding && + !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO) + ) { + return ( + <> + + + + {ChannelsNoticeModule && } + {isDebugMode() && ( + + Debug mode enabled + + Logging to: {isDebugToStdErr() ? 'stderr' : getDebugLogPath()} + + + )} + + {process.env.CLAUDE_CODE_TMUX_SESSION && ( + + + tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION} + + + {process.env.CLAUDE_CODE_TMUX_PREFIX_CONFLICTS + ? `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} ${process.env.CLAUDE_CODE_TMUX_PREFIX} d (press prefix twice - Claude uses ${process.env.CLAUDE_CODE_TMUX_PREFIX})` + : `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} d`} + + + )} + {announcement && ( + + {!process.env.IS_DEMO && config.oauthAccount?.organizationName && ( + + Message from {config.oauthAccount.organizationName}: + + )} + {announcement} + + )} + {process.env.USER_TYPE === 'ant' && !process.env.DEMO_VERSION && ( + + Use /issue to report model behavior issues + + )} + {process.env.USER_TYPE === 'ant' && !process.env.DEMO_VERSION && ( + + [ANT-ONLY] Logs: + + API calls: {getDisplayPath(getDumpPromptsPath())} + + + Debug logs: {getDisplayPath(getDebugLogPath())} + + {isDetailedProfilingEnabled() && ( + + Startup Perf: {getDisplayPath(getStartupPerfLogPath())} + + )} + + )} + {process.env.USER_TYPE === 'ant' && } + {process.env.USER_TYPE === 'ant' && } + + ) } - const modelDisplayName = t10; - if (!hasReleaseNotes && !showOnboarding && !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO)) { - let t11; - let t12; - let t13; - let t14; - let t15; - let t16; - let t17; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t11 = ; - t12 = ; - t13 = ; - t14 = ChannelsNoticeModule && ; - t15 = isDebugMode() && Debug mode enabledLogging to: {isDebugToStdErr() ? "stderr" : getDebugLogPath()}; - t16 = ; - t17 = process.env.CLAUDE_CODE_TMUX_SESSION && tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION}{process.env.CLAUDE_CODE_TMUX_PREFIX_CONFLICTS ? `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} ${process.env.CLAUDE_CODE_TMUX_PREFIX} d (press prefix twice - Claude uses ${process.env.CLAUDE_CODE_TMUX_PREFIX})` : `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} d`}; - $[15] = t11; - $[16] = t12; - $[17] = t13; - $[18] = t14; - $[19] = t15; - $[20] = t16; - $[21] = t17; - } else { - t11 = $[15]; - t12 = $[16]; - t13 = $[17]; - t14 = $[18]; - t15 = $[19]; - t16 = $[20]; - t17 = $[21]; + + // Calculate layout and display values + const layoutMode = getLayoutMode(columns) + + const userTheme = resolveThemeSetting(getGlobalConfig().theme) + const borderTitle = ` ${color('claude', userTheme)('Claude Code')} ${color('inactive', userTheme)(`v${version}`)} ` + const compactBorderTitle = color('claude', userTheme)(' Claude Code ') + + // Early return for compact mode + if (layoutMode === 'compact') { + const layoutWidth = 4 // border + padding + let welcomeMessage = formatWelcomeMessage(username) + if (stringWidth(welcomeMessage) > columns - layoutWidth) { + welcomeMessage = formatWelcomeMessage(null) } - let t18; - if ($[22] !== announcement || $[23] !== config) { - t18 = announcement && {!process.env.IS_DEMO && config.oauthAccount?.organizationName && Message from {config.oauthAccount.organizationName}:}{announcement}; - $[22] = announcement; - $[23] = config; - $[24] = t18; - } else { - t18 = $[24]; - } - let t19; - let t20; - let t21; - let t22; - if ($[25] === Symbol.for("react.memo_cache_sentinel")) { - t19 = false && !process.env.DEMO_VERSION && Use /issue to report model behavior issues; - t20 = false && !process.env.DEMO_VERSION && [ANT-ONLY] Logs:API calls: {getDisplayPath(getDumpPromptsPath())}Debug logs: {getDisplayPath(getDebugLogPath())}{isDetailedProfilingEnabled() && Startup Perf: {getDisplayPath(getStartupPerfLogPath())}}; - t21 = false && ; - t22 = false && ; - $[25] = t19; - $[26] = t20; - $[27] = t21; - $[28] = t22; - } else { - t19 = $[25]; - t20 = $[26]; - t21 = $[27]; - t22 = $[28]; - } - let t23; - if ($[29] !== t18) { - t23 = <>{t11}{t12}{t13}{t14}{t15}{t16}{t17}{t18}{t19}{t20}{t21}{t22}; - $[29] = t18; - $[30] = t23; - } else { - t23 = $[30]; - } - return t23; + + // Calculate cwd width accounting for agent name if present + const separator = ' · ' + const atPrefix = '@' + const cwdAvailableWidth = agentName + ? columns - + layoutWidth - + atPrefix.length - + stringWidth(agentName) - + separator.length + : columns - layoutWidth + const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)) + // OffscreenFreeze: logo is the first thing to enter scrollback; useMainLoopModel() + // subscribes to model changes and getLogoDisplayData() reads cwd/subscription — + // any change while in scrollback forces a full reset. + return ( + <> + + + {welcomeMessage} + + + + {modelDisplayName} + {billingType} + + {agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd} + + + + + + {ChannelsNoticeModule && } + {showSandboxStatus && ( + + + Your bash commands will be sandboxed. Disable with /sandbox. + + + )} + {process.env.USER_TYPE === 'ant' && } + {process.env.USER_TYPE === 'ant' && } + + ) } - const layoutMode = getLayoutMode(columns); - const userTheme = resolveThemeSetting(getGlobalConfig().theme); - const borderTitle = ` ${color("claude", userTheme)("Claude Code")} ${color("inactive", userTheme)(`v${version}`)} `; - const compactBorderTitle = color("claude", userTheme)(" Claude Code "); - if (layoutMode === "compact") { - let welcomeMessage = formatWelcomeMessage(username); - if (stringWidth(welcomeMessage) > columns - 4) { - let t11; - if ($[31] === Symbol.for("react.memo_cache_sentinel")) { - t11 = formatWelcomeMessage(null); - $[31] = t11; - } else { - t11 = $[31]; - } - welcomeMessage = t11; - } - const cwdAvailableWidth = agentName ? columns - 4 - 1 - stringWidth(agentName) - 3 : columns - 4; - const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)); - let t11; - if ($[32] !== compactBorderTitle) { - t11 = { - content: compactBorderTitle, - position: "top", - align: "start", - offset: 1 - }; - $[32] = compactBorderTitle; - $[33] = t11; - } else { - t11 = $[33]; - } - let t12; - if ($[34] === Symbol.for("react.memo_cache_sentinel")) { - t12 = ; - $[34] = t12; - } else { - t12 = $[34]; - } - let t13; - if ($[35] !== modelDisplayName) { - t13 = {modelDisplayName}; - $[35] = modelDisplayName; - $[36] = t13; - } else { - t13 = $[36]; - } - let t14; - let t15; - let t16; - if ($[37] === Symbol.for("react.memo_cache_sentinel")) { - t14 = ; - t15 = ; - t16 = ChannelsNoticeModule && ; - $[37] = t14; - $[38] = t15; - $[39] = t16; - } else { - t14 = $[37]; - t15 = $[38]; - t16 = $[39]; - } - let t17; - if ($[40] !== showSandboxStatus) { - t17 = showSandboxStatus && Your bash commands will be sandboxed. Disable with /sandbox.; - $[40] = showSandboxStatus; - $[41] = t17; - } else { - t17 = $[41]; - } - let t18; - let t19; - if ($[42] === Symbol.for("react.memo_cache_sentinel")) { - t18 = false && ; - t19 = false && ; - $[42] = t18; - $[43] = t19; - } else { - t18 = $[42]; - t19 = $[43]; - } - return <>{welcomeMessage}{t12}{t13}{billingType}{agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd}{t14}{t15}{t16}{t17}{t18}{t19}; - } - const welcomeMessage_0 = formatWelcomeMessage(username); - const modelLine = !process.env.IS_DEMO && config.oauthAccount?.organizationName ? `${modelDisplayName} · ${billingType} · ${config.oauthAccount.organizationName}` : `${modelDisplayName} · ${billingType}`; - const cwdAvailableWidth_0 = agentName ? LEFT_PANEL_MAX_WIDTH - 1 - stringWidth(agentName) - 3 : LEFT_PANEL_MAX_WIDTH; - const truncatedCwd_0 = truncatePath(cwd, Math.max(cwdAvailableWidth_0, 10)); - const cwdLine = agentName ? `@${agentName} · ${truncatedCwd_0}` : truncatedCwd_0; - const optimalLeftWidth = calculateOptimalLeftWidth(welcomeMessage_0, cwdLine, modelLine); - const { - leftWidth, - rightWidth - } = calculateLayoutDimensions(columns, layoutMode, optimalLeftWidth); - const T0 = OffscreenFreeze; - const T1 = Box; - const t11 = "column"; - const t12 = "round"; - const t13 = "claude"; - let t14; - if ($[44] !== borderTitle) { - t14 = { - content: borderTitle, - position: "top", - align: "start", - offset: 3 - }; - $[44] = borderTitle; - $[45] = t14; - } else { - t14 = $[45]; - } - const T2 = Box; - const t15 = layoutMode === "horizontal" ? "row" : "column"; - const t16 = 1; - const t17 = 1; - let t18; - if ($[46] !== welcomeMessage_0) { - t18 = {welcomeMessage_0}; - $[46] = welcomeMessage_0; - $[47] = t18; - } else { - t18 = $[47]; - } - let t19; - if ($[48] === Symbol.for("react.memo_cache_sentinel")) { - t19 = ; - $[48] = t19; - } else { - t19 = $[48]; - } - let t20; - if ($[49] !== modelLine) { - t20 = {modelLine}; - $[49] = modelLine; - $[50] = t20; - } else { - t20 = $[50]; - } - let t21; - if ($[51] !== cwdLine) { - t21 = {cwdLine}; - $[51] = cwdLine; - $[52] = t21; - } else { - t21 = $[52]; - } - let t22; - if ($[53] !== t20 || $[54] !== t21) { - t22 = {t20}{t21}; - $[53] = t20; - $[54] = t21; - $[55] = t22; - } else { - t22 = $[55]; - } - let t23; - if ($[56] !== leftWidth || $[57] !== t18 || $[58] !== t22) { - t23 = {t18}{t19}{t22}; - $[56] = leftWidth; - $[57] = t18; - $[58] = t22; - $[59] = t23; - } else { - t23 = $[59]; - } - let t24; - if ($[60] !== layoutMode) { - t24 = layoutMode === "horizontal" && ; - $[60] = layoutMode; - $[61] = t24; - } else { - t24 = $[61]; - } - const t25 = layoutMode === "horizontal" && ; - let t26; - if ($[62] !== T2 || $[63] !== t15 || $[64] !== t23 || $[65] !== t24 || $[66] !== t25) { - t26 = {t23}{t24}{t25}; - $[62] = T2; - $[63] = t15; - $[64] = t23; - $[65] = t24; - $[66] = t25; - $[67] = t26; - } else { - t26 = $[67]; - } - let t27; - if ($[68] !== T1 || $[69] !== t14 || $[70] !== t26) { - t27 = {t26}; - $[68] = T1; - $[69] = t14; - $[70] = t26; - $[71] = t27; - } else { - t27 = $[71]; - } - let t28; - if ($[72] !== T0 || $[73] !== t27) { - t28 = {t27}; - $[72] = T0; - $[73] = t27; - $[74] = t28; - } else { - t28 = $[74]; - } - let t29; - let t30; - let t31; - let t32; - let t33; - let t34; - if ($[75] === Symbol.for("react.memo_cache_sentinel")) { - t29 = ; - t30 = ; - t31 = ChannelsNoticeModule && ; - t32 = isDebugMode() && Debug mode enabledLogging to: {isDebugToStdErr() ? "stderr" : getDebugLogPath()}; - t33 = ; - t34 = process.env.CLAUDE_CODE_TMUX_SESSION && tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION}{process.env.CLAUDE_CODE_TMUX_PREFIX_CONFLICTS ? `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} ${process.env.CLAUDE_CODE_TMUX_PREFIX} d (press prefix twice - Claude uses ${process.env.CLAUDE_CODE_TMUX_PREFIX})` : `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} d`}; - $[75] = t29; - $[76] = t30; - $[77] = t31; - $[78] = t32; - $[79] = t33; - $[80] = t34; - } else { - t29 = $[75]; - t30 = $[76]; - t31 = $[77]; - t32 = $[78]; - t33 = $[79]; - t34 = $[80]; - } - let t35; - if ($[81] !== announcement || $[82] !== config) { - t35 = announcement && {!process.env.IS_DEMO && config.oauthAccount?.organizationName && Message from {config.oauthAccount.organizationName}:}{announcement}; - $[81] = announcement; - $[82] = config; - $[83] = t35; - } else { - t35 = $[83]; - } - let t36; - if ($[84] !== showSandboxStatus) { - t36 = showSandboxStatus && Your bash commands will be sandboxed. Disable with /sandbox.; - $[84] = showSandboxStatus; - $[85] = t36; - } else { - t36 = $[85]; - } - let t37; - let t38; - let t39; - let t40; - if ($[86] === Symbol.for("react.memo_cache_sentinel")) { - t37 = false && !process.env.DEMO_VERSION && Use /issue to report model behavior issues; - t38 = false && !process.env.DEMO_VERSION && [ANT-ONLY] Logs:API calls: {getDisplayPath(getDumpPromptsPath())}Debug logs: {getDisplayPath(getDebugLogPath())}{isDetailedProfilingEnabled() && Startup Perf: {getDisplayPath(getStartupPerfLogPath())}}; - t39 = false && ; - t40 = false && ; - $[86] = t37; - $[87] = t38; - $[88] = t39; - $[89] = t40; - } else { - t37 = $[86]; - t38 = $[87]; - t39 = $[88]; - t40 = $[89]; - } - let t41; - if ($[90] !== t28 || $[91] !== t35 || $[92] !== t36) { - t41 = <>{t28}{t29}{t30}{t31}{t32}{t33}{t34}{t35}{t36}{t37}{t38}{t39}{t40}; - $[90] = t28; - $[91] = t35; - $[92] = t36; - $[93] = t41; - } else { - t41 = $[93]; - } - return t41; -} -function _temp3(current) { - if (current.lastReleaseNotesSeen === MACRO.VERSION) { - return current; - } - return { - ...current, - lastReleaseNotesSeen: MACRO.VERSION - }; -} -function _temp2(s_0) { - return s_0.effortValue; -} -function _temp(s) { - return s.agent; + + const welcomeMessage = formatWelcomeMessage(username) + const modelLine = + !process.env.IS_DEMO && config.oauthAccount?.organizationName + ? `${modelDisplayName} · ${billingType} · ${config.oauthAccount.organizationName}` + : `${modelDisplayName} · ${billingType}` + // Calculate cwd width accounting for agent name if present + const cwdSeparator = ' · ' + const cwdAtPrefix = '@' + const cwdAvailableWidth = agentName + ? LEFT_PANEL_MAX_WIDTH - + cwdAtPrefix.length - + stringWidth(agentName) - + cwdSeparator.length + : LEFT_PANEL_MAX_WIDTH + const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)) + const cwdLine = agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd + const optimalLeftWidth = calculateOptimalLeftWidth( + welcomeMessage, + cwdLine, + modelLine, + ) + + // Calculate layout dimensions + const { leftWidth, rightWidth } = calculateLayoutDimensions( + columns, + layoutMode, + optimalLeftWidth, + ) + + return ( + <> + + + {/* Main content */} + + {/* Left Panel */} + + + {welcomeMessage} + + + + + + {modelLine} + {cwdLine} + + + + {/* Vertical divider */} + {layoutMode === 'horizontal' && ( + + )} + + {/* Right Panel - Project Onboarding or Recent Activity and What's New */} + {layoutMode === 'horizontal' && ( + + )} + + + + + + {ChannelsNoticeModule && } + {isDebugMode() && ( + + Debug mode enabled + + Logging to: {isDebugToStdErr() ? 'stderr' : getDebugLogPath()} + + + )} + + {process.env.CLAUDE_CODE_TMUX_SESSION && ( + + + tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION} + + + {process.env.CLAUDE_CODE_TMUX_PREFIX_CONFLICTS + ? `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} ${process.env.CLAUDE_CODE_TMUX_PREFIX} d (press prefix twice - Claude uses ${process.env.CLAUDE_CODE_TMUX_PREFIX})` + : `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} d`} + + + )} + {announcement && ( + + {!process.env.IS_DEMO && config.oauthAccount?.organizationName && ( + + Message from {config.oauthAccount.organizationName}: + + )} + {announcement} + + )} + {showSandboxStatus && ( + + + Your bash commands will be sandboxed. Disable with /sandbox. + + + )} + {process.env.USER_TYPE === 'ant' && !process.env.DEMO_VERSION && ( + + Use /issue to report model behavior issues + + )} + {process.env.USER_TYPE === 'ant' && !process.env.DEMO_VERSION && ( + + [ANT-ONLY] Logs: + + API calls: {getDisplayPath(getDumpPromptsPath())} + + Debug logs: {getDisplayPath(getDebugLogPath())} + {isDetailedProfilingEnabled() && ( + + Startup Perf: {getDisplayPath(getStartupPerfLogPath())} + + )} + + )} + {process.env.USER_TYPE === 'ant' && } + {process.env.USER_TYPE === 'ant' && } + + ) } + diff --git a/src/components/LogoV2/Opus1mMergeNotice.tsx b/src/components/LogoV2/Opus1mMergeNotice.tsx index f07efc672..63c42ab66 100644 --- a/src/components/LogoV2/Opus1mMergeNotice.tsx +++ b/src/components/LogoV2/Opus1mMergeNotice.tsx @@ -1,54 +1,41 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { UP_ARROW } from '../../constants/figures.js'; -import { Box, Text } from '../../ink.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { isOpus1mMergeEnabled } from '../../utils/model/model.js'; -import { AnimatedAsterisk } from './AnimatedAsterisk.js'; -const MAX_SHOW_COUNT = 6; +import * as React from 'react' +import { useEffect, useState } from 'react' +import { UP_ARROW } from '../../constants/figures.js' +import { Box, Text } from '../../ink.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { isOpus1mMergeEnabled } from '../../utils/model/model.js' +import { AnimatedAsterisk } from './AnimatedAsterisk.js' + +const MAX_SHOW_COUNT = 6 + export function shouldShowOpus1mMergeNotice(): boolean { - return isOpus1mMergeEnabled() && (getGlobalConfig().opus1mMergeNoticeSeenCount ?? 0) < MAX_SHOW_COUNT; + return ( + isOpus1mMergeEnabled() && + (getGlobalConfig().opus1mMergeNoticeSeenCount ?? 0) < MAX_SHOW_COUNT + ) } -export function Opus1mMergeNotice() { - const $ = _c(4); - const [show] = useState(shouldShowOpus1mMergeNotice); - let t0; - let t1; - if ($[0] !== show) { - t0 = () => { - if (!show) { - return; - } - const newCount = (getGlobalConfig().opus1mMergeNoticeSeenCount ?? 0) + 1; - saveGlobalConfig(prev => { - if ((prev.opus1mMergeNoticeSeenCount ?? 0) >= newCount) { - return prev; - } - return { - ...prev, - opus1mMergeNoticeSeenCount: newCount - }; - }); - }; - t1 = [show]; - $[0] = show; - $[1] = t0; - $[2] = t1; - } else { - t0 = $[1]; - t1 = $[2]; - } - useEffect(t0, t1); - if (!show) { - return null; - } - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {" "}Opus now defaults to 1M context · 5x more room, same pricing; - $[3] = t2; - } else { - t2 = $[3]; - } - return t2; + +export function Opus1mMergeNotice(): React.ReactNode { + const [show] = useState(shouldShowOpus1mMergeNotice) + + useEffect(() => { + if (!show) return + const newCount = (getGlobalConfig().opus1mMergeNoticeSeenCount ?? 0) + 1 + saveGlobalConfig(prev => { + if ((prev.opus1mMergeNoticeSeenCount ?? 0) >= newCount) return prev + return { ...prev, opus1mMergeNoticeSeenCount: newCount } + }) + }, [show]) + + if (!show) return null + + return ( + + + + {' '} + Opus now defaults to 1M context · 5x more room, same pricing + + + ) } diff --git a/src/components/LogoV2/OverageCreditUpsell.tsx b/src/components/LogoV2/OverageCreditUpsell.tsx index fd5e6dea8..ce006e0d0 100644 --- a/src/components/LogoV2/OverageCreditUpsell.tsx +++ b/src/components/LogoV2/OverageCreditUpsell.tsx @@ -1,13 +1,17 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useState } from 'react'; -import { Text } from '../../ink.js'; -import { logEvent } from '../../services/analytics/index.js'; -import { formatGrantAmount, getCachedOverageCreditGrant, refreshOverageCreditGrantCache } from '../../services/api/overageCreditGrant.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { truncate } from '../../utils/format.js'; -import type { FeedConfig } from './Feed.js'; -const MAX_IMPRESSIONS = 3; +import * as React from 'react' +import { useState } from 'react' +import { Text } from '../../ink.js' +import { logEvent } from '../../services/analytics/index.js' +import { + formatGrantAmount, + getCachedOverageCreditGrant, + refreshOverageCreditGrantCache, +} from '../../services/api/overageCreditGrant.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { truncate } from '../../utils/format.js' +import type { FeedConfig } from './Feed.js' + +const MAX_IMPRESSIONS = 3 /** * Whether to show the overage credit upsell on any surface. @@ -25,16 +29,20 @@ const MAX_IMPRESSIONS = 3; * (welcome feed, tips). */ export function isEligibleForOverageCreditGrant(): boolean { - const info = getCachedOverageCreditGrant(); - if (!info || !info.available || info.granted) return false; - return formatGrantAmount(info) !== null; + const info = getCachedOverageCreditGrant() + if (!info || !info.available || info.granted) return false + return formatGrantAmount(info) !== null } + export function shouldShowOverageCreditUpsell(): boolean { - if (!isEligibleForOverageCreditGrant()) return false; - const config = getGlobalConfig(); - if (config.hasVisitedExtraUsage) return false; - if ((config.overageCreditUpsellSeenCount ?? 0) >= MAX_IMPRESSIONS) return false; - return true; + if (!isEligibleForOverageCreditGrant()) return false + + const config = getGlobalConfig() + if (config.hasVisitedExtraUsage) return false + if ((config.overageCreditUpsellSeenCount ?? 0) >= MAX_IMPRESSIONS) + return false + + return true } /** @@ -42,105 +50,78 @@ export function shouldShowOverageCreditUpsell(): boolean { * unconditionally on mount — it no-ops if cache is fresh. */ export function maybeRefreshOverageCreditCache(): void { - if (getCachedOverageCreditGrant() !== null) return; - void refreshOverageCreditGrantCache(); + if (getCachedOverageCreditGrant() !== null) return + void refreshOverageCreditGrantCache() } -export function useShowOverageCreditUpsell() { - const [show] = useState(_temp); - return show; -} -function _temp() { - maybeRefreshOverageCreditCache(); - return shouldShowOverageCreditUpsell(); + +export function useShowOverageCreditUpsell(): boolean { + const [show] = useState(() => { + maybeRefreshOverageCreditCache() + return shouldShowOverageCreditUpsell() + }) + return show } + export function incrementOverageCreditUpsellSeenCount(): void { - let newCount = 0; + let newCount = 0 saveGlobalConfig(prev => { - newCount = (prev.overageCreditUpsellSeenCount ?? 0) + 1; + newCount = (prev.overageCreditUpsellSeenCount ?? 0) + 1 return { ...prev, - overageCreditUpsellSeenCount: newCount - }; - }); - logEvent('tengu_overage_credit_upsell_shown', { - seen_count: newCount - }); + overageCreditUpsellSeenCount: newCount, + } + }) + logEvent('tengu_overage_credit_upsell_shown', { seen_count: newCount }) } // Copy from "OC & Bulk Overages copy" doc (#6 — CLI /usage) function getUsageText(amount: string): string { - return `${amount} in extra usage for third-party apps · /extra-usage`; + return `${amount} in extra usage for third-party apps · /extra-usage` } // Copy from "OC & Bulk Overages copy" doc (#4 — CLI Welcome screen). // Char budgets: title ≤19, subtitle ≤48. -const FEED_SUBTITLE = 'On us. Works on third-party apps · /extra-usage'; +const FEED_SUBTITLE = 'On us. Works on third-party apps · /extra-usage' + function getFeedTitle(amount: string): string { - return `${amount} in extra usage`; + return `${amount} in extra usage` } -type Props = { - maxWidth?: number; - twoLine?: boolean; -}; -export function OverageCreditUpsell(t0) { - const $ = _c(8); - const { - maxWidth, - twoLine - } = t0; - let t1; - let t2; - if ($[0] !== maxWidth || $[1] !== twoLine) { - t2 = Symbol.for("react.early_return_sentinel"); - bb0: { - const info = getCachedOverageCreditGrant(); - if (!info) { - t2 = null; - break bb0; - } - const amount = formatGrantAmount(info); - if (!amount) { - t2 = null; - break bb0; - } - if (twoLine) { - const title = getFeedTitle(amount); - let t3; - if ($[4] !== maxWidth) { - t3 = maxWidth ? truncate(FEED_SUBTITLE, maxWidth) : FEED_SUBTITLE; - $[4] = maxWidth; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== t3) { - t4 = {t3}; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - t2 = <>{maxWidth ? truncate(title, maxWidth) : title}{t4}; - break bb0; - } - const text = getUsageText(amount); - const display = maxWidth ? truncate(text, maxWidth) : text; - const highlightLen = Math.min(getFeedTitle(amount).length, display.length); - t1 = {display.slice(0, highlightLen)}{display.slice(highlightLen)}; - } - $[0] = maxWidth; - $[1] = twoLine; - $[2] = t1; - $[3] = t2; - } else { - t1 = $[2]; - t2 = $[3]; + +type Props = { maxWidth?: number; twoLine?: boolean } + +export function OverageCreditUpsell({ + maxWidth, + twoLine, +}: Props): React.ReactNode { + const info = getCachedOverageCreditGrant() + if (!info) return null + const amount = formatGrantAmount(info) + if (!amount) return null + + if (twoLine) { + const title = getFeedTitle(amount) + return ( + <> + + {maxWidth ? truncate(title, maxWidth) : title} + + + {maxWidth ? truncate(FEED_SUBTITLE, maxWidth) : FEED_SUBTITLE} + + + ) } - if (t2 !== Symbol.for("react.early_return_sentinel")) { - return t2; - } - return t1; + + const text = getUsageText(amount) + const display = maxWidth ? truncate(text, maxWidth) : text + const highlightLen = Math.min(getFeedTitle(amount).length, display.length) + + return ( + + {display.slice(0, highlightLen)} + {display.slice(highlightLen)} + + ) } /** @@ -151,15 +132,15 @@ export function OverageCreditUpsell(t0) { * Char budgets: title ≤19, subtitle ≤48. */ export function createOverageCreditFeed(): FeedConfig { - const info = getCachedOverageCreditGrant(); - const amount = info ? formatGrantAmount(info) : null; - const title = amount ? getFeedTitle(amount) : 'extra usage credit'; + const info = getCachedOverageCreditGrant() + const amount = info ? formatGrantAmount(info) : null + const title = amount ? getFeedTitle(amount) : 'extra usage credit' return { title, lines: [], customContent: { content: {FEED_SUBTITLE}, - width: Math.max(title.length, FEED_SUBTITLE.length) - } - }; + width: Math.max(title.length, FEED_SUBTITLE.length), + }, + } } diff --git a/src/components/LogoV2/VoiceModeNotice.tsx b/src/components/LogoV2/VoiceModeNotice.tsx index 5028ac75f..531460533 100644 --- a/src/components/LogoV2/VoiceModeNotice.tsx +++ b/src/components/LogoV2/VoiceModeNotice.tsx @@ -1,67 +1,51 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { Box, Text } from '../../ink.js'; -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -import { getInitialSettings } from '../../utils/settings/settings.js'; -import { isVoiceModeEnabled } from '../../voice/voiceModeEnabled.js'; -import { AnimatedAsterisk } from './AnimatedAsterisk.js'; -import { shouldShowOpus1mMergeNotice } from './Opus1mMergeNotice.js'; -const MAX_SHOW_COUNT = 3; -export function VoiceModeNotice() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = feature("VOICE_MODE") ? : null; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { useEffect, useState } from 'react' +import { Box, Text } from '../../ink.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { getInitialSettings } from '../../utils/settings/settings.js' +import { isVoiceModeEnabled } from '../../voice/voiceModeEnabled.js' +import { AnimatedAsterisk } from './AnimatedAsterisk.js' +import { shouldShowOpus1mMergeNotice } from './Opus1mMergeNotice.js' + +const MAX_SHOW_COUNT = 3 + +export function VoiceModeNotice(): React.ReactNode { + // Positive ternary pattern — see docs/feature-gating.md. + // All strings must be inside the guarded branch for dead-code elimination. + return feature('VOICE_MODE') ? : null } -function VoiceModeNoticeInner() { - const $ = _c(4); - const [show] = useState(_temp); - let t0; - let t1; - if ($[0] !== show) { - t0 = () => { - if (!show) { - return; - } - const newCount = (getGlobalConfig().voiceNoticeSeenCount ?? 0) + 1; - saveGlobalConfig(prev => { - if ((prev.voiceNoticeSeenCount ?? 0) >= newCount) { - return prev; - } - return { - ...prev, - voiceNoticeSeenCount: newCount - }; - }); - }; - t1 = [show]; - $[0] = show; - $[1] = t0; - $[2] = t1; - } else { - t0 = $[1]; - t1 = $[2]; - } - useEffect(t0, t1); - if (!show) { - return null; - } - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Voice mode is now available · /voice to enable; - $[3] = t2; - } else { - t2 = $[3]; - } - return t2; -} -function _temp() { - return isVoiceModeEnabled() && getInitialSettings().voiceEnabled !== true && (getGlobalConfig().voiceNoticeSeenCount ?? 0) < MAX_SHOW_COUNT && !shouldShowOpus1mMergeNotice(); + +function VoiceModeNoticeInner(): React.ReactNode { + // Capture eligibility once at mount — no reactive subscriptions. This sits + // at the top of the message list and enters scrollback quickly; any + // re-render after it's in scrollback would force a full terminal reset. + // If the user runs /voice this session, the notice stays visible; it won't + // show next session since voiceEnabled will be true on disk. + const [show] = useState( + () => + isVoiceModeEnabled() && + getInitialSettings().voiceEnabled !== true && + (getGlobalConfig().voiceNoticeSeenCount ?? 0) < MAX_SHOW_COUNT && + !shouldShowOpus1mMergeNotice(), + ) + + useEffect(() => { + if (!show) return + // Capture outside the updater so StrictMode's second invocation is a no-op. + const newCount = (getGlobalConfig().voiceNoticeSeenCount ?? 0) + 1 + saveGlobalConfig(prev => { + if ((prev.voiceNoticeSeenCount ?? 0) >= newCount) return prev + return { ...prev, voiceNoticeSeenCount: newCount } + }) + }, [show]) + + if (!show) return null + + return ( + + + Voice mode is now available · /voice to enable + + ) } diff --git a/src/components/LogoV2/WelcomeV2.tsx b/src/components/LogoV2/WelcomeV2.tsx index 0094ef170..354e1182b 100644 --- a/src/components/LogoV2/WelcomeV2.tsx +++ b/src/components/LogoV2/WelcomeV2.tsx @@ -1,432 +1,326 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text, useTheme } from 'src/ink.js'; -import { env } from '../../utils/env.js'; -const WELCOME_V2_WIDTH = 58; -export function WelcomeV2() { - const $ = _c(35); - const [theme] = useTheme(); - if (env.terminal === "Apple_Terminal") { - let t0; - if ($[0] !== theme) { - t0 = ; - $[0] = theme; - $[1] = t0; - } else { - t0 = $[1]; - } - return t0; +import React from 'react' +import { Box, Text, useTheme } from 'src/ink.js' +import { env } from '../../utils/env.js' + +const WELCOME_V2_WIDTH = 58 + +export function WelcomeV2(): React.ReactNode { + const [theme] = useTheme() + const welcomeMessage = 'Welcome to Claude Code' + + if (env.terminal === 'Apple_Terminal') { + return ( + + ) } - if (["light", "light-daltonized", "light-ansi"].includes(theme)) { - let t0; - let t1; - let t2; - let t3; - let t4; - let t5; - let t6; - let t7; - let t8; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t0 = {"Welcome to Claude Code"} v{MACRO.VERSION} ; - t1 = {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}; - t2 = {" "}; - t3 = {" "}; - t4 = {" "}; - t5 = {" \u2591\u2591\u2591\u2591\u2591\u2591 "}; - t6 = {" \u2591\u2591\u2591 \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}; - t7 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}; - t8 = {" "}; - $[2] = t0; - $[3] = t1; - $[4] = t2; - $[5] = t3; - $[6] = t4; - $[7] = t5; - $[8] = t6; - $[9] = t7; - $[10] = t8; - } else { - t0 = $[2]; - t1 = $[3]; - t2 = $[4]; - t3 = $[5]; - t4 = $[6]; - t5 = $[7]; - t6 = $[8]; - t7 = $[9]; - t8 = $[10]; - } - let t9; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t9 = {" \u2591\u2591\u2591\u2591"}{" \u2588\u2588 "}; - $[11] = t9; - } else { - t9 = $[11]; - } - let t10; - let t11; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t10 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591"}{" \u2588\u2588\u2592\u2592\u2588\u2588 "}; - t11 = {" \u2592\u2592 \u2588\u2588 \u2592"}; - $[12] = t10; - $[13] = t11; - } else { - t10 = $[12]; - t11 = $[13]; - } - let t12; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t12 = {" "} █████████ {" \u2592\u2592\u2591\u2591\u2592\u2592 \u2592 \u2592\u2592"}; - $[14] = t12; - } else { - t12 = $[14]; - } - let t13; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t13 = {" "}██▄█████▄██{" \u2592\u2592 \u2592\u2592 "}; - $[15] = t13; - } else { - t13 = $[15]; - } - let t14; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t14 = {" "} █████████ {" \u2591 \u2592 "}; - $[16] = t14; - } else { - t14 = $[16]; - } - let t15; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t15 = {t0}{t1}{t2}{t3}{t4}{t5}{t6}{t7}{t8}{t9}{t10}{t11}{t12}{t13}{t14}{"\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}{"\u2588 \u2588 \u2588 \u2588"}{"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2591\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2592\u2026\u2026\u2026\u2026"}; - $[17] = t15; - } else { - t15 = $[17]; - } - return t15; + + if (['light', 'light-daltonized', 'light-ansi'].includes(theme)) { + return ( + + + + {welcomeMessage} + v{MACRO.VERSION} + + + {'…………………………………………………………………………………………………………………………………………………………'} + + + {' '} + + + {' '} + + + {' '} + + + {' ░░░░░░ '} + + + {' ░░░ ░░░░░░░░░░ '} + + + {' ░░░░░░░░░░░░░░░░░░░ '} + + + {' '} + + + {' ░░░░'} + {' ██ '} + + + {' ░░░░░░░░░░'} + {' ██▒▒██ '} + + + {' ▒▒ ██ ▒'} + + + {' '} + █████████ + {' ▒▒░░▒▒ ▒ ▒▒'} + + + {' '} + + ██▄█████▄██ + + {' ▒▒ ▒▒ '} + + + {' '} + █████████ + {' ░ ▒ '} + + + {'…………………'} + {'█ █ █ █'} + {'……………………………………………………………………░…………………………▒…………'} + + + + ) } - let t0; - let t1; - let t2; - let t3; - let t4; - let t5; - let t6; - if ($[18] === Symbol.for("react.memo_cache_sentinel")) { - t0 = {"Welcome to Claude Code"} v{MACRO.VERSION} ; - t1 = {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}; - t2 = {" "}; - t3 = {" * \u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2591 "}; - t4 = {" * \u2588\u2588\u2588\u2593\u2591 \u2591\u2591 "}; - t5 = {" \u2591\u2591\u2591\u2591\u2591\u2591 \u2588\u2588\u2588\u2593\u2591 "}; - t6 = {" \u2591\u2591\u2591 \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 \u2588\u2588\u2588\u2593\u2591 "}; - $[18] = t0; - $[19] = t1; - $[20] = t2; - $[21] = t3; - $[22] = t4; - $[23] = t5; - $[24] = t6; - } else { - t0 = $[18]; - t1 = $[19]; - t2 = $[20]; - t3 = $[21]; - t4 = $[22]; - t5 = $[23]; - t6 = $[24]; - } - let t10; - let t11; - let t7; - let t8; - let t9; - if ($[25] === Symbol.for("react.memo_cache_sentinel")) { - t7 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}*{" \u2588\u2588\u2593\u2591\u2591 \u2593 "}; - t8 = {" \u2591\u2593\u2593\u2588\u2588\u2588\u2593\u2593\u2591 "}; - t9 = {" * \u2591\u2591\u2591\u2591 "}; - t10 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}; - t11 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}; - $[25] = t10; - $[26] = t11; - $[27] = t7; - $[28] = t8; - $[29] = t9; - } else { - t10 = $[25]; - t11 = $[26]; - t7 = $[27]; - t8 = $[28]; - t9 = $[29]; - } - let t12; - if ($[30] === Symbol.for("react.memo_cache_sentinel")) { - t12 = █████████ ; - $[30] = t12; - } else { - t12 = $[30]; - } - let t13; - if ($[31] === Symbol.for("react.memo_cache_sentinel")) { - t13 = {" "}{t12}{" "}* ; - $[31] = t13; - } else { - t13 = $[31]; - } - let t14; - if ($[32] === Symbol.for("react.memo_cache_sentinel")) { - t14 = {" "}██▄█████▄██{" "}*{" "}; - $[32] = t14; - } else { - t14 = $[32]; - } - let t15; - if ($[33] === Symbol.for("react.memo_cache_sentinel")) { - t15 = {" "} █████████ {" * "}; - $[33] = t15; - } else { - t15 = $[33]; - } - let t16; - if ($[34] === Symbol.for("react.memo_cache_sentinel")) { - t16 = {t0}{t1}{t2}{t3}{t4}{t5}{t6}{t7}{t8}{t9}{t10}{t11}{t13}{t14}{t15}{"\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}{"\u2588 \u2588 \u2588 \u2588"}{"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}; - $[34] = t16; - } else { - t16 = $[34]; - } - return t16; + + return ( + + + + {welcomeMessage} + v{MACRO.VERSION} + + + {'…………………………………………………………………………………………………………………………………………………………'} + + + {' '} + + + {' * █████▓▓░ '} + + + {' * ███▓░ ░░ '} + + + {' ░░░░░░ ███▓░ '} + + + {' ░░░ ░░░░░░░░░░ ███▓░ '} + + + {' ░░░░░░░░░░░░░░░░░░░ '} + * + {' ██▓░░ ▓ '} + + + {' ░▓▓███▓▓░ '} + + + {' * ░░░░ '} + + + {' ░░░░░░░░ '} + + + {' ░░░░░░░░░░░░░░░░ '} + + + {' '} + █████████ + {' '} + * + + + + {' '} + ██▄█████▄██ + {' '} + * + {' '} + + + {' '} + █████████ + {' * '} + + + {'…………………'} + {'█ █ █ █'} + {'………………………………………………………………………………………………………………'} + + + + ) } + type AppleTerminalWelcomeV2Props = { - theme: string; - welcomeMessage: string; -}; -function AppleTerminalWelcomeV2(t0) { - const $ = _c(44); - const { - theme, - welcomeMessage - } = t0; - const isLightTheme = ["light", "light-daltonized", "light-ansi"].includes(theme); - if (isLightTheme) { - let t1; - if ($[0] !== welcomeMessage) { - t1 = {welcomeMessage} ; - $[0] = welcomeMessage; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = v{MACRO.VERSION} ; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== t1) { - t3 = {t1}{t2}; - $[3] = t1; - $[4] = t3; - } else { - t3 = $[4]; - } - let t10; - let t11; - let t4; - let t5; - let t6; - let t7; - let t8; - let t9; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}; - t5 = {" "}; - t6 = {" "}; - t7 = {" "}; - t8 = {" \u2591\u2591\u2591\u2591\u2591\u2591 "}; - t9 = {" \u2591\u2591\u2591 \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}; - t10 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}; - t11 = {" "}; - $[5] = t10; - $[6] = t11; - $[7] = t4; - $[8] = t5; - $[9] = t6; - $[10] = t7; - $[11] = t8; - $[12] = t9; - } else { - t10 = $[5]; - t11 = $[6]; - t4 = $[7]; - t5 = $[8]; - t6 = $[9]; - t7 = $[10]; - t8 = $[11]; - t9 = $[12]; - } - let t12; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t12 = {" \u2591\u2591\u2591\u2591"}{" \u2588\u2588 "}; - $[13] = t12; - } else { - t12 = $[13]; - } - let t13; - let t14; - let t15; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t13 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591"}{" \u2588\u2588\u2592\u2592\u2588\u2588 "}; - t14 = {" \u2592\u2592 \u2588\u2588 \u2592"}; - t15 = {" \u2592\u2592\u2591\u2591\u2592\u2592 \u2592 \u2592\u2592"}; - $[14] = t13; - $[15] = t14; - $[16] = t15; - } else { - t13 = $[14]; - t14 = $[15]; - t15 = $[16]; - } - let t16; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t16 = {" "}{" "}▗{" "}▖{" "}{" \u2592\u2592 \u2592\u2592 "}; - $[17] = t16; - } else { - t16 = $[17]; - } - let t17; - if ($[18] === Symbol.for("react.memo_cache_sentinel")) { - t17 = {" "}{" ".repeat(9)}{" \u2591 \u2592 "}; - $[18] = t17; - } else { - t17 = $[18]; - } - let t18; - if ($[19] === Symbol.for("react.memo_cache_sentinel")) { - t18 = {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026"} {" "} {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2591\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2592\u2026\u2026\u2026\u2026"}; - $[19] = t18; - } else { - t18 = $[19]; - } - let t19; - if ($[20] !== t3) { - t19 = {t3}{t4}{t5}{t6}{t7}{t8}{t9}{t10}{t11}{t12}{t13}{t14}{t15}{t16}{t17}{t18}; - $[20] = t3; - $[21] = t19; - } else { - t19 = $[21]; - } - return t19; - } - let t1; - if ($[22] !== welcomeMessage) { - t1 = {welcomeMessage} ; - $[22] = welcomeMessage; - $[23] = t1; - } else { - t1 = $[23]; - } - let t2; - if ($[24] === Symbol.for("react.memo_cache_sentinel")) { - t2 = v{MACRO.VERSION} ; - $[24] = t2; - } else { - t2 = $[24]; - } - let t3; - if ($[25] !== t1) { - t3 = {t1}{t2}; - $[25] = t1; - $[26] = t3; - } else { - t3 = $[26]; - } - let t4; - let t5; - let t6; - let t7; - let t8; - let t9; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t4 = {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}; - t5 = {" "}; - t6 = {" * \u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2591 "}; - t7 = {" * \u2588\u2588\u2588\u2593\u2591 \u2591\u2591 "}; - t8 = {" \u2591\u2591\u2591\u2591\u2591\u2591 \u2588\u2588\u2588\u2593\u2591 "}; - t9 = {" \u2591\u2591\u2591 \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 \u2588\u2588\u2588\u2593\u2591 "}; - $[27] = t4; - $[28] = t5; - $[29] = t6; - $[30] = t7; - $[31] = t8; - $[32] = t9; - } else { - t4 = $[27]; - t5 = $[28]; - t6 = $[29]; - t7 = $[30]; - t8 = $[31]; - t9 = $[32]; - } - let t10; - let t11; - let t12; - let t13; - let t14; - if ($[33] === Symbol.for("react.memo_cache_sentinel")) { - t10 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}*{" \u2588\u2588\u2593\u2591\u2591 \u2593 "}; - t11 = {" \u2591\u2593\u2593\u2588\u2588\u2588\u2593\u2593\u2591 "}; - t12 = {" * \u2591\u2591\u2591\u2591 "}; - t13 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}; - t14 = {" \u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591 "}; - $[33] = t10; - $[34] = t11; - $[35] = t12; - $[36] = t13; - $[37] = t14; - } else { - t10 = $[33]; - t11 = $[34]; - t12 = $[35]; - t13 = $[36]; - t14 = $[37]; - } - let t15; - if ($[38] === Symbol.for("react.memo_cache_sentinel")) { - t15 = {" "}* ; - $[38] = t15; - } else { - t15 = $[38]; - } - let t16; - if ($[39] === Symbol.for("react.memo_cache_sentinel")) { - t16 = {" "}{" "}▗{" "}▖{" "}{" "}*{" "}; - $[39] = t16; - } else { - t16 = $[39]; - } - let t17; - if ($[40] === Symbol.for("react.memo_cache_sentinel")) { - t17 = {" "}{" ".repeat(9)}{" * "}; - $[40] = t17; - } else { - t17 = $[40]; - } - let t18; - if ($[41] === Symbol.for("react.memo_cache_sentinel")) { - t18 = {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026"} {" "} {"\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026\u2026"}; - $[41] = t18; - } else { - t18 = $[41]; - } - let t19; - if ($[42] !== t3) { - t19 = {t3}{t4}{t5}{t6}{t7}{t8}{t9}{t10}{t11}{t12}{t13}{t14}{t15}{t16}{t17}{t18}; - $[42] = t3; - $[43] = t19; - } else { - t19 = $[43]; - } - return t19; + theme: string + welcomeMessage: string +} + +function AppleTerminalWelcomeV2({ + theme, + welcomeMessage, +}: AppleTerminalWelcomeV2Props): React.ReactNode { + const isLightTheme = ['light', 'light-daltonized', 'light-ansi'].includes( + theme, + ) + + if (isLightTheme) { + return ( + + + + {welcomeMessage} + v{MACRO.VERSION} + + + {'…………………………………………………………………………………………………………………………………………………………'} + + + {' '} + + + {' '} + + + {' '} + + + {' ░░░░░░ '} + + + {' ░░░ ░░░░░░░░░░ '} + + + {' ░░░░░░░░░░░░░░░░░░░ '} + + + {' '} + + + {' ░░░░'} + {' ██ '} + + + {' ░░░░░░░░░░'} + {' ██▒▒██ '} + + + {' ▒▒ ██ ▒'} + + + {' ▒▒░░▒▒ ▒ ▒▒'} + + + {' '} + + + {' '} + ▗{' '}▖{' '} + + + {' ▒▒ ▒▒ '} + + + {' '} + {' '.repeat(9)} + {' ░ ▒ '} + + + {'…………………'} + + + + {' '} + + + + {'……………………………………………………………………░…………………………▒…………'} + + + + ) + } + + return ( + + + + {welcomeMessage} + v{MACRO.VERSION} + + + {'…………………………………………………………………………………………………………………………………………………………'} + + + {' '} + + + {' * █████▓▓░ '} + + + {' * ███▓░ ░░ '} + + + {' ░░░░░░ ███▓░ '} + + + {' ░░░ ░░░░░░░░░░ ███▓░ '} + + + {' ░░░░░░░░░░░░░░░░░░░ '} + * + {' ██▓░░ ▓ '} + + + {' ░▓▓███▓▓░ '} + + + {' * ░░░░ '} + + + {' ░░░░░░░░ '} + + + {' ░░░░░░░░░░░░░░░░ '} + + + {' '} + * + + + + {' '} + + + {' '} + ▗{' '}▖{' '} + + + {' '} + * + {' '} + + + {' '} + {' '.repeat(9)} + {' * '} + + + {'…………………'} + + + + {' '} + + + + {'………………………………………………………………………………………………………………'} + + + + ) } diff --git a/src/components/LogoV2/feedConfigs.tsx b/src/components/LogoV2/feedConfigs.tsx index cf8841967..50ec4575c 100644 --- a/src/components/LogoV2/feedConfigs.tsx +++ b/src/components/LogoV2/feedConfigs.tsx @@ -1,91 +1,117 @@ -import figures from 'figures'; -import { homedir } from 'os'; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import type { Step } from '../../projectOnboardingState.js'; -import { formatCreditAmount, getCachedReferrerReward } from '../../services/api/referral.js'; -import type { LogOption } from '../../types/logs.js'; -import { getCwd } from '../../utils/cwd.js'; -import { formatRelativeTimeAgo } from '../../utils/format.js'; -import type { FeedConfig, FeedLine } from './Feed.js'; +import figures from 'figures' +import { homedir } from 'os' +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import type { Step } from '../../projectOnboardingState.js' +import { + formatCreditAmount, + getCachedReferrerReward, +} from '../../services/api/referral.js' +import type { LogOption } from '../../types/logs.js' +import { getCwd } from '../../utils/cwd.js' +import { formatRelativeTimeAgo } from '../../utils/format.js' +import type { FeedConfig, FeedLine } from './Feed.js' + export function createRecentActivityFeed(activities: LogOption[]): FeedConfig { const lines: FeedLine[] = activities.map(log => { - const time = formatRelativeTimeAgo(log.modified); - const description = log.summary && log.summary !== 'No prompt' ? log.summary : log.firstPrompt; + const time = formatRelativeTimeAgo(log.modified) + const description = + log.summary && log.summary !== 'No prompt' ? log.summary : log.firstPrompt + return { text: description || '', - timestamp: time - }; - }); + timestamp: time, + } + }) + return { title: 'Recent activity', lines, footer: lines.length > 0 ? '/resume for more' : undefined, - emptyMessage: 'No recent activity' - }; + emptyMessage: 'No recent activity', + } } + export function createWhatsNewFeed(releaseNotes: string[]): FeedConfig { const lines: FeedLine[] = releaseNotes.map(note => { - if ((process.env.USER_TYPE) === 'ant') { - const match = note.match(/^(\d+\s+\w+\s+ago)\s+(.+)$/); + if (process.env.USER_TYPE === 'ant') { + const match = note.match(/^(\d+\s+\w+\s+ago)\s+(.+)$/) if (match) { return { timestamp: match[1], - text: match[2] || '' - }; + text: match[2] || '', + } } } return { - text: note - }; - }); - const emptyMessage = (process.env.USER_TYPE) === 'ant' ? 'Unable to fetch latest claude-cli-internal commits' : 'Check the Claude Code changelog for updates'; + text: note, + } + }) + + const emptyMessage = + process.env.USER_TYPE === 'ant' + ? 'Unable to fetch latest claude-cli-internal commits' + : 'Check the Claude Code changelog for updates' + return { - title: (process.env.USER_TYPE) === 'ant' ? "What's new [ANT-ONLY: Latest CC commits]" : "What's new", + title: + process.env.USER_TYPE === 'ant' + ? "What's new [ANT-ONLY: Latest CC commits]" + : "What's new", lines, footer: lines.length > 0 ? '/release-notes for more' : undefined, - emptyMessage - }; + emptyMessage, + } } + export function createProjectOnboardingFeed(steps: Step[]): FeedConfig { - const enabledSteps = steps.filter(({ - isEnabled - }) => isEnabled).sort((a, b) => Number(a.isComplete) - Number(b.isComplete)); - const lines: FeedLine[] = enabledSteps.map(({ - text, - isComplete - }) => { - const checkmark = isComplete ? `${figures.tick} ` : ''; + const enabledSteps = steps + .filter(({ isEnabled }) => isEnabled) + .sort((a, b) => Number(a.isComplete) - Number(b.isComplete)) + + const lines: FeedLine[] = enabledSteps.map(({ text, isComplete }) => { + const checkmark = isComplete ? `${figures.tick} ` : '' return { - text: `${checkmark}${text}` - }; - }); - const warningText = getCwd() === homedir() ? 'Note: You have launched claude in your home directory. For the best experience, launch it in a project directory instead.' : undefined; + text: `${checkmark}${text}`, + } + }) + + const warningText = + getCwd() === homedir() + ? 'Note: You have launched claude in your home directory. For the best experience, launch it in a project directory instead.' + : undefined + if (warningText) { lines.push({ - text: warningText - }); + text: warningText, + }) } + return { title: 'Tips for getting started', - lines - }; + lines, + } } + export function createGuestPassesFeed(): FeedConfig { - const reward = getCachedReferrerReward(); - const subtitle = reward ? `Share Claude Code and earn ${formatCreditAmount(reward)} of extra usage` : 'Share Claude Code with friends'; + const reward = getCachedReferrerReward() + const subtitle = reward + ? `Share Claude Code and earn ${formatCreditAmount(reward)} of extra usage` + : 'Share Claude Code with friends' return { title: '3 guest passes', lines: [], customContent: { - content: <> + content: ( + <> [✻] [✻] [✻] {subtitle} - , - width: 48 + + ), + width: 48, }, - footer: '/passes' - }; + footer: '/passes', + } } diff --git a/src/components/LspRecommendation/LspRecommendationMenu.tsx b/src/components/LspRecommendation/LspRecommendationMenu.tsx index 538c94375..7dc41ac39 100644 --- a/src/components/LspRecommendation/LspRecommendationMenu.tsx +++ b/src/components/LspRecommendation/LspRecommendationMenu.tsx @@ -1,63 +1,83 @@ -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { Select } from '../CustomSelect/select.js'; -import { PermissionDialog } from '../permissions/PermissionDialog.js'; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { Select } from '../CustomSelect/select.js' +import { PermissionDialog } from '../permissions/PermissionDialog.js' + type Props = { - pluginName: string; - pluginDescription?: string; - fileExtension: string; - onResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void; -}; -const AUTO_DISMISS_MS = 30_000; + pluginName: string + pluginDescription?: string + fileExtension: string + onResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void +} + +const AUTO_DISMISS_MS = 30_000 + export function LspRecommendationMenu({ pluginName, pluginDescription, fileExtension, - onResponse + onResponse, }: Props): React.ReactNode { // Use ref to avoid timer reset when onResponse changes - const onResponseRef = React.useRef(onResponse); - onResponseRef.current = onResponse; + const onResponseRef = React.useRef(onResponse) + onResponseRef.current = onResponse // 30-second auto-dismiss timer - counts as ignored (no) React.useEffect(() => { - const timeoutId = setTimeout(ref => ref.current('no'), AUTO_DISMISS_MS, onResponseRef); - return () => clearTimeout(timeoutId); - }, []); + const timeoutId = setTimeout( + ref => ref.current('no'), + AUTO_DISMISS_MS, + onResponseRef, + ) + return () => clearTimeout(timeoutId) + }, []) + function onSelect(value: string): void { switch (value) { case 'yes': - onResponse('yes'); - break; + onResponse('yes') + break case 'no': - onResponse('no'); - break; + onResponse('no') + break case 'never': - onResponse('never'); - break; + onResponse('never') + break case 'disable': - onResponse('disable'); - break; + onResponse('disable') + break } } - const options = [{ - label: + + const options = [ + { + label: ( + Yes, install {pluginName} - , - value: 'yes' - }, { - label: 'No, not now', - value: 'no' - }, { - label: + + ), + value: 'yes', + }, + { + label: 'No, not now', + value: 'no', + }, + { + label: ( + Never for {pluginName} - , - value: 'never' - }, { - label: 'Disable all LSP recommendations', - value: 'disable' - }]; - return + + ), + value: 'never', + }, + { + label: 'Disable all LSP recommendations', + value: 'disable', + }, + ] + + return ( + @@ -69,9 +89,11 @@ export function LspRecommendationMenu({ Plugin: {pluginName} - {pluginDescription && + {pluginDescription && ( + {pluginDescription} - } + + )} Triggered by: {fileExtension} files @@ -80,8 +102,13 @@ export function LspRecommendationMenu({ Would you like to install this LSP plugin? - onResponse('no')} + /> - ; + + ) } diff --git a/src/components/MCPServerApprovalDialog.tsx b/src/components/MCPServerApprovalDialog.tsx index af0c7d0d3..5d5e00898 100644 --- a/src/components/MCPServerApprovalDialog.tsx +++ b/src/components/MCPServerApprovalDialog.tsx @@ -1,114 +1,90 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'; -import { Select } from './CustomSelect/index.js'; -import { Dialog } from './design-system/Dialog.js'; -import { MCPServerDialogCopy } from './MCPServerDialogCopy.js'; +import React from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { + getSettings_DEPRECATED, + updateSettingsForSource, +} from '../utils/settings/settings.js' +import { Select } from './CustomSelect/index.js' +import { Dialog } from './design-system/Dialog.js' +import { MCPServerDialogCopy } from './MCPServerDialogCopy.js' + type Props = { - serverName: string; - onDone(): void; -}; -export function MCPServerApprovalDialog(t0) { - const $ = _c(13); - const { - serverName, - onDone - } = t0; - let t1; - if ($[0] !== onDone || $[1] !== serverName) { - t1 = function onChange(value) { - logEvent("tengu_mcp_dialog_choice", { - choice: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - bb2: switch (value) { - case "yes": - case "yes_all": - { - const currentSettings_0 = getSettings_DEPRECATED() || {}; - const enabledServers = currentSettings_0.enabledMcpjsonServers || []; - if (!enabledServers.includes(serverName)) { - updateSettingsForSource("localSettings", { - enabledMcpjsonServers: [...enabledServers, serverName] - }); - } - if (value === "yes_all") { - updateSettingsForSource("localSettings", { - enableAllProjectMcpServers: true - }); - } - onDone(); - break bb2; - } - case "no": - { - const currentSettings = getSettings_DEPRECATED() || {}; - const disabledServers = currentSettings.disabledMcpjsonServers || []; - if (!disabledServers.includes(serverName)) { - updateSettingsForSource("localSettings", { - disabledMcpjsonServers: [...disabledServers, serverName] - }); - } - onDone(); - } - } - }; - $[0] = onDone; - $[1] = serverName; - $[2] = t1; - } else { - t1 = $[2]; - } - const onChange = t1; - const t2 = `New MCP server found in .mcp.json: ${serverName}`; - let t3; - if ($[3] !== onChange) { - t3 = () => onChange("no"); - $[3] = onChange; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t5 = [{ - label: "Use this and all future MCP servers in this project", - value: "yes_all" - }, { - label: "Use this MCP server", - value: "yes" - }, { - label: "Continue without using this MCP server", - value: "no" - }]; - $[6] = t5; - } else { - t5 = $[6]; - } - let t6; - if ($[7] !== onChange) { - t6 = onChange(value as 'yes_all' | 'yes' | 'no')} + onCancel={() => onChange('no')} + /> + + ) } diff --git a/src/components/MCPServerDesktopImportDialog.tsx b/src/components/MCPServerDesktopImportDialog.tsx index 779c278a5..50b9ef6d6 100644 --- a/src/components/MCPServerDesktopImportDialog.tsx +++ b/src/components/MCPServerDesktopImportDialog.tsx @@ -1,202 +1,134 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useEffect, useState } from 'react'; -import { gracefulShutdown } from 'src/utils/gracefulShutdown.js'; -import { writeToStdout } from 'src/utils/process.js'; -import { Box, color, Text, useTheme } from '../ink.js'; -import { addMcpConfig, getAllMcpConfigs } from '../services/mcp/config.js'; -import type { ConfigScope, McpServerConfig, ScopedMcpServerConfig } from '../services/mcp/types.js'; -import { plural } from '../utils/stringUtils.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { SelectMulti } from './CustomSelect/SelectMulti.js'; -import { Byline } from './design-system/Byline.js'; -import { Dialog } from './design-system/Dialog.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; +import React, { useCallback, useEffect, useState } from 'react' +import { gracefulShutdown } from 'src/utils/gracefulShutdown.js' +import { writeToStdout } from 'src/utils/process.js' +import { Box, color, Text, useTheme } from '../ink.js' +import { addMcpConfig, getAllMcpConfigs } from '../services/mcp/config.js' +import type { + ConfigScope, + McpServerConfig, + ScopedMcpServerConfig, +} from '../services/mcp/types.js' +import { plural } from '../utils/stringUtils.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { SelectMulti } from './CustomSelect/SelectMulti.js' +import { Byline } from './design-system/Byline.js' +import { Dialog } from './design-system/Dialog.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' + type Props = { - servers: Record; - scope: ConfigScope; - onDone(): void; -}; -export function MCPServerDesktopImportDialog(t0) { - const $ = _c(36); - const { - servers, - scope, - onDone - } = t0; - let t1; - if ($[0] !== servers) { - t1 = Object.keys(servers); - $[0] = servers; - $[1] = t1; - } else { - t1 = $[1]; - } - const serverNames = t1; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {}; - $[2] = t2; - } else { - t2 = $[2]; - } - const [existingServers, setExistingServers] = useState(t2); - let t3; - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = () => { - getAllMcpConfigs().then(t5 => { - const { - servers: servers_0 - } = t5; - return setExistingServers(servers_0); - }); - }; - t4 = []; - $[3] = t3; - $[4] = t4; - } else { - t3 = $[3]; - t4 = $[4]; - } - useEffect(t3, t4); - let t5; - if ($[5] !== existingServers || $[6] !== serverNames) { - t5 = serverNames.filter(name => existingServers[name] !== undefined); - $[5] = existingServers; - $[6] = serverNames; - $[7] = t5; - } else { - t5 = $[7]; - } - const collisions = t5; - const onSubmit = async function onSubmit(selectedServers) { - let importedCount = 0; + servers: Record + scope: ConfigScope + onDone(): void +} + +export function MCPServerDesktopImportDialog({ + servers, + scope, + onDone, +}: Props): React.ReactNode { + const serverNames = Object.keys(servers) + const [existingServers, setExistingServers] = useState< + Record + >({}) + + useEffect(() => { + void getAllMcpConfigs().then(({ servers }) => setExistingServers(servers)) + }, []) + + const collisions = serverNames.filter( + name => existingServers[name] !== undefined, + ) + + async function onSubmit(selectedServers: string[]) { + let importedCount = 0 + for (const serverName of selectedServers) { - const serverConfig = servers[serverName]; + const serverConfig = servers[serverName] if (serverConfig) { - let finalName = serverName; + // If the server name already exists, find a new name with _1, _2, etc. + let finalName = serverName if (existingServers[finalName] !== undefined) { - let counter = 1; + let counter = 1 while (existingServers[`${serverName}_${counter}`] !== undefined) { - counter++; + counter++ } - finalName = `${serverName}_${counter}`; + finalName = `${serverName}_${counter}` } - await addMcpConfig(finalName, serverConfig, scope); - importedCount++; + + await addMcpConfig(finalName, serverConfig, scope) + importedCount++ } } - done(importedCount); - }; - const [theme] = useTheme(); - let t6; - if ($[8] !== onDone || $[9] !== scope || $[10] !== theme) { - t6 = importedCount_0 => { - if (importedCount_0 > 0) { - writeToStdout(`\n${color("success", theme)(`Successfully imported ${importedCount_0} MCP ${plural(importedCount_0, "server")} to ${scope} config.`)}\n`); + + done(importedCount) + } + + const [theme] = useTheme() + + // Define done before using in useCallback + const done = useCallback( + (importedCount: number) => { + if (importedCount > 0) { + writeToStdout( + `\n${color('success', theme)(`Successfully imported ${importedCount} MCP ${plural(importedCount, 'server')} to ${scope} config.`)}\n`, + ) } else { - writeToStdout("\nNo servers were imported."); + writeToStdout('\nNo servers were imported.') } - onDone(); - gracefulShutdown(); - }; - $[8] = onDone; - $[9] = scope; - $[10] = theme; - $[11] = t6; - } else { - t6 = $[11]; - } - const done = t6; - let t7; - if ($[12] !== done) { - t7 = () => { - done(0); - }; - $[12] = done; - $[13] = t7; - } else { - t7 = $[13]; - } - done; - const handleEscCancel = t7; - const t8 = serverNames.length; - let t9; - if ($[14] !== serverNames.length) { - t9 = plural(serverNames.length, "server"); - $[14] = serverNames.length; - $[15] = t9; - } else { - t9 = $[15]; - } - const t10 = `Found ${t8} MCP ${t9} in Claude Desktop.`; - let t11; - if ($[16] !== collisions.length) { - t11 = collisions.length > 0 && Note: Some servers already exist with the same name. If selected, they will be imported with a numbered suffix.; - $[16] = collisions.length; - $[17] = t11; - } else { - t11 = $[17]; - } - let t12; - if ($[18] === Symbol.for("react.memo_cache_sentinel")) { - t12 = Please select the servers you want to import:; - $[18] = t12; - } else { - t12 = $[18]; - } - let t13; - let t14; - if ($[19] !== collisions || $[20] !== serverNames) { - t13 = serverNames.map(server => ({ - label: `${server}${collisions.includes(server) ? " (already exists)" : ""}`, - value: server - })); - t14 = serverNames.filter(name_0 => !collisions.includes(name_0)); - $[19] = collisions; - $[20] = serverNames; - $[21] = t13; - $[22] = t14; - } else { - t13 = $[21]; - t14 = $[22]; - } - let t15; - if ($[23] !== handleEscCancel || $[24] !== onSubmit || $[25] !== t13 || $[26] !== t14) { - t15 = ; - $[23] = handleEscCancel; - $[24] = onSubmit; - $[25] = t13; - $[26] = t14; - $[27] = t15; - } else { - t15 = $[27]; - } - let t16; - if ($[28] !== handleEscCancel || $[29] !== t10 || $[30] !== t11 || $[31] !== t15) { - t16 = {t11}{t12}{t15}; - $[28] = handleEscCancel; - $[29] = t10; - $[30] = t11; - $[31] = t15; - $[32] = t16; - } else { - t16 = $[32]; - } - let t17; - if ($[33] === Symbol.for("react.memo_cache_sentinel")) { - t17 = ; - $[33] = t17; - } else { - t17 = $[33]; - } - let t18; - if ($[34] !== t16) { - t18 = <>{t16}{t17}; - $[34] = t16; - $[35] = t18; - } else { - t18 = $[35]; - } - return t18; + onDone() + + void gracefulShutdown() + }, + [theme, scope, onDone], + ) + + // Handle ESC to cancel (import 0 servers) + const handleEscCancel = useCallback(() => { + done(0) + }, [done]) + + return ( + <> + + {collisions.length > 0 && ( + + Note: Some servers already exist with the same name. If selected, + they will be imported with a numbered suffix. + + )} + Please select the servers you want to import: + + ({ + label: `${server}${collisions.includes(server) ? ' (already exists)' : ''}`, + value: server, + }))} + defaultValue={serverNames.filter(name => !collisions.includes(name))} // Only preselect non-colliding servers + onSubmit={onSubmit} + onCancel={handleEscCancel} + hideIndexes + /> + + + + + + + + + + + + ) } diff --git a/src/components/MCPServerDialogCopy.tsx b/src/components/MCPServerDialogCopy.tsx index 12a8ada2e..93dce3655 100644 --- a/src/components/MCPServerDialogCopy.tsx +++ b/src/components/MCPServerDialogCopy.tsx @@ -1,14 +1,12 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Link, Text } from '../ink.js'; -export function MCPServerDialogCopy() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = MCP servers may execute code or access system resources. All tool calls require approval. Learn more in the{" "}MCP documentation.; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +import React from 'react' +import { Link, Text } from '../ink.js' + +export function MCPServerDialogCopy(): React.ReactNode { + return ( + + MCP servers may execute code or access system resources. All tool calls + require approval. Learn more in the{' '} + MCP documentation. + + ) } diff --git a/src/components/MCPServerMultiselectDialog.tsx b/src/components/MCPServerMultiselectDialog.tsx index f4ba343e5..e14c46d46 100644 --- a/src/components/MCPServerMultiselectDialog.tsx +++ b/src/components/MCPServerMultiselectDialog.tsx @@ -1,132 +1,117 @@ -import { c as _c } from "react/compiler-runtime"; -import partition from 'lodash-es/partition.js'; -import React, { useCallback } from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; -import { Box, Text } from '../ink.js'; -import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { SelectMulti } from './CustomSelect/SelectMulti.js'; -import { Byline } from './design-system/Byline.js'; -import { Dialog } from './design-system/Dialog.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { MCPServerDialogCopy } from './MCPServerDialogCopy.js'; +import partition from 'lodash-es/partition.js' +import React, { useCallback } from 'react' +import { logEvent } from 'src/services/analytics/index.js' +import { Box, Text } from '../ink.js' +import { + getSettings_DEPRECATED, + updateSettingsForSource, +} from '../utils/settings/settings.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { SelectMulti } from './CustomSelect/SelectMulti.js' +import { Byline } from './design-system/Byline.js' +import { Dialog } from './design-system/Dialog.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { MCPServerDialogCopy } from './MCPServerDialogCopy.js' + type Props = { - serverNames: string[]; - onDone(): void; -}; -export function MCPServerMultiselectDialog(t0) { - const $ = _c(21); - const { - serverNames, - onDone - } = t0; - let t1; - if ($[0] !== onDone || $[1] !== serverNames) { - t1 = function onSubmit(selectedServers) { - const currentSettings = getSettings_DEPRECATED() || {}; - const enabledServers = currentSettings.enabledMcpjsonServers || []; - const disabledServers = currentSettings.disabledMcpjsonServers || []; - const [approvedServers, rejectedServers] = partition(serverNames, server => selectedServers.includes(server)); - logEvent("tengu_mcp_multidialog_choice", { - approved: approvedServers.length, - rejected: rejectedServers.length - }); - if (approvedServers.length > 0) { - const newEnabledServers = [...new Set([...enabledServers, ...approvedServers])]; - updateSettingsForSource("localSettings", { - enabledMcpjsonServers: newEnabledServers - }); - } - if (rejectedServers.length > 0) { - const newDisabledServers = [...new Set([...disabledServers, ...rejectedServers])]; - updateSettingsForSource("localSettings", { - disabledMcpjsonServers: newDisabledServers - }); - } - onDone(); - }; - $[0] = onDone; - $[1] = serverNames; - $[2] = t1; - } else { - t1 = $[2]; - } - const onSubmit = t1; - let t2; - if ($[3] !== onDone || $[4] !== serverNames) { - t2 = () => { - const currentSettings_0 = getSettings_DEPRECATED() || {}; - const disabledServers_0 = currentSettings_0.disabledMcpjsonServers || []; - const newDisabledServers_0 = [...new Set([...disabledServers_0, ...serverNames])]; - updateSettingsForSource("localSettings", { - disabledMcpjsonServers: newDisabledServers_0 - }); - onDone(); - }; - $[3] = onDone; - $[4] = serverNames; - $[5] = t2; - } else { - t2 = $[5]; - } - const handleEscRejectAll = t2; - const t3 = `${serverNames.length} new MCP servers found in .mcp.json`; - let t4; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== serverNames) { - t5 = serverNames.map(_temp); - $[7] = serverNames; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== handleEscRejectAll || $[10] !== onSubmit || $[11] !== serverNames || $[12] !== t5) { - t6 = ; - $[9] = handleEscRejectAll; - $[10] = onSubmit; - $[11] = serverNames; - $[12] = t5; - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - if ($[14] !== handleEscRejectAll || $[15] !== t3 || $[16] !== t6) { - t7 = {t4}{t6}; - $[14] = handleEscRejectAll; - $[15] = t3; - $[16] = t6; - $[17] = t7; - } else { - t7 = $[17]; - } - let t8; - if ($[18] === Symbol.for("react.memo_cache_sentinel")) { - t8 = ; - $[18] = t8; - } else { - t8 = $[18]; - } - let t9; - if ($[19] !== t7) { - t9 = <>{t7}{t8}; - $[19] = t7; - $[20] = t9; - } else { - t9 = $[20]; - } - return t9; + serverNames: string[] + onDone(): void } -function _temp(server_0) { - return { - label: server_0, - value: server_0 - }; + +export function MCPServerMultiselectDialog({ + serverNames, + onDone, +}: Props): React.ReactNode { + function onSubmit(selectedServers: string[]) { + const currentSettings = getSettings_DEPRECATED() || {} + const enabledServers = currentSettings.enabledMcpjsonServers || [] + const disabledServers = currentSettings.disabledMcpjsonServers || [] + + // Use partition to separate approved and rejected servers + const [approvedServers, rejectedServers] = partition(serverNames, server => + selectedServers.includes(server), + ) + + logEvent('tengu_mcp_multidialog_choice', { + approved: approvedServers.length, + rejected: rejectedServers.length, + }) + + // Update settings with approved servers + if (approvedServers.length > 0) { + const newEnabledServers = [ + ...new Set([...enabledServers, ...approvedServers]), + ] + updateSettingsForSource('localSettings', { + enabledMcpjsonServers: newEnabledServers, + }) + } + + // Update settings with rejected servers + if (rejectedServers.length > 0) { + const newDisabledServers = [ + ...new Set([...disabledServers, ...rejectedServers]), + ] + updateSettingsForSource('localSettings', { + disabledMcpjsonServers: newDisabledServers, + }) + } + + onDone() + } + + // Handle ESC to reject all servers + const handleEscRejectAll = useCallback(() => { + const currentSettings = getSettings_DEPRECATED() || {} + const disabledServers = currentSettings.disabledMcpjsonServers || [] + + const newDisabledServers = [ + ...new Set([...disabledServers, ...serverNames]), + ] + + updateSettingsForSource('localSettings', { + disabledMcpjsonServers: newDisabledServers, + }) + + onDone() + }, [serverNames, onDone]) + + return ( + <> + + + + ({ + label: server, + value: server, + }))} + defaultValue={serverNames} + onSubmit={onSubmit} + onCancel={handleEscRejectAll} + hideIndexes + /> + + + + + + + + + + + + ) } diff --git a/src/components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.tsx b/src/components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.tsx index 345e592a7..392979770 100644 --- a/src/components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.tsx +++ b/src/components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.tsx @@ -1,148 +1,88 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import type { SettingsJson } from '../../utils/settings/types.js'; -import { Select } from '../CustomSelect/index.js'; -import { PermissionDialog } from '../permissions/PermissionDialog.js'; -import { extractDangerousSettings, formatDangerousSettingsList } from './utils.js'; +import React from 'react' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import type { SettingsJson } from '../../utils/settings/types.js' +import { Select } from '../CustomSelect/index.js' +import { PermissionDialog } from '../permissions/PermissionDialog.js' +import { + extractDangerousSettings, + formatDangerousSettingsList, +} from './utils.js' + type Props = { - settings: SettingsJson; - onAccept: () => void; - onReject: () => void; -}; -export function ManagedSettingsSecurityDialog(t0) { - const $ = _c(26); - const { - settings, - onAccept, - onReject - } = t0; - const dangerous = extractDangerousSettings(settings); - const settingsList = formatDangerousSettingsList(dangerous); - const exitState = useExitOnCtrlCDWithKeybindings(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - context: "Confirmation" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - useKeybinding("confirm:no", onReject, t1); - let t2; - if ($[1] !== onAccept || $[2] !== onReject) { - t2 = function onChange(value) { - if (value === "exit") { - onReject(); - return; - } - onAccept(); - }; - $[1] = onAccept; - $[2] = onReject; - $[3] = t2; - } else { - t2 = $[3]; - } - const onChange = t2; - const T0 = PermissionDialog; - const t3 = "warning"; - const t4 = "warning"; - const t5 = "Managed settings require approval"; - const T1 = Box; - const t6 = "column"; - const t7 = 1; - const t8 = 1; - let t9; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t9 = Your organization has configured managed settings that could allow execution of arbitrary code or interception of your prompts and responses.; - $[4] = t9; - } else { - t9 = $[4]; - } - const T2 = Box; - const t10 = "column"; - let t11; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t11 = Settings requiring approval:; - $[5] = t11; - } else { - t11 = $[5]; - } - const t12 = settingsList.map(_temp); - let t13; - if ($[6] !== T2 || $[7] !== t11 || $[8] !== t12) { - t13 = {t11}{t12}; - $[6] = T2; - $[7] = t11; - $[8] = t12; - $[9] = t13; - } else { - t13 = $[9]; - } - let t14; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t14 = Only accept if you trust your organization's IT administration and expect these settings to be configured.; - $[10] = t14; - } else { - t14 = $[10]; - } - let t15; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t15 = [{ - label: "Yes, I trust these settings", - value: "accept" - }, { - label: "No, exit Claude Code", - value: "exit" - }]; - $[11] = t15; - } else { - t15 = $[11]; - } - let t16; - if ($[12] !== onChange) { - t16 = onChange(value as 'accept' | 'exit')} + onCancel={() => onChange('exit')} + /> + + + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + <>Enter to confirm · Esc to exit + )} + + + + ) } diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index dc089dffb..4616f46c2 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -1,26 +1,29 @@ -import { c as _c } from "react/compiler-runtime"; -import { marked, type Token, type Tokens } from 'marked'; -import React, { Suspense, use, useMemo, useRef } from 'react'; -import { useSettings } from '../hooks/useSettings.js'; -import { Ansi, Box, useTheme } from '../ink.js'; -import { type CliHighlight, getCliHighlightPromise } from '../utils/cliHighlight.js'; -import { hashContent } from '../utils/hash.js'; -import { configureMarked, formatToken } from '../utils/markdown.js'; -import { stripPromptXMLTags } from '../utils/messages.js'; -import { MarkdownTable } from './MarkdownTable.js'; +import { marked, type Token, type Tokens } from 'marked' +import React, { Suspense, use, useMemo, useRef } from 'react' +import { useSettings } from '../hooks/useSettings.js' +import { Ansi, Box, useTheme } from '../ink.js' +import { + type CliHighlight, + getCliHighlightPromise, +} from '../utils/cliHighlight.js' +import { hashContent } from '../utils/hash.js' +import { configureMarked, formatToken } from '../utils/markdown.js' +import { stripPromptXMLTags } from '../utils/messages.js' +import { MarkdownTable } from './MarkdownTable.js' + type Props = { - children: string; + children: string /** When true, render all text content as dim */ - dimColor?: boolean; -}; + dimColor?: boolean +} // Module-level token cache — marked.lexer is the hot cost on virtual-scroll // remounts (~3ms per message). useMemo doesn't survive unmount→remount, so // scrolling back to a previously-visible message re-parses. Messages are // immutable in history; same content → same tokens. Keyed by hash to avoid // retaining full content strings (turn50→turn99 RSS regression, #24180). -const TOKEN_CACHE_MAX = 500; -const tokenCache = new Map(); +const TOKEN_CACHE_MAX = 500 +const tokenCache = new Map() // Characters that indicate markdown syntax. If none are present, skip the // ~3ms marked.lexer call entirely — render as a single paragraph. Covers @@ -28,46 +31,45 @@ const tokenCache = new Map(); // plain sentences. Checked via indexOf (not regex) for speed. // Single regex: matches any MD marker or ordered-list start (N. at line start). // One pass instead of 10× includes scans. -const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. /; +const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. / function hasMarkdownSyntax(s: string): boolean { // Sample first 500 chars — if markdown exists it's usually early (headers, // code fence, list). Long tool outputs are mostly plain text tails. - return MD_SYNTAX_RE.test(s.length > 500 ? s.slice(0, 500) : s); + return MD_SYNTAX_RE.test(s.length > 500 ? s.slice(0, 500) : s) } + function cachedLexer(content: string): Token[] { // Fast path: plain text with no markdown syntax → single paragraph token. // Skips marked.lexer's full GFM parse (~3ms on long content). Not cached — // reconstruction is a single object allocation, and caching would retain // 4× content in raw/text fields plus the hash key for zero benefit. if (!hasMarkdownSyntax(content)) { - return [{ - type: 'paragraph', - raw: content, - text: content, - tokens: [{ - type: 'text', + return [ + { + type: 'paragraph', raw: content, - text: content - }] - } as Token]; + text: content, + tokens: [{ type: 'text', raw: content, text: content }], + } as Token, + ] } - const key = hashContent(content); - const hit = tokenCache.get(key); + const key = hashContent(content) + const hit = tokenCache.get(key) if (hit) { // Promote to MRU — without this the eviction is FIFO (scrolling back to // an early message evicts the very item you're looking at). - tokenCache.delete(key); - tokenCache.set(key, hit); - return hit; + tokenCache.delete(key) + tokenCache.set(key, hit) + return hit } - const tokens = marked.lexer(content); + const tokens = marked.lexer(content) if (tokenCache.size >= TOKEN_CACHE_MAX) { // LRU-ish: drop oldest. Map preserves insertion order. - const first = tokenCache.keys().next().value; - if (first !== undefined) tokenCache.delete(first); + const first = tokenCache.keys().next().value + if (first !== undefined) tokenCache.delete(first) } - tokenCache.set(key, tokens); - return tokens; + tokenCache.set(key, tokens) + return tokens } /** @@ -75,103 +77,78 @@ function cachedLexer(content: string): Token[] { * - Tables are rendered as React components with proper flexbox layout * - Other content is rendered as ANSI strings via formatToken */ -export function Markdown(props) { - const $ = _c(4); - const settings = useSettings(); +export function Markdown(props: Props): React.ReactNode { + const settings = useSettings() if (settings.syntaxHighlightingDisabled) { - let t0; - if ($[0] !== props) { - t0 = ; - $[0] = props; - $[1] = t0; - } else { - t0 = $[1]; - } - return t0; + return } - let t0; - if ($[2] !== props) { - t0 = }>; - $[2] = props; - $[3] = t0; - } else { - t0 = $[3]; - } - return t0; + // Suspense fallback renders with highlight=null — plain markdown shows + // for ~50ms on first ever render while cli-highlight loads. + return ( + }> + + + ) } -function MarkdownWithHighlight(props) { - const $ = _c(4); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = getCliHighlightPromise(); - $[0] = t0; - } else { - t0 = $[0]; - } - const highlight = use(t0); - let t1; - if ($[1] !== highlight || $[2] !== props) { - t1 = ; - $[1] = highlight; - $[2] = props; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; + +function MarkdownWithHighlight(props: Props): React.ReactNode { + const highlight = use(getCliHighlightPromise()) + return } -function MarkdownBody(t0) { - const $ = _c(7); - const { - children, - dimColor, - highlight - } = t0; - const [theme] = useTheme(); - configureMarked(); - let elements: React.ReactNode[]; - if ($[0] !== children || $[1] !== dimColor || $[2] !== highlight || $[3] !== theme) { - const tokens = cachedLexer(stripPromptXMLTags(children)); - elements = []; - let nonTableContent = ""; - const flushNonTableContent = function flushNonTableContent() { + +function MarkdownBody({ + children, + dimColor, + highlight, +}: Props & { highlight: CliHighlight | null }): React.ReactNode { + const [theme] = useTheme() + configureMarked() + + const elements = useMemo(() => { + const tokens = cachedLexer(stripPromptXMLTags(children)) + const elements: React.ReactNode[] = [] + let nonTableContent = '' + + function flushNonTableContent(): void { if (nonTableContent) { - elements.push({nonTableContent.trim()}); - nonTableContent = ""; - } - }; - for (const token of tokens) { - if (token.type === "table") { - flushNonTableContent(); - elements.push(); - } else { - nonTableContent = nonTableContent + formatToken(token, theme, 0, null, null, highlight); - nonTableContent; + elements.push( + + {nonTableContent.trim()} + , + ) + nonTableContent = '' } } - flushNonTableContent(); - $[0] = children; - $[1] = dimColor; - $[2] = highlight; - $[3] = theme; - $[4] = elements; - } else { - elements = $[4] as React.ReactNode[]; - } - const elements_0 = elements; - let t1; - if ($[5] !== elements_0) { - t1 = {elements_0}; - $[5] = elements_0; - $[6] = t1; - } else { - t1 = $[6]; - } - return t1; + + for (const token of tokens) { + if (token.type === 'table') { + flushNonTableContent() + elements.push( + , + ) + } else { + nonTableContent += formatToken(token, theme, 0, null, null, highlight) + } + } + + flushNonTableContent() + return elements + }, [children, dimColor, highlight, theme]) + + return ( + + {elements} + + ) } + type StreamingProps = { - children: string; -}; + children: string +} /** * Renders markdown during streaming by splitting at the last top-level block @@ -184,52 +161,55 @@ type StreamingProps = { * between turns (streamingText → null), resetting the ref. */ export function StreamingMarkdown({ - children + children, }: StreamingProps): React.ReactNode { // React Compiler: this component reads and writes stablePrefixRef.current // during render by design. The boundary only advances (monotonic), so // the ref mutation is idempotent under StrictMode double-render — but the // compiler can't prove that, and memoizing around the ref reads would // break the algorithm (stale boundary). Opt out. - 'use no memo'; - - configureMarked(); + 'use no memo' + configureMarked() // Strip before boundary tracking so it matches 's stripping // (line 29). When a closing tag arrives, stripped(N+1) is not a prefix // of stripped(N), but the startsWith reset below handles that with a // one-time re-lex on the smaller stripped string. - const stripped = stripPromptXMLTags(children); - const stablePrefixRef = useRef(''); + const stripped = stripPromptXMLTags(children) + + const stablePrefixRef = useRef('') // Reset if text was replaced (defensive; normally unmount handles this) if (!stripped.startsWith(stablePrefixRef.current)) { - stablePrefixRef.current = ''; + stablePrefixRef.current = '' } // Lex only from current boundary — O(unstable length), not O(full text) - const boundary = stablePrefixRef.current.length; - const tokens = marked.lexer(stripped.substring(boundary)); + const boundary = stablePrefixRef.current.length + const tokens = marked.lexer(stripped.substring(boundary)) // Last non-space token is the growing block; everything before is final - let lastContentIdx = tokens.length - 1; + let lastContentIdx = tokens.length - 1 while (lastContentIdx >= 0 && tokens[lastContentIdx]!.type === 'space') { - lastContentIdx--; + lastContentIdx-- } - let advance = 0; + let advance = 0 for (let i = 0; i < lastContentIdx; i++) { - advance += tokens[i]!.raw.length; + advance += tokens[i]!.raw.length } if (advance > 0) { - stablePrefixRef.current = stripped.substring(0, boundary + advance); + stablePrefixRef.current = stripped.substring(0, boundary + advance) } - const stablePrefix = stablePrefixRef.current; - const unstableSuffix = stripped.substring(stablePrefix.length); + + const stablePrefix = stablePrefixRef.current + const unstableSuffix = stripped.substring(stablePrefix.length) // stablePrefix is memoized inside via useMemo([children, ...]) // so it never re-parses as the unstable suffix grows - return + return ( + {stablePrefix && {stablePrefix}} {unstableSuffix && {unstableSuffix}} - ; + + ) } diff --git a/src/components/MarkdownTable.tsx b/src/components/MarkdownTable.tsx index b3670ced0..c8997d9a1 100644 --- a/src/components/MarkdownTable.tsx +++ b/src/components/MarkdownTable.tsx @@ -1,38 +1,39 @@ -import type { Token, Tokens } from 'marked'; -import React from 'react'; -import stripAnsi from 'strip-ansi'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { stringWidth } from '../ink/stringWidth.js'; -import { wrapAnsi } from '../ink/wrapAnsi.js'; -import { Ansi, useTheme } from '../ink.js'; -import type { CliHighlight } from '../utils/cliHighlight.js'; -import { formatToken, padAligned } from '../utils/markdown.js'; +import type { Token, Tokens } from 'marked' +import React from 'react' +import stripAnsi from 'strip-ansi' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { stringWidth } from '../ink/stringWidth.js' +import { wrapAnsi } from '../ink/wrapAnsi.js' +import { Ansi, useTheme } from '../ink.js' +import type { CliHighlight } from '../utils/cliHighlight.js' +import { formatToken, padAligned } from '../utils/markdown.js' /** Accounts for parent indentation (e.g. message dot prefix) and terminal * resize races. Without enough margin the table overflows its layout box * and Ink's clip truncates differently on alternating frames, causing an * infinite flicker loop in scrollback. */ -const SAFETY_MARGIN = 4; +const SAFETY_MARGIN = 4 /** Minimum column width to prevent degenerate layouts */ -const MIN_COLUMN_WIDTH = 3; +const MIN_COLUMN_WIDTH = 3 /** * Maximum number of lines per row before switching to vertical format. * When wrapping would make rows taller than this, vertical (key-value) * format provides better readability. */ -const MAX_ROW_LINES = 4; +const MAX_ROW_LINES = 4 /** ANSI escape codes for text formatting */ -const ANSI_BOLD_START = '\x1b[1m'; -const ANSI_BOLD_END = '\x1b[22m'; +const ANSI_BOLD_START = '\x1b[1m' +const ANSI_BOLD_END = '\x1b[22m' + type Props = { - token: Tokens.Table; - highlight: CliHighlight | null; + token: Tokens.Table + highlight: CliHighlight | null /** Override terminal width (useful for testing) */ - forceWidth?: number; -}; + forceWidth?: number +} /** * Wrap text to fit within a given width, returning array of lines. @@ -41,24 +42,26 @@ type Props = { * @param hard - If true, break words that exceed width (needed when columns * are narrower than the longest word). Default false. */ -function wrapText(text: string, width: number, options?: { - hard?: boolean; -}): string[] { - if (width <= 0) return [text]; +function wrapText( + text: string, + width: number, + options?: { hard?: boolean }, +): string[] { + if (width <= 0) return [text] // Strip trailing whitespace/newlines before wrapping. // formatToken() adds EOL to paragraphs and other token types, // which would otherwise create extra blank lines in table cells. - const trimmedText = text.trimEnd(); + const trimmedText = text.trimEnd() const wrapped = wrapAnsi(trimmedText, width, { hard: options?.hard ?? false, trim: false, - wordWrap: true - }); + wordWrap: true, + }) // Filter out empty lines that result from trailing newlines or // multiple consecutive newlines in the source content. - const lines = wrapped.split('\n').filter(line => line.length > 0); + const lines = wrapped.split('\n').filter(line => line.length > 0) // Ensure we always return at least one line (empty string for empty cells) - return lines.length > 0 ? lines : ['']; + return lines.length > 0 ? lines : [''] } /** @@ -72,154 +75,171 @@ function wrapText(text: string, width: number, options?: { export function MarkdownTable({ token, highlight, - forceWidth + forceWidth, }: Props): React.ReactNode { - const [theme] = useTheme(); - const { - columns: actualTerminalWidth - } = useTerminalSize(); - const terminalWidth = forceWidth ?? actualTerminalWidth; + const [theme] = useTheme() + const { columns: actualTerminalWidth } = useTerminalSize() + const terminalWidth = forceWidth ?? actualTerminalWidth // Format cell content to ANSI string function formatCell(tokens: Token[] | undefined): string { - return tokens?.map(_ => formatToken(_, theme, 0, null, null, highlight)).join('') ?? ''; + return ( + tokens + ?.map(_ => formatToken(_, theme, 0, null, null, highlight)) + .join('') ?? '' + ) } // Get plain text (stripped of ANSI codes) - function getPlainText(tokens_0: Token[] | undefined): string { - return stripAnsi(formatCell(tokens_0)); + function getPlainText(tokens: Token[] | undefined): string { + return stripAnsi(formatCell(tokens)) } // Get the longest word width in a cell (minimum width to avoid breaking words) - function getMinWidth(tokens_1: Token[] | undefined): number { - const text = getPlainText(tokens_1); - const words = text.split(/\s+/).filter(w => w.length > 0); - if (words.length === 0) return MIN_COLUMN_WIDTH; - return Math.max(...words.map(w_0 => stringWidth(w_0)), MIN_COLUMN_WIDTH); + function getMinWidth(tokens: Token[] | undefined): number { + const text = getPlainText(tokens) + const words = text.split(/\s+/).filter(w => w.length > 0) + if (words.length === 0) return MIN_COLUMN_WIDTH + return Math.max(...words.map(w => stringWidth(w)), MIN_COLUMN_WIDTH) } // Get ideal width (full content without wrapping) - function getIdealWidth(tokens_2: Token[] | undefined): number { - return Math.max(stringWidth(getPlainText(tokens_2)), MIN_COLUMN_WIDTH); + function getIdealWidth(tokens: Token[] | undefined): number { + return Math.max(stringWidth(getPlainText(tokens)), MIN_COLUMN_WIDTH) } // Calculate column widths // Step 1: Get minimum (longest word) and ideal (full content) widths const minWidths = token.header.map((header, colIndex) => { - let maxMinWidth = getMinWidth(header.tokens); + let maxMinWidth = getMinWidth(header.tokens) for (const row of token.rows) { - maxMinWidth = Math.max(maxMinWidth, getMinWidth(row[colIndex]?.tokens)); + maxMinWidth = Math.max(maxMinWidth, getMinWidth(row[colIndex]?.tokens)) } - return maxMinWidth; - }); - const idealWidths = token.header.map((header_0, colIndex_0) => { - let maxIdeal = getIdealWidth(header_0.tokens); - for (const row_0 of token.rows) { - maxIdeal = Math.max(maxIdeal, getIdealWidth(row_0[colIndex_0]?.tokens)); + return maxMinWidth + }) + + const idealWidths = token.header.map((header, colIndex) => { + let maxIdeal = getIdealWidth(header.tokens) + for (const row of token.rows) { + maxIdeal = Math.max(maxIdeal, getIdealWidth(row[colIndex]?.tokens)) } - return maxIdeal; - }); + return maxIdeal + }) // Step 2: Calculate available space // Border overhead: │ content │ content │ = 1 + (width + 3) per column - const numCols = token.header.length; - const borderOverhead = 1 + numCols * 3; // │ + (2 padding + 1 border) per col + const numCols = token.header.length + const borderOverhead = 1 + numCols * 3 // │ + (2 padding + 1 border) per col // Account for SAFETY_MARGIN to avoid triggering the fallback safety check - const availableWidth = Math.max(terminalWidth - borderOverhead - SAFETY_MARGIN, numCols * MIN_COLUMN_WIDTH); + const availableWidth = Math.max( + terminalWidth - borderOverhead - SAFETY_MARGIN, + numCols * MIN_COLUMN_WIDTH, + ) // Step 3: Calculate column widths that fit available space - const totalMin = minWidths.reduce((sum, w_1) => sum + w_1, 0); - const totalIdeal = idealWidths.reduce((sum_0, w_2) => sum_0 + w_2, 0); + const totalMin = minWidths.reduce((sum, w) => sum + w, 0) + const totalIdeal = idealWidths.reduce((sum, w) => sum + w, 0) // Track whether columns are narrower than longest words (needs hard wrap) - let needsHardWrap = false; - let columnWidths: number[]; + let needsHardWrap = false + + let columnWidths: number[] if (totalIdeal <= availableWidth) { // Everything fits - use ideal widths - columnWidths = idealWidths; + columnWidths = idealWidths } else if (totalMin <= availableWidth) { // Need to shrink - give each column its min, distribute remaining space - const extraSpace = availableWidth - totalMin; - const overflows = idealWidths.map((ideal, i) => ideal - minWidths[i]!); - const totalOverflow = overflows.reduce((sum_1, o) => sum_1 + o, 0); - columnWidths = minWidths.map((min, i_0) => { - if (totalOverflow === 0) return min; - const extra = Math.floor(overflows[i_0]! / totalOverflow * extraSpace); - return min + extra; - }); + const extraSpace = availableWidth - totalMin + const overflows = idealWidths.map((ideal, i) => ideal - minWidths[i]!) + const totalOverflow = overflows.reduce((sum, o) => sum + o, 0) + + columnWidths = minWidths.map((min, i) => { + if (totalOverflow === 0) return min + const extra = Math.floor((overflows[i]! / totalOverflow) * extraSpace) + return min + extra + }) } else { // Table wider than terminal at minimum widths // Shrink columns proportionally to fit, allowing word breaks - needsHardWrap = true; - const scaleFactor = availableWidth / totalMin; - columnWidths = minWidths.map(w_3 => Math.max(Math.floor(w_3 * scaleFactor), MIN_COLUMN_WIDTH)); + needsHardWrap = true + const scaleFactor = availableWidth / totalMin + columnWidths = minWidths.map(w => + Math.max(Math.floor(w * scaleFactor), MIN_COLUMN_WIDTH), + ) } // Step 4: Calculate max row lines to determine if vertical format is needed function calculateMaxRowLines(): number { - let maxLines = 1; + let maxLines = 1 // Check header - for (let i_1 = 0; i_1 < token.header.length; i_1++) { - const content = formatCell(token.header[i_1]!.tokens); - const wrapped = wrapText(content, columnWidths[i_1]!, { - hard: needsHardWrap - }); - maxLines = Math.max(maxLines, wrapped.length); + for (let i = 0; i < token.header.length; i++) { + const content = formatCell(token.header[i]!.tokens) + const wrapped = wrapText(content, columnWidths[i]!, { + hard: needsHardWrap, + }) + maxLines = Math.max(maxLines, wrapped.length) } // Check rows - for (const row_1 of token.rows) { - for (let i_2 = 0; i_2 < row_1.length; i_2++) { - const content_0 = formatCell(row_1[i_2]?.tokens); - const wrapped_0 = wrapText(content_0, columnWidths[i_2]!, { - hard: needsHardWrap - }); - maxLines = Math.max(maxLines, wrapped_0.length); + for (const row of token.rows) { + for (let i = 0; i < row.length; i++) { + const content = formatCell(row[i]?.tokens) + const wrapped = wrapText(content, columnWidths[i]!, { + hard: needsHardWrap, + }) + maxLines = Math.max(maxLines, wrapped.length) } } - return maxLines; + return maxLines } // Use vertical format if wrapping would make rows too tall - const maxRowLines = calculateMaxRowLines(); - const useVerticalFormat = maxRowLines > MAX_ROW_LINES; + const maxRowLines = calculateMaxRowLines() + const useVerticalFormat = maxRowLines > MAX_ROW_LINES // Render a single row with potential multi-line cells // Returns an array of strings, one per line of the row - function renderRowLines(cells: Array<{ - tokens?: Token[]; - }>, isHeader: boolean): string[] { + function renderRowLines( + cells: Array<{ tokens?: Token[] }>, + isHeader: boolean, + ): string[] { // Get wrapped lines for each cell (preserving ANSI formatting) - const cellLines = cells.map((cell, colIndex_1) => { - const formattedText = formatCell(cell.tokens); - const width = columnWidths[colIndex_1]!; - return wrapText(formattedText, width, { - hard: needsHardWrap - }); - }); + const cellLines = cells.map((cell, colIndex) => { + const formattedText = formatCell(cell.tokens) + const width = columnWidths[colIndex]! + return wrapText(formattedText, width, { hard: needsHardWrap }) + }) // Find max number of lines in this row - const maxLines_0 = Math.max(...cellLines.map(lines => lines.length), 1); + const maxLines = Math.max(...cellLines.map(lines => lines.length), 1) // Calculate vertical offset for each cell (to center vertically) - const verticalOffsets = cellLines.map(lines_0 => Math.floor((maxLines_0 - lines_0.length) / 2)); + const verticalOffsets = cellLines.map(lines => + Math.floor((maxLines - lines.length) / 2), + ) // Build each line of the row as a single string - const result: string[] = []; - for (let lineIdx = 0; lineIdx < maxLines_0; lineIdx++) { - let line = '│'; - for (let colIndex_2 = 0; colIndex_2 < cells.length; colIndex_2++) { - const lines_1 = cellLines[colIndex_2]!; - const offset = verticalOffsets[colIndex_2]!; - const contentLineIdx = lineIdx - offset; - const lineText = contentLineIdx >= 0 && contentLineIdx < lines_1.length ? lines_1[contentLineIdx]! : ''; - const width_0 = columnWidths[colIndex_2]!; + const result: string[] = [] + for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) { + let line = '│' + for (let colIndex = 0; colIndex < cells.length; colIndex++) { + const lines = cellLines[colIndex]! + const offset = verticalOffsets[colIndex]! + const contentLineIdx = lineIdx - offset + const lineText = + contentLineIdx >= 0 && contentLineIdx < lines.length + ? lines[contentLineIdx]! + : '' + const width = columnWidths[colIndex]! // Headers always centered; data uses table alignment - const align = isHeader ? 'center' : token.align?.[colIndex_2] ?? 'left'; - line += ' ' + padAligned(lineText, stringWidth(lineText), width_0, align) + ' │'; + const align = isHeader ? 'center' : (token.align?.[colIndex] ?? 'left') + + line += + ' ' + padAligned(lineText, stringWidth(lineText), width, align) + ' │' } - result.push(line); + result.push(line) } - return result; + + return result } // Render horizontal border as a single string @@ -227,95 +247,110 @@ export function MarkdownTable({ const [left, mid, cross, right] = { top: ['┌', '─', '┬', '┐'], middle: ['├', '─', '┼', '┤'], - bottom: ['└', '─', '┴', '┘'] - }[type] as [string, string, string, string]; - let line_0 = left; - columnWidths.forEach((width_1, colIndex_3) => { - line_0 += mid.repeat(width_1 + 2); - line_0 += colIndex_3 < columnWidths.length - 1 ? cross : right; - }); - return line_0; + bottom: ['└', '─', '┴', '┘'], + }[type] as [string, string, string, string] + + let line = left + columnWidths.forEach((width, colIndex) => { + line += mid.repeat(width + 2) + line += colIndex < columnWidths.length - 1 ? cross : right + }) + return line } // Render vertical format (key-value pairs) for extra-narrow terminals function renderVerticalFormat(): string { - const lines_2: string[] = []; - const headers = token.header.map(h => getPlainText(h.tokens)); - const separatorWidth = Math.min(terminalWidth - 1, 40); - const separator = '─'.repeat(separatorWidth); + const lines: string[] = [] + const headers = token.header.map(h => getPlainText(h.tokens)) + const separatorWidth = Math.min(terminalWidth - 1, 40) + const separator = '─'.repeat(separatorWidth) // Small indent for wrapped lines (just 2 spaces) - const wrapIndent = ' '; - token.rows.forEach((row_2, rowIndex) => { + const wrapIndent = ' ' + + token.rows.forEach((row, rowIndex) => { if (rowIndex > 0) { - lines_2.push(separator); + lines.push(separator) } - row_2.forEach((cell_0, colIndex_4) => { - const label = headers[colIndex_4] || `Column ${colIndex_4 + 1}`; + + row.forEach((cell, colIndex) => { + const label = headers[colIndex] || `Column ${colIndex + 1}` // Clean value: trim, remove extra internal whitespace/newlines - const rawValue = formatCell(cell_0.tokens).trimEnd(); - const value = rawValue.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim(); + const rawValue = formatCell(cell.tokens).trimEnd() + const value = rawValue.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim() // Wrap value to fit terminal, accounting for label on first line - const firstLineWidth = terminalWidth - stringWidth(label) - 3; - const subsequentLineWidth = terminalWidth - wrapIndent.length - 1; + const firstLineWidth = terminalWidth - stringWidth(label) - 3 + const subsequentLineWidth = terminalWidth - wrapIndent.length - 1 // Two-pass wrap: first line is narrower (label takes space), // continuation lines get the full width minus indent. - const firstPassLines = wrapText(value, Math.max(firstLineWidth, 10)); - const firstLine = firstPassLines[0] || ''; - let wrappedValue: string[]; - if (firstPassLines.length <= 1 || subsequentLineWidth <= firstLineWidth) { - wrappedValue = firstPassLines; + const firstPassLines = wrapText(value, Math.max(firstLineWidth, 10)) + const firstLine = firstPassLines[0] || '' + + let wrappedValue: string[] + if ( + firstPassLines.length <= 1 || + subsequentLineWidth <= firstLineWidth + ) { + wrappedValue = firstPassLines } else { // Re-join remaining text and re-wrap to the wider continuation width - const remainingText = firstPassLines.slice(1).map(l => l.trim()).join(' '); - const rewrapped = wrapText(remainingText, subsequentLineWidth); - wrappedValue = [firstLine, ...rewrapped]; + const remainingText = firstPassLines + .slice(1) + .map(l => l.trim()) + .join(' ') + const rewrapped = wrapText(remainingText, subsequentLineWidth) + wrappedValue = [firstLine, ...rewrapped] } // First line: bold label + value - lines_2.push(`${ANSI_BOLD_START}${label}:${ANSI_BOLD_END} ${wrappedValue[0] || ''}`); + lines.push( + `${ANSI_BOLD_START}${label}:${ANSI_BOLD_END} ${wrappedValue[0] || ''}`, + ) // Subsequent lines with small indent (skip empty lines) - for (let i_3 = 1; i_3 < wrappedValue.length; i_3++) { - const line_1 = wrappedValue[i_3]!; - if (!line_1.trim()) continue; - lines_2.push(`${wrapIndent}${line_1}`); + for (let i = 1; i < wrappedValue.length; i++) { + const line = wrappedValue[i]! + if (!line.trim()) continue + lines.push(`${wrapIndent}${line}`) } - }); - }); - return lines_2.join('\n'); + }) + }) + + return lines.join('\n') } // Choose format based on available width if (useVerticalFormat) { - return {renderVerticalFormat()}; + return {renderVerticalFormat()} } // Build the complete horizontal table as an array of strings - const tableLines: string[] = []; - tableLines.push(renderBorderLine('top')); - tableLines.push(...renderRowLines(token.header, true)); - tableLines.push(renderBorderLine('middle')); - token.rows.forEach((row_3, rowIndex_0) => { - tableLines.push(...renderRowLines(row_3, false)); - if (rowIndex_0 < token.rows.length - 1) { - tableLines.push(renderBorderLine('middle')); + const tableLines: string[] = [] + tableLines.push(renderBorderLine('top')) + tableLines.push(...renderRowLines(token.header, true)) + tableLines.push(renderBorderLine('middle')) + token.rows.forEach((row, rowIndex) => { + tableLines.push(...renderRowLines(row, false)) + if (rowIndex < token.rows.length - 1) { + tableLines.push(renderBorderLine('middle')) } - }); - tableLines.push(renderBorderLine('bottom')); + }) + tableLines.push(renderBorderLine('bottom')) // Safety check: verify no line exceeds terminal width. // This catches edge cases during terminal resize where calculations // were based on a different width than the current render target. - const maxLineWidth = Math.max(...tableLines.map(line_2 => stringWidth(stripAnsi(line_2)))); + const maxLineWidth = Math.max( + ...tableLines.map(line => stringWidth(stripAnsi(line))), + ) // If we're within SAFETY_MARGIN characters of the edge, use vertical format // to account for terminal resize race conditions. if (maxLineWidth > terminalWidth - SAFETY_MARGIN) { - return {renderVerticalFormat()}; + return {renderVerticalFormat()} } // Render as a single Ansi block to prevent Ink from wrapping mid-row - return {tableLines.join('\n')}; + return {tableLines.join('\n')} } diff --git a/src/components/MemoryUsageIndicator.tsx b/src/components/MemoryUsageIndicator.tsx index c7b0fefe8..37c91e778 100644 --- a/src/components/MemoryUsageIndicator.tsx +++ b/src/components/MemoryUsageIndicator.tsx @@ -1,36 +1,40 @@ -import * as React from 'react'; -import { useMemoryUsage } from '../hooks/useMemoryUsage.js'; -import { Box, Text } from '../ink.js'; -import { formatFileSize } from '../utils/format.js'; +import * as React from 'react' +import { useMemoryUsage } from '../hooks/useMemoryUsage.js' +import { Box, Text } from '../ink.js' +import { formatFileSize } from '../utils/format.js' + export function MemoryUsageIndicator(): React.ReactNode { // Ant-only: the /heapdump link is an internal debugging aid. Gating before // the hook means the 10s polling interval is never set up in external builds. // USER_TYPE is a build-time constant, so the hook call below is either always // reached or dead-code-eliminated — never conditional at runtime. - if ((process.env.USER_TYPE) !== 'ant') { - return null; + if ("external" !== 'ant') { + return null } // eslint-disable-next-line react-hooks/rules-of-hooks // biome-ignore lint/correctness/useHookAtTopLevel: USER_TYPE is a build-time constant - const memoryUsage = useMemoryUsage(); + const memoryUsage = useMemoryUsage() + if (!memoryUsage) { - return null; + return null } - const { - heapUsed, - status - } = memoryUsage; + + const { heapUsed, status } = memoryUsage // Only show indicator when memory usage is high or critical if (status === 'normal') { - return null; + return null } - const formattedSize = formatFileSize(heapUsed); - const color = status === 'critical' ? 'error' : 'warning'; - return + + const formattedSize = formatFileSize(heapUsed) + const color = status === 'critical' ? 'error' : 'warning' + + return ( + High memory usage ({formattedSize}) · /heapdump - ; + + ) } diff --git a/src/components/Message.tsx b/src/components/Message.tsx index 02f5fb2c4..79a152682 100644 --- a/src/components/Message.tsx +++ b/src/components/Message.tsx @@ -1,626 +1,526 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import type { BetaContentBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'; -import type { ImageBlockParam, TextBlockParam, ThinkingBlockParam, ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import * as React from 'react'; -import type { Command } from '../commands.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { Box } from '../ink.js'; -import type { Tools } from '../Tool.js'; -import { type ConnectorTextBlock, isConnectorTextBlock } from '../types/connectorText.js'; -import type { AssistantMessage, AttachmentMessage as AttachmentMessageType, CollapsedReadSearchGroup as CollapsedReadSearchGroupType, GroupedToolUseMessage as GroupedToolUseMessageType, NormalizedUserMessage, ProgressMessage, SystemMessage } from '../types/message.js'; -import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js'; -import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; -import { logError } from '../utils/log.js'; -import type { buildMessageLookups } from '../utils/messages.js'; -import { CompactSummary } from './CompactSummary.js'; -import { AdvisorMessage } from './messages/AdvisorMessage.js'; -import { AssistantRedactedThinkingMessage } from './messages/AssistantRedactedThinkingMessage.js'; -import { AssistantTextMessage } from './messages/AssistantTextMessage.js'; -import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js'; -import { AssistantToolUseMessage } from './messages/AssistantToolUseMessage.js'; -import { AttachmentMessage } from './messages/AttachmentMessage.js'; -import { CollapsedReadSearchContent } from './messages/CollapsedReadSearchContent.js'; -import { CompactBoundaryMessage } from './messages/CompactBoundaryMessage.js'; -import { GroupedToolUseContent } from './messages/GroupedToolUseContent.js'; -import { SystemTextMessage } from './messages/SystemTextMessage.js'; -import { UserImageMessage } from './messages/UserImageMessage.js'; -import { UserTextMessage } from './messages/UserTextMessage.js'; -import { UserToolResultMessage } from './messages/UserToolResultMessage/UserToolResultMessage.js'; -import { OffscreenFreeze } from './OffscreenFreeze.js'; -import { ExpandShellOutputProvider } from './shell/ExpandShellOutputContext.js'; +import { feature } from 'bun:bundle' +import type { BetaContentBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' +import type { + ImageBlockParam, + TextBlockParam, + ThinkingBlockParam, + ToolResultBlockParam, + ToolUseBlockParam, +} from '@anthropic-ai/sdk/resources/index.mjs' +import * as React from 'react' +import type { Command } from '../commands.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { Box } from '../ink.js' +import type { Tools } from '../Tool.js' +import { + type ConnectorTextBlock, + isConnectorTextBlock, +} from '../types/connectorText.js' +import type { + AssistantMessage, + AttachmentMessage as AttachmentMessageType, + CollapsedReadSearchGroup as CollapsedReadSearchGroupType, + GroupedToolUseMessage as GroupedToolUseMessageType, + NormalizedUserMessage, + ProgressMessage, + SystemMessage, +} from '../types/message.js' +import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js' +import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' +import { logError } from '../utils/log.js' +import type { buildMessageLookups } from '../utils/messages.js' +import { CompactSummary } from './CompactSummary.js' +import { AdvisorMessage } from './messages/AdvisorMessage.js' +import { AssistantRedactedThinkingMessage } from './messages/AssistantRedactedThinkingMessage.js' +import { AssistantTextMessage } from './messages/AssistantTextMessage.js' +import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js' +import { AssistantToolUseMessage } from './messages/AssistantToolUseMessage.js' +import { AttachmentMessage } from './messages/AttachmentMessage.js' +import { CollapsedReadSearchContent } from './messages/CollapsedReadSearchContent.js' +import { CompactBoundaryMessage } from './messages/CompactBoundaryMessage.js' +import { GroupedToolUseContent } from './messages/GroupedToolUseContent.js' +import { SystemTextMessage } from './messages/SystemTextMessage.js' +import { UserImageMessage } from './messages/UserImageMessage.js' +import { UserTextMessage } from './messages/UserTextMessage.js' +import { UserToolResultMessage } from './messages/UserToolResultMessage/UserToolResultMessage.js' +import { OffscreenFreeze } from './OffscreenFreeze.js' +import { ExpandShellOutputProvider } from './shell/ExpandShellOutputContext.js' + export type Props = { - message: NormalizedUserMessage | AssistantMessage | AttachmentMessageType | SystemMessage | GroupedToolUseMessageType | CollapsedReadSearchGroupType; - lookups: ReturnType; + message: + | NormalizedUserMessage + | AssistantMessage + | AttachmentMessageType + | SystemMessage + | GroupedToolUseMessageType + | CollapsedReadSearchGroupType + lookups: ReturnType // TODO: Find a way to remove this, and leave spacing to the consumer /** Absolute width for the container Box. When provided, eliminates a wrapper Box in the caller. */ - containerWidth?: number; - addMargin: boolean; - tools: Tools; - commands: Command[]; - verbose: boolean; - inProgressToolUseIDs: Set; - progressMessagesForMessage: ProgressMessage[]; - shouldAnimate: boolean; - shouldShowDot: boolean; - style?: 'condensed'; - width?: number | string; - isTranscriptMode: boolean; - isStatic: boolean; - onOpenRateLimitOptions?: () => void; - isActiveCollapsedGroup?: boolean; - isUserContinuation?: boolean; + containerWidth?: number + addMargin: boolean + tools: Tools + commands: Command[] + verbose: boolean + inProgressToolUseIDs: Set + progressMessagesForMessage: ProgressMessage[] + shouldAnimate: boolean + shouldShowDot: boolean + style?: 'condensed' + width?: number | string + isTranscriptMode: boolean + isStatic: boolean + onOpenRateLimitOptions?: () => void + isActiveCollapsedGroup?: boolean + isUserContinuation?: boolean /** ID of the last thinking block (uuid:index) to show, used for hiding past thinking in transcript mode */ - lastThinkingBlockId?: string | null; + lastThinkingBlockId?: string | null /** UUID of the latest user bash output message (for auto-expanding) */ - latestBashOutputUUID?: string | null; -}; -function MessageImpl(t0) { - const $ = _c(94); - const { - message, - lookups, - containerWidth, - addMargin, - tools, - commands, - verbose, - inProgressToolUseIDs, - progressMessagesForMessage, - shouldAnimate, - shouldShowDot, - style, - width, - isTranscriptMode, - onOpenRateLimitOptions, - isActiveCollapsedGroup, - isUserContinuation: t1, - lastThinkingBlockId, - latestBashOutputUUID - } = t0; - const isUserContinuation = t1 === undefined ? false : t1; + latestBashOutputUUID?: string | null +} + +function MessageImpl({ + message, + lookups, + containerWidth, + addMargin, + tools, + commands, + verbose, + inProgressToolUseIDs, + progressMessagesForMessage, + shouldAnimate, + shouldShowDot, + style, + width, + isTranscriptMode, + onOpenRateLimitOptions, + isActiveCollapsedGroup, + isUserContinuation = false, + lastThinkingBlockId, + latestBashOutputUUID, +}: Props): React.ReactNode { switch (message.type) { - case "attachment": - { - let t2; - if ($[0] !== addMargin || $[1] !== isTranscriptMode || $[2] !== message.attachment || $[3] !== verbose) { - t2 = ; - $[0] = addMargin; - $[1] = isTranscriptMode; - $[2] = message.attachment; - $[3] = verbose; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; + case 'attachment': + return ( + + ) + case 'assistant': + return ( + + {message.message.content.map((_, index) => ( + + ))} + + ) + case 'user': { + if (message.isCompactSummary) { + return ( + + ) } - case "assistant": - { - const t2 = containerWidth ?? "100%"; - let t3; - if ($[5] !== addMargin || $[6] !== commands || $[7] !== inProgressToolUseIDs || $[8] !== isTranscriptMode || $[9] !== lastThinkingBlockId || $[10] !== lookups || $[11] !== message.advisorModel || $[12] !== message.message.content || $[13] !== message.uuid || $[14] !== onOpenRateLimitOptions || $[15] !== progressMessagesForMessage || $[16] !== shouldAnimate || $[17] !== shouldShowDot || $[18] !== tools || $[19] !== verbose || $[20] !== width) { - let t4; - if ($[22] !== addMargin || $[23] !== commands || $[24] !== inProgressToolUseIDs || $[25] !== isTranscriptMode || $[26] !== lastThinkingBlockId || $[27] !== lookups || $[28] !== message.advisorModel || $[29] !== message.uuid || $[30] !== onOpenRateLimitOptions || $[31] !== progressMessagesForMessage || $[32] !== shouldAnimate || $[33] !== shouldShowDot || $[34] !== tools || $[35] !== verbose || $[36] !== width) { - t4 = (_, index_0) => ; - $[22] = addMargin; - $[23] = commands; - $[24] = inProgressToolUseIDs; - $[25] = isTranscriptMode; - $[26] = lastThinkingBlockId; - $[27] = lookups; - $[28] = message.advisorModel; - $[29] = message.uuid; - $[30] = onOpenRateLimitOptions; - $[31] = progressMessagesForMessage; - $[32] = shouldAnimate; - $[33] = shouldShowDot; - $[34] = tools; - $[35] = verbose; - $[36] = width; - $[37] = t4; - } else { - t4 = $[37]; - } - t3 = message.message.content.map(t4); - $[5] = addMargin; - $[6] = commands; - $[7] = inProgressToolUseIDs; - $[8] = isTranscriptMode; - $[9] = lastThinkingBlockId; - $[10] = lookups; - $[11] = message.advisorModel; - $[12] = message.message.content; - $[13] = message.uuid; - $[14] = onOpenRateLimitOptions; - $[15] = progressMessagesForMessage; - $[16] = shouldAnimate; - $[17] = shouldShowDot; - $[18] = tools; - $[19] = verbose; - $[20] = width; - $[21] = t3; + // Precompute the imageIndex prop for each content block. The previous + // version incremented a counter inside the .map() callback, which + // React Compiler bails on ("UpdateExpression to variables captured + // within lambdas"). A plain for loop keeps the mutation out of a + // closure so the compiler can memoize MessageImpl. + const imageIndices: number[] = [] + let imagePosition = 0 + for (const param of message.message.content) { + if (param.type === 'image') { + const id = message.imagePasteIds?.[imagePosition] + imagePosition++ + imageIndices.push(id ?? imagePosition) } else { - t3 = $[21]; + imageIndices.push(imagePosition) } - let t4; - if ($[38] !== t2 || $[39] !== t3) { - t4 = {t3}; - $[38] = t2; - $[39] = t3; - $[40] = t4; - } else { - t4 = $[40]; - } - return t4; } - case "user": - { - if (message.isCompactSummary) { - const t2 = isTranscriptMode ? "transcript" : "prompt"; - let t3; - if ($[41] !== message || $[42] !== t2) { - t3 = ; - $[41] = message; - $[42] = t2; - $[43] = t3; - } else { - t3 = $[43]; - } - return t3; + // Check if this message is the latest bash output - if so, wrap content + // with provider so OutputLine can show full output via context + const isLatestBashOutput = latestBashOutputUUID === message.uuid + const content = ( + + {message.message.content.map((param, index) => ( + + ))} + + ) + return isLatestBashOutput ? ( + {content} + ) : ( + content + ) + } + case 'system': + if (message.subtype === 'compact_boundary') { + // Fullscreen keeps pre-compact messages in the ScrollBox (REPL.tsx + // appends instead of resetting, Messages.tsx skips the boundary + // filter) — scroll up for history, no need for the ctrl+o hint. + if (isFullscreenEnvEnabled()) { + return null } - let imageIndices; - if ($[44] !== message.imagePasteIds || $[45] !== message.message.content) { - imageIndices = []; - let imagePosition = 0; - for (const param of message.message.content) { - if (param.type === "image") { - const id = message.imagePasteIds?.[imagePosition]; - imagePosition++; - imageIndices.push(id ?? imagePosition); - } else { - imageIndices.push(imagePosition); - } - } - $[44] = message.imagePasteIds; - $[45] = message.message.content; - $[46] = imageIndices; - } else { - imageIndices = $[46]; - } - const isLatestBashOutput = latestBashOutputUUID === message.uuid; - const t2 = containerWidth ?? "100%"; - let t3; - if ($[47] !== addMargin || $[48] !== imageIndices || $[49] !== isTranscriptMode || $[50] !== isUserContinuation || $[51] !== lookups || $[52] !== message || $[53] !== progressMessagesForMessage || $[54] !== style || $[55] !== tools || $[56] !== verbose) { - t3 = message.message.content.map((param_0, index) => ); - $[47] = addMargin; - $[48] = imageIndices; - $[49] = isTranscriptMode; - $[50] = isUserContinuation; - $[51] = lookups; - $[52] = message; - $[53] = progressMessagesForMessage; - $[54] = style; - $[55] = tools; - $[56] = verbose; - $[57] = t3; - } else { - t3 = $[57]; - } - let t4; - if ($[58] !== t2 || $[59] !== t3) { - t4 = {t3}; - $[58] = t2; - $[59] = t3; - $[60] = t4; - } else { - t4 = $[60]; - } - const content = t4; - let t5; - if ($[61] !== content || $[62] !== isLatestBashOutput) { - t5 = isLatestBashOutput ? {content} : content; - $[61] = content; - $[62] = isLatestBashOutput; - $[63] = t5; - } else { - t5 = $[63]; - } - return t5; + return } - case "system": - { - if (message.subtype === "compact_boundary") { - if (isFullscreenEnvEnabled()) { - return null; - } - let t2; - if ($[64] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[64] = t2; - } else { - t2 = $[64]; - } - return t2; - } - if (message.subtype === "microcompact_boundary") { - return null; - } - if (feature("HISTORY_SNIP")) { - const { - isSnipBoundaryMessage - } = require("../services/compact/snipProjection.js") as typeof import('../services/compact/snipProjection.js'); - const { - isSnipMarkerMessage - } = require("../services/compact/snipCompact.js") as typeof import('../services/compact/snipCompact.js'); - if (isSnipBoundaryMessage(message)) { - let t2; - if ($[65] === Symbol.for("react.memo_cache_sentinel")) { - t2 = require("./messages/SnipBoundaryMessage.js"); - $[65] = t2; - } else { - t2 = $[65]; - } - const { - SnipBoundaryMessage - } = t2 as typeof import('./messages/SnipBoundaryMessage.js'); - let t3; - if ($[66] !== message) { - t3 = ; - $[66] = message; - $[67] = t3; - } else { - t3 = $[67]; - } - return t3; - } - if (isSnipMarkerMessage(message)) { - return null; - } - } - if (message.subtype === "local_command") { - let t2; - if ($[68] !== message.content) { - t2 = { - type: "text", - text: message.content - }; - $[68] = message.content; - $[69] = t2; - } else { - t2 = $[69]; - } - let t3; - if ($[70] !== addMargin || $[71] !== isTranscriptMode || $[72] !== t2 || $[73] !== verbose) { - t3 = ; - $[70] = addMargin; - $[71] = isTranscriptMode; - $[72] = t2; - $[73] = verbose; - $[74] = t3; - } else { - t3 = $[74]; - } - return t3; - } - let t2; - if ($[75] !== addMargin || $[76] !== isTranscriptMode || $[77] !== message || $[78] !== verbose) { - t2 = ; - $[75] = addMargin; - $[76] = isTranscriptMode; - $[77] = message; - $[78] = verbose; - $[79] = t2; - } else { - t2 = $[79]; - } - return t2; + if (message.subtype === 'microcompact_boundary') { + // Logged at creation time in createMicrocompactBoundaryMessage + return null } - case "grouped_tool_use": - { - let t2; - if ($[80] !== inProgressToolUseIDs || $[81] !== lookups || $[82] !== message || $[83] !== shouldAnimate || $[84] !== tools) { - t2 = ; - $[80] = inProgressToolUseIDs; - $[81] = lookups; - $[82] = message; - $[83] = shouldAnimate; - $[84] = tools; - $[85] = t2; - } else { - t2 = $[85]; + if (feature('HISTORY_SNIP')) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { isSnipBoundaryMessage } = + require('../services/compact/snipProjection.js') as typeof import('../services/compact/snipProjection.js') + const { isSnipMarkerMessage } = + require('../services/compact/snipCompact.js') as typeof import('../services/compact/snipCompact.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + if (isSnipBoundaryMessage(message)) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { SnipBoundaryMessage } = + require('./messages/SnipBoundaryMessage.js') as typeof import('./messages/SnipBoundaryMessage.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + return } - return t2; - } - case "collapsed_read_search": - { - const t2 = verbose || isTranscriptMode; - let t3; - if ($[86] !== inProgressToolUseIDs || $[87] !== isActiveCollapsedGroup || $[88] !== lookups || $[89] !== message || $[90] !== shouldAnimate || $[91] !== t2 || $[92] !== tools) { - t3 = ; - $[86] = inProgressToolUseIDs; - $[87] = isActiveCollapsedGroup; - $[88] = lookups; - $[89] = message; - $[90] = shouldAnimate; - $[91] = t2; - $[92] = tools; - $[93] = t3; - } else { - t3 = $[93]; + if (isSnipMarkerMessage(message)) { + // Internal registration marker — not user-facing. The boundary + // message (above) is what shows when snips actually execute. + return null } - return t3; } + if (message.subtype === 'local_command') { + return ( + + ) + } + return ( + + ) + case 'grouped_tool_use': + return ( + + ) + case 'collapsed_read_search': + // OffscreenFreeze: the verb flips "Reading…"→"Read" when tools complete. + // If the group has scrolled into scrollback by then, the update triggers + // a full terminal reset (CC-1155). This component is never marked static + // in prompt mode (shouldRenderStatically returns false to allow live + // updates between API turns), so the memo can't help. Freeze when + // offscreen — scrollback shows whatever state was visible when it left. + return ( + + + + ) } } -function UserMessage(t0) { - const $ = _c(20); - const { - message, - addMargin, - tools, - progressMessagesForMessage, - param, - style, - verbose, - imageIndex, - isUserContinuation, - lookups, - isTranscriptMode - } = t0; - const { - columns - } = useTerminalSize(); + +function UserMessage({ + message, + addMargin, + tools, + progressMessagesForMessage, + param, + style, + verbose, + imageIndex, + isUserContinuation, + lookups, + isTranscriptMode, +}: { + message: NormalizedUserMessage + addMargin: boolean + tools: Tools + progressMessagesForMessage: ProgressMessage[] + param: + | TextBlockParam + | ImageBlockParam + | ToolUseBlockParam + | ToolResultBlockParam + style?: 'condensed' + verbose: boolean + imageIndex?: number + isUserContinuation: boolean + lookups: ReturnType + isTranscriptMode: boolean +}): React.ReactNode { + const { columns } = useTerminalSize() switch (param.type) { - case "text": - { - let t1; - if ($[0] !== addMargin || $[1] !== isTranscriptMode || $[2] !== message.planContent || $[3] !== message.timestamp || $[4] !== param || $[5] !== verbose) { - t1 = ; - $[0] = addMargin; - $[1] = isTranscriptMode; - $[2] = message.planContent; - $[3] = message.timestamp; - $[4] = param; - $[5] = verbose; - $[6] = t1; - } else { - t1 = $[6]; - } - return t1; - } - case "image": - { - const t1 = addMargin && !isUserContinuation; - let t2; - if ($[7] !== imageIndex || $[8] !== t1) { - t2 = ; - $[7] = imageIndex; - $[8] = t1; - $[9] = t2; - } else { - t2 = $[9]; - } - return t2; - } - case "tool_result": - { - const t1 = columns - 5; - let t2; - if ($[10] !== isTranscriptMode || $[11] !== lookups || $[12] !== message || $[13] !== param || $[14] !== progressMessagesForMessage || $[15] !== style || $[16] !== t1 || $[17] !== tools || $[18] !== verbose) { - t2 = ; - $[10] = isTranscriptMode; - $[11] = lookups; - $[12] = message; - $[13] = param; - $[14] = progressMessagesForMessage; - $[15] = style; - $[16] = t1; - $[17] = tools; - $[18] = verbose; - $[19] = t2; - } else { - t2 = $[19]; - } - return t2; - } + case 'text': + return ( + + ) + case 'image': + // If previous message is user (text or image), this is a continuation - use connector + // Otherwise this image starts a new user turn - use margin + return ( + + ) + case 'tool_result': + return ( + + ) default: - { - return; - } + return undefined } } -function AssistantMessageBlock(t0) { - const $ = _c(45); - const { - param, - addMargin, - tools, - commands, - verbose, - inProgressToolUseIDs, - progressMessagesForMessage, - shouldAnimate, - shouldShowDot, - width, - inProgressToolCallCount, - isTranscriptMode, - lookups, - onOpenRateLimitOptions, - thinkingBlockId, - lastThinkingBlockId, - advisorModel - } = t0; - if (feature("CONNECTOR_TEXT")) { + +function AssistantMessageBlock({ + param, + addMargin, + tools, + commands, + verbose, + inProgressToolUseIDs, + progressMessagesForMessage, + shouldAnimate, + shouldShowDot, + width, + inProgressToolCallCount, + isTranscriptMode, + lookups, + onOpenRateLimitOptions, + thinkingBlockId, + lastThinkingBlockId, + advisorModel, +}: { + param: + | BetaContentBlock + | ConnectorTextBlock + | AdvisorBlock + | TextBlockParam + | ImageBlockParam + | ThinkingBlockParam + | ToolUseBlockParam + | ToolResultBlockParam + addMargin: boolean + tools: Tools + commands: Command[] + verbose: boolean + inProgressToolUseIDs: Set + progressMessagesForMessage: ProgressMessage[] + shouldAnimate: boolean + shouldShowDot: boolean + width?: number | string + inProgressToolCallCount?: number + isTranscriptMode: boolean + lookups: ReturnType + onOpenRateLimitOptions?: () => void + /** ID of this content block's message:index for thinking block comparison */ + thinkingBlockId: string + /** ID of the last thinking block to show, null means show all */ + lastThinkingBlockId?: string | null + advisorModel?: string +}): React.ReactNode { + if (feature('CONNECTOR_TEXT')) { if (isConnectorTextBlock(param)) { - let t1; - if ($[0] !== param.connector_text) { - t1 = { - type: "text", - text: param.connector_text - }; - $[0] = param.connector_text; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== addMargin || $[3] !== onOpenRateLimitOptions || $[4] !== shouldShowDot || $[5] !== t1 || $[6] !== verbose || $[7] !== width) { - t2 = ; - $[2] = addMargin; - $[3] = onOpenRateLimitOptions; - $[4] = shouldShowDot; - $[5] = t1; - $[6] = verbose; - $[7] = width; - $[8] = t2; - } else { - t2 = $[8]; - } - return t2; + return ( + + ) } } switch (param.type) { - case "tool_use": - { - let t1; - if ($[9] !== addMargin || $[10] !== commands || $[11] !== inProgressToolCallCount || $[12] !== inProgressToolUseIDs || $[13] !== isTranscriptMode || $[14] !== lookups || $[15] !== param || $[16] !== progressMessagesForMessage || $[17] !== shouldAnimate || $[18] !== shouldShowDot || $[19] !== tools || $[20] !== verbose) { - t1 = ; - $[9] = addMargin; - $[10] = commands; - $[11] = inProgressToolCallCount; - $[12] = inProgressToolUseIDs; - $[13] = isTranscriptMode; - $[14] = lookups; - $[15] = param; - $[16] = progressMessagesForMessage; - $[17] = shouldAnimate; - $[18] = shouldShowDot; - $[19] = tools; - $[20] = verbose; - $[21] = t1; - } else { - t1 = $[21]; - } - return t1; + case 'tool_use': + return ( + + ) + case 'text': + return ( + + ) + case 'redacted_thinking': + if (!isTranscriptMode && !verbose) { + return null } - case "text": - { - let t1; - if ($[22] !== addMargin || $[23] !== onOpenRateLimitOptions || $[24] !== param || $[25] !== shouldShowDot || $[26] !== verbose || $[27] !== width) { - t1 = ; - $[22] = addMargin; - $[23] = onOpenRateLimitOptions; - $[24] = param; - $[25] = shouldShowDot; - $[26] = verbose; - $[27] = width; - $[28] = t1; - } else { - t1 = $[28]; - } - return t1; + return + case 'thinking': { + if (!isTranscriptMode && !verbose) { + return null } - case "redacted_thinking": - { - if (!isTranscriptMode && !verbose) { - return null; - } - let t1; - if ($[29] !== addMargin) { - t1 = ; - $[29] = addMargin; - $[30] = t1; - } else { - t1 = $[30]; - } - return t1; - } - case "thinking": - { - if (!isTranscriptMode && !verbose) { - return null; - } - const isLastThinking = !lastThinkingBlockId || thinkingBlockId === lastThinkingBlockId; - const t1 = isTranscriptMode && !isLastThinking; - let t2; - if ($[31] !== addMargin || $[32] !== isTranscriptMode || $[33] !== param || $[34] !== t1 || $[35] !== verbose) { - t2 = ; - $[31] = addMargin; - $[32] = isTranscriptMode; - $[33] = param; - $[34] = t1; - $[35] = verbose; - $[36] = t2; - } else { - t2 = $[36]; - } - return t2; - } - case "server_tool_use": - case "advisor_tool_result": - { - if (isAdvisorBlock(param)) { - const t1 = verbose || isTranscriptMode; - let t2; - if ($[37] !== addMargin || $[38] !== advisorModel || $[39] !== lookups.erroredToolUseIDs || $[40] !== lookups.resolvedToolUseIDs || $[41] !== param || $[42] !== shouldAnimate || $[43] !== t1) { - t2 = ; - $[37] = addMargin; - $[38] = advisorModel; - $[39] = lookups.erroredToolUseIDs; - $[40] = lookups.resolvedToolUseIDs; - $[41] = param; - $[42] = shouldAnimate; - $[43] = t1; - $[44] = t2; - } else { - t2 = $[44]; - } - return t2; - } - logError(new Error(`Unable to render server tool block: ${param.type}`)); - return null; + // In transcript mode with hidePastThinking, only show the last thinking block + const isLastThinking = + !lastThinkingBlockId || thinkingBlockId === lastThinkingBlockId + return ( + + ) + } + case 'server_tool_use': + case 'advisor_tool_result': + if (isAdvisorBlock(param)) { + return ( + + ) } + logError(new Error(`Unable to render server tool block: ${param.type}`)) + return null default: - { - logError(new Error(`Unable to render message type: ${param.type}`)); - return null; - } + logError(new Error(`Unable to render message type: ${param.type}`)) + return null } } + export function hasThinkingContent(m: { - type: string; - message?: { - content: Array<{ - type: string; - }>; - }; + type: string + message?: { content: Array<{ type: string }> } }): boolean { - if (m.type !== 'assistant' || !m.message) return false; - return m.message.content.some(b => b.type === 'thinking' || b.type === 'redacted_thinking'); + if (m.type !== 'assistant' || !m.message) return false + return m.message.content.some( + b => b.type === 'thinking' || b.type === 'redacted_thinking', + ) } /** Exported for testing */ export function areMessagePropsEqual(prev: Props, next: Props): boolean { - if (prev.message.uuid !== next.message.uuid) return false; + if (prev.message.uuid !== next.message.uuid) return false // Only re-render on lastThinkingBlockId change if this message actually // has thinking content — otherwise every message in scrollback re-renders // whenever streaming thinking starts/stops (CC-941). - if (prev.lastThinkingBlockId !== next.lastThinkingBlockId && hasThinkingContent(next.message as any)) { - return false; + if ( + prev.lastThinkingBlockId !== next.lastThinkingBlockId && + hasThinkingContent(next.message) + ) { + return false } // Verbose toggle changes thinking block visibility/expansion - if (prev.verbose !== next.verbose) return false; + if (prev.verbose !== next.verbose) return false // Only re-render if this message's "is latest bash output" status changed, // not when the global latestBashOutputUUID changes to a different message - const prevIsLatest = prev.latestBashOutputUUID === prev.message.uuid; - const nextIsLatest = next.latestBashOutputUUID === next.message.uuid; - if (prevIsLatest !== nextIsLatest) return false; - if (prev.isTranscriptMode !== next.isTranscriptMode) return false; + const prevIsLatest = prev.latestBashOutputUUID === prev.message.uuid + const nextIsLatest = next.latestBashOutputUUID === next.message.uuid + if (prevIsLatest !== nextIsLatest) return false + if (prev.isTranscriptMode !== next.isTranscriptMode) return false // containerWidth is an absolute number in the no-metadata path (wrapper // Box is skipped). Static messages must re-render on terminal resize. - if (prev.containerWidth !== next.containerWidth) return false; - if (prev.isStatic && next.isStatic) return true; - return false; + if (prev.containerWidth !== next.containerWidth) return false + if (prev.isStatic && next.isStatic) return true + return false } -export const Message = React.memo(MessageImpl, areMessagePropsEqual); + +export const Message = React.memo(MessageImpl, areMessagePropsEqual) diff --git a/src/components/MessageModel.tsx b/src/components/MessageModel.tsx index aca627f0f..a99f861e6 100644 --- a/src/components/MessageModel.tsx +++ b/src/components/MessageModel.tsx @@ -1,42 +1,30 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { stringWidth } from '../ink/stringWidth.js'; -import { Box, Text } from '../ink.js'; -import type { NormalizedMessage } from '../types/message.js'; +import React from 'react' +import { stringWidth } from '../ink/stringWidth.js' +import { Box, Text } from '../ink.js' +import type { NormalizedMessage } from '../types/message.js' + type Props = { - message: NormalizedMessage; - isTranscriptMode: boolean; -}; -export function MessageModel(t0) { - const $ = _c(5); - const { - message, - isTranscriptMode - } = t0; - const shouldShowModel = isTranscriptMode && message.type === "assistant" && message.message.model && message.message.content.some(_temp); + message: NormalizedMessage + isTranscriptMode: boolean +} + +export function MessageModel({ + message, + isTranscriptMode, +}: Props): React.ReactNode { + const shouldShowModel = + isTranscriptMode && + message.type === 'assistant' && + message.message.model && + message.message.content.some(c => c.type === 'text') + if (!shouldShowModel) { - return null; + return null } - const t1 = stringWidth(message.message.model) + 8; - let t2; - if ($[0] !== message.message.model) { - t2 = {message.message.model}; - $[0] = message.message.model; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== t1 || $[3] !== t2) { - t3 = {t2}; - $[2] = t1; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; -} -function _temp(c) { - return c.type === "text"; + + return ( + + {message.message.model} + + ) } diff --git a/src/components/MessageResponse.tsx b/src/components/MessageResponse.tsx index 8a23b29f5..f71d40ce8 100644 --- a/src/components/MessageResponse.tsx +++ b/src/components/MessageResponse.tsx @@ -1,77 +1,49 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useContext } from 'react'; -import { Box, NoSelect, Text } from '../ink.js'; -import { Ratchet } from './design-system/Ratchet.js'; +import * as React from 'react' +import { useContext } from 'react' +import { Box, NoSelect, Text } from '../ink.js' +import { Ratchet } from './design-system/Ratchet.js' + type Props = { - children: React.ReactNode; - height?: number; -}; -export function MessageResponse(t0) { - const $ = _c(8); - const { - children, - height - } = t0; - const isMessageResponse = useContext(MessageResponseContext); + children: React.ReactNode + height?: number +} + +export function MessageResponse({ children, height }: Props): React.ReactNode { + const isMessageResponse = useContext(MessageResponseContext) if (isMessageResponse) { - return children; + return children } - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {" "}⎿  ; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== children) { - t2 = {children}; - $[1] = children; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== height || $[4] !== t2) { - t3 = {t1}{t2}; - $[3] = height; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - const content = t3; + const content = ( + + + + {' '}⎿   + + + {children} + + + + ) if (height !== undefined) { - return content; + return content } - let t4; - if ($[6] !== content) { - t4 = {content}; - $[6] = content; - $[7] = t4; - } else { - t4 = $[7]; - } - return t4; + return {content} } // This is a context that is used to determine if the message response // is rendered as a descendant of another MessageResponse. We use it // to avoid rendering nested ⎿ characters. -const MessageResponseContext = React.createContext(false); -function MessageResponseProvider(t0) { - const $ = _c(2); - const { - children - } = t0; - let t1; - if ($[0] !== children) { - t1 = {children}; - $[0] = children; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; +const MessageResponseContext = React.createContext(false) + +function MessageResponseProvider({ + children, +}: { + children: React.ReactNode +}): React.ReactNode { + return ( + + {children} + + ) } diff --git a/src/components/MessageRow.tsx b/src/components/MessageRow.tsx index fdbb19e73..e42fb9d96 100644 --- a/src/components/MessageRow.tsx +++ b/src/components/MessageRow.tsx @@ -1,44 +1,52 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import type { Command } from '../commands.js'; -import { Box } from '../ink.js'; -import type { Screen } from '../screens/REPL.js'; -import type { Tools } from '../Tool.js'; -import type { RenderableMessage } from '../types/message.js'; -import { getDisplayMessageFromCollapsed, getToolSearchOrReadInfo, getToolUseIdsFromCollapsedGroup, hasAnyToolInProgress } from '../utils/collapseReadSearch.js'; -import { type buildMessageLookups, EMPTY_STRING_SET, getProgressMessagesFromLookup, getSiblingToolUseIDsFromLookup, getToolUseID } from '../utils/messages.js'; -import { hasThinkingContent, Message } from './Message.js'; -import { MessageModel } from './MessageModel.js'; -import { shouldRenderStatically } from './Messages.js'; -import { MessageTimestamp } from './MessageTimestamp.js'; +import * as React from 'react' +import type { Command } from '../commands.js' +import { Box } from '../ink.js' +import type { Screen } from '../screens/REPL.js' +import type { Tools } from '../Tool.js' +import type { RenderableMessage } from '../types/message.js' +import { + getDisplayMessageFromCollapsed, + getToolSearchOrReadInfo, + getToolUseIdsFromCollapsedGroup, + hasAnyToolInProgress, +} from '../utils/collapseReadSearch.js' +import { + type buildMessageLookups, + EMPTY_STRING_SET, + getProgressMessagesFromLookup, + getSiblingToolUseIDsFromLookup, + getToolUseID, +} from '../utils/messages.js' +import { hasThinkingContent, Message } from './Message.js' +import { MessageModel } from './MessageModel.js' +import { shouldRenderStatically } from './Messages.js' +import { MessageTimestamp } from './MessageTimestamp.js' +import { OffscreenFreeze } from './OffscreenFreeze.js' -/** Narrowed content block shape used for type assertions on MessageContent arrays. */ -type ContentBlockLike = { type: string; name?: string; input?: unknown; id?: string; text?: string }; -import { OffscreenFreeze } from './OffscreenFreeze.js'; export type Props = { - message: RenderableMessage; + message: RenderableMessage /** Whether the previous message in renderableMessages is also a user message. */ - isUserContinuation: boolean; + isUserContinuation: boolean /** * Whether there is non-skippable content after this message in renderableMessages. * Only needs to be accurate for `collapsed_read_search` messages — used to decide * if the collapsed group spinner should stay active. Pass `false` otherwise. */ - hasContentAfter: boolean; - tools: Tools; - commands: Command[]; - verbose: boolean; - inProgressToolUseIDs: Set; - streamingToolUseIDs: Set; - screen: Screen; - canAnimate: boolean; - onOpenRateLimitOptions?: () => void; - lastThinkingBlockId: string | null; - latestBashOutputUUID: string | null; - columns: number; - isLoading: boolean; - lookups: ReturnType; -}; + hasContentAfter: boolean + tools: Tools + commands: Command[] + verbose: boolean + inProgressToolUseIDs: Set + streamingToolUseIDs: Set + screen: Screen + canAnimate: boolean + onOpenRateLimitOptions?: () => void + lastThinkingBlockId: string | null + latestBashOutputUUID: string | null + columns: number + isLoading: boolean + lookups: ReturnType +} /** * Scans forward from `index+1` to check if any "real" content follows. Used to @@ -50,290 +58,244 @@ export type Props = { * to each MessageRow (which React Compiler would pin in the fiber's memoCache, * accumulating every historical version of the array ≈ 1-2MB over a 7-turn session). */ -export function hasContentAfterIndex(messages: RenderableMessage[], index: number, tools: Tools, streamingToolUseIDs: Set): boolean { +export function hasContentAfterIndex( + messages: RenderableMessage[], + index: number, + tools: Tools, + streamingToolUseIDs: Set, +): boolean { for (let i = index + 1; i < messages.length; i++) { - const msg = messages[i]; + const msg = messages[i] if (msg?.type === 'assistant') { - const content = (msg.message.content as ContentBlockLike[])[0]; - if (content?.type === 'thinking' || content?.type === 'redacted_thinking') { - continue; + const content = msg.message.content[0] + if ( + content?.type === 'thinking' || + content?.type === 'redacted_thinking' + ) { + continue } if (content?.type === 'tool_use') { - if (getToolSearchOrReadInfo(content.name!, content.input, tools).isCollapsible) { - continue; + if ( + getToolSearchOrReadInfo(content.name, content.input, tools) + .isCollapsible + ) { + continue } // Non-collapsible tool uses appear in syntheticStreamingToolUseMessages // before their ID is added to inProgressToolUseIDs. Skip while streaming // to avoid briefly finalizing the read group. - if (streamingToolUseIDs.has(content.id!)) { - continue; + if (streamingToolUseIDs.has(content.id)) { + continue } } - return true; + return true } if (msg?.type === 'system' || msg?.type === 'attachment') { - continue; + continue } // Tool results arrive while the collapsed group is still being built if (msg?.type === 'user') { - const content = (msg.message.content as ContentBlockLike[])[0]; + const content = msg.message.content[0] if (content?.type === 'tool_result') { - continue; + continue } } // Collapsible grouped_tool_use messages arrive transiently before being // merged into the current collapsed group on the next render cycle if (msg?.type === 'grouped_tool_use') { - const firstInput = (msg.messages[0]?.message.content as ContentBlockLike[])?.[0]?.input; - if (getToolSearchOrReadInfo(msg.toolName, firstInput, tools).isCollapsible) { - continue; + const firstInput = msg.messages[0]?.message.content[0]?.input + if ( + getToolSearchOrReadInfo(msg.toolName, firstInput, tools).isCollapsible + ) { + continue } } - return true; + return true } - return false; + return false } -function MessageRowImpl(t0) { - const $ = _c(64); - const { - message: msg, - isUserContinuation, - hasContentAfter, - tools, - commands, - verbose, - inProgressToolUseIDs, + +function MessageRowImpl({ + message: msg, + isUserContinuation, + hasContentAfter, + tools, + commands, + verbose, + inProgressToolUseIDs, + streamingToolUseIDs, + screen, + canAnimate, + onOpenRateLimitOptions, + lastThinkingBlockId, + latestBashOutputUUID, + columns, + isLoading, + lookups, +}: Props): React.ReactNode { + const isTranscriptMode = screen === 'transcript' + const isGrouped = msg.type === 'grouped_tool_use' + const isCollapsed = msg.type === 'collapsed_read_search' + + // A collapsed group is "active" (grey dot, present tense "Reading…") when its tools + // are still executing OR when the overall query is still running with nothing after it. + // hasAnyToolInProgress takes priority: if tools are running, always show active regardless + // of what else is in the message list (avoids false finalization during parallel execution). + const isActiveCollapsedGroup = + isCollapsed && + (hasAnyToolInProgress(msg, inProgressToolUseIDs) || + (isLoading && !hasContentAfter)) + + const displayMsg = isGrouped + ? msg.displayMessage + : isCollapsed + ? getDisplayMessageFromCollapsed(msg) + : msg + + const progressMessagesForMessage = + isGrouped || isCollapsed ? [] : getProgressMessagesFromLookup(msg, lookups) + + const siblingToolUseIDs = + isGrouped || isCollapsed + ? EMPTY_STRING_SET + : getSiblingToolUseIDsFromLookup(msg, lookups) + + const isStatic = shouldRenderStatically( + msg, streamingToolUseIDs, + inProgressToolUseIDs, + siblingToolUseIDs, screen, - canAnimate, - onOpenRateLimitOptions, - lastThinkingBlockId, - latestBashOutputUUID, - columns, - isLoading, - lookups - } = t0; - const isTranscriptMode = screen === "transcript"; - const isGrouped = msg.type === "grouped_tool_use"; - const isCollapsed = msg.type === "collapsed_read_search"; - let t1; - if ($[0] !== hasContentAfter || $[1] !== inProgressToolUseIDs || $[2] !== isCollapsed || $[3] !== isLoading || $[4] !== msg) { - t1 = isCollapsed && (hasAnyToolInProgress(msg, inProgressToolUseIDs) || isLoading && !hasContentAfter); - $[0] = hasContentAfter; - $[1] = inProgressToolUseIDs; - $[2] = isCollapsed; - $[3] = isLoading; - $[4] = msg; - $[5] = t1; - } else { - t1 = $[5]; - } - const isActiveCollapsedGroup = t1; - let t2; - if ($[6] !== isCollapsed || $[7] !== isGrouped || $[8] !== msg) { - t2 = isGrouped ? msg.displayMessage : isCollapsed ? getDisplayMessageFromCollapsed(msg) : msg; - $[6] = isCollapsed; - $[7] = isGrouped; - $[8] = msg; - $[9] = t2; - } else { - t2 = $[9]; - } - const displayMsg = t2; - let t3; - if ($[10] !== isCollapsed || $[11] !== isGrouped || $[12] !== lookups || $[13] !== msg) { - t3 = isGrouped || isCollapsed ? [] : getProgressMessagesFromLookup(msg, lookups); - $[10] = isCollapsed; - $[11] = isGrouped; - $[12] = lookups; - $[13] = msg; - $[14] = t3; - } else { - t3 = $[14]; - } - const progressMessagesForMessage = t3; - let t4; - if ($[15] !== inProgressToolUseIDs || $[16] !== isCollapsed || $[17] !== isGrouped || $[18] !== lookups || $[19] !== msg || $[20] !== screen || $[21] !== streamingToolUseIDs) { - const siblingToolUseIDs = isGrouped || isCollapsed ? EMPTY_STRING_SET : getSiblingToolUseIDsFromLookup(msg, lookups); - t4 = shouldRenderStatically(msg, streamingToolUseIDs, inProgressToolUseIDs, siblingToolUseIDs, screen, lookups); - $[15] = inProgressToolUseIDs; - $[16] = isCollapsed; - $[17] = isGrouped; - $[18] = lookups; - $[19] = msg; - $[20] = screen; - $[21] = streamingToolUseIDs; - $[22] = t4; - } else { - t4 = $[22]; - } - const isStatic = t4; - let shouldAnimate = false; + lookups, + ) + + let shouldAnimate = false if (canAnimate) { if (isGrouped) { - let t5; - if ($[23] !== inProgressToolUseIDs || $[24] !== msg.messages) { - let t6; - if ($[26] !== inProgressToolUseIDs) { - t6 = m => { - const content = m.message.content[0]; - return content?.type === "tool_use" && inProgressToolUseIDs.has(content.id); - }; - $[26] = inProgressToolUseIDs; - $[27] = t6; - } else { - t6 = $[27]; - } - t5 = msg.messages.some(t6); - $[23] = inProgressToolUseIDs; - $[24] = msg.messages; - $[25] = t5; - } else { - t5 = $[25]; - } - shouldAnimate = t5; + shouldAnimate = msg.messages.some(m => { + const content = m.message.content[0] + return ( + content?.type === 'tool_use' && inProgressToolUseIDs.has(content.id) + ) + }) + } else if (isCollapsed) { + shouldAnimate = hasAnyToolInProgress(msg, inProgressToolUseIDs) } else { - if (isCollapsed) { - let t5; - if ($[28] !== inProgressToolUseIDs || $[29] !== msg) { - t5 = hasAnyToolInProgress(msg, inProgressToolUseIDs); - $[28] = inProgressToolUseIDs; - $[29] = msg; - $[30] = t5; - } else { - t5 = $[30]; - } - shouldAnimate = t5; - } else { - let t5; - if ($[31] !== inProgressToolUseIDs || $[32] !== msg) { - const toolUseID = getToolUseID(msg); - t5 = !toolUseID || inProgressToolUseIDs.has(toolUseID); - $[31] = inProgressToolUseIDs; - $[32] = msg; - $[33] = t5; - } else { - t5 = $[33]; - } - shouldAnimate = t5; - } + const toolUseID = getToolUseID(msg) + shouldAnimate = !toolUseID || inProgressToolUseIDs.has(toolUseID) } } - let t5; - if ($[34] !== displayMsg || $[35] !== isTranscriptMode) { - t5 = isTranscriptMode && displayMsg.type === "assistant" && displayMsg.message.content.some(_temp) && (displayMsg.timestamp || displayMsg.message.model); - $[34] = displayMsg; - $[35] = isTranscriptMode; - $[36] = t5; - } else { - t5 = $[36]; - } - const hasMetadata = t5; - const t6 = !hasMetadata; - const t7 = hasMetadata ? undefined : columns; - let t8; - if ($[37] !== commands || $[38] !== inProgressToolUseIDs || $[39] !== isActiveCollapsedGroup || $[40] !== isStatic || $[41] !== isTranscriptMode || $[42] !== isUserContinuation || $[43] !== lastThinkingBlockId || $[44] !== latestBashOutputUUID || $[45] !== lookups || $[46] !== msg || $[47] !== onOpenRateLimitOptions || $[48] !== progressMessagesForMessage || $[49] !== shouldAnimate || $[50] !== t6 || $[51] !== t7 || $[52] !== tools || $[53] !== verbose) { - t8 = ; - $[37] = commands; - $[38] = inProgressToolUseIDs; - $[39] = isActiveCollapsedGroup; - $[40] = isStatic; - $[41] = isTranscriptMode; - $[42] = isUserContinuation; - $[43] = lastThinkingBlockId; - $[44] = latestBashOutputUUID; - $[45] = lookups; - $[46] = msg; - $[47] = onOpenRateLimitOptions; - $[48] = progressMessagesForMessage; - $[49] = shouldAnimate; - $[50] = t6; - $[51] = t7; - $[52] = tools; - $[53] = verbose; - $[54] = t8; - } else { - t8 = $[54]; - } - const messageEl = t8; + + const hasMetadata = + isTranscriptMode && + displayMsg.type === 'assistant' && + displayMsg.message.content.some(c => c.type === 'text') && + (displayMsg.timestamp || displayMsg.message.model) + + const messageEl = ( + + ) + // OffscreenFreeze: the outer React.memo already bails for static messages, + // so this only wraps rows that DO re-render — in-progress tools, collapsed + // read/search spinners, bash elapsed timers. When those rows have scrolled + // into terminal scrollback (non-fullscreen external builds), any content + // change forces log-update.ts into a full terminal reset per tick. Freezing + // returns the cached element ref so React bails and produces zero diff. if (!hasMetadata) { - let t9; - if ($[55] !== messageEl) { - t9 = {messageEl}; - $[55] = messageEl; - $[56] = t9; - } else { - t9 = $[56]; - } - return t9; + return {messageEl} } - let t9; - if ($[57] !== displayMsg || $[58] !== isTranscriptMode) { - t9 = ; - $[57] = displayMsg; - $[58] = isTranscriptMode; - $[59] = t9; - } else { - t9 = $[59]; - } - let t10; - if ($[60] !== columns || $[61] !== messageEl || $[62] !== t9) { - t10 = {t9}{messageEl}; - $[60] = columns; - $[61] = messageEl; - $[62] = t9; - $[63] = t10; - } else { - t10 = $[63]; - } - return t10; + // Margin on children, not here — else null items (hook_success etc.) get phantom 1-row spacing. + return ( + + + + + + + {messageEl} + + + ) } /** * Checks if a message is "streaming" - i.e., its content may still be changing. * Exported for testing. */ -function _temp(c) { - return c.type === "text"; -} -export function isMessageStreaming(msg: RenderableMessage, streamingToolUseIDs: Set): boolean { +export function isMessageStreaming( + msg: RenderableMessage, + streamingToolUseIDs: Set, +): boolean { if (msg.type === 'grouped_tool_use') { return msg.messages.some(m => { - const content = (m.message.content as ContentBlockLike[])[0]; - return content?.type === 'tool_use' && streamingToolUseIDs.has(content.id!); - }); + const content = m.message.content[0] + return content?.type === 'tool_use' && streamingToolUseIDs.has(content.id) + }) } if (msg.type === 'collapsed_read_search') { - const toolIds = getToolUseIdsFromCollapsedGroup(msg); - return toolIds.some(id => streamingToolUseIDs.has(id)); + const toolIds = getToolUseIdsFromCollapsedGroup(msg) + return toolIds.some(id => streamingToolUseIDs.has(id)) } - const toolUseID = getToolUseID(msg); - return !!toolUseID && streamingToolUseIDs.has(toolUseID); + const toolUseID = getToolUseID(msg) + return !!toolUseID && streamingToolUseIDs.has(toolUseID) } /** * Checks if all tools in a message are resolved. * Exported for testing. */ -export function allToolsResolved(msg: RenderableMessage, resolvedToolUseIDs: Set): boolean { +export function allToolsResolved( + msg: RenderableMessage, + resolvedToolUseIDs: Set, +): boolean { if (msg.type === 'grouped_tool_use') { return msg.messages.every(m => { - const content = (m.message.content as ContentBlockLike[])[0]; - return content?.type === 'tool_use' && resolvedToolUseIDs.has(content.id!); - }); + const content = m.message.content[0] + return content?.type === 'tool_use' && resolvedToolUseIDs.has(content.id) + }) } if (msg.type === 'collapsed_read_search') { - const toolIds = getToolUseIdsFromCollapsedGroup(msg); - return toolIds.every(id => resolvedToolUseIDs.has(id)); + const toolIds = getToolUseIdsFromCollapsedGroup(msg) + return toolIds.every(id => resolvedToolUseIDs.has(id)) } if (msg.type === 'assistant') { - const block = (msg.message.content as ContentBlockLike[])[0]; + const block = msg.message.content[0] if (block?.type === 'server_tool_use') { - return resolvedToolUseIDs.has(block.id!); + return resolvedToolUseIDs.has(block.id) } } - const toolUseID = getToolUseID(msg); - return !toolUseID || resolvedToolUseIDs.has(toolUseID); + const toolUseID = getToolUseID(msg) + return !toolUseID || resolvedToolUseIDs.has(toolUseID) } /** @@ -344,42 +306,52 @@ export function allToolsResolved(msg: RenderableMessage, resolvedToolUseIDs: Set */ export function areMessageRowPropsEqual(prev: Props, next: Props): boolean { // Different message reference = content may have changed, must re-render - if (prev.message !== next.message) return false; + if (prev.message !== next.message) return false // Screen mode change = re-render - if (prev.screen !== next.screen) return false; + if (prev.screen !== next.screen) return false // Verbose toggle changes thinking block visibility - if (prev.verbose !== next.verbose) return false; + if (prev.verbose !== next.verbose) return false // collapsed_read_search is never static in prompt mode (matches shouldRenderStatically) - if (prev.message.type === 'collapsed_read_search' && next.screen !== 'transcript') { - return false; + if ( + prev.message.type === 'collapsed_read_search' && + next.screen !== 'transcript' + ) { + return false } // Width change affects Box layout - if (prev.columns !== next.columns) return false; + if (prev.columns !== next.columns) return false // latestBashOutputUUID affects rendering (full vs truncated output) - const prevIsLatestBash = prev.latestBashOutputUUID === prev.message.uuid; - const nextIsLatestBash = next.latestBashOutputUUID === next.message.uuid; - if (prevIsLatestBash !== nextIsLatestBash) return false; + const prevIsLatestBash = prev.latestBashOutputUUID === prev.message.uuid + const nextIsLatestBash = next.latestBashOutputUUID === next.message.uuid + if (prevIsLatestBash !== nextIsLatestBash) return false // lastThinkingBlockId affects thinking block visibility — but only for // messages that HAVE thinking content. Checking unconditionally busts the // memo for every scrollback message whenever thinking starts/stops (CC-941). - if (prev.lastThinkingBlockId !== next.lastThinkingBlockId && hasThinkingContent(next.message as Parameters[0])) { - return false; + if ( + prev.lastThinkingBlockId !== next.lastThinkingBlockId && + hasThinkingContent(next.message) + ) { + return false } // Check if this message is still "in flight" - const isStreaming = isMessageStreaming(prev.message, prev.streamingToolUseIDs); - const isResolved = allToolsResolved(prev.message, prev.lookups.resolvedToolUseIDs); + const isStreaming = isMessageStreaming(prev.message, prev.streamingToolUseIDs) + const isResolved = allToolsResolved( + prev.message, + prev.lookups.resolvedToolUseIDs, + ) // Only bail out for truly static messages - if (isStreaming || !isResolved) return false; + if (isStreaming || !isResolved) return false // Static message - safe to skip re-render - return true; + return true } -export const MessageRow = React.memo(MessageRowImpl, areMessageRowPropsEqual); + +export const MessageRow = React.memo(MessageRowImpl, areMessageRowPropsEqual) diff --git a/src/components/MessageSelector.tsx b/src/components/MessageSelector.tsx index 8ee089025..b372a4b5d 100644 --- a/src/components/MessageSelector.tsx +++ b/src/components/MessageSelector.tsx @@ -1,48 +1,96 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ContentBlockParam, TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import { randomUUID, type UUID } from 'crypto'; -import figures from 'figures'; -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { useAppState } from 'src/state/AppState.js'; -import { type DiffStats, fileHistoryCanRestore, fileHistoryEnabled, fileHistoryGetDiffStats } from 'src/utils/fileHistory.js'; -import { logError } from 'src/utils/log.js'; -import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Text } from '../ink.js'; -import { useKeybinding, useKeybindings } from '../keybindings/useKeybinding.js'; -import type { Message, PartialCompactDirection, UserMessage } from '../types/message.js'; -import { stripDisplayTags } from '../utils/displayTags.js'; -import { createUserMessage, extractTag, isEmptyMessageText, isSyntheticMessage, isToolUseResultMessage } from '../utils/messages.js'; -import { type OptionWithDescription, Select } from './CustomSelect/select.js'; -import { Spinner } from './Spinner.js'; +import type { + ContentBlockParam, + TextBlockParam, +} from '@anthropic-ai/sdk/resources/index.mjs' +import { randomUUID, type UUID } from 'crypto' +import figures from 'figures' +import * as React from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { useAppState } from 'src/state/AppState.js' +import { + type DiffStats, + fileHistoryCanRestore, + fileHistoryEnabled, + fileHistoryGetDiffStats, +} from 'src/utils/fileHistory.js' +import { logError } from 'src/utils/log.js' +import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Text } from '../ink.js' +import { useKeybinding, useKeybindings } from '../keybindings/useKeybinding.js' +import type { + Message, + PartialCompactDirection, + UserMessage, +} from '../types/message.js' +import { stripDisplayTags } from '../utils/displayTags.js' +import { + createUserMessage, + extractTag, + isEmptyMessageText, + isSyntheticMessage, + isToolUseResultMessage, +} from '../utils/messages.js' +import { type OptionWithDescription, Select } from './CustomSelect/select.js' +import { Spinner } from './Spinner.js' + function isTextBlock(block: ContentBlockParam): block is TextBlockParam { - return block.type === 'text'; + return block.type === 'text' } -import * as path from 'path'; -import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; -import type { FileEditOutput } from 'src/tools/FileEditTool/types.js'; -import type { Output as FileWriteToolOutput } from 'src/tools/FileWriteTool/FileWriteTool.js'; -import { BASH_STDERR_TAG, BASH_STDOUT_TAG, COMMAND_MESSAGE_TAG, LOCAL_COMMAND_STDERR_TAG, LOCAL_COMMAND_STDOUT_TAG, TASK_NOTIFICATION_TAG, TEAMMATE_MESSAGE_TAG, TICK_TAG } from '../constants/xml.js'; -import { count } from '../utils/array.js'; -import { formatRelativeTimeAgo, truncate } from '../utils/format.js'; -import type { Theme } from '../utils/theme.js'; -import { Divider } from './design-system/Divider.js'; -type RestoreOption = 'both' | 'conversation' | 'code' | 'summarize' | 'summarize_up_to' | 'nevermind'; -function isSummarizeOption(option: RestoreOption | null): option is 'summarize' | 'summarize_up_to' { - return option === 'summarize' || option === 'summarize_up_to'; + +import * as path from 'path' +import { useTerminalSize } from 'src/hooks/useTerminalSize.js' +import type { FileEditOutput } from 'src/tools/FileEditTool/types.js' +import type { Output as FileWriteToolOutput } from 'src/tools/FileWriteTool/FileWriteTool.js' +import { + BASH_STDERR_TAG, + BASH_STDOUT_TAG, + COMMAND_MESSAGE_TAG, + LOCAL_COMMAND_STDERR_TAG, + LOCAL_COMMAND_STDOUT_TAG, + TASK_NOTIFICATION_TAG, + TEAMMATE_MESSAGE_TAG, + TICK_TAG, +} from '../constants/xml.js' +import { count } from '../utils/array.js' +import { formatRelativeTimeAgo, truncate } from '../utils/format.js' +import type { Theme } from '../utils/theme.js' +import { Divider } from './design-system/Divider.js' + +type RestoreOption = + | 'both' + | 'conversation' + | 'code' + | 'summarize' + | 'summarize_up_to' + | 'nevermind' + +function isSummarizeOption( + option: RestoreOption | null, +): option is 'summarize' | 'summarize_up_to' { + return option === 'summarize' || option === 'summarize_up_to' } + type Props = { - messages: Message[]; - onPreRestore: () => void; - onRestoreMessage: (message: UserMessage) => Promise; - onRestoreCode: (message: UserMessage) => Promise; - onSummarize: (message: UserMessage, feedback?: string, direction?: PartialCompactDirection) => Promise; - onClose: () => void; + messages: Message[] + onPreRestore: () => void + onRestoreMessage: (message: UserMessage) => Promise + onRestoreCode: (message: UserMessage) => Promise + onSummarize: ( + message: UserMessage, + feedback?: string, + direction?: PartialCompactDirection, + ) => Promise + onClose: () => void /** Skip pick-list, land on confirm. Caller ran skip-check first. Esc closes fully (no back-to-list). */ - preselectedMessage?: UserMessage; -}; -const MAX_VISIBLE_MESSAGES = 7; + preselectedMessage?: UserMessage +} + +const MAX_VISIBLE_MESSAGES = 7 + export function MessageSelector({ messages, onPreRestore, @@ -50,745 +98,845 @@ export function MessageSelector({ onRestoreCode, onSummarize, onClose, - preselectedMessage + preselectedMessage, }: Props): React.ReactNode { - const fileHistory = useAppState(s => s.fileHistory); - const [error, setError] = useState(undefined); - const isFileHistoryEnabled = fileHistoryEnabled(); + const fileHistory = useAppState(s => s.fileHistory) + const [error, setError] = useState(undefined) + const isFileHistoryEnabled = fileHistoryEnabled() // Add current prompt as a virtual message - const currentUUID = useMemo(randomUUID, []); - const messageOptions = useMemo(() => [...messages.filter(selectableUserMessagesFilter), { - ...createUserMessage({ - content: '' - }), - uuid: currentUUID - } as UserMessage], [messages, currentUUID]); - const [selectedIndex, setSelectedIndex] = useState(messageOptions.length - 1); + const currentUUID = useMemo(randomUUID, []) + const messageOptions = useMemo( + () => [ + ...messages.filter(selectableUserMessagesFilter), + { + ...createUserMessage({ + content: '', + }), + uuid: currentUUID, + } as UserMessage, + ], + [messages, currentUUID], + ) + const [selectedIndex, setSelectedIndex] = useState(messageOptions.length - 1) // Orient the selected message as the middle of the visible options - const firstVisibleIndex = Math.max(0, Math.min(selectedIndex - Math.floor(MAX_VISIBLE_MESSAGES / 2), messageOptions.length - MAX_VISIBLE_MESSAGES)); - const hasMessagesToSelect = messageOptions.length > 1; - const [messageToRestore, setMessageToRestore] = useState(preselectedMessage); - const [diffStatsForRestore, setDiffStatsForRestore] = useState(undefined); + const firstVisibleIndex = Math.max( + 0, + Math.min( + selectedIndex - Math.floor(MAX_VISIBLE_MESSAGES / 2), + messageOptions.length - MAX_VISIBLE_MESSAGES, + ), + ) + + const hasMessagesToSelect = messageOptions.length > 1 + + const [messageToRestore, setMessageToRestore] = useState< + UserMessage | undefined + >(preselectedMessage) + const [diffStatsForRestore, setDiffStatsForRestore] = useState< + DiffStats | undefined + >(undefined) + useEffect(() => { - if (!preselectedMessage || !isFileHistoryEnabled) return; - let cancelled = false; - void fileHistoryGetDiffStats(fileHistory, preselectedMessage.uuid).then(stats => { - if (!cancelled) setDiffStatsForRestore(stats); - }); + if (!preselectedMessage || !isFileHistoryEnabled) return + let cancelled = false + void fileHistoryGetDiffStats(fileHistory, preselectedMessage.uuid).then( + stats => { + if (!cancelled) setDiffStatsForRestore(stats) + }, + ) return () => { - cancelled = true; - }; - }, [preselectedMessage, isFileHistoryEnabled, fileHistory]); - const [isRestoring, setIsRestoring] = useState(false); - const [restoringOption, setRestoringOption] = useState(null); - const [selectedRestoreOption, setSelectedRestoreOption] = useState('both'); + cancelled = true + } + }, [preselectedMessage, isFileHistoryEnabled, fileHistory]) + + const [isRestoring, setIsRestoring] = useState(false) + const [restoringOption, setRestoringOption] = useState( + null, + ) + const [selectedRestoreOption, setSelectedRestoreOption] = + useState('both') // Per-option feedback state; Select's internal inputValues Map persists // per-option text independently, so sharing one variable would desync. - const [summarizeFromFeedback, setSummarizeFromFeedback] = useState(''); - const [summarizeUpToFeedback, setSummarizeUpToFeedback] = useState(''); + const [summarizeFromFeedback, setSummarizeFromFeedback] = useState('') + const [summarizeUpToFeedback, setSummarizeUpToFeedback] = useState('') // Generate options with summarize as input type for inline context - function getRestoreOptions(canRestoreCode: boolean): OptionWithDescription[] { - const baseOptions: OptionWithDescription[] = canRestoreCode ? [{ - value: 'both', - label: 'Restore code and conversation' - }, { - value: 'conversation', - label: 'Restore conversation' - }, { - value: 'code', - label: 'Restore code' - }] : [{ - value: 'conversation', - label: 'Restore conversation' - }]; + function getRestoreOptions( + canRestoreCode: boolean, + ): OptionWithDescription[] { + const baseOptions: OptionWithDescription[] = canRestoreCode + ? [ + { value: 'both', label: 'Restore code and conversation' }, + { value: 'conversation', label: 'Restore conversation' }, + { value: 'code', label: 'Restore code' }, + ] + : [{ value: 'conversation', label: 'Restore conversation' }] + const summarizeInputProps = { type: 'input' as const, placeholder: 'add context (optional)', initialValue: '', allowEmptySubmitToCancel: true, showLabelWithValue: true, - labelValueSeparator: ': ' - }; + labelValueSeparator: ': ', + } baseOptions.push({ value: 'summarize', label: 'Summarize from here', ...summarizeInputProps, - onChange: setSummarizeFromFeedback - }); - if ((process.env.USER_TYPE) === 'ant') { + onChange: setSummarizeFromFeedback, + }) + if (process.env.USER_TYPE === 'ant') { baseOptions.push({ value: 'summarize_up_to', label: 'Summarize up to here', ...summarizeInputProps, - onChange: setSummarizeUpToFeedback - }); + onChange: setSummarizeUpToFeedback, + }) } - baseOptions.push({ - value: 'nevermind', - label: 'Never mind' - }); - return baseOptions; + + baseOptions.push({ value: 'nevermind', label: 'Never mind' }) + return baseOptions } // Log when selector is opened useEffect(() => { - logEvent('tengu_message_selector_opened', {}); - }, []); + logEvent('tengu_message_selector_opened', {}) + }, []) // Helper to restore conversation without confirmation async function restoreConversationDirectly(message: UserMessage) { - onPreRestore(); - setIsRestoring(true); + onPreRestore() + setIsRestoring(true) try { - await onRestoreMessage(message); - setIsRestoring(false); - onClose(); - } catch (error_0) { - logError(error_0 as Error); - setIsRestoring(false); - setError(`Failed to restore the conversation:\n${error_0}`); + await onRestoreMessage(message) + setIsRestoring(false) + onClose() + } catch (error) { + logError(error as Error) + setIsRestoring(false) + setError(`Failed to restore the conversation:\n${error}`) } } - async function handleSelect(message_0: UserMessage) { - const index = messages.indexOf(message_0); - const indexFromEnd = messages.length - 1 - index; + + async function handleSelect(message: UserMessage) { + const index = messages.indexOf(message) + const indexFromEnd = messages.length - 1 - index + logEvent('tengu_message_selector_selected', { index_from_end: indexFromEnd, - message_type: message_0.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - is_current_prompt: false - }); + message_type: + message.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + is_current_prompt: false, + }) // Do nothing if the message is not found - if (!messages.includes(message_0)) { - onClose(); - return; + if (!messages.includes(message)) { + onClose() + return } + if (!isFileHistoryEnabled) { - await restoreConversationDirectly(message_0); - return; + await restoreConversationDirectly(message) + return } - const diffStats = await fileHistoryGetDiffStats(fileHistory, message_0.uuid); - setMessageToRestore(message_0); - setDiffStatsForRestore(diffStats); + + const diffStats = await fileHistoryGetDiffStats(fileHistory, message.uuid) + setMessageToRestore(message) + setDiffStatsForRestore(diffStats) } + async function onSelectRestoreOption(option: RestoreOption) { logEvent('tengu_message_selector_restore_option_selected', { - option: option as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + option: + option as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) if (!messageToRestore) { - setError('Message not found.'); - return; + setError('Message not found.') + return } if (option === 'nevermind') { - if (preselectedMessage) onClose();else setMessageToRestore(undefined); - return; + if (preselectedMessage) onClose() + else setMessageToRestore(undefined) + return } + if (isSummarizeOption(option)) { - onPreRestore(); - setIsRestoring(true); - setRestoringOption(option); - setError(undefined); + onPreRestore() + setIsRestoring(true) + setRestoringOption(option) + setError(undefined) try { - const direction = option === 'summarize_up_to' ? 'up_to' : 'from'; - const feedback = (direction === 'up_to' ? summarizeUpToFeedback : summarizeFromFeedback).trim() || undefined; - await onSummarize(messageToRestore, feedback, direction); - setIsRestoring(false); - setRestoringOption(null); - setMessageToRestore(undefined); - onClose(); - } catch (error_1) { - logError(error_1 as Error); - setIsRestoring(false); - setRestoringOption(null); - setMessageToRestore(undefined); - setError(`Failed to summarize:\n${error_1}`); + const direction = option === 'summarize_up_to' ? 'up_to' : 'from' + const feedback = + (direction === 'up_to' + ? summarizeUpToFeedback + : summarizeFromFeedback + ).trim() || undefined + await onSummarize(messageToRestore, feedback, direction) + setIsRestoring(false) + setRestoringOption(null) + setMessageToRestore(undefined) + onClose() + } catch (error) { + logError(error as Error) + setIsRestoring(false) + setRestoringOption(null) + setMessageToRestore(undefined) + setError(`Failed to summarize:\n${error}`) } - return; + return } - onPreRestore(); - setIsRestoring(true); - setError(undefined); - let codeError: Error | null = null; - let conversationError: Error | null = null; + + onPreRestore() + setIsRestoring(true) + setError(undefined) + + let codeError: Error | null = null + let conversationError: Error | null = null + if (option === 'code' || option === 'both') { try { - await onRestoreCode(messageToRestore); - } catch (error_2) { - codeError = error_2 as Error; - logError(codeError); + await onRestoreCode(messageToRestore) + } catch (error) { + codeError = error as Error + logError(codeError) } } + if (option === 'conversation' || option === 'both') { try { - await onRestoreMessage(messageToRestore); - } catch (error_3) { - conversationError = error_3 as Error; - logError(conversationError); + await onRestoreMessage(messageToRestore) + } catch (error) { + conversationError = error as Error + logError(conversationError) } } - setIsRestoring(false); - setMessageToRestore(undefined); + + setIsRestoring(false) + setMessageToRestore(undefined) // Handle errors if (conversationError && codeError) { - setError(`Failed to restore the conversation and code:\n${conversationError}\n${codeError}`); + setError( + `Failed to restore the conversation and code:\n${conversationError}\n${codeError}`, + ) } else if (conversationError) { - setError(`Failed to restore the conversation:\n${conversationError}`); + setError(`Failed to restore the conversation:\n${conversationError}`) } else if (codeError) { - setError(`Failed to restore the code:\n${codeError}`); + setError(`Failed to restore the code:\n${codeError}`) } else { // Success - close the selector - onClose(); + onClose() } } - const exitState = useExitOnCtrlCDWithKeybindings(); + + const exitState = useExitOnCtrlCDWithKeybindings() + const handleEscape = useCallback(() => { if (messageToRestore && !preselectedMessage) { // Go back to message list instead of closing entirely - setMessageToRestore(undefined); - return; + setMessageToRestore(undefined) + return } - logEvent('tengu_message_selector_cancelled', {}); - onClose(); - }, [onClose, messageToRestore, preselectedMessage]); - const moveUp = useCallback(() => setSelectedIndex(prev => Math.max(0, prev - 1)), []); - const moveDown = useCallback(() => setSelectedIndex(prev_0 => Math.min(messageOptions.length - 1, prev_0 + 1)), [messageOptions.length]); - const jumpToTop = useCallback(() => setSelectedIndex(0), []); - const jumpToBottom = useCallback(() => setSelectedIndex(messageOptions.length - 1), [messageOptions.length]); + logEvent('tengu_message_selector_cancelled', {}) + onClose() + }, [onClose, messageToRestore, preselectedMessage]) + + const moveUp = useCallback( + () => setSelectedIndex(prev => Math.max(0, prev - 1)), + [], + ) + const moveDown = useCallback( + () => + setSelectedIndex(prev => Math.min(messageOptions.length - 1, prev + 1)), + [messageOptions.length], + ) + const jumpToTop = useCallback(() => setSelectedIndex(0), []) + const jumpToBottom = useCallback( + () => setSelectedIndex(messageOptions.length - 1), + [messageOptions.length], + ) const handleSelectCurrent = useCallback(() => { - const selected = messageOptions[selectedIndex]; + const selected = messageOptions[selectedIndex] if (selected) { - void handleSelect(selected); + void handleSelect(selected) } - }, [messageOptions, selectedIndex, handleSelect]); + }, [messageOptions, selectedIndex, handleSelect]) // Escape to close - uses Confirmation context where escape is bound useKeybinding('confirm:no', handleEscape, { context: 'Confirmation', - isActive: !messageToRestore - }); + isActive: !messageToRestore, + }) // Message selector navigation keybindings - useKeybindings({ - 'messageSelector:up': moveUp, - 'messageSelector:down': moveDown, - 'messageSelector:top': jumpToTop, - 'messageSelector:bottom': jumpToBottom, - 'messageSelector:select': handleSelectCurrent - }, { - context: 'MessageSelector', - isActive: !isRestoring && !error && !messageToRestore && hasMessagesToSelect - }); - const [fileHistoryMetadata, setFileHistoryMetadata] = useState>({}); + useKeybindings( + { + 'messageSelector:up': moveUp, + 'messageSelector:down': moveDown, + 'messageSelector:top': jumpToTop, + 'messageSelector:bottom': jumpToBottom, + 'messageSelector:select': handleSelectCurrent, + }, + { + context: 'MessageSelector', + isActive: + !isRestoring && !error && !messageToRestore && hasMessagesToSelect, + }, + ) + + const [fileHistoryMetadata, setFileHistoryMetadata] = useState< + Record + >({}) + useEffect(() => { async function loadFileHistoryMetadata() { if (!isFileHistoryEnabled) { - return; + return } // Load file snapshot metadata - void Promise.all(messageOptions.map(async (userMessage, itemIndex) => { - if (userMessage.uuid !== currentUUID) { - const canRestore = fileHistoryCanRestore(fileHistory, userMessage.uuid); - const nextUserMessage = messageOptions.at(itemIndex + 1); - const diffStats_0 = canRestore ? computeDiffStatsBetweenMessages(messages, userMessage.uuid, nextUserMessage?.uuid !== currentUUID ? nextUserMessage?.uuid : undefined) : undefined; - if (diffStats_0 !== undefined) { - setFileHistoryMetadata(prev_1 => ({ - ...prev_1, - [itemIndex]: diffStats_0 - })); - } else { - setFileHistoryMetadata(prev_2 => ({ - ...prev_2, - [itemIndex]: undefined - })); + void Promise.all( + messageOptions.map(async (userMessage, itemIndex) => { + if (userMessage.uuid !== currentUUID) { + const canRestore = fileHistoryCanRestore( + fileHistory, + userMessage.uuid, + ) + + const nextUserMessage = messageOptions.at(itemIndex + 1) + const diffStats = canRestore + ? computeDiffStatsBetweenMessages( + messages, + userMessage.uuid, + nextUserMessage?.uuid !== currentUUID + ? nextUserMessage?.uuid + : undefined, + ) + : undefined + + if (diffStats !== undefined) { + setFileHistoryMetadata(prev => ({ + ...prev, + [itemIndex]: diffStats, + })) + } else { + setFileHistoryMetadata(prev => ({ + ...prev, + [itemIndex]: undefined, + })) + } } - } - })); + }), + ) } - void loadFileHistoryMetadata(); - }, [messageOptions, messages, currentUUID, fileHistory, isFileHistoryEnabled]); - const canRestoreCode_0 = isFileHistoryEnabled && diffStatsForRestore?.filesChanged && diffStatsForRestore.filesChanged.length > 0; - const showPickList = !error && !messageToRestore && !preselectedMessage && hasMessagesToSelect; - return + void loadFileHistoryMetadata() + }, [messageOptions, messages, currentUUID, fileHistory, isFileHistoryEnabled]) + + const canRestoreCode = + isFileHistoryEnabled && + diffStatsForRestore?.filesChanged && + diffStatsForRestore.filesChanged.length > 0 + const showPickList = + !error && !messageToRestore && !preselectedMessage && hasMessagesToSelect + + return ( + Rewind - {error && <> + {error && ( + <> Error: {error} - } - {!hasMessagesToSelect && <> + + )} + {!hasMessagesToSelect && ( + <> Nothing to rewind to yet. - } - {!error && messageToRestore && hasMessagesToSelect && <> + + )} + {!error && messageToRestore && hasMessagesToSelect && ( + <> Confirm you want to restore{' '} {!diffStatsForRestore && 'the conversation '}to the point before you sent this message: - - + + - ({formatRelativeTimeAgo(new Date(messageToRestore.timestamp as number))}) + ({formatRelativeTimeAgo(new Date(messageToRestore.timestamp))}) - - {isRestoring && isSummarizeOption(restoringOption) ? + + {isRestoring && isSummarizeOption(restoringOption) ? ( + Summarizing… - : + setSelectedRestoreOption(value as RestoreOption) + } + onChange={value => + onSelectRestoreOption(value as RestoreOption) + } + onCancel={() => + preselectedMessage + ? onClose() + : setMessageToRestore(undefined) + } + /> + )} + {canRestoreCode && ( + {figures.warning} Rewinding does not affect files edited manually or via bash. - } - } - {showPickList && <> - {isFileHistoryEnabled ? + + )} + + )} + {showPickList && ( + <> + {isFileHistoryEnabled ? ( + Restore the code and/or conversation to the point before… - : + + ) : ( + Restore and fork the conversation to the point before… - } + + )} - {messageOptions.slice(firstVisibleIndex, firstVisibleIndex + MAX_VISIBLE_MESSAGES).map((msg, visibleOptionIndex) => { - const optionIndex = firstVisibleIndex + visibleOptionIndex; - const isSelected = optionIndex === selectedIndex; - const isCurrent = msg.uuid === currentUUID; - const metadataLoaded = optionIndex in fileHistoryMetadata; - const metadata = fileHistoryMetadata[optionIndex]; - const numFilesChanged = metadata?.filesChanged && metadata.filesChanged.length; - return + {messageOptions + .slice( + firstVisibleIndex, + firstVisibleIndex + MAX_VISIBLE_MESSAGES, + ) + .map((msg, visibleOptionIndex) => { + const optionIndex = firstVisibleIndex + visibleOptionIndex + const isSelected = optionIndex === selectedIndex + const isCurrent = msg.uuid === currentUUID + + const metadataLoaded = optionIndex in fileHistoryMetadata + const metadata = fileHistoryMetadata[optionIndex] + const numFilesChanged = + metadata?.filesChanged && metadata.filesChanged.length + + return ( + - {isSelected ? + {isSelected ? ( + {figures.pointer}{' '} - : {' '}} + + ) : ( + {' '} + )} - + - {isFileHistoryEnabled && metadataLoaded && - {metadata ? <> + {isFileHistoryEnabled && metadataLoaded && ( + + {metadata ? ( + <> - {numFilesChanged ? <> - {numFilesChanged === 1 && metadata.filesChanged![0] ? `${path.basename(metadata.filesChanged![0])} ` : `${numFilesChanged} files changed `} + {numFilesChanged ? ( + <> + {numFilesChanged === 1 && + metadata.filesChanged![0] + ? `${path.basename(metadata.filesChanged![0])} ` + : `${numFilesChanged} files changed `} - : <>No code changes} + + ) : ( + <>No code changes + )} - : + + ) : ( + {figures.warning} No code restore - } - } + + )} + + )} - ; - })} + + ) + })} - } - {!messageToRestore && - {exitState.pending ? <>Press {exitState.keyName} again to exit : <> + + )} + {!messageToRestore && ( + + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + <> {!error && hasMessagesToSelect && 'Enter to continue · '}Esc to exit - } - } + + )} + + )} - ; + + ) } + function getRestoreOptionConversationText(option: RestoreOption): string { switch (option) { case 'summarize': - return 'Messages after this point will be summarized.'; + return 'Messages after this point will be summarized.' case 'summarize_up_to': - return 'Preceding messages will be summarized. This and subsequent messages will remain unchanged — you will stay at the end of the conversation.'; + return 'Preceding messages will be summarized. This and subsequent messages will remain unchanged — you will stay at the end of the conversation.' case 'both': case 'conversation': - return 'The conversation will be forked.'; + return 'The conversation will be forked.' case 'code': case 'nevermind': - return 'The conversation will be unchanged.'; + return 'The conversation will be unchanged.' } } -function RestoreOptionDescription(t0) { - const $ = _c(11); - const { - selectedRestoreOption, - canRestoreCode, - diffStatsForRestore - } = t0; - const showCodeRestore = canRestoreCode && (selectedRestoreOption === "both" || selectedRestoreOption === "code"); - let t1; - if ($[0] !== selectedRestoreOption) { - t1 = getRestoreOptionConversationText(selectedRestoreOption); - $[0] = selectedRestoreOption; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== t1) { - t2 = {t1}; - $[2] = t1; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== diffStatsForRestore || $[5] !== selectedRestoreOption || $[6] !== showCodeRestore) { - t3 = !isSummarizeOption(selectedRestoreOption) && (showCodeRestore ? : The code will be unchanged.); - $[4] = diffStatsForRestore; - $[5] = selectedRestoreOption; - $[6] = showCodeRestore; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] !== t2 || $[9] !== t3) { - t4 = {t2}{t3}; - $[8] = t2; - $[9] = t3; - $[10] = t4; - } else { - t4 = $[10]; - } - return t4; + +function RestoreOptionDescription({ + selectedRestoreOption, + canRestoreCode, + diffStatsForRestore, +}: { + selectedRestoreOption: RestoreOption + canRestoreCode: boolean + diffStatsForRestore: DiffStats | undefined +}): React.ReactNode { + const showCodeRestore = + canRestoreCode && + (selectedRestoreOption === 'both' || selectedRestoreOption === 'code') + + return ( + + + {getRestoreOptionConversationText(selectedRestoreOption)} + + {!isSummarizeOption(selectedRestoreOption) && + (showCodeRestore ? ( + + ) : ( + The code will be unchanged. + ))} + + ) } -function RestoreCodeConfirmation(t0) { - const $ = _c(14); - const { - diffStatsForRestore - } = t0; + +function RestoreCodeConfirmation({ + diffStatsForRestore, +}: { + diffStatsForRestore: DiffStats | undefined +}): React.ReactNode { if (diffStatsForRestore === undefined) { - return; + return undefined } - if (!diffStatsForRestore.filesChanged || !diffStatsForRestore.filesChanged[0]) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = The code has not changed (nothing will be restored).; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; + if ( + !diffStatsForRestore.filesChanged || + !diffStatsForRestore.filesChanged[0] + ) { + return ( + The code has not changed (nothing will be restored). + ) } - const numFilesChanged = diffStatsForRestore.filesChanged.length; - let fileLabel; + + const numFilesChanged = diffStatsForRestore.filesChanged.length + + let fileLabel = '' if (numFilesChanged === 1) { - let t1; - if ($[1] !== diffStatsForRestore.filesChanged[0]) { - t1 = path.basename(diffStatsForRestore.filesChanged[0] || ""); - $[1] = diffStatsForRestore.filesChanged[0]; - $[2] = t1; - } else { - t1 = $[2]; - } - fileLabel = t1; + fileLabel = path.basename(diffStatsForRestore.filesChanged[0] || '') + } else if (numFilesChanged === 2) { + const file1 = path.basename(diffStatsForRestore.filesChanged[0] || '') + const file2 = path.basename(diffStatsForRestore.filesChanged[1] || '') + fileLabel = `${file1} and ${file2}` } else { - if (numFilesChanged === 2) { - let t1; - if ($[3] !== diffStatsForRestore.filesChanged[0]) { - t1 = path.basename(diffStatsForRestore.filesChanged[0] || ""); - $[3] = diffStatsForRestore.filesChanged[0]; - $[4] = t1; - } else { - t1 = $[4]; - } - const file1 = t1; - let t2; - if ($[5] !== diffStatsForRestore.filesChanged[1]) { - t2 = path.basename(diffStatsForRestore.filesChanged[1] || ""); - $[5] = diffStatsForRestore.filesChanged[1]; - $[6] = t2; - } else { - t2 = $[6]; - } - const file2 = t2; - fileLabel = `${file1} and ${file2}`; - } else { - let t1; - if ($[7] !== diffStatsForRestore.filesChanged[0]) { - t1 = path.basename(diffStatsForRestore.filesChanged[0] || ""); - $[7] = diffStatsForRestore.filesChanged[0]; - $[8] = t1; - } else { - t1 = $[8]; - } - const file1_0 = t1; - fileLabel = `${file1_0} and ${diffStatsForRestore.filesChanged.length - 1} other files`; - } + const file1 = path.basename(diffStatsForRestore.filesChanged[0] || '') + fileLabel = `${file1} and ${diffStatsForRestore.filesChanged.length - 1} other files` } - let t1; - if ($[9] !== diffStatsForRestore) { - t1 = ; - $[9] = diffStatsForRestore; - $[10] = t1; - } else { - t1 = $[10]; - } - let t2; - if ($[11] !== fileLabel || $[12] !== t1) { - t2 = <>The code will be restored{" "}{t1} in {fileLabel}.; - $[11] = fileLabel; - $[12] = t1; - $[13] = t2; - } else { - t2 = $[13]; - } - return t2; + + return ( + <> + + The code will be restored{' '} + in {fileLabel}. + + + ) } -function DiffStatsText(t0) { - const $ = _c(7); - const { - diffStats - } = t0; + +function DiffStatsText({ + diffStats, +}: { + diffStats: DiffStats | undefined +}): React.ReactNode { if (!diffStats || !diffStats.filesChanged) { - return; + return undefined } - let t1; - if ($[0] !== diffStats.insertions) { - t1 = +{diffStats.insertions} ; - $[0] = diffStats.insertions; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== diffStats.deletions) { - t2 = -{diffStats.deletions}; - $[2] = diffStats.deletions; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== t1 || $[5] !== t2) { - t3 = <>{t1}{t2}; - $[4] = t1; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - return t3; + return ( + <> + +{diffStats.insertions} + -{diffStats.deletions} + + ) } -function UserMessageOption(t0) { - const $ = _c(31); - const { - userMessage, - color, - dimColor, - isCurrent, - paddingRight - } = t0; - const { - columns - } = useTerminalSize(); + +function UserMessageOption({ + userMessage, + color, + dimColor, + isCurrent, + paddingRight, +}: { + userMessage: UserMessage + color?: keyof Theme + dimColor?: boolean + isCurrent: boolean + paddingRight?: number +}): React.ReactNode { + const { columns } = useTerminalSize() if (isCurrent) { - let t1; - if ($[0] !== color || $[1] !== dimColor) { - t1 = (current); - $[0] = color; - $[1] = dimColor; - $[2] = t1; - } else { - t1 = $[2]; + return ( + + + (current) + + + ) + } + + const content = userMessage.message.content + const lastBlock = + typeof content === 'string' ? null : content[content.length - 1] + const rawMessageText = + typeof content === 'string' + ? content.trim() + : lastBlock && isTextBlock(lastBlock) + ? lastBlock.text.trim() + : '(no prompt)' + + // Strip display-unfriendly tags (like ) before showing in the list + const messageText = stripDisplayTags(rawMessageText) + + if (isEmptyMessageText(messageText)) { + return ( + + + ((empty message)) + + + ) + } + + // Bash inputs + if (messageText.includes('')) { + const input = extractTag(messageText, 'bash-input') + if (input) { + return ( + + ! + + {' '} + {input} + + + ) } - return t1; } - const content = userMessage.message.content; - const lastBlock = typeof content === "string" ? null : content[content.length - 1]; - let T0; - let T1; - let t1; - let t2; - let t3; - let t4; - let t5; - let t6; - if ($[3] !== color || $[4] !== columns || $[5] !== content || $[6] !== dimColor || $[7] !== lastBlock || $[8] !== paddingRight) { - t6 = Symbol.for("react.early_return_sentinel"); - bb0: { - const rawMessageText = typeof content === "string" ? content.trim() : lastBlock && isTextBlock(lastBlock) ? lastBlock.text.trim() : "(no prompt)"; - const messageText = stripDisplayTags(rawMessageText); - if (isEmptyMessageText(messageText)) { - let t7; - if ($[17] !== color || $[18] !== dimColor) { - t7 = ((empty message)); - $[17] = color; - $[18] = dimColor; - $[19] = t7; - } else { - t7 = $[19]; - } - t6 = t7; - break bb0; + + // Skills and slash commands + if (messageText.includes(`<${COMMAND_MESSAGE_TAG}>`)) { + const commandMessage = extractTag(messageText, COMMAND_MESSAGE_TAG) + const args = extractTag(messageText, 'command-args') + const isSkillFormat = extractTag(messageText, 'skill-format') === 'true' + if (commandMessage) { + if (isSkillFormat) { + // Skills: Display as "Skill(name)" + return ( + + + Skill({commandMessage}) + + + ) + } else { + // Slash commands: Add "/" prefix and include args + return ( + + + /{commandMessage} {args} + + + ) } - if (messageText.includes("")) { - const input = extractTag(messageText, "bash-input"); - if (input) { - let t7; - if ($[20] === Symbol.for("react.memo_cache_sentinel")) { - t7 = !; - $[20] = t7; - } else { - t7 = $[20]; - } - t6 = {t7}{" "}{input}; - break bb0; - } - } - if (messageText.includes(`<${COMMAND_MESSAGE_TAG}>`)) { - const commandMessage = extractTag(messageText, COMMAND_MESSAGE_TAG); - const args = extractTag(messageText, "command-args"); - const isSkillFormat = extractTag(messageText, "skill-format") === "true"; - if (commandMessage) { - if (isSkillFormat) { - t6 = Skill({commandMessage}); - break bb0; - } else { - t6 = /{commandMessage} {args}; - break bb0; - } - } - } - T1 = Box; - t4 = "row"; - t5 = "100%"; - T0 = Text; - t1 = color; - t2 = dimColor; - t3 = paddingRight ? truncate(messageText, columns - paddingRight, true) : messageText.slice(0, 500).split("\n").slice(0, 4).join("\n"); } - $[3] = color; - $[4] = columns; - $[5] = content; - $[6] = dimColor; - $[7] = lastBlock; - $[8] = paddingRight; - $[9] = T0; - $[10] = T1; - $[11] = t1; - $[12] = t2; - $[13] = t3; - $[14] = t4; - $[15] = t5; - $[16] = t6; - } else { - T0 = $[9]; - T1 = $[10]; - t1 = $[11]; - t2 = $[12]; - t3 = $[13]; - t4 = $[14]; - t5 = $[15]; - t6 = $[16]; } - if (t6 !== Symbol.for("react.early_return_sentinel")) { - return t6; - } - let t7; - if ($[21] !== T0 || $[22] !== t1 || $[23] !== t2 || $[24] !== t3) { - t7 = {t3}; - $[21] = T0; - $[22] = t1; - $[23] = t2; - $[24] = t3; - $[25] = t7; - } else { - t7 = $[25]; - } - let t8; - if ($[26] !== T1 || $[27] !== t4 || $[28] !== t5 || $[29] !== t7) { - t8 = {t7}; - $[26] = T1; - $[27] = t4; - $[28] = t5; - $[29] = t7; - $[30] = t8; - } else { - t8 = $[30]; - } - return t8; + + // User prompts + return ( + + + {paddingRight + ? truncate(messageText, columns - paddingRight, true) + : messageText.slice(0, 500).split('\n').slice(0, 4).join('\n')} + + + ) } /** * Computes the diff stats for all the file edits in-between two messages. */ -function computeDiffStatsBetweenMessages(messages: Message[], fromMessageId: UUID, toMessageId: UUID | undefined): DiffStats | undefined { - const startIndex = messages.findIndex(msg => msg.uuid === fromMessageId); +function computeDiffStatsBetweenMessages( + messages: Message[], + fromMessageId: UUID, + toMessageId: UUID | undefined, +): DiffStats | undefined { + const startIndex = messages.findIndex(msg => msg.uuid === fromMessageId) if (startIndex === -1) { - return undefined; + return undefined } - let endIndex = toMessageId ? messages.findIndex(msg => msg.uuid === toMessageId) : messages.length; + + let endIndex = toMessageId + ? messages.findIndex(msg => msg.uuid === toMessageId) + : messages.length if (endIndex === -1) { - endIndex = messages.length; + endIndex = messages.length } - const filesChanged: string[] = []; - let insertions = 0; - let deletions = 0; + + const filesChanged: string[] = [] + let insertions = 0 + let deletions = 0 + for (let i = startIndex + 1; i < endIndex; i++) { - const msg = messages[i]; + const msg = messages[i] if (!msg || !isToolUseResultMessage(msg)) { - continue; + continue } - const result = msg.toolUseResult as FileEditOutput | FileWriteToolOutput; + + const result = msg.toolUseResult as FileEditOutput | FileWriteToolOutput if (!result || !result.filePath || !result.structuredPatch) { - continue; + continue } + if (!filesChanged.includes(result.filePath)) { - filesChanged.push(result.filePath); + filesChanged.push(result.filePath) } + try { if ('type' in result && result.type === 'create') { - insertions += result.content.split(/\r?\n/).length; + insertions += result.content.split(/\r?\n/).length } else { for (const hunk of result.structuredPatch) { - const additions = count(hunk.lines, line => line.startsWith('+')); - const removals = count(hunk.lines, line => line.startsWith('-')); - insertions += additions; - deletions += removals; + const additions = count(hunk.lines, line => line.startsWith('+')) + const removals = count(hunk.lines, line => line.startsWith('-')) + + insertions += additions + deletions += removals } } } catch { - continue; + continue } } + return { filesChanged, insertions, - deletions - }; -} -export function selectableUserMessagesFilter(message: Message): message is UserMessage { - if (message.type !== 'user') { - return false; + deletions, } - if (Array.isArray(message.message.content) && message.message.content[0]?.type === 'tool_result') { - return false; +} + +export function selectableUserMessagesFilter( + message: Message, +): message is UserMessage { + if (message.type !== 'user') { + return false + } + if ( + Array.isArray(message.message.content) && + message.message.content[0]?.type === 'tool_result' + ) { + return false } if (isSyntheticMessage(message)) { - return false; + return false } if (message.isMeta) { - return false; + return false } if (message.isCompactSummary || message.isVisibleInTranscriptOnly) { - return false; + return false } - const content = message.message.content; - const lastBlock = typeof content === 'string' ? null : content[content.length - 1]; - const messageText = typeof content === 'string' ? content.trim() : lastBlock && isTextBlock(lastBlock) ? lastBlock.text.trim() : ''; + + const content = message.message.content + const lastBlock = + typeof content === 'string' ? null : content[content.length - 1] + const messageText = + typeof content === 'string' + ? content.trim() + : lastBlock && isTextBlock(lastBlock) + ? lastBlock.text.trim() + : '' // Filter out non-user-authored messages (command outputs, task notifications, ticks). - if (messageText.indexOf(`<${LOCAL_COMMAND_STDOUT_TAG}>`) !== -1 || messageText.indexOf(`<${LOCAL_COMMAND_STDERR_TAG}>`) !== -1 || messageText.indexOf(`<${BASH_STDOUT_TAG}>`) !== -1 || messageText.indexOf(`<${BASH_STDERR_TAG}>`) !== -1 || messageText.indexOf(`<${TASK_NOTIFICATION_TAG}>`) !== -1 || messageText.indexOf(`<${TICK_TAG}>`) !== -1 || messageText.indexOf(`<${TEAMMATE_MESSAGE_TAG}`) !== -1) { - return false; + if ( + messageText.indexOf(`<${LOCAL_COMMAND_STDOUT_TAG}>`) !== -1 || + messageText.indexOf(`<${LOCAL_COMMAND_STDERR_TAG}>`) !== -1 || + messageText.indexOf(`<${BASH_STDOUT_TAG}>`) !== -1 || + messageText.indexOf(`<${BASH_STDERR_TAG}>`) !== -1 || + messageText.indexOf(`<${TASK_NOTIFICATION_TAG}>`) !== -1 || + messageText.indexOf(`<${TICK_TAG}>`) !== -1 || + messageText.indexOf(`<${TEAMMATE_MESSAGE_TAG}`) !== -1 + ) { + return false } - return true; + return true } /** @@ -796,35 +944,42 @@ export function selectableUserMessagesFilter(message: Message): message is UserM * or non-meaningful content. Returns true if there's nothing meaningful to confirm - * for example, if the user hit enter then immediately cancelled. */ -export function messagesAfterAreOnlySynthetic(messages: Message[], fromIndex: number): boolean { +export function messagesAfterAreOnlySynthetic( + messages: Message[], + fromIndex: number, +): boolean { for (let i = fromIndex + 1; i < messages.length; i++) { - const msg = messages[i]; - if (!msg) continue; + const msg = messages[i] + if (!msg) continue // Skip known non-meaningful message types - if (isSyntheticMessage(msg)) continue; - if (isToolUseResultMessage(msg)) continue; - if (msg.type === 'progress') continue; - if (msg.type === 'system') continue; - if (msg.type === 'attachment') continue; - if (msg.type === 'user' && msg.isMeta) continue; + if (isSyntheticMessage(msg)) continue + if (isToolUseResultMessage(msg)) continue + if (msg.type === 'progress') continue + if (msg.type === 'system') continue + if (msg.type === 'attachment') continue + if (msg.type === 'user' && msg.isMeta) continue // Assistant with actual content = meaningful if (msg.type === 'assistant') { - const content = msg.message.content; + const content = msg.message.content if (Array.isArray(content)) { - const hasMeaningfulContent = content.some(block => block.type === 'text' && block.text.trim() || block.type === 'tool_use'); - if (hasMeaningfulContent) return false; + const hasMeaningfulContent = content.some( + block => + (block.type === 'text' && block.text.trim()) || + block.type === 'tool_use', + ) + if (hasMeaningfulContent) return false } - continue; + continue } // User messages that aren't synthetic or meta = meaningful if (msg.type === 'user') { - return false; + return false } // Other types (e.g., tombstone) are non-meaningful, continue } - return true; + return true } diff --git a/src/components/MessageTimestamp.tsx b/src/components/MessageTimestamp.tsx index 60e08f4c5..8eac935e5 100644 --- a/src/components/MessageTimestamp.tsx +++ b/src/components/MessageTimestamp.tsx @@ -1,62 +1,39 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { stringWidth } from '../ink/stringWidth.js'; -import { Box, Text } from '../ink.js'; -import type { NormalizedMessage } from '../types/message.js'; +import React from 'react' +import { stringWidth } from '../ink/stringWidth.js' +import { Box, Text } from '../ink.js' +import type { NormalizedMessage } from '../types/message.js' + type Props = { - message: NormalizedMessage; - isTranscriptMode: boolean; -}; -export function MessageTimestamp(t0) { - const $ = _c(10); - const { - message, - isTranscriptMode - } = t0; - const shouldShowTimestamp = isTranscriptMode && message.timestamp && message.type === "assistant" && message.message.content.some(_temp); + message: NormalizedMessage + isTranscriptMode: boolean +} + +export function MessageTimestamp({ + message, + isTranscriptMode, +}: Props): React.ReactNode { + const shouldShowTimestamp = + isTranscriptMode && + message.timestamp && + message.type === 'assistant' && + message.message.content.some(c => c.type === 'text') + if (!shouldShowTimestamp) { - return null; + return null } - let T0; - let formattedTimestamp; - let t1; - if ($[0] !== message.timestamp) { - formattedTimestamp = new Date(message.timestamp).toLocaleTimeString("en-US", { - hour: "2-digit", - minute: "2-digit", - hour12: true - }); - T0 = Box; - t1 = stringWidth(formattedTimestamp); - $[0] = message.timestamp; - $[1] = T0; - $[2] = formattedTimestamp; - $[3] = t1; - } else { - T0 = $[1]; - formattedTimestamp = $[2]; - t1 = $[3]; - } - let t2; - if ($[4] !== formattedTimestamp) { - t2 = {formattedTimestamp}; - $[4] = formattedTimestamp; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] !== T0 || $[7] !== t1 || $[8] !== t2) { - t3 = {t2}; - $[6] = T0; - $[7] = t1; - $[8] = t2; - $[9] = t3; - } else { - t3 = $[9]; - } - return t3; -} -function _temp(c) { - return c.type === "text"; + + const formattedTimestamp = new Date(message.timestamp).toLocaleTimeString( + 'en-US', + { + hour: '2-digit', + minute: '2-digit', + hour12: true, + }, + ) + + return ( + + {formattedTimestamp} + + ) } diff --git a/src/components/Messages.tsx b/src/components/Messages.tsx index 64a24f220..c946a9fd7 100644 --- a/src/components/Messages.tsx +++ b/src/components/Messages.tsx @@ -1,48 +1,71 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import chalk from 'chalk'; -import type { UUID } from 'crypto'; -import type { RefObject } from 'react'; -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { every } from 'src/utils/set.js'; -import { getIsRemoteMode } from '../bootstrap/state.js'; -import type { Command } from '../commands.js'; -import { BLACK_CIRCLE } from '../constants/figures.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; -import { useTerminalNotification } from '../ink/useTerminalNotification.js'; -import { Box, Text } from '../ink.js'; -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; -import type { Screen } from '../screens/REPL.js'; -import type { Tools } from '../Tool.js'; -import { findToolByName } from '../Tool.js'; -import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js'; -import type { AssistantMessage, AttachmentMessage, Message as MessageType, NormalizedMessage, ProgressMessage as ProgressMessageType, RenderableMessage, SystemMessage, UserMessage } from '../types/message.js'; -import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js'; -import { collapseBackgroundBashNotifications } from '../utils/collapseBackgroundBashNotifications.js'; -import { collapseHookSummaries } from '../utils/collapseHookSummaries.js'; -import { collapseReadSearchGroups } from '../utils/collapseReadSearch.js'; -import { collapseTeammateShutdowns } from '../utils/collapseTeammateShutdowns.js'; -import { getGlobalConfig } from '../utils/config.js'; -import { isEnvTruthy } from '../utils/envUtils.js'; -import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; -import { applyGrouping } from '../utils/groupToolUses.js'; -import { buildMessageLookups, createAssistantMessage, deriveUUID, getMessagesAfterCompactBoundary, getToolUseID, getToolUseIDs, hasUnresolvedHooksFromLookup, isNotEmptyMessage, normalizeMessages, reorderMessagesInUI, type StreamingThinking, type StreamingToolUse, shouldShowUserMessage } from '../utils/messages.js'; -import { plural } from '../utils/stringUtils.js'; -import { renderableSearchText } from '../utils/transcriptSearch.js'; -import { Divider } from './design-system/Divider.js'; -import type { UnseenDivider } from './FullscreenLayout.js'; -import { LogoV2 } from './LogoV2/LogoV2.js'; -import { StreamingMarkdown } from './Markdown.js'; -import { hasContentAfterIndex, MessageRow } from './MessageRow.js'; -import { InVirtualListContext, type MessageActionsNav, MessageActionsSelectedContext, type MessageActionsState } from './messageActions.js'; -import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js'; -import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js'; -import { OffscreenFreeze } from './OffscreenFreeze.js'; -import type { ToolUseConfirm } from './permissions/PermissionRequest.js'; -import { StatusNotices } from './StatusNotices.js'; -import type { JumpHandle } from './VirtualMessageList.js'; +import { feature } from 'bun:bundle' +import chalk from 'chalk' +import type { UUID } from 'crypto' +import type { RefObject } from 'react' +import * as React from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { every } from 'src/utils/set.js' +import { getIsRemoteMode } from '../bootstrap/state.js' +import type { Command } from '../commands.js' +import { BLACK_CIRCLE } from '../constants/figures.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js' +import { useTerminalNotification } from '../ink/useTerminalNotification.js' +import { Box, Text } from '../ink.js' +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' +import type { Screen } from '../screens/REPL.js' +import type { Tools } from '../Tool.js' +import { findToolByName } from '../Tool.js' +import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js' +import type { + Message as MessageType, + NormalizedMessage, + ProgressMessage as ProgressMessageType, + RenderableMessage, +} from '../types/message.js' +import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js' +import { collapseBackgroundBashNotifications } from '../utils/collapseBackgroundBashNotifications.js' +import { collapseHookSummaries } from '../utils/collapseHookSummaries.js' +import { collapseReadSearchGroups } from '../utils/collapseReadSearch.js' +import { collapseTeammateShutdowns } from '../utils/collapseTeammateShutdowns.js' +import { getGlobalConfig } from '../utils/config.js' +import { isEnvTruthy } from '../utils/envUtils.js' +import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' +import { applyGrouping } from '../utils/groupToolUses.js' +import { + buildMessageLookups, + createAssistantMessage, + deriveUUID, + getMessagesAfterCompactBoundary, + getToolUseID, + getToolUseIDs, + hasUnresolvedHooksFromLookup, + isNotEmptyMessage, + normalizeMessages, + reorderMessagesInUI, + type StreamingThinking, + type StreamingToolUse, + shouldShowUserMessage, +} from '../utils/messages.js' +import { plural } from '../utils/stringUtils.js' +import { renderableSearchText } from '../utils/transcriptSearch.js' +import { Divider } from './design-system/Divider.js' +import type { UnseenDivider } from './FullscreenLayout.js' +import { LogoV2 } from './LogoV2/LogoV2.js' +import { StreamingMarkdown } from './Markdown.js' +import { hasContentAfterIndex, MessageRow } from './MessageRow.js' +import { + InVirtualListContext, + type MessageActionsNav, + MessageActionsSelectedContext, + type MessageActionsState, +} from './messageActions.js' +import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js' +import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js' +import { OffscreenFreeze } from './OffscreenFreeze.js' +import type { ToolUseConfirm } from './permissions/PermissionRequest.js' +import { StatusNotices } from './StatusNotices.js' +import type { JumpHandle } from './VirtualMessageList.js' // Memoed logo header: this box is the FIRST sibling before all MessageRows // in main-screen mode. If it becomes dirty on every Messages re-render, @@ -52,37 +75,46 @@ import type { JumpHandle } from './VirtualMessageList.js'; // and pegs CPU at 100%. Memo on agentDefinitions so a new messages array // doesn't invalidate the logo subtree. LogoV2/StatusNotices internally // subscribe to useAppState/useSettings for their own updates. -const LogoHeader = React.memo(function LogoHeader(t0: { agentDefinitions: AgentDefinitionsResult }) { - const $ = _c(3); - const { - agentDefinitions - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== agentDefinitions) { - t2 = {t1}; - $[1] = agentDefinitions; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; -}); +const LogoHeader = React.memo(function LogoHeader({ + agentDefinitions, +}: { + agentDefinitions: AgentDefinitionsResult | undefined +}): React.ReactNode { + // LogoV2 has its own internal OffscreenFreeze (catches its useAppState + // re-renders). This outer freeze catches agentDefinitions changes and any + // future StatusNotices subscriptions while the header is in scrollback. + return ( + + + + + + + + + ) +}) // Dead code elimination: conditional import for proactive mode /* eslint-disable @typescript-eslint/no-require-imports */ -const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/index.js') : null; -const BRIEF_TOOL_NAME: string | null = feature('KAIROS') || feature('KAIROS_BRIEF') ? (require('../tools/BriefTool/prompt.js') as typeof import('../tools/BriefTool/prompt.js')).BRIEF_TOOL_NAME : null; -const SEND_USER_FILE_TOOL_NAME: string | null = feature('KAIROS') ? (require('../tools/SendUserFileTool/prompt.js') as typeof import('../tools/SendUserFileTool/prompt.js')).SEND_USER_FILE_TOOL_NAME : null; +const proactiveModule = + feature('PROACTIVE') || feature('KAIROS') + ? require('../proactive/index.js') + : null +const BRIEF_TOOL_NAME: string | null = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? ( + require('../tools/BriefTool/prompt.js') as typeof import('../tools/BriefTool/prompt.js') + ).BRIEF_TOOL_NAME + : null +const SEND_USER_FILE_TOOL_NAME: string | null = feature('KAIROS') + ? ( + require('../tools/SendUserFileTool/prompt.js') as typeof import('../tools/SendUserFileTool/prompt.js') + ).SEND_USER_FILE_TOOL_NAME + : null /* eslint-enable @typescript-eslint/no-require-imports */ -import { VirtualMessageList } from './VirtualMessageList.js'; +import { VirtualMessageList } from './VirtualMessageList.js' /** * In brief-only mode, filter messages to show ONLY Brief tool_use blocks, @@ -90,58 +122,61 @@ import { VirtualMessageList } from './VirtualMessageList.js'; * if the model forgets to call Brief, the user sees nothing for that turn. * That's on the model to get right; the filter does not second-guess it. */ -export function filterForBriefTool; - }; - attachment?: { - type: string; - isMeta?: boolean; - origin?: unknown; - commandMode?: string; - }; -}>(messages: T[], briefToolNames: string[]): T[] { - const nameSet = new Set(briefToolNames); +export function filterForBriefTool< + T extends { + type: string + subtype?: string + isMeta?: boolean + isApiErrorMessage?: boolean + message?: { + content: Array<{ + type: string + name?: string + tool_use_id?: string + }> + } + attachment?: { + type: string + isMeta?: boolean + origin?: unknown + commandMode?: string + } + }, +>(messages: T[], briefToolNames: string[]): T[] { + const nameSet = new Set(briefToolNames) // tool_use always precedes its tool_result in the array, so we can collect // IDs and match against them in a single pass. - const briefToolUseIDs = new Set(); + const briefToolUseIDs = new Set() return messages.filter(msg => { // System messages (attach confirmation, remote errors, compact boundaries) // must stay visible — dropping them leaves the viewer with no feedback. // Exception: api_metrics is per-turn debug noise (TTFT, config writes, // hook timing) that defeats the point of brief mode. Still visible in // transcript mode (ctrl+o) which bypasses this filter. - if (msg.type === 'system') return msg.subtype !== 'api_metrics'; - const block = msg.message?.content[0]; + if (msg.type === 'system') return msg.subtype !== 'api_metrics' + const block = msg.message?.content[0] if (msg.type === 'assistant') { // API error messages (auth failures, rate limits, etc.) must stay visible - if (msg.isApiErrorMessage) return true; + if (msg.isApiErrorMessage) return true // Keep Brief tool_use blocks (renders with standard tool call chrome, // and must be in the list so buildMessageLookups can resolve tool results) if (block?.type === 'tool_use' && block.name && nameSet.has(block.name)) { if ('id' in block) { - briefToolUseIDs.add((block as { - id: string; - }).id); + briefToolUseIDs.add((block as { id: string }).id) } - return true; + return true } - return false; + return false } if (msg.type === 'user') { if (block?.type === 'tool_result') { - return block.tool_use_id !== undefined && briefToolUseIDs.has(block.tool_use_id); + return ( + block.tool_use_id !== undefined && + briefToolUseIDs.has(block.tool_use_id) + ) } // Real user input only — drop meta/tick messages. - return !msg.isMeta; + return !msg.isMeta } if (msg.type === 'attachment') { // Human input drained mid-turn arrives as a queued_command attachment @@ -150,11 +185,16 @@ export function filterForBriefTool; - }; -}>(messages: T[], briefToolNames: string[]): T[] { - const nameSet = new Set(briefToolNames); +export function dropTextInBriefTurns< + T extends { + type: string + isMeta?: boolean + message?: { content: Array<{ type: string; name?: string }> } + }, +>(messages: T[], briefToolNames: string[]): T[] { + const nameSet = new Set(briefToolNames) // First pass: find which turns (bounded by non-meta user messages) contain // a Brief tool_use. Tag each assistant text block with its turn index. - const turnsWithBrief = new Set(); - const textIndexToTurn: number[] = []; - let turn = 0; + const turnsWithBrief = new Set() + const textIndexToTurn: number[] = [] + let turn = 0 for (let i = 0; i < messages.length; i++) { - const msg = messages[i]!; - const block = msg.message?.content[0]; + const msg = messages[i]! + const block = msg.message?.content[0] if (msg.type === 'user' && block?.type !== 'tool_result' && !msg.isMeta) { - turn++; - continue; + turn++ + continue } if (msg.type === 'assistant') { if (block?.type === 'text') { - textIndexToTurn[i] = turn; - } else if (block?.type === 'tool_use' && block.name && nameSet.has(block.name)) { - turnsWithBrief.add(turn); + textIndexToTurn[i] = turn + } else if ( + block?.type === 'tool_use' && + block.name && + nameSet.has(block.name) + ) { + turnsWithBrief.add(turn) } } } - if (turnsWithBrief.size === 0) return messages; + if (turnsWithBrief.size === 0) return messages // Second pass: drop text blocks whose turn called Brief. return messages.filter((_, i) => { - const t = textIndexToTurn[i]; - return t === undefined || !turnsWithBrief.has(t); - }); + const t = textIndexToTurn[i] + return t === undefined || !turnsWithBrief.has(t) + }) } + type Props = { - messages: MessageType[]; - tools: Tools; - commands: Command[]; - verbose: boolean; + messages: MessageType[] + tools: Tools + commands: Command[] + verbose: boolean toolJSX: { - jsx: React.ReactNode | null; - shouldHidePromptInput: boolean; - shouldContinueAnimation?: true; - } | null; - toolUseConfirmQueue: ToolUseConfirm[]; - inProgressToolUseIDs: Set; - isMessageSelectorVisible: boolean; - conversationId: string; - screen: Screen; - streamingToolUses: StreamingToolUse[]; - showAllInTranscript?: boolean; - agentDefinitions?: AgentDefinitionsResult; - onOpenRateLimitOptions?: () => void; + jsx: React.ReactNode | null + shouldHidePromptInput: boolean + shouldContinueAnimation?: true + } | null + toolUseConfirmQueue: ToolUseConfirm[] + inProgressToolUseIDs: Set + isMessageSelectorVisible: boolean + conversationId: string + screen: Screen + streamingToolUses: StreamingToolUse[] + showAllInTranscript?: boolean + agentDefinitions?: AgentDefinitionsResult + onOpenRateLimitOptions?: () => void /** Hide the logo/header - used for subagent zoom view */ - hideLogo?: boolean; - isLoading: boolean; + hideLogo?: boolean + isLoading: boolean /** In transcript mode, hide all thinking blocks except the last one */ - hidePastThinking?: boolean; + hidePastThinking?: boolean /** Streaming thinking content (live updates, not frozen) */ - streamingThinking?: StreamingThinking | null; + streamingThinking?: StreamingThinking | null /** Streaming text preview (rendered as last item so transition to final message is positionally seamless) */ - streamingText?: string | null; + streamingText?: string | null /** When true, only show Brief tool output (hide everything else) */ - isBriefOnly?: boolean; + isBriefOnly?: boolean /** Fullscreen-mode "─── N new ───" divider. Renders before the first * renderableMessage derived from firstUnseenUuid (matched by the 24-char * prefix that deriveUUID preserves). */ - unseenDivider?: UnseenDivider; + unseenDivider?: UnseenDivider /** Fullscreen-mode ScrollBox handle. Enables React-level virtualization when present. */ - scrollRef?: RefObject; + scrollRef?: RefObject /** Fullscreen-mode: enable sticky-prompt tracking (writes via ScrollChromeContext). */ - trackStickyPrompt?: boolean; + trackStickyPrompt?: boolean /** Transcript search: jump-to-index + setSearchQuery/nextMatch/prevMatch. */ - jumpRef?: RefObject; + jumpRef?: RefObject /** Transcript search: fires when match count/position changes. */ - onSearchMatchesChange?: (count: number, current: number) => void; + onSearchMatchesChange?: (count: number, current: number) => void /** Paint an existing DOM subtree to fresh Screen, scan. Element comes * from the main tree (all real providers). Message-relative positions. */ - scanElement?: (el: import('../ink/dom.js').DOMElement) => import('../ink/render-to-screen.js').MatchPosition[]; + scanElement?: ( + el: import('../ink/dom.js').DOMElement, + ) => import('../ink/render-to-screen.js').MatchPosition[] /** Position-based CURRENT highlight. positions stable (msg-relative), * rowOffset tracks scroll. null clears. */ - setPositions?: (state: { - positions: import('../ink/render-to-screen.js').MatchPosition[]; - rowOffset: number; - currentIdx: number; - } | null) => void; + setPositions?: ( + state: { + positions: import('../ink/render-to-screen.js').MatchPosition[] + rowOffset: number + currentIdx: number + } | null, + ) => void /** Bypass MAX_MESSAGES_WITHOUT_VIRTUALIZATION. For one-shot headless renders * (e.g. /export via renderToString) where the memory concern doesn't apply * and the "already in scrollback" justification doesn't hold. */ - disableRenderCap?: boolean; + disableRenderCap?: boolean /** In-transcript cursor; expanded overrides verbose for selected message. */ - cursor?: MessageActionsState | null; - setCursor?: (cursor: MessageActionsState | null) => void; + cursor?: MessageActionsState | null + setCursor?: (cursor: MessageActionsState | null) => void /** Passed through to VirtualMessageList (heightCache owns visibility). */ - cursorNavRef?: React.Ref; + cursorNavRef?: React.Ref /** Render only collapsed.slice(start, end). For chunked headless export * (streamRenderedMessages in exportRenderer.tsx): prep runs on the FULL * messages array so grouping/lookups are correct, but only this slice * chunk instead of the full session. The logo renders only for chunk 0 * (start === 0); later chunks are mid-stream continuations. * Measured Mar 2026: 538-msg session, 20 slices → −55% plateau RSS. */ - renderRange?: readonly [start: number, end: number]; -}; -const MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE = 30; + renderRange?: readonly [start: number, end: number] +} + +const MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE = 30 // Safety cap for the non-virtualized render path (fullscreen off or // explicitly disabled). Ink mounts a full fiber tree per message (~250 KB @@ -304,40 +351,47 @@ const MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE = 30; // slice roughly where it was instead of resetting to 0 — which would // jump from ~200 rendered messages to the full history, orphaning // in-progress badge snapshots in scrollback. -const MAX_MESSAGES_WITHOUT_VIRTUALIZATION = 200; -const MESSAGE_CAP_STEP = 50; -export type SliceAnchor = { - uuid: string; - idx: number; -} | null; +const MAX_MESSAGES_WITHOUT_VIRTUALIZATION = 200 +const MESSAGE_CAP_STEP = 50 + +export type SliceAnchor = { uuid: string; idx: number } | null /** Exported for testing. Mutates anchorRef when the window needs to advance. */ -export function computeSliceStart(collapsed: ReadonlyArray<{ - uuid: string; -}>, anchorRef: { - current: SliceAnchor; -}, cap = MAX_MESSAGES_WITHOUT_VIRTUALIZATION, step = MESSAGE_CAP_STEP): number { - const anchor = anchorRef.current; - const anchorIdx = anchor ? collapsed.findIndex(m => m.uuid === anchor.uuid) : -1; +export function computeSliceStart( + collapsed: ReadonlyArray<{ uuid: string }>, + anchorRef: { current: SliceAnchor }, + cap = MAX_MESSAGES_WITHOUT_VIRTUALIZATION, + step = MESSAGE_CAP_STEP, +): number { + const anchor = anchorRef.current + const anchorIdx = anchor + ? collapsed.findIndex(m => m.uuid === anchor.uuid) + : -1 // Anchor found → use it. Anchor lost → fall back to stored index // (clamped) so collapse-regrouping uuid churn doesn't reset to 0. - let start = anchorIdx >= 0 ? anchorIdx : anchor ? Math.min(anchor.idx, Math.max(0, collapsed.length - cap)) : 0; + let start = + anchorIdx >= 0 + ? anchorIdx + : anchor + ? Math.min(anchor.idx, Math.max(0, collapsed.length - cap)) + : 0 if (collapsed.length - start > cap + step) { - start = collapsed.length - cap; + start = collapsed.length - cap } // Refresh anchor from whatever lives at the current start — heals a // stale uuid after fallback and captures a new one after advancement. - const msgAtStart = collapsed[start]; - if (msgAtStart && (anchor?.uuid !== msgAtStart.uuid || anchor.idx !== start)) { - anchorRef.current = { - uuid: msgAtStart.uuid, - idx: start - }; + const msgAtStart = collapsed[start] + if ( + msgAtStart && + (anchor?.uuid !== msgAtStart.uuid || anchor.idx !== start) + ) { + anchorRef.current = { uuid: msgAtStart.uuid, idx: start } } else if (!msgAtStart && anchor) { - anchorRef.current = null; + anchorRef.current = null } - return start; + return start } + const MessagesImpl = ({ messages, tools, @@ -370,107 +424,140 @@ const MessagesImpl = ({ cursor = null, setCursor, cursorNavRef, - renderRange + renderRange, }: Props): React.ReactNode => { - const { - columns - } = useTerminalSize(); - const toggleShowAllShortcut = useShortcutDisplay('transcript:toggleShowAll', 'Transcript', 'Ctrl+E'); - const normalizedMessages = useMemo(() => normalizeMessages(messages).filter(isNotEmptyMessage), [messages]); + const { columns } = useTerminalSize() + const toggleShowAllShortcut = useShortcutDisplay( + 'transcript:toggleShowAll', + 'Transcript', + 'Ctrl+E', + ) + + const normalizedMessages = useMemo( + () => normalizeMessages(messages).filter(isNotEmptyMessage), + [messages], + ) // Check if streaming thinking should be visible (streaming or within 30s timeout) const isStreamingThinkingVisible = useMemo(() => { - if (!streamingThinking) return false; - if (streamingThinking.isStreaming) return true; + if (!streamingThinking) return false + if (streamingThinking.isStreaming) return true if (streamingThinking.streamingEndedAt) { - return Date.now() - streamingThinking.streamingEndedAt < 30000; + return Date.now() - streamingThinking.streamingEndedAt < 30000 } - return false; - }, [streamingThinking]); + return false + }, [streamingThinking]) // Find the last thinking block (message UUID + content index) for hiding past thinking in transcript mode // When streaming thinking is visible, use a special ID that won't match any completed thinking block // With adaptive thinking, only consider thinking blocks from the current turn and stop searching once we // hit the last user message. const lastThinkingBlockId = useMemo(() => { - if (!hidePastThinking) return null; + if (!hidePastThinking) return null // If streaming thinking is visible, hide all completed thinking blocks by using a non-matching ID - if (isStreamingThinkingVisible) return 'streaming'; + if (isStreamingThinkingVisible) return 'streaming' // Iterate backwards to find the last message with a thinking block for (let i = normalizedMessages.length - 1; i >= 0; i--) { - const msg = normalizedMessages[i]; + const msg = normalizedMessages[i] if (msg?.type === 'assistant') { - const content = msg.message.content as Array<{ type: string }>; + const content = msg.message.content // Find the last thinking block in this message for (let j = content.length - 1; j >= 0; j--) { if (content[j]?.type === 'thinking') { - return `${msg.uuid}:${j}`; + return `${msg.uuid}:${j}` } } } else if (msg?.type === 'user') { - const hasToolResult = (msg.message.content as Array<{ type: string }>).some(block => block.type === 'tool_result'); + const hasToolResult = msg.message.content.some( + block => block.type === 'tool_result', + ) if (!hasToolResult) { // Reached a previous user turn so don't show stale thinking from before - return 'no-thinking'; + return 'no-thinking' } } } - return null; - }, [normalizedMessages, hidePastThinking, isStreamingThinkingVisible]); + return null + }, [normalizedMessages, hidePastThinking, isStreamingThinkingVisible]) // Find the latest user bash output message (from ! commands) // This allows us to show full output for the most recent bash command const latestBashOutputUUID = useMemo(() => { // Iterate backwards to find the last user message with bash output - for (let i_0 = normalizedMessages.length - 1; i_0 >= 0; i_0--) { - const msg_0 = normalizedMessages[i_0]; - if (msg_0?.type === 'user') { - const content_0 = msg_0.message.content as Array<{ type: string; text?: string }>; + for (let i = normalizedMessages.length - 1; i >= 0; i--) { + const msg = normalizedMessages[i] + if (msg?.type === 'user') { + const content = msg.message.content // Check if any text content is bash output - for (const block_0 of content_0) { - if (block_0.type === 'text') { - const text = block_0.text!; - if (text.startsWith(' getToolUseIDs(normalizedMessages), [normalizedMessages]); - const streamingToolUsesWithoutInProgress = useMemo(() => streamingToolUses.filter(stu => !inProgressToolUseIDs.has(stu.contentBlock.id) && !normalizedToolUseIDs.has(stu.contentBlock.id)), [streamingToolUses, inProgressToolUseIDs, normalizedToolUseIDs]); - const syntheticStreamingToolUseMessages = useMemo(() => streamingToolUsesWithoutInProgress.flatMap(streamingToolUse => { - const msg_1 = createAssistantMessage({ - content: [streamingToolUse.contentBlock] - }); - // Override randomUUID with deterministic value derived from content - // block ID to prevent React key changes on every memo recomputation. - // Same class of bug fixed in normalizeMessages (commit 383326e613): - // fresh randomUUID → unstable React keys → component remounts → - // Ink rendering corruption (overlapping text from stale DOM nodes). - msg_1.uuid = deriveUUID(streamingToolUse.contentBlock.id as UUID, 0); - return normalizeMessages([msg_1]); - }), [streamingToolUsesWithoutInProgress]); - const isTranscriptMode = screen === 'transcript'; + const normalizedToolUseIDs = useMemo( + () => getToolUseIDs(normalizedMessages), + [normalizedMessages], + ) + + const streamingToolUsesWithoutInProgress = useMemo( + () => + streamingToolUses.filter( + stu => + !inProgressToolUseIDs.has(stu.contentBlock.id) && + !normalizedToolUseIDs.has(stu.contentBlock.id), + ), + [streamingToolUses, inProgressToolUseIDs, normalizedToolUseIDs], + ) + + const syntheticStreamingToolUseMessages = useMemo( + () => + streamingToolUsesWithoutInProgress.flatMap(streamingToolUse => { + const msg = createAssistantMessage({ + content: [streamingToolUse.contentBlock], + }) + // Override randomUUID with deterministic value derived from content + // block ID to prevent React key changes on every memo recomputation. + // Same class of bug fixed in normalizeMessages (commit 383326e613): + // fresh randomUUID → unstable React keys → component remounts → + // Ink rendering corruption (overlapping text from stale DOM nodes). + msg.uuid = deriveUUID(streamingToolUse.contentBlock.id as UUID, 0) + return normalizeMessages([msg]) + }), + [streamingToolUsesWithoutInProgress], + ) + + const isTranscriptMode = screen === 'transcript' // Hoisted to mount-time — this component re-renders on every scroll. - const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []); + const disableVirtualScroll = useMemo( + () => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), + [], + ) // Virtual scroll replaces the transcript cap: everything is scrollable and // memory is bounded by the mounted-item count, not the total. scrollRef is // only passed when isFullscreenEnvEnabled() is true (REPL.tsx gates it), // so scrollRef's presence is the signal. - const virtualScrollRuntimeGate = scrollRef != null && !disableVirtualScroll; - const shouldTruncate = isTranscriptMode && !showAllInTranscript && !virtualScrollRuntimeGate; + const virtualScrollRuntimeGate = scrollRef != null && !disableVirtualScroll + const shouldTruncate = + isTranscriptMode && !showAllInTranscript && !virtualScrollRuntimeGate // Anchor for the first rendered message in the non-virtualized cap slice. // Monotonic advance only — mutation during render is idempotent (safe // under StrictMode double-render). See MAX_MESSAGES_WITHOUT_VIRTUALIZATION // comment above for why this replaced count-based slicing. - const sliceAnchorRef = useRef(null); + const sliceAnchorRef = useRef(null) // Expensive message transforms — filter, reorder, group, collapse, lookups. // All O(n) over 27k messages. Split from the renderRange slice so scrolling @@ -478,55 +565,107 @@ const MessagesImpl = ({ // useMemo included renderRange → every scroll rebuilt 6 Maps over 27k // messages + 4 filter/map passes = ~50ms alloc per scroll → GC pressure → // 100-173ms stop-the-world pauses on the 1GB heap. - const { - collapsed: collapsed_0, - lookups: lookups_0, - hasTruncatedMessages: hasTruncatedMessages_0, - hiddenMessageCount: hiddenMessageCount_0 - } = useMemo(() => { - // In fullscreen mode the alt buffer has no native scrollback, so the - // compact-boundary filter just hides history the ScrollBox could - // otherwise scroll to. Main-screen mode keeps the filter — pre-compact - // rows live above the viewport in native scrollback there, and - // re-rendering them triggers full resets. - // includeSnipped: UI rendering keeps snipped messages for scrollback - // (this PR's core goal — full history in UI, filter only for the model). - // Also avoids a UUID mismatch: normalizeMessages derives new UUIDs, so - // projectSnippedView's check against original removedUuids would fail. - const compactAwareMessages = verbose || isFullscreenEnvEnabled() ? normalizedMessages : getMessagesAfterCompactBoundary(normalizedMessages, { - includeSnipped: true - }); - const messagesToShowNotTruncated = reorderMessagesInUI(compactAwareMessages.filter((msg_2): msg_2 is Exclude => msg_2.type !== 'progress') - // CC-724: drop attachment messages that AttachmentMessage renders as - // null (hook_success, hook_additional_context, hook_cancelled, etc.) - // BEFORE counting/slicing so they don't inflate the "N messages" - // count in ctrl-o or consume slots in the 200-message render cap. - .filter(msg_3 => !isNullRenderingAttachment(msg_3)).filter(_ => shouldShowUserMessage(_, isTranscriptMode)) as (UserMessage | AssistantMessage | AttachmentMessage | SystemMessage)[], syntheticStreamingToolUseMessages); - // Three-tier filtering. Transcript mode (ctrl+o screen) is truly unfiltered. - // Brief-only: SendUserMessage + user input only. Default: drop redundant - // assistant text in turns where SendUserMessage was called (the model's - // text is working-notes that duplicate the SendUserMessage content). - const briefToolNames = [BRIEF_TOOL_NAME, SEND_USER_FILE_TOOL_NAME].filter((n): n is string => n !== null); - // dropTextInBriefTurns should only trigger on SendUserMessage turns — - // SendUserFile delivers a file without replacement text, so dropping - // assistant text for file-only turns would leave the user with no context. - const dropTextToolNames = [BRIEF_TOOL_NAME].filter((n_0): n_0 is string => n_0 !== null); - const briefFiltered: MessageType[] = (briefToolNames.length > 0 && !isTranscriptMode ? isBriefOnly ? filterForBriefTool(messagesToShowNotTruncated as Parameters[0], briefToolNames) : dropTextToolNames.length > 0 ? dropTextInBriefTurns(messagesToShowNotTruncated as Parameters[0], dropTextToolNames) : messagesToShowNotTruncated : messagesToShowNotTruncated) as MessageType[]; - const messagesToShow = shouldTruncate ? briefFiltered.slice(-MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE) : briefFiltered; - const hasTruncatedMessages = shouldTruncate && briefFiltered.length > MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE; - const { - messages: groupedMessages - } = applyGrouping(messagesToShow, tools, verbose); - const collapsed = collapseBackgroundBashNotifications(collapseHookSummaries(collapseTeammateShutdowns(collapseReadSearchGroups(groupedMessages, tools))), verbose); - const lookups = buildMessageLookups(normalizedMessages, messagesToShow); - const hiddenMessageCount = messagesToShowNotTruncated.length - MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE; - return { - collapsed, - lookups, - hasTruncatedMessages, - hiddenMessageCount - }; - }, [verbose, normalizedMessages, isTranscriptMode, syntheticStreamingToolUseMessages, shouldTruncate, tools, isBriefOnly]); + const { collapsed, lookups, hasTruncatedMessages, hiddenMessageCount } = + useMemo(() => { + // In fullscreen mode the alt buffer has no native scrollback, so the + // compact-boundary filter just hides history the ScrollBox could + // otherwise scroll to. Main-screen mode keeps the filter — pre-compact + // rows live above the viewport in native scrollback there, and + // re-rendering them triggers full resets. + // includeSnipped: UI rendering keeps snipped messages for scrollback + // (this PR's core goal — full history in UI, filter only for the model). + // Also avoids a UUID mismatch: normalizeMessages derives new UUIDs, so + // projectSnippedView's check against original removedUuids would fail. + const compactAwareMessages = + verbose || isFullscreenEnvEnabled() + ? normalizedMessages + : getMessagesAfterCompactBoundary(normalizedMessages, { + includeSnipped: true, + }) + + const messagesToShowNotTruncated = reorderMessagesInUI( + compactAwareMessages + .filter( + (msg): msg is Exclude => + msg.type !== 'progress', + ) + // CC-724: drop attachment messages that AttachmentMessage renders as + // null (hook_success, hook_additional_context, hook_cancelled, etc.) + // BEFORE counting/slicing so they don't inflate the "N messages" + // count in ctrl-o or consume slots in the 200-message render cap. + .filter(msg => !isNullRenderingAttachment(msg)) + .filter(_ => shouldShowUserMessage(_, isTranscriptMode)), + syntheticStreamingToolUseMessages, + ) + // Three-tier filtering. Transcript mode (ctrl+o screen) is truly unfiltered. + // Brief-only: SendUserMessage + user input only. Default: drop redundant + // assistant text in turns where SendUserMessage was called (the model's + // text is working-notes that duplicate the SendUserMessage content). + const briefToolNames = [BRIEF_TOOL_NAME, SEND_USER_FILE_TOOL_NAME].filter( + (n): n is string => n !== null, + ) + // dropTextInBriefTurns should only trigger on SendUserMessage turns — + // SendUserFile delivers a file without replacement text, so dropping + // assistant text for file-only turns would leave the user with no context. + const dropTextToolNames = [BRIEF_TOOL_NAME].filter( + (n): n is string => n !== null, + ) + const briefFiltered = + briefToolNames.length > 0 && !isTranscriptMode + ? isBriefOnly + ? filterForBriefTool(messagesToShowNotTruncated, briefToolNames) + : dropTextToolNames.length > 0 + ? dropTextInBriefTurns( + messagesToShowNotTruncated, + dropTextToolNames, + ) + : messagesToShowNotTruncated + : messagesToShowNotTruncated + + const messagesToShow = shouldTruncate + ? briefFiltered.slice(-MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE) + : briefFiltered + + const hasTruncatedMessages = + shouldTruncate && + briefFiltered.length > MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE + + const { messages: groupedMessages } = applyGrouping( + messagesToShow, + tools, + verbose, + ) + + const collapsed = collapseBackgroundBashNotifications( + collapseHookSummaries( + collapseTeammateShutdowns( + collapseReadSearchGroups(groupedMessages, tools), + ), + ), + verbose, + ) + + const lookups = buildMessageLookups(normalizedMessages, messagesToShow) + + const hiddenMessageCount = + messagesToShowNotTruncated.length - + MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE + + return { + collapsed, + lookups, + hasTruncatedMessages, + hiddenMessageCount, + } + }, [ + verbose, + normalizedMessages, + isTranscriptMode, + syntheticStreamingToolUseMessages, + shouldTruncate, + tools, + isBriefOnly, + ]) // Cheap slice — only runs when scroll range or slice config changes. const renderableMessages = useMemo(() => { @@ -537,39 +676,57 @@ const MessagesImpl = ({ // component's lifetime (scrollRef is either always passed or never). // renderRange is first: the chunked export path slices the // post-grouping array so each chunk gets correct tool-call grouping. - const capApplies = !virtualScrollRuntimeGate && !disableRenderCap; - const sliceStart = capApplies ? computeSliceStart(collapsed_0, sliceAnchorRef) : 0; - return renderRange ? collapsed_0.slice(renderRange[0], renderRange[1]) : sliceStart > 0 ? collapsed_0.slice(sliceStart) : collapsed_0; - }, [collapsed_0, renderRange, virtualScrollRuntimeGate, disableRenderCap]); - const streamingToolUseIDs = useMemo(() => new Set(streamingToolUses.map(__0 => __0.contentBlock.id)), [streamingToolUses]); + const capApplies = !virtualScrollRuntimeGate && !disableRenderCap + const sliceStart = capApplies + ? computeSliceStart(collapsed, sliceAnchorRef) + : 0 + return renderRange + ? collapsed.slice(renderRange[0], renderRange[1]) + : sliceStart > 0 + ? collapsed.slice(sliceStart) + : collapsed + }, [collapsed, renderRange, virtualScrollRuntimeGate, disableRenderCap]) + + const streamingToolUseIDs = useMemo( + () => new Set(streamingToolUses.map(_ => _.contentBlock.id)), + [streamingToolUses], + ) // Divider insertion point: first renderableMessage whose uuid shares the // 24-char prefix with firstUnseenUuid (deriveUUID keeps the first 24 // chars of the source message uuid, so this matches any block from it). const dividerBeforeIndex = useMemo(() => { - if (!unseenDivider) return -1; - const prefix = unseenDivider.firstUnseenUuid.slice(0, 24); - return renderableMessages.findIndex(m => m.uuid.slice(0, 24) === prefix); - }, [unseenDivider, renderableMessages]); + if (!unseenDivider) return -1 + const prefix = unseenDivider.firstUnseenUuid.slice(0, 24) + return renderableMessages.findIndex(m => m.uuid.slice(0, 24) === prefix) + }, [unseenDivider, renderableMessages]) + const selectedIdx = useMemo(() => { - if (!cursor) return -1; - return renderableMessages.findIndex(m_0 => m_0.uuid === cursor.uuid); - }, [cursor, renderableMessages]); + if (!cursor) return -1 + return renderableMessages.findIndex(m => m.uuid === cursor.uuid) + }, [cursor, renderableMessages]) // Fullscreen: click a message to toggle verbose rendering for it. Keyed by // tool_use_id where available so a tool_use and its tool_result (separate // rows) expand together; falls back to uuid for groups/thinking. Stale keys // are harmless — they never match anything in renderableMessages. - const [expandedKeys, setExpandedKeys] = useState>(() => new Set()); - const onItemClick = useCallback((msg_4: RenderableMessage) => { - const k = expandKey(msg_4); + const [expandedKeys, setExpandedKeys] = useState>( + () => new Set(), + ) + const onItemClick = useCallback((msg: RenderableMessage) => { + const k = expandKey(msg) setExpandedKeys(prev => { - const next = new Set(prev); - if (next.has(k)) next.delete(k);else next.add(k); - return next; - }); - }, []); - const isItemExpanded = useCallback((msg_5: RenderableMessage) => expandedKeys.size > 0 && expandedKeys.has(expandKey(msg_5)), [expandedKeys]); + const next = new Set(prev) + if (next.has(k)) next.delete(k) + else next.add(k) + return next + }) + }, []) + const isItemExpanded = useCallback( + (msg: RenderableMessage) => + expandedKeys.size > 0 && expandedKeys.has(expandKey(msg)), + [expandedKeys], + ) // Only hover/click messages where the verbose toggle reveals more: // collapsed read/search groups, or tool results that self-report truncation // via isResultTruncated. Callback must be stable across message updates: if @@ -577,64 +734,136 @@ const MessagesImpl = ({ // attaches after the mouse is already inside → hover never fires. tools is // session-stable; lookups is read via ref so the callback doesn't churn on // every new message. - const lookupsRef = useRef(lookups_0); - lookupsRef.current = lookups_0; - const isItemClickable = useCallback((msg_6: RenderableMessage): boolean => { - if (msg_6.type === 'collapsed_read_search') return true; - if (msg_6.type === 'assistant') { - const b = msg_6.message.content[0] as unknown as AdvisorBlock | undefined; - return b != null && isAdvisorBlock(b) && b.type === 'advisor_tool_result' && b.content.type === 'advisor_result'; - } - if (msg_6.type !== 'user') return false; - const b_0 = (msg_6.message.content as Array<{ type: string; is_error?: boolean; tool_use_id?: string }>)[0]; - if (b_0?.type !== 'tool_result' || b_0.is_error || !msg_6.toolUseResult) return false; - const name = lookupsRef.current.toolUseByToolUseID.get(b_0.tool_use_id!)?.name; - const tool = name ? findToolByName(tools, name) : undefined; - return tool?.isResultTruncated?.(msg_6.toolUseResult as never) ?? false; - }, [tools]); - const canAnimate = (!toolJSX || !!toolJSX.shouldContinueAnimation) && !toolUseConfirmQueue.length && !isMessageSelectorVisible; - const hasToolsInProgress = inProgressToolUseIDs.size > 0; + const lookupsRef = useRef(lookups) + lookupsRef.current = lookups + const isItemClickable = useCallback( + (msg: RenderableMessage): boolean => { + if (msg.type === 'collapsed_read_search') return true + if (msg.type === 'assistant') { + const b = msg.message.content[0] as unknown as AdvisorBlock | undefined + return ( + b != null && + isAdvisorBlock(b) && + b.type === 'advisor_tool_result' && + b.content.type === 'advisor_result' + ) + } + if (msg.type !== 'user') return false + const b = msg.message.content[0] + if (b?.type !== 'tool_result' || b.is_error || !msg.toolUseResult) + return false + const name = lookupsRef.current.toolUseByToolUseID.get( + b.tool_use_id, + )?.name + const tool = name ? findToolByName(tools, name) : undefined + return tool?.isResultTruncated?.(msg.toolUseResult as never) ?? false + }, + [tools], + ) + + const canAnimate = + (!toolJSX || !!toolJSX.shouldContinueAnimation) && + !toolUseConfirmQueue.length && + !isMessageSelectorVisible + + const hasToolsInProgress = inProgressToolUseIDs.size > 0 // Report progress to terminal (for terminals that support OSC 9;4) - const { - progress - } = useTerminalNotification(); - const prevProgressState = useRef(null); - const progressEnabled = getGlobalConfig().terminalProgressBarEnabled && !getIsRemoteMode() && !(proactiveModule?.isProactiveActive() ?? false); + const { progress } = useTerminalNotification() + const prevProgressState = useRef(null) + const progressEnabled = + getGlobalConfig().terminalProgressBarEnabled && + !getIsRemoteMode() && + !(proactiveModule?.isProactiveActive() ?? false) useEffect(() => { - const state = progressEnabled ? hasToolsInProgress ? 'indeterminate' : 'completed' : null; - if (prevProgressState.current === state) return; - prevProgressState.current = state; - progress(state); - }, [progress, progressEnabled, hasToolsInProgress]); + const state = progressEnabled + ? hasToolsInProgress + ? 'indeterminate' + : 'completed' + : null + if (prevProgressState.current === state) return + prevProgressState.current = state + progress(state) + }, [progress, progressEnabled, hasToolsInProgress]) useEffect(() => { - return () => progress(null); - }, [progress]); - const messageKey = useCallback((msg_7: RenderableMessage) => `${msg_7.uuid}-${conversationId}`, [conversationId]); - const renderMessageRow = (msg_8: RenderableMessage, index: number) => { - const prevType = index > 0 ? renderableMessages[index - 1]?.type : undefined; - const isUserContinuation = msg_8.type === 'user' && prevType === 'user'; + return () => progress(null) + }, [progress]) + + const messageKey = useCallback( + (msg: RenderableMessage) => `${msg.uuid}-${conversationId}`, + [conversationId], + ) + + const renderMessageRow = (msg: RenderableMessage, index: number) => { + const prevType = index > 0 ? renderableMessages[index - 1]?.type : undefined + const isUserContinuation = msg.type === 'user' && prevType === 'user' // hasContentAfter is only consumed for collapsed_read_search groups; // skip the scan for everything else. streamingText is rendered as a // sibling after this map, so it's never in renderableMessages — OR it // in explicitly so the group flips to past tense as soon as text starts // streaming instead of waiting for the block to finalize. - const hasContentAfter = msg_8.type === 'collapsed_read_search' && (!!streamingText || hasContentAfterIndex(renderableMessages, index, tools, streamingToolUseIDs)); - const k_0 = messageKey(msg_8); - const row = ; + const hasContentAfter = + msg.type === 'collapsed_read_search' && + (!!streamingText || + hasContentAfterIndex( + renderableMessages, + index, + tools, + streamingToolUseIDs, + )) + + const k = messageKey(msg) + const row = ( + + ) // Per-row Provider — only 2 rows re-render on selection change. // Wrapped BEFORE divider branch so both return paths get it. - const wrapped = + const wrapped = ( + {row} - ; + + ) + if (unseenDivider && index === dividerBeforeIndex) { - return [ - - , wrapped]; + return [ + + + , + wrapped, + ] } - return wrapped; - }; + return wrapped + } // Search indexing: for tool_result messages, look up the Tool and use // its extractSearchText — tool-owned, precise, matches what @@ -646,47 +875,72 @@ const MessagesImpl = ({ // A second-React-root reconcile approach was tried and ruled out // (measured 3.1ms/msg, growing — flushSyncWork processes all roots; // component hooks mutate shared state → main root accumulates updates). - const searchTextCache = useRef(new WeakMap()); - const extractSearchText = useCallback((msg_9: RenderableMessage): string => { - const cached = searchTextCache.current.get(msg_9); - if (cached !== undefined) return cached; - let text_0 = renderableSearchText(msg_9); - // If this is a tool_result message and the tool implements - // extractSearchText, prefer that — it's precise (tool-owned) - // vs renderableSearchText's field-name heuristic. - if (msg_9.type === 'user' && msg_9.toolUseResult && Array.isArray(msg_9.message.content)) { - const tr = msg_9.message.content.find(b_1 => b_1.type === 'tool_result'); - if (tr && 'tool_use_id' in tr) { - const tu = lookups_0.toolUseByToolUseID.get(tr.tool_use_id); - const tool_0 = tu && findToolByName(tools, tu.name); - const extracted = tool_0?.extractSearchText?.(msg_9.toolUseResult as never); - // undefined = tool didn't implement → keep heuristic. Empty - // string = tool says "nothing to index" → respect that. - if (extracted !== undefined) text_0 = extracted; + const searchTextCache = useRef(new WeakMap()) + const extractSearchText = useCallback( + (msg: RenderableMessage): string => { + const cached = searchTextCache.current.get(msg) + if (cached !== undefined) return cached + let text = renderableSearchText(msg) + // If this is a tool_result message and the tool implements + // extractSearchText, prefer that — it's precise (tool-owned) + // vs renderableSearchText's field-name heuristic. + if ( + msg.type === 'user' && + msg.toolUseResult && + Array.isArray(msg.message.content) + ) { + const tr = msg.message.content.find(b => b.type === 'tool_result') + if (tr && 'tool_use_id' in tr) { + const tu = lookups.toolUseByToolUseID.get(tr.tool_use_id) + const tool = tu && findToolByName(tools, tu.name) + const extracted = tool?.extractSearchText?.( + msg.toolUseResult as never, + ) + // undefined = tool didn't implement → keep heuristic. Empty + // string = tool says "nothing to index" → respect that. + if (extracted !== undefined) text = extracted + } } - } - // Cache LOWERED: setSearchQuery's hot loop indexOfs per keystroke. - // Lowering here (once, at warm) vs there (every keystroke) trades - // ~same steady-state memory for zero per-keystroke alloc. Cache - // GC's with messages on transcript exit. Tool methods return raw; - // renderableSearchText already lowercases (redundant but cheap). - const lowered = text_0.toLowerCase(); - searchTextCache.current.set(msg_9, lowered); - return lowered; - }, [tools, lookups_0]); - return <> + // Cache LOWERED: setSearchQuery's hot loop indexOfs per keystroke. + // Lowering here (once, at warm) vs there (every keystroke) trades + // ~same steady-state memory for zero per-keystroke alloc. Cache + // GC's with messages on transcript exit. Tool methods return raw; + // renderableSearchText already lowercases (redundant but cheap). + const lowered = text.toLowerCase() + searchTextCache.current.set(msg, lowered) + return lowered + }, + [tools, lookups], + ) + + return ( + <> {/* Logo */} - {!hideLogo && !(renderRange && renderRange[0] > 0) && } + {!hideLogo && !(renderRange && renderRange[0] > 0) && ( + + )} {/* Truncation indicator */} - {hasTruncatedMessages_0 && } + {hasTruncatedMessages && ( + + )} {/* Show all indicator */} - {isTranscriptMode && showAllInTranscript && hiddenMessageCount_0 > 0 && - // disableRenderCap (e.g. [ dump-to-scrollback) means we're uncapped - // as a one-shot escape hatch, not a toggle — ctrl+e is dead and - // nothing is actually "hidden" to restore. - !disableRenderCap && } + {isTranscriptMode && + showAllInTranscript && + hiddenMessageCount > 0 && + // disableRenderCap (e.g. [ dump-to-scrollback) means we're uncapped + // as a one-shot escape hatch, not a toggle — ctrl+e is dead and + // nothing is actually "hidden" to restore. + !disableRenderCap && ( + + )} {/* Messages - rendered as memoized MessageRow components. flatMap inserts the unseen-divider as a separate keyed sibling so @@ -696,11 +950,39 @@ const MessagesImpl = ({ each row - React Compiler pins props in the fiber's memoCache, so passing the array would accumulate every historical version (~1-2MB over a 7-turn session). */} - {virtualScrollRuntimeGate ? - = 0 ? selectedIdx : undefined} cursorNavRef={cursorNavRef} setCursor={setCursor} jumpRef={jumpRef} onSearchMatchesChange={onSearchMatchesChange} scanElement={scanElement} setPositions={setPositions} extractSearchText={extractSearchText} /> - : renderableMessages.flatMap(renderMessageRow)} + {virtualScrollRuntimeGate ? ( + + = 0 ? selectedIdx : undefined} + cursorNavRef={cursorNavRef} + setCursor={setCursor} + jumpRef={jumpRef} + onSearchMatchesChange={onSearchMatchesChange} + scanElement={scanElement} + setPositions={setPositions} + extractSearchText={extractSearchText} + /> + + ) : ( + renderableMessages.flatMap(renderMessageRow) + )} - {streamingText && !isBriefOnly && + {streamingText && !isBriefOnly && ( + {BLACK_CIRCLE} @@ -709,21 +991,35 @@ const MessagesImpl = ({ {streamingText} - } + + )} - {isStreamingThinkingVisible && streamingThinking && !isBriefOnly && - - } - ; -}; + {isStreamingThinkingVisible && streamingThinking && !isBriefOnly && ( + + + + )} + + ) +} /** Key for click-to-expand: tool_use_id where available (so tool_use + its * tool_result expand together), else uuid for groups/thinking. */ function expandKey(msg: RenderableMessage): string { - return (msg.type === 'assistant' || msg.type === 'user' ? getToolUseID(msg) : null) ?? msg.uuid; + return ( + (msg.type === 'assistant' || msg.type === 'user' + ? getToolUseID(msg) + : null) ?? msg.uuid + ) } // Custom comparator to prevent unnecessary re-renders during streaming. @@ -732,102 +1028,131 @@ function expandKey(msg: RenderableMessage): string { // 2. streamingToolUses array is recreated on every delta, but only contentBlock matters for rendering // 3. streamingThinking changes on every delta - we DO want to re-render for this function setsEqual(a: Set, b: Set): boolean { - if (a.size !== b.size) return false; + if (a.size !== b.size) return false for (const item of a) { - if (!b.has(item)) return false; + if (!b.has(item)) return false } - return true; + return true } + export const Messages = React.memo(MessagesImpl, (prev, next) => { - const keys = Object.keys(prev) as (keyof typeof prev)[]; + const keys = Object.keys(prev) as (keyof typeof prev)[] for (const key of keys) { - if (key === 'onOpenRateLimitOptions' || key === 'scrollRef' || key === 'trackStickyPrompt' || key === 'setCursor' || key === 'cursorNavRef' || key === 'jumpRef' || key === 'onSearchMatchesChange' || key === 'scanElement' || key === 'setPositions') continue; + if ( + key === 'onOpenRateLimitOptions' || + key === 'scrollRef' || + key === 'trackStickyPrompt' || + key === 'setCursor' || + key === 'cursorNavRef' || + key === 'jumpRef' || + key === 'onSearchMatchesChange' || + key === 'scanElement' || + key === 'setPositions' + ) + continue if (prev[key] !== next[key]) { if (key === 'streamingToolUses') { - const p = prev.streamingToolUses; - const n = next.streamingToolUses; - if (p.length === n.length && p.every((item, i) => item.contentBlock === n[i]?.contentBlock)) { - continue; + const p = prev.streamingToolUses + const n = next.streamingToolUses + if ( + p.length === n.length && + p.every((item, i) => item.contentBlock === n[i]?.contentBlock) + ) { + continue } } if (key === 'inProgressToolUseIDs') { if (setsEqual(prev.inProgressToolUseIDs, next.inProgressToolUseIDs)) { - continue; + continue } } if (key === 'unseenDivider') { - const p = prev.unseenDivider; - const n = next.unseenDivider; - if (p?.firstUnseenUuid === n?.firstUnseenUuid && p?.count === n?.count) { - continue; + const p = prev.unseenDivider + const n = next.unseenDivider + if ( + p?.firstUnseenUuid === n?.firstUnseenUuid && + p?.count === n?.count + ) { + continue } } if (key === 'tools') { - const p = prev.tools; - const n = next.tools; - if (p.length === n.length && p.every((tool, i) => tool.name === n[i]?.name)) { - continue; + const p = prev.tools + const n = next.tools + if ( + p.length === n.length && + p.every((tool, i) => tool.name === n[i]?.name) + ) { + continue } } // streamingThinking changes frequently - always re-render when it changes // (no special handling needed, default behavior is correct) - return false; + return false } } - return true; -}); -export function shouldRenderStatically(message: RenderableMessage, streamingToolUseIDs: Set, inProgressToolUseIDs: Set, siblingToolUseIDs: ReadonlySet, screen: Screen, lookups: ReturnType): boolean { + return true +}) + +export function shouldRenderStatically( + message: RenderableMessage, + streamingToolUseIDs: Set, + inProgressToolUseIDs: Set, + siblingToolUseIDs: ReadonlySet, + screen: Screen, + lookups: ReturnType, +): boolean { if (screen === 'transcript') { - return true; + return true } switch (message.type) { case 'attachment': case 'user': - case 'assistant': - { - if (message.type === 'assistant') { - const block = (message.message.content as Array<{ type: string; id?: string }>)[0]; - if (block?.type === 'server_tool_use') { - return lookups.resolvedToolUseIDs.has(block.id!); - } - } - const toolUseID = getToolUseID(message); - if (!toolUseID) { - return true; - } - if (streamingToolUseIDs.has(toolUseID)) { - return false; - } - if (inProgressToolUseIDs.has(toolUseID)) { - return false; + case 'assistant': { + if (message.type === 'assistant') { + const block = message.message.content[0] + if (block?.type === 'server_tool_use') { + return lookups.resolvedToolUseIDs.has(block.id) } + } + const toolUseID = getToolUseID(message) + if (!toolUseID) { + return true + } + if (streamingToolUseIDs.has(toolUseID)) { + return false + } + if (inProgressToolUseIDs.has(toolUseID)) { + return false + } - // Check if there are any unresolved PostToolUse hooks for this tool use - // If so, keep the message transient so the HookProgressMessage can update - if (hasUnresolvedHooksFromLookup(toolUseID, 'PostToolUse', lookups)) { - return false; - } - return every(siblingToolUseIDs, lookups.resolvedToolUseIDs); - } - case 'system': - { - // api errors always render dynamically, since we hide - // them as soon as we see another non-error message. - return message.subtype !== 'api_error'; - } - case 'grouped_tool_use': - { - const allResolved = message.messages.every(msg => { - const content = (msg.message.content as Array<{ type: string; id?: string }>)[0]; - return content?.type === 'tool_use' && lookups.resolvedToolUseIDs.has(content.id!); - }); - return allResolved; - } - case 'collapsed_read_search': - { - // In prompt mode, never mark as static to prevent flicker between API turns - // (In transcript mode, we already returned true at the top of this function) - return false; + // Check if there are any unresolved PostToolUse hooks for this tool use + // If so, keep the message transient so the HookProgressMessage can update + if (hasUnresolvedHooksFromLookup(toolUseID, 'PostToolUse', lookups)) { + return false } + + return every(siblingToolUseIDs, lookups.resolvedToolUseIDs) + } + case 'system': { + // api errors always render dynamically, since we hide + // them as soon as we see another non-error message. + return message.subtype !== 'api_error' + } + case 'grouped_tool_use': { + const allResolved = message.messages.every(msg => { + const content = msg.message.content[0] + return ( + content?.type === 'tool_use' && + lookups.resolvedToolUseIDs.has(content.id) + ) + }) + return allResolved + } + case 'collapsed_read_search': { + // In prompt mode, never mark as static to prevent flicker between API turns + // (In transcript mode, we already returned true at the top of this function) + return false + } } } diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index 674f65794..6658ad62d 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -1,447 +1,368 @@ -import { c as _c } from "react/compiler-runtime"; -import capitalize from 'lodash-es/capitalize.js'; -import * as React from 'react'; -import { useCallback, useMemo, useState } from 'react'; -import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { FAST_MODE_MODEL_DISPLAY, isFastModeAvailable, isFastModeCooldown, isFastModeEnabled } from 'src/utils/fastMode.js'; -import { Box, Text } from '../ink.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import { useAppState, useSetAppState } from '../state/AppState.js'; -import { convertEffortValueToLevel, type EffortLevel, getDefaultEffortForModel, modelSupportsEffort, modelSupportsMaxEffort, resolvePickerEffortPersistence, toPersistableEffort } from '../utils/effort.js'; -import { getDefaultMainLoopModel, type ModelSetting, modelDisplayString, parseUserSpecifiedModel } from '../utils/model/model.js'; -import { getModelOptions } from '../utils/model/modelOptions.js'; -import { getSettingsForSource, updateSettingsForSource } from '../utils/settings/settings.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { Select } from './CustomSelect/index.js'; -import { Byline } from './design-system/Byline.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { Pane } from './design-system/Pane.js'; -import { effortLevelToSymbol } from './EffortIndicator.js'; +import capitalize from 'lodash-es/capitalize.js' +import * as React from 'react' +import { useCallback, useMemo, useState } from 'react' +import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { + FAST_MODE_MODEL_DISPLAY, + isFastModeAvailable, + isFastModeCooldown, + isFastModeEnabled, +} from 'src/utils/fastMode.js' +import { Box, Text } from '../ink.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import { useAppState, useSetAppState } from '../state/AppState.js' +import { + convertEffortValueToLevel, + type EffortLevel, + getDefaultEffortForModel, + modelSupportsEffort, + modelSupportsMaxEffort, + resolvePickerEffortPersistence, + toPersistableEffort, +} from '../utils/effort.js' +import { + getDefaultMainLoopModel, + type ModelSetting, + modelDisplayString, + parseUserSpecifiedModel, +} from '../utils/model/model.js' +import { getModelOptions } from '../utils/model/modelOptions.js' +import { + getSettingsForSource, + updateSettingsForSource, +} from '../utils/settings/settings.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { Select } from './CustomSelect/index.js' +import { Byline } from './design-system/Byline.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { Pane } from './design-system/Pane.js' +import { effortLevelToSymbol } from './EffortIndicator.js' + export type Props = { - initial: string | null; - sessionModel?: ModelSetting; - onSelect: (model: string | null, effort: EffortLevel | undefined) => void; - onCancel?: () => void; - isStandaloneCommand?: boolean; - showFastModeNotice?: boolean; + initial: string | null + sessionModel?: ModelSetting + onSelect: (model: string | null, effort: EffortLevel | undefined) => void + onCancel?: () => void + isStandaloneCommand?: boolean + showFastModeNotice?: boolean /** Overrides the dim header line below "Select model". */ - headerText?: string; + headerText?: string /** * When true, skip writing effortLevel to userSettings on selection. * Used by the assistant installer wizard where the model choice is * project-scoped (written to the assistant's .claude/settings.json via * install.ts) and should not leak to the user's global ~/.claude/settings. */ - skipSettingsWrite?: boolean; -}; -const NO_PREFERENCE = '__NO_PREFERENCE__'; -export function ModelPicker(t0) { - const $ = _c(82); - const { - initial, - sessionModel, - onSelect, - onCancel, - isStandaloneCommand, - showFastModeNotice, - headerText, - skipSettingsWrite - } = t0; - const setAppState = useSetAppState(); - const exitState = useExitOnCtrlCDWithKeybindings(); - const initialValue = initial === null ? NO_PREFERENCE : initial; - const [focusedValue, setFocusedValue] = useState(initialValue); - const isFastMode = useAppState(_temp); - const [hasToggledEffort, setHasToggledEffort] = useState(false); - const effortValue = useAppState(_temp2); - let t1; - if ($[0] !== effortValue) { - t1 = effortValue !== undefined ? convertEffortValueToLevel(effortValue) : undefined; - $[0] = effortValue; - $[1] = t1; - } else { - t1 = $[1]; - } - const [effort, setEffort] = useState(t1); - const t2 = isFastMode ?? false; - let t3; - if ($[2] !== t2) { - t3 = getModelOptions(t2); - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - const modelOptions = t3; - let t4; - bb0: { + skipSettingsWrite?: boolean +} + +const NO_PREFERENCE = '__NO_PREFERENCE__' + +export function ModelPicker({ + initial, + sessionModel, + onSelect, + onCancel, + isStandaloneCommand, + showFastModeNotice, + headerText, + skipSettingsWrite, +}: Props): React.ReactNode { + const setAppState = useSetAppState() + const exitState = useExitOnCtrlCDWithKeybindings() + const maxVisible = 10 + + const initialValue = initial === null ? NO_PREFERENCE : initial + const [focusedValue, setFocusedValue] = useState( + initialValue, + ) + + const isFastMode = useAppState(s => + isFastModeEnabled() ? s.fastMode : false, + ) + + const [hasToggledEffort, setHasToggledEffort] = useState(false) + const effortValue = useAppState(s => s.effortValue) + const [effort, setEffort] = useState( + effortValue !== undefined + ? convertEffortValueToLevel(effortValue) + : undefined, + ) + + // Memoize all derived values to prevent re-renders + const modelOptions = useMemo( + () => getModelOptions(isFastMode ?? false), + [isFastMode], + ) + + // Ensure the initial value is in the options list + // This handles edge cases where the user's current model (e.g., 'haiku' for 3P users) + // is not in the base options but should still be selectable and shown as selected + const optionsWithInitial = useMemo(() => { if (initial !== null && !modelOptions.some(opt => opt.value === initial)) { - let t5; - if ($[4] !== initial) { - t5 = modelDisplayString(initial); - $[4] = initial; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] !== initial || $[7] !== t5) { - t6 = { + return [ + ...modelOptions, + { value: initial, - label: t5, - description: "Current model" - }; - $[6] = initial; - $[7] = t5; - $[8] = t6; - } else { - t6 = $[8]; - } - let t7; - if ($[9] !== modelOptions || $[10] !== t6) { - t7 = [...modelOptions, t6]; - $[9] = modelOptions; - $[10] = t6; - $[11] = t7; - } else { - t7 = $[11]; - } - t4 = t7; - break bb0; + label: modelDisplayString(initial), + description: 'Current model', + }, + ] } - t4 = modelOptions; - } - const optionsWithInitial = t4; - let t5; - if ($[12] !== optionsWithInitial) { - t5 = optionsWithInitial.map(_temp3); - $[12] = optionsWithInitial; - $[13] = t5; - } else { - t5 = $[13]; - } - const selectOptions = t5; - let t6; - if ($[14] !== initialValue || $[15] !== selectOptions) { - t6 = selectOptions.some(_ => _.value === initialValue) ? initialValue : selectOptions[0]?.value ?? undefined; - $[14] = initialValue; - $[15] = selectOptions; - $[16] = t6; - } else { - t6 = $[16]; - } - const initialFocusValue = t6; - const visibleCount = Math.min(10, selectOptions.length); - const hiddenCount = Math.max(0, selectOptions.length - visibleCount); - let t7; - if ($[17] !== focusedValue || $[18] !== selectOptions) { - t7 = selectOptions.find(opt_1 => opt_1.value === focusedValue)?.label; - $[17] = focusedValue; - $[18] = selectOptions; - $[19] = t7; - } else { - t7 = $[19]; - } - const focusedModelName = t7; - let focusedSupportsEffort; - let t8; - if ($[20] !== focusedValue) { - const focusedModel = resolveOptionModel(focusedValue); - focusedSupportsEffort = focusedModel ? modelSupportsEffort(focusedModel) : false; - t8 = focusedModel ? modelSupportsMaxEffort(focusedModel) : false; - $[20] = focusedValue; - $[21] = focusedSupportsEffort; - $[22] = t8; - } else { - focusedSupportsEffort = $[21]; - t8 = $[22]; - } - const focusedSupportsMax = t8; - let t9; - if ($[23] !== focusedValue) { - t9 = getDefaultEffortLevelForOption(focusedValue); - $[23] = focusedValue; - $[24] = t9; - } else { - t9 = $[24]; - } - const focusedDefaultEffort = t9; - const displayEffort = effort === "max" && !focusedSupportsMax ? "high" : effort; - let t10; - if ($[25] !== effortValue || $[26] !== hasToggledEffort) { - t10 = value => { - setFocusedValue(value); + return modelOptions + }, [modelOptions, initial]) + + const selectOptions = useMemo( + () => + optionsWithInitial.map(opt => ({ + ...opt, + value: opt.value === null ? NO_PREFERENCE : opt.value, + })), + [optionsWithInitial], + ) + const initialFocusValue = useMemo( + () => + selectOptions.some(_ => _.value === initialValue) + ? initialValue + : (selectOptions[0]?.value ?? undefined), + [selectOptions, initialValue], + ) + const visibleCount = Math.min(maxVisible, selectOptions.length) + const hiddenCount = Math.max(0, selectOptions.length - visibleCount) + + const focusedModelName = selectOptions.find( + opt => opt.value === focusedValue, + )?.label + const focusedModel = resolveOptionModel(focusedValue) + const focusedSupportsEffort = focusedModel + ? modelSupportsEffort(focusedModel) + : false + const focusedSupportsMax = focusedModel + ? modelSupportsMaxEffort(focusedModel) + : false + const focusedDefaultEffort = getDefaultEffortLevelForOption(focusedValue) + // Clamp display when 'max' is selected but the focused model doesn't support it. + // resolveAppliedEffort() does the same downgrade at API-send time. + const displayEffort = + effort === 'max' && !focusedSupportsMax ? 'high' : effort + + const handleFocus = useCallback( + (value: string) => { + setFocusedValue(value) if (!hasToggledEffort && effortValue === undefined) { - setEffort(getDefaultEffortLevelForOption(value)); + setEffort(getDefaultEffortLevelForOption(value)) } - }; - $[25] = effortValue; - $[26] = hasToggledEffort; - $[27] = t10; - } else { - t10 = $[27]; - } - const handleFocus = t10; - let t11; - if ($[28] !== focusedDefaultEffort || $[29] !== focusedSupportsEffort || $[30] !== focusedSupportsMax) { - t11 = direction => { - if (!focusedSupportsEffort) { - return; + }, + [hasToggledEffort, effortValue], + ) + + // Effort level cycling keybindings + const handleCycleEffort = useCallback( + (direction: 'left' | 'right') => { + if (!focusedSupportsEffort) return + setEffort(prev => + cycleEffortLevel( + prev ?? focusedDefaultEffort, + direction, + focusedSupportsMax, + ), + ) + setHasToggledEffort(true) + }, + [focusedSupportsEffort, focusedSupportsMax, focusedDefaultEffort], + ) + + useKeybindings( + { + 'modelPicker:decreaseEffort': () => handleCycleEffort('left'), + 'modelPicker:increaseEffort': () => handleCycleEffort('right'), + }, + { context: 'ModelPicker' }, + ) + + function handleSelect(value: string): void { + logEvent('tengu_model_command_menu_effort', { + effort: + effort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + if (!skipSettingsWrite) { + // Prior comes from userSettings on disk — NOT merged settings (which + // includes project/policy layers that must not leak into the user's + // global ~/.claude/settings.json), and NOT AppState.effortValue (which + // includes session-ephemeral sources like --effort CLI flag). + // See resolvePickerEffortPersistence JSDoc. + const effortLevel = resolvePickerEffortPersistence( + effort, + getDefaultEffortLevelForOption(value), + getSettingsForSource('userSettings')?.effortLevel, + hasToggledEffort, + ) + const persistable = toPersistableEffort(effortLevel) + if (persistable !== undefined) { + updateSettingsForSource('userSettings', { effortLevel: persistable }) } - setEffort(prev => cycleEffortLevel(prev ?? focusedDefaultEffort, direction, focusedSupportsMax)); - setHasToggledEffort(true); - }; - $[28] = focusedDefaultEffort; - $[29] = focusedSupportsEffort; - $[30] = focusedSupportsMax; - $[31] = t11; - } else { - t11 = $[31]; + setAppState(prev => ({ ...prev, effortValue: effortLevel })) + } + + const selectedModel = resolveOptionModel(value) + const selectedEffort = + hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel) + ? effort + : undefined + if (value === NO_PREFERENCE) { + onSelect(null, selectedEffort) + return + } + onSelect(value, selectedEffort) } - const handleCycleEffort = t11; - let t12; - if ($[32] !== handleCycleEffort) { - t12 = { - "modelPicker:decreaseEffort": () => handleCycleEffort("left"), - "modelPicker:increaseEffort": () => handleCycleEffort("right") - }; - $[32] = handleCycleEffort; - $[33] = t12; - } else { - t12 = $[33]; - } - let t13; - if ($[34] === Symbol.for("react.memo_cache_sentinel")) { - t13 = { - context: "ModelPicker" - }; - $[34] = t13; - } else { - t13 = $[34]; - } - useKeybindings(t12, t13); - let t14; - if ($[35] !== effort || $[36] !== hasToggledEffort || $[37] !== onSelect || $[38] !== setAppState || $[39] !== skipSettingsWrite) { - t14 = function handleSelect(value_0) { - logEvent("tengu_model_command_menu_effort", { - effort: effort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - if (!skipSettingsWrite) { - const effortLevel = resolvePickerEffortPersistence(effort, getDefaultEffortLevelForOption(value_0), getSettingsForSource("userSettings")?.effortLevel, hasToggledEffort); - const persistable = toPersistableEffort(effortLevel); - if (persistable !== undefined) { - updateSettingsForSource("userSettings", { - effortLevel: persistable - }); - } - setAppState(prev_0 => ({ - ...prev_0, - effortValue: effortLevel - })); - } - const selectedModel = resolveOptionModel(value_0); - const selectedEffort = hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel) ? effort : undefined; - if (value_0 === NO_PREFERENCE) { - onSelect(null, selectedEffort); - return; - } - onSelect(value_0, selectedEffort); - }; - $[35] = effort; - $[36] = hasToggledEffort; - $[37] = onSelect; - $[38] = setAppState; - $[39] = skipSettingsWrite; - $[40] = t14; - } else { - t14 = $[40]; - } - const handleSelect = t14; - let t15; - if ($[41] === Symbol.for("react.memo_cache_sentinel")) { - t15 = Select model; - $[41] = t15; - } else { - t15 = $[41]; - } - const t16 = headerText ?? "Switch between Claude models. Applies to this session and future Claude Code sessions. For other/previous model names, specify with --model."; - let t17; - if ($[42] !== t16) { - t17 = {t16}; - $[42] = t16; - $[43] = t17; - } else { - t17 = $[43]; - } - let t18; - if ($[44] !== sessionModel) { - t18 = sessionModel && Currently using {modelDisplayString(sessionModel)} for this session (set by plan mode). Selecting a model will undo this.; - $[44] = sessionModel; - $[45] = t18; - } else { - t18 = $[45]; - } - let t19; - if ($[46] !== t17 || $[47] !== t18) { - t19 = {t15}{t17}{t18}; - $[46] = t17; - $[47] = t18; - $[48] = t19; - } else { - t19 = $[48]; - } - const t20 = onCancel ?? _temp4; - let t21; - if ($[49] !== handleFocus || $[50] !== handleSelect || $[51] !== initialFocusValue || $[52] !== initialValue || $[53] !== selectOptions || $[54] !== t20 || $[55] !== visibleCount) { - t21 = {})} + visibleOptionCount={visibleCount} + /> + + {hiddenCount > 0 && ( + + and {hiddenCount} more… + + )} + + + + {focusedSupportsEffort ? ( + + {' '} + {capitalize(displayEffort)} effort + {displayEffort === focusedDefaultEffort ? ` (default)` : ``}{' '} + ← → to adjust + + ) : ( + + Effort not supported + {focusedModelName ? ` for ${focusedModelName}` : ''} + + )} + + + {isFastModeEnabled() ? ( + showFastModeNotice ? ( + + + Fast mode is ON and available with{' '} + {FAST_MODE_MODEL_DISPLAY} only (/fast). Switching to other + models turn off fast mode. + + + ) : isFastModeAvailable() && !isFastModeCooldown() ? ( + + + Use /fast to turn on Fast mode ( + {FAST_MODE_MODEL_DISPLAY} only). + + + ) : null + ) : null} + + + {isStandaloneCommand && ( + + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + + + + + )} + + )} + + ) + if (!isStandaloneCommand) { - return content; + return content } - let t29; - if ($[80] !== content) { - t29 = {content}; - $[80] = content; - $[81] = t29; - } else { - t29 = $[81]; - } - return t29; -} -function _temp4() {} -function _temp3(opt_0) { - return { - ...opt_0, - value: opt_0.value === null ? NO_PREFERENCE : opt_0.value - }; -} -function _temp2(s_0) { - return s_0.effortValue; -} -function _temp(s) { - return isFastModeEnabled() ? s.fastMode : false; + + return {content} } + function resolveOptionModel(value?: string): string | undefined { - if (!value) return undefined; - return value === NO_PREFERENCE ? getDefaultMainLoopModel() : parseUserSpecifiedModel(value); + if (!value) return undefined + return value === NO_PREFERENCE + ? getDefaultMainLoopModel() + : parseUserSpecifiedModel(value) } -function EffortLevelIndicator(t0) { - const $ = _c(5); - const { - effort - } = t0; - const t1 = effort ? "claude" : "subtle"; - const t2 = effort ?? "low"; - let t3; - if ($[0] !== t2) { - t3 = effortLevelToSymbol(t2); - $[0] = t2; - $[1] = t3; - } else { - t3 = $[1]; - } - let t4; - if ($[2] !== t1 || $[3] !== t3) { - t4 = {t3}; - $[2] = t1; - $[3] = t3; - $[4] = t4; - } else { - t4 = $[4]; - } - return t4; + +function EffortLevelIndicator({ + effort, +}: { + effort?: EffortLevel +}): React.ReactNode { + return ( + + {effortLevelToSymbol(effort ?? 'low')} + + ) } -function cycleEffortLevel(current: EffortLevel, direction: 'left' | 'right', includeMax: boolean): EffortLevel { - const levels: EffortLevel[] = includeMax ? ['low', 'medium', 'high', 'max'] : ['low', 'medium', 'high']; + +function cycleEffortLevel( + current: EffortLevel, + direction: 'left' | 'right', + includeMax: boolean, +): EffortLevel { + const levels: EffortLevel[] = includeMax + ? ['low', 'medium', 'high', 'max'] + : ['low', 'medium', 'high'] // If the current level isn't in the cycle (e.g. 'max' after switching to a // non-Opus model), clamp to 'high'. - const idx = levels.indexOf(current); - const currentIndex = idx !== -1 ? idx : levels.indexOf('high'); + const idx = levels.indexOf(current) + const currentIndex = idx !== -1 ? idx : levels.indexOf('high') if (direction === 'right') { - return levels[(currentIndex + 1) % levels.length]!; + return levels[(currentIndex + 1) % levels.length]! } else { - return levels[(currentIndex - 1 + levels.length) % levels.length]!; + return levels[(currentIndex - 1 + levels.length) % levels.length]! } } + function getDefaultEffortLevelForOption(value?: string): EffortLevel { - const resolved = resolveOptionModel(value) ?? getDefaultMainLoopModel(); - const defaultValue = getDefaultEffortForModel(resolved); - return defaultValue !== undefined ? convertEffortValueToLevel(defaultValue) : 'high'; + const resolved = resolveOptionModel(value) ?? getDefaultMainLoopModel() + const defaultValue = getDefaultEffortForModel(resolved) + return defaultValue !== undefined + ? convertEffortValueToLevel(defaultValue) + : 'high' } diff --git a/src/components/NativeAutoUpdater.tsx b/src/components/NativeAutoUpdater.tsx index 3d860c2b9..fcb448ade 100644 --- a/src/components/NativeAutoUpdater.tsx +++ b/src/components/NativeAutoUpdater.tsx @@ -1,134 +1,152 @@ -import * as React from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; -import { logForDebugging } from 'src/utils/debug.js'; -import { logError } from 'src/utils/log.js'; -import { useInterval } from 'usehooks-ts'; -import { useUpdateNotification } from '../hooks/useUpdateNotification.js'; -import { Box, Text } from '../ink.js'; -import type { AutoUpdaterResult } from '../utils/autoUpdater.js'; -import { getMaxVersion, getMaxVersionMessage } from '../utils/autoUpdater.js'; -import { isAutoUpdaterDisabled } from '../utils/config.js'; -import { installLatest } from '../utils/nativeInstaller/index.js'; -import { gt } from '../utils/semver.js'; -import { getInitialSettings } from '../utils/settings/settings.js'; +import * as React from 'react' +import { useEffect, useRef, useState } from 'react' +import { logEvent } from 'src/services/analytics/index.js' +import { logForDebugging } from 'src/utils/debug.js' +import { logError } from 'src/utils/log.js' +import { useInterval } from 'usehooks-ts' +import { useUpdateNotification } from '../hooks/useUpdateNotification.js' +import { Box, Text } from '../ink.js' +import type { AutoUpdaterResult } from '../utils/autoUpdater.js' +import { getMaxVersion, getMaxVersionMessage } from '../utils/autoUpdater.js' +import { isAutoUpdaterDisabled } from '../utils/config.js' +import { installLatest } from '../utils/nativeInstaller/index.js' +import { gt } from '../utils/semver.js' +import { getInitialSettings } from '../utils/settings/settings.js' /** * Categorize error messages for analytics */ function getErrorType(errorMessage: string): string { if (errorMessage.includes('timeout')) { - return 'timeout'; + return 'timeout' } if (errorMessage.includes('Checksum mismatch')) { - return 'checksum_mismatch'; + return 'checksum_mismatch' } if (errorMessage.includes('ENOENT') || errorMessage.includes('not found')) { - return 'not_found'; + return 'not_found' } if (errorMessage.includes('EACCES') || errorMessage.includes('permission')) { - return 'permission_denied'; + return 'permission_denied' } if (errorMessage.includes('ENOSPC')) { - return 'disk_full'; + return 'disk_full' } if (errorMessage.includes('npm')) { - return 'npm_error'; + return 'npm_error' } - if (errorMessage.includes('network') || errorMessage.includes('ECONNREFUSED') || errorMessage.includes('ENOTFOUND')) { - return 'network_error'; + if ( + errorMessage.includes('network') || + errorMessage.includes('ECONNREFUSED') || + errorMessage.includes('ENOTFOUND') + ) { + return 'network_error' } - return 'unknown'; + return 'unknown' } + type Props = { - isUpdating: boolean; - onChangeIsUpdating: (isUpdating: boolean) => void; - onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void; - autoUpdaterResult: AutoUpdaterResult | null; - showSuccessMessage: boolean; - verbose: boolean; -}; + isUpdating: boolean + onChangeIsUpdating: (isUpdating: boolean) => void + onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void + autoUpdaterResult: AutoUpdaterResult | null + showSuccessMessage: boolean + verbose: boolean +} + export function NativeAutoUpdater({ isUpdating, onChangeIsUpdating, onAutoUpdaterResult, autoUpdaterResult, showSuccessMessage, - verbose + verbose, }: Props): React.ReactNode { const [versions, setVersions] = useState<{ - current?: string | null; - latest?: string | null; - }>({}); - const [maxVersionIssue, setMaxVersionIssue] = useState(null); - const updateSemver = useUpdateNotification(autoUpdaterResult?.version); - const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest'; + current?: string | null + latest?: string | null + }>({}) + const [maxVersionIssue, setMaxVersionIssue] = useState(null) + const updateSemver = useUpdateNotification(autoUpdaterResult?.version) + const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest' // Track latest isUpdating value in a ref so the memoized checkForUpdates // callback always sees the current value without changing callback identity // (which would re-trigger the initial-check useEffect below and cause // repeated downloads on remount — the upstream trigger for #22413). - const isUpdatingRef = useRef(isUpdating); - isUpdatingRef.current = isUpdating; + const isUpdatingRef = useRef(isUpdating) + isUpdatingRef.current = isUpdating + const checkForUpdates = React.useCallback(async () => { if (isUpdatingRef.current) { - return; + return } - if (("production" as string) === 'test' || ("production" as string) === 'development') { - logForDebugging('NativeAutoUpdater: Skipping update check in test/dev environment'); - return; + + if ( + "production" === 'test' || + "production" === 'development' + ) { + logForDebugging( + 'NativeAutoUpdater: Skipping update check in test/dev environment', + ) + return } + if (isAutoUpdaterDisabled()) { - return; + return } - onChangeIsUpdating(true); - const startTime = Date.now(); + + onChangeIsUpdating(true) + const startTime = Date.now() // Log the start of an auto-update check for funnel analysis - logEvent('tengu_native_auto_updater_start', {}); + logEvent('tengu_native_auto_updater_start', {}) + try { // Check if current version is above the max allowed version - const maxVersion = await getMaxVersion(); + const maxVersion = await getMaxVersion() if (maxVersion && gt(MACRO.VERSION, maxVersion)) { - const msg = await getMaxVersionMessage(); - setMaxVersionIssue(msg ?? 'affects your version'); + const msg = await getMaxVersionMessage() + setMaxVersionIssue(msg ?? 'affects your version') } - const result = await installLatest(channel); - const currentVersion = MACRO.VERSION; - const latencyMs = Date.now() - startTime; + + const result = await installLatest(channel) + const currentVersion = MACRO.VERSION + const latencyMs = Date.now() - startTime // Handle lock contention gracefully - just return without treating as error if (result.lockFailed) { logEvent('tengu_native_auto_updater_lock_contention', { - latency_ms: latencyMs - }); - return; // Silently skip this update check, will try again later + latency_ms: latencyMs, + }) + return // Silently skip this update check, will try again later } // Update versions for display - setVersions({ - current: currentVersion, - latest: result.latestVersion - }); + setVersions({ current: currentVersion, latest: result.latestVersion }) + if (result.wasUpdated) { logEvent('tengu_native_auto_updater_success', { - latency_ms: latencyMs - }); + latency_ms: latencyMs, + }) + onAutoUpdaterResult({ version: result.latestVersion, - status: 'success' - }); + status: 'success', + }) } else { // Already up to date logEvent('tengu_native_auto_updater_up_to_date', { - latency_ms: latencyMs - }); + latency_ms: latencyMs, + }) } } catch (error) { - const latencyMs = Date.now() - startTime; - const errorMessage = error instanceof Error ? error.message : String(error); - logError(error); - const errorType = getErrorType(errorMessage); + const latencyMs = Date.now() - startTime + const errorMessage = + error instanceof Error ? error.message : String(error) + logError(error) + + const errorType = getErrorType(errorMessage) logEvent('tengu_native_auto_updater_fail', { latency_ms: latencyMs, error_timeout: errorType === 'timeout', @@ -137,56 +155,77 @@ export function NativeAutoUpdater({ error_permission: errorType === 'permission_denied', error_disk_full: errorType === 'disk_full', error_npm: errorType === 'npm_error', - error_network: errorType === 'network_error' - }); + error_network: errorType === 'network_error', + }) + onAutoUpdaterResult({ version: null, - status: 'install_failed' - }); + status: 'install_failed', + }) } finally { - onChangeIsUpdating(false); + onChangeIsUpdating(false) } // isUpdating intentionally omitted from deps; we read isUpdatingRef // instead so the guard is always current without changing callback // identity (which would re-trigger the initial-check useEffect below). // eslint-disable-next-line react-hooks/exhaustive-deps // biome-ignore lint/correctness/useExhaustiveDependencies: isUpdating read via ref - }, [onAutoUpdaterResult, channel]); + }, [onAutoUpdaterResult, channel]) // Initial check useEffect(() => { - void checkForUpdates(); - }, [checkForUpdates]); + void checkForUpdates() + }, [checkForUpdates]) // Check every 30 minutes - useInterval(checkForUpdates, 30 * 60 * 1000); - const hasUpdateResult = !!autoUpdaterResult?.version; - const hasVersionInfo = !!versions.current && !!versions.latest; + useInterval(checkForUpdates, 30 * 60 * 1000) + + const hasUpdateResult = !!autoUpdaterResult?.version + const hasVersionInfo = !!versions.current && !!versions.latest // Show the component when: // - warning banner needed (above max version), or // - there's an update result to display (success/error), or // - actively checking and we have version info to show - const shouldRender = !!maxVersionIssue || hasUpdateResult || isUpdating && hasVersionInfo; + const shouldRender = + !!maxVersionIssue || hasUpdateResult || (isUpdating && hasVersionInfo) + if (!shouldRender) { - return null; + return null } - return - {verbose && + + return ( + + {verbose && ( + current: {versions.current} · {channel}: {versions.latest} - } - {isUpdating ? + + )} + {isUpdating ? ( + Checking for updates - : autoUpdaterResult?.status === 'success' && showSuccessMessage && updateSemver && + + ) : ( + autoUpdaterResult?.status === 'success' && + showSuccessMessage && + updateSemver && ( + ✓ Update installed · Restart to update - } - {autoUpdaterResult?.status === 'install_failed' && + + ) + )} + {autoUpdaterResult?.status === 'install_failed' && ( + ✗ Auto-update failed · Try /status - } - {maxVersionIssue && (process.env.USER_TYPE) === 'ant' && + + )} + {maxVersionIssue && process.env.USER_TYPE === 'ant' && ( + ⚠ Known issue: {maxVersionIssue} · Run{' '} claude rollback --safe to downgrade - } - ; + + )} + + ) } diff --git a/src/components/NotebookEditToolUseRejectedMessage.tsx b/src/components/NotebookEditToolUseRejectedMessage.tsx index 2a1aaade2..4eb3cf887 100644 --- a/src/components/NotebookEditToolUseRejectedMessage.tsx +++ b/src/components/NotebookEditToolUseRejectedMessage.tsx @@ -1,91 +1,49 @@ -import { c as _c } from "react/compiler-runtime"; -import { relative } from 'path'; -import * as React from 'react'; -import { getCwd } from 'src/utils/cwd.js'; -import { Box, Text } from '../ink.js'; -import { HighlightedCode } from './HighlightedCode.js'; -import { MessageResponse } from './MessageResponse.js'; +import { relative } from 'path' +import * as React from 'react' +import { getCwd } from 'src/utils/cwd.js' +import { Box, Text } from '../ink.js' +import { HighlightedCode } from './HighlightedCode.js' +import { MessageResponse } from './MessageResponse.js' + type Props = { - notebook_path: string; - cell_id: string | undefined; - new_source: string; - cell_type?: 'code' | 'markdown'; - edit_mode?: 'replace' | 'insert' | 'delete'; - verbose: boolean; -}; -export function NotebookEditToolUseRejectedMessage(t0) { - const $ = _c(20); - const { - notebook_path, - cell_id, - new_source, - cell_type, - edit_mode: t1, - verbose - } = t0; - const edit_mode = t1 === undefined ? "replace" : t1; - const operation = edit_mode === "delete" ? "delete" : `${edit_mode} cell in`; - let t2; - if ($[0] !== operation) { - t2 = User rejected {operation} ; - $[0] = operation; - $[1] = t2; - } else { - t2 = $[1]; - } - let t3; - if ($[2] !== notebook_path || $[3] !== verbose) { - t3 = verbose ? notebook_path : relative(getCwd(), notebook_path); - $[2] = notebook_path; - $[3] = verbose; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t3) { - t4 = {t3}; - $[5] = t3; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== cell_id) { - t5 = at cell {cell_id}; - $[7] = cell_id; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== t2 || $[10] !== t4 || $[11] !== t5) { - t6 = {t2}{t4}{t5}; - $[9] = t2; - $[10] = t4; - $[11] = t5; - $[12] = t6; - } else { - t6 = $[12]; - } - let t7; - if ($[13] !== cell_type || $[14] !== edit_mode || $[15] !== new_source) { - t7 = edit_mode !== "delete" && ; - $[13] = cell_type; - $[14] = edit_mode; - $[15] = new_source; - $[16] = t7; - } else { - t7 = $[16]; - } - let t8; - if ($[17] !== t6 || $[18] !== t7) { - t8 = {t6}{t7}; - $[17] = t6; - $[18] = t7; - $[19] = t8; - } else { - t8 = $[19]; - } - return t8; + notebook_path: string + cell_id: string | undefined + new_source: string + cell_type?: 'code' | 'markdown' + edit_mode?: 'replace' | 'insert' | 'delete' + verbose: boolean +} + +export function NotebookEditToolUseRejectedMessage({ + notebook_path, + cell_id, + new_source, + cell_type, + edit_mode = 'replace', + verbose, +}: Props): React.ReactNode { + const operation = edit_mode === 'delete' ? 'delete' : `${edit_mode} cell in` + + return ( + + + + User rejected {operation} + + {verbose ? notebook_path : relative(getCwd(), notebook_path)} + + at cell {cell_id} + + {edit_mode !== 'delete' && ( + + + + )} + + + ) } diff --git a/src/components/OffscreenFreeze.tsx b/src/components/OffscreenFreeze.tsx index 1cb8e9007..51595bb6c 100644 --- a/src/components/OffscreenFreeze.tsx +++ b/src/components/OffscreenFreeze.tsx @@ -1,10 +1,11 @@ -import React, { useContext, useRef } from 'react'; -import { useTerminalViewport } from '../ink/hooks/use-terminal-viewport.js'; -import { Box } from '../ink.js'; -import { InVirtualListContext } from './messageActions.js'; +import React, { useContext, useRef } from 'react' +import { useTerminalViewport } from '../ink/hooks/use-terminal-viewport.js' +import { Box } from '../ink.js' +import { InVirtualListContext } from './messageActions.js' + type Props = { - children: React.ReactNode; -}; + children: React.ReactNode +} /** * Freezes children when they scroll above the terminal viewport (into scrollback). @@ -20,24 +21,19 @@ type Props = { * The cache is one slot deep: the first re-render after scrolling back into view * picks up the live children. Content still updates normally while visible. */ -export function OffscreenFreeze({ - children -}: Props): React.ReactNode { +export function OffscreenFreeze({ children }: Props): React.ReactNode { // React Compiler: reading cached.current in the return is the entire // freeze mechanism — memoizing this component would defeat it. Opt out. - 'use no memo'; - - const inVirtualList = useContext(InVirtualListContext); - const [ref, { - isVisible - }] = useTerminalViewport(); - const cached = useRef(children); + 'use no memo' + const inVirtualList = useContext(InVirtualListContext) + const [ref, { isVisible }] = useTerminalViewport() + const cached = useRef(children) // Virtual list has no terminal scrollback — the ScrollBox clips inside the // viewport, so there's nothing to freeze. Freezing there also blocks // click-to-expand since useTerminalViewport's visibility calc can disagree // with the ScrollBox's virtual scroll position. if (isVisible || inVirtualList) { - cached.current = children; + cached.current = children } - return {cached.current}; + return {cached.current} } diff --git a/src/components/Onboarding.tsx b/src/components/Onboarding.tsx index e0e1fed60..80083ff91 100644 --- a/src/components/Onboarding.tsx +++ b/src/components/Onboarding.tsx @@ -1,68 +1,96 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { setupTerminal, shouldOfferTerminalSetup } from '../commands/terminalSetup/terminalSetup.js'; -import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, Link, Newline, Text, useTheme } from '../ink.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import { isAnthropicAuthEnabled } from '../utils/auth.js'; -import { normalizeApiKeyForConfig } from '../utils/authPortable.js'; -import { getCustomApiKeyStatus } from '../utils/config.js'; -import { env } from '../utils/env.js'; -import { isRunningOnHomespace } from '../utils/envUtils.js'; -import { PreflightStep } from '../utils/preflightChecks.js'; -import type { ThemeSetting } from '../utils/theme.js'; -import { ApproveApiKey } from './ApproveApiKey.js'; -import { ConsoleOAuthFlow } from './ConsoleOAuthFlow.js'; -import { Select } from './CustomSelect/select.js'; -import { WelcomeV2 } from './LogoV2/WelcomeV2.js'; -import { PressEnterToContinue } from './PressEnterToContinue.js'; -import { ThemePicker } from './ThemePicker.js'; -import { OrderedList } from './ui/OrderedList.js'; -type StepId = 'preflight' | 'theme' | 'oauth' | 'api-key' | 'security' | 'terminal-setup'; +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { + setupTerminal, + shouldOfferTerminalSetup, +} from '../commands/terminalSetup/terminalSetup.js' +import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, Link, Newline, Text, useTheme } from '../ink.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import { isAnthropicAuthEnabled } from '../utils/auth.js' +import { normalizeApiKeyForConfig } from '../utils/authPortable.js' +import { getCustomApiKeyStatus } from '../utils/config.js' +import { env } from '../utils/env.js' +import { isRunningOnHomespace } from '../utils/envUtils.js' +import { PreflightStep } from '../utils/preflightChecks.js' +import type { ThemeSetting } from '../utils/theme.js' +import { ApproveApiKey } from './ApproveApiKey.js' +import { ConsoleOAuthFlow } from './ConsoleOAuthFlow.js' +import { Select } from './CustomSelect/select.js' +import { WelcomeV2 } from './LogoV2/WelcomeV2.js' +import { PressEnterToContinue } from './PressEnterToContinue.js' +import { ThemePicker } from './ThemePicker.js' +import { OrderedList } from './ui/OrderedList.js' + +type StepId = + | 'preflight' + | 'theme' + | 'oauth' + | 'api-key' + | 'security' + | 'terminal-setup' + interface OnboardingStep { - id: StepId; - component: React.ReactNode; + id: StepId + component: React.ReactNode } + type Props = { - onDone(): void; -}; -export function Onboarding({ - onDone -}: Props): React.ReactNode { - const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [skipOAuth, setSkipOAuth] = useState(false); - const [oauthEnabled] = useState(() => isAnthropicAuthEnabled()); - const [theme, setTheme] = useTheme(); + onDone(): void +} + +export function Onboarding({ onDone }: Props): React.ReactNode { + const [currentStepIndex, setCurrentStepIndex] = useState(0) + const [skipOAuth, setSkipOAuth] = useState(false) + const [oauthEnabled] = useState(() => isAnthropicAuthEnabled()) + const [theme, setTheme] = useTheme() + useEffect(() => { logEvent('tengu_began_setup', { - oauthEnabled - }); - }, [oauthEnabled]); + oauthEnabled, + }) + }, [oauthEnabled]) + function goToNextStep() { if (currentStepIndex < steps.length - 1) { - const nextIndex = currentStepIndex + 1; - setCurrentStepIndex(nextIndex); + const nextIndex = currentStepIndex + 1 + setCurrentStepIndex(nextIndex) + logEvent('tengu_onboarding_step', { oauthEnabled, - stepId: steps[nextIndex]?.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + stepId: steps[nextIndex] + ?.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } else { - onDone(); + onDone() } } + function handleThemeSelection(newTheme: ThemeSetting) { - setTheme(newTheme); - goToNextStep(); + setTheme(newTheme) + goToNextStep() } - const exitState = useExitOnCtrlCDWithKeybindings(); + + const exitState = useExitOnCtrlCDWithKeybindings() // Define all onboarding steps - const themeStep = - - ; - const securityStep = + const themeStep = ( + + + + ) + + const securityStep = ( + Security notes: {/** @@ -92,152 +120,182 @@ export function Onboarding({ - ; - const preflightStep = ; + + ) + + const preflightStep = // Create the steps array - determine which steps to include based on reAuth and oauthEnabled const apiKeyNeedingApproval = useMemo(() => { // Add API key step if needed // On homespace, ANTHROPIC_API_KEY is preserved in process.env for child // processes but ignored by Claude Code itself (see auth.ts). if (!process.env.ANTHROPIC_API_KEY || isRunningOnHomespace()) { - return ''; + return '' } - const customApiKeyTruncated = normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY); + const customApiKeyTruncated = normalizeApiKeyForConfig( + process.env.ANTHROPIC_API_KEY, + ) if (getCustomApiKeyStatus(customApiKeyTruncated) === 'new') { - return customApiKeyTruncated; + return customApiKeyTruncated } - }, []); + }, []) + function handleApiKeyDone(approved: boolean) { if (approved) { - setSkipOAuth(true); + setSkipOAuth(true) } - goToNextStep(); + goToNextStep() } - const steps: OnboardingStep[] = []; + + const steps: OnboardingStep[] = [] if (oauthEnabled) { - steps.push({ - id: 'preflight', - component: preflightStep - }); + steps.push({ id: 'preflight', component: preflightStep }) } - steps.push({ - id: 'theme', - component: themeStep - }); + steps.push({ id: 'theme', component: themeStep }) + if (apiKeyNeedingApproval) { steps.push({ id: 'api-key', - component: - }); + component: ( + + ), + }) } + if (oauthEnabled) { steps.push({ id: 'oauth', - component: + component: ( + - }); + ), + }) } - steps.push({ - id: 'security', - component: securityStep - }); + + steps.push({ id: 'security', component: securityStep }) + if (shouldOfferTerminalSetup()) { steps.push({ id: 'terminal-setup', - component: + component: ( + Use Claude Code's terminal setup? For the optimal coding experience, enable the recommended settings for your terminal:{' '} - {env.terminal === 'Apple_Terminal' ? 'Option+Enter for newlines and visual bell' : 'Shift+Enter for newlines'} + {env.terminal === 'Apple_Terminal' + ? 'Option+Enter for newlines and visual bell' + : 'Shift+Enter for newlines'} - { + if (value === 'install') { + // Errors already logged in setupTerminal, just swallow and proceed + void setupTerminal(theme) + .catch(() => {}) + .finally(goToNextStep) + } else { + goToNextStep() + } + }} + onCancel={() => goToNextStep()} + /> - {exitState.pending ? <>Press {exitState.keyName} again to exit : <>Enter to confirm · Esc to skip} + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + <>Enter to confirm · Esc to skip + )} - }); + ), + }) } - const currentStep = steps[currentStepIndex]; + + const currentStep = steps[currentStepIndex] // Handle Enter on security step and Escape on terminal-setup step // Dependencies match what goToNextStep uses internally const handleSecurityContinue = useCallback(() => { if (currentStepIndex === steps.length - 1) { - onDone(); + onDone() } else { - goToNextStep(); + goToNextStep() } - }, [currentStepIndex, steps.length, oauthEnabled, onDone]); + }, [currentStepIndex, steps.length, oauthEnabled, onDone]) + const handleTerminalSetupSkip = useCallback(() => { - goToNextStep(); - }, [currentStepIndex, steps.length, oauthEnabled, onDone]); - useKeybindings({ - 'confirm:yes': handleSecurityContinue - }, { - context: 'Confirmation', - isActive: currentStep?.id === 'security' - }); - useKeybindings({ - 'confirm:no': handleTerminalSetupSkip - }, { - context: 'Confirmation', - isActive: currentStep?.id === 'terminal-setup' - }); - return + goToNextStep() + }, [currentStepIndex, steps.length, oauthEnabled, onDone]) + + useKeybindings( + { + 'confirm:yes': handleSecurityContinue, + }, + { + context: 'Confirmation', + isActive: currentStep?.id === 'security', + }, + ) + + useKeybindings( + { + 'confirm:no': handleTerminalSetupSkip, + }, + { + context: 'Confirmation', + isActive: currentStep?.id === 'terminal-setup', + }, + ) + + return ( + {currentStep?.component} - {exitState.pending && + {exitState.pending && ( + Press {exitState.keyName} again to exit - } + + )} - ; + + ) } -export function SkippableStep(t0) { - const $ = _c(4); - const { - skip, - onSkip, - children - } = t0; - let t1; - let t2; - if ($[0] !== onSkip || $[1] !== skip) { - t1 = () => { - if (skip) { - onSkip(); - } - }; - t2 = [skip, onSkip]; - $[0] = onSkip; - $[1] = skip; - $[2] = t1; - $[3] = t2; - } else { - t1 = $[2]; - t2 = $[3]; - } - useEffect(t1, t2); + +export function SkippableStep({ + skip, + onSkip, + children, +}: { + skip: boolean + onSkip(): void + children: React.ReactNode +}): React.ReactNode { + useEffect(() => { + if (skip) { + onSkip() + } + }, [skip, onSkip]) if (skip) { - return null; + return null } - return children; + return children } diff --git a/src/components/OutputStylePicker.tsx b/src/components/OutputStylePicker.tsx index 534b6322d..4ff039a82 100644 --- a/src/components/OutputStylePicker.tsx +++ b/src/components/OutputStylePicker.tsx @@ -1,111 +1,95 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useCallback, useEffect, useState } from 'react'; -import { getAllOutputStyles, OUTPUT_STYLE_CONFIG, type OutputStyleConfig } from '../constants/outputStyles.js'; -import { Box, Text } from '../ink.js'; -import type { OutputStyle } from '../utils/config.js'; -import { getCwd } from '../utils/cwd.js'; -import type { OptionWithDescription } from './CustomSelect/select.js'; -import { Select } from './CustomSelect/select.js'; -import { Dialog } from './design-system/Dialog.js'; -const DEFAULT_OUTPUT_STYLE_LABEL = 'Default'; -const DEFAULT_OUTPUT_STYLE_DESCRIPTION = 'Claude completes coding tasks efficiently and provides concise responses'; +import * as React from 'react' +import { useCallback, useEffect, useState } from 'react' +import { + getAllOutputStyles, + OUTPUT_STYLE_CONFIG, + type OutputStyleConfig, +} from '../constants/outputStyles.js' +import { Box, Text } from '../ink.js' +import type { OutputStyle } from '../utils/config.js' +import { getCwd } from '../utils/cwd.js' +import type { OptionWithDescription } from './CustomSelect/select.js' +import { Select } from './CustomSelect/select.js' +import { Dialog } from './design-system/Dialog.js' + +const DEFAULT_OUTPUT_STYLE_LABEL = 'Default' +const DEFAULT_OUTPUT_STYLE_DESCRIPTION = + 'Claude completes coding tasks efficiently and provides concise responses' + function mapConfigsToOptions(styles: { - [styleName: string]: OutputStyleConfig | null; + [styleName: string]: OutputStyleConfig | null }): OptionWithDescription[] { return Object.entries(styles).map(([style, config]) => ({ label: config?.name ?? DEFAULT_OUTPUT_STYLE_LABEL, value: style, - description: config?.description ?? DEFAULT_OUTPUT_STYLE_DESCRIPTION - })); + description: config?.description ?? DEFAULT_OUTPUT_STYLE_DESCRIPTION, + })) } + export type OutputStylePickerProps = { - initialStyle: OutputStyle; - onComplete: (style: OutputStyle) => void; - onCancel: () => void; - isStandaloneCommand?: boolean; -}; -export function OutputStylePicker(t0) { - const $ = _c(16); - const { - initialStyle, - onComplete, - onCancel, - isStandaloneCommand - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - const [styleOptions, setStyleOptions] = useState(t1); - const [isLoading, setIsLoading] = useState(true); - let t2; - let t3; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => { - getAllOutputStyles(getCwd()).then(allStyles => { - const options = mapConfigsToOptions(allStyles); - setStyleOptions(options); - setIsLoading(false); - }).catch(() => { - const builtInOptions = mapConfigsToOptions(OUTPUT_STYLE_CONFIG); - setStyleOptions(builtInOptions); - setIsLoading(false); - }); - }; - t3 = []; - $[1] = t2; - $[2] = t3; - } else { - t2 = $[1]; - t3 = $[2]; - } - useEffect(t2, t3); - let t4; - if ($[3] !== onComplete) { - t4 = style => { - const outputStyle = style as OutputStyle; - onComplete(outputStyle); - }; - $[3] = onComplete; - $[4] = t4; - } else { - t4 = $[4]; - } - const handleStyleSelect = t4; - const t5 = !isStandaloneCommand; - const t6 = !isStandaloneCommand; - let t7; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t7 = This changes how Claude Code communicates with you; - $[5] = t7; - } else { - t7 = $[5]; - } - let t8; - if ($[6] !== handleStyleSelect || $[7] !== initialStyle || $[8] !== isLoading || $[9] !== styleOptions) { - t8 = {t7}{isLoading ? Loading output styles… : + )} + + + ) } diff --git a/src/components/PackageManagerAutoUpdater.tsx b/src/components/PackageManagerAutoUpdater.tsx index f90b85818..e97d32b12 100644 --- a/src/components/PackageManagerAutoUpdater.tsx +++ b/src/components/PackageManagerAutoUpdater.tsx @@ -1,103 +1,119 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useState } from 'react'; -import { useInterval } from 'usehooks-ts'; -import { Text } from '../ink.js'; -import { type AutoUpdaterResult, getLatestVersionFromGcs, getMaxVersion, shouldSkipVersion } from '../utils/autoUpdater.js'; -import { isAutoUpdaterDisabled } from '../utils/config.js'; -import { logForDebugging } from '../utils/debug.js'; -import { getPackageManager, type PackageManager } from '../utils/nativeInstaller/packageManagers.js'; -import { gt, gte } from '../utils/semver.js'; -import { getInitialSettings } from '../utils/settings/settings.js'; +import * as React from 'react' +import { useState } from 'react' +import { useInterval } from 'usehooks-ts' +import { Text } from '../ink.js' +import { + type AutoUpdaterResult, + getLatestVersionFromGcs, + getMaxVersion, + shouldSkipVersion, +} from '../utils/autoUpdater.js' +import { isAutoUpdaterDisabled } from '../utils/config.js' +import { logForDebugging } from '../utils/debug.js' +import { + getPackageManager, + type PackageManager, +} from '../utils/nativeInstaller/packageManagers.js' +import { gt, gte } from '../utils/semver.js' +import { getInitialSettings } from '../utils/settings/settings.js' + type Props = { - isUpdating: boolean; - onChangeIsUpdating: (isUpdating: boolean) => void; - onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void; - autoUpdaterResult: AutoUpdaterResult | null; - showSuccessMessage: boolean; - verbose: boolean; -}; -export function PackageManagerAutoUpdater(t0) { - const $ = _c(10); - const { - verbose - } = t0; - const [updateAvailable, setUpdateAvailable] = useState(false); - const [packageManager, setPackageManager] = useState("unknown"); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = async () => { - false || false; - if (isAutoUpdaterDisabled()) { - return; - } - const [channel, pm] = await Promise.all([Promise.resolve(getInitialSettings()?.autoUpdatesChannel ?? "latest"), getPackageManager()]); - setPackageManager(pm); - let latest = await getLatestVersionFromGcs(channel); - const maxVersion = await getMaxVersion(); - if (maxVersion && latest && gt(latest, maxVersion)) { - logForDebugging(`PackageManagerAutoUpdater: maxVersion ${maxVersion} is set, capping update from ${latest} to ${maxVersion}`); - if (gte(MACRO.VERSION, maxVersion)) { - logForDebugging(`PackageManagerAutoUpdater: current version ${MACRO.VERSION} is already at or above maxVersion ${maxVersion}, skipping update`); - setUpdateAvailable(false); - return; - } - latest = maxVersion; - } - const hasUpdate = latest && !gte(MACRO.VERSION, latest) && !shouldSkipVersion(latest); - setUpdateAvailable(!!hasUpdate); - if (hasUpdate) { - logForDebugging(`PackageManagerAutoUpdater: Update available ${MACRO.VERSION} -> ${latest}`); - } - }; - $[0] = t1; - } else { - t1 = $[0]; - } - const checkForUpdates = t1; - let t2; - let t3; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => { - checkForUpdates(); - }; - t3 = [checkForUpdates]; - $[1] = t2; - $[2] = t3; - } else { - t2 = $[1]; - t3 = $[2]; - } - React.useEffect(t2, t3); - useInterval(checkForUpdates, 1800000); - if (!updateAvailable) { - return null; - } - const updateCommand = packageManager === "homebrew" ? "brew upgrade claude-code" : packageManager === "winget" ? "winget upgrade Anthropic.ClaudeCode" : packageManager === "apk" ? "apk upgrade claude-code" : "your package manager update command"; - let t4; - if ($[3] !== verbose) { - t4 = verbose && currentVersion: {MACRO.VERSION}; - $[3] = verbose; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] !== updateCommand) { - t5 = Update available! Run: {updateCommand}; - $[5] = updateCommand; - $[6] = t5; - } else { - t5 = $[6]; - } - let t6; - if ($[7] !== t4 || $[8] !== t5) { - t6 = <>{t4}{t5}; - $[7] = t4; - $[8] = t5; - $[9] = t6; - } else { - t6 = $[9]; - } - return t6; + isUpdating: boolean + onChangeIsUpdating: (isUpdating: boolean) => void + onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void + autoUpdaterResult: AutoUpdaterResult | null + showSuccessMessage: boolean + verbose: boolean +} + +export function PackageManagerAutoUpdater({ verbose }: Props): React.ReactNode { + const [updateAvailable, setUpdateAvailable] = useState(false) + const [packageManager, setPackageManager] = + useState('unknown') + + const checkForUpdates = React.useCallback(async () => { + if ( + "production" === 'test' || + "production" === 'development' + ) { + return + } + + if (isAutoUpdaterDisabled()) { + return + } + + const [channel, pm] = await Promise.all([ + Promise.resolve(getInitialSettings()?.autoUpdatesChannel ?? 'latest'), + getPackageManager(), + ]) + setPackageManager(pm) + + let latest = await getLatestVersionFromGcs(channel) + + // Check if max version is set (server-side kill switch for auto-updates) + const maxVersion = await getMaxVersion() + + if (maxVersion && latest && gt(latest, maxVersion)) { + logForDebugging( + `PackageManagerAutoUpdater: maxVersion ${maxVersion} is set, capping update from ${latest} to ${maxVersion}`, + ) + if (gte(MACRO.VERSION, maxVersion)) { + logForDebugging( + `PackageManagerAutoUpdater: current version ${MACRO.VERSION} is already at or above maxVersion ${maxVersion}, skipping update`, + ) + setUpdateAvailable(false) + return + } + latest = maxVersion + } + + const hasUpdate = + latest && !gte(MACRO.VERSION, latest) && !shouldSkipVersion(latest) + + setUpdateAvailable(!!hasUpdate) + + if (hasUpdate) { + logForDebugging( + `PackageManagerAutoUpdater: Update available ${MACRO.VERSION} -> ${latest}`, + ) + } + }, []) + + // Initial check + React.useEffect(() => { + void checkForUpdates() + }, [checkForUpdates]) + + // Check every 30 minutes + useInterval(checkForUpdates, 30 * 60 * 1000) + + if (!updateAvailable) { + return null + } + + // pacman, deb, and rpm don't get specific commands because they each have + // multiple frontends (pacman: yay/paru/makepkg, deb: apt/apt-get/aptitude/nala, + // rpm: dnf/yum/zypper) + const updateCommand = + packageManager === 'homebrew' + ? 'brew upgrade claude-code' + : packageManager === 'winget' + ? 'winget upgrade Anthropic.ClaudeCode' + : packageManager === 'apk' + ? 'apk upgrade claude-code' + : 'your package manager update command' + + return ( + <> + {verbose && ( + + currentVersion: {MACRO.VERSION} + + )} + + Update available! Run: {updateCommand} + + + ) } diff --git a/src/components/Passes/Passes.tsx b/src/components/Passes/Passes.tsx index 291fdbbf9..69388618a 100644 --- a/src/components/Passes/Passes.tsx +++ b/src/components/Passes/Passes.tsx @@ -1,148 +1,189 @@ -import * as React from 'react'; -import { useCallback, useEffect, useState } from 'react'; -import type { CommandResultDisplay } from '../../commands.js'; -import { TEARDROP_ASTERISK } from '../../constants/figures.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { setClipboard } from '../../ink/termio/osc.js'; +import * as React from 'react' +import { useCallback, useEffect, useState } from 'react' +import type { CommandResultDisplay } from '../../commands.js' +import { TEARDROP_ASTERISK } from '../../constants/figures.js' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { setClipboard } from '../../ink/termio/osc.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- enter to copy link -import { Box, Link, Text, useInput } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { logEvent } from '../../services/analytics/index.js'; -import { fetchReferralRedemptions, formatCreditAmount, getCachedOrFetchPassesEligibility } from '../../services/api/referral.js'; -import type { ReferralRedemptionsResponse, ReferrerRewardInfo } from '../../services/oauth/types.js'; -import { count } from '../../utils/array.js'; -import { logError } from '../../utils/log.js'; -import { Pane } from '../design-system/Pane.js'; +import { Box, Link, Text, useInput } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { logEvent } from '../../services/analytics/index.js' +import { + fetchReferralRedemptions, + formatCreditAmount, + getCachedOrFetchPassesEligibility, +} from '../../services/api/referral.js' +import type { + ReferralRedemptionsResponse, + ReferrerRewardInfo, +} from '../../services/oauth/types.js' +import { count } from '../../utils/array.js' +import { logError } from '../../utils/log.js' +import { Pane } from '../design-system/Pane.js' + type PassStatus = { - passNumber: number; - isAvailable: boolean; -}; + passNumber: number + isAvailable: boolean +} + type Props = { - onDone: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -export function Passes({ - onDone -}: Props): React.ReactNode { - const [loading, setLoading] = useState(true); - const [passStatuses, setPassStatuses] = useState([]); - const [isAvailable, setIsAvailable] = useState(false); - const [referralLink, setReferralLink] = useState(null); - const [referrerReward, setReferrerReward] = useState(undefined); - const exitState = useExitOnCtrlCDWithKeybindings(() => onDone('Guest passes dialog dismissed', { - display: 'system' - })); + onDone: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +export function Passes({ onDone }: Props): React.ReactNode { + const [loading, setLoading] = useState(true) + const [passStatuses, setPassStatuses] = useState([]) + const [isAvailable, setIsAvailable] = useState(false) + const [referralLink, setReferralLink] = useState(null) + const [referrerReward, setReferrerReward] = useState< + ReferrerRewardInfo | null | undefined + >(undefined) + + const exitState = useExitOnCtrlCDWithKeybindings(() => + onDone('Guest passes dialog dismissed', { display: 'system' }), + ) + const handleCancel = useCallback(() => { - onDone('Guest passes dialog dismissed', { - display: 'system' - }); - }, [onDone]); - useKeybinding('confirm:no', handleCancel, { - context: 'Confirmation' - }); + onDone('Guest passes dialog dismissed', { display: 'system' }) + }, [onDone]) + + useKeybinding('confirm:no', handleCancel, { context: 'Confirmation' }) + useInput((_input, key) => { if (key.return && referralLink) { void setClipboard(referralLink).then(raw => { - if (raw) process.stdout.write(raw); - logEvent('tengu_guest_passes_link_copied', {}); - onDone(`Referral link copied to clipboard!`); - }); + if (raw) process.stdout.write(raw) + logEvent('tengu_guest_passes_link_copied', {}) + onDone(`Referral link copied to clipboard!`) + }) } - }); + }) + useEffect(() => { async function loadPassesData() { try { // Check eligibility first (uses cache if available) - const eligibilityData = await getCachedOrFetchPassesEligibility(); + const eligibilityData = await getCachedOrFetchPassesEligibility() + if (!eligibilityData || !eligibilityData.eligible) { - setIsAvailable(false); - setLoading(false); - return; + setIsAvailable(false) + setLoading(false) + return } - setIsAvailable(true); + + setIsAvailable(true) // Store the referral link if available if (eligibilityData.referral_code_details?.referral_link) { - setReferralLink(eligibilityData.referral_code_details.referral_link); + setReferralLink(eligibilityData.referral_code_details.referral_link) } // Store referrer reward info for v1 campaign messaging - setReferrerReward(eligibilityData.referrer_reward); + setReferrerReward(eligibilityData.referrer_reward) // Use the campaign returned from eligibility for redemptions - const campaign = eligibilityData.referral_code_details?.campaign ?? 'claude_code_guest_pass'; + const campaign = + eligibilityData.referral_code_details?.campaign ?? + 'claude_code_guest_pass' // Fetch redemptions data - let redemptionsData: ReferralRedemptionsResponse; + let redemptionsData: ReferralRedemptionsResponse try { - redemptionsData = await fetchReferralRedemptions(campaign); - } catch (err_0) { - logError(err_0 as Error); - setIsAvailable(false); - setLoading(false); - return; + redemptionsData = await fetchReferralRedemptions(campaign) + } catch (err) { + logError(err as Error) + setIsAvailable(false) + setLoading(false) + return } // Build pass statuses array - const redemptions = redemptionsData.redemptions || []; - const maxRedemptions = redemptionsData.limit || 3; - const statuses: PassStatus[] = []; + const redemptions = redemptionsData.redemptions || [] + const maxRedemptions = redemptionsData.limit || 3 + const statuses: PassStatus[] = [] + for (let i = 0; i < maxRedemptions; i++) { - const redemption = redemptions[i]; + const redemption = redemptions[i] statuses.push({ passNumber: i + 1, - isAvailable: !redemption - }); + isAvailable: !redemption, + }) } - setPassStatuses(statuses); - setLoading(false); + + setPassStatuses(statuses) + setLoading(false) } catch (err) { // For any error, just show passes as not available - logError(err as Error); - setIsAvailable(false); - setLoading(false); + logError(err as Error) + setIsAvailable(false) + setLoading(false) } } - void loadPassesData(); - }, []); + + void loadPassesData() + }, []) + if (loading) { - return + return ( + Loading guest pass information… - {exitState.pending ? <>Press {exitState.keyName} again to exit : <>Esc to cancel} + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + <>Esc to cancel + )} - ; + + ) } + if (!isAvailable) { - return + return ( + Guest passes are not currently available. - {exitState.pending ? <>Press {exitState.keyName} again to exit : <>Esc to cancel} + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + <>Esc to cancel + )} - ; + + ) } - const availableCount = count(passStatuses, p => p.isAvailable); + + const availableCount = count(passStatuses, p => p.isAvailable) // Sort passes: available first, then redeemed - const sortedPasses = [...passStatuses].sort((a, b) => +b.isAvailable - +a.isAvailable); + const sortedPasses = [...passStatuses].sort( + (a, b) => +b.isAvailable - +a.isAvailable, + ) // ASCII art for tickets const renderTicket = (pass: PassStatus) => { - const isRedeemed = !pass.isAvailable; + const isRedeemed = !pass.isAvailable + if (isRedeemed) { // Grayed out redeemed ticket with slashes - return + return ( + {'┌─────────╱'} {` ) CC ${TEARDROP_ASTERISK} ┊╱`} {'└───────╱'} - ; + + ) } - return + + return ( + {'┌──────────┐'} {' ) CC '} @@ -150,24 +191,37 @@ export function Passes({ {' ┊ ( '} {'└──────────┘'} - ; - }; - return + + ) + } + + return ( + Guest passes · {availableCount} left - {sortedPasses.slice(0, 3).map(pass_0 => renderTicket(pass_0))} + {sortedPasses.slice(0, 3).map(pass => renderTicket(pass))} - {referralLink && + {referralLink && ( + {referralLink} - } + + )} - {referrerReward ? `Share a free week of Claude Code with friends. If they love it and subscribe, you'll get ${formatCreditAmount(referrerReward)} of extra usage to keep building. ` : 'Share a free week of Claude Code with friends. '} - + {referrerReward + ? `Share a free week of Claude Code with friends. If they love it and subscribe, you'll get ${formatCreditAmount(referrerReward)} of extra usage to keep building. ` + : 'Share a free week of Claude Code with friends. '} + Terms apply. @@ -175,9 +229,14 @@ export function Passes({ - {exitState.pending ? <>Press {exitState.keyName} again to exit : <>Enter to copy link · Esc to cancel} + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + <>Enter to copy link · Esc to cancel + )} - ; + + ) } diff --git a/src/components/PrBadge.tsx b/src/components/PrBadge.tsx index ccc94b139..bb0aef9e7 100644 --- a/src/components/PrBadge.tsx +++ b/src/components/PrBadge.tsx @@ -1,96 +1,56 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Link, Text } from '../ink.js'; -import type { PrReviewState } from '../utils/ghPrStatus.js'; +import React from 'react' +import { Link, Text } from '../ink.js' +import type { PrReviewState } from '../utils/ghPrStatus.js' + type Props = { - number: number; - url: string; - reviewState?: PrReviewState; - bold?: boolean; -}; -export function PrBadge(t0) { - const $ = _c(21); - const { - number, - url, - reviewState, - bold - } = t0; - let t1; - if ($[0] !== reviewState) { - t1 = getPrStatusColor(reviewState); - $[0] = reviewState; - $[1] = t1; - } else { - t1 = $[1]; - } - const statusColor = t1; - const t2 = !statusColor && !bold; - let t3; - if ($[2] !== bold || $[3] !== number || $[4] !== statusColor || $[5] !== t2) { - t3 = #{number}; - $[2] = bold; - $[3] = number; - $[4] = statusColor; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - const label = t3; - const t4 = !bold; - let t5; - if ($[7] !== t4) { - t5 = PR; - $[7] = t4; - $[8] = t5; - } else { - t5 = $[8]; - } - const t6 = !statusColor && !bold; - let t7; - if ($[9] !== bold || $[10] !== number || $[11] !== statusColor || $[12] !== t6) { - t7 = #{number}; - $[9] = bold; - $[10] = number; - $[11] = statusColor; - $[12] = t6; - $[13] = t7; - } else { - t7 = $[13]; - } - let t8; - if ($[14] !== label || $[15] !== t7 || $[16] !== url) { - t8 = {t7}; - $[14] = label; - $[15] = t7; - $[16] = url; - $[17] = t8; - } else { - t8 = $[17]; - } - let t9; - if ($[18] !== t5 || $[19] !== t8) { - t9 = {t5}{" "}{t8}; - $[18] = t5; - $[19] = t8; - $[20] = t9; - } else { - t9 = $[20]; - } - return t9; + number: number + url: string + reviewState?: PrReviewState + bold?: boolean } -function getPrStatusColor(state?: PrReviewState): 'success' | 'error' | 'warning' | 'merged' | undefined { + +export function PrBadge({ + number, + url, + reviewState, + bold, +}: Props): React.ReactNode { + const statusColor = getPrStatusColor(reviewState) + const label = ( + + #{number} + + ) + return ( + + PR{' '} + + + #{number} + + + + ) +} + +function getPrStatusColor( + state?: PrReviewState, +): 'success' | 'error' | 'warning' | 'merged' | undefined { switch (state) { case 'approved': - return 'success'; + return 'success' case 'changes_requested': - return 'error'; + return 'error' case 'pending': - return 'warning'; + return 'warning' case 'merged': - return 'merged'; + return 'merged' default: - return undefined; + return undefined } } diff --git a/src/components/PressEnterToContinue.tsx b/src/components/PressEnterToContinue.tsx index 04fa04a48..662c7af85 100644 --- a/src/components/PressEnterToContinue.tsx +++ b/src/components/PressEnterToContinue.tsx @@ -1,14 +1,10 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Text } from '../ink.js'; -export function PressEnterToContinue() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = Press Enter to continue…; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +import * as React from 'react' +import { Text } from '../ink.js' + +export function PressEnterToContinue(): React.ReactNode { + return ( + + Press Enter to continue… + + ) } diff --git a/src/components/QuickOpenDialog.tsx b/src/components/QuickOpenDialog.tsx index 4e103524d..37b7bb7e1 100644 --- a/src/components/QuickOpenDialog.tsx +++ b/src/components/QuickOpenDialog.tsx @@ -1,243 +1,182 @@ -import { c as _c } from "react/compiler-runtime"; -import * as path from 'path'; -import * as React from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { useRegisterOverlay } from '../context/overlayContext.js'; -import { generateFileSuggestions } from '../hooks/fileSuggestions.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { Text } from '../ink.js'; -import { logEvent } from '../services/analytics/index.js'; -import { getCwd } from '../utils/cwd.js'; -import { openFileInExternalEditor } from '../utils/editor.js'; -import { truncatePathMiddle, truncateToWidth } from '../utils/format.js'; -import { highlightMatch } from '../utils/highlightMatch.js'; -import { readFileInRange } from '../utils/readFileInRange.js'; -import { FuzzyPicker } from './design-system/FuzzyPicker.js'; -import { LoadingState } from './design-system/LoadingState.js'; +import * as path from 'path' +import * as React from 'react' +import { useEffect, useRef, useState } from 'react' +import { useRegisterOverlay } from '../context/overlayContext.js' +import { generateFileSuggestions } from '../hooks/fileSuggestions.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { Text } from '../ink.js' +import { logEvent } from '../services/analytics/index.js' +import { getCwd } from '../utils/cwd.js' +import { openFileInExternalEditor } from '../utils/editor.js' +import { truncatePathMiddle, truncateToWidth } from '../utils/format.js' +import { highlightMatch } from '../utils/highlightMatch.js' +import { readFileInRange } from '../utils/readFileInRange.js' +import { FuzzyPicker } from './design-system/FuzzyPicker.js' +import { LoadingState } from './design-system/LoadingState.js' + type Props = { - onDone: () => void; - onInsert: (text: string) => void; -}; -const VISIBLE_RESULTS = 8; -const PREVIEW_LINES = 20; + onDone: () => void + onInsert: (text: string) => void +} + +const VISIBLE_RESULTS = 8 +const PREVIEW_LINES = 20 /** * Quick Open dialog (ctrl+shift+p / cmd+shift+p). * Fuzzy file finder with a syntax-highlighted preview of the focused file. */ -export function QuickOpenDialog(t0) { - const $ = _c(35); - const { - onDone, - onInsert - } = t0; - useRegisterOverlay("quick-open", undefined); - const { - columns, - rows - } = useTerminalSize(); - const visibleResults = Math.min(VISIBLE_RESULTS, Math.max(4, rows - 14)); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; +export function QuickOpenDialog({ onDone, onInsert }: Props): React.ReactNode { + useRegisterOverlay('quick-open') + const { columns, rows } = useTerminalSize() + // Chrome (title + search + hints + pane border + gaps) eats ~14 rows. + // Shrink the list on short terminals so the dialog doesn't clip. + const visibleResults = Math.min(VISIBLE_RESULTS, Math.max(4, rows - 14)) + + const [results, setResults] = useState([]) + const [query, setQuery] = useState('') + const [focusedPath, setFocusedPath] = useState(undefined) + const [preview, setPreview] = useState<{ + path: string + content: string + } | null>(null) + const queryGenRef = useRef(0) + useEffect(() => () => void queryGenRef.current++, []) + + const previewOnRight = columns >= 120 + // Side preview sits in a fixed-height row alongside the list (visibleCount + // rows), so overflowing that height garbles the layout — cap to fit, minus + // one for the path header line. + const effectivePreviewLines = previewOnRight + ? VISIBLE_RESULTS - 1 + : PREVIEW_LINES + + // A generation counter invalidates stale results if the user types faster + // than the index can respond. + const handleQueryChange = (q: string) => { + setQuery(q) + const gen = ++queryGenRef.current + if (!q.trim()) { + // generateFileSuggestions('') returns raw readdir() of cwd (designed for + // @-mentions). For Quick Open that's just noise — show the empty state. + setResults([]) + return + } + void generateFileSuggestions(q, true).then(items => { + if (gen !== queryGenRef.current) return + // Filter out directory entries — they come back with a trailing path.sep + // from getTopLevelPaths() and would cause readFileInRange to throw EISDIR, + // leaving the preview pane stuck on "Loading preview…". + // Normalize separators to '/' so truncatePathMiddle (which uses + // lastIndexOf('/')) can find the filename on Windows too. + const paths = items + .filter(i => i.id.startsWith('file-')) + .map(i => i.displayText) + .filter(p => !p.endsWith(path.sep)) + .map(p => p.split(path.sep).join('/')) + setResults(paths) + }) } - const [results, setResults] = useState(t1); - const [query, setQuery] = useState(""); - const [focusedPath, setFocusedPath] = useState(undefined); - const [preview, setPreview] = useState(null); - const queryGenRef = useRef(0); - let t2; - let t3; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => () => { - queryGenRef.current = queryGenRef.current + 1; - return void queryGenRef.current; - }; - t3 = []; - $[1] = t2; - $[2] = t3; - } else { - t2 = $[1]; - t3 = $[2]; + + // Load a short preview of the focused file. Each navigation aborts the + // previous read so holding ↓ doesn't pile up whole-file reads and so a + // slow early read can't overwrite a faster later one. The stale preview + // stays visible until the new one arrives — renderPreview overlays a dim + // loading indicator rather than blanking the pane. + useEffect(() => { + if (!focusedPath) { + // No results — clear so the empty-state renders instead of a stale + // preview from a previous query. + setPreview(null) + return + } + const controller = new AbortController() + const absolute = path.resolve(getCwd(), focusedPath) + void readFileInRange( + absolute, + 0, + effectivePreviewLines, + undefined, + controller.signal, + ) + .then(r => { + if (controller.signal.aborted) return + setPreview({ path: focusedPath, content: r.content }) + }) + .catch(() => { + if (controller.signal.aborted) return + setPreview({ path: focusedPath, content: '(preview unavailable)' }) + }) + return () => controller.abort() + }, [focusedPath, effectivePreviewLines]) + + const maxPathWidth = previewOnRight + ? Math.max(20, Math.floor((columns - 10) * 0.4)) + : Math.max(20, columns - 8) + const previewWidth = previewOnRight + ? Math.max(40, columns - maxPathWidth - 14) + : columns - 6 + + const handleOpen = (p: string) => { + const opened = openFileInExternalEditor(path.resolve(getCwd(), p)) + logEvent('tengu_quick_open_select', { + result_count: results.length, + opened_editor: opened, + }) + onDone() } - useEffect(t2, t3); - const previewOnRight = columns >= 120; - const effectivePreviewLines = previewOnRight ? VISIBLE_RESULTS - 1 : PREVIEW_LINES; - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = q => { - setQuery(q); - const gen = queryGenRef.current = queryGenRef.current + 1; - if (!q.trim()) { - setResults([]); - return; + + const handleInsert = (p: string, mention: boolean) => { + onInsert(mention ? `@${p} ` : `${p} `) + logEvent('tengu_quick_open_insert', { + result_count: results.length, + mention, + }) + onDone() + } + + return ( + p} + visibleCount={visibleResults} + direction="up" + previewPosition={previewOnRight ? 'right' : 'bottom'} + onQueryChange={handleQueryChange} + onFocus={setFocusedPath} + onSelect={handleOpen} + onTab={{ action: 'mention', handler: p => handleInsert(p, true) }} + onShiftTab={{ + action: 'insert path', + handler: p => handleInsert(p, false), + }} + onCancel={onDone} + emptyMessage={q => (q ? 'No matching files' : 'Start typing to search…')} + selectAction="open in editor" + renderItem={(p, isFocused) => ( + + {truncatePathMiddle(p, maxPathWidth)} + + )} + renderPreview={p => + preview ? ( + <> + + {truncatePathMiddle(p, previewWidth)} + {preview.path !== p ? ' · loading…' : ''} + + {preview.content.split('\n').map((line, i) => ( + + {highlightMatch(truncateToWidth(line, previewWidth), query)} + + ))} + + ) : ( + + ) } - generateFileSuggestions(q, true).then(items => { - if (gen !== queryGenRef.current) { - return; - } - const paths = items.filter(_temp).map(_temp2).filter(_temp3).map(_temp4); - setResults(paths); - }); - }; - $[3] = t4; - } else { - t4 = $[3]; - } - const handleQueryChange = t4; - let t5; - let t6; - if ($[4] !== effectivePreviewLines || $[5] !== focusedPath) { - t5 = () => { - if (!focusedPath) { - setPreview(null); - return; - } - const controller = new AbortController(); - const absolute = path.resolve(getCwd(), focusedPath); - readFileInRange(absolute, 0, effectivePreviewLines, undefined, controller.signal).then(r => { - if (controller.signal.aborted) { - return; - } - setPreview({ - path: focusedPath, - content: r.content - }); - }).catch(() => { - if (controller.signal.aborted) { - return; - } - setPreview({ - path: focusedPath, - content: "(preview unavailable)" - }); - }); - return () => controller.abort(); - }; - t6 = [focusedPath, effectivePreviewLines]; - $[4] = effectivePreviewLines; - $[5] = focusedPath; - $[6] = t5; - $[7] = t6; - } else { - t5 = $[6]; - t6 = $[7]; - } - useEffect(t5, t6); - const maxPathWidth = previewOnRight ? Math.max(20, Math.floor((columns - 10) * 0.4)) : Math.max(20, columns - 8); - const previewWidth = previewOnRight ? Math.max(40, columns - maxPathWidth - 14) : columns - 6; - let t7; - if ($[8] !== onDone || $[9] !== results.length) { - t7 = p_1 => { - const opened = openFileInExternalEditor(path.resolve(getCwd(), p_1)); - logEvent("tengu_quick_open_select", { - result_count: results.length, - opened_editor: opened - }); - onDone(); - }; - $[8] = onDone; - $[9] = results.length; - $[10] = t7; - } else { - t7 = $[10]; - } - const handleOpen = t7; - let t8; - if ($[11] !== onDone || $[12] !== onInsert || $[13] !== results.length) { - t8 = (p_2, mention) => { - onInsert(mention ? `@${p_2} ` : `${p_2} `); - logEvent("tengu_quick_open_insert", { - result_count: results.length, - mention - }); - onDone(); - }; - $[11] = onDone; - $[12] = onInsert; - $[13] = results.length; - $[14] = t8; - } else { - t8 = $[14]; - } - const handleInsert = t8; - const t9 = previewOnRight ? "right" : "bottom"; - let t10; - if ($[15] !== handleInsert) { - t10 = { - action: "mention", - handler: p_4 => handleInsert(p_4, true) - }; - $[15] = handleInsert; - $[16] = t10; - } else { - t10 = $[16]; - } - let t11; - if ($[17] !== handleInsert) { - t11 = { - action: "insert path", - handler: p_5 => handleInsert(p_5, false) - }; - $[17] = handleInsert; - $[18] = t11; - } else { - t11 = $[18]; - } - let t12; - if ($[19] !== maxPathWidth) { - t12 = (p_6, isFocused) => {truncatePathMiddle(p_6, maxPathWidth)}; - $[19] = maxPathWidth; - $[20] = t12; - } else { - t12 = $[20]; - } - let t13; - if ($[21] !== preview || $[22] !== previewWidth || $[23] !== query) { - t13 = p_7 => preview ? <>{truncatePathMiddle(p_7, previewWidth)}{preview.path !== p_7 ? " \xB7 loading\u2026" : ""}{preview.content.split("\n").map((line, i_1) => {highlightMatch(truncateToWidth(line, previewWidth), query)})} : ; - $[21] = preview; - $[22] = previewWidth; - $[23] = query; - $[24] = t13; - } else { - t13 = $[24]; - } - let t14; - if ($[25] !== handleOpen || $[26] !== onDone || $[27] !== results || $[28] !== t10 || $[29] !== t11 || $[30] !== t12 || $[31] !== t13 || $[32] !== t9 || $[33] !== visibleResults) { - t14 = ; - $[25] = handleOpen; - $[26] = onDone; - $[27] = results; - $[28] = t10; - $[29] = t11; - $[30] = t12; - $[31] = t13; - $[32] = t9; - $[33] = visibleResults; - $[34] = t14; - } else { - t14 = $[34]; - } - return t14; -} -function _temp6(q_0) { - return q_0 ? "No matching files" : "Start typing to search\u2026"; -} -function _temp5(p_3) { - return p_3; -} -function _temp4(p_0) { - return p_0.split(path.sep).join("/"); -} -function _temp3(p) { - return !p.endsWith(path.sep); -} -function _temp2(i_0) { - return i_0.displayText; -} -function _temp(i) { - return i.id.startsWith("file-"); + /> + ) } diff --git a/src/components/RemoteCallout.tsx b/src/components/RemoteCallout.tsx index 15da3a581..d6b0af589 100644 --- a/src/components/RemoteCallout.tsx +++ b/src/components/RemoteCallout.tsx @@ -1,47 +1,53 @@ -import React, { useCallback, useEffect, useRef } from 'react'; -import { isBridgeEnabled } from '../bridge/bridgeEnabled.js'; -import { Box, Text } from '../ink.js'; -import { getClaudeAIOAuthTokens } from '../utils/auth.js'; -import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'; -import type { OptionWithDescription } from './CustomSelect/select.js'; -import { Select } from './CustomSelect/select.js'; -import { PermissionDialog } from './permissions/PermissionDialog.js'; -type RemoteCalloutSelection = 'enable' | 'dismiss'; +import React, { useCallback, useEffect, useRef } from 'react' +import { isBridgeEnabled } from '../bridge/bridgeEnabled.js' +import { Box, Text } from '../ink.js' +import { getClaudeAIOAuthTokens } from '../utils/auth.js' +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' +import type { OptionWithDescription } from './CustomSelect/select.js' +import { Select } from './CustomSelect/select.js' +import { PermissionDialog } from './permissions/PermissionDialog.js' + +type RemoteCalloutSelection = 'enable' | 'dismiss' + type Props = { - onDone: (selection: RemoteCalloutSelection) => void; -}; -export function RemoteCallout({ - onDone -}: Props): React.ReactNode { - const onDoneRef = useRef(onDone); - onDoneRef.current = onDone; + onDone: (selection: RemoteCalloutSelection) => void +} + +export function RemoteCallout({ onDone }: Props): React.ReactNode { + const onDoneRef = useRef(onDone) + onDoneRef.current = onDone + const handleCancel = useCallback((): void => { - onDoneRef.current('dismiss'); - }, []); + onDoneRef.current('dismiss') + }, []) // Permanently mark as seen on mount so it only shows once useEffect(() => { saveGlobalConfig(current => { - if (current.remoteDialogSeen) return current; - return { - ...current, - remoteDialogSeen: true - }; - }); - }, []); + if (current.remoteDialogSeen) return current + return { ...current, remoteDialogSeen: true } + }) + }, []) + const handleSelect = useCallback((value: RemoteCalloutSelection): void => { - onDoneRef.current(value); - }, []); - const options: OptionWithDescription[] = [{ - label: 'Enable Remote Control for this session', - description: 'Opens a secure connection to claude.ai.', - value: 'enable' - }, { - label: 'Never mind', - description: 'You can always enable it later with /remote-control.', - value: 'dismiss' - }]; - return + onDoneRef.current(value) + }, []) + + const options: OptionWithDescription[] = [ + { + label: 'Enable Remote Control for this session', + description: 'Opens a secure connection to claude.ai.', + value: 'enable', + }, + { + label: 'Never mind', + description: 'You can always enable it later with /remote-control.', + value: 'dismiss', + }, + ] + + return ( + @@ -56,20 +62,25 @@ export function RemoteCallout({ - - ; + + ) } /** * Check whether to show the remote callout (first-time dialog). */ export function shouldShowRemoteCallout(): boolean { - const config = getGlobalConfig(); - if (config.remoteDialogSeen) return false; - if (!isBridgeEnabled()) return false; - const tokens = getClaudeAIOAuthTokens(); - if (!tokens?.accessToken) return false; - return true; + const config = getGlobalConfig() + if (config.remoteDialogSeen) return false + if (!isBridgeEnabled()) return false + const tokens = getClaudeAIOAuthTokens() + if (!tokens?.accessToken) return false + return true } diff --git a/src/components/RemoteEnvironmentDialog.tsx b/src/components/RemoteEnvironmentDialog.tsx index ce1d98743..b0f47a60c 100644 --- a/src/components/RemoteEnvironmentDialog.tsx +++ b/src/components/RemoteEnvironmentDialog.tsx @@ -1,339 +1,237 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import figures from 'figures'; -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { Text } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { toError } from '../utils/errors.js'; -import { logError } from '../utils/log.js'; -import { getSettingSourceName, type SettingSource } from '../utils/settings/constants.js'; -import { updateSettingsForSource } from '../utils/settings/settings.js'; -import { getEnvironmentSelectionInfo } from '../utils/teleport/environmentSelection.js'; -import type { EnvironmentResource } from '../utils/teleport/environments.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { Select } from './CustomSelect/select.js'; -import { Byline } from './design-system/Byline.js'; -import { Dialog } from './design-system/Dialog.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { LoadingState } from './design-system/LoadingState.js'; -const DIALOG_TITLE = 'Select Remote Environment'; -const SETUP_HINT = `Configure environments at: https://claude.ai/code`; +import chalk from 'chalk' +import figures from 'figures' +import * as React from 'react' +import { useEffect, useState } from 'react' +import { Text } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { toError } from '../utils/errors.js' +import { logError } from '../utils/log.js' +import { + getSettingSourceName, + type SettingSource, +} from '../utils/settings/constants.js' +import { updateSettingsForSource } from '../utils/settings/settings.js' +import { getEnvironmentSelectionInfo } from '../utils/teleport/environmentSelection.js' +import type { EnvironmentResource } from '../utils/teleport/environments.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { Select } from './CustomSelect/select.js' +import { Byline } from './design-system/Byline.js' +import { Dialog } from './design-system/Dialog.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { LoadingState } from './design-system/LoadingState.js' + +const DIALOG_TITLE = 'Select Remote Environment' +const SETUP_HINT = `Configure environments at: https://claude.ai/code` + type Props = { - onDone: (message?: string) => void; -}; -type LoadingState = 'loading' | 'updating' | null; -export function RemoteEnvironmentDialog(t0) { - const $ = _c(27); - const { - onDone - } = t0; - const [loadingState, setLoadingState] = useState("loading"); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - const [environments, setEnvironments] = useState(t1); - const [selectedEnvironment, setSelectedEnvironment] = useState(null); - const [selectedEnvironmentSource, setSelectedEnvironmentSource] = useState(null); - const [error, setError] = useState(null); - let t2; - let t3; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => { - let cancelled = false; - const fetchInfo = async function fetchInfo() { - ; - try { - const result = await getEnvironmentSelectionInfo(); - if (cancelled) { - return; - } - setEnvironments(result.availableEnvironments); - setSelectedEnvironment(result.selectedEnvironment); - setSelectedEnvironmentSource(result.selectedEnvironmentSource); - setLoadingState(null); - } catch (t4) { - const err = t4; - if (cancelled) { - return; - } - const fetchError = toError(err); - logError(fetchError); - setError(fetchError.message); - setLoadingState(null); - } - }; - fetchInfo(); - return () => { - cancelled = true; - }; - }; - t3 = []; - $[1] = t2; - $[2] = t3; - } else { - t2 = $[1]; - t3 = $[2]; - } - useEffect(t2, t3); - let t4; - if ($[3] !== environments || $[4] !== onDone) { - t4 = function handleSelect(value) { - if (value === "cancel") { - onDone(); - return; + onDone: (message?: string) => void +} + +type LoadingState = 'loading' | 'updating' | null + +export function RemoteEnvironmentDialog({ onDone }: Props): React.ReactNode { + const [loadingState, setLoadingState] = useState('loading') + const [environments, setEnvironments] = useState([]) + const [selectedEnvironment, setSelectedEnvironment] = + useState(null) + const [selectedEnvironmentSource, setSelectedEnvironmentSource] = + useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + async function fetchInfo(): Promise { + try { + const result = await getEnvironmentSelectionInfo() + if (cancelled) return + setEnvironments(result.availableEnvironments) + setSelectedEnvironment(result.selectedEnvironment) + setSelectedEnvironmentSource(result.selectedEnvironmentSource) + setLoadingState(null) + } catch (err) { + if (cancelled) return + const fetchError = toError(err) + logError(fetchError) + setError(fetchError.message) + setLoadingState(null) } - setLoadingState("updating"); - const selectedEnv = environments.find(env => env.environment_id === value); - if (!selectedEnv) { - onDone("Error: Selected environment not found"); - return; - } - updateSettingsForSource("localSettings", { - remote: { - defaultEnvironmentId: selectedEnv.environment_id - } - }); - onDone(`Set default remote environment to ${chalk.bold(selectedEnv.name)} (${selectedEnv.environment_id})`); - }; - $[3] = environments; - $[4] = onDone; - $[5] = t4; - } else { - t4 = $[5]; - } - const handleSelect = t4; - if (loadingState === "loading") { - let t5; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t5 = ; - $[6] = t5; - } else { - t5 = $[6]; } - let t6; - if ($[7] !== onDone) { - t6 = {t5}; - $[7] = onDone; - $[8] = t6; - } else { - t6 = $[8]; + void fetchInfo() + return () => { + cancelled = true } - return t6; + }, []) + + function handleSelect(value: string): void { + if (value === 'cancel') { + onDone() + return + } + + setLoadingState('updating') + + const selectedEnv = environments.find(env => env.environment_id === value) + + if (!selectedEnv) { + onDone('Error: Selected environment not found') + return + } + + updateSettingsForSource('localSettings', { + remote: { + defaultEnvironmentId: selectedEnv.environment_id, + }, + }) + + onDone( + `Set default remote environment to ${chalk.bold(selectedEnv.name)} (${selectedEnv.environment_id})`, + ) } + + // Loading state + if (loadingState === 'loading') { + return ( + + + + ) + } + + // Error state if (error) { - let t5; - if ($[9] !== error) { - t5 = Error: {error}; - $[9] = error; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== onDone || $[12] !== t5) { - t6 = {t5}; - $[11] = onDone; - $[12] = t5; - $[13] = t6; - } else { - t6 = $[13]; - } - return t6; + return ( + + Error: {error} + + ) } + + // No environments available if (!selectedEnvironment) { - let t5; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t5 = No remote environments available.; - $[14] = t5; - } else { - t5 = $[14]; - } - let t6; - if ($[15] !== onDone) { - t6 = {t5}; - $[15] = onDone; - $[16] = t6; - } else { - t6 = $[16]; - } - return t6; + return ( + + No remote environments available. + + ) } + + // Single environment - just show info if (environments.length === 1) { - let t5; - if ($[17] !== onDone || $[18] !== selectedEnvironment) { - t5 = ; - $[17] = onDone; - $[18] = selectedEnvironment; - $[19] = t5; - } else { - t5 = $[19]; - } - return t5; + return ( + + ) } - let t5; - if ($[20] !== environments || $[21] !== handleSelect || $[22] !== loadingState || $[23] !== onDone || $[24] !== selectedEnvironment || $[25] !== selectedEnvironmentSource) { - t5 = ; - $[20] = environments; - $[21] = handleSelect; - $[22] = loadingState; - $[23] = onDone; - $[24] = selectedEnvironment; - $[25] = selectedEnvironmentSource; - $[26] = t5; - } else { - t5 = $[26]; - } - return t5; + + // Multiple environments - show selection UI + return ( + + ) } -function EnvironmentLabel(t0) { - const $ = _c(7); - const { - environment - } = t0; - let t1; - if ($[0] !== environment.name) { - t1 = {environment.name}; - $[0] = environment.name; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== environment.environment_id) { - t2 = ({environment.environment_id}); - $[2] = environment.environment_id; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== t1 || $[5] !== t2) { - t3 = {figures.tick} Using {t1}{" "}{t2}; - $[4] = t1; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - return t3; + +function EnvironmentLabel({ + environment, +}: { + environment: EnvironmentResource +}): React.ReactNode { + return ( + + {figures.tick} Using {environment.name}{' '} + ({environment.environment_id}) + + ) } -function SingleEnvironmentContent(t0) { - const $ = _c(6); - const { - environment, - onDone - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - context: "Confirmation" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - useKeybinding("confirm:yes", onDone, t1); - let t2; - if ($[1] !== environment) { - t2 = ; - $[1] = environment; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== onDone || $[4] !== t2) { - t3 = {t2}; - $[3] = onDone; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - return t3; + +function SingleEnvironmentContent({ + environment, + onDone, +}: { + environment: EnvironmentResource + onDone: () => void +}): React.ReactNode { + // Handle Enter to continue + useKeybinding('confirm:yes', onDone, { context: 'Confirmation' }) + + return ( + + + + ) } -function MultipleEnvironmentsContent(t0) { - const $ = _c(18); - const { - environments, - selectedEnvironment, - selectedEnvironmentSource, - loadingState, - onSelect, - onCancel - } = t0; - let t1; - if ($[0] !== selectedEnvironmentSource) { - t1 = selectedEnvironmentSource && selectedEnvironmentSource !== "localSettings" ? ` (from ${getSettingSourceName(selectedEnvironmentSource)} settings)` : ""; - $[0] = selectedEnvironmentSource; - $[1] = t1; - } else { - t1 = $[1]; - } - const sourceSuffix = t1; - let t2; - if ($[2] !== selectedEnvironment.name) { - t2 = {selectedEnvironment.name}; - $[2] = selectedEnvironment.name; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== sourceSuffix || $[5] !== t2) { - t3 = Currently using: {t2}{sourceSuffix}; - $[4] = sourceSuffix; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - const subtitle = t3; - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = {SETUP_HINT}; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== environments || $[9] !== loadingState || $[10] !== onSelect || $[11] !== selectedEnvironment.environment_id) { - t5 = loadingState === "updating" ? : ({ + label: ( + + {env.name} ({env.environment_id}) + + ), + value: env.environment_id, + }))} + defaultValue={selectedEnvironment.environment_id} + onChange={onSelect} + onCancel={() => onSelect('cancel')} + layout="compact-vertical" + /> + )} + + + + + + + + ) } diff --git a/src/components/ResumeTask.tsx b/src/components/ResumeTask.tsx index b8f5e8241..8b657ab0d 100644 --- a/src/components/ResumeTask.tsx +++ b/src/components/ResumeTask.tsx @@ -1,122 +1,139 @@ -import React, { useCallback, useState } from 'react'; -import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; -import { type CodeSession, fetchCodeSessionsFromSessionsAPI } from 'src/utils/teleport/api.js'; +import React, { useCallback, useState } from 'react' +import { useTerminalSize } from 'src/hooks/useTerminalSize.js' +import { + type CodeSession, + fetchCodeSessionsFromSessionsAPI, +} from 'src/utils/teleport/api.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow list navigation -import { Box, Text, useInput } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; -import { logForDebugging } from '../utils/debug.js'; -import { detectCurrentRepository } from '../utils/detectRepository.js'; -import { formatRelativeTime } from '../utils/format.js'; -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; -import { Select } from './CustomSelect/index.js'; -import { Byline } from './design-system/Byline.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { Spinner } from './Spinner.js'; -import { TeleportError } from './TeleportError.js'; +import { Box, Text, useInput } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' +import { logForDebugging } from '../utils/debug.js' +import { detectCurrentRepository } from '../utils/detectRepository.js' +import { formatRelativeTime } from '../utils/format.js' +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' +import { Select } from './CustomSelect/index.js' +import { Byline } from './design-system/Byline.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { Spinner } from './Spinner.js' +import { TeleportError } from './TeleportError.js' + type Props = { - onSelect: (session: CodeSession) => void; - onCancel: () => void; - isEmbedded?: boolean; -}; -type LoadErrorType = 'network' | 'auth' | 'api' | 'other'; -const UPDATED_STRING = 'Updated'; -const SPACE_BETWEEN_TABLE_COLUMNS = ' '; + onSelect: (session: CodeSession) => void + onCancel: () => void + isEmbedded?: boolean +} + +type LoadErrorType = 'network' | 'auth' | 'api' | 'other' + +const UPDATED_STRING = 'Updated' +const SPACE_BETWEEN_TABLE_COLUMNS = ' ' + export function ResumeTask({ onSelect, onCancel, - isEmbedded = false + isEmbedded = false, }: Props): React.ReactNode { - const { - rows - } = useTerminalSize(); - const [sessions, setSessions] = useState([]); - const [currentRepo, setCurrentRepo] = useState(null); - const [loading, setLoading] = useState(true); - const [loadErrorType, setLoadErrorType] = useState(null); - const [retrying, setRetrying] = useState(false); - const [hasCompletedTeleportErrorFlow, setHasCompletedTeleportErrorFlow] = useState(false); + const { rows } = useTerminalSize() + const [sessions, setSessions] = useState([]) + const [currentRepo, setCurrentRepo] = useState(null) + + const [loading, setLoading] = useState(true) + const [loadErrorType, setLoadErrorType] = useState(null) + const [retrying, setRetrying] = useState(false) + + const [hasCompletedTeleportErrorFlow, setHasCompletedTeleportErrorFlow] = + useState(false) // Track focused index for scroll position display in title - const [focusedIndex, setFocusedIndex] = useState(1); - const escKey = useShortcutDisplay('confirm:no', 'Confirmation', 'Esc'); + const [focusedIndex, setFocusedIndex] = useState(1) + + const escKey = useShortcutDisplay('confirm:no', 'Confirmation', 'Esc') + const loadSessions = useCallback(async () => { try { - setLoading(true); - setLoadErrorType(null); + setLoading(true) + setLoadErrorType(null) // Detect current repository - const detectedRepo = await detectCurrentRepository(); - setCurrentRepo(detectedRepo); - logForDebugging(`Current repository: ${detectedRepo || 'not detected'}`); - const codeSessions = await fetchCodeSessionsFromSessionsAPI(); + const detectedRepo = await detectCurrentRepository() + setCurrentRepo(detectedRepo) + logForDebugging(`Current repository: ${detectedRepo || 'not detected'}`) + + const codeSessions = await fetchCodeSessionsFromSessionsAPI() // Filter sessions by current repository if detected - let filteredSessions = codeSessions; + let filteredSessions = codeSessions if (detectedRepo) { filteredSessions = codeSessions.filter(session => { - if (!session.repo) return false; - const sessionRepo = `${session.repo.owner.login}/${session.repo.name}`; - return sessionRepo === detectedRepo; - }); - logForDebugging(`Filtered ${filteredSessions.length} sessions for repo ${detectedRepo} from ${codeSessions.length} total`); + if (!session.repo) return false + const sessionRepo = `${session.repo.owner.login}/${session.repo.name}` + return sessionRepo === detectedRepo + }) + logForDebugging( + `Filtered ${filteredSessions.length} sessions for repo ${detectedRepo} from ${codeSessions.length} total`, + ) } // Sort by updated_at (newest first) const sortedSessions = [...filteredSessions].sort((a, b) => { - const dateA = new Date(a.updated_at); - const dateB = new Date(b.updated_at); - return dateB.getTime() - dateA.getTime(); - }); - setSessions(sortedSessions); + const dateA = new Date(a.updated_at) + const dateB = new Date(b.updated_at) + return dateB.getTime() - dateA.getTime() + }) + + setSessions(sortedSessions) } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - logForDebugging(`Error loading code sessions: ${errorMessage}`); - setLoadErrorType(determineErrorType(errorMessage)); + const errorMessage = err instanceof Error ? err.message : String(err) + logForDebugging(`Error loading code sessions: ${errorMessage}`) + setLoadErrorType(determineErrorType(errorMessage)) } finally { - setLoading(false); - setRetrying(false); + setLoading(false) + setRetrying(false) } - }, []); + }, []) + const handleRetry = () => { - setRetrying(true); - void loadSessions(); - }; + setRetrying(true) + void loadSessions() + } // Handle escape via keybinding - useKeybinding('confirm:no', onCancel, { - context: 'Confirmation' - }); + useKeybinding('confirm:no', onCancel, { context: 'Confirmation' }) + useInput((input, key) => { // We need to handle ctrl+c in case we don't render a { - const session_1 = sessions.find(s => s.id === value); - if (session_1) { - onSelect(session_1); - } - }} onFocus={value_0 => { - const index = options.findIndex(o => o.value === value_0); - if (index >= 0) { - setFocusedIndex(index + 1); - } - }} /> + { - isDirty.current = true; - setShowSubmenu(null); - setTabsHidden(false); - saveGlobalConfig(current_24 => ({ - ...current_24, - autoUpdates: true - })); - setGlobalConfig({ - ...getGlobalConfig(), - autoUpdates: true - }); - updateSettingsForSource('userSettings', { - autoUpdatesChannel: channel as 'latest' | 'stable', - minimumVersion: undefined - }); - setSettingsData(prev_26 => ({ - ...prev_26, - autoUpdatesChannel: channel as 'latest' | 'stable', - minimumVersion: undefined - })); - logEvent('tengu_autoupdate_enabled', { - channel: channel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - }} />} - : showSubmenu === 'ChannelDowngrade' ? { - setShowSubmenu(null); - setTabsHidden(false); - if (choice === 'cancel') { - // User cancelled - don't change anything - return; - } - isDirty.current = true; - // Switch to stable channel - const newSettings: { - autoUpdatesChannel: 'stable'; - minimumVersion?: string; - } = { - autoUpdatesChannel: 'stable' - }; - if (choice === 'stay') { - // User wants to stay on current version until stable catches up - newSettings.minimumVersion = MACRO.VERSION; - } - updateSettingsForSource('userSettings', newSettings); - setSettingsData(prev_27 => ({ - ...prev_27, - ...newSettings - })); - logEvent('tengu_autoupdate_channel_changed', { - channel: 'stable' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - minimum_version_set: choice === 'stay' - }); - }} /> : - + + )} + + ) : ( + ; - $[20] = onInputModeToggle; - $[21] = options; - $[22] = t6; - $[23] = t7; - $[24] = t8; - $[25] = t9; - } else { - t9 = $[25]; - } - let t10; - if ($[26] !== t5 || $[27] !== t9) { - t10 = {t5}{t9}; - $[26] = t5; - $[27] = t9; - $[28] = t10; - } else { - t10 = $[28]; - } - const t11 = (focusedOption === "yes" && !yesInputMode || focusedOption === "no" && !noInputMode) && " \xB7 Tab to amend"; - let t12; - if ($[29] !== t11) { - t12 = Esc to cancel{t11}; - $[29] = t11; - $[30] = t12; - } else { - t12 = $[30]; - } - let t13; - if ($[31] !== t1 || $[32] !== t10 || $[33] !== t12 || $[34] !== t2) { - t13 = {t1}{t2}{t3}{t10}{t12}; - $[31] = t1; - $[32] = t10; - $[33] = t12; - $[34] = t2; - $[35] = t13; - } else { - t13 = $[35]; - } - return t13; + filePath: string + input: A + onChange: (option: PermissionOption, args: A, feedback?: string) => void + options: PermissionOptionWithLabel[] + ideName: string + symlinkTarget?: string | null + rejectFeedback: string + acceptFeedback: string + setFocusedOption: (value: string) => void + onInputModeToggle: (value: string) => void + focusedOption: string + yesInputMode: boolean + noInputMode: boolean +} + +export function ShowInIDEPrompt({ + onChange, + options, + input, + filePath, + ideName, + symlinkTarget, + rejectFeedback, + acceptFeedback, + setFocusedOption, + onInputModeToggle, + focusedOption, + yesInputMode, + noInputMode, +}: Props): React.ReactNode { + return ( + + + + Opened changes in {ideName} ⧉ + + {symlinkTarget && ( + + {relative(getCwd(), symlinkTarget).startsWith('..') + ? `This will modify ${symlinkTarget} (outside working directory) via a symlink` + : `Symlink target: ${symlinkTarget}`} + + )} + {isSupportedVSCodeTerminal() && ( + Save file to continue… + )} + + + Do you want to make this edit to{' '} + {basename(filePath)}? + + ; - $[17] = t10; - } else { - t10 = $[17]; - } - return t10; + case 'needsGitStash': + return ( + + ) + + case 'needsLogin': { + if (isLoggingIn) { + return ( + + ) } + + return ( + + + Teleport requires a Claude.ai account. + + Your Claude Pro/Max subscription will be used by Claude Code. + + + void handleChange(value_0)} />} : {errorMessage && {errorMessage}}Run claude --teleport from a checkout of {targetRepo}; - $[8] = availablePaths.length; - $[9] = errorMessage; - $[10] = handleChange; - $[11] = options; - $[12] = targetRepo; - $[13] = validating; - $[14] = t3; - } else { - t3 = $[14]; - } - let t4; - if ($[15] !== onCancel || $[16] !== t3) { - t4 = {t3}; - $[15] = onCancel; - $[16] = t3; - $[17] = t4; - } else { - t4 = $[17]; - } - return t4; -} -function _temp(path) { - return { - label: Use {getDisplayPath(path)}, - value: path - }; + + // Path is invalid - remove it from config and update state + removePathFromRepo(targetRepo, value) + const updatedPaths = availablePaths.filter(p => p !== value) + setAvailablePaths(updatedPaths) + setValidating(false) + + setErrorMessage( + `${getDisplayPath(value)} no longer contains the correct repository. Select another path.`, + ) + }, + [targetRepo, availablePaths, onSelectPath, onCancel], + ) + + const options = [ + ...availablePaths.map(path => ({ + label: ( + + Use {getDisplayPath(path)} + + ), + value: path, + })), + { label: 'Cancel', value: 'cancel' }, + ] + + return ( + + {availablePaths.length > 0 ? ( + <> + + {errorMessage && {errorMessage}} + + Open Claude Code in {targetRepo}: + + + + {validating ? ( + + + Validating repository… + + ) : ( + } - ; + + ) : ( + ; - $[25] = t15; - $[26] = t16; - $[27] = t17; - $[28] = themeSetting; - $[29] = t18; - } else { - t18 = $[29]; - } - let t19; - if ($[30] !== t11 || $[31] !== t14 || $[32] !== t18) { - t19 = {t11}{t14}{t18}; - $[30] = t11; - $[31] = t14; - $[32] = t18; - $[33] = t19; - } else { - t19 = $[33]; - } - let t20; - if ($[34] === Symbol.for("react.memo_cache_sentinel")) { - t20 = { - oldStart: 1, - newStart: 1, - oldLines: 3, - newLines: 3, - lines: [" function greet() {", "- console.log(\"Hello, World!\");", "+ console.log(\"Hello, Claude!\");", " }"] - }; - $[34] = t20; - } else { - t20 = $[34]; - } - let t21; - if ($[35] !== columns) { - t21 = ; - $[35] = columns; - $[36] = t21; - } else { - t21 = $[36]; - } - const t22 = colorModuleUnavailableReason === "env" ? `Syntax highlighting disabled (via CLAUDE_CODE_SYNTAX_HIGHLIGHT=${process.env.CLAUDE_CODE_SYNTAX_HIGHLIGHT})` : syntaxHighlightingDisabled ? `Syntax highlighting disabled (${syntaxToggleShortcut} to enable)` : syntaxTheme ? `Syntax theme: ${syntaxTheme.theme}${syntaxTheme.source ? ` (from ${syntaxTheme.source})` : ""} (${syntaxToggleShortcut} to disable)` : `Syntax highlighting enabled (${syntaxToggleShortcut} to disable)`; - let t23; - if ($[37] !== t22) { - t23 = {" "}{t22}; - $[37] = t22; - $[38] = t23; - } else { - t23 = $[38]; - } - let t24; - if ($[39] !== t21 || $[40] !== t23) { - t24 = {t21}{t23}; - $[39] = t21; - $[40] = t23; - $[41] = t24; - } else { - t24 = $[41]; - } - let t25; - if ($[42] !== t19 || $[43] !== t24) { - t25 = {t19}{t24}; - $[42] = t19; - $[43] = t24; - $[44] = t25; - } else { - t25 = $[44]; - } - const content = t25; + }, + { context: 'ThemePicker' }, + ) + // Always call the hook to follow React rules, but conditionally assign the exit handler + const exitState = useExitOnCtrlCDWithKeybindings( + skipExitHandling ? () => {} : undefined, + ) + + const themeOptions: { label: string; value: ThemeSetting }[] = [ + ...(feature('AUTO_THEME') + ? [{ label: 'Auto (match terminal)', value: 'auto' as const }] + : []), + { label: 'Dark mode', value: 'dark' }, + { label: 'Light mode', value: 'light' }, + { + label: 'Dark mode (colorblind-friendly)', + value: 'dark-daltonized', + }, + { + label: 'Light mode (colorblind-friendly)', + value: 'light-daltonized', + }, + { + label: 'Dark mode (ANSI colors only)', + value: 'dark-ansi', + }, + { + label: 'Light mode (ANSI colors only)', + value: 'light-ansi', + }, + ] + + const content = ( + + + {showIntroText ? ( + Let's get started. + ) : ( + + Theme + + )} + + + Choose the text style that looks best with your terminal + + {helpText && !showHelpTextBelow && {helpText}} + + }; - $[15] = confirmationPending; - $[16] = currentValue; - $[17] = handleSelectChange; - $[18] = onCancel; - $[19] = t9; - } else { - t9 = $[19]; - } - let t10; - if ($[20] !== confirmationPending || $[21] !== exitState.keyName || $[22] !== exitState.pending) { - t10 = {exitState.pending ? <>Press {exitState.keyName} again to exit : confirmationPending !== null ? : }; - $[20] = confirmationPending; - $[21] = exitState.keyName; - $[22] = exitState.pending; - $[23] = t10; - } else { - t10 = $[23]; - } - let t11; - if ($[24] !== t10 || $[25] !== t9) { - t11 = {t9}{t10}; - $[24] = t10; - $[25] = t9; - $[26] = t11; - } else { - t11 = $[26]; - } - return t11; + currentValue: boolean + onSelect: (enabled: boolean) => void + onCancel?: () => void + isMidConversation?: boolean +} + +export function ThinkingToggle({ + currentValue, + onSelect, + onCancel, + isMidConversation, +}: Props): React.ReactNode { + const exitState = useExitOnCtrlCDWithKeybindings() + const [confirmationPending, setConfirmationPending] = useState< + boolean | null + >(null) + + const options = [ + { + value: 'true', + label: 'Enabled', + description: 'Claude will think before responding', + }, + { + value: 'false', + label: 'Disabled', + description: 'Claude will respond without extended thinking', + }, + ] + + // Use configurable keybinding for ESC to cancel/go back + useKeybinding( + 'confirm:no', + () => { + if (confirmationPending !== null) { + setConfirmationPending(null) + } else { + onCancel?.() + } + }, + { context: 'Confirmation' }, + ) + + // Use configurable keybinding for Enter to confirm in confirmation mode + useKeybinding( + 'confirm:yes', + () => { + if (confirmationPending !== null) { + onSelect(confirmationPending) + } + }, + { context: 'Confirmation', isActive: confirmationPending !== null }, + ) + + function handleSelectChange(value: string): void { + const selected = value === 'true' + if (isMidConversation && selected !== currentValue) { + setConfirmationPending(selected) + } else { + onSelect(selected) + } + } + + return ( + + + + + Toggle thinking mode + + Enable or disable thinking for this session. + + + {confirmationPending !== null ? ( + + + Changing thinking mode mid-conversation will increase latency and + may reduce quality. For best results, set this at the start of a + session. + + Do you want to proceed? + + ) : ( + + onChange(value_0 as 'enable_all' | 'exit')} onCancel={() => onChange("exit")} />; - $[25] = onChange; - $[26] = t21; - } else { - t21 = $[26]; - } - let t22; - if ($[27] !== exitState.keyName || $[28] !== exitState.pending) { - t22 = {exitState.pending ? <>Press {exitState.keyName} again to exit : <>Enter to confirm · Esc to cancel}; - $[27] = exitState.keyName; - $[28] = exitState.pending; - $[29] = t22; - } else { - t22 = $[29]; - } - let t23; - if ($[30] !== t21 || $[31] !== t22) { - t23 = {t16}{t17}{t18}{t19}{t21}{t22}; - $[30] = t21; - $[31] = t22; - $[32] = t23; - } else { - t23 = $[32]; - } - return t23; -} -function _temp7() { - gracefulShutdownSync(0); -} -function _temp6() { - return gracefulShutdownSync(1); -} -function _temp5(current) { - return { - ...current, - hasTrustDialogAccepted: true - }; -} -function _temp4(command_0) { - return command_0.type === "prompt" && (command_0.loadedFrom === "skills" || command_0.loadedFrom === "plugin") && (command_0.source === "projectSettings" || command_0.source === "localSettings" || command_0.source === "plugin") && command_0.allowedTools?.some(_temp3); -} -function _temp3(tool_0) { - return tool_0 === BASH_TOOL_NAME || tool_0.startsWith(BASH_TOOL_NAME + "("); -} -function _temp2(command) { - return command.type === "prompt" && command.loadedFrom === "commands_DEPRECATED" && (command.source === "projectSettings" || command.source === "localSettings") && command.allowedTools?.some(_temp); -} -function _temp(tool) { - return tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + "("); + + return ( + + + {getFsImplementation().cwd()} + + + Quick safety check: Is this a project you created or one you trust? + (Like your own code, a well-known open source project, or work from + your team). If not, take a moment to review what{"'"}s in this folder + first. + + + Claude Code{"'"}ll be able to read, edit, and execute files here. + + + + + Security guide + + + + - ; + + const removeDescription = + hasUncommitted || hasCommits + ? 'All changes and commits will be lost.' + : 'Clean up the worktree directory.' + + const hasTmuxSession = Boolean(worktreeSession.tmuxSessionName) + + const options = hasTmuxSession + ? [ + { + label: 'Keep worktree and tmux session', + value: 'keep-with-tmux', + description: `Stays at ${worktreeSession.worktreePath}. Reattach with: tmux attach -t ${worktreeSession.tmuxSessionName}`, + }, + { + label: 'Keep worktree, kill tmux session', + value: 'keep-kill-tmux', + description: `Keeps worktree at ${worktreeSession.worktreePath}, terminates tmux session.`, + }, + { + label: 'Remove worktree and tmux session', + value: 'remove-with-tmux', + description: removeDescription, + }, + ] + : [ + { + label: 'Keep worktree', + value: 'keep', + description: `Stays at ${worktreeSession.worktreePath}`, + }, + { + label: 'Remove worktree', + value: 'remove', + description: removeDescription, + }, + ] + + const defaultValue = hasTmuxSession ? 'keep-with-tmux' : 'keep' + + return ( + + ; - $[26] = handleCancel; - $[27] = t11; - $[28] = t12; - $[29] = t13; - } else { - t13 = $[29]; - } - let t14; - if ($[30] !== handleCancel || $[31] !== t13 || $[32] !== t8) { - t14 = {t8}{t13}; - $[30] = handleCancel; - $[31] = t13; - $[32] = t8; - $[33] = t14; - } else { - t14 = $[33]; - } - return t14; -} -function _temp(exitState) { - return exitState.pending ? Press {exitState.keyName} again to exit : ; + + return ( + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + + + + ) + } + > + + + {groveConfig?.notice_is_grace_period ? ( + + ) : ( + + )} + + + {NEW_TERMS_ASCII} + + + + + + Please select how you'd like to continue + Your choice takes effect immediately upon confirmation. + + + ; - $[7] = options; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== request.message || $[11] !== t3 || $[12] !== t5 || $[13] !== title) { - t6 = {t5}; - $[10] = request.message; - $[11] = t3; - $[12] = t5; - $[13] = title; - $[14] = t6; - } else { - t6 = $[14]; - } - return t6; + title: string + toolInputSummary?: string | null + request: PromptRequest + onRespond: (key: string) => void + onAbort: () => void } -function _temp(opt) { - return { + +export function PromptDialog({ + title, + toolInputSummary, + request, + onRespond, + onAbort, +}: Props): React.ReactNode { + useKeybinding('app:interrupt', onAbort, { isActive: true }) + + const options = request.options.map(opt => ({ label: opt.label, value: opt.key, - description: opt.description - }; + description: opt.description, + })) + + return ( + {toolInputSummary} : undefined + } + > + + ; - $[12] = onCancel; - $[13] = t4; - $[14] = t6; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== t2 || $[17] !== t7) { - t8 = {t2}{t3}{t7}; - $[16] = t2; - $[17] = t7; - $[18] = t8; - } else { - t8 = $[18]; - } - let t9; - if ($[19] !== onCancel || $[20] !== subtitle || $[21] !== t8) { - t9 = {t8}; - $[19] = onCancel; - $[20] = subtitle; - $[21] = t8; - $[22] = t9; - } else { - t9 = $[22]; - } - return t9; + hookEventMetadata: Record + hooksByEvent: Partial> + totalHooksCount: number + restrictedByPolicy: boolean + onSelectEvent: (event: HookEvent) => void + onCancel: () => void +} + +export function SelectEventMode({ + hookEventMetadata, + hooksByEvent, + totalHooksCount, + restrictedByPolicy, + onSelectEvent, + onCancel, +}: Props): React.ReactNode { + const subtitle = `${totalHooksCount} ${plural(totalHooksCount, 'hook')} configured` + + return ( + + + {restrictedByPolicy && ( + + + {figures.info} Hooks Restricted by Policy + + + Only hooks from managed settings can run. User-defined hooks from + ~/.claude/settings.json, .claude/settings.json, and + .claude/settings.local.json are blocked. + + + )} + + + + {figures.info} This menu is read-only. To add or modify hooks, edit + settings.json directly or ask Claude.{' '} + Learn more + + + + + ; - $[10] = onCancel; - $[11] = t2; - $[12] = t3; - $[13] = t4; - } else { - t4 = $[13]; - } - let t5; - if ($[14] !== hookEventMetadata.description || $[15] !== onCancel || $[16] !== t4 || $[17] !== title) { - t5 = {t4}; - $[14] = hookEventMetadata.description; - $[15] = onCancel; - $[16] = t4; - $[17] = title; - $[18] = t5; - } else { - t5 = $[18]; - } - return t5; -} -function _temp2(hook, index) { - return { - label: `[${hook.config.type}] ${getHookDisplayText(hook.config)}`, - value: index.toString(), - description: hook.source === "pluginHook" && hook.pluginName ? `${hookSourceHeaderDisplayString(hook.source)} (${hook.pluginName})` : hookSourceHeaderDisplayString(hook.source) - }; -} -function _temp() { - return Esc to go back; + + return ( + + + ; - $[16] = onCancel; - $[17] = t3; - $[18] = t4; - $[19] = t5; - } else { - t5 = $[19]; - } - let t6; - if ($[20] !== eventDescription || $[21] !== onCancel || $[22] !== t2 || $[23] !== t5) { - t6 = {t5}; - $[20] = eventDescription; - $[21] = onCancel; - $[22] = t2; - $[23] = t5; - $[24] = t6; - } else { - t6 = $[24]; - } - return t6; -} -function _temp3(item) { - const sourceText = item.sources.map(hookSourceInlineDisplayString).join(", "); - const matcherLabel = item.matcher || "(all)"; - return { - label: `[${sourceText}] ${matcherLabel}`, - value: item.matcher, - description: `${item.hookCount} ${plural(item.hookCount, "hook")}` - }; -} -function _temp2() { - return Esc to go back; -} -function _temp(h) { - return h.source; + + return ( + + + ; - $[48] = initialPath; - $[49] = memoryOptions; - $[50] = onCancel; - $[51] = t20; - $[52] = t21; - $[53] = toggleFocused; - $[54] = t22; - } else { - t22 = $[54]; - } - let t23; - if ($[55] !== t19 || $[56] !== t22) { - t23 = {t19}{t22}; - $[55] = t19; - $[56] = t22; - $[57] = t23; - } else { - t23 = $[57]; - } - return t23; -} -function _temp8() {} -function _temp7(prev_0) { - return prev_0 !== null && prev_0 > 0 ? prev_0 - 1 : prev_0; -} -function _temp6(s_0) { - return Object.values(s_0.tasks).some(_temp5); -} -function _temp5(t) { - return t.type === "dream" && t.status === "running"; -} -function _temp4(opt) { - return opt.value === lastSelectedPath; -} -function _temp3(s) { - return s.agentDefinitions; -} -function _temp2(f_2) { - return { - ...f_2, - exists: true - }; -} -function _temp(f_1) { - return f_1.type !== "AutoMem" && f_1.type !== "TeamMem"; + + useExitOnCtrlCDWithKeybindings() + + useKeybinding('confirm:no', onCancel, { context: 'Confirmation' }) + + useKeybinding( + 'confirm:yes', + () => { + if (focusedToggle === 0) handleToggleAutoMemory() + else if (focusedToggle === 1) handleToggleAutoDream() + }, + { context: 'Confirmation', isActive: toggleFocused }, + ) + useKeybinding( + 'select:next', + () => { + setFocusedToggle(prev => + prev !== null && prev < lastToggleIndex ? prev + 1 : null, + ) + }, + { context: 'Select', isActive: toggleFocused }, + ) + useKeybinding( + 'select:previous', + () => { + setFocusedToggle(prev => (prev !== null && prev > 0 ? prev - 1 : prev)) + }, + { context: 'Select', isActive: toggleFocused }, + ) + + return ( + + + + Auto-memory: {autoMemoryOn ? 'on' : 'off'} + + {showDreamRow && ( + + + Auto-dream: {autoDreamOn ? 'on' : 'off'} + {dreamStatus && · {dreamStatus}} + {!isDreamRunning && autoDreamOn && ( + · /dream to run + )} + + + )} + + + ; - $[34] = focusNodeId; - $[35] = handleChange; - $[36] = handleFocus; - $[37] = hideIndexes; - $[38] = isDisabled; - $[39] = layout; - $[40] = onCancel; - $[41] = onUpFromFirstItem; - $[42] = options; - $[43] = visibleOptionCount; - $[44] = t13; - } else { - t13 = $[44]; - } - let t14; - if ($[45] !== handleKeyDown || $[46] !== t13) { - t14 = {t13}; - $[45] = handleKeyDown; - $[46] = t13; - $[47] = t14; - } else { - t14 = $[47]; - } - return t14; -} -function _temp2(_depth) { - return " \u25B8 "; -} -function _temp(isExpanded_0) { - return isExpanded_0 ? "\u25BC " : "\u25B6 "; + }, + [onFocus, nodeMap], + ) + + return ( + + = Record> = (tool: ToolType, input: Input, toolUseContext: ToolUseContext, assistantMessage: AssistantMessage, toolUseID: string, forceDecision?: PermissionDecision) => Promise>; -function useCanUseTool(setToolUseConfirmQueue, setToolPermissionContext) { - const $ = _c(3); - let t0; - if ($[0] !== setToolPermissionContext || $[1] !== setToolUseConfirmQueue) { - t0 = async (tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision) => new Promise(resolve => { - const ctx = createPermissionContext(tool, input, toolUseContext, assistantMessage, toolUseID, setToolPermissionContext, createPermissionQueueOps(setToolUseConfirmQueue)); - if (ctx.resolveIfAborted(resolve)) { - return; - } - const decisionPromise = forceDecision !== undefined ? Promise.resolve(forceDecision) : hasPermissionsToUseTool(tool, input, toolUseContext, assistantMessage, toolUseID); - return decisionPromise.then(async result => { - if (result.behavior === "allow") { - if (ctx.resolveIfAborted(resolve)) { - return; - } - if (feature("TRANSCRIPT_CLASSIFIER") && result.decisionReason?.type === "classifier" && result.decisionReason.classifier === "auto-mode") { - setYoloClassifierApproval(toolUseID, result.decisionReason.reason); - } - ctx.logDecision({ - decision: "accept", - source: "config" - }); - resolve(ctx.buildAllow(result.updatedInput ?? input, { - decisionReason: result.decisionReason - })); - return; - } - const appState = toolUseContext.getAppState(); - const description = await tool.description(input as never, { - isNonInteractiveSession: toolUseContext.options.isNonInteractiveSession, - toolPermissionContext: appState.toolPermissionContext, - tools: toolUseContext.options.tools - }); - if (ctx.resolveIfAborted(resolve)) { - return; - } - switch (result.behavior) { - case "deny": - { - logPermissionDecision({ +import { feature } from 'bun:bundle' +import { APIUserAbortError } from '@anthropic-ai/sdk' +import * as React from 'react' +import { useCallback } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js' +import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js' +import { Text } from '../ink.js' +import type { + ToolPermissionContext, + Tool as ToolType, + ToolUseContext, +} from '../Tool.js' +import { + consumeSpeculativeClassifierCheck, + peekSpeculativeClassifierCheck, +} from '../tools/BashTool/bashPermissions.js' +import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js' +import type { AssistantMessage } from '../types/message.js' +import { recordAutoModeDenial } from '../utils/autoModeDenials.js' +import { + clearClassifierChecking, + setClassifierApproval, + setYoloClassifierApproval, +} from '../utils/classifierApprovals.js' +import { logForDebugging } from '../utils/debug.js' +import { AbortError } from '../utils/errors.js' +import { logError } from '../utils/log.js' +import type { PermissionDecision } from '../utils/permissions/PermissionResult.js' +import { hasPermissionsToUseTool } from '../utils/permissions/permissions.js' +import { jsonStringify } from '../utils/slowOperations.js' +import { handleCoordinatorPermission } from './toolPermission/handlers/coordinatorHandler.js' +import { handleInteractivePermission } from './toolPermission/handlers/interactiveHandler.js' +import { handleSwarmWorkerPermission } from './toolPermission/handlers/swarmWorkerHandler.js' +import { + createPermissionContext, + createPermissionQueueOps, +} from './toolPermission/PermissionContext.js' +import { logPermissionDecision } from './toolPermission/permissionLogging.js' + +export type CanUseToolFn< + Input extends Record = Record, +> = ( + tool: ToolType, + input: Input, + toolUseContext: ToolUseContext, + assistantMessage: AssistantMessage, + toolUseID: string, + forceDecision?: PermissionDecision, +) => Promise> + +function useCanUseTool( + setToolUseConfirmQueue: React.Dispatch< + React.SetStateAction + >, + setToolPermissionContext: (context: ToolPermissionContext) => void, +): CanUseToolFn { + return useCallback( + async ( + tool, + input, + toolUseContext, + assistantMessage, + toolUseID, + forceDecision, + ) => { + return new Promise(resolve => { + const ctx = createPermissionContext( + tool, + input, + toolUseContext, + assistantMessage, + toolUseID, + setToolPermissionContext, + createPermissionQueueOps(setToolUseConfirmQueue), + ) + + if (ctx.resolveIfAborted(resolve)) return + + const decisionPromise = + forceDecision !== undefined + ? Promise.resolve(forceDecision) + : hasPermissionsToUseTool( tool, input, toolUseContext, - messageId: ctx.messageId, - toolUseID - }, { - decision: "reject", - source: "config" - }); - if (feature("TRANSCRIPT_CLASSIFIER") && result.decisionReason?.type === "classifier" && result.decisionReason.classifier === "auto-mode") { - recordAutoModeDenial({ - toolName: tool.name, - display: description, - reason: result.decisionReason.reason ?? "", - timestamp: Date.now() - }); - toolUseContext.addNotification?.({ - key: "auto-mode-denied", - priority: "immediate", - jsx: <>{tool.userFacingName(input).toLowerCase()} denied by auto mode · /permissions - }); - } - resolve(result); - return; + assistantMessage, + toolUseID, + ) + + return decisionPromise + .then(async result => { + // [ANT-ONLY] Log all tool permission decisions with tool name and args + if (process.env.USER_TYPE === 'ant') { + logEvent('tengu_internal_tool_permission_decision', { + toolName: sanitizeToolNameForAnalytics(tool.name), + behavior: + result.behavior as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + // Note: input contains code/filepaths, only log for ants + input: jsonStringify( + input, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + messageID: + ctx.messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + isMcp: tool.isMcp ?? false, + }) } - case "ask": - { - if (appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog) { - const coordinatorDecision = await handleCoordinatorPermission({ + + // Has permissions to use tool, granted in config + if (result.behavior === 'allow') { + if (ctx.resolveIfAborted(resolve)) return + // Track auto mode classifier approvals for UI display + if ( + feature('TRANSCRIPT_CLASSIFIER') && + result.decisionReason?.type === 'classifier' && + result.decisionReason.classifier === 'auto-mode' + ) { + setYoloClassifierApproval( + toolUseID, + result.decisionReason.reason, + ) + } + + ctx.logDecision({ decision: 'accept', source: 'config' }) + + resolve( + ctx.buildAllow(result.updatedInput ?? input, { + decisionReason: result.decisionReason, + }), + ) + return + } + + const appState = toolUseContext.getAppState() + const description = await tool.description(input as never, { + isNonInteractiveSession: + toolUseContext.options.isNonInteractiveSession, + toolPermissionContext: appState.toolPermissionContext, + tools: toolUseContext.options.tools, + }) + + if (ctx.resolveIfAborted(resolve)) return + + // Does not have permissions to use tool, check the behavior + switch (result.behavior) { + case 'deny': { + logPermissionDecision( + { + tool, + input, + toolUseContext, + messageId: ctx.messageId, + toolUseID, + }, + { decision: 'reject', source: 'config' }, + ) + if ( + feature('TRANSCRIPT_CLASSIFIER') && + result.decisionReason?.type === 'classifier' && + result.decisionReason.classifier === 'auto-mode' + ) { + recordAutoModeDenial({ + toolName: tool.name, + display: description, + reason: result.decisionReason.reason ?? '', + timestamp: Date.now(), + }) + toolUseContext.addNotification?.({ + key: 'auto-mode-denied', + priority: 'immediate', + jsx: ( + <> + + {tool.userFacingName(input).toLowerCase()} denied by + auto mode + + · /permissions + + ), + }) + } + resolve(result) + return + } + + case 'ask': { + // For coordinator workers, await automated checks before showing dialog. + // Background workers should only interrupt the user when automated checks can't decide. + if ( + appState.toolPermissionContext + .awaitAutomatedChecksBeforeDialog + ) { + const coordinatorDecision = await handleCoordinatorPermission( + { + ctx, + ...(feature('BASH_CLASSIFIER') + ? { + pendingClassifierCheck: + result.pendingClassifierCheck, + } + : {}), + updatedInput: result.updatedInput, + suggestions: result.suggestions, + permissionMode: appState.toolPermissionContext.mode, + }, + ) + if (coordinatorDecision) { + resolve(coordinatorDecision) + return + } + // null means neither automated check resolved -- fall through to dialog below. + // Hooks already ran, classifier already consumed. + } + + // After awaiting automated checks, verify the request wasn't aborted + // while we were waiting. Without this check, a stale dialog could appear. + if (ctx.resolveIfAborted(resolve)) return + + // For swarm workers, try classifier auto-approval then + // forward permission requests to the leader via mailbox. + const swarmDecision = await handleSwarmWorkerPermission({ ctx, - ...(feature("BASH_CLASSIFIER") ? { - pendingClassifierCheck: result.pendingClassifierCheck - } : {}), + description, + ...(feature('BASH_CLASSIFIER') + ? { + pendingClassifierCheck: result.pendingClassifierCheck, + } + : {}), updatedInput: result.updatedInput, suggestions: result.suggestions, - permissionMode: appState.toolPermissionContext.mode - }); - if (coordinatorDecision) { - resolve(coordinatorDecision); - return; + }) + if (swarmDecision) { + resolve(swarmDecision) + return } - } - if (ctx.resolveIfAborted(resolve)) { - return; - } - const swarmDecision = await handleSwarmWorkerPermission({ - ctx, - description, - ...(feature("BASH_CLASSIFIER") ? { - pendingClassifierCheck: result.pendingClassifierCheck - } : {}), - updatedInput: result.updatedInput, - suggestions: result.suggestions - }); - if (swarmDecision) { - resolve(swarmDecision); - return; - } - if (feature("BASH_CLASSIFIER") && result.pendingClassifierCheck && tool.name === BASH_TOOL_NAME && !appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog) { - const speculativePromise = peekSpeculativeClassifierCheck((input as { - command: string; - }).command); - if (speculativePromise) { - const raceResult = await Promise.race([speculativePromise.then(_temp), new Promise(_temp2)]); - if (ctx.resolveIfAborted(resolve)) { - return; - } - if ((raceResult as any).type === "result" && (raceResult as any).result.matches && (raceResult as any).result.confidence === "high" && feature("BASH_CLASSIFIER")) { - consumeSpeculativeClassifierCheck((input as { - command: string; - }).command); - const matchedRule = (raceResult as any).result.matchedDescription ?? undefined; - if (matchedRule) { - setClassifierApproval(toolUseID, matchedRule); + + // Grace period: wait up to 2s for speculative classifier + // to resolve before showing the dialog (main agent only) + if ( + feature('BASH_CLASSIFIER') && + result.pendingClassifierCheck && + tool.name === BASH_TOOL_NAME && + !appState.toolPermissionContext + .awaitAutomatedChecksBeforeDialog + ) { + const speculativePromise = peekSpeculativeClassifierCheck( + (input as { command: string }).command, + ) + if (speculativePromise) { + const raceResult = await Promise.race([ + speculativePromise.then(r => ({ + type: 'result' as const, + result: r, + })), + new Promise<{ type: 'timeout' }>(res => + // eslint-disable-next-line no-restricted-syntax -- resolves with a value, not void + setTimeout(res, 2000, { type: 'timeout' as const }), + ), + ]) + + if (ctx.resolveIfAborted(resolve)) return + + if ( + raceResult.type === 'result' && + raceResult.result.matches && + raceResult.result.confidence === 'high' && + feature('BASH_CLASSIFIER') + ) { + // Classifier approved within grace period — skip dialog + void consumeSpeculativeClassifierCheck( + (input as { command: string }).command, + ) + + const matchedRule = + raceResult.result.matchedDescription ?? undefined + if (matchedRule) { + setClassifierApproval(toolUseID, matchedRule) + } + + ctx.logDecision({ + decision: 'accept', + source: { type: 'classifier' }, + }) + resolve( + ctx.buildAllow( + result.updatedInput ?? + (input as Record), + { + decisionReason: { + type: 'classifier' as const, + classifier: 'bash_allow' as const, + reason: `Allowed by prompt rule: "${raceResult.result.matchedDescription}"`, + }, + }, + ), + ) + return } - ctx.logDecision({ - decision: "accept", - source: { - type: "classifier" - } - }); - resolve(ctx.buildAllow(result.updatedInput ?? input as Record, { - decisionReason: { - type: "classifier" as const, - classifier: "bash_allow" as const, - reason: `Allowed by prompt rule: "${(raceResult as any).result.matchedDescription}"` - } - })); - return; + // Timeout or no match — fall through to show dialog } } + + // Show dialog and start hooks/classifier in background + handleInteractivePermission( + { + ctx, + description, + result, + awaitAutomatedChecksBeforeDialog: + appState.toolPermissionContext + .awaitAutomatedChecksBeforeDialog, + bridgeCallbacks: feature('BRIDGE_MODE') + ? appState.replBridgePermissionCallbacks + : undefined, + channelCallbacks: + feature('KAIROS') || feature('KAIROS_CHANNELS') + ? appState.channelPermissionCallbacks + : undefined, + }, + resolve, + ) + + return } - handleInteractivePermission({ - ctx, - description, - result, - awaitAutomatedChecksBeforeDialog: appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog, - bridgeCallbacks: feature("BRIDGE_MODE") ? appState.replBridgePermissionCallbacks : undefined, - channelCallbacks: feature("KAIROS") || feature("KAIROS_CHANNELS") ? appState.channelPermissionCallbacks : undefined - }, resolve); - return; } - } - }).catch(error => { - if (error instanceof AbortError || error instanceof APIUserAbortError) { - logForDebugging(`Permission check threw ${error.constructor.name} for tool=${tool.name}: ${error.message}`); - ctx.logCancelled(); - resolve(ctx.cancelAndAbort(undefined, true)); - } else { - logError(error); - resolve(ctx.cancelAndAbort(undefined, true)); - } - }).finally(() => { - clearClassifierChecking(toolUseID); - }); - }); - $[0] = setToolPermissionContext; - $[1] = setToolUseConfirmQueue; - $[2] = t0; - } else { - t0 = $[2]; - } - return t0; + }) + .catch(error => { + if ( + error instanceof AbortError || + error instanceof APIUserAbortError + ) { + logForDebugging( + `Permission check threw ${error.constructor.name} for tool=${tool.name}: ${error.message}`, + ) + ctx.logCancelled() + resolve(ctx.cancelAndAbort(undefined, true)) + } else { + logError(error) + resolve(ctx.cancelAndAbort(undefined, true)) + } + }) + .finally(() => { + clearClassifierChecking(toolUseID) + }) + }) + }, + [setToolUseConfirmQueue, setToolPermissionContext], + ) } -function _temp2(res) { - return setTimeout(res, 2000, { - type: "timeout" as const - }); -} -function _temp(r) { - return { - type: "result" as const, - result: r - }; -} -export default useCanUseTool; + +export default useCanUseTool diff --git a/src/hooks/useChromeExtensionNotification.tsx b/src/hooks/useChromeExtensionNotification.tsx index a32aac968..dc058df0e 100644 --- a/src/hooks/useChromeExtensionNotification.tsx +++ b/src/hooks/useChromeExtensionNotification.tsx @@ -1,42 +1,66 @@ -import * as React from 'react'; -import { Text } from '../ink.js'; -import { isClaudeAISubscriber } from '../utils/auth.js'; -import { isChromeExtensionInstalled, shouldEnableClaudeInChrome } from '../utils/claudeInChrome/setup.js'; -import { isRunningOnHomespace } from '../utils/envUtils.js'; -import { useStartupNotification } from './notifs/useStartupNotification.js'; +import * as React from 'react' +import { Text } from '../ink.js' +import { isClaudeAISubscriber } from '../utils/auth.js' +import { + isChromeExtensionInstalled, + shouldEnableClaudeInChrome, +} from '../utils/claudeInChrome/setup.js' +import { isRunningOnHomespace } from '../utils/envUtils.js' +import { useStartupNotification } from './notifs/useStartupNotification.js' + function getChromeFlag(): boolean | undefined { if (process.argv.includes('--chrome')) { - return true; + return true } if (process.argv.includes('--no-chrome')) { - return false; + return false } - return undefined; + return undefined } -export function useChromeExtensionNotification() { - useStartupNotification(_temp); -} -async function _temp() { - const chromeFlag = getChromeFlag(); - if (!shouldEnableClaudeInChrome(chromeFlag)) { - return null; - } - // Subscription check bypassed - const installed = await isChromeExtensionInstalled(); - if (!installed && !isRunningOnHomespace()) { - return { - key: "chrome-extension-not-detected", - jsx: Chrome extension not detected · https://claude.ai/chrome to install, - priority: "immediate", - timeoutMs: 3000 - }; - } - if (chromeFlag === undefined) { - return { - key: "claude-in-chrome-default-enabled", - text: "Claude in Chrome enabled \xB7 /chrome", - priority: "low" - }; - } - return null; + +export function useChromeExtensionNotification(): void { + useStartupNotification(async () => { + const chromeFlag = getChromeFlag() + if (!shouldEnableClaudeInChrome(chromeFlag)) return null + + // Claude in Chrome is only supported for claude.ai subscribers (unless user is ant) + if ("external" !== 'ant' && !isClaudeAISubscriber()) { + return { + key: 'chrome-requires-subscription', + jsx: ( + + Claude in Chrome requires a claude.ai subscription + + ), + priority: 'immediate', + timeoutMs: 5000, + } + } + + const installed = await isChromeExtensionInstalled() + if (!installed && !isRunningOnHomespace()) { + // Skip notification on Homespace since Chrome setup requires different steps (see go/hsproxy) + return { + key: 'chrome-extension-not-detected', + jsx: ( + + Chrome extension not detected · https://claude.ai/chrome to install + + ), + // TODO(hackyon): Lower the priority if the claude-in-chrome integration is no longer opt-in + priority: 'immediate', + timeoutMs: 3000, + } + } + if (chromeFlag === undefined) { + // Show low priority notification only when Chrome is enabled by default + // (not explicitly enabled with --chrome or disabled with --no-chrome) + return { + key: 'claude-in-chrome-default-enabled', + text: `Claude in Chrome enabled · /chrome`, + priority: 'low', + } + } + return null + }) } diff --git a/src/hooks/useClaudeCodeHintRecommendation.tsx b/src/hooks/useClaudeCodeHintRecommendation.tsx index 0b2291167..9e9aa1cf3 100644 --- a/src/hooks/useClaudeCodeHintRecommendation.tsx +++ b/src/hooks/useClaudeCodeHintRecommendation.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * Surfaces plugin-install prompts driven by `` tags * that CLIs/SDKs emit to stderr. See docs/claude-code-hints.md. @@ -9,120 +8,117 @@ import { c as _c } from "react/compiler-runtime"; * anything that reaches this hook is worth resolving. */ -import * as React from 'react'; -import { useNotifications } from '../context/notifications.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, logEvent } from '../services/analytics/index.js'; -import { clearPendingHint, getPendingHintSnapshot, markShownThisSession, subscribeToPendingHint } from '../utils/claudeCodeHints.js'; -import { logForDebugging } from '../utils/debug.js'; -import { disableHintRecommendations, markHintPluginShown, type PluginHintRecommendation, resolvePluginHint } from '../utils/plugins/hintRecommendation.js'; -import { installPluginFromMarketplace } from '../utils/plugins/pluginInstallationHelpers.js'; -import { installPluginAndNotify, usePluginRecommendationBase } from './usePluginRecommendationBase.js'; +import * as React from 'react' +import { useNotifications } from '../context/notifications.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, + logEvent, +} from '../services/analytics/index.js' +import { + clearPendingHint, + getPendingHintSnapshot, + markShownThisSession, + subscribeToPendingHint, +} from '../utils/claudeCodeHints.js' +import { logForDebugging } from '../utils/debug.js' +import { + disableHintRecommendations, + markHintPluginShown, + type PluginHintRecommendation, + resolvePluginHint, +} from '../utils/plugins/hintRecommendation.js' +import { installPluginFromMarketplace } from '../utils/plugins/pluginInstallationHelpers.js' +import { + installPluginAndNotify, + usePluginRecommendationBase, +} from './usePluginRecommendationBase.js' + type UseClaudeCodeHintRecommendationResult = { - recommendation: PluginHintRecommendation | null; - handleResponse: (response: 'yes' | 'no' | 'disable') => void; -}; -export function useClaudeCodeHintRecommendation() { - const $ = _c(11); - const pendingHint = React.useSyncExternalStore(subscribeToPendingHint, getPendingHintSnapshot); - const { - addNotification - } = useNotifications(); - const { - recommendation, - clearRecommendation, - tryResolve - } = usePluginRecommendationBase(); - let t0; - let t1; - if ($[0] !== pendingHint || $[1] !== tryResolve) { - t0 = () => { - if (!pendingHint) { - return; + recommendation: PluginHintRecommendation | null + handleResponse: (response: 'yes' | 'no' | 'disable') => void +} + +export function useClaudeCodeHintRecommendation(): UseClaudeCodeHintRecommendationResult { + const pendingHint = React.useSyncExternalStore( + subscribeToPendingHint, + getPendingHintSnapshot, + ) + const { addNotification } = useNotifications() + const { recommendation, clearRecommendation, tryResolve } = + usePluginRecommendationBase() + + React.useEffect(() => { + if (!pendingHint) return + tryResolve(async () => { + const resolved = await resolvePluginHint(pendingHint) + if (resolved) { + logForDebugging( + `[useClaudeCodeHintRecommendation] surfacing ${resolved.pluginId} from ${resolved.sourceCommand}`, + ) + markShownThisSession() } - tryResolve(async () => { - const resolved = await resolvePluginHint(pendingHint); - if (resolved) { - logForDebugging(`[useClaudeCodeHintRecommendation] surfacing ${resolved.pluginId} from ${resolved.sourceCommand}`); - markShownThisSession(); - } - if (getPendingHintSnapshot() === pendingHint) { - clearPendingHint(); - } - return resolved; - }); - }; - t1 = [pendingHint, tryResolve]; - $[0] = pendingHint; - $[1] = tryResolve; - $[2] = t0; - $[3] = t1; - } else { - t0 = $[2]; - t1 = $[3]; - } - React.useEffect(t0, t1); - let t2; - if ($[4] !== addNotification || $[5] !== clearRecommendation || $[6] !== recommendation) { - t2 = response => { - if (!recommendation) { - return; + // Drop the slot — but only if it still holds the hint we just + // resolved. A newer hint may have overwritten it during the async + // lookup; don't clobber that. + if (getPendingHintSnapshot() === pendingHint) { + clearPendingHint() } - markHintPluginShown(recommendation.pluginId); - logEvent("tengu_plugin_hint_response", { - _PROTO_plugin_name: recommendation.pluginName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, - _PROTO_marketplace_name: recommendation.marketplaceName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, - response: response as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - bb15: switch (response) { - case "yes": - { - const { - pluginId, - pluginName, - marketplaceName - } = recommendation; - installPluginAndNotify(pluginId, pluginName, "hint-plugin", addNotification, async pluginData => { + return resolved + }) + }, [pendingHint, tryResolve]) + + const handleResponse = React.useCallback( + (response: 'yes' | 'no' | 'disable') => { + if (!recommendation) return + + // Record show-once here, not at resolution-time — the dialog may have + // been blocked by a higher-priority focusedInputDialog and never + // rendered. Auto-dismiss reaches this via onResponse('no'). + markHintPluginShown(recommendation.pluginId) + logEvent('tengu_plugin_hint_response', { + _PROTO_plugin_name: + recommendation.pluginName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, + _PROTO_marketplace_name: + recommendation.marketplaceName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, + response: + response as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + switch (response) { + case 'yes': { + const { pluginId, pluginName, marketplaceName } = recommendation + void installPluginAndNotify( + pluginId, + pluginName, + 'hint-plugin', + addNotification, + async pluginData => { const result = await installPluginFromMarketplace({ pluginId, entry: pluginData.entry, marketplaceName, - scope: "user", - trigger: "hint" - }); + scope: 'user', + trigger: 'hint', + }) if (!result.success) { - throw new Error((result as any).error); + throw new Error(result.error) } - }); - break bb15; - } - case "disable": - { - disableHintRecommendations(); - break bb15; - } - case "no": + }, + ) + break + } + case 'disable': + disableHintRecommendations() + break + case 'no': + break } - clearRecommendation(); - }; - $[4] = addNotification; - $[5] = clearRecommendation; - $[6] = recommendation; - $[7] = t2; - } else { - t2 = $[7]; - } - const handleResponse = t2; - let t3; - if ($[8] !== handleResponse || $[9] !== recommendation) { - t3 = { - recommendation, - handleResponse - }; - $[8] = handleResponse; - $[9] = recommendation; - $[10] = t3; - } else { - t3 = $[10]; - } - return t3; + + clearRecommendation() + }, + [recommendation, addNotification, clearRecommendation], + ) + + return { recommendation, handleResponse } } diff --git a/src/hooks/useCommandKeybindings.tsx b/src/hooks/useCommandKeybindings.tsx index 581e43796..416a07ce7 100644 --- a/src/hooks/useCommandKeybindings.tsx +++ b/src/hooks/useCommandKeybindings.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * Component that registers keybinding handlers for command bindings. * @@ -9,99 +8,75 @@ import { c as _c } from "react/compiler-runtime"; * Commands triggered via keybinding are treated as "immediate" - they execute right * away and preserve the user's existing input text (the prompt is not cleared). */ -import { useMemo } from 'react'; -import { useIsModalOverlayActive } from '../context/overlayContext.js'; -import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import type { PromptInputHelpers } from '../utils/handlePromptSubmit.js'; +import { useMemo } from 'react' +import { useIsModalOverlayActive } from '../context/overlayContext.js' +import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import type { PromptInputHelpers } from '../utils/handlePromptSubmit.js' + type Props = { // onSubmit accepts additional parameters beyond what we pass here, // so we use a rest parameter to allow any additional args - onSubmit: (input: string, helpers: PromptInputHelpers, ...rest: [speculationAccept?: undefined, options?: { - fromKeybinding?: boolean; - }]) => void; + onSubmit: ( + input: string, + helpers: PromptInputHelpers, + ...rest: [ + speculationAccept?: undefined, + options?: { fromKeybinding?: boolean }, + ] + ) => void /** Set to false to disable command keybindings (e.g., when a dialog is open) */ - isActive?: boolean; -}; + isActive?: boolean +} + const NOOP_HELPERS: PromptInputHelpers = { setCursorOffset: () => {}, clearBuffer: () => {}, - resetHistory: () => {} -}; + resetHistory: () => {}, +} /** * Registers keybinding handlers for all "command:*" actions found in the * user's keybinding configuration. When triggered, each handler submits * the corresponding slash command (e.g., "command:commit" submits "/commit"). */ -export function CommandKeybindingHandlers(t0) { - const $ = _c(8); - const { - onSubmit, - isActive: t1 - } = t0; - const isActive = t1 === undefined ? true : t1; - const keybindingContext = useOptionalKeybindingContext(); - const isModalOverlayActive = useIsModalOverlayActive(); - let t2; - bb0: { - if (!keybindingContext) { - let t3; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t3 = new Set(); - $[0] = t3; - } else { - t3 = $[0]; +export function CommandKeybindingHandlers({ + onSubmit, + isActive = true, +}: Props): null { + const keybindingContext = useOptionalKeybindingContext() + const isModalOverlayActive = useIsModalOverlayActive() + + // Extract command actions from parsed bindings + const commandActions = useMemo(() => { + if (!keybindingContext) return new Set() + const actions = new Set() + for (const binding of keybindingContext.bindings) { + if (binding.action?.startsWith('command:')) { + actions.add(binding.action) } - t2 = t3; - break bb0; } - let actions; - if ($[1] !== keybindingContext.bindings) { - actions = new Set(); - for (const binding of keybindingContext.bindings) { - if (binding.action?.startsWith("command:")) { - actions.add(binding.action); - } - } - $[1] = keybindingContext.bindings; - $[2] = actions; - } else { - actions = $[2]; - } - t2 = actions; - } - const commandActions = t2; - let map; - if ($[3] !== commandActions || $[4] !== onSubmit) { - map = {}; + return actions + }, [keybindingContext]) + + // Build handler map for all command actions + const handlers = useMemo(() => { + const map: Record void> = {} for (const action of commandActions) { - const commandName = action.slice(8); + const commandName = action.slice('command:'.length) map[action] = () => { onSubmit(`/${commandName}`, NOOP_HELPERS, undefined, { - fromKeybinding: true - }); - }; + fromKeybinding: true, + }) + } } - $[3] = commandActions; - $[4] = onSubmit; - $[5] = map; - } else { - map = $[5]; - } - const handlers = map; - const t3 = isActive && !isModalOverlayActive; - let t4; - if ($[6] !== t3) { - t4 = { - context: "Chat", - isActive: t3 - }; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - useKeybindings(handlers, t4); - return null; + return map + }, [commandActions, onSubmit]) + + useKeybindings(handlers, { + context: 'Chat', + isActive: isActive && !isModalOverlayActive, + }) + + return null } diff --git a/src/hooks/useGlobalKeybindings.tsx b/src/hooks/useGlobalKeybindings.tsx index faaf1fe82..a41b1b6a5 100644 --- a/src/hooks/useGlobalKeybindings.tsx +++ b/src/hooks/useGlobalKeybindings.tsx @@ -4,27 +4,31 @@ * Must be rendered inside KeybindingSetup to have access to the keybinding context. * This component renders nothing - it just registers the keybinding handlers. */ -import { feature } from 'bun:bundle'; -import { useCallback } from 'react'; -import instances from '../ink/instances.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import type { Screen } from '../screens/REPL.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../services/analytics/index.js'; -import { useAppState, useSetAppState } from '../state/AppState.js'; -import { count } from '../utils/array.js'; -import { getTerminalPanel } from '../utils/terminalPanel.js'; +import { feature } from 'bun:bundle' +import { useCallback } from 'react' +import instances from '../ink/instances.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import type { Screen } from '../screens/REPL.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../services/analytics/index.js' +import { useAppState, useSetAppState } from '../state/AppState.js' +import { count } from '../utils/array.js' +import { getTerminalPanel } from '../utils/terminalPanel.js' + type Props = { - screen: Screen; - setScreen: React.Dispatch>; - showAllInTranscript: boolean; - setShowAllInTranscript: React.Dispatch>; - messageCount: number; - onEnterTranscript?: () => void; - onExitTranscript?: () => void; - virtualScrollActive?: boolean; - searchBarOpen?: boolean; -}; + screen: Screen + setScreen: React.Dispatch> + showAllInTranscript: boolean + setShowAllInTranscript: React.Dispatch> + messageCount: number + onEnterTranscript?: () => void + onExitTranscript?: () => void + virtualScrollActive?: boolean + searchBarOpen?: boolean +} /** * Registers global keybinding handlers for: @@ -42,56 +46,55 @@ export function GlobalKeybindingHandlers({ onEnterTranscript, onExitTranscript, virtualScrollActive, - searchBarOpen = false + searchBarOpen = false, }: Props): null { - const expandedView = useAppState(s => s.expandedView); - const setAppState = useSetAppState(); + const expandedView = useAppState(s => s.expandedView) + const setAppState = useSetAppState() // Toggle todo list (ctrl+t) - cycles through views const handleToggleTodos = useCallback(() => { logEvent('tengu_toggle_todos', { - is_expanded: expandedView === 'tasks' - }); + is_expanded: expandedView === 'tasks', + }) setAppState(prev => { - const { - getAllInProcessTeammateTasks - } = - // eslint-disable-next-line @typescript-eslint/no-require-imports - require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js'); - const hasTeammates = count(getAllInProcessTeammateTasks(prev.tasks), t => t.status === 'running') > 0; + const { getAllInProcessTeammateTasks } = + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') + const hasTeammates = + count( + getAllInProcessTeammateTasks(prev.tasks), + t => t.status === 'running', + ) > 0 + if (hasTeammates) { // Both exist: none → tasks → teammates → none switch (prev.expandedView) { case 'none': - return { - ...prev, - expandedView: 'tasks' as const - }; + return { ...prev, expandedView: 'tasks' as const } case 'tasks': - return { - ...prev, - expandedView: 'teammates' as const - }; + return { ...prev, expandedView: 'teammates' as const } case 'teammates': - return { - ...prev, - expandedView: 'none' as const - }; + return { ...prev, expandedView: 'none' as const } } } // Only tasks: none ↔ tasks return { ...prev, - expandedView: prev.expandedView === 'tasks' ? 'none' as const : 'tasks' as const - }; - }); - }, [expandedView, setAppState]); + expandedView: + prev.expandedView === 'tasks' + ? ('none' as const) + : ('tasks' as const), + } + }) + }, [expandedView, setAppState]) // Toggle transcript mode (ctrl+o). Two-way prompt ↔ transcript. // Brief view has its own dedicated toggle on ctrl+shift+b. - const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_0 => s_0.isBriefOnly) : false; + const isBriefOnly = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.isBriefOnly) + : false const handleToggleTranscript = useCallback(() => { if (feature('KAIROS') || feature('KAIROS_BRIEF')) { // Escape hatch: GB kill-switch while defaultView=chat was persisted @@ -100,58 +103,71 @@ export function GlobalKeybindingHandlers({ // Only needed in the prompt screen — transcript mode already ignores // isBriefOnly (Messages.tsx filter is gated on !isTranscriptMode). /* eslint-disable @typescript-eslint/no-require-imports */ - const { - isBriefEnabled - } = require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js'); + const { isBriefEnabled } = + require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js') /* eslint-enable @typescript-eslint/no-require-imports */ if (!isBriefEnabled() && isBriefOnly && screen !== 'transcript') { - setAppState(prev_0 => { - if (!prev_0.isBriefOnly) return prev_0; - return { - ...prev_0, - isBriefOnly: false - }; - }); - return; + setAppState(prev => { + if (!prev.isBriefOnly) return prev + return { ...prev, isBriefOnly: false } + }) + return } } - const isEnteringTranscript = screen !== 'transcript'; + + const isEnteringTranscript = screen !== 'transcript' logEvent('tengu_toggle_transcript', { is_entering: isEnteringTranscript, show_all: showAllInTranscript, - message_count: messageCount - }); - setScreen(s_1 => s_1 === 'transcript' ? 'prompt' : 'transcript'); - setShowAllInTranscript(false); + message_count: messageCount, + }) + setScreen(s => (s === 'transcript' ? 'prompt' : 'transcript')) + setShowAllInTranscript(false) if (isEnteringTranscript && onEnterTranscript) { - onEnterTranscript(); + onEnterTranscript() } if (!isEnteringTranscript && onExitTranscript) { - onExitTranscript(); + onExitTranscript() } - }, [screen, setScreen, isBriefOnly, showAllInTranscript, setShowAllInTranscript, messageCount, setAppState, onEnterTranscript, onExitTranscript]); + }, [ + screen, + setScreen, + isBriefOnly, + showAllInTranscript, + setShowAllInTranscript, + messageCount, + setAppState, + onEnterTranscript, + onExitTranscript, + ]) // Toggle showing all messages in transcript mode (ctrl+e) const handleToggleShowAll = useCallback(() => { logEvent('tengu_transcript_toggle_show_all', { is_expanding: !showAllInTranscript, - message_count: messageCount - }); - setShowAllInTranscript(prev_1 => !prev_1); - }, [showAllInTranscript, setShowAllInTranscript, messageCount]); + message_count: messageCount, + }) + setShowAllInTranscript(prev => !prev) + }, [showAllInTranscript, setShowAllInTranscript, messageCount]) // Exit transcript mode (ctrl+c or escape) const handleExitTranscript = useCallback(() => { logEvent('tengu_transcript_exit', { show_all: showAllInTranscript, - message_count: messageCount - }); - setScreen('prompt'); - setShowAllInTranscript(false); + message_count: messageCount, + }) + setScreen('prompt') + setShowAllInTranscript(false) if (onExitTranscript) { - onExitTranscript(); + onExitTranscript() } - }, [setScreen, showAllInTranscript, setShowAllInTranscript, messageCount, onExitTranscript]); + }, [ + setScreen, + showAllInTranscript, + setShowAllInTranscript, + messageCount, + onExitTranscript, + ]) // Toggle brief-only view (ctrl+shift+b). Pure display filter toggle — // does not touch opt-in state. Asymmetric gate (mirrors /brief): OFF @@ -160,81 +176,80 @@ export function GlobalKeybindingHandlers({ const handleToggleBrief = useCallback(() => { if (feature('KAIROS') || feature('KAIROS_BRIEF')) { /* eslint-disable @typescript-eslint/no-require-imports */ - const { - isBriefEnabled: isBriefEnabled_0 - } = require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js'); + const { isBriefEnabled } = + require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js') /* eslint-enable @typescript-eslint/no-require-imports */ - if (!isBriefEnabled_0() && !isBriefOnly) return; - const next = !isBriefOnly; + if (!isBriefEnabled() && !isBriefOnly) return + const next = !isBriefOnly logEvent('tengu_brief_mode_toggled', { enabled: next, gated: false, - source: 'keybinding' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - setAppState(prev_2 => { - if (prev_2.isBriefOnly === next) return prev_2; - return { - ...prev_2, - isBriefOnly: next - }; - }); + source: + 'keybinding' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + setAppState(prev => { + if (prev.isBriefOnly === next) return prev + return { ...prev, isBriefOnly: next } + }) } - }, [isBriefOnly, setAppState]); + }, [isBriefOnly, setAppState]) // Register keybinding handlers useKeybinding('app:toggleTodos', handleToggleTodos, { - context: 'Global' - }); + context: 'Global', + }) useKeybinding('app:toggleTranscript', handleToggleTranscript, { - context: 'Global' - }); + context: 'Global', + }) if (feature('KAIROS') || feature('KAIROS_BRIEF')) { // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useKeybinding('app:toggleBrief', handleToggleBrief, { - context: 'Global' - }); + context: 'Global', + }) } // Register teammate keybinding - useKeybinding('app:toggleTeammatePreview', () => { - setAppState(prev_3 => ({ - ...prev_3, - showTeammateMessagePreview: !prev_3.showTeammateMessagePreview - })); - }, { - context: 'Global' - }); + useKeybinding( + 'app:toggleTeammatePreview', + () => { + setAppState(prev => ({ + ...prev, + showTeammateMessagePreview: !prev.showTeammateMessagePreview, + })) + }, + { + context: 'Global', + }, + ) // Toggle built-in terminal panel (meta+j). // toggle() blocks in spawnSync until the user detaches from tmux. const handleToggleTerminal = useCallback(() => { if (feature('TERMINAL_PANEL')) { if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_panel', false)) { - return; + return } - getTerminalPanel().toggle(); + getTerminalPanel().toggle() } - }, []); + }, []) useKeybinding('app:toggleTerminal', handleToggleTerminal, { - context: 'Global' - }); + context: 'Global', + }) // Clear screen and force full redraw (ctrl+l). Recovery path when the // terminal was cleared externally (macOS Cmd+K) and Ink's diff engine // thinks unchanged cells don't need repainting. const handleRedraw = useCallback(() => { - instances.get(process.stdout)?.forceRedraw(); - }, []); - useKeybinding('app:redraw', handleRedraw, { - context: 'Global' - }); + instances.get(process.stdout)?.forceRedraw() + }, []) + useKeybinding('app:redraw', handleRedraw, { context: 'Global' }) // Transcript-specific bindings (only active when in transcript mode) - const isInTranscript = screen === 'transcript'; + const isInTranscript = screen === 'transcript' useKeybinding('transcript:toggleShowAll', handleToggleShowAll, { context: 'Transcript', - isActive: isInTranscript && !virtualScrollActive - }); + isActive: isInTranscript && !virtualScrollActive, + }) useKeybinding('transcript:exit', handleExitTranscript, { context: 'Transcript', // Bar-open is a mode (owns keystrokes). Navigating (highlights @@ -242,7 +257,8 @@ export function GlobalKeybindingHandlers({ // directly, same as less q. useSearchInput doesn't stopPropagation, // so without this gate its onCancel AND this handler would both // fire on one Esc (child registers first, fires first, bubbles). - isActive: isInTranscript && !searchBarOpen - }); - return null; + isActive: isInTranscript && !searchBarOpen, + }) + + return null } diff --git a/src/hooks/useIDEIntegration.tsx b/src/hooks/useIDEIntegration.tsx index 29c50b7c6..786146ee7 100644 --- a/src/hooks/useIDEIntegration.tsx +++ b/src/hooks/useIDEIntegration.tsx @@ -1,69 +1,88 @@ -import { c as _c } from "react/compiler-runtime"; -import { useEffect } from 'react'; -import type { ScopedMcpServerConfig } from '../services/mcp/types.js'; -import { getGlobalConfig } from '../utils/config.js'; -import { isEnvDefinedFalsy, isEnvTruthy } from '../utils/envUtils.js'; -import type { DetectedIDEInfo } from '../utils/ide.js'; -import { type IDEExtensionInstallationStatus, type IdeType, initializeIdeIntegration, isSupportedTerminal } from '../utils/ide.js'; +import { useEffect } from 'react' +import type { ScopedMcpServerConfig } from '../services/mcp/types.js' +import { getGlobalConfig } from '../utils/config.js' +import { isEnvDefinedFalsy, isEnvTruthy } from '../utils/envUtils.js' +import type { DetectedIDEInfo } from '../utils/ide.js' +import { + type IDEExtensionInstallationStatus, + type IdeType, + initializeIdeIntegration, + isSupportedTerminal, +} from '../utils/ide.js' + type UseIDEIntegrationProps = { - autoConnectIdeFlag?: boolean; - ideToInstallExtension: IdeType | null; - setDynamicMcpConfig: React.Dispatch | undefined>>; - setShowIdeOnboarding: React.Dispatch>; - setIDEInstallationState: React.Dispatch>; -}; -export function useIDEIntegration(t0) { - const $ = _c(7); - const { + autoConnectIdeFlag?: boolean + ideToInstallExtension: IdeType | null + setDynamicMcpConfig: React.Dispatch< + React.SetStateAction | undefined> + > + setShowIdeOnboarding: React.Dispatch> + setIDEInstallationState: React.Dispatch< + React.SetStateAction + > +} + +export function useIDEIntegration({ + autoConnectIdeFlag, + ideToInstallExtension, + setDynamicMcpConfig, + setShowIdeOnboarding, + setIDEInstallationState, +}: UseIDEIntegrationProps): void { + useEffect(() => { + function addIde(ide: DetectedIDEInfo | null) { + if (!ide) { + return + } + + // Check if auto-connect is enabled + const globalConfig = getGlobalConfig() + const autoConnectEnabled = + (globalConfig.autoConnectIde || + autoConnectIdeFlag || + isSupportedTerminal() || + // tmux/screen overwrite TERM_PROGRAM, breaking terminal detection, but the + // IDE extension's port env var is inherited. If set, auto-connect anyway. + process.env.CLAUDE_CODE_SSE_PORT || + ideToInstallExtension || + isEnvTruthy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE)) && + !isEnvDefinedFalsy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE) + + if (!autoConnectEnabled) { + return + } + + setDynamicMcpConfig(prev => { + // Only add the IDE if we don't already have one + if (prev?.ide) { + return prev + } + return { + ...prev, + ide: { + type: ide.url.startsWith('ws:') ? 'ws-ide' : 'sse-ide', + url: ide.url, + ideName: ide.name, + authToken: ide.authToken, + ideRunningInWindows: ide.ideRunningInWindows, + scope: 'dynamic' as const, + }, + } + }) + } + + // Use the new utility function + void initializeIdeIntegration( + addIde, + ideToInstallExtension, + () => setShowIdeOnboarding(true), + status => setIDEInstallationState(status), + ) + }, [ autoConnectIdeFlag, ideToInstallExtension, setDynamicMcpConfig, setShowIdeOnboarding, - setIDEInstallationState - } = t0; - let t1; - let t2; - if ($[0] !== autoConnectIdeFlag || $[1] !== ideToInstallExtension || $[2] !== setDynamicMcpConfig || $[3] !== setIDEInstallationState || $[4] !== setShowIdeOnboarding) { - t1 = () => { - const addIde = function addIde(ide) { - if (!ide) { - return; - } - const globalConfig = getGlobalConfig(); - const autoConnectEnabled = (globalConfig.autoConnectIde || autoConnectIdeFlag || isSupportedTerminal() || process.env.CLAUDE_CODE_SSE_PORT || ideToInstallExtension || isEnvTruthy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE)) && !isEnvDefinedFalsy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE); - if (!autoConnectEnabled) { - return; - } - setDynamicMcpConfig(prev => { - if (prev?.ide) { - return prev; - } - return { - ...prev, - ide: { - type: ide.url.startsWith("ws:") ? "ws-ide" : "sse-ide", - url: ide.url, - ideName: ide.name, - authToken: ide.authToken, - ideRunningInWindows: ide.ideRunningInWindows, - scope: "dynamic" as const - } - }; - }); - }; - initializeIdeIntegration(addIde, ideToInstallExtension, () => setShowIdeOnboarding(true), status => setIDEInstallationState(status)); - }; - t2 = [autoConnectIdeFlag, ideToInstallExtension, setDynamicMcpConfig, setShowIdeOnboarding, setIDEInstallationState]; - $[0] = autoConnectIdeFlag; - $[1] = ideToInstallExtension; - $[2] = setDynamicMcpConfig; - $[3] = setIDEInstallationState; - $[4] = setShowIdeOnboarding; - $[5] = t1; - $[6] = t2; - } else { - t1 = $[5]; - t2 = $[6]; - } - useEffect(t1, t2); + setIDEInstallationState, + ]) } diff --git a/src/hooks/useLspPluginRecommendation.tsx b/src/hooks/useLspPluginRecommendation.tsx index aaffb43a2..610431a63 100644 --- a/src/hooks/useLspPluginRecommendation.tsx +++ b/src/hooks/useLspPluginRecommendation.tsx @@ -1,4 +1,3 @@ -import { c as _c } from "react/compiler-runtime"; /** * Hook for LSP plugin recommendations * @@ -11,183 +10,170 @@ import { c as _c } from "react/compiler-runtime"; * Only shows one recommendation per session. */ -import { extname, join } from 'path'; -import * as React from 'react'; -import { hasShownLspRecommendationThisSession, setLspRecommendationShownThisSession } from '../bootstrap/state.js'; -import { useNotifications } from '../context/notifications.js'; -import { useAppState } from '../state/AppState.js'; -import { saveGlobalConfig } from '../utils/config.js'; -import { logForDebugging } from '../utils/debug.js'; -import { logError } from '../utils/log.js'; -import { addToNeverSuggest, getMatchingLspPlugins, incrementIgnoredCount } from '../utils/plugins/lspRecommendation.js'; -import { cacheAndRegisterPlugin } from '../utils/plugins/pluginInstallationHelpers.js'; -import { getSettingsForSource, updateSettingsForSource } from '../utils/settings/settings.js'; -import { installPluginAndNotify, usePluginRecommendationBase } from './usePluginRecommendationBase.js'; +import { extname, join } from 'path' +import * as React from 'react' +import { + hasShownLspRecommendationThisSession, + setLspRecommendationShownThisSession, +} from '../bootstrap/state.js' +import { useNotifications } from '../context/notifications.js' +import { useAppState } from '../state/AppState.js' +import { saveGlobalConfig } from '../utils/config.js' +import { logForDebugging } from '../utils/debug.js' +import { logError } from '../utils/log.js' +import { + addToNeverSuggest, + getMatchingLspPlugins, + incrementIgnoredCount, +} from '../utils/plugins/lspRecommendation.js' +import { cacheAndRegisterPlugin } from '../utils/plugins/pluginInstallationHelpers.js' +import { + getSettingsForSource, + updateSettingsForSource, +} from '../utils/settings/settings.js' +import { + installPluginAndNotify, + usePluginRecommendationBase, +} from './usePluginRecommendationBase.js' // Threshold for detecting timeout vs explicit dismiss (ms) // Menu auto-dismisses at 30s, so anything over 28s is likely timeout -const TIMEOUT_THRESHOLD_MS = 28_000; +const TIMEOUT_THRESHOLD_MS = 28_000 + export type LspRecommendationState = { - pluginId: string; - pluginName: string; - pluginDescription?: string; - fileExtension: string; - shownAt: number; // Timestamp for timeout detection -} | null; + pluginId: string + pluginName: string + pluginDescription?: string + fileExtension: string + shownAt: number // Timestamp for timeout detection +} | null + type UseLspPluginRecommendationResult = { - recommendation: LspRecommendationState; - handleResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void; -}; -export function useLspPluginRecommendation() { - const $ = _c(12); - const trackedFiles = useAppState(_temp); - const { - addNotification - } = useNotifications(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = new Set(); - $[0] = t0; - } else { - t0 = $[0]; - } - const checkedFilesRef = React.useRef(t0); - const { - recommendation, - clearRecommendation, - tryResolve - } = usePluginRecommendationBase(); - let t1; - let t2; - if ($[1] !== trackedFiles || $[2] !== tryResolve) { - t1 = () => { - tryResolve(async () => { - if (hasShownLspRecommendationThisSession()) { - return null; + recommendation: LspRecommendationState + handleResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void +} + +export function useLspPluginRecommendation(): UseLspPluginRecommendationResult { + const trackedFiles = useAppState(s => s.fileHistory.trackedFiles) + const { addNotification } = useNotifications() + const checkedFilesRef = React.useRef>(new Set()) + const { recommendation, clearRecommendation, tryResolve } = + usePluginRecommendationBase>() + + React.useEffect(() => { + tryResolve(async () => { + if (hasShownLspRecommendationThisSession()) return null + + const newFiles: string[] = [] + for (const file of trackedFiles) { + if (!checkedFilesRef.current.has(file)) { + checkedFilesRef.current.add(file) + newFiles.push(file) } - const newFiles = []; - for (const file of trackedFiles) { - if (!checkedFilesRef.current.has(file)) { - checkedFilesRef.current.add(file); - newFiles.push(file); - } - } - for (const filePath of newFiles) { - ; - try { - const matches = await getMatchingLspPlugins(filePath); - const match = matches[0]; - if (match) { - logForDebugging(`[useLspPluginRecommendation] Found match: ${match.pluginName} for ${filePath}`); - setLspRecommendationShownThisSession(true); - return { - pluginId: match.pluginId, - pluginName: match.pluginName, - pluginDescription: match.description, - fileExtension: extname(filePath), - shownAt: Date.now() - }; - } - } catch (t3) { - const error = t3; - logError(error); - } - } - return null; - }); - }; - t2 = [trackedFiles, tryResolve]; - $[1] = trackedFiles; - $[2] = tryResolve; - $[3] = t1; - $[4] = t2; - } else { - t1 = $[3]; - t2 = $[4]; - } - React.useEffect(t1, t2); - let t3; - if ($[5] !== addNotification || $[6] !== clearRecommendation || $[7] !== recommendation) { - t3 = response => { - if (!recommendation) { - return; } - const { - pluginId, - pluginName, - shownAt - } = recommendation; - logForDebugging(`[useLspPluginRecommendation] User response: ${response} for ${pluginName}`); - bb60: switch (response) { - case "yes": - { - installPluginAndNotify(pluginId, pluginName, "lsp-plugin", addNotification, async pluginData => { - logForDebugging(`[useLspPluginRecommendation] Installing plugin: ${pluginId}`); - const localSourcePath = typeof pluginData.entry.source === "string" ? join(pluginData.marketplaceInstallLocation, pluginData.entry.source) : undefined; - await cacheAndRegisterPlugin(pluginId, pluginData.entry, "user", undefined, localSourcePath); - const settings = getSettingsForSource("userSettings"); - updateSettingsForSource("userSettings", { + + for (const filePath of newFiles) { + try { + const matches = await getMatchingLspPlugins(filePath) + const match = matches[0] // official plugins prioritized + if (match) { + logForDebugging( + `[useLspPluginRecommendation] Found match: ${match.pluginName} for ${filePath}`, + ) + setLspRecommendationShownThisSession(true) + return { + pluginId: match.pluginId, + pluginName: match.pluginName, + pluginDescription: match.description, + fileExtension: extname(filePath), + shownAt: Date.now(), + } + } + } catch (error) { + logError(error) + } + } + return null + }) + }, [trackedFiles, tryResolve]) + + const handleResponse = React.useCallback( + (response: 'yes' | 'no' | 'never' | 'disable') => { + if (!recommendation) return + + const { pluginId, pluginName, shownAt } = recommendation + + logForDebugging( + `[useLspPluginRecommendation] User response: ${response} for ${pluginName}`, + ) + + switch (response) { + case 'yes': + void installPluginAndNotify( + pluginId, + pluginName, + 'lsp-plugin', + addNotification, + async pluginData => { + logForDebugging( + `[useLspPluginRecommendation] Installing plugin: ${pluginId}`, + ) + const localSourcePath = + typeof pluginData.entry.source === 'string' + ? join( + pluginData.marketplaceInstallLocation, + pluginData.entry.source, + ) + : undefined + await cacheAndRegisterPlugin( + pluginId, + pluginData.entry, + 'user', + undefined, // projectPath - not needed for user scope + localSourcePath, + ) + // Enable in user settings so it loads on restart + const settings = getSettingsForSource('userSettings') + updateSettingsForSource('userSettings', { enabledPlugins: { ...settings?.enabledPlugins, - [pluginId]: true - } - }); - logForDebugging(`[useLspPluginRecommendation] Plugin installed: ${pluginId}`); - }); - break bb60; - } - case "no": - { - const elapsed = Date.now() - shownAt; - if (elapsed >= TIMEOUT_THRESHOLD_MS) { - logForDebugging(`[useLspPluginRecommendation] Timeout detected (${elapsed}ms), incrementing ignored count`); - incrementIgnoredCount(); - } - break bb60; - } - case "never": - { - addToNeverSuggest(pluginId); - break bb60; - } - case "disable": - { - saveGlobalConfig(_temp2); + [pluginId]: true, + }, + }) + logForDebugging( + `[useLspPluginRecommendation] Plugin installed: ${pluginId}`, + ) + }, + ) + break + + case 'no': { + const elapsed = Date.now() - shownAt + if (elapsed >= TIMEOUT_THRESHOLD_MS) { + logForDebugging( + `[useLspPluginRecommendation] Timeout detected (${elapsed}ms), incrementing ignored count`, + ) + incrementIgnoredCount() } + break + } + + case 'never': + addToNeverSuggest(pluginId) + break + + case 'disable': + saveGlobalConfig(current => { + if (current.lspRecommendationDisabled) return current + return { ...current, lspRecommendationDisabled: true } + }) + break } - clearRecommendation(); - }; - $[5] = addNotification; - $[6] = clearRecommendation; - $[7] = recommendation; - $[8] = t3; - } else { - t3 = $[8]; - } - const handleResponse = t3; - let t4; - if ($[9] !== handleResponse || $[10] !== recommendation) { - t4 = { - recommendation, - handleResponse - }; - $[9] = handleResponse; - $[10] = recommendation; - $[11] = t4; - } else { - t4 = $[11]; - } - return t4; -} -function _temp2(current) { - if (current.lspRecommendationDisabled) { - return current; - } - return { - ...current, - lspRecommendationDisabled: true - }; -} -function _temp(s) { - return s.fileHistory.trackedFiles; + + clearRecommendation() + }, + [recommendation, addNotification, clearRecommendation], + ) + + return { recommendation, handleResponse } } diff --git a/src/hooks/useOfficialMarketplaceNotification.tsx b/src/hooks/useOfficialMarketplaceNotification.tsx index 7784c23d6..25cf62254 100644 --- a/src/hooks/useOfficialMarketplaceNotification.tsx +++ b/src/hooks/useOfficialMarketplaceNotification.tsx @@ -1,47 +1,67 @@ -import * as React from 'react'; -import type { Notification } from '../context/notifications.js'; -import { Text } from '../ink.js'; -import { logForDebugging } from '../utils/debug.js'; -import { checkAndInstallOfficialMarketplace } from '../utils/plugins/officialMarketplaceStartupCheck.js'; -import { useStartupNotification } from './notifs/useStartupNotification.js'; +import * as React from 'react' +import type { Notification } from '../context/notifications.js' +import { Text } from '../ink.js' +import { logForDebugging } from '../utils/debug.js' +import { checkAndInstallOfficialMarketplace } from '../utils/plugins/officialMarketplaceStartupCheck.js' +import { useStartupNotification } from './notifs/useStartupNotification.js' /** * Hook that handles official marketplace auto-installation and shows * notifications for success/failure in the bottom right of the REPL. */ -export function useOfficialMarketplaceNotification() { - useStartupNotification(_temp); -} -async function _temp() { - const result = await checkAndInstallOfficialMarketplace(); - const notifs = []; - if (result.configSaveFailed) { - logForDebugging("Showing marketplace config save failure notification"); - notifs.push({ - key: "marketplace-config-save-failed", - jsx: Failed to save marketplace retry info · Check ~/.claude.json permissions, - priority: "immediate", - timeoutMs: 10000 - }); - } - if (result.installed) { - logForDebugging("Showing marketplace installation success notification"); - notifs.push({ - key: "marketplace-installed", - jsx: ✓ Anthropic marketplace installed · /plugin to see available plugins, - priority: "immediate", - timeoutMs: 7000 - }); - } else { - if (result.skipped && result.reason === "unknown") { - logForDebugging("Showing marketplace installation failure notification"); +export function useOfficialMarketplaceNotification(): void { + useStartupNotification(async () => { + const result = await checkAndInstallOfficialMarketplace() + const notifs: Notification[] = [] + + // Check for config save failure first - this is critical + if (result.configSaveFailed) { + logForDebugging('Showing marketplace config save failure notification') notifs.push({ - key: "marketplace-install-failed", - jsx: Failed to install Anthropic marketplace · Will retry on next startup, - priority: "immediate", - timeoutMs: 8000 - }); + key: 'marketplace-config-save-failed', + jsx: ( + + Failed to save marketplace retry info · Check ~/.claude.json + permissions + + ), + priority: 'immediate', + timeoutMs: 10000, + }) } - } - return notifs; + + if (result.installed) { + logForDebugging('Showing marketplace installation success notification') + notifs.push({ + key: 'marketplace-installed', + jsx: ( + + ✓ Anthropic marketplace installed · /plugin to see available plugins + + ), + priority: 'immediate', + timeoutMs: 7000, + }) + } else if (result.skipped && result.reason === 'unknown') { + logForDebugging('Showing marketplace installation failure notification') + notifs.push({ + key: 'marketplace-install-failed', + jsx: ( + + Failed to install Anthropic marketplace · Will retry on next startup + + ), + priority: 'immediate', + timeoutMs: 8000, + }) + } + // Don't show notifications for: + // - already_installed (user already has it) + // - policy_blocked (enterprise policy, don't nag) + // - already_attempted (handled by retry logic now) + // - git_unavailable (marketplace is a nice-to-have; if git is missing + // or is a non-functional macOS xcrun shim, retry silently on backoff + // rather than nagging — the user will sort git out for other reasons) + return notifs + }) } diff --git a/src/hooks/usePluginRecommendationBase.tsx b/src/hooks/usePluginRecommendationBase.tsx index db0167ccf..23930fba4 100644 --- a/src/hooks/usePluginRecommendationBase.tsx +++ b/src/hooks/usePluginRecommendationBase.tsx @@ -1,19 +1,19 @@ -import { c as _c } from "react/compiler-runtime"; /** * Shared state machine + install helper for plugin-recommendation hooks * (LSP, claude-code-hint). Centralizes the gate chain, async-guard, * and success/failure notification JSX so new sources stay small. */ -import figures from 'figures'; -import * as React from 'react'; -import { getIsRemoteMode } from '../bootstrap/state.js'; -import type { useNotifications } from '../context/notifications.js'; -import { Text } from '../ink.js'; -import { logError } from '../utils/log.js'; -import { getPluginById } from '../utils/plugins/marketplaceManager.js'; -type AddNotification = ReturnType['addNotification']; -type PluginData = NonNullable>>; +import figures from 'figures' +import * as React from 'react' +import { getIsRemoteMode } from '../bootstrap/state.js' +import type { useNotifications } from '../context/notifications.js' +import { Text } from '../ink.js' +import { logError } from '../utils/log.js' +import { getPluginById } from '../utils/plugins/marketplaceManager.js' + +type AddNotification = ReturnType['addNotification'] +type PluginData = NonNullable>> /** * Call tryResolve inside a useEffect; it applies standard gates (remote @@ -21,84 +21,72 @@ type PluginData = NonNullable>>; * becomes the recommendation. Include tryResolve in effect deps — its * identity tracks recommendation, so clearing re-triggers resolution. */ -export function usePluginRecommendationBase() { - const $ = _c(6); - const [recommendation, setRecommendation] = React.useState(null); - const isCheckingRef = React.useRef(false); - let t0; - if ($[0] !== recommendation) { - t0 = resolve => { - if (getIsRemoteMode()) { - return; - } - if (recommendation) { - return; - } - if (isCheckingRef.current) { - return; - } - isCheckingRef.current = true; - resolve().then(rec => { - if (rec) { - setRecommendation(rec); - } - }).catch(logError).finally(() => { - isCheckingRef.current = false; - }); - }; - $[0] = recommendation; - $[1] = t0; - } else { - t0 = $[1]; - } - const tryResolve = t0; - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => setRecommendation(null); - $[2] = t1; - } else { - t1 = $[2]; - } - const clearRecommendation = t1; - let t2; - if ($[3] !== recommendation || $[4] !== tryResolve) { - t2 = { - recommendation, - clearRecommendation, - tryResolve - }; - $[3] = recommendation; - $[4] = tryResolve; - $[5] = t2; - } else { - t2 = $[5]; - } - return t2; +export function usePluginRecommendationBase(): { + recommendation: T | null + clearRecommendation: () => void + tryResolve: (resolve: () => Promise) => void +} { + const [recommendation, setRecommendation] = React.useState(null) + const isCheckingRef = React.useRef(false) + + const tryResolve = React.useCallback( + (resolve: () => Promise) => { + if (getIsRemoteMode()) return + if (recommendation) return + if (isCheckingRef.current) return + + isCheckingRef.current = true + void resolve() + .then(rec => { + if (rec) setRecommendation(rec) + }) + .catch(logError) + .finally(() => { + isCheckingRef.current = false + }) + }, + [recommendation], + ) + + const clearRecommendation = React.useCallback( + () => setRecommendation(null), + [], + ) + + return { recommendation, clearRecommendation, tryResolve } } /** Look up plugin, run install(), emit standard success/failure notification. */ -export async function installPluginAndNotify(pluginId: string, pluginName: string, keyPrefix: string, addNotification: AddNotification, install: (pluginData: PluginData) => Promise): Promise { +export async function installPluginAndNotify( + pluginId: string, + pluginName: string, + keyPrefix: string, + addNotification: AddNotification, + install: (pluginData: PluginData) => Promise, +): Promise { try { - const pluginData = await getPluginById(pluginId); + const pluginData = await getPluginById(pluginId) if (!pluginData) { - throw new Error(`Plugin ${pluginId} not found in marketplace`); + throw new Error(`Plugin ${pluginId} not found in marketplace`) } - await install(pluginData); + await install(pluginData) addNotification({ key: `${keyPrefix}-installed`, - jsx: + jsx: ( + {figures.tick} {pluginName} installed · restart to apply - , + + ), priority: 'immediate', - timeoutMs: 5000 - }); + timeoutMs: 5000, + }) } catch (error) { - logError(error); + logError(error) addNotification({ key: `${keyPrefix}-install-failed`, jsx: Failed to install {pluginName}, priority: 'immediate', - timeoutMs: 5000 - }); + timeoutMs: 5000, + }) } } diff --git a/src/hooks/usePromptsFromClaudeInChrome.tsx b/src/hooks/usePromptsFromClaudeInChrome.tsx index d71f08353..be7fa8363 100644 --- a/src/hooks/usePromptsFromClaudeInChrome.tsx +++ b/src/hooks/usePromptsFromClaudeInChrome.tsx @@ -1,70 +1,129 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'; -import { useEffect, useRef } from 'react'; -import { logError } from 'src/utils/log.js'; -import { z } from 'zod/v4'; -import { callIdeRpc } from '../services/mcp/client.js'; -import type { ConnectedMCPServer, MCPServerConnection } from '../services/mcp/types.js'; -import type { PermissionMode } from '../types/permissions.js'; -import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, isTrackedClaudeInChromeTabId } from '../utils/claudeInChrome/common.js'; -import { lazySchema } from '../utils/lazySchema.js'; -import { enqueuePendingNotification } from '../utils/messageQueueManager.js'; +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' +import { useEffect, useRef } from 'react' +import { logError } from 'src/utils/log.js' +import { z } from 'zod/v4' +import { callIdeRpc } from '../services/mcp/client.js' +import type { + ConnectedMCPServer, + MCPServerConnection, +} from '../services/mcp/types.js' +import type { PermissionMode } from '../types/permissions.js' +import { + CLAUDE_IN_CHROME_MCP_SERVER_NAME, + isTrackedClaudeInChromeTabId, +} from '../utils/claudeInChrome/common.js' +import { lazySchema } from '../utils/lazySchema.js' +import { enqueuePendingNotification } from '../utils/messageQueueManager.js' // Schema for the prompt notification from Chrome extension (JSON-RPC 2.0 format) -const ClaudeInChromePromptNotificationSchema = lazySchema(() => z.object({ - method: z.literal('notifications/message'), - params: z.object({ - prompt: z.string(), - image: z.object({ - type: z.literal('base64'), - media_type: z.enum(['image/jpeg', 'image/png', 'image/gif', 'image/webp']), - data: z.string() - }).optional(), - tabId: z.number().optional() - }) -})); +const ClaudeInChromePromptNotificationSchema = lazySchema(() => + z.object({ + method: z.literal('notifications/message'), + params: z.object({ + prompt: z.string(), + image: z + .object({ + type: z.literal('base64'), + media_type: z.enum([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + ]), + data: z.string(), + }) + .optional(), + tabId: z.number().optional(), + }), + }), +) /** * A hook that listens for prompt notifications from the Claude for Chrome extension, * enqueues them as user prompts, and syncs permission mode changes to the extension. */ -export function usePromptsFromClaudeInChrome(mcpClients, toolPermissionMode) { - const $ = _c(6); - useRef(undefined); - let t0; - if ($[0] !== mcpClients) { - t0 = [mcpClients]; - $[0] = mcpClients; - $[1] = t0; - } else { - t0 = $[1]; - } - useEffect(_temp, t0); - let t1; - let t2; - if ($[2] !== mcpClients || $[3] !== toolPermissionMode) { - t1 = () => { - const chromeClient = findChromeClient(mcpClients); - if (!chromeClient) { - return; - } - const chromeMode = toolPermissionMode === "bypassPermissions" ? "skip_all_permission_checks" : "ask"; - callIdeRpc("set_permission_mode", { - mode: chromeMode - }, chromeClient); - }; - t2 = [mcpClients, toolPermissionMode]; - $[2] = mcpClients; - $[3] = toolPermissionMode; - $[4] = t1; - $[5] = t2; - } else { - t1 = $[4]; - t2 = $[5]; - } - useEffect(t1, t2); +export function usePromptsFromClaudeInChrome( + mcpClients: MCPServerConnection[], + toolPermissionMode: PermissionMode, +): void { + const mcpClientRef = useRef(undefined) + + useEffect(() => { + if ("external" !== 'ant') { + return + } + + const mcpClient = findChromeClient(mcpClients) + if (mcpClientRef.current !== mcpClient) { + mcpClientRef.current = mcpClient + } + + if (mcpClient) { + mcpClient.client.setNotificationHandler( + ClaudeInChromePromptNotificationSchema(), + notification => { + if (mcpClientRef.current !== mcpClient) { + return + } + const { tabId, prompt, image } = notification.params + + // Process notifications from tabs we're tracking since notifications are broadcasted + if ( + typeof tabId !== 'number' || + !isTrackedClaudeInChromeTabId(tabId) + ) { + return + } + + try { + // Build content blocks if there's an image, otherwise just use the prompt string + if (image) { + const contentBlocks: ContentBlockParam[] = [ + { type: 'text', text: prompt }, + { + type: 'image', + source: { + type: image.type, + media_type: image.media_type, + data: image.data, + }, + }, + ] + enqueuePendingNotification({ + value: contentBlocks, + mode: 'prompt', + }) + } else { + enqueuePendingNotification({ value: prompt, mode: 'prompt' }) + } + } catch (error) { + logError(error as Error) + } + }, + ) + } + }, [mcpClients]) + + // Sync permission mode with Chrome extension whenever it changes + useEffect(() => { + const chromeClient = findChromeClient(mcpClients) + if (!chromeClient) return + + const chromeMode = + toolPermissionMode === 'bypassPermissions' + ? 'skip_all_permission_checks' + : 'ask' + + void callIdeRpc('set_permission_mode', { mode: chromeMode }, chromeClient) + }, [mcpClients, toolPermissionMode]) } -function _temp() {} -function findChromeClient(clients: MCPServerConnection[]): ConnectedMCPServer | undefined { - return clients.find((client): client is ConnectedMCPServer => client.type === 'connected' && client.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME); + +function findChromeClient( + clients: MCPServerConnection[], +): ConnectedMCPServer | undefined { + return clients.find( + (client): client is ConnectedMCPServer => + client.type === 'connected' && + client.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME, + ) } diff --git a/src/hooks/useReplBridge.tsx b/src/hooks/useReplBridge.tsx index 6a767a1cd..522202891 100644 --- a/src/hooks/useReplBridge.tsx +++ b/src/hooks/useReplBridge.tsx @@ -1,32 +1,52 @@ -import { feature } from 'bun:bundle'; -import React, { useCallback, useEffect, useRef } from 'react'; -import { setMainLoopModelOverride } from '../bootstrap/state.js'; -import { type BridgePermissionCallbacks, type BridgePermissionResponse, isBridgePermissionResponse } from '../bridge/bridgePermissionCallbacks.js'; -import { buildBridgeConnectUrl } from '../bridge/bridgeStatusUtil.js'; -import { extractInboundMessageFields } from '../bridge/inboundMessages.js'; -import type { BridgeState, ReplBridgeHandle } from '../bridge/replBridge.js'; -import { setReplBridgeHandle } from '../bridge/replBridgeHandle.js'; -import type { Command } from '../commands.js'; -import { getSlashCommandToolSkills, isBridgeSafeCommand } from '../commands.js'; -import { getRemoteSessionUrl } from '../constants/product.js'; -import { useNotifications } from '../context/notifications.js'; -import type { PermissionMode, SDKMessage } from '../entrypoints/agentSdkTypes.js'; -import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js'; -import { Text } from '../ink.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'; -import { useAppState, useAppStateStore, useSetAppState } from '../state/AppState.js'; -import type { Message } from '../types/message.js'; -import { getCwd } from '../utils/cwd.js'; -import { logForDebugging } from '../utils/debug.js'; -import { errorMessage } from '../utils/errors.js'; -import { enqueue } from '../utils/messageQueueManager.js'; -import { buildSystemInitMessage } from '../utils/messages/systemInit.js'; -import { createBridgeStatusMessage, createSystemMessage } from '../utils/messages.js'; -import { getAutoModeUnavailableNotification, getAutoModeUnavailableReason, isAutoModeGateEnabled, isBypassPermissionsModeDisabled, transitionPermissionMode } from '../utils/permissions/permissionSetup.js'; -import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js'; +import { feature } from 'bun:bundle' +import React, { useCallback, useEffect, useRef } from 'react' +import { setMainLoopModelOverride } from '../bootstrap/state.js' +import { + type BridgePermissionCallbacks, + type BridgePermissionResponse, + isBridgePermissionResponse, +} from '../bridge/bridgePermissionCallbacks.js' +import { buildBridgeConnectUrl } from '../bridge/bridgeStatusUtil.js' +import { extractInboundMessageFields } from '../bridge/inboundMessages.js' +import type { BridgeState, ReplBridgeHandle } from '../bridge/replBridge.js' +import { setReplBridgeHandle } from '../bridge/replBridgeHandle.js' +import type { Command } from '../commands.js' +import { getSlashCommandToolSkills, isBridgeSafeCommand } from '../commands.js' +import { getRemoteSessionUrl } from '../constants/product.js' +import { useNotifications } from '../context/notifications.js' +import type { + PermissionMode, + SDKMessage, +} from '../entrypoints/agentSdkTypes.js' +import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js' +import { Text } from '../ink.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' +import { + useAppState, + useAppStateStore, + useSetAppState, +} from '../state/AppState.js' +import type { Message } from '../types/message.js' +import { getCwd } from '../utils/cwd.js' +import { logForDebugging } from '../utils/debug.js' +import { errorMessage } from '../utils/errors.js' +import { enqueue } from '../utils/messageQueueManager.js' +import { buildSystemInitMessage } from '../utils/messages/systemInit.js' +import { + createBridgeStatusMessage, + createSystemMessage, +} from '../utils/messages.js' +import { + getAutoModeUnavailableNotification, + getAutoModeUnavailableReason, + isAutoModeGateEnabled, + isBypassPermissionsModeDisabled, + transitionPermissionMode, +} from '../utils/permissions/permissionSetup.js' +import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js' /** How long after a failure before replBridgeEnabled is auto-cleared (stops retries). */ -export const BRIDGE_FAILURE_DISMISS_MS = 10_000; +export const BRIDGE_FAILURE_DISMISS_MS = 10_000 /** * Max consecutive initReplBridge failures before the hook stops re-attempting @@ -37,7 +57,7 @@ export const BRIDGE_FAILURE_DISMISS_MS = 10_000; * top stuck client generated 2,879 × 401/day alone (17% of all 401s on the * route). */ -const MAX_CONSECUTIVE_INIT_FAILURES = 3; +const MAX_CONSECUTIVE_INIT_FAILURES = 3 /** * Hook that initializes an always-on bridge connection in the background @@ -50,44 +70,52 @@ const MAX_CONSECUTIVE_INIT_FAILURES = 3; * * Inbound messages from claude.ai are injected into the REPL via queuedCommands. */ -export function useReplBridge(messages: Message[], setMessages: (action: React.SetStateAction) => void, abortControllerRef: React.RefObject, commands: readonly Command[], mainLoopModel: string): { - sendBridgeResult: () => void; -} { - const handleRef = useRef(null); - const teardownPromiseRef = useRef | undefined>(undefined); - const lastWrittenIndexRef = useRef(0); +export function useReplBridge( + messages: Message[], + setMessages: (action: React.SetStateAction) => void, + abortControllerRef: React.RefObject, + commands: readonly Command[], + mainLoopModel: string, +): { sendBridgeResult: () => void } { + const handleRef = useRef(null) + const teardownPromiseRef = useRef | undefined>(undefined) + const lastWrittenIndexRef = useRef(0) // Tracks UUIDs already flushed as initial messages. Persists across // bridge reconnections so Bridge #2+ only sends new messages — sending // duplicate UUIDs causes the server to kill the WebSocket. - const flushedUUIDsRef = useRef(new Set()); - const failureTimeoutRef = useRef | undefined>(undefined); + const flushedUUIDsRef = useRef(new Set()) + const failureTimeoutRef = useRef | undefined>( + undefined, + ) // Persists across effect re-runs (unlike the effect's local state). Reset // only on successful init. Hits MAX_CONSECUTIVE_INIT_FAILURES → fuse blown // for the session, regardless of replBridgeEnabled re-toggling. - const consecutiveFailuresRef = useRef(0); - const setAppState = useSetAppState(); - const commandsRef = useRef(commands); - commandsRef.current = commands; - const mainLoopModelRef = useRef(mainLoopModel); - mainLoopModelRef.current = mainLoopModel; - const messagesRef = useRef(messages); - messagesRef.current = messages; - const store = useAppStateStore(); - const { - addNotification - } = useNotifications(); - const replBridgeEnabled = feature('BRIDGE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.replBridgeEnabled) : false; - const replBridgeConnected = feature('BRIDGE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_0 => s_0.replBridgeConnected) : false; - const replBridgeOutboundOnly = feature('BRIDGE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_1 => s_1.replBridgeOutboundOnly) : false; - const replBridgeInitialName = feature('BRIDGE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_2 => s_2.replBridgeInitialName) : undefined; + const consecutiveFailuresRef = useRef(0) + const setAppState = useSetAppState() + const commandsRef = useRef(commands) + commandsRef.current = commands + const mainLoopModelRef = useRef(mainLoopModel) + mainLoopModelRef.current = mainLoopModel + const messagesRef = useRef(messages) + messagesRef.current = messages + const store = useAppStateStore() + const { addNotification } = useNotifications() + const replBridgeEnabled = feature('BRIDGE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.replBridgeEnabled) + : false + const replBridgeConnected = feature('BRIDGE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.replBridgeConnected) + : false + const replBridgeOutboundOnly = feature('BRIDGE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.replBridgeOutboundOnly) + : false + const replBridgeInitialName = feature('BRIDGE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.replBridgeInitialName) + : undefined // Initialize/teardown bridge when enabled state changes. // Passes current messages as initialMessages so the remote session @@ -97,39 +125,48 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // negative pattern (if (!feature(...)) return) does NOT eliminate // dynamic imports below. if (feature('BRIDGE_MODE')) { - if (!replBridgeEnabled) return; - const outboundOnly = replBridgeOutboundOnly; + if (!replBridgeEnabled) return + + const outboundOnly = replBridgeOutboundOnly function notifyBridgeFailed(detail?: string): void { - if (outboundOnly) return; + if (outboundOnly) return addNotification({ key: 'bridge-failed', - jsx: <> + jsx: ( + <> Remote Control failed {detail && · {detail}} - , - priority: 'immediate' - }); + + ), + priority: 'immediate', + }) } + if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_INIT_FAILURES) { - logForDebugging(`[bridge:repl] Hook: ${consecutiveFailuresRef.current} consecutive init failures, not retrying this session`); + logForDebugging( + `[bridge:repl] Hook: ${consecutiveFailuresRef.current} consecutive init failures, not retrying this session`, + ) // Clear replBridgeEnabled so /remote-control doesn't mistakenly show // BridgeDisconnectDialog for a bridge that never connected. - const fuseHint = 'disabled after repeated failures · restart to retry'; - notifyBridgeFailed(fuseHint); + const fuseHint = 'disabled after repeated failures · restart to retry' + notifyBridgeFailed(fuseHint) setAppState(prev => { - if (prev.replBridgeError === fuseHint && !prev.replBridgeEnabled) return prev; + if (prev.replBridgeError === fuseHint && !prev.replBridgeEnabled) + return prev return { ...prev, replBridgeError: fuseHint, - replBridgeEnabled: false - }; - }); - return; + replBridgeEnabled: false, + } + }) + return } - let cancelled = false; + + let cancelled = false // Capture messages.length now so we don't re-send initial messages // through writeMessages after the bridge connects. - const initialMessageCount = messages.length; + const initialMessageCount = messages.length + void (async () => { try { // Wait for any in-progress teardown to complete before registering @@ -137,20 +174,22 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // the previous teardown races with the new register call, and the // server may tear down the freshly-created environment. if (teardownPromiseRef.current) { - logForDebugging('[bridge:repl] Hook: waiting for previous teardown to complete before re-init'); - await teardownPromiseRef.current; - teardownPromiseRef.current = undefined; - logForDebugging('[bridge:repl] Hook: previous teardown complete, proceeding with re-init'); + logForDebugging( + '[bridge:repl] Hook: waiting for previous teardown to complete before re-init', + ) + await teardownPromiseRef.current + teardownPromiseRef.current = undefined + logForDebugging( + '[bridge:repl] Hook: previous teardown complete, proceeding with re-init', + ) } - if (cancelled) return; + if (cancelled) return // Dynamic import so the module is tree-shaken in external builds - const { - initReplBridge - } = await import('../bridge/initReplBridge.js'); - const { - shouldShowAppUpgradeMessage - } = await import('../bridge/envLessBridgeConfig.js'); + const { initReplBridge } = await import('../bridge/initReplBridge.js') + const { shouldShowAppUpgradeMessage } = await import( + '../bridge/envLessBridgeConfig.js' + ) // Assistant mode: perpetual bridge session — claude.ai shows one // continuous conversation across CLI restarts instead of a new @@ -161,12 +200,10 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // pointer-clear so the session survives clean exits, not just // crashes. Non-assistant bridges clear the pointer on teardown // (crash-recovery only). - let perpetual = false; + let perpetual = false if (feature('KAIROS')) { - const { - isAssistantMode - } = await import('../assistant/index.js'); - perpetual = isAssistantMode(); + const { isAssistantMode } = await import('../assistant/index.js') + perpetual = isAssistantMode() } // When a user message arrives from claude.ai, inject it into the REPL. @@ -179,30 +216,32 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // later, which is fine (web messages aren't rapid-fire). async function handleInboundMessage(msg: SDKMessage): Promise { try { - const fields = extractInboundMessageFields(msg); - if (!fields) return; - const { - uuid - } = fields; + const fields = extractInboundMessageFields(msg) + if (!fields) return + + const { uuid } = fields // Dynamic import keeps the bridge code out of non-BRIDGE_MODE builds. - const { - resolveAndPrepend - } = await import('../bridge/inboundAttachments.js'); - let sanitized = fields.content; + const { resolveAndPrepend } = await import( + '../bridge/inboundAttachments.js' + ) + let sanitized = fields.content if (feature('KAIROS_GITHUB_WEBHOOKS')) { /* eslint-disable @typescript-eslint/no-require-imports */ - const { - sanitizeInboundWebhookContent - } = require('../bridge/webhookSanitizer.js') as typeof import('../bridge/webhookSanitizer.js'); + const { sanitizeInboundWebhookContent } = + require('../bridge/webhookSanitizer.js') as typeof import('../bridge/webhookSanitizer.js') /* eslint-enable @typescript-eslint/no-require-imports */ - if (typeof fields.content === 'string') { - sanitized = sanitizeInboundWebhookContent(fields.content); - } + sanitized = sanitizeInboundWebhookContent(fields.content) } - const content = await resolveAndPrepend(msg, sanitized); - const preview = typeof content === 'string' ? content.slice(0, 80) : `[${content.length} content blocks]`; - logForDebugging(`[bridge:repl] Injecting inbound user message: ${preview}${uuid ? ` uuid=${uuid}` : ''}`); + const content = await resolveAndPrepend(msg, sanitized) + + const preview = + typeof content === 'string' + ? content.slice(0, 80) + : `[${content.length} content blocks]` + logForDebugging( + `[bridge:repl] Injecting inbound user message: ${preview}${uuid ? ` uuid=${uuid}` : ''}`, + ) enqueue({ value: content, mode: 'prompt' as const, @@ -213,54 +252,73 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // This keeps exit-word suppression and immediate-command blocks // intact for any code path that checks skipSlashCommands directly. skipSlashCommands: true, - bridgeOrigin: true - }); + bridgeOrigin: true, + }) } catch (e) { - logForDebugging(`[bridge:repl] handleInboundMessage failed: ${e}`, { - level: 'error' - }); + logForDebugging( + `[bridge:repl] handleInboundMessage failed: ${e}`, + { level: 'error' }, + ) } } // State change callback — maps bridge lifecycle events to AppState. - function handleStateChange(state: BridgeState, detail_0?: string): void { - if (cancelled) return; + function handleStateChange( + state: BridgeState, + detail?: string, + ): void { + if (cancelled) return if (outboundOnly) { - logForDebugging(`[bridge:repl] Mirror state=${state}${detail_0 ? ` detail=${detail_0}` : ''}`); + logForDebugging( + `[bridge:repl] Mirror state=${state}${detail ? ` detail=${detail}` : ''}`, + ) // Sync replBridgeConnected so the forwarding effect starts/stops // writing as the transport comes up or dies. if (state === 'failed') { - setAppState(prev_3 => { - if (!prev_3.replBridgeConnected) return prev_3; - return { - ...prev_3, - replBridgeConnected: false - }; - }); + setAppState(prev => { + if (!prev.replBridgeConnected) return prev + return { ...prev, replBridgeConnected: false } + }) } else if (state === 'ready' || state === 'connected') { - setAppState(prev_4 => { - if (prev_4.replBridgeConnected) return prev_4; - return { - ...prev_4, - replBridgeConnected: true - }; - }); + setAppState(prev => { + if (prev.replBridgeConnected) return prev + return { ...prev, replBridgeConnected: true } + }) } - return; + return } - const handle = handleRef.current; + const handle = handleRef.current switch (state) { case 'ready': - setAppState(prev_9 => { - const connectUrl = handle && handle.environmentId !== '' ? buildBridgeConnectUrl(handle.environmentId, handle.sessionIngressUrl) : prev_9.replBridgeConnectUrl; - const sessionUrl = handle ? getRemoteSessionUrl(handle.bridgeSessionId, handle.sessionIngressUrl) : prev_9.replBridgeSessionUrl; - const envId = handle?.environmentId; - const sessionId = handle?.bridgeSessionId; - if (prev_9.replBridgeConnected && !prev_9.replBridgeSessionActive && !prev_9.replBridgeReconnecting && prev_9.replBridgeConnectUrl === connectUrl && prev_9.replBridgeSessionUrl === sessionUrl && prev_9.replBridgeEnvironmentId === envId && prev_9.replBridgeSessionId === sessionId) { - return prev_9; + setAppState(prev => { + const connectUrl = + handle && handle.environmentId !== '' + ? buildBridgeConnectUrl( + handle.environmentId, + handle.sessionIngressUrl, + ) + : prev.replBridgeConnectUrl + const sessionUrl = handle + ? getRemoteSessionUrl( + handle.bridgeSessionId, + handle.sessionIngressUrl, + ) + : prev.replBridgeSessionUrl + const envId = handle?.environmentId + const sessionId = handle?.bridgeSessionId + if ( + prev.replBridgeConnected && + !prev.replBridgeSessionActive && + !prev.replBridgeReconnecting && + prev.replBridgeConnectUrl === connectUrl && + prev.replBridgeSessionUrl === sessionUrl && + prev.replBridgeEnvironmentId === envId && + prev.replBridgeSessionId === sessionId + ) { + return prev } return { - ...prev_9, + ...prev, replBridgeConnected: true, replBridgeSessionActive: false, replBridgeReconnecting: false, @@ -268,35 +326,40 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S replBridgeSessionUrl: sessionUrl, replBridgeEnvironmentId: envId, replBridgeSessionId: sessionId, - replBridgeError: undefined - }; - }); - break; - case 'connected': - { - setAppState(prev_8 => { - if (prev_8.replBridgeSessionActive) return prev_8; - return { - ...prev_8, - replBridgeConnected: true, - replBridgeSessionActive: true, - replBridgeReconnecting: false, - replBridgeError: undefined - }; - }); - // Send system/init so remote clients (web/iOS/Android) get - // session metadata. REPL uses query() directly — never hits - // QueryEngine's SDKMessage layer — so this is the only path - // to put system/init on the REPL-bridge wire. Skills load is - // async (memoized, cheap after REPL startup); fire-and-forget - // so the connected-state transition isn't blocked. - if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_system_init', true)) { - void (async () => { - try { - const skills = await getSlashCommandToolSkills(getCwd()); - if (cancelled) return; - const state_0 = store.getState(); - handleRef.current?.writeSdkMessages([buildSystemInitMessage({ + replBridgeError: undefined, + } + }) + break + case 'connected': { + setAppState(prev => { + if (prev.replBridgeSessionActive) return prev + return { + ...prev, + replBridgeConnected: true, + replBridgeSessionActive: true, + replBridgeReconnecting: false, + replBridgeError: undefined, + } + }) + // Send system/init so remote clients (web/iOS/Android) get + // session metadata. REPL uses query() directly — never hits + // QueryEngine's SDKMessage layer — so this is the only path + // to put system/init on the REPL-bridge wire. Skills load is + // async (memoized, cheap after REPL startup); fire-and-forget + // so the connected-state transition isn't blocked. + if ( + getFeatureValue_CACHED_MAY_BE_STALE( + 'tengu_bridge_system_init', + false, + ) + ) { + void (async () => { + try { + const skills = await getSlashCommandToolSkills(getCwd()) + if (cancelled) return + const state = store.getState() + handleRef.current?.writeSdkMessages([ + buildSystemInitMessage({ // tools/mcpClients/plugins redacted for REPL-bridge: // MCP-prefixed tool names and server names leak which // integrations the user has wired up; plugin paths leak @@ -308,112 +371,119 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S tools: [], mcpClients: [], model: mainLoopModelRef.current, - permissionMode: state_0.toolPermissionContext.mode as PermissionMode, - // TODO: avoid the cast + permissionMode: state.toolPermissionContext + .mode as PermissionMode, // TODO: avoid the cast // Remote clients can only invoke bridge-safe commands — // advertising unsafe ones (local-jsx, unallowed local) // would let mobile/web attempt them and hit errors. - commands: commandsRef.current.filter(isBridgeSafeCommand), - agents: state_0.agentDefinitions.activeAgents, + commands: + commandsRef.current.filter(isBridgeSafeCommand), + agents: state.agentDefinitions.activeAgents, skills, plugins: [], - fastMode: state_0.fastMode - })]); - } catch (err_0) { - logForDebugging(`[bridge:repl] Failed to send system/init: ${errorMessage(err_0)}`, { - level: 'error' - }); - } - })(); - } - break; + fastMode: state.fastMode, + }), + ]) + } catch (err) { + logForDebugging( + `[bridge:repl] Failed to send system/init: ${errorMessage(err)}`, + { level: 'error' }, + ) + } + })() } + break + } case 'reconnecting': - setAppState(prev_7 => { - if (prev_7.replBridgeReconnecting) return prev_7; + setAppState(prev => { + if (prev.replBridgeReconnecting) return prev return { - ...prev_7, + ...prev, replBridgeReconnecting: true, - replBridgeSessionActive: false - }; - }); - break; + replBridgeSessionActive: false, + } + }) + break case 'failed': // Clear any previous failure dismiss timer - clearTimeout(failureTimeoutRef.current); - notifyBridgeFailed(detail_0); - setAppState(prev_5 => ({ - ...prev_5, - replBridgeError: detail_0, + clearTimeout(failureTimeoutRef.current) + notifyBridgeFailed(detail) + setAppState(prev => ({ + ...prev, + replBridgeError: detail, replBridgeReconnecting: false, replBridgeSessionActive: false, - replBridgeConnected: false - })); + replBridgeConnected: false, + })) // Auto-disable after timeout so the hook stops retrying. failureTimeoutRef.current = setTimeout(() => { - if (cancelled) return; - failureTimeoutRef.current = undefined; - setAppState(prev_6 => { - if (!prev_6.replBridgeError) return prev_6; + if (cancelled) return + failureTimeoutRef.current = undefined + setAppState(prev => { + if (!prev.replBridgeError) return prev return { - ...prev_6, + ...prev, replBridgeEnabled: false, - replBridgeError: undefined - }; - }); - }, BRIDGE_FAILURE_DISMISS_MS); - break; + replBridgeError: undefined, + } + }) + }, BRIDGE_FAILURE_DISMISS_MS) + break } } // Map of pending bridge permission response handlers, keyed by request_id. // Each entry is an onResponse handler waiting for CCR to reply. - const pendingPermissionHandlers = new Map void>(); + const pendingPermissionHandlers = new Map< + string, + (response: BridgePermissionResponse) => void + >() // Dispatch incoming control_response messages to registered handlers - function handlePermissionResponse(msg_0: SDKControlResponse): void { - const requestId = (msg_0 as any).response?.request_id; - if (!requestId) return; - const handler = pendingPermissionHandlers.get(requestId); + function handlePermissionResponse(msg: SDKControlResponse): void { + const requestId = msg.response?.request_id + if (!requestId) return + const handler = pendingPermissionHandlers.get(requestId) if (!handler) { - logForDebugging(`[bridge:repl] No handler for control_response request_id=${requestId}`); - return; + logForDebugging( + `[bridge:repl] No handler for control_response request_id=${requestId}`, + ) + return } - pendingPermissionHandlers.delete(requestId); + pendingPermissionHandlers.delete(requestId) // Extract the permission decision from the control_response payload - const inner = (msg_0 as any).response; - if (inner.subtype === 'success' && inner.response && isBridgePermissionResponse(inner.response)) { - handler(inner.response); + const inner = msg.response + if ( + inner.subtype === 'success' && + inner.response && + isBridgePermissionResponse(inner.response) + ) { + handler(inner.response) } } - const handle_0 = await initReplBridge({ + + const handle = await initReplBridge({ outboundOnly, tags: outboundOnly ? ['ccr-mirror'] : undefined, onInboundMessage: handleInboundMessage, onPermissionResponse: handlePermissionResponse, onInterrupt() { - abortControllerRef.current?.abort(); + abortControllerRef.current?.abort() }, onSetModel(model) { - const resolved = model === 'default' ? null : model ?? null; - setMainLoopModelOverride(resolved); - setAppState(prev_10 => { - if (prev_10.mainLoopModelForSession === resolved) return prev_10; - return { - ...prev_10, - mainLoopModelForSession: resolved - }; - }); + const resolved = model === 'default' ? null : (model ?? null) + setMainLoopModelOverride(resolved) + setAppState(prev => { + if (prev.mainLoopModelForSession === resolved) return prev + return { ...prev, mainLoopModelForSession: resolved } + }) }, onSetMaxThinkingTokens(maxTokens) { - const enabled = maxTokens !== null; - setAppState(prev_11 => { - if (prev_11.thinkingEnabled === enabled) return prev_11; - return { - ...prev_11, - thinkingEnabled: enabled - }; - }); + const enabled = maxTokens !== null + setAppState(prev => { + if (prev.thinkingEnabled === enabled) return prev + return { ...prev, thinkingEnabled: enabled } + }) }, onSetPermissionMode(mode) { // Policy guards MUST fire before transitionPermissionMode — @@ -430,190 +500,240 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S if (isBypassPermissionsModeDisabled()) { return { ok: false, - error: 'Cannot set permission mode to bypassPermissions because it is disabled by settings or configuration' - }; + error: + 'Cannot set permission mode to bypassPermissions because it is disabled by settings or configuration', + } } - if (!store.getState().toolPermissionContext.isBypassPermissionsModeAvailable) { + if ( + !store.getState().toolPermissionContext + .isBypassPermissionsModeAvailable + ) { return { ok: false, - error: 'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions' - }; + error: + 'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions', + } } } - if (feature('TRANSCRIPT_CLASSIFIER') && mode === 'auto' && !isAutoModeGateEnabled()) { - const reason = getAutoModeUnavailableReason(); + if ( + feature('TRANSCRIPT_CLASSIFIER') && + mode === 'auto' && + !isAutoModeGateEnabled() + ) { + const reason = getAutoModeUnavailableReason() return { ok: false, - error: reason ? `Cannot set permission mode to auto: ${getAutoModeUnavailableNotification(reason)}` : 'Cannot set permission mode to auto' - }; + error: reason + ? `Cannot set permission mode to auto: ${getAutoModeUnavailableNotification(reason)}` + : 'Cannot set permission mode to auto', + } } // Guards passed — apply via the centralized transition so // prePlanMode stashing and auto-mode state sync all fire. - setAppState(prev_12 => { - const current = prev_12.toolPermissionContext.mode; - if (current === mode) return prev_12; - const next = transitionPermissionMode(current, mode, prev_12.toolPermissionContext); + setAppState(prev => { + const current = prev.toolPermissionContext.mode + if (current === mode) return prev + const next = transitionPermissionMode( + current, + mode, + prev.toolPermissionContext, + ) return { - ...prev_12, - toolPermissionContext: { - ...next, - mode - } - }; - }); + ...prev, + toolPermissionContext: { ...next, mode }, + } + }) // Recheck queued permission prompts now that mode changed. setImmediate(() => { getLeaderToolUseConfirmQueue()?.(currentQueue => { currentQueue.forEach(item => { - void item.recheckPermission(); - }); - return currentQueue; - }); - }); - return { - ok: true - }; + void item.recheckPermission() + }) + return currentQueue + }) + }) + return { ok: true } }, onStateChange: handleStateChange, initialMessages: messages.length > 0 ? messages : undefined, getMessages: () => messagesRef.current, previouslyFlushedUUIDs: flushedUUIDsRef.current, initialName: replBridgeInitialName, - perpetual - }); + perpetual, + }) if (cancelled) { // Effect was cancelled while initReplBridge was in flight. // Tear down the handle to avoid leaking resources (poll loop, // WebSocket, registered environment, cleanup callback). - logForDebugging(`[bridge:repl] Hook: init cancelled during flight, tearing down${handle_0 ? ` env=${handle_0.environmentId}` : ''}`); - if (handle_0) { - void handle_0.teardown(); + logForDebugging( + `[bridge:repl] Hook: init cancelled during flight, tearing down${handle ? ` env=${handle.environmentId}` : ''}`, + ) + if (handle) { + void handle.teardown() } - return; + return } - if (!handle_0) { + if (!handle) { // initReplBridge returned null — a precondition failed. For most // cases (no_oauth, policy_denied, etc.) onStateChange('failed') // already fired with a specific hint. The GrowthBook-gate-off case // is intentionally silent — not a failure, just not rolled out. - consecutiveFailuresRef.current++; - logForDebugging(`[bridge:repl] Init returned null (precondition or session creation failed); consecutive failures: ${consecutiveFailuresRef.current}`); - clearTimeout(failureTimeoutRef.current); - setAppState(prev_13 => ({ - ...prev_13, - replBridgeError: prev_13.replBridgeError ?? 'check debug logs for details' - })); + consecutiveFailuresRef.current++ + logForDebugging( + `[bridge:repl] Init returned null (precondition or session creation failed); consecutive failures: ${consecutiveFailuresRef.current}`, + ) + clearTimeout(failureTimeoutRef.current) + setAppState(prev => ({ + ...prev, + replBridgeError: + prev.replBridgeError ?? 'check debug logs for details', + })) failureTimeoutRef.current = setTimeout(() => { - if (cancelled) return; - failureTimeoutRef.current = undefined; - setAppState(prev_14 => { - if (!prev_14.replBridgeError) return prev_14; + if (cancelled) return + failureTimeoutRef.current = undefined + setAppState(prev => { + if (!prev.replBridgeError) return prev return { - ...prev_14, + ...prev, replBridgeEnabled: false, - replBridgeError: undefined - }; - }); - }, BRIDGE_FAILURE_DISMISS_MS); - return; + replBridgeError: undefined, + } + }) + }, BRIDGE_FAILURE_DISMISS_MS) + return } - handleRef.current = handle_0; - setReplBridgeHandle(handle_0); - consecutiveFailuresRef.current = 0; + handleRef.current = handle + setReplBridgeHandle(handle) + consecutiveFailuresRef.current = 0 // Skip initial messages in the forwarding effect — they were // already loaded as session events during creation. - lastWrittenIndexRef.current = initialMessageCount; + lastWrittenIndexRef.current = initialMessageCount + if (outboundOnly) { - setAppState(prev_15 => { - if (prev_15.replBridgeConnected && prev_15.replBridgeSessionId === handle_0.bridgeSessionId) return prev_15; + setAppState(prev => { + if ( + prev.replBridgeConnected && + prev.replBridgeSessionId === handle.bridgeSessionId + ) + return prev return { - ...prev_15, + ...prev, replBridgeConnected: true, - replBridgeSessionId: handle_0.bridgeSessionId, + replBridgeSessionId: handle.bridgeSessionId, replBridgeSessionUrl: undefined, replBridgeConnectUrl: undefined, - replBridgeError: undefined - }; - }); - logForDebugging(`[bridge:repl] Mirror initialized, session=${handle_0.bridgeSessionId}`); + replBridgeError: undefined, + } + }) + logForDebugging( + `[bridge:repl] Mirror initialized, session=${handle.bridgeSessionId}`, + ) } else { // Build bridge permission callbacks so the interactive permission // handler can race bridge responses against local user interaction. const permissionCallbacks: BridgePermissionCallbacks = { - sendRequest(requestId_0, toolName, input, toolUseId, description, permissionSuggestions, blockedPath) { - handle_0.sendControlRequest({ + sendRequest( + requestId, + toolName, + input, + toolUseId, + description, + permissionSuggestions, + blockedPath, + ) { + handle.sendControlRequest({ type: 'control_request', - request_id: requestId_0, + request_id: requestId, request: { subtype: 'can_use_tool', tool_name: toolName, input, tool_use_id: toolUseId, description, - ...(permissionSuggestions ? { - permission_suggestions: permissionSuggestions - } : {}), - ...(blockedPath ? { - blocked_path: blockedPath - } : {}) - } - }); + ...(permissionSuggestions + ? { permission_suggestions: permissionSuggestions } + : {}), + ...(blockedPath ? { blocked_path: blockedPath } : {}), + }, + }) }, - sendResponse(requestId_1, response) { - const payload: Record = { - ...response - }; - handle_0.sendControlResponse({ + sendResponse(requestId, response) { + const payload: Record = { ...response } + handle.sendControlResponse({ type: 'control_response', response: { subtype: 'success', - request_id: requestId_1, - response: payload - } - }); + request_id: requestId, + response: payload, + }, + }) }, - cancelRequest(requestId_2) { - handle_0.sendControlCancelRequest(requestId_2); + cancelRequest(requestId) { + handle.sendControlCancelRequest(requestId) }, - onResponse(requestId_3, handler_0) { - pendingPermissionHandlers.set(requestId_3, handler_0); + onResponse(requestId, handler) { + pendingPermissionHandlers.set(requestId, handler) return () => { - pendingPermissionHandlers.delete(requestId_3); - }; - } - }; - setAppState(prev_16 => ({ - ...prev_16, - replBridgePermissionCallbacks: permissionCallbacks - })); - const url = getRemoteSessionUrl(handle_0.bridgeSessionId, handle_0.sessionIngressUrl); + pendingPermissionHandlers.delete(requestId) + } + }, + } + setAppState(prev => ({ + ...prev, + replBridgePermissionCallbacks: permissionCallbacks, + })) + const url = getRemoteSessionUrl( + handle.bridgeSessionId, + handle.sessionIngressUrl, + ) // environmentId === '' signals the v2 env-less path. buildBridgeConnectUrl // builds an env-specific connect URL, which doesn't exist without an env. - const hasEnv = handle_0.environmentId !== ''; - const connectUrl_0 = hasEnv ? buildBridgeConnectUrl(handle_0.environmentId, handle_0.sessionIngressUrl) : undefined; - setAppState(prev_17 => { - if (prev_17.replBridgeConnected && prev_17.replBridgeSessionUrl === url) { - return prev_17; + const hasEnv = handle.environmentId !== '' + const connectUrl = hasEnv + ? buildBridgeConnectUrl( + handle.environmentId, + handle.sessionIngressUrl, + ) + : undefined + setAppState(prev => { + if ( + prev.replBridgeConnected && + prev.replBridgeSessionUrl === url + ) { + return prev } return { - ...prev_17, + ...prev, replBridgeConnected: true, replBridgeSessionUrl: url, - replBridgeConnectUrl: connectUrl_0 ?? prev_17.replBridgeConnectUrl, - replBridgeEnvironmentId: handle_0.environmentId, - replBridgeSessionId: handle_0.bridgeSessionId, - replBridgeError: undefined - }; - }); + replBridgeConnectUrl: connectUrl ?? prev.replBridgeConnectUrl, + replBridgeEnvironmentId: handle.environmentId, + replBridgeSessionId: handle.bridgeSessionId, + replBridgeError: undefined, + } + }) // Show bridge status with URL in the transcript. perpetual (KAIROS // assistant mode) falls back to v1 at initReplBridge.ts — skip the // v2-only upgrade nudge for them. Own try/catch so a cosmetic // GrowthBook hiccup doesn't hit the outer init-failure handler. - const upgradeNudge = !perpetual ? await shouldShowAppUpgradeMessage().catch(() => false) : false; - if (cancelled) return; - setMessages(prev_18 => [...prev_18, createBridgeStatusMessage(url, upgradeNudge ? 'Please upgrade to the latest version of the Claude mobile app to see your Remote Control sessions.' : undefined)]); - logForDebugging(`[bridge:repl] Hook initialized, session=${handle_0.bridgeSessionId}`); + const upgradeNudge = !perpetual + ? await shouldShowAppUpgradeMessage().catch(() => false) + : false + if (cancelled) return + setMessages(prev => [ + ...prev, + createBridgeStatusMessage( + url, + upgradeNudge + ? 'Please upgrade to the latest version of the Claude mobile app to see your Remote Control sessions.' + : undefined, + ), + ]) + + logForDebugging( + `[bridge:repl] Hook initialized, session=${handle.bridgeSessionId}`, + ) } } catch (err) { // Never crash the REPL — surface the error in the UI. @@ -622,49 +742,64 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S // error), don't count that toward the fuse or spam a stale error // into the UI. Also fixes pre-existing spurious setAppState/ // setMessages on cancelled throws. - if (cancelled) return; - consecutiveFailuresRef.current++; - const errMsg = errorMessage(err); - logForDebugging(`[bridge:repl] Init failed: ${errMsg}; consecutive failures: ${consecutiveFailuresRef.current}`); - clearTimeout(failureTimeoutRef.current); - notifyBridgeFailed(errMsg); - setAppState(prev_0 => ({ - ...prev_0, - replBridgeError: errMsg - })); + if (cancelled) return + consecutiveFailuresRef.current++ + const errMsg = errorMessage(err) + logForDebugging( + `[bridge:repl] Init failed: ${errMsg}; consecutive failures: ${consecutiveFailuresRef.current}`, + ) + clearTimeout(failureTimeoutRef.current) + notifyBridgeFailed(errMsg) + setAppState(prev => ({ + ...prev, + replBridgeError: errMsg, + })) failureTimeoutRef.current = setTimeout(() => { - if (cancelled) return; - failureTimeoutRef.current = undefined; - setAppState(prev_1 => { - if (!prev_1.replBridgeError) return prev_1; + if (cancelled) return + failureTimeoutRef.current = undefined + setAppState(prev => { + if (!prev.replBridgeError) return prev return { - ...prev_1, + ...prev, replBridgeEnabled: false, - replBridgeError: undefined - }; - }); - }, BRIDGE_FAILURE_DISMISS_MS); + replBridgeError: undefined, + } + }) + }, BRIDGE_FAILURE_DISMISS_MS) if (!outboundOnly) { - setMessages(prev_2 => [...prev_2, createSystemMessage(`Remote Control failed to connect: ${errMsg}`, 'warning')]); + setMessages(prev => [ + ...prev, + createSystemMessage( + `Remote Control failed to connect: ${errMsg}`, + 'warning', + ), + ]) } } - })(); + })() + return () => { - cancelled = true; - clearTimeout(failureTimeoutRef.current); - failureTimeoutRef.current = undefined; + cancelled = true + clearTimeout(failureTimeoutRef.current) + failureTimeoutRef.current = undefined if (handleRef.current) { - logForDebugging(`[bridge:repl] Hook cleanup: starting teardown for env=${handleRef.current.environmentId} session=${handleRef.current.bridgeSessionId}`); - teardownPromiseRef.current = handleRef.current.teardown(); - handleRef.current = null; - setReplBridgeHandle(null); + logForDebugging( + `[bridge:repl] Hook cleanup: starting teardown for env=${handleRef.current.environmentId} session=${handleRef.current.bridgeSessionId}`, + ) + teardownPromiseRef.current = handleRef.current.teardown() + handleRef.current = null + setReplBridgeHandle(null) } - setAppState(prev_19 => { - if (!prev_19.replBridgeConnected && !prev_19.replBridgeSessionActive && !prev_19.replBridgeError) { - return prev_19; + setAppState(prev => { + if ( + !prev.replBridgeConnected && + !prev.replBridgeSessionActive && + !prev.replBridgeError + ) { + return prev } return { - ...prev_19, + ...prev, replBridgeConnected: false, replBridgeSessionActive: false, replBridgeReconnecting: false, @@ -673,13 +808,19 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S replBridgeEnvironmentId: undefined, replBridgeSessionId: undefined, replBridgeError: undefined, - replBridgePermissionCallbacks: undefined - }; - }); - lastWrittenIndexRef.current = 0; - }; + replBridgePermissionCallbacks: undefined, + } + }) + lastWrittenIndexRef.current = 0 + } } - }, [replBridgeEnabled, replBridgeOutboundOnly, setAppState, setMessages, addNotification]); + }, [ + replBridgeEnabled, + replBridgeOutboundOnly, + setAppState, + setMessages, + addNotification, + ]) // Write new messages as they appear. // Also re-runs when replBridgeConnected changes (bridge finishes init), @@ -687,38 +828,47 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S useEffect(() => { // Positive feature() guard — see first useEffect comment if (feature('BRIDGE_MODE')) { - if (!replBridgeConnected) return; - const handle_1 = handleRef.current; - if (!handle_1) return; + if (!replBridgeConnected) return + + const handle = handleRef.current + if (!handle) return // Clamp the index in case messages were compacted (array shortened). // After compaction the ref could exceed messages.length, and without // clamping no new messages would be forwarded. if (lastWrittenIndexRef.current > messages.length) { - logForDebugging(`[bridge:repl] Compaction detected: lastWrittenIndex=${lastWrittenIndexRef.current} > messages.length=${messages.length}, clamping`); + logForDebugging( + `[bridge:repl] Compaction detected: lastWrittenIndex=${lastWrittenIndexRef.current} > messages.length=${messages.length}, clamping`, + ) } - const startIndex = Math.min(lastWrittenIndexRef.current, messages.length); + const startIndex = Math.min(lastWrittenIndexRef.current, messages.length) // Collect new messages since last write - const newMessages: Message[] = []; + const newMessages: Message[] = [] for (let i = startIndex; i < messages.length; i++) { - const msg_1 = messages[i]; - if (msg_1 && (msg_1.type === 'user' || msg_1.type === 'assistant' || msg_1.type === 'system' && msg_1.subtype === 'local_command')) { - newMessages.push(msg_1); + const msg = messages[i] + if ( + msg && + (msg.type === 'user' || + msg.type === 'assistant' || + (msg.type === 'system' && msg.subtype === 'local_command')) + ) { + newMessages.push(msg) } } - lastWrittenIndexRef.current = messages.length; + lastWrittenIndexRef.current = messages.length + if (newMessages.length > 0) { - handle_1.writeMessages(newMessages); + handle.writeMessages(newMessages) } } - }, [messages, replBridgeConnected]); + }, [messages, replBridgeConnected]) + const sendBridgeResult = useCallback(() => { if (feature('BRIDGE_MODE')) { - handleRef.current?.sendResult(); + handleRef.current?.sendResult() } - }, []); - return { - sendBridgeResult - }; + }, []) + + return { sendBridgeResult } } diff --git a/src/hooks/useTeleportResume.tsx b/src/hooks/useTeleportResume.tsx index 24265fbc8..bc0e1fb0e 100644 --- a/src/hooks/useTeleportResume.tsx +++ b/src/hooks/useTeleportResume.tsx @@ -1,84 +1,78 @@ -import { c as _c } from "react/compiler-runtime"; -import { useCallback, useState } from 'react'; -import { setTeleportedSessionInfo } from 'src/bootstrap/state.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import type { TeleportRemoteResponse } from 'src/utils/conversationRecovery.js'; -import type { CodeSession } from 'src/utils/teleport/api.js'; -import { errorMessage, TeleportOperationError } from '../utils/errors.js'; -import { teleportResumeCodeSession } from '../utils/teleport.js'; +import { useCallback, useState } from 'react' +import { setTeleportedSessionInfo } from 'src/bootstrap/state.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import type { TeleportRemoteResponse } from 'src/utils/conversationRecovery.js' +import type { CodeSession } from 'src/utils/teleport/api.js' +import { errorMessage, TeleportOperationError } from '../utils/errors.js' +import { teleportResumeCodeSession } from '../utils/teleport.js' + export type TeleportResumeError = { - message: string; - formattedMessage?: string; - isOperationError: boolean; -}; -export type TeleportSource = 'cliArg' | 'localCommand'; -export function useTeleportResume(source) { - const $ = _c(8); - const [isResuming, setIsResuming] = useState(false); - const [error, setError] = useState(null); - const [selectedSession, setSelectedSession] = useState(null); - let t0; - if ($[0] !== source) { - t0 = async session => { - setIsResuming(true); - setError(null); - setSelectedSession(session); - logEvent("tengu_teleport_resume_session", { - source: source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - session_id: session.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - ; - try { - const result = await teleportResumeCodeSession(session.id); - setTeleportedSessionInfo({ - sessionId: session.id - }); - setIsResuming(false); - return result; - } catch (t1) { - const err = t1; - const teleportError = { - message: err instanceof TeleportOperationError ? err.message : errorMessage(err), - formattedMessage: err instanceof TeleportOperationError ? err.formattedMessage : undefined, - isOperationError: err instanceof TeleportOperationError - }; - setError(teleportError); - setIsResuming(false); - return null; - } - }; - $[0] = source; - $[1] = t0; - } else { - t0 = $[1]; - } - const resumeSession = t0; - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => { - setError(null); - }; - $[2] = t1; - } else { - t1 = $[2]; - } - const clearError = t1; - let t2; - if ($[3] !== error || $[4] !== isResuming || $[5] !== resumeSession || $[6] !== selectedSession) { - t2 = { - resumeSession, - isResuming, - error, - selectedSession, - clearError - }; - $[3] = error; - $[4] = isResuming; - $[5] = resumeSession; - $[6] = selectedSession; - $[7] = t2; - } else { - t2 = $[7]; - } - return t2; + message: string + formattedMessage?: string + isOperationError: boolean +} + +export type TeleportSource = 'cliArg' | 'localCommand' + +export function useTeleportResume(source: TeleportSource) { + const [isResuming, setIsResuming] = useState(false) + const [error, setError] = useState(null) + const [selectedSession, setSelectedSession] = useState( + null, + ) + + const resumeSession = useCallback( + async (session: CodeSession): Promise => { + setIsResuming(true) + setError(null) + setSelectedSession(session) + + // Log teleport session selection + logEvent('tengu_teleport_resume_session', { + source: + source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + session_id: + session.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + try { + const result = await teleportResumeCodeSession(session.id) + // Track teleported session for reliability logging + setTeleportedSessionInfo({ sessionId: session.id }) + setIsResuming(false) + return result + } catch (err) { + const teleportError: TeleportResumeError = { + message: + err instanceof TeleportOperationError + ? err.message + : errorMessage(err), + formattedMessage: + err instanceof TeleportOperationError + ? err.formattedMessage + : undefined, + isOperationError: err instanceof TeleportOperationError, + } + setError(teleportError) + setIsResuming(false) + return null + } + }, + [source], + ) + + const clearError = useCallback(() => { + setError(null) + }, []) + + return { + resumeSession, + isResuming, + error, + selectedSession, + clearError, + } } diff --git a/src/hooks/useTypeahead.tsx b/src/hooks/useTypeahead.tsx index 13333171e..3e2dbd220 100644 --- a/src/hooks/useTypeahead.tsx +++ b/src/hooks/useTypeahead.tsx @@ -1,119 +1,178 @@ -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useNotifications } from 'src/context/notifications.js'; -import { Text } from 'src/ink.js'; -import { logEvent } from 'src/services/analytics/index.js'; -import { useDebounceCallback } from 'usehooks-ts'; -import { type Command, getCommandName } from '../commands.js'; -import { getModeFromInput, getValueFromInput } from '../components/PromptInput/inputModes.js'; -import type { SuggestionItem, SuggestionType } from '../components/PromptInput/PromptInputFooterSuggestions.js'; -import { useIsModalOverlayActive, useRegisterOverlay } from '../context/overlayContext.js'; -import { KeyboardEvent } from '../ink/events/keyboard-event.js'; +import * as React from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useNotifications } from 'src/context/notifications.js' +import { Text } from 'src/ink.js' +import { logEvent } from 'src/services/analytics/index.js' +import { useDebounceCallback } from 'usehooks-ts' +import { type Command, getCommandName } from '../commands.js' +import { + getModeFromInput, + getValueFromInput, +} from '../components/PromptInput/inputModes.js' +import type { + SuggestionItem, + SuggestionType, +} from '../components/PromptInput/PromptInputFooterSuggestions.js' +import { + useIsModalOverlayActive, + useRegisterOverlay, +} from '../context/overlayContext.js' +import { KeyboardEvent } from '../ink/events/keyboard-event.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- backward-compat bridge until consumers wire handleKeyDown to -import { useInput } from '../ink.js'; -import { useOptionalKeybindingContext, useRegisterKeybindingContext } from '../keybindings/KeybindingContext.js'; -import { useKeybindings } from '../keybindings/useKeybinding.js'; -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; -import { useAppState, useAppStateStore } from '../state/AppState.js'; -import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'; -import type { InlineGhostText, PromptInputMode } from '../types/textInputTypes.js'; -import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'; -import { generateProgressiveArgumentHint, parseArguments } from '../utils/argumentSubstitution.js'; -import { getShellCompletions, type ShellCompletionType } from '../utils/bash/shellCompletion.js'; -import { formatLogMetadata } from '../utils/format.js'; -import { getSessionIdFromLog, searchSessionsByCustomTitle } from '../utils/sessionStorage.js'; -import { applyCommandSuggestion, findMidInputSlashCommand, generateCommandSuggestions, getBestCommandMatch, isCommandInput } from '../utils/suggestions/commandSuggestions.js'; -import { getDirectoryCompletions, getPathCompletions, isPathLikeToken } from '../utils/suggestions/directoryCompletion.js'; -import { getShellHistoryCompletion } from '../utils/suggestions/shellHistoryCompletion.js'; -import { getSlackChannelSuggestions, hasSlackMcpServer } from '../utils/suggestions/slackChannelSuggestions.js'; -import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js'; -import { applyFileSuggestion, findLongestCommonPrefix, onIndexBuildComplete, startBackgroundCacheRefresh } from './fileSuggestions.js'; -import { generateUnifiedSuggestions } from './unifiedSuggestions.js'; +import { useInput } from '../ink.js' +import { + useOptionalKeybindingContext, + useRegisterKeybindingContext, +} from '../keybindings/KeybindingContext.js' +import { useKeybindings } from '../keybindings/useKeybinding.js' +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' +import { useAppState, useAppStateStore } from '../state/AppState.js' +import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' +import type { + InlineGhostText, + PromptInputMode, +} from '../types/textInputTypes.js' +import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js' +import { + generateProgressiveArgumentHint, + parseArguments, +} from '../utils/argumentSubstitution.js' +import { + getShellCompletions, + type ShellCompletionType, +} from '../utils/bash/shellCompletion.js' +import { formatLogMetadata } from '../utils/format.js' +import { + getSessionIdFromLog, + searchSessionsByCustomTitle, +} from '../utils/sessionStorage.js' +import { + applyCommandSuggestion, + findMidInputSlashCommand, + generateCommandSuggestions, + getBestCommandMatch, + isCommandInput, +} from '../utils/suggestions/commandSuggestions.js' +import { + getDirectoryCompletions, + getPathCompletions, + isPathLikeToken, +} from '../utils/suggestions/directoryCompletion.js' +import { getShellHistoryCompletion } from '../utils/suggestions/shellHistoryCompletion.js' +import { + getSlackChannelSuggestions, + hasSlackMcpServer, +} from '../utils/suggestions/slackChannelSuggestions.js' +import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js' +import { + applyFileSuggestion, + findLongestCommonPrefix, + onIndexBuildComplete, + startBackgroundCacheRefresh, +} from './fileSuggestions.js' +import { generateUnifiedSuggestions } from './unifiedSuggestions.js' // Unicode-aware character class for file path tokens: // \p{L} = letters (CJK, Latin, Cyrillic, etc.) // \p{N} = numbers (incl. fullwidth) // \p{M} = combining marks (macOS NFD accents, Devanagari vowel signs) -const AT_TOKEN_HEAD_RE = /^@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*/u; -const PATH_CHAR_HEAD_RE = /^[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+/u; -const TOKEN_WITH_AT_RE = /(@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+)$/u; -const TOKEN_WITHOUT_AT_RE = /[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+$/u; -const HAS_AT_SYMBOL_RE = /(^|\s)@([\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|"[^"]*"?)$/u; -const HASH_CHANNEL_RE = /(^|\s)#([a-z0-9][a-z0-9_-]*)$/; +const AT_TOKEN_HEAD_RE = /^@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*/u +const PATH_CHAR_HEAD_RE = /^[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+/u +const TOKEN_WITH_AT_RE = + /(@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+)$/u +const TOKEN_WITHOUT_AT_RE = /[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+$/u +const HAS_AT_SYMBOL_RE = /(^|\s)@([\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|"[^"]*"?)$/u +const HASH_CHANNEL_RE = /(^|\s)#([a-z0-9][a-z0-9_-]*)$/ // Type guard for path completion metadata -function isPathMetadata(metadata: unknown): metadata is { - type: 'directory' | 'file'; -} { - return typeof metadata === 'object' && metadata !== null && 'type' in metadata && (metadata.type === 'directory' || metadata.type === 'file'); +function isPathMetadata( + metadata: unknown, +): metadata is { type: 'directory' | 'file' } { + return ( + typeof metadata === 'object' && + metadata !== null && + 'type' in metadata && + (metadata.type === 'directory' || metadata.type === 'file') + ) } // Helper to determine selectedSuggestion when updating suggestions -function getPreservedSelection(prevSuggestions: SuggestionItem[], prevSelection: number, newSuggestions: SuggestionItem[]): number { +function getPreservedSelection( + prevSuggestions: SuggestionItem[], + prevSelection: number, + newSuggestions: SuggestionItem[], +): number { // No new suggestions if (newSuggestions.length === 0) { - return -1; + return -1 } // No previous selection if (prevSelection < 0) { - return 0; + return 0 } // Get the previously selected item - const prevSelectedItem = prevSuggestions[prevSelection]; + const prevSelectedItem = prevSuggestions[prevSelection] if (!prevSelectedItem) { - return 0; + return 0 } // Try to find the same item in the new list by ID - const newIndex = newSuggestions.findIndex(item => item.id === prevSelectedItem.id); + const newIndex = newSuggestions.findIndex( + item => item.id === prevSelectedItem.id, + ) // Return the new index if found, otherwise default to 0 - return newIndex >= 0 ? newIndex : 0; + return newIndex >= 0 ? newIndex : 0 } + function buildResumeInputFromSuggestion(suggestion: SuggestionItem): string { - const metadata = suggestion.metadata as { - sessionId: string; - } | undefined; - return metadata?.sessionId ? `/resume ${metadata.sessionId}` : `/resume ${suggestion.displayText}`; + const metadata = suggestion.metadata as { sessionId: string } | undefined + return metadata?.sessionId + ? `/resume ${metadata.sessionId}` + : `/resume ${suggestion.displayText}` } + type Props = { - onInputChange: (value: string) => void; - onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void; - setCursorOffset: (offset: number) => void; - input: string; - cursorOffset: number; - commands: Command[]; - mode: string; - agents: AgentDefinition[]; - setSuggestionsState: (f: (previousSuggestionsState: { - suggestions: SuggestionItem[]; - selectedSuggestion: number; - commandArgumentHint?: string; - }) => { - suggestions: SuggestionItem[]; - selectedSuggestion: number; - commandArgumentHint?: string; - }) => void; + onInputChange: (value: string) => void + onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void + setCursorOffset: (offset: number) => void + input: string + cursorOffset: number + commands: Command[] + mode: string + agents: AgentDefinition[] + setSuggestionsState: ( + f: (previousSuggestionsState: { + suggestions: SuggestionItem[] + selectedSuggestion: number + commandArgumentHint?: string + }) => { + suggestions: SuggestionItem[] + selectedSuggestion: number + commandArgumentHint?: string + }, + ) => void suggestionsState: { - suggestions: SuggestionItem[]; - selectedSuggestion: number; - commandArgumentHint?: string; - }; - suppressSuggestions?: boolean; - markAccepted: () => void; - onModeChange?: (mode: PromptInputMode) => void; -}; + suggestions: SuggestionItem[] + selectedSuggestion: number + commandArgumentHint?: string + } + suppressSuggestions?: boolean + markAccepted: () => void + onModeChange?: (mode: PromptInputMode) => void +} + type UseTypeaheadResult = { - suggestions: SuggestionItem[]; - selectedSuggestion: number; - suggestionType: SuggestionType; - maxColumnWidth?: number; - commandArgumentHint?: string; - inlineGhostText?: InlineGhostText; - handleKeyDown: (e: KeyboardEvent) => void; -}; + suggestions: SuggestionItem[] + selectedSuggestion: number + suggestionType: SuggestionType + maxColumnWidth?: number + commandArgumentHint?: string + inlineGhostText?: InlineGhostText + handleKeyDown: (e: KeyboardEvent) => void +} /** * Extract search token from a completion token by removing @ prefix and quotes @@ -121,16 +180,16 @@ type UseTypeaheadResult = { * @returns The search token with @ and quotes removed */ export function extractSearchToken(completionToken: { - token: string; - isQuoted?: boolean; + token: string + isQuoted?: boolean }): string { if (completionToken.isQuoted) { // Remove @" prefix and optional closing " - return completionToken.token.slice(2).replace(/"$/, ''); + return completionToken.token.slice(2).replace(/"$/, '') } else if (completionToken.token.startsWith('@')) { - return completionToken.token.substring(1); + return completionToken.token.substring(1) } else { - return completionToken.token; + return completionToken.token } } @@ -146,80 +205,109 @@ export function extractSearchToken(completionToken: { * @returns The formatted replacement value */ export function formatReplacementValue(options: { - displayText: string; - mode: string; - hasAtPrefix: boolean; - needsQuotes: boolean; - isQuoted?: boolean; - isComplete: boolean; + displayText: string + mode: string + hasAtPrefix: boolean + needsQuotes: boolean + isQuoted?: boolean + isComplete: boolean }): string { - const { - displayText, - mode, - hasAtPrefix, - needsQuotes, - isQuoted, - isComplete - } = options; - const space = isComplete ? ' ' : ''; + const { displayText, mode, hasAtPrefix, needsQuotes, isQuoted, isComplete } = + options + const space = isComplete ? ' ' : '' + if (isQuoted || needsQuotes) { // Use quoted format - return mode === 'bash' ? `"${displayText}"${space}` : `@"${displayText}"${space}`; + return mode === 'bash' + ? `"${displayText}"${space}` + : `@"${displayText}"${space}` } else if (hasAtPrefix) { - return mode === 'bash' ? `${displayText}${space}` : `@${displayText}${space}`; + return mode === 'bash' + ? `${displayText}${space}` + : `@${displayText}${space}` } else { - return displayText; + return displayText } } /** * Apply a shell completion suggestion by replacing the current word */ -export function applyShellSuggestion(suggestion: SuggestionItem, input: string, cursorOffset: number, onInputChange: (value: string) => void, setCursorOffset: (offset: number) => void, completionType: ShellCompletionType | undefined): void { - const beforeCursor = input.slice(0, cursorOffset); - const lastSpaceIndex = beforeCursor.lastIndexOf(' '); - const wordStart = lastSpaceIndex + 1; +export function applyShellSuggestion( + suggestion: SuggestionItem, + input: string, + cursorOffset: number, + onInputChange: (value: string) => void, + setCursorOffset: (offset: number) => void, + completionType: ShellCompletionType | undefined, +): void { + const beforeCursor = input.slice(0, cursorOffset) + const lastSpaceIndex = beforeCursor.lastIndexOf(' ') + const wordStart = lastSpaceIndex + 1 // Prepare the replacement text based on completion type - let replacementText: string; + let replacementText: string if (completionType === 'variable') { - replacementText = '$' + suggestion.displayText + ' '; + replacementText = '$' + suggestion.displayText + ' ' } else if (completionType === 'command') { - replacementText = suggestion.displayText + ' '; + replacementText = suggestion.displayText + ' ' } else { - replacementText = suggestion.displayText; + replacementText = suggestion.displayText } - const newInput = input.slice(0, wordStart) + replacementText + input.slice(cursorOffset); - onInputChange(newInput); - setCursorOffset(wordStart + replacementText.length); + + const newInput = + input.slice(0, wordStart) + replacementText + input.slice(cursorOffset) + + onInputChange(newInput) + setCursorOffset(wordStart + replacementText.length) } -const DM_MEMBER_RE = /(^|\s)@[\w-]*$/; -function applyTriggerSuggestion(suggestion: SuggestionItem, input: string, cursorOffset: number, triggerRe: RegExp, onInputChange: (value: string) => void, setCursorOffset: (offset: number) => void): void { - const m = input.slice(0, cursorOffset).match(triggerRe); - if (!m || m.index === undefined) return; - const prefixStart = m.index + (m[1]?.length ?? 0); - const before = input.slice(0, prefixStart); - const newInput = before + suggestion.displayText + ' ' + input.slice(cursorOffset); - onInputChange(newInput); - setCursorOffset(before.length + suggestion.displayText.length + 1); + +const DM_MEMBER_RE = /(^|\s)@[\w-]*$/ + +function applyTriggerSuggestion( + suggestion: SuggestionItem, + input: string, + cursorOffset: number, + triggerRe: RegExp, + onInputChange: (value: string) => void, + setCursorOffset: (offset: number) => void, +): void { + const m = input.slice(0, cursorOffset).match(triggerRe) + if (!m || m.index === undefined) return + const prefixStart = m.index + (m[1]?.length ?? 0) + const before = input.slice(0, prefixStart) + const newInput = + before + suggestion.displayText + ' ' + input.slice(cursorOffset) + onInputChange(newInput) + setCursorOffset(before.length + suggestion.displayText.length + 1) } -let currentShellCompletionAbortController: AbortController | null = null; + +let currentShellCompletionAbortController: AbortController | null = null /** * Generate bash shell completion suggestions */ -async function generateBashSuggestions(input: string, cursorOffset: number): Promise { +async function generateBashSuggestions( + input: string, + cursorOffset: number, +): Promise { try { if (currentShellCompletionAbortController) { - currentShellCompletionAbortController.abort(); + currentShellCompletionAbortController.abort() } - currentShellCompletionAbortController = new AbortController(); - const suggestions = await getShellCompletions(input, cursorOffset, currentShellCompletionAbortController.signal); - return suggestions; + + currentShellCompletionAbortController = new AbortController() + const suggestions = await getShellCompletions( + input, + cursorOffset, + currentShellCompletionAbortController.signal, + ) + + return suggestions } catch { // Silent failure - don't break UX - logEvent('tengu_shell_completion_failed', {}); - return []; + logEvent('tengu_shell_completion_failed', {}) + return [] } } @@ -234,21 +322,25 @@ async function generateBashSuggestions(input: string, cursorOffset: number): Pro * @param isDirectory Whether the suggestion is a directory (adds / suffix) or file (adds space) * @returns Object with the new input text and cursor position */ -export function applyDirectorySuggestion(input: string, suggestionId: string, tokenStartPos: number, tokenLength: number, isDirectory: boolean): { - newInput: string; - cursorPos: number; -} { - const suffix = isDirectory ? '/' : ' '; - const before = input.slice(0, tokenStartPos); - const after = input.slice(tokenStartPos + tokenLength); +export function applyDirectorySuggestion( + input: string, + suggestionId: string, + tokenStartPos: number, + tokenLength: number, + isDirectory: boolean, +): { newInput: string; cursorPos: number } { + const suffix = isDirectory ? '/' : ' ' + const before = input.slice(0, tokenStartPos) + const after = input.slice(tokenStartPos + tokenLength) // Always add @ prefix - if token already has it, we're replacing // the whole token (including @) with @suggestion.id - const replacement = '@' + suggestionId + suffix; - const newInput = before + replacement + after; + const replacement = '@' + suggestionId + suffix + const newInput = before + replacement + after + return { newInput, - cursorPos: before.length + replacement.length - }; + cursorPos: before.length + replacement.length, + } } /** @@ -258,93 +350,104 @@ export function applyDirectorySuggestion(input: string, suggestionId: string, to * @param includeAtSymbol Whether to consider @ symbol as part of the token * @returns The completable token and its start position, or null if not found */ -export function extractCompletionToken(text: string, cursorPos: number, includeAtSymbol = false): { - token: string; - startPos: number; - isQuoted?: boolean; -} | null { +export function extractCompletionToken( + text: string, + cursorPos: number, + includeAtSymbol = false, +): { token: string; startPos: number; isQuoted?: boolean } | null { // Empty input check - if (!text) return null; + if (!text) return null // Get text up to cursor - const textBeforeCursor = text.substring(0, cursorPos); + const textBeforeCursor = text.substring(0, cursorPos) // Check for quoted @ mention first (e.g., @"my file with spaces") if (includeAtSymbol) { - const quotedAtRegex = /@"([^"]*)"?$/; - const quotedMatch = textBeforeCursor.match(quotedAtRegex); + const quotedAtRegex = /@"([^"]*)"?$/ + const quotedMatch = textBeforeCursor.match(quotedAtRegex) if (quotedMatch && quotedMatch.index !== undefined) { // Include any remaining quoted content after cursor until closing quote or end - const textAfterCursor = text.substring(cursorPos); - const afterQuotedMatch = textAfterCursor.match(/^[^"]*"?/); - const quotedSuffix = afterQuotedMatch ? afterQuotedMatch[0] : ''; + const textAfterCursor = text.substring(cursorPos) + const afterQuotedMatch = textAfterCursor.match(/^[^"]*"?/) + const quotedSuffix = afterQuotedMatch ? afterQuotedMatch[0] : '' + return { token: quotedMatch[0] + quotedSuffix, startPos: quotedMatch.index, - isQuoted: true - }; + isQuoted: true, + } } } // Fast path for @ tokens: use lastIndexOf to avoid expensive $ anchor scan if (includeAtSymbol) { - const atIdx = textBeforeCursor.lastIndexOf('@'); - if (atIdx >= 0 && (atIdx === 0 || /\s/.test(textBeforeCursor[atIdx - 1]!))) { - const fromAt = textBeforeCursor.substring(atIdx); - const atHeadMatch = fromAt.match(AT_TOKEN_HEAD_RE); + const atIdx = textBeforeCursor.lastIndexOf('@') + if ( + atIdx >= 0 && + (atIdx === 0 || /\s/.test(textBeforeCursor[atIdx - 1]!)) + ) { + const fromAt = textBeforeCursor.substring(atIdx) + const atHeadMatch = fromAt.match(AT_TOKEN_HEAD_RE) if (atHeadMatch && atHeadMatch[0].length === fromAt.length) { - const textAfterCursor = text.substring(cursorPos); - const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE); - const tokenSuffix = afterMatch ? afterMatch[0] : ''; + const textAfterCursor = text.substring(cursorPos) + const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE) + const tokenSuffix = afterMatch ? afterMatch[0] : '' return { token: atHeadMatch[0] + tokenSuffix, startPos: atIdx, - isQuoted: false - }; + isQuoted: false, + } } } } // Non-@ token or cursor outside @ token — use $ anchor on (short) tail - const tokenRegex = includeAtSymbol ? TOKEN_WITH_AT_RE : TOKEN_WITHOUT_AT_RE; - const match = textBeforeCursor.match(tokenRegex); + const tokenRegex = includeAtSymbol ? TOKEN_WITH_AT_RE : TOKEN_WITHOUT_AT_RE + const match = textBeforeCursor.match(tokenRegex) if (!match || match.index === undefined) { - return null; + return null } // Check if cursor is in the MIDDLE of a token (more word characters after cursor) // If so, extend the token to include all characters until whitespace or end of string - const textAfterCursor = text.substring(cursorPos); - const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE); - const tokenSuffix = afterMatch ? afterMatch[0] : ''; + const textAfterCursor = text.substring(cursorPos) + const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE) + const tokenSuffix = afterMatch ? afterMatch[0] : '' + return { token: match[0] + tokenSuffix, startPos: match.index, - isQuoted: false - }; + isQuoted: false, + } } + function extractCommandNameAndArgs(value: string): { - commandName: string; - args: string; + commandName: string + args: string } | null { if (isCommandInput(value)) { - const spaceIndex = value.indexOf(' '); - if (spaceIndex === -1) return { - commandName: value.slice(1), - args: '' - }; + const spaceIndex = value.indexOf(' ') + if (spaceIndex === -1) + return { + commandName: value.slice(1), + args: '', + } return { commandName: value.slice(1, spaceIndex), - args: value.slice(spaceIndex + 1) - }; + args: value.slice(spaceIndex + 1), + } } - return null; + return null } -function hasCommandWithArguments(isAtEndWithWhitespace: boolean, value: string) { + +function hasCommandWithArguments( + isAtEndWithWhitespace: boolean, + value: string, +) { // If value.endsWith(' ') but the user is not at the end, then the user has // potentially gone back to the command in an effort to edit the command name // (but preserve the arguments). - return !isAtEndWithWhitespace && value.includes(' ') && !value.endsWith(' '); + return !isAtEndWithWhitespace && value.includes(' ') && !value.endsWith(' ') } /** @@ -360,122 +463,150 @@ export function useTypeahead({ mode, agents, setSuggestionsState, - suggestionsState: { - suggestions, - selectedSuggestion, - commandArgumentHint - }, + suggestionsState: { suggestions, selectedSuggestion, commandArgumentHint }, suppressSuggestions = false, markAccepted, - onModeChange + onModeChange, }: Props): UseTypeaheadResult { - const { - addNotification - } = useNotifications(); - const thinkingToggleShortcut = useShortcutDisplay('chat:thinkingToggle', 'Chat', 'alt+t'); - const [suggestionType, setSuggestionType] = useState('none'); + const { addNotification } = useNotifications() + const thinkingToggleShortcut = useShortcutDisplay( + 'chat:thinkingToggle', + 'Chat', + 'alt+t', + ) + const [suggestionType, setSuggestionType] = useState('none') // Compute max column width from ALL commands once (not filtered results) // This prevents layout shift when filtering const allCommandsMaxWidth = useMemo(() => { - const visibleCommands = commands.filter(cmd => !cmd.isHidden); - if (visibleCommands.length === 0) return undefined; - const maxLen = Math.max(...visibleCommands.map(cmd => getCommandName(cmd).length)); - return maxLen + 6; // +1 for "/" prefix, +5 for padding - }, [commands]); - const [maxColumnWidth, setMaxColumnWidth] = useState(undefined); - const mcpResources = useAppState(s => s.mcp.resources); - const store = useAppStateStore(); - const promptSuggestion = useAppState(s => s.promptSuggestion); + const visibleCommands = commands.filter(cmd => !cmd.isHidden) + if (visibleCommands.length === 0) return undefined + const maxLen = Math.max( + ...visibleCommands.map(cmd => getCommandName(cmd).length), + ) + return maxLen + 6 // +1 for "/" prefix, +5 for padding + }, [commands]) + + const [maxColumnWidth, setMaxColumnWidth] = useState( + undefined, + ) + const mcpResources = useAppState(s => s.mcp.resources) + const store = useAppStateStore() + const promptSuggestion = useAppState(s => s.promptSuggestion) // PromptInput hides suggestion ghost text in teammate view — mirror that // gate here so Tab/rightArrow can't accept what isn't displayed. - const isViewingTeammate = useAppState(s => !!s.viewingAgentTaskId); + const isViewingTeammate = useAppState(s => !!s.viewingAgentTaskId) // Access keybinding context to check for pending chord sequences - const keybindingContext = useOptionalKeybindingContext(); + const keybindingContext = useOptionalKeybindingContext() // State for inline ghost text (bash history completion - async) - const [inlineGhostText, setInlineGhostText] = useState(undefined); + const [inlineGhostText, setInlineGhostText] = useState< + InlineGhostText | undefined + >(undefined) // Synchronous ghost text for prompt mode mid-input slash commands. // Computed during render via useMemo to eliminate the one-frame flicker // that occurs when using useState + useEffect (effect runs after render). const syncPromptGhostText = useMemo((): InlineGhostText | undefined => { - if (mode !== 'prompt' || suppressSuggestions) return undefined; - const midInputCommand = findMidInputSlashCommand(input, cursorOffset); - if (!midInputCommand) return undefined; - const match = getBestCommandMatch(midInputCommand.partialCommand, commands); - if (!match) return undefined; + if (mode !== 'prompt' || suppressSuggestions) return undefined + const midInputCommand = findMidInputSlashCommand(input, cursorOffset) + if (!midInputCommand) return undefined + const match = getBestCommandMatch(midInputCommand.partialCommand, commands) + if (!match) return undefined return { text: match.suffix, fullCommand: match.fullCommand, - insertPosition: midInputCommand.startPos + 1 + midInputCommand.partialCommand.length - }; - }, [input, cursorOffset, mode, commands, suppressSuggestions]); + insertPosition: + midInputCommand.startPos + 1 + midInputCommand.partialCommand.length, + } + }, [input, cursorOffset, mode, commands, suppressSuggestions]) // Merged ghost text: prompt mode uses synchronous useMemo, bash mode uses async useState - const effectiveGhostText = suppressSuggestions ? undefined : mode === 'prompt' ? syncPromptGhostText : inlineGhostText; + const effectiveGhostText = suppressSuggestions + ? undefined + : mode === 'prompt' + ? syncPromptGhostText + : inlineGhostText // Use a ref for cursorOffset to avoid re-triggering suggestions on cursor movement alone // We only want to re-fetch suggestions when the actual search token changes - const cursorOffsetRef = useRef(cursorOffset); - cursorOffsetRef.current = cursorOffset; + const cursorOffsetRef = useRef(cursorOffset) + cursorOffsetRef.current = cursorOffset // Track the latest search token to discard stale results from slow async operations - const latestSearchTokenRef = useRef(null); + const latestSearchTokenRef = useRef(null) // Track previous input to detect actual text changes vs. callback recreations - const prevInputRef = useRef(''); + const prevInputRef = useRef('') // Track the latest path token to discard stale results from path completion - const latestPathTokenRef = useRef(''); + const latestPathTokenRef = useRef('') // Track the latest bash input to discard stale results from history completion - const latestBashInputRef = useRef(''); + const latestBashInputRef = useRef('') // Track the latest slack channel token to discard stale results from MCP - const latestSlackTokenRef = useRef(''); + const latestSlackTokenRef = useRef('') // Track suggestions via ref to avoid updateSuggestions being recreated on selection changes - const suggestionsRef = useRef(suggestions); - suggestionsRef.current = suggestions; + const suggestionsRef = useRef(suggestions) + suggestionsRef.current = suggestions // Track the input value when suggestions were manually dismissed to prevent re-triggering - const dismissedForInputRef = useRef(null); + const dismissedForInputRef = useRef(null) // Clear all suggestions const clearSuggestions = useCallback(() => { setSuggestionsState(() => ({ commandArgumentHint: undefined, suggestions: [], - selectedSuggestion: -1 - })); - setSuggestionType('none'); - setMaxColumnWidth(undefined); - setInlineGhostText(undefined); - }, [setSuggestionsState]); + selectedSuggestion: -1, + })) + setSuggestionType('none') + setMaxColumnWidth(undefined) + setInlineGhostText(undefined) + }, [setSuggestionsState]) // Expensive async operation to fetch file/resource suggestions - const fetchFileSuggestions = useCallback(async (searchToken: string, isAtSymbol = false): Promise => { - latestSearchTokenRef.current = searchToken; - const combinedItems = await generateUnifiedSuggestions(searchToken, mcpResources, agents, isAtSymbol); - // Discard stale results if a newer query was initiated while waiting - if (latestSearchTokenRef.current !== searchToken) { - return; - } - if (combinedItems.length === 0) { - // Inline clearSuggestions logic to avoid needing debouncedFetchFileSuggestions - setSuggestionsState(() => ({ + const fetchFileSuggestions = useCallback( + async (searchToken: string, isAtSymbol = false): Promise => { + latestSearchTokenRef.current = searchToken + const combinedItems = await generateUnifiedSuggestions( + searchToken, + mcpResources, + agents, + isAtSymbol, + ) + // Discard stale results if a newer query was initiated while waiting + if (latestSearchTokenRef.current !== searchToken) { + return + } + if (combinedItems.length === 0) { + // Inline clearSuggestions logic to avoid needing debouncedFetchFileSuggestions + setSuggestionsState(() => ({ + commandArgumentHint: undefined, + suggestions: [], + selectedSuggestion: -1, + })) + setSuggestionType('none') + setMaxColumnWidth(undefined) + return + } + setSuggestionsState(prev => ({ commandArgumentHint: undefined, - suggestions: [], - selectedSuggestion: -1 - })); - setSuggestionType('none'); - setMaxColumnWidth(undefined); - return; - } - setSuggestionsState(prev => ({ - commandArgumentHint: undefined, - suggestions: combinedItems, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, combinedItems) - })); - setSuggestionType(combinedItems.length > 0 ? 'file' : 'none'); - setMaxColumnWidth(undefined); // No fixed width for file suggestions - }, [mcpResources, setSuggestionsState, setSuggestionType, setMaxColumnWidth, agents]); + suggestions: combinedItems, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + combinedItems, + ), + })) + setSuggestionType(combinedItems.length > 0 ? 'file' : 'none') + setMaxColumnWidth(undefined) // No fixed width for file suggestions + }, + [ + mcpResources, + setSuggestionsState, + setSuggestionType, + setMaxColumnWidth, + agents, + ], + ) // Pre-warm the file index on mount so the first @-mention doesn't block. // The build runs in background with ~4ms event-loop yields, so it doesn't @@ -492,399 +623,515 @@ export function useTypeahead({ // subsequent tests in the shard. The subscriber still registers so // fileSuggestions tests that trigger a refresh directly work correctly. useEffect(() => { - if (("production" as string) !== 'test') { - startBackgroundCacheRefresh(); + if ("production" !== 'test') { + startBackgroundCacheRefresh() } return onIndexBuildComplete(() => { - const token = latestSearchTokenRef.current; + const token = latestSearchTokenRef.current if (token !== null) { - latestSearchTokenRef.current = null; - void fetchFileSuggestions(token, token === ''); + latestSearchTokenRef.current = null + void fetchFileSuggestions(token, token === '') } - }); - }, [fetchFileSuggestions]); + }) + }, [fetchFileSuggestions]) // Debounce the file fetch operation. 50ms sits just above macOS default // key-repeat (~33ms) so held-delete/backspace coalesces into one search // instead of stuttering on each repeated key. The search itself is ~8–15ms // on a 270k-file index. - const debouncedFetchFileSuggestions = useDebounceCallback(fetchFileSuggestions, 50); - const fetchSlackChannels = useCallback(async (partial: string): Promise => { - latestSlackTokenRef.current = partial; - const channels = await getSlackChannelSuggestions(store.getState().mcp.clients, partial); - if (latestSlackTokenRef.current !== partial) return; - setSuggestionsState(prev => ({ - commandArgumentHint: undefined, - suggestions: channels, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, channels) - })); - setSuggestionType(channels.length > 0 ? 'slack-channel' : 'none'); - setMaxColumnWidth(undefined); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- store is a stable context ref - [setSuggestionsState]); + const debouncedFetchFileSuggestions = useDebounceCallback( + fetchFileSuggestions, + 50, + ) + + const fetchSlackChannels = useCallback( + async (partial: string): Promise => { + latestSlackTokenRef.current = partial + const channels = await getSlackChannelSuggestions( + store.getState().mcp.clients, + partial, + ) + if (latestSlackTokenRef.current !== partial) return + setSuggestionsState(prev => ({ + commandArgumentHint: undefined, + suggestions: channels, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + channels, + ), + })) + setSuggestionType(channels.length > 0 ? 'slack-channel' : 'none') + setMaxColumnWidth(undefined) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- store is a stable context ref + [setSuggestionsState], + ) // First keystroke after # needs the MCP round-trip; subsequent keystrokes // that share the same first-word segment hit the cache synchronously. - const debouncedFetchSlackChannels = useDebounceCallback(fetchSlackChannels, 150); + const debouncedFetchSlackChannels = useDebounceCallback( + fetchSlackChannels, + 150, + ) // Handle immediate suggestion logic (cheap operations) // biome-ignore lint/correctness/useExhaustiveDependencies: store is a stable context ref, read imperatively at call-time - const updateSuggestions = useCallback(async (value: string, inputCursorOffset?: number): Promise => { - // Use provided cursor offset or fall back to ref (avoids dependency on cursorOffset) - const effectiveCursorOffset = inputCursorOffset ?? cursorOffsetRef.current; - if (suppressSuggestions) { - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - return; - } + const updateSuggestions = useCallback( + async (value: string, inputCursorOffset?: number): Promise => { + // Use provided cursor offset or fall back to ref (avoids dependency on cursorOffset) + const effectiveCursorOffset = inputCursorOffset ?? cursorOffsetRef.current + if (suppressSuggestions) { + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + return + } - // Check for mid-input slash command (e.g., "help me /com") - // Only in prompt mode, not when input starts with "/" (handled separately) - // Note: ghost text for prompt mode is computed synchronously via syncPromptGhostText useMemo. - // We only need to clear dropdown suggestions here when ghost text is active. - if (mode === 'prompt') { - const midInputCommand = findMidInputSlashCommand(value, effectiveCursorOffset); - if (midInputCommand) { - const match = getBestCommandMatch(midInputCommand.partialCommand, commands); - if (match) { + // Check for mid-input slash command (e.g., "help me /com") + // Only in prompt mode, not when input starts with "/" (handled separately) + // Note: ghost text for prompt mode is computed synchronously via syncPromptGhostText useMemo. + // We only need to clear dropdown suggestions here when ghost text is active. + if (mode === 'prompt') { + const midInputCommand = findMidInputSlashCommand( + value, + effectiveCursorOffset, + ) + if (midInputCommand) { + const match = getBestCommandMatch( + midInputCommand.partialCommand, + commands, + ) + if (match) { + // Clear dropdown suggestions when showing ghost text + setSuggestionsState(() => ({ + commandArgumentHint: undefined, + suggestions: [], + selectedSuggestion: -1, + })) + setSuggestionType('none') + setMaxColumnWidth(undefined) + return + } + } + } + + // Bash mode: check for history-based ghost text completion + if (mode === 'bash' && value.trim()) { + latestBashInputRef.current = value + const historyMatch = await getShellHistoryCompletion(value) + // Discard stale results if input changed while waiting + if (latestBashInputRef.current !== value) { + return + } + if (historyMatch) { + setInlineGhostText({ + text: historyMatch.suffix, + fullCommand: historyMatch.fullCommand, + insertPosition: value.length, + }) // Clear dropdown suggestions when showing ghost text setSuggestionsState(() => ({ commandArgumentHint: undefined, suggestions: [], - selectedSuggestion: -1 - })); - setSuggestionType('none'); - setMaxColumnWidth(undefined); - return; + selectedSuggestion: -1, + })) + setSuggestionType('none') + setMaxColumnWidth(undefined) + return + } else { + // No history match, clear ghost text + setInlineGhostText(undefined) } } - } - // Bash mode: check for history-based ghost text completion - if (mode === 'bash' && value.trim()) { - latestBashInputRef.current = value; - const historyMatch = await getShellHistoryCompletion(value); - // Discard stale results if input changed while waiting - if (latestBashInputRef.current !== value) { - return; - } - if (historyMatch) { - setInlineGhostText({ - text: historyMatch.suffix, - fullCommand: historyMatch.fullCommand, - insertPosition: value.length - }); - // Clear dropdown suggestions when showing ghost text - setSuggestionsState(() => ({ - commandArgumentHint: undefined, - suggestions: [], - selectedSuggestion: -1 - })); - setSuggestionType('none'); - setMaxColumnWidth(undefined); - return; - } else { - // No history match, clear ghost text - setInlineGhostText(undefined); - } - } + // Check for @ to trigger team member / named subagent suggestions + // Must check before @ file symbol to prevent conflict + // Skip in bash mode - @ has no special meaning in shell commands + const atMatch = + mode !== 'bash' + ? value.substring(0, effectiveCursorOffset).match(/(^|\s)@([\w-]*)$/) + : null + if (atMatch) { + const partialName = (atMatch[2] ?? '').toLowerCase() + // Imperative read — reading at call-time fixes staleness for + // teammates/subagents added mid-session. + const state = store.getState() + const members: SuggestionItem[] = [] + const seen = new Set() - // Check for @ to trigger team member / named subagent suggestions - // Must check before @ file symbol to prevent conflict - // Skip in bash mode - @ has no special meaning in shell commands - const atMatch = mode !== 'bash' ? value.substring(0, effectiveCursorOffset).match(/(^|\s)@([\w-]*)$/) : null; - if (atMatch) { - const partialName = (atMatch[2] ?? '').toLowerCase(); - // Imperative read — reading at call-time fixes staleness for - // teammates/subagents added mid-session. - const state = store.getState(); - const members: SuggestionItem[] = []; - const seen = new Set(); - if (isAgentSwarmsEnabled() && state.teamContext) { - for (const t of Object.values(state.teamContext.teammates ?? {})) { - if (t.name === TEAM_LEAD_NAME) continue; - if (!t.name.toLowerCase().startsWith(partialName)) continue; - seen.add(t.name); + if (isAgentSwarmsEnabled() && state.teamContext) { + for (const t of Object.values(state.teamContext.teammates ?? {})) { + if (t.name === TEAM_LEAD_NAME) continue + if (!t.name.toLowerCase().startsWith(partialName)) continue + seen.add(t.name) + members.push({ + id: `dm-${t.name}`, + displayText: `@${t.name}`, + description: 'send message', + }) + } + } + + for (const [name, agentId] of state.agentNameRegistry) { + if (seen.has(name)) continue + if (!name.toLowerCase().startsWith(partialName)) continue + const status = state.tasks[agentId]?.status members.push({ - id: `dm-${t.name}`, - displayText: `@${t.name}`, - description: 'send message' - }); + id: `dm-${name}`, + displayText: `@${name}`, + description: status ? `send message · ${status}` : 'send message', + }) } - } - for (const [name, agentId] of state.agentNameRegistry) { - if (seen.has(name)) continue; - if (!name.toLowerCase().startsWith(partialName)) continue; - const status = state.tasks[agentId]?.status; - members.push({ - id: `dm-${name}`, - displayText: `@${name}`, - description: status ? `send message · ${status}` : 'send message' - }); - } - if (members.length > 0) { - debouncedFetchFileSuggestions.cancel(); - setSuggestionsState(prev => ({ - commandArgumentHint: undefined, - suggestions: members, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, members) - })); - setSuggestionType('agent'); - setMaxColumnWidth(undefined); - return; - } - } - // Check for # to trigger Slack channel suggestions (requires Slack MCP server) - if (mode === 'prompt') { - const hashMatch = value.substring(0, effectiveCursorOffset).match(HASH_CHANNEL_RE); - if (hashMatch && hasSlackMcpServer(store.getState().mcp.clients)) { - debouncedFetchSlackChannels(hashMatch[2]!); - return; - } else if (suggestionType === 'slack-channel') { - debouncedFetchSlackChannels.cancel(); - clearSuggestions(); - } - } - - // Check for @ symbol to trigger file suggestions (including quoted paths) - // Includes colon for MCP resources (e.g., server:resource/path) - const hasAtSymbol = value.substring(0, effectiveCursorOffset).match(HAS_AT_SYMBOL_RE); - - // First, check for slash command suggestions (higher priority than @ symbol) - // Only show slash command selector if cursor is not on the "/" character itself - // Also don't show if cursor is at end of line with whitespace before it - // Don't show slash commands in bash mode - const isAtEndWithWhitespace = effectiveCursorOffset === value.length && effectiveCursorOffset > 0 && value.length > 0 && value[effectiveCursorOffset - 1] === ' '; - - // Handle directory completion for commands - if (mode === 'prompt' && isCommandInput(value) && effectiveCursorOffset > 0) { - const parsedCommand = extractCommandNameAndArgs(value); - if (parsedCommand && parsedCommand.commandName === 'add-dir' && parsedCommand.args) { - const { - args - } = parsedCommand; - - // Clear suggestions if args end with whitespace (user is done with path) - if (args.match(/\s+$/)) { - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - return; - } - const dirSuggestions = await getDirectoryCompletions(args); - if (dirSuggestions.length > 0) { + if (members.length > 0) { + debouncedFetchFileSuggestions.cancel() setSuggestionsState(prev => ({ - suggestions: dirSuggestions, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, dirSuggestions), - commandArgumentHint: undefined - })); - setSuggestionType('directory'); - return; + commandArgumentHint: undefined, + suggestions: members, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + members, + ), + })) + setSuggestionType('agent') + setMaxColumnWidth(undefined) + return } - - // No suggestions found - clear and return - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - return; } - // Handle custom title completion for /resume command - if (parsedCommand && parsedCommand.commandName === 'resume' && parsedCommand.args !== undefined && value.includes(' ')) { - const { - args - } = parsedCommand; - - // Get custom title suggestions using partial match - const matches = await searchSessionsByCustomTitle(args, { - limit: 10 - }); - const suggestions = matches.map(log => { - const sessionId = getSessionIdFromLog(log); - return { - id: `resume-title-${sessionId}`, - displayText: log.customTitle!, - description: formatLogMetadata(log), - metadata: { - sessionId - } - }; - }); - if (suggestions.length > 0) { - setSuggestionsState(prev => ({ - suggestions, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, suggestions), - commandArgumentHint: undefined - })); - setSuggestionType('custom-title'); - return; + // Check for # to trigger Slack channel suggestions (requires Slack MCP server) + if (mode === 'prompt') { + const hashMatch = value + .substring(0, effectiveCursorOffset) + .match(HASH_CHANNEL_RE) + if (hashMatch && hasSlackMcpServer(store.getState().mcp.clients)) { + debouncedFetchSlackChannels(hashMatch[2]!) + return + } else if (suggestionType === 'slack-channel') { + debouncedFetchSlackChannels.cancel() + clearSuggestions() } - - // No suggestions found - clear and return - clearSuggestions(); - return; } - } - // Determine whether to display the argument hint and command suggestions. - if (mode === 'prompt' && isCommandInput(value) && effectiveCursorOffset > 0 && !hasCommandWithArguments(isAtEndWithWhitespace, value)) { - let commandArgumentHint: string | undefined = undefined; - if (value.length > 1) { - // We have a partial or complete command without arguments - // Check if it matches a command exactly and has an argument hint + // Check for @ symbol to trigger file suggestions (including quoted paths) + // Includes colon for MCP resources (e.g., server:resource/path) + const hasAtSymbol = value + .substring(0, effectiveCursorOffset) + .match(HAS_AT_SYMBOL_RE) - // Extract command name: everything after / until the first space (or end) - const spaceIndex = value.indexOf(' '); - const commandName = spaceIndex === -1 ? value.slice(1) : value.slice(1, spaceIndex); + // First, check for slash command suggestions (higher priority than @ symbol) + // Only show slash command selector if cursor is not on the "/" character itself + // Also don't show if cursor is at end of line with whitespace before it + // Don't show slash commands in bash mode + const isAtEndWithWhitespace = + effectiveCursorOffset === value.length && + effectiveCursorOffset > 0 && + value.length > 0 && + value[effectiveCursorOffset - 1] === ' ' - // Check if there are real arguments (non-whitespace after the command) - const hasRealArguments = spaceIndex !== -1 && value.slice(spaceIndex + 1).trim().length > 0; + // Handle directory completion for commands + if ( + mode === 'prompt' && + isCommandInput(value) && + effectiveCursorOffset > 0 + ) { + const parsedCommand = extractCommandNameAndArgs(value) - // Check if input is exactly "command + single space" (ready for arguments) - const hasExactlyOneTrailingSpace = spaceIndex !== -1 && value.length === spaceIndex + 1; + if ( + parsedCommand && + parsedCommand.commandName === 'add-dir' && + parsedCommand.args + ) { + const { args } = parsedCommand - // If input has a space after the command, don't show suggestions - // This prevents Enter from selecting a different command after Tab completion - if (spaceIndex !== -1) { - const exactMatch = commands.find(cmd => getCommandName(cmd) === commandName); - if (exactMatch || hasRealArguments) { - // Priority 1: Static argumentHint (only on first trailing space for backwards compat) - if (exactMatch?.argumentHint && hasExactlyOneTrailingSpace) { - commandArgumentHint = exactMatch.argumentHint; - } - // Priority 2: Progressive hint from argNames (show when trailing space) - else if (exactMatch?.type === 'prompt' && exactMatch.argNames?.length && value.endsWith(' ')) { - const argsText = value.slice(spaceIndex + 1); - const typedArgs = parseArguments(argsText); - commandArgumentHint = generateProgressiveArgumentHint(exactMatch.argNames, typedArgs); - } - setSuggestionsState(() => ({ - commandArgumentHint, - suggestions: [], - selectedSuggestion: -1 - })); - setSuggestionType('none'); - setMaxColumnWidth(undefined); - return; + // Clear suggestions if args end with whitespace (user is done with path) + if (args.match(/\s+$/)) { + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + return } - } - // Note: argument hint is only shown when there's exactly one trailing space - // (set above when hasExactlyOneTrailingSpace is true) - } - const commandItems = generateCommandSuggestions(value, commands); - setSuggestionsState(() => ({ - commandArgumentHint, - suggestions: commandItems, - selectedSuggestion: commandItems.length > 0 ? 0 : -1 - })); - setSuggestionType(commandItems.length > 0 ? 'command' : 'none'); - - // Use stable width from all commands (prevents layout shift when filtering) - if (commandItems.length > 0) { - setMaxColumnWidth(allCommandsMaxWidth); - } - return; - } - if (suggestionType === 'command') { - // If we had command suggestions but the input no longer starts with '/' - // we need to clear the suggestions. However, we should not return - // because there may be relevant @ symbol and file suggestions. - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - } else if (isCommandInput(value) && hasCommandWithArguments(isAtEndWithWhitespace, value)) { - // If we have a command with arguments (no trailing space), clear any stale hint - // This prevents the hint from flashing when transitioning between states - setSuggestionsState(prev => prev.commandArgumentHint ? { - ...prev, - commandArgumentHint: undefined - } : prev); - } - if (suggestionType === 'custom-title') { - // If we had custom-title suggestions but the input is no longer /resume - // we need to clear the suggestions. - clearSuggestions(); - } - if (suggestionType === 'agent' && suggestionsRef.current.some((s: SuggestionItem) => s.id?.startsWith('dm-'))) { - // If we had team member suggestions but the input no longer has @ - // we need to clear the suggestions. - const hasAt = value.substring(0, effectiveCursorOffset).match(/(^|\s)@([\w-]*)$/); - if (!hasAt) { - clearSuggestions(); - } - } - - // Check for @ symbol to trigger file and MCP resource suggestions - // Skip @ autocomplete in bash mode - @ has no special meaning in shell commands - if (hasAtSymbol && mode !== 'bash') { - // Get the @ token (including the @ symbol) - const completionToken = extractCompletionToken(value, effectiveCursorOffset, true); - if (completionToken && completionToken.token.startsWith('@')) { - const searchToken = extractSearchToken(completionToken); - - // If the token after @ is path-like, use path completion instead of fuzzy search - // This handles cases like @~/path, @./path, @/path for directory traversal - if (isPathLikeToken(searchToken)) { - latestPathTokenRef.current = searchToken; - const pathSuggestions = await getPathCompletions(searchToken, { - maxResults: 10 - }); - // Discard stale results if a newer query was initiated while waiting - if (latestPathTokenRef.current !== searchToken) { - return; - } - if (pathSuggestions.length > 0) { + const dirSuggestions = await getDirectoryCompletions(args) + if (dirSuggestions.length > 0) { setSuggestionsState(prev => ({ - suggestions: pathSuggestions, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, pathSuggestions), - commandArgumentHint: undefined - })); - setSuggestionType('directory'); - return; + suggestions: dirSuggestions, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + dirSuggestions, + ), + commandArgumentHint: undefined, + })) + setSuggestionType('directory') + return } + + // No suggestions found - clear and return + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + return } - // Skip if we already fetched for this exact token (prevents loop from - // suggestions dependency causing updateSuggestions to be recreated) - if (latestSearchTokenRef.current === searchToken) { - return; - } - void debouncedFetchFileSuggestions(searchToken, true); - return; - } - } + // Handle custom title completion for /resume command + if ( + parsedCommand && + parsedCommand.commandName === 'resume' && + parsedCommand.args !== undefined && + value.includes(' ') + ) { + const { args } = parsedCommand - // If we have active file suggestions or the input changed, check for file suggestions - if (suggestionType === 'file') { - const completionToken = extractCompletionToken(value, effectiveCursorOffset, true); - if (completionToken) { - const searchToken = extractSearchToken(completionToken); - // Skip if we already fetched for this exact token - if (latestSearchTokenRef.current === searchToken) { - return; - } - void debouncedFetchFileSuggestions(searchToken, false); - } else { - // If we had file suggestions but now there's no completion token - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - } - } + // Get custom title suggestions using partial match + const matches = await searchSessionsByCustomTitle(args, { + limit: 10, + }) - // Clear shell suggestions if not in bash mode OR if input has changed - if (suggestionType === 'shell') { - const inputSnapshot = (suggestionsRef.current[0]?.metadata as { - inputSnapshot?: string; - })?.inputSnapshot; - if (mode !== 'bash' || value !== inputSnapshot) { - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + const suggestions = matches.map(log => { + const sessionId = getSessionIdFromLog(log) + return { + id: `resume-title-${sessionId}`, + displayText: log.customTitle!, + description: formatLogMetadata(log), + metadata: { sessionId }, + } + }) + + if (suggestions.length > 0) { + setSuggestionsState(prev => ({ + suggestions, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + suggestions, + ), + commandArgumentHint: undefined, + })) + setSuggestionType('custom-title') + return + } + + // No suggestions found - clear and return + clearSuggestions() + return + } } - } - }, [suggestionType, commands, setSuggestionsState, clearSuggestions, debouncedFetchFileSuggestions, debouncedFetchSlackChannels, mode, suppressSuggestions, - // Note: using suggestionsRef instead of suggestions to avoid recreating - // this callback when only selectedSuggestion changes (not the suggestions list) - allCommandsMaxWidth]); + + // Determine whether to display the argument hint and command suggestions. + if ( + mode === 'prompt' && + isCommandInput(value) && + effectiveCursorOffset > 0 && + !hasCommandWithArguments(isAtEndWithWhitespace, value) + ) { + let commandArgumentHint: string | undefined = undefined + if (value.length > 1) { + // We have a partial or complete command without arguments + // Check if it matches a command exactly and has an argument hint + + // Extract command name: everything after / until the first space (or end) + const spaceIndex = value.indexOf(' ') + const commandName = + spaceIndex === -1 ? value.slice(1) : value.slice(1, spaceIndex) + + // Check if there are real arguments (non-whitespace after the command) + const hasRealArguments = + spaceIndex !== -1 && value.slice(spaceIndex + 1).trim().length > 0 + + // Check if input is exactly "command + single space" (ready for arguments) + const hasExactlyOneTrailingSpace = + spaceIndex !== -1 && value.length === spaceIndex + 1 + + // If input has a space after the command, don't show suggestions + // This prevents Enter from selecting a different command after Tab completion + if (spaceIndex !== -1) { + const exactMatch = commands.find( + cmd => getCommandName(cmd) === commandName, + ) + if (exactMatch || hasRealArguments) { + // Priority 1: Static argumentHint (only on first trailing space for backwards compat) + if (exactMatch?.argumentHint && hasExactlyOneTrailingSpace) { + commandArgumentHint = exactMatch.argumentHint + } + // Priority 2: Progressive hint from argNames (show when trailing space) + else if ( + exactMatch?.type === 'prompt' && + exactMatch.argNames?.length && + value.endsWith(' ') + ) { + const argsText = value.slice(spaceIndex + 1) + const typedArgs = parseArguments(argsText) + commandArgumentHint = generateProgressiveArgumentHint( + exactMatch.argNames, + typedArgs, + ) + } + setSuggestionsState(() => ({ + commandArgumentHint, + suggestions: [], + selectedSuggestion: -1, + })) + setSuggestionType('none') + setMaxColumnWidth(undefined) + return + } + } + + // Note: argument hint is only shown when there's exactly one trailing space + // (set above when hasExactlyOneTrailingSpace is true) + } + + const commandItems = generateCommandSuggestions(value, commands) + setSuggestionsState(() => ({ + commandArgumentHint, + suggestions: commandItems, + selectedSuggestion: commandItems.length > 0 ? 0 : -1, + })) + setSuggestionType(commandItems.length > 0 ? 'command' : 'none') + + // Use stable width from all commands (prevents layout shift when filtering) + if (commandItems.length > 0) { + setMaxColumnWidth(allCommandsMaxWidth) + } + return + } + + if (suggestionType === 'command') { + // If we had command suggestions but the input no longer starts with '/' + // we need to clear the suggestions. However, we should not return + // because there may be relevant @ symbol and file suggestions. + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + } else if ( + isCommandInput(value) && + hasCommandWithArguments(isAtEndWithWhitespace, value) + ) { + // If we have a command with arguments (no trailing space), clear any stale hint + // This prevents the hint from flashing when transitioning between states + setSuggestionsState(prev => + prev.commandArgumentHint + ? { ...prev, commandArgumentHint: undefined } + : prev, + ) + } + + if (suggestionType === 'custom-title') { + // If we had custom-title suggestions but the input is no longer /resume + // we need to clear the suggestions. + clearSuggestions() + } + + if ( + suggestionType === 'agent' && + suggestionsRef.current.some((s: SuggestionItem) => + s.id?.startsWith('dm-'), + ) + ) { + // If we had team member suggestions but the input no longer has @ + // we need to clear the suggestions. + const hasAt = value + .substring(0, effectiveCursorOffset) + .match(/(^|\s)@([\w-]*)$/) + if (!hasAt) { + clearSuggestions() + } + } + + // Check for @ symbol to trigger file and MCP resource suggestions + // Skip @ autocomplete in bash mode - @ has no special meaning in shell commands + if (hasAtSymbol && mode !== 'bash') { + // Get the @ token (including the @ symbol) + const completionToken = extractCompletionToken( + value, + effectiveCursorOffset, + true, + ) + if (completionToken && completionToken.token.startsWith('@')) { + const searchToken = extractSearchToken(completionToken) + + // If the token after @ is path-like, use path completion instead of fuzzy search + // This handles cases like @~/path, @./path, @/path for directory traversal + if (isPathLikeToken(searchToken)) { + latestPathTokenRef.current = searchToken + const pathSuggestions = await getPathCompletions(searchToken, { + maxResults: 10, + }) + // Discard stale results if a newer query was initiated while waiting + if (latestPathTokenRef.current !== searchToken) { + return + } + if (pathSuggestions.length > 0) { + setSuggestionsState(prev => ({ + suggestions: pathSuggestions, + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + pathSuggestions, + ), + commandArgumentHint: undefined, + })) + setSuggestionType('directory') + return + } + } + + // Skip if we already fetched for this exact token (prevents loop from + // suggestions dependency causing updateSuggestions to be recreated) + if (latestSearchTokenRef.current === searchToken) { + return + } + void debouncedFetchFileSuggestions(searchToken, true) + return + } + } + + // If we have active file suggestions or the input changed, check for file suggestions + if (suggestionType === 'file') { + const completionToken = extractCompletionToken( + value, + effectiveCursorOffset, + true, + ) + if (completionToken) { + const searchToken = extractSearchToken(completionToken) + // Skip if we already fetched for this exact token + if (latestSearchTokenRef.current === searchToken) { + return + } + void debouncedFetchFileSuggestions(searchToken, false) + } else { + // If we had file suggestions but now there's no completion token + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + } + } + + // Clear shell suggestions if not in bash mode OR if input has changed + if (suggestionType === 'shell') { + const inputSnapshot = ( + suggestionsRef.current[0]?.metadata as { inputSnapshot?: string } + )?.inputSnapshot + + if (mode !== 'bash' || value !== inputSnapshot) { + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + } + } + }, + [ + suggestionType, + commands, + setSuggestionsState, + clearSuggestions, + debouncedFetchFileSuggestions, + debouncedFetchSlackChannels, + mode, + suppressSuggestions, + // Note: using suggestionsRef instead of suggestions to avoid recreating + // this callback when only selectedSuggestion changes (not the suggestions list) + allCommandsMaxWidth, + ], + ) // Update suggestions when input changes // Note: We intentionally don't depend on cursorOffset here - cursor movement alone @@ -893,19 +1140,19 @@ export function useTypeahead({ useEffect(() => { // If suggestions were dismissed for this exact input, don't re-trigger if (dismissedForInputRef.current === input) { - return; + return } // When the actual input text changes (not just updateSuggestions being recreated), // reset the search token ref so the same query can be re-fetched. // This fixes: type @readme.md, clear, retype @readme.md → no suggestions. if (prevInputRef.current !== input) { - prevInputRef.current = input; - latestSearchTokenRef.current = null; + prevInputRef.current = input + latestSearchTokenRef.current = null } // Clear the dismissed state when input changes - dismissedForInputRef.current = null; - void updateSuggestions(input); - }, [input, updateSuggestions]); + dismissedForInputRef.current = null + void updateSuggestions(input) + }, [input, updateSuggestions]) // Handle tab key press - complete suggestions or trigger file suggestions const handleTab = useCallback(async () => { @@ -914,143 +1161,216 @@ export function useTypeahead({ // Check for bash mode history completion first if (mode === 'bash') { // Replace the input with the full command from history - onInputChange(effectiveGhostText.fullCommand); - setCursorOffset(effectiveGhostText.fullCommand.length); - setInlineGhostText(undefined); - return; + onInputChange(effectiveGhostText.fullCommand) + setCursorOffset(effectiveGhostText.fullCommand.length) + setInlineGhostText(undefined) + return } // Find the mid-input command to get its position (for prompt mode) - const midInputCommand = findMidInputSlashCommand(input, cursorOffset); + const midInputCommand = findMidInputSlashCommand(input, cursorOffset) if (midInputCommand) { // Replace the partial command with the full command + space - const before = input.slice(0, midInputCommand.startPos); - const after = input.slice(midInputCommand.startPos + midInputCommand.token.length); - const newInput = before + '/' + effectiveGhostText.fullCommand + ' ' + after; - const newCursorOffset = midInputCommand.startPos + 1 + effectiveGhostText.fullCommand.length + 1; - onInputChange(newInput); - setCursorOffset(newCursorOffset); - return; + const before = input.slice(0, midInputCommand.startPos) + const after = input.slice( + midInputCommand.startPos + midInputCommand.token.length, + ) + const newInput = + before + '/' + effectiveGhostText.fullCommand + ' ' + after + const newCursorOffset = + midInputCommand.startPos + + 1 + + effectiveGhostText.fullCommand.length + + 1 + + onInputChange(newInput) + setCursorOffset(newCursorOffset) + return } } // If we have active suggestions, select one if (suggestions.length > 0) { // Cancel any pending debounced fetches to prevent flicker when accepting - debouncedFetchFileSuggestions.cancel(); - debouncedFetchSlackChannels.cancel(); - const index = selectedSuggestion === -1 ? 0 : selectedSuggestion; - const suggestion = suggestions[index]; + debouncedFetchFileSuggestions.cancel() + debouncedFetchSlackChannels.cancel() + + const index = selectedSuggestion === -1 ? 0 : selectedSuggestion + const suggestion = suggestions[index] + if (suggestionType === 'command' && index < suggestions.length) { if (suggestion) { - applyCommandSuggestion(suggestion, false, - // don't execute on tab - commands, onInputChange, setCursorOffset, onSubmit); - clearSuggestions(); + applyCommandSuggestion( + suggestion, + false, // don't execute on tab + commands, + onInputChange, + setCursorOffset, + onSubmit, + ) + clearSuggestions() } } else if (suggestionType === 'custom-title' && suggestions.length > 0) { // Apply custom title to /resume command with sessionId if (suggestion) { - const newInput = buildResumeInputFromSuggestion(suggestion); - onInputChange(newInput); - setCursorOffset(newInput.length); - clearSuggestions(); + const newInput = buildResumeInputFromSuggestion(suggestion) + onInputChange(newInput) + setCursorOffset(newInput.length) + clearSuggestions() } } else if (suggestionType === 'directory' && suggestions.length > 0) { - const suggestion = suggestions[index]; + const suggestion = suggestions[index] if (suggestion) { // Check if this is a command context (e.g., /add-dir) or general path completion - const isInCommandContext = isCommandInput(input); - let newInput: string; + const isInCommandContext = isCommandInput(input) + + let newInput: string if (isInCommandContext) { // Command context: replace just the argument portion - const spaceIndex = input.indexOf(' '); - const commandPart = input.slice(0, spaceIndex + 1); // Include the space - const cmdSuffix = isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory' ? '/' : ' '; - newInput = commandPart + suggestion.id + cmdSuffix; - onInputChange(newInput); - setCursorOffset(newInput.length); - if (isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory') { + const spaceIndex = input.indexOf(' ') + const commandPart = input.slice(0, spaceIndex + 1) // Include the space + const cmdSuffix = + isPathMetadata(suggestion.metadata) && + suggestion.metadata.type === 'directory' + ? '/' + : ' ' + newInput = commandPart + suggestion.id + cmdSuffix + + onInputChange(newInput) + setCursorOffset(newInput.length) + + if ( + isPathMetadata(suggestion.metadata) && + suggestion.metadata.type === 'directory' + ) { // For directories, fetch new suggestions for the updated path setSuggestionsState(prev => ({ ...prev, - commandArgumentHint: undefined - })); - void updateSuggestions(newInput, newInput.length); + commandArgumentHint: undefined, + })) + void updateSuggestions(newInput, newInput.length) } else { - clearSuggestions(); + clearSuggestions() } } else { // General path completion: replace the path token in input with @-prefixed path // Try to get token with @ prefix first to check if already prefixed - const completionTokenWithAt = extractCompletionToken(input, cursorOffset, true); - const completionToken = completionTokenWithAt ?? extractCompletionToken(input, cursorOffset, false); + const completionTokenWithAt = extractCompletionToken( + input, + cursorOffset, + true, + ) + const completionToken = + completionTokenWithAt ?? + extractCompletionToken(input, cursorOffset, false) + if (completionToken) { - const isDir = isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory'; - const result = applyDirectorySuggestion(input, suggestion.id, completionToken.startPos, completionToken.token.length, isDir); - newInput = result.newInput; - onInputChange(newInput); - setCursorOffset(result.cursorPos); + const isDir = + isPathMetadata(suggestion.metadata) && + suggestion.metadata.type === 'directory' + const result = applyDirectorySuggestion( + input, + suggestion.id, + completionToken.startPos, + completionToken.token.length, + isDir, + ) + newInput = result.newInput + + onInputChange(newInput) + setCursorOffset(result.cursorPos) + if (isDir) { // For directories, fetch new suggestions for the updated path setSuggestionsState(prev => ({ ...prev, - commandArgumentHint: undefined - })); - void updateSuggestions(newInput, result.cursorPos); + commandArgumentHint: undefined, + })) + void updateSuggestions(newInput, result.cursorPos) } else { // For files, clear suggestions - clearSuggestions(); + clearSuggestions() } } else { // No completion token found (e.g., cursor after space) - just clear suggestions // without modifying input to avoid data loss - clearSuggestions(); + clearSuggestions() } } } } else if (suggestionType === 'shell' && suggestions.length > 0) { - const suggestion = suggestions[index]; + const suggestion = suggestions[index] if (suggestion) { - const metadata = suggestion.metadata as { - completionType: ShellCompletionType; - } | undefined; - applyShellSuggestion(suggestion, input, cursorOffset, onInputChange, setCursorOffset, metadata?.completionType); - clearSuggestions(); + const metadata = suggestion.metadata as + | { completionType: ShellCompletionType } + | undefined + applyShellSuggestion( + suggestion, + input, + cursorOffset, + onInputChange, + setCursorOffset, + metadata?.completionType, + ) + clearSuggestions() } - } else if (suggestionType === 'agent' && suggestions.length > 0 && suggestions[index]?.id?.startsWith('dm-')) { - const suggestion = suggestions[index]; + } else if ( + suggestionType === 'agent' && + suggestions.length > 0 && + suggestions[index]?.id?.startsWith('dm-') + ) { + const suggestion = suggestions[index] if (suggestion) { - applyTriggerSuggestion(suggestion, input, cursorOffset, DM_MEMBER_RE, onInputChange, setCursorOffset); - clearSuggestions(); + applyTriggerSuggestion( + suggestion, + input, + cursorOffset, + DM_MEMBER_RE, + onInputChange, + setCursorOffset, + ) + clearSuggestions() } } else if (suggestionType === 'slack-channel' && suggestions.length > 0) { - const suggestion = suggestions[index]; + const suggestion = suggestions[index] if (suggestion) { - applyTriggerSuggestion(suggestion, input, cursorOffset, HASH_CHANNEL_RE, onInputChange, setCursorOffset); - clearSuggestions(); + applyTriggerSuggestion( + suggestion, + input, + cursorOffset, + HASH_CHANNEL_RE, + onInputChange, + setCursorOffset, + ) + clearSuggestions() } } else if (suggestionType === 'file' && suggestions.length > 0) { - const completionToken = extractCompletionToken(input, cursorOffset, true); + const completionToken = extractCompletionToken( + input, + cursorOffset, + true, + ) if (!completionToken) { - clearSuggestions(); - return; + clearSuggestions() + return } // Check if all suggestions share a common prefix longer than the current input - const commonPrefix = findLongestCommonPrefix(suggestions); + const commonPrefix = findLongestCommonPrefix(suggestions) // Determine if token starts with @ to preserve it during replacement - const hasAtPrefix = completionToken.token.startsWith('@'); + const hasAtPrefix = completionToken.token.startsWith('@') // The effective token length excludes the @ and quotes if present - let effectiveTokenLength: number; + let effectiveTokenLength: number if (completionToken.isQuoted) { // Remove @" prefix and optional closing " to get effective length - effectiveTokenLength = completionToken.token.slice(2).replace(/"$/, '').length; + effectiveTokenLength = completionToken.token + .slice(2) + .replace(/"$/, '').length } else if (hasAtPrefix) { - effectiveTokenLength = completionToken.token.length - 1; + effectiveTokenLength = completionToken.token.length - 1 } else { - effectiveTokenLength = completionToken.token.length; + effectiveTokenLength = completionToken.token.length } // If there's a common prefix longer than what the user has typed, @@ -1060,233 +1380,401 @@ export function useTypeahead({ displayText: commonPrefix, mode, hasAtPrefix, - needsQuotes: false, - // common prefix doesn't need quotes unless already quoted + needsQuotes: false, // common prefix doesn't need quotes unless already quoted isQuoted: completionToken.isQuoted, - isComplete: false // partial completion - }); - applyFileSuggestion(replacementValue, input, completionToken.token, completionToken.startPos, onInputChange, setCursorOffset); + isComplete: false, // partial completion + }) + + applyFileSuggestion( + replacementValue, + input, + completionToken.token, + completionToken.startPos, + onInputChange, + setCursorOffset, + ) // Don't clear suggestions so user can continue typing or select a specific option // Instead, update for the new prefix - void updateSuggestions(input.replace(completionToken.token, replacementValue), cursorOffset); + void updateSuggestions( + input.replace(completionToken.token, replacementValue), + cursorOffset, + ) } else if (index < suggestions.length) { // Otherwise, apply the selected suggestion - const suggestion = suggestions[index]; + const suggestion = suggestions[index] if (suggestion) { - const needsQuotes = suggestion.displayText.includes(' '); + const needsQuotes = suggestion.displayText.includes(' ') const replacementValue = formatReplacementValue({ displayText: suggestion.displayText, mode, hasAtPrefix, needsQuotes, isQuoted: completionToken.isQuoted, - isComplete: true // complete suggestion - }); - applyFileSuggestion(replacementValue, input, completionToken.token, completionToken.startPos, onInputChange, setCursorOffset); - clearSuggestions(); + isComplete: true, // complete suggestion + }) + + applyFileSuggestion( + replacementValue, + input, + completionToken.token, + completionToken.startPos, + onInputChange, + setCursorOffset, + ) + clearSuggestions() } } } } else if (input.trim() !== '') { - let suggestionType: SuggestionType; - let suggestionItems: SuggestionItem[]; + let suggestionType: SuggestionType + let suggestionItems: SuggestionItem[] + if (mode === 'bash') { - suggestionType = 'shell'; + suggestionType = 'shell' // This should be very fast, taking <10ms - const bashSuggestions = await generateBashSuggestions(input, cursorOffset); + const bashSuggestions = await generateBashSuggestions( + input, + cursorOffset, + ) if (bashSuggestions.length === 1) { // If single suggestion, apply it immediately - const suggestion = bashSuggestions[0]; + const suggestion = bashSuggestions[0] if (suggestion) { - const metadata = suggestion.metadata as { - completionType: ShellCompletionType; - } | undefined; - applyShellSuggestion(suggestion, input, cursorOffset, onInputChange, setCursorOffset, metadata?.completionType); + const metadata = suggestion.metadata as + | { completionType: ShellCompletionType } + | undefined + applyShellSuggestion( + suggestion, + input, + cursorOffset, + onInputChange, + setCursorOffset, + metadata?.completionType, + ) } - suggestionItems = []; + suggestionItems = [] } else { - suggestionItems = bashSuggestions; + suggestionItems = bashSuggestions } } else { - suggestionType = 'file'; + suggestionType = 'file' // If no suggestions, fetch file and MCP resource suggestions - const completionInfo = extractCompletionToken(input, cursorOffset, true); + const completionInfo = extractCompletionToken(input, cursorOffset, true) if (completionInfo) { // If token starts with @, search without the @ prefix - const isAtSymbol = completionInfo.token.startsWith('@'); - const searchToken = isAtSymbol ? completionInfo.token.substring(1) : completionInfo.token; - suggestionItems = await generateUnifiedSuggestions(searchToken, mcpResources, agents, isAtSymbol); + const isAtSymbol = completionInfo.token.startsWith('@') + const searchToken = isAtSymbol + ? completionInfo.token.substring(1) + : completionInfo.token + + suggestionItems = await generateUnifiedSuggestions( + searchToken, + mcpResources, + agents, + isAtSymbol, + ) } else { - suggestionItems = []; + suggestionItems = [] } } + if (suggestionItems.length > 0) { // Multiple suggestions or not bash mode: show list setSuggestionsState(prev => ({ commandArgumentHint: undefined, suggestions: suggestionItems, - selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, suggestionItems) - })); - setSuggestionType(suggestionType); - setMaxColumnWidth(undefined); + selectedSuggestion: getPreservedSelection( + prev.suggestions, + prev.selectedSuggestion, + suggestionItems, + ), + })) + setSuggestionType(suggestionType) + setMaxColumnWidth(undefined) } } - }, [suggestions, selectedSuggestion, input, suggestionType, commands, mode, onInputChange, setCursorOffset, onSubmit, clearSuggestions, cursorOffset, updateSuggestions, mcpResources, setSuggestionsState, agents, debouncedFetchFileSuggestions, debouncedFetchSlackChannels, effectiveGhostText]); + }, [ + suggestions, + selectedSuggestion, + input, + suggestionType, + commands, + mode, + onInputChange, + setCursorOffset, + onSubmit, + clearSuggestions, + cursorOffset, + updateSuggestions, + mcpResources, + setSuggestionsState, + agents, + debouncedFetchFileSuggestions, + debouncedFetchSlackChannels, + effectiveGhostText, + ]) // Handle enter key press - apply and execute suggestions const handleEnter = useCallback(() => { - if (selectedSuggestion < 0 || suggestions.length === 0) return; - const suggestion = suggestions[selectedSuggestion]; - if (suggestionType === 'command' && selectedSuggestion < suggestions.length) { + if (selectedSuggestion < 0 || suggestions.length === 0) return + + const suggestion = suggestions[selectedSuggestion] + + if ( + suggestionType === 'command' && + selectedSuggestion < suggestions.length + ) { if (suggestion) { - applyCommandSuggestion(suggestion, true, - // execute on return - commands, onInputChange, setCursorOffset, onSubmit); - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + applyCommandSuggestion( + suggestion, + true, // execute on return + commands, + onInputChange, + setCursorOffset, + onSubmit, + ) + debouncedFetchFileSuggestions.cancel() + clearSuggestions() } - } else if (suggestionType === 'custom-title' && selectedSuggestion < suggestions.length) { + } else if ( + suggestionType === 'custom-title' && + selectedSuggestion < suggestions.length + ) { // Apply custom title and execute /resume command with sessionId if (suggestion) { - const newInput = buildResumeInputFromSuggestion(suggestion); - onInputChange(newInput); - setCursorOffset(newInput.length); - onSubmit(newInput, /* isSubmittingSlashCommand */true); - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + const newInput = buildResumeInputFromSuggestion(suggestion) + onInputChange(newInput) + setCursorOffset(newInput.length) + onSubmit(newInput, /* isSubmittingSlashCommand */ true) + debouncedFetchFileSuggestions.cancel() + clearSuggestions() } - } else if (suggestionType === 'shell' && selectedSuggestion < suggestions.length) { - const suggestion = suggestions[selectedSuggestion]; + } else if ( + suggestionType === 'shell' && + selectedSuggestion < suggestions.length + ) { + const suggestion = suggestions[selectedSuggestion] if (suggestion) { - const metadata = suggestion.metadata as { - completionType: ShellCompletionType; - } | undefined; - applyShellSuggestion(suggestion, input, cursorOffset, onInputChange, setCursorOffset, metadata?.completionType); - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + const metadata = suggestion.metadata as + | { completionType: ShellCompletionType } + | undefined + applyShellSuggestion( + suggestion, + input, + cursorOffset, + onInputChange, + setCursorOffset, + metadata?.completionType, + ) + debouncedFetchFileSuggestions.cancel() + clearSuggestions() } - } else if (suggestionType === 'agent' && selectedSuggestion < suggestions.length && suggestion?.id?.startsWith('dm-')) { - applyTriggerSuggestion(suggestion, input, cursorOffset, DM_MEMBER_RE, onInputChange, setCursorOffset); - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - } else if (suggestionType === 'slack-channel' && selectedSuggestion < suggestions.length) { + } else if ( + suggestionType === 'agent' && + selectedSuggestion < suggestions.length && + suggestion?.id?.startsWith('dm-') + ) { + applyTriggerSuggestion( + suggestion, + input, + cursorOffset, + DM_MEMBER_RE, + onInputChange, + setCursorOffset, + ) + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + } else if ( + suggestionType === 'slack-channel' && + selectedSuggestion < suggestions.length + ) { if (suggestion) { - applyTriggerSuggestion(suggestion, input, cursorOffset, HASH_CHANNEL_RE, onInputChange, setCursorOffset); - debouncedFetchSlackChannels.cancel(); - clearSuggestions(); + applyTriggerSuggestion( + suggestion, + input, + cursorOffset, + HASH_CHANNEL_RE, + onInputChange, + setCursorOffset, + ) + debouncedFetchSlackChannels.cancel() + clearSuggestions() } - } else if (suggestionType === 'file' && selectedSuggestion < suggestions.length) { + } else if ( + suggestionType === 'file' && + selectedSuggestion < suggestions.length + ) { // Extract completion token directly when needed - const completionInfo = extractCompletionToken(input, cursorOffset, true); + const completionInfo = extractCompletionToken(input, cursorOffset, true) if (completionInfo) { if (suggestion) { - const hasAtPrefix = completionInfo.token.startsWith('@'); - const needsQuotes = suggestion.displayText.includes(' '); + const hasAtPrefix = completionInfo.token.startsWith('@') + const needsQuotes = suggestion.displayText.includes(' ') const replacementValue = formatReplacementValue({ displayText: suggestion.displayText, mode, hasAtPrefix, needsQuotes, isQuoted: completionInfo.isQuoted, - isComplete: true // complete suggestion - }); - applyFileSuggestion(replacementValue, input, completionInfo.token, completionInfo.startPos, onInputChange, setCursorOffset); - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + isComplete: true, // complete suggestion + }) + + applyFileSuggestion( + replacementValue, + input, + completionInfo.token, + completionInfo.startPos, + onInputChange, + setCursorOffset, + ) + debouncedFetchFileSuggestions.cancel() + clearSuggestions() } } - } else if (suggestionType === 'directory' && selectedSuggestion < suggestions.length) { + } else if ( + suggestionType === 'directory' && + selectedSuggestion < suggestions.length + ) { if (suggestion) { // In command context (e.g., /add-dir), Enter submits the command // rather than applying the directory suggestion. Just clear // suggestions and let the submit handler process the current input. if (isCommandInput(input)) { - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); - return; + debouncedFetchFileSuggestions.cancel() + clearSuggestions() + return } // General path completion: replace the path token - const completionTokenWithAt = extractCompletionToken(input, cursorOffset, true); - const completionToken = completionTokenWithAt ?? extractCompletionToken(input, cursorOffset, false); + const completionTokenWithAt = extractCompletionToken( + input, + cursorOffset, + true, + ) + const completionToken = + completionTokenWithAt ?? + extractCompletionToken(input, cursorOffset, false) + if (completionToken) { - const isDir = isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory'; - const result = applyDirectorySuggestion(input, suggestion.id, completionToken.startPos, completionToken.token.length, isDir); - onInputChange(result.newInput); - setCursorOffset(result.cursorPos); + const isDir = + isPathMetadata(suggestion.metadata) && + suggestion.metadata.type === 'directory' + const result = applyDirectorySuggestion( + input, + suggestion.id, + completionToken.startPos, + completionToken.token.length, + isDir, + ) + onInputChange(result.newInput) + setCursorOffset(result.cursorPos) } // If no completion token found (e.g., cursor after space), don't modify input // to avoid data loss - just clear suggestions - debouncedFetchFileSuggestions.cancel(); - clearSuggestions(); + debouncedFetchFileSuggestions.cancel() + clearSuggestions() } } - }, [suggestions, selectedSuggestion, suggestionType, commands, input, cursorOffset, mode, onInputChange, setCursorOffset, onSubmit, clearSuggestions, debouncedFetchFileSuggestions, debouncedFetchSlackChannels]); + }, [ + suggestions, + selectedSuggestion, + suggestionType, + commands, + input, + cursorOffset, + mode, + onInputChange, + setCursorOffset, + onSubmit, + clearSuggestions, + debouncedFetchFileSuggestions, + debouncedFetchSlackChannels, + ]) // Handler for autocomplete:accept - accepts current suggestion via Tab or Right Arrow const handleAutocompleteAccept = useCallback(() => { - void handleTab(); - }, [handleTab]); + void handleTab() + }, [handleTab]) // Handler for autocomplete:dismiss - clears suggestions and prevents re-triggering const handleAutocompleteDismiss = useCallback(() => { - debouncedFetchFileSuggestions.cancel(); - debouncedFetchSlackChannels.cancel(); - clearSuggestions(); + debouncedFetchFileSuggestions.cancel() + debouncedFetchSlackChannels.cancel() + clearSuggestions() // Remember the input when dismissed to prevent immediate re-triggering - dismissedForInputRef.current = input; - }, [debouncedFetchFileSuggestions, debouncedFetchSlackChannels, clearSuggestions, input]); + dismissedForInputRef.current = input + }, [ + debouncedFetchFileSuggestions, + debouncedFetchSlackChannels, + clearSuggestions, + input, + ]) // Handler for autocomplete:previous - selects previous suggestion const handleAutocompletePrevious = useCallback(() => { setSuggestionsState(prev => ({ ...prev, - selectedSuggestion: prev.selectedSuggestion <= 0 ? suggestions.length - 1 : prev.selectedSuggestion - 1 - })); - }, [suggestions.length, setSuggestionsState]); + selectedSuggestion: + prev.selectedSuggestion <= 0 + ? suggestions.length - 1 + : prev.selectedSuggestion - 1, + })) + }, [suggestions.length, setSuggestionsState]) // Handler for autocomplete:next - selects next suggestion const handleAutocompleteNext = useCallback(() => { setSuggestionsState(prev => ({ ...prev, - selectedSuggestion: prev.selectedSuggestion >= suggestions.length - 1 ? 0 : prev.selectedSuggestion + 1 - })); - }, [suggestions.length, setSuggestionsState]); + selectedSuggestion: + prev.selectedSuggestion >= suggestions.length - 1 + ? 0 + : prev.selectedSuggestion + 1, + })) + }, [suggestions.length, setSuggestionsState]) // Autocomplete context keybindings - only active when suggestions are visible - const autocompleteHandlers = useMemo(() => ({ - 'autocomplete:accept': handleAutocompleteAccept, - 'autocomplete:dismiss': handleAutocompleteDismiss, - 'autocomplete:previous': handleAutocompletePrevious, - 'autocomplete:next': handleAutocompleteNext - }), [handleAutocompleteAccept, handleAutocompleteDismiss, handleAutocompletePrevious, handleAutocompleteNext]); + const autocompleteHandlers = useMemo( + () => ({ + 'autocomplete:accept': handleAutocompleteAccept, + 'autocomplete:dismiss': handleAutocompleteDismiss, + 'autocomplete:previous': handleAutocompletePrevious, + 'autocomplete:next': handleAutocompleteNext, + }), + [ + handleAutocompleteAccept, + handleAutocompleteDismiss, + handleAutocompletePrevious, + handleAutocompleteNext, + ], + ) // Register autocomplete as an overlay so CancelRequestHandler defers ESC handling // This ensures ESC dismisses autocomplete before canceling running tasks - const isAutocompleteActive = suggestions.length > 0 || !!effectiveGhostText; - const isModalOverlayActive = useIsModalOverlayActive(); - useRegisterOverlay('autocomplete', isAutocompleteActive); + const isAutocompleteActive = suggestions.length > 0 || !!effectiveGhostText + const isModalOverlayActive = useIsModalOverlayActive() + useRegisterOverlay('autocomplete', isAutocompleteActive) // Register Autocomplete context so it appears in activeContexts for other handlers. // This allows Chat's resolver to see Autocomplete and defer to its bindings for up/down. - useRegisterKeybindingContext('Autocomplete', isAutocompleteActive); + useRegisterKeybindingContext('Autocomplete', isAutocompleteActive) // Disable autocomplete keybindings when a modal overlay (e.g., DiffDialog) is active, // so escape reaches the overlay's handler instead of dismissing autocomplete useKeybindings(autocompleteHandlers, { context: 'Autocomplete', - isActive: isAutocompleteActive && !isModalOverlayActive - }); + isActive: isAutocompleteActive && !isModalOverlayActive, + }) + function acceptSuggestionText(text: string): void { - const detectedMode = getModeFromInput(text); + const detectedMode = getModeFromInput(text) if (detectedMode !== 'prompt' && onModeChange) { - onModeChange(detectedMode); - const stripped = getValueFromInput(text); - onInputChange(stripped); - setCursorOffset(stripped.length); + onModeChange(detectedMode) + const stripped = getValueFromInput(text) + onInputChange(stripped) + setCursorOffset(stripped.length) } else { - onInputChange(text); - setCursorOffset(text.length); + onInputChange(text) + setCursorOffset(text.length) } } @@ -1294,13 +1782,13 @@ export function useTypeahead({ const handleKeyDown = (e: KeyboardEvent): void => { // Handle right arrow to accept prompt suggestion ghost text if (e.key === 'right' && !isViewingTeammate) { - const suggestionText = promptSuggestion.text; - const suggestionShownAt = promptSuggestion.shownAt; + const suggestionText = promptSuggestion.text + const suggestionShownAt = promptSuggestion.shownAt if (suggestionText && suggestionShownAt > 0 && input === '') { - markAccepted(); - acceptSuggestionText(suggestionText); - e.stopImmediatePropagation(); - return; + markAccepted() + acceptSuggestionText(suggestionText) + e.stopImmediatePropagation() + return } } @@ -1309,69 +1797,78 @@ export function useTypeahead({ if (e.key === 'tab' && !e.shift) { // Skip if autocomplete is handling this (suggestions or ghost text exist) if (suggestions.length > 0 || effectiveGhostText) { - return; + return } // Accept prompt suggestion if it exists in AppState - const suggestionText = promptSuggestion.text; - const suggestionShownAt = promptSuggestion.shownAt; - if (suggestionText && suggestionShownAt > 0 && input === '' && !isViewingTeammate) { - e.preventDefault(); - markAccepted(); - acceptSuggestionText(suggestionText); - return; + const suggestionText = promptSuggestion.text + const suggestionShownAt = promptSuggestion.shownAt + if ( + suggestionText && + suggestionShownAt > 0 && + input === '' && + !isViewingTeammate + ) { + e.preventDefault() + markAccepted() + acceptSuggestionText(suggestionText) + return } // Remind user about thinking toggle shortcut if empty input if (input.trim() === '') { - e.preventDefault(); + e.preventDefault() addNotification({ key: 'thinking-toggle-hint', - jsx: + jsx: ( + Use {thinkingToggleShortcut} to toggle thinking - , + + ), priority: 'immediate', - timeoutMs: 3000 - }); + timeoutMs: 3000, + }) } - return; + return } // Only continue with navigation if we have suggestions - if (suggestions.length === 0) return; + if (suggestions.length === 0) return // Handle Ctrl-N/P for navigation (arrows handled by keybindings) // Skip if we're in the middle of a chord sequence to allow chords like ctrl+f n - const hasPendingChord = keybindingContext?.pendingChord != null; + const hasPendingChord = keybindingContext?.pendingChord != null if (e.ctrl && e.key === 'n' && !hasPendingChord) { - e.preventDefault(); - handleAutocompleteNext(); - return; + e.preventDefault() + handleAutocompleteNext() + return } + if (e.ctrl && e.key === 'p' && !hasPendingChord) { - e.preventDefault(); - handleAutocompletePrevious(); - return; + e.preventDefault() + handleAutocompletePrevious() + return } // Handle selection and execution via return/enter // Shift+Enter and Meta+Enter insert newlines (handled by useTextInput), // so don't accept the suggestion for those. if (e.key === 'return' && !e.shift && !e.meta) { - e.preventDefault(); - handleEnter(); + e.preventDefault() + handleEnter() } - }; + } // Backward-compat bridge: PromptInput doesn't yet wire handleKeyDown to // . Subscribe via useInput and adapt InputEvent → // KeyboardEvent until the consumer is migrated (separate PR). // TODO(onKeyDown-migration): remove once PromptInput passes handleKeyDown. useInput((_input, _key, event) => { - const kbEvent = new KeyboardEvent(event.keypress); - handleKeyDown(kbEvent); + const kbEvent = new KeyboardEvent(event.keypress) + handleKeyDown(kbEvent) if (kbEvent.didStopImmediatePropagation()) { - event.stopImmediatePropagation(); + event.stopImmediatePropagation() } - }); + }) + return { suggestions, selectedSuggestion, @@ -1379,6 +1876,6 @@ export function useTypeahead({ maxColumnWidth, commandArgumentHint, inlineGhostText: effectiveGhostText, - handleKeyDown - }; + handleKeyDown, + } } diff --git a/src/hooks/useVoiceIntegration.tsx b/src/hooks/useVoiceIntegration.tsx index 47de37c93..7cedb1c0f 100644 --- a/src/hooks/useVoiceIntegration.tsx +++ b/src/hooks/useVoiceIntegration.tsx @@ -1,75 +1,85 @@ -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { useNotifications } from '../context/notifications.js'; -import { useIsModalOverlayActive } from '../context/overlayContext.js'; -import { useGetVoiceState, useSetVoiceState, useVoiceState } from '../context/voice.js'; -import { KeyboardEvent } from '../ink/events/keyboard-event.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useNotifications } from '../context/notifications.js' +import { useIsModalOverlayActive } from '../context/overlayContext.js' +import { + useGetVoiceState, + useSetVoiceState, + useVoiceState, +} from '../context/voice.js' +import { KeyboardEvent } from '../ink/events/keyboard-event.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- backward-compat bridge until REPL wires handleKeyDown to -import { useInput } from '../ink.js'; -import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js'; -import { keystrokesEqual } from '../keybindings/resolver.js'; -import type { ParsedKeystroke } from '../keybindings/types.js'; -import { normalizeFullWidthSpace } from '../utils/stringUtils.js'; -import { useVoiceEnabled } from './useVoiceEnabled.js'; +import { useInput } from '../ink.js' +import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js' +import { keystrokesEqual } from '../keybindings/resolver.js' +import type { ParsedKeystroke } from '../keybindings/types.js' +import { normalizeFullWidthSpace } from '../utils/stringUtils.js' +import { useVoiceEnabled } from './useVoiceEnabled.js' // Dead code elimination: conditional import for voice input hook. /* eslint-disable @typescript-eslint/no-require-imports */ // Capture the module namespace, not the function: spyOn() mutates the module // object, so `voiceNs.useVoice(...)` resolves to the spy even if this module // was loaded before the spy was installed (test ordering independence). -const voiceNs: { - useVoice: typeof import('./useVoice.js').useVoice; -} = feature('VOICE_MODE') ? require('./useVoice.js') : { - useVoice: ({ - enabled: _e - }: { - onTranscript: (t: string) => void; - enabled: boolean; - }) => ({ - state: 'idle' as const, - handleKeyEvent: (_fallbackMs?: number) => {} - }) -}; +const voiceNs: { useVoice: typeof import('./useVoice.js').useVoice } = feature( + 'VOICE_MODE', +) + ? require('./useVoice.js') + : { + useVoice: ({ + enabled: _e, + }: { + onTranscript: (t: string) => void + enabled: boolean + }) => ({ + state: 'idle' as const, + handleKeyEvent: (_fallbackMs?: number) => {}, + }), + } /* eslint-enable @typescript-eslint/no-require-imports */ // Maximum gap (ms) between key presses to count as held (auto-repeat). // Terminal auto-repeat fires every 30-80ms; 120ms covers jitter while // excluding normal typing speed (100-300ms between keystrokes). -const RAPID_KEY_GAP_MS = 120; +const RAPID_KEY_GAP_MS = 120 // Fallback (ms) for modifier-combo first-press activation. Must match // FIRST_PRESS_FALLBACK_MS in useVoice.ts. Covers the max OS initial // key-repeat delay (~2s on macOS with slider at "Long") so holding a // modifier combo doesn't fragment into two sessions when the first // auto-repeat arrives after the default 600ms REPEAT_FALLBACK_MS. -const MODIFIER_FIRST_PRESS_FALLBACK_MS = 2000; +const MODIFIER_FIRST_PRESS_FALLBACK_MS = 2000 // Number of rapid consecutive key events required to activate voice. // Only applies to bare-char bindings (space, v, etc.) where a single press // could be normal typing. Modifier combos activate on the first press. -const HOLD_THRESHOLD = 5; +const HOLD_THRESHOLD = 5 // Number of rapid key events to start showing warmup feedback. -const WARMUP_THRESHOLD = 2; +const WARMUP_THRESHOLD = 2 // Match a KeyboardEvent against a ParsedKeystroke. Replaces the legacy // matchesKeystroke(input, Key, ...) path which assumed useInput's raw // `input` arg — KeyboardEvent.key holds normalized names (e.g. 'space', // 'f9') that getKeyName() didn't handle, so modifier combos and f-keys // silently failed to match after the onKeyDown migration (#23524). -function matchesKeyboardEvent(e: KeyboardEvent, target: ParsedKeystroke): boolean { +function matchesKeyboardEvent( + e: KeyboardEvent, + target: ParsedKeystroke, +): boolean { // KeyboardEvent stores key names; ParsedKeystroke stores ' ' for space // and 'enter' for return (see parser.ts case 'space'/'return'). - const key = e.key === 'space' ? ' ' : e.key === 'return' ? 'enter' : e.key.toLowerCase(); - if (key !== target.key) return false; - if (e.ctrl !== target.ctrl) return false; - if (e.shift !== target.shift) return false; + const key = + e.key === 'space' ? ' ' : e.key === 'return' ? 'enter' : e.key.toLowerCase() + if (key !== target.key) return false + if (e.ctrl !== target.ctrl) return false + if (e.shift !== target.shift) return false // KeyboardEvent.meta folds alt|option (terminal limitation — esc-prefix); // ParsedKeystroke has both alt and meta as aliases for the same thing. - if (e.meta !== (target.alt || target.meta)) return false; - if (e.superKey !== target.super) return false; - return true; + if (e.meta !== (target.alt || target.meta)) return false + if (e.superKey !== target.super) return false + return true } // Hardcoded default for when there's no KeybindingProvider at all (e.g. @@ -82,60 +92,61 @@ const DEFAULT_VOICE_KEYSTROKE: ParsedKeystroke = { alt: false, shift: false, meta: false, - super: false -}; + super: false, +} + type InsertTextHandle = { - insert: (text: string) => void; - setInputWithCursor: (value: string, cursor: number) => void; - cursorOffset: number; -}; + insert: (text: string) => void + setInputWithCursor: (value: string, cursor: number) => void + cursorOffset: number +} + type UseVoiceIntegrationArgs = { - setInputValueRaw: React.Dispatch>; - inputValueRef: React.RefObject; - insertTextRef: React.RefObject; -}; -type InterimRange = { - start: number; - end: number; -}; + setInputValueRaw: React.Dispatch> + inputValueRef: React.RefObject + insertTextRef: React.RefObject +} + +type InterimRange = { start: number; end: number } + type StripOpts = { // Which char to strip (the configured hold key). Defaults to space. - char?: string; + char?: string // Capture the voice prefix/suffix anchor at the stripped position. - anchor?: boolean; + anchor?: boolean // Minimum trailing count to leave behind — prevents stripping the // intentional warmup chars when defensively cleaning up leaks. - floor?: number; -}; + floor?: number +} + type UseVoiceIntegrationResult = { // Returns the number of trailing chars remaining after stripping. - stripTrailing: (maxStrip: number, opts?: StripOpts) => number; + stripTrailing: (maxStrip: number, opts?: StripOpts) => number // Undo the gap space and reset anchor refs after a failed voice activation. - resetAnchor: () => void; - handleKeyEvent: (fallbackMs?: number) => void; - interimRange: InterimRange | null; -}; + resetAnchor: () => void + handleKeyEvent: (fallbackMs?: number) => void + interimRange: InterimRange | null +} + export function useVoiceIntegration({ setInputValueRaw, inputValueRef, - insertTextRef + insertTextRef, }: UseVoiceIntegrationArgs): UseVoiceIntegrationResult { - const { - addNotification - } = useNotifications(); + const { addNotification } = useNotifications() // Tracks the input content before/after the cursor when voice starts, // so interim transcripts can be inserted at the cursor position without // clobbering surrounding user text. - const voicePrefixRef = useRef(null); - const voiceSuffixRef = useRef(''); + const voicePrefixRef = useRef(null) + const voiceSuffixRef = useRef('') // Tracks the last input value this hook wrote (via anchor, interim effect, // or handleVoiceTranscript). If inputValueRef.current diverges, the user // submitted or edited — both write paths bail to avoid clobbering. This is // the only guard that correctly handles empty-prefix-empty-suffix: a // startsWith('')/endsWith('') check vacuously passes, and a length check // can't distinguish a cleared input from a never-set one. - const lastSetInputRef = useRef(null); + const lastSetInputRef = useRef(null) // Strip trailing hold-key chars (and optionally capture the voice // anchor). Called during warmup (to clean up chars that leaked past @@ -149,53 +160,59 @@ export function useVoiceIntegration({ // defensive cleanup only removes leaks). Returns the number of // trailing chars remaining after stripping. When nothing changes, no // state update is performed. - const stripTrailing = useCallback((maxStrip: number, { - char = ' ', - anchor = false, - floor = 0 - }: StripOpts = {}) => { - const prev = inputValueRef.current; - const offset = insertTextRef.current?.cursorOffset ?? prev.length; - const beforeCursor = prev.slice(0, offset); - const afterCursor = prev.slice(offset); - // When the hold key is space, also count full-width spaces (U+3000) - // that a CJK IME may have inserted for the same physical key. - // U+3000 is BMP single-code-unit so indices align with beforeCursor. - const scan = char === ' ' ? normalizeFullWidthSpace(beforeCursor) : beforeCursor; - let trailing = 0; - while (trailing < scan.length && scan[scan.length - 1 - trailing] === char) { - trailing++; - } - const stripCount = Math.max(0, Math.min(trailing - floor, maxStrip)); - const remaining = trailing - stripCount; - const stripped = beforeCursor.slice(0, beforeCursor.length - stripCount); - // When anchoring with a non-space suffix, insert a gap space so the - // waveform cursor sits on the gap instead of covering the first - // suffix letter. The interim transcript effect maintains this same - // structure (prefix + leading + interim + trailing + suffix), so - // the gap is seamless once transcript text arrives. - // Always overwrite on anchor — if a prior activation failed to start - // voice (voiceState stayed 'idle'), the cleanup effect didn't fire and - // the old anchor is stale. anchor=true is only passed on the single - // activation call, never during recording, so overwrite is safe. - let gap = ''; - if (anchor) { - voicePrefixRef.current = stripped; - voiceSuffixRef.current = afterCursor; - if (afterCursor.length > 0 && !/^\s/.test(afterCursor)) { - gap = ' '; + const stripTrailing = useCallback( + ( + maxStrip: number, + { char = ' ', anchor = false, floor = 0 }: StripOpts = {}, + ) => { + const prev = inputValueRef.current + const offset = insertTextRef.current?.cursorOffset ?? prev.length + const beforeCursor = prev.slice(0, offset) + const afterCursor = prev.slice(offset) + // When the hold key is space, also count full-width spaces (U+3000) + // that a CJK IME may have inserted for the same physical key. + // U+3000 is BMP single-code-unit so indices align with beforeCursor. + const scan = + char === ' ' ? normalizeFullWidthSpace(beforeCursor) : beforeCursor + let trailing = 0 + while ( + trailing < scan.length && + scan[scan.length - 1 - trailing] === char + ) { + trailing++ } - } - const newValue = stripped + gap + afterCursor; - if (anchor) lastSetInputRef.current = newValue; - if (newValue === prev && stripCount === 0) return remaining; - if (insertTextRef.current) { - insertTextRef.current.setInputWithCursor(newValue, stripped.length); - } else { - setInputValueRaw(newValue); - } - return remaining; - }, [setInputValueRaw, inputValueRef, insertTextRef]); + const stripCount = Math.max(0, Math.min(trailing - floor, maxStrip)) + const remaining = trailing - stripCount + const stripped = beforeCursor.slice(0, beforeCursor.length - stripCount) + // When anchoring with a non-space suffix, insert a gap space so the + // waveform cursor sits on the gap instead of covering the first + // suffix letter. The interim transcript effect maintains this same + // structure (prefix + leading + interim + trailing + suffix), so + // the gap is seamless once transcript text arrives. + // Always overwrite on anchor — if a prior activation failed to start + // voice (voiceState stayed 'idle'), the cleanup effect didn't fire and + // the old anchor is stale. anchor=true is only passed on the single + // activation call, never during recording, so overwrite is safe. + let gap = '' + if (anchor) { + voicePrefixRef.current = stripped + voiceSuffixRef.current = afterCursor + if (afterCursor.length > 0 && !/^\s/.test(afterCursor)) { + gap = ' ' + } + } + const newValue = stripped + gap + afterCursor + if (anchor) lastSetInputRef.current = newValue + if (newValue === prev && stripCount === 0) return remaining + if (insertTextRef.current) { + insertTextRef.current.setInputWithCursor(newValue, stripped.length) + } else { + setInputValueRaw(newValue) + } + return remaining + }, + [setInputValueRaw, inputValueRef, insertTextRef], + ) // Undo the gap space inserted by stripTrailing(..., {anchor:true}) and // reset the voice prefix/suffix refs. Called when voice activation fails @@ -204,110 +221,124 @@ export function useVoiceIntegration({ // reach the stale anchor. Without this, the gap space and stale refs // persist in the input. const resetAnchor = useCallback(() => { - const prefix = voicePrefixRef.current; - if (prefix === null) return; - const suffix = voiceSuffixRef.current; - voicePrefixRef.current = null; - voiceSuffixRef.current = ''; - const restored = prefix + suffix; + const prefix = voicePrefixRef.current + if (prefix === null) return + const suffix = voiceSuffixRef.current + voicePrefixRef.current = null + voiceSuffixRef.current = '' + const restored = prefix + suffix if (insertTextRef.current) { - insertTextRef.current.setInputWithCursor(restored, prefix.length); + insertTextRef.current.setInputWithCursor(restored, prefix.length) } else { - setInputValueRaw(restored); + setInputValueRaw(restored) } - }, [setInputValueRaw, insertTextRef]); + }, [setInputValueRaw, insertTextRef]) // Voice state selectors. useVoiceEnabled = user intent (settings) + // auth + GB kill-switch, with the auth half memoized on authVersion so // render loops never hit a cold keychain spawn. // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false; - const voiceState = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s => s.voiceState) : 'idle' as const; - const voiceInterimTranscript: string = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s_0 => s_0.voiceInterimTranscript) as string : ''; + const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false + const voiceState = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceState) + : ('idle' as const) + const voiceInterimTranscript = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceInterimTranscript) + : '' // Set the voice anchor for focus mode (where recording starts via terminal // focus, not key hold). Key-hold sets the anchor in stripTrailing. useEffect(() => { - if (!feature('VOICE_MODE')) return; + if (!feature('VOICE_MODE')) return if (voiceState === 'recording' && voicePrefixRef.current === null) { - const input = inputValueRef.current; - const offset_0 = insertTextRef.current?.cursorOffset ?? input.length; - voicePrefixRef.current = input.slice(0, offset_0); - voiceSuffixRef.current = input.slice(offset_0); - lastSetInputRef.current = input; + const input = inputValueRef.current + const offset = insertTextRef.current?.cursorOffset ?? input.length + voicePrefixRef.current = input.slice(0, offset) + voiceSuffixRef.current = input.slice(offset) + lastSetInputRef.current = input } if (voiceState === 'idle') { - voicePrefixRef.current = null; - voiceSuffixRef.current = ''; - lastSetInputRef.current = null; + voicePrefixRef.current = null + voiceSuffixRef.current = '' + lastSetInputRef.current = null } - }, [voiceState, inputValueRef, insertTextRef]); + }, [voiceState, inputValueRef, insertTextRef]) // Live-update the prompt input with the interim transcript as voice // transcribes speech. The prefix (user-typed text before the cursor) is // preserved and the transcript is inserted between prefix and suffix. useEffect(() => { - if (!feature('VOICE_MODE')) return; - if (voicePrefixRef.current === null) return; - const prefix_0 = voicePrefixRef.current; - const suffix_0 = voiceSuffixRef.current; + if (!feature('VOICE_MODE')) return + if (voicePrefixRef.current === null) return + const prefix = voicePrefixRef.current + const suffix = voiceSuffixRef.current // Submit race: if the input isn't what this hook last set it to, the // user submitted (clearing it) or edited it. voicePrefixRef is only // cleared on voiceState→idle, so it's still set during the 'processing' // window between CloseStream and WS close — this catches refined // TranscriptText arriving then and re-filling a cleared input. - if (inputValueRef.current !== lastSetInputRef.current) return; - const needsSpace = prefix_0.length > 0 && !/\s$/.test(prefix_0) && voiceInterimTranscript.length > 0; + if (inputValueRef.current !== lastSetInputRef.current) return + const needsSpace = + prefix.length > 0 && + !/\s$/.test(prefix) && + voiceInterimTranscript.length > 0 // Don't gate on voiceInterimTranscript.length -- when interim clears to '' // after handleVoiceTranscript sets the final text, the trailing space // between prefix and suffix must still be preserved. - const needsTrailingSpace = suffix_0.length > 0 && !/^\s/.test(suffix_0); - const leadingSpace = needsSpace ? ' ' : ''; - const trailingSpace = needsTrailingSpace ? ' ' : ''; - const newValue_0 = prefix_0 + leadingSpace + voiceInterimTranscript + trailingSpace + suffix_0; + const needsTrailingSpace = suffix.length > 0 && !/^\s/.test(suffix) + const leadingSpace = needsSpace ? ' ' : '' + const trailingSpace = needsTrailingSpace ? ' ' : '' + const newValue = + prefix + leadingSpace + voiceInterimTranscript + trailingSpace + suffix // Position cursor after the transcribed text (before suffix) - const cursorPos = prefix_0.length + leadingSpace.length + voiceInterimTranscript.length; + const cursorPos = + prefix.length + leadingSpace.length + voiceInterimTranscript.length if (insertTextRef.current) { - insertTextRef.current.setInputWithCursor(newValue_0, cursorPos); + insertTextRef.current.setInputWithCursor(newValue, cursorPos) } else { - setInputValueRaw(newValue_0); + setInputValueRaw(newValue) } - lastSetInputRef.current = newValue_0; - }, [voiceInterimTranscript, setInputValueRaw, inputValueRef, insertTextRef]); - const handleVoiceTranscript = useCallback((text: string) => { - if (!feature('VOICE_MODE')) return; - const prefix_1 = voicePrefixRef.current; - // No voice anchor — voice was reset (or never started). Nothing to do. - if (prefix_1 === null) return; - const suffix_1 = voiceSuffixRef.current; - // Submit race: finishRecording() → user presses Enter (input cleared) - // → WebSocket close → this callback fires with stale prefix/suffix. - // If the input isn't what this hook last set (via the interim effect - // or anchor), the user submitted or edited — don't re-fill. Comparing - // against `text.length` would false-positive when the final is longer - // than the interim (ASR routinely adds punctuation/corrections). - if (inputValueRef.current !== lastSetInputRef.current) return; - const needsSpace_0 = prefix_1.length > 0 && !/\s$/.test(prefix_1) && text.length > 0; - const needsTrailingSpace_0 = suffix_1.length > 0 && !/^\s/.test(suffix_1) && text.length > 0; - const leadingSpace_0 = needsSpace_0 ? ' ' : ''; - const trailingSpace_0 = needsTrailingSpace_0 ? ' ' : ''; - const newInput = prefix_1 + leadingSpace_0 + text + trailingSpace_0 + suffix_1; - // Position cursor after the transcribed text (before suffix) - const cursorPos_0 = prefix_1.length + leadingSpace_0.length + text.length; - if (insertTextRef.current) { - insertTextRef.current.setInputWithCursor(newInput, cursorPos_0); - } else { - setInputValueRaw(newInput); - } - lastSetInputRef.current = newInput; - // Update the prefix to include this chunk so focus mode can continue - // appending subsequent transcripts after it. - voicePrefixRef.current = prefix_1 + leadingSpace_0 + text; - }, [setInputValueRaw, inputValueRef, insertTextRef]); + lastSetInputRef.current = newValue + }, [voiceInterimTranscript, setInputValueRaw, inputValueRef, insertTextRef]) + + const handleVoiceTranscript = useCallback( + (text: string) => { + if (!feature('VOICE_MODE')) return + const prefix = voicePrefixRef.current + // No voice anchor — voice was reset (or never started). Nothing to do. + if (prefix === null) return + const suffix = voiceSuffixRef.current + // Submit race: finishRecording() → user presses Enter (input cleared) + // → WebSocket close → this callback fires with stale prefix/suffix. + // If the input isn't what this hook last set (via the interim effect + // or anchor), the user submitted or edited — don't re-fill. Comparing + // against `text.length` would false-positive when the final is longer + // than the interim (ASR routinely adds punctuation/corrections). + if (inputValueRef.current !== lastSetInputRef.current) return + const needsSpace = + prefix.length > 0 && !/\s$/.test(prefix) && text.length > 0 + const needsTrailingSpace = + suffix.length > 0 && !/^\s/.test(suffix) && text.length > 0 + const leadingSpace = needsSpace ? ' ' : '' + const trailingSpace = needsTrailingSpace ? ' ' : '' + const newInput = prefix + leadingSpace + text + trailingSpace + suffix + // Position cursor after the transcribed text (before suffix) + const cursorPos = prefix.length + leadingSpace.length + text.length + if (insertTextRef.current) { + insertTextRef.current.setInputWithCursor(newInput, cursorPos) + } else { + setInputValueRaw(newInput) + } + lastSetInputRef.current = newInput + // Update the prefix to include this chunk so focus mode can continue + // appending subsequent transcripts after it. + voicePrefixRef.current = prefix + leadingSpace + text + }, + [setInputValueRaw, inputValueRef, insertTextRef], + ) + const voice = voiceNs.useVoice({ onTranscript: handleVoiceTranscript, onError: (message: string) => { @@ -316,34 +347,35 @@ export function useVoiceIntegration({ text: message, color: 'error', priority: 'immediate', - timeoutMs: 10_000 - }); + timeoutMs: 10_000, + }) }, enabled: voiceEnabled, - focusMode: false - }); + focusMode: false, + }) // Compute the character range of interim (not-yet-finalized) transcript // text in the input value, so the UI can dim it. const interimRange = useMemo((): InterimRange | null => { - if (!feature('VOICE_MODE')) return null; - if (voicePrefixRef.current === null) return null; - if (voiceInterimTranscript.length === 0) return null; - const prefix_2 = voicePrefixRef.current; - const needsSpace_1 = prefix_2.length > 0 && !/\s$/.test(prefix_2) && voiceInterimTranscript.length > 0; - const start = prefix_2.length + (needsSpace_1 ? 1 : 0); - const end = start + voiceInterimTranscript.length; - return { - start, - end - }; - }, [voiceInterimTranscript]); + if (!feature('VOICE_MODE')) return null + if (voicePrefixRef.current === null) return null + if (voiceInterimTranscript.length === 0) return null + const prefix = voicePrefixRef.current + const needsSpace = + prefix.length > 0 && + !/\s$/.test(prefix) && + voiceInterimTranscript.length > 0 + const start = prefix.length + (needsSpace ? 1 : 0) + const end = start + voiceInterimTranscript.length + return { start, end } + }, [voiceInterimTranscript]) + return { stripTrailing, resetAnchor, handleKeyEvent: voice.handleKeyEvent, - interimRange - }; + interimRange, + } } /** @@ -374,24 +406,23 @@ export function useVoiceKeybindingHandler({ voiceHandleKeyEvent, stripTrailing, resetAnchor, - isActive + isActive, }: { - voiceHandleKeyEvent: (fallbackMs?: number) => void; - stripTrailing: (maxStrip: number, opts?: StripOpts) => number; - resetAnchor: () => void; - isActive: boolean; -}): { - handleKeyDown: (e: KeyboardEvent) => void; -} { - const getVoiceState = useGetVoiceState(); - const setVoiceState = useSetVoiceState(); - const keybindingContext = useOptionalKeybindingContext(); - const isModalOverlayActive = useIsModalOverlayActive(); + voiceHandleKeyEvent: (fallbackMs?: number) => void + stripTrailing: (maxStrip: number, opts?: StripOpts) => number + resetAnchor: () => void + isActive: boolean +}): { handleKeyDown: (e: KeyboardEvent) => void } { + const getVoiceState = useGetVoiceState() + const setVoiceState = useSetVoiceState() + const keybindingContext = useOptionalKeybindingContext() + const isModalOverlayActive = useIsModalOverlayActive() // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false; - const voiceState = feature('VOICE_MODE') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s => s.voiceState) : 'idle'; + const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false + const voiceState = feature('VOICE_MODE') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useVoiceState(s => s.voiceState) + : 'idle' // Find the configured key for voice:pushToTalk from keybinding context. // Forward iteration with last-wins (matching the resolver): if a later @@ -403,22 +434,22 @@ export function useVoiceKeybindingHandler({ // is also bound in Settings/Confirmation/Plugin (select:accept etc.); // without the filter those would null out the default. const voiceKeystroke = useMemo((): ParsedKeystroke | null => { - if (!keybindingContext) return DEFAULT_VOICE_KEYSTROKE; - let result: ParsedKeystroke | null = null; + if (!keybindingContext) return DEFAULT_VOICE_KEYSTROKE + let result: ParsedKeystroke | null = null for (const binding of keybindingContext.bindings) { - if (binding.context !== 'Chat') continue; - if (binding.chord.length !== 1) continue; - const ks = binding.chord[0]; - if (!ks) continue; + if (binding.context !== 'Chat') continue + if (binding.chord.length !== 1) continue + const ks = binding.chord[0] + if (!ks) continue if (binding.action === 'voice:pushToTalk') { - result = ks; + result = ks } else if (result !== null && keystrokesEqual(ks, result)) { // A later binding overrides this chord (null unbind or reassignment) - result = null; + result = null } } - return result; - }, [keybindingContext]); + return result + }, [keybindingContext]) // If the binding is a bare (unmodified) single printable char, terminal // auto-repeat may batch N keystrokes into one input event (e.g. "vvv"), @@ -426,8 +457,18 @@ export function useVoiceKeybindingHandler({ // Modifier combos (meta+k, ctrl+x) also auto-repeat (the letter part // repeats) but don't insert text, so they're swallowed from the first // press with no stripping needed. matchesKeyboardEvent handles those. - const bareChar = voiceKeystroke !== null && voiceKeystroke.key.length === 1 && !voiceKeystroke.ctrl && !voiceKeystroke.alt && !voiceKeystroke.shift && !voiceKeystroke.meta && !voiceKeystroke.super ? voiceKeystroke.key : null; - const rapidCountRef = useRef(0); + const bareChar = + voiceKeystroke !== null && + voiceKeystroke.key.length === 1 && + !voiceKeystroke.ctrl && + !voiceKeystroke.alt && + !voiceKeystroke.shift && + !voiceKeystroke.meta && + !voiceKeystroke.super + ? voiceKeystroke.key + : null + + const rapidCountRef = useRef(0) // How many rapid chars we intentionally let through to the text // input (the first WARMUP_THRESHOLD). The activation strip removes // up to this many + the activation event's potential leak. For the @@ -436,15 +477,15 @@ export function useVoiceKeybindingHandler({ // one pre-existing char if the input already ended in the bound // letter (e.g. "hav" + hold "v" → "ha"). We don't track that // boundary — it's best-effort and the warning says so. - const charsInInputRef = useRef(0); + const charsInInputRef = useRef(0) // Trailing-char count remaining after the activation strip — these // belong to the user's anchored prefix and must be preserved during // recording's defensive leak cleanup. - const recordingFloorRef = useRef(0); + const recordingFloorRef = useRef(0) // True when the current recording was started by key-hold (not focus). // Used to avoid swallowing keypresses during focus-mode recording. - const isHoldActiveRef = useRef(false); - const resetTimerRef = useRef | null>(null); + const isHoldActiveRef = useRef(false) + const resetTimerRef = useRef | null>(null) // Reset hold state as soon as we leave 'recording'. The physical hold // ends when key-repeat stops (state → 'processing'); keeping the ref @@ -452,21 +493,19 @@ export function useVoiceKeybindingHandler({ // while the transcript finalizes. useEffect(() => { if (voiceState !== 'recording') { - isHoldActiveRef.current = false; - rapidCountRef.current = 0; - charsInInputRef.current = 0; - recordingFloorRef.current = 0; + isHoldActiveRef.current = false + rapidCountRef.current = 0 + charsInInputRef.current = 0 + recordingFloorRef.current = 0 setVoiceState(prev => { - if (!prev.voiceWarmingUp) return prev; - return { - ...prev, - voiceWarmingUp: false - }; - }); + if (!prev.voiceWarmingUp) return prev + return { ...prev, voiceWarmingUp: false } + }) } - }, [voiceState, setVoiceState]); + }, [voiceState, setVoiceState]) + const handleKeyDown = (e: KeyboardEvent): void => { - if (!voiceEnabled) return; + if (!voiceEnabled) return // PromptInput is not a valid transcript target — let the hold key // flow through instead of swallowing it into stale refs (#33556). @@ -476,32 +515,37 @@ export function useVoiceKeybindingHandler({ // /plugin. Mirrors CommandKeybindingHandlers' isActive gate. // - isModalOverlayActive: overlay (permission dialog, Select with // onCancel) has focus; PromptInput is mounted but focus=false. - if (!isActive || isModalOverlayActive) return; + if (!isActive || isModalOverlayActive) return // null means the user overrode the default (null-unbind/reassign) — // hold-to-talk is disabled via binding. To toggle the feature // itself, use /voice. - if (voiceKeystroke === null) return; + if (voiceKeystroke === null) return // Match the configured key. Bare chars match by content (handles // batched auto-repeat like "vvv") with a modifier reject so e.g. // ctrl+v doesn't trip a "v" binding. Modifier combos go through // matchesKeyboardEvent (one event per repeat, no batching). - let repeatCount: number; + let repeatCount: number if (bareChar !== null) { - if (e.ctrl || e.meta || e.shift) return; + if (e.ctrl || e.meta || e.shift) return // When bound to space, also accept U+3000 (full-width space) — // CJK IMEs emit it for the same physical key. - const normalized = bareChar === ' ' ? normalizeFullWidthSpace(e.key) : e.key; + const normalized = + bareChar === ' ' ? normalizeFullWidthSpace(e.key) : e.key // Fast-path: normal typing (any char that isn't the bound one) // bails here without allocating. The repeat() check only matters // for batched auto-repeat (input.length > 1) which is rare. - if (normalized[0] !== bareChar) return; - if (normalized.length > 1 && normalized !== bareChar.repeat(normalized.length)) return; - repeatCount = normalized.length; + if (normalized[0] !== bareChar) return + if ( + normalized.length > 1 && + normalized !== bareChar.repeat(normalized.length) + ) + return + repeatCount = normalized.length } else { - if (!matchesKeyboardEvent(e, voiceKeystroke)) return; - repeatCount = 1; + if (!matchesKeyboardEvent(e, voiceKeystroke)) return + repeatCount = 1 } // Guard: only swallow keypresses when recording was triggered by @@ -511,22 +555,22 @@ export function useVoiceKeybindingHandler({ // from the store so that if voiceHandleKeyEvent() fails to transition // state (module not loaded, stream unavailable) we don't permanently // swallow keypresses. - const currentVoiceState = getVoiceState().voiceState; + const currentVoiceState = getVoiceState().voiceState if (isHoldActiveRef.current && currentVoiceState !== 'idle') { // Already recording — swallow continued keypresses and forward // to voice for release detection. For bare chars, defensively // strip in case the text input handler fired before this one // (listener order is not guaranteed). Modifier combos don't // insert text, so nothing to strip. - e.stopImmediatePropagation(); + e.stopImmediatePropagation() if (bareChar !== null) { stripTrailing(repeatCount, { char: bareChar, - floor: recordingFloorRef.current - }); + floor: recordingFloorRef.current, + }) } - voiceHandleKeyEvent(); - return; + voiceHandleKeyEvent() + return } // Non-hold recording (focus-mode) or processing is active. @@ -536,11 +580,12 @@ export function useVoiceKeybindingHandler({ // hit the warmup else-branch (swallow only). Bare chars flow through // unconditionally — user may be typing during focus-recording. if (currentVoiceState !== 'idle') { - if (bareChar === null) e.stopImmediatePropagation(); - return; + if (bareChar === null) e.stopImmediatePropagation() + return } - const countBefore = rapidCountRef.current; - rapidCountRef.current += repeatCount; + + const countBefore = rapidCountRef.current + rapidCountRef.current += repeatCount // ── Activation ──────────────────────────────────────────── // Handled first so the warmup branch below does NOT also run @@ -550,42 +595,37 @@ export function useVoiceKeybindingHandler({ // typed accidentally, so the hold threshold (which exists to // distinguish typing a space from holding space) doesn't apply. if (bareChar === null || rapidCountRef.current >= HOLD_THRESHOLD) { - e.stopImmediatePropagation(); + e.stopImmediatePropagation() if (resetTimerRef.current) { - clearTimeout(resetTimerRef.current); - resetTimerRef.current = null; + clearTimeout(resetTimerRef.current) + resetTimerRef.current = null } - rapidCountRef.current = 0; - isHoldActiveRef.current = true; - setVoiceState(prev_0 => { - if (!prev_0.voiceWarmingUp) return prev_0; - return { - ...prev_0, - voiceWarmingUp: false - }; - }); + rapidCountRef.current = 0 + isHoldActiveRef.current = true + setVoiceState(prev => { + if (!prev.voiceWarmingUp) return prev + return { ...prev, voiceWarmingUp: false } + }) if (bareChar !== null) { // Strip the intentional warmup chars plus this event's leak // (if text input fired first). Cap covers both; min(trailing) // handles the no-leak case. Anchor the voice prefix here. // The return value (remaining) becomes the floor for // recording-time leak cleanup. - recordingFloorRef.current = stripTrailing(charsInInputRef.current + repeatCount, { - char: bareChar, - anchor: true - }); - charsInInputRef.current = 0; - voiceHandleKeyEvent(); + recordingFloorRef.current = stripTrailing( + charsInInputRef.current + repeatCount, + { char: bareChar, anchor: true }, + ) + charsInInputRef.current = 0 + voiceHandleKeyEvent() } else { // Modifier combo: nothing inserted, nothing to strip. Just // anchor the voice prefix at the current cursor position. // Longer fallback: this call is at t=0 (before auto-repeat), // so the gap to the next keypress is the OS initial repeat // *delay* (up to ~2s), not the repeat *rate* (~30-80ms). - stripTrailing(0, { - anchor: true - }); - voiceHandleKeyEvent(MODIFIER_FIRST_PRESS_FALLBACK_MS); + stripTrailing(0, { anchor: true }) + voiceHandleKeyEvent(MODIFIER_FIRST_PRESS_FALLBACK_MS) } // If voice failed to transition (module not loaded, stream // unavailable, stale enabled), clear the ref so a later @@ -594,10 +634,10 @@ export function useVoiceKeybindingHandler({ // immediate. The anchor set by stripTrailing above will // be overwritten on retry (anchor always overwrites now). if (getVoiceState().voiceState === 'idle') { - isHoldActiveRef.current = false; - resetAnchor(); + isHoldActiveRef.current = false + resetAnchor() } - return; + return } // ── Warmup (bare-char only; modifier combos activated above) ── @@ -610,67 +650,74 @@ export function useVoiceKeybindingHandler({ // no-op when nothing leaked. Check countBefore so the event that // crosses the threshold still flows through (terminal batching). if (countBefore >= WARMUP_THRESHOLD) { - e.stopImmediatePropagation(); + e.stopImmediatePropagation() stripTrailing(repeatCount, { char: bareChar, - floor: charsInInputRef.current - }); + floor: charsInInputRef.current, + }) } else { - charsInInputRef.current += repeatCount; + charsInInputRef.current += repeatCount } // Show warmup feedback once we detect a hold pattern if (rapidCountRef.current >= WARMUP_THRESHOLD) { - setVoiceState(prev_1 => { - if (prev_1.voiceWarmingUp) return prev_1; - return { - ...prev_1, - voiceWarmingUp: true - }; - }); + setVoiceState(prev => { + if (prev.voiceWarmingUp) return prev + return { ...prev, voiceWarmingUp: true } + }) } + if (resetTimerRef.current) { - clearTimeout(resetTimerRef.current); + clearTimeout(resetTimerRef.current) } - resetTimerRef.current = setTimeout((resetTimerRef_0, rapidCountRef_0, charsInInputRef_0, setVoiceState_0) => { - resetTimerRef_0.current = null; - rapidCountRef_0.current = 0; - charsInInputRef_0.current = 0; - setVoiceState_0(prev_2 => { - if (!prev_2.voiceWarmingUp) return prev_2; - return { - ...prev_2, - voiceWarmingUp: false - }; - }); - }, RAPID_KEY_GAP_MS, resetTimerRef, rapidCountRef, charsInInputRef, setVoiceState); - }; + resetTimerRef.current = setTimeout( + (resetTimerRef, rapidCountRef, charsInInputRef, setVoiceState) => { + resetTimerRef.current = null + rapidCountRef.current = 0 + charsInInputRef.current = 0 + setVoiceState(prev => { + if (!prev.voiceWarmingUp) return prev + return { ...prev, voiceWarmingUp: false } + }) + }, + RAPID_KEY_GAP_MS, + resetTimerRef, + rapidCountRef, + charsInInputRef, + setVoiceState, + ) + } // Backward-compat bridge: REPL.tsx doesn't yet wire handleKeyDown to // . Subscribe via useInput and adapt InputEvent → // KeyboardEvent until the consumer is migrated (separate PR). // TODO(onKeyDown-migration): remove once REPL passes handleKeyDown. - useInput((_input, _key, event) => { - const kbEvent = new KeyboardEvent(event.keypress); - handleKeyDown(kbEvent); - // handleKeyDown stopped the adapter event, not the InputEvent the - // emitter actually checks — forward it so the text input's useInput - // listener is skipped and held spaces don't leak into the prompt. - if (kbEvent.didStopImmediatePropagation()) { - event.stopImmediatePropagation(); - } - }, { - isActive - }); - return { - handleKeyDown - }; + useInput( + (_input, _key, event) => { + const kbEvent = new KeyboardEvent(event.keypress) + handleKeyDown(kbEvent) + // handleKeyDown stopped the adapter event, not the InputEvent the + // emitter actually checks — forward it so the text input's useInput + // listener is skipped and held spaces don't leak into the prompt. + if (kbEvent.didStopImmediatePropagation()) { + event.stopImmediatePropagation() + } + }, + { isActive }, + ) + + return { handleKeyDown } } // TODO(onKeyDown-migration): temporary shim so existing JSX callers // () keep compiling. Remove once REPL.tsx // wires handleKeyDown directly. -export function VoiceKeybindingHandler(props) { - useVoiceKeybindingHandler(props); - return null; +export function VoiceKeybindingHandler(props: { + voiceHandleKeyEvent: (fallbackMs?: number) => void + stripTrailing: (maxStrip: number, opts?: StripOpts) => number + resetAnchor: () => void + isActive: boolean +}): null { + useVoiceKeybindingHandler(props) + return null } diff --git a/src/tools/AgentTool/AgentTool.tsx b/src/tools/AgentTool/AgentTool.tsx index 709f31e66..7fbed68a4 100644 --- a/src/tools/AgentTool/AgentTool.tsx +++ b/src/tools/AgentTool/AgentTool.tsx @@ -1,105 +1,235 @@ -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { buildTool, type ToolDef, toolMatchesName } from 'src/Tool.js'; -import type { AssistantMessage, Message as MessageType, NormalizedUserMessage } from 'src/types/message.js'; -import { getQuerySourceForAgent } from 'src/utils/promptCategory.js'; -import { z } from 'zod/v4'; -import { clearInvokedSkillsForAgent, getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js'; -import { enhanceSystemPromptWithEnvDetails, getSystemPrompt } from '../../constants/prompts.js'; -import { isCoordinatorMode } from '../../coordinator/coordinatorMode.js'; -import { startAgentSummarization } from '../../services/AgentSummary/agentSummary.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; -import { clearDumpState } from '../../services/api/dumpPrompts.js'; -import { completeAgentTask as completeAsyncAgent, createActivityDescriptionResolver, createProgressTracker, enqueueAgentNotification, failAgentTask as failAsyncAgent, getProgressUpdate, getTokenCountFromTracker, isLocalAgentTask, killAsyncAgent, registerAgentForeground, registerAsyncAgent, unregisterAgentForeground, updateAgentProgress as updateAsyncAgentProgress, updateProgressFromMessage } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; -import { checkRemoteAgentEligibility, formatPreconditionError, getRemoteTaskSessionUrl, registerRemoteAgentTask } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; -import { assembleToolPool } from '../../tools.js'; -import { asAgentId } from '../../types/ids.js'; -import { type SubagentContext, runWithAgentContext } from '../../utils/agentContext.js'; -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; -import { getCwd, runWithCwdOverride } from '../../utils/cwd.js'; -import { logForDebugging } from '../../utils/debug.js'; -import { isEnvTruthy } from '../../utils/envUtils.js'; -import { AbortError, errorMessage, toError } from '../../utils/errors.js'; -import type { CacheSafeParams } from '../../utils/forkedAgent.js'; -import { lazySchema } from '../../utils/lazySchema.js'; -import { createUserMessage, extractTextContent, isSyntheticMessage, normalizeMessages } from '../../utils/messages.js'; -import { getAgentModel } from '../../utils/model/agent.js'; -import { permissionModeSchema } from '../../utils/permissions/PermissionMode.js'; -import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'; -import { filterDeniedAgents, getDenyRuleForAgent } from '../../utils/permissions/permissions.js'; -import { enqueueSdkEvent } from '../../utils/sdkEventQueue.js'; -import { writeAgentMetadata } from '../../utils/sessionStorage.js'; -import { sleep } from '../../utils/sleep.js'; -import { buildEffectiveSystemPrompt } from '../../utils/systemPrompt.js'; -import { asSystemPrompt } from '../../utils/systemPromptType.js'; -import { getTaskOutputPath } from '../../utils/task/diskOutput.js'; -import { getParentSessionId, isTeammate } from '../../utils/teammate.js'; -import { isInProcessTeammate } from '../../utils/teammateContext.js'; -import { teleportToRemote } from '../../utils/teleport.js'; -import { getAssistantMessageContentLength } from '../../utils/tokens.js'; -import { createAgentId } from '../../utils/uuid.js'; -import { createAgentWorktree, hasWorktreeChanges, removeAgentWorktree } from '../../utils/worktree.js'; -import { BASH_TOOL_NAME } from '../BashTool/toolName.js'; -import { BackgroundHint } from '../BashTool/UI.js'; -import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'; -import { spawnTeammate } from '../shared/spawnMultiAgent.js'; -import { setAgentColor } from './agentColorManager.js'; -import { agentToolResultSchema, classifyHandoffIfNeeded, emitTaskProgress, extractPartialResult, finalizeAgentTool, getLastToolUseName, runAsyncAgentLifecycle } from './agentToolUtils.js'; -import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'; -import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME, ONE_SHOT_BUILTIN_AGENT_TYPES } from './constants.js'; -import { buildForkedMessages, buildWorktreeNotice, FORK_AGENT, isForkSubagentEnabled, isInForkChild } from './forkSubagent.js'; -import type { AgentDefinition } from './loadAgentsDir.js'; -import { filterAgentsByMcpRequirements, hasRequiredMcpServers, isBuiltInAgent } from './loadAgentsDir.js'; -import { getPrompt } from './prompt.js'; -import { runAgent } from './runAgent.js'; -import { renderGroupedAgentToolUse, renderToolResultMessage, renderToolUseErrorMessage, renderToolUseMessage, renderToolUseProgressMessage, renderToolUseRejectedMessage, renderToolUseTag, userFacingName, userFacingNameBackgroundColor } from './UI.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { buildTool, type ToolDef, toolMatchesName } from 'src/Tool.js' +import type { + Message as MessageType, + NormalizedUserMessage, +} from 'src/types/message.js' +import { getQuerySourceForAgent } from 'src/utils/promptCategory.js' +import { z } from 'zod/v4' +import { + clearInvokedSkillsForAgent, + getSdkAgentProgressSummariesEnabled, +} from '../../bootstrap/state.js' +import { + enhanceSystemPromptWithEnvDetails, + getSystemPrompt, +} from '../../constants/prompts.js' +import { isCoordinatorMode } from '../../coordinator/coordinatorMode.js' +import { startAgentSummarization } from '../../services/AgentSummary/agentSummary.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../../services/analytics/index.js' +import { clearDumpState } from '../../services/api/dumpPrompts.js' +import { + completeAgentTask as completeAsyncAgent, + createActivityDescriptionResolver, + createProgressTracker, + enqueueAgentNotification, + failAgentTask as failAsyncAgent, + getProgressUpdate, + getTokenCountFromTracker, + isLocalAgentTask, + killAsyncAgent, + registerAgentForeground, + registerAsyncAgent, + unregisterAgentForeground, + updateAgentProgress as updateAsyncAgentProgress, + updateProgressFromMessage, +} from '../../tasks/LocalAgentTask/LocalAgentTask.js' +import { + checkRemoteAgentEligibility, + formatPreconditionError, + getRemoteTaskSessionUrl, + registerRemoteAgentTask, +} from '../../tasks/RemoteAgentTask/RemoteAgentTask.js' +import { assembleToolPool } from '../../tools.js' +import { asAgentId } from '../../types/ids.js' +import { runWithAgentContext } from '../../utils/agentContext.js' +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' +import { getCwd, runWithCwdOverride } from '../../utils/cwd.js' +import { logForDebugging } from '../../utils/debug.js' +import { isEnvTruthy } from '../../utils/envUtils.js' +import { AbortError, errorMessage, toError } from '../../utils/errors.js' +import type { CacheSafeParams } from '../../utils/forkedAgent.js' +import { lazySchema } from '../../utils/lazySchema.js' +import { + createUserMessage, + extractTextContent, + isSyntheticMessage, + normalizeMessages, +} from '../../utils/messages.js' +import { getAgentModel } from '../../utils/model/agent.js' +import { permissionModeSchema } from '../../utils/permissions/PermissionMode.js' +import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' +import { + filterDeniedAgents, + getDenyRuleForAgent, +} from '../../utils/permissions/permissions.js' +import { enqueueSdkEvent } from '../../utils/sdkEventQueue.js' +import { writeAgentMetadata } from '../../utils/sessionStorage.js' +import { sleep } from '../../utils/sleep.js' +import { buildEffectiveSystemPrompt } from '../../utils/systemPrompt.js' +import { asSystemPrompt } from '../../utils/systemPromptType.js' +import { getTaskOutputPath } from '../../utils/task/diskOutput.js' +import { getParentSessionId, isTeammate } from '../../utils/teammate.js' +import { isInProcessTeammate } from '../../utils/teammateContext.js' +import { teleportToRemote } from '../../utils/teleport.js' +import { getAssistantMessageContentLength } from '../../utils/tokens.js' +import { createAgentId } from '../../utils/uuid.js' +import { + createAgentWorktree, + hasWorktreeChanges, + removeAgentWorktree, +} from '../../utils/worktree.js' +import { BASH_TOOL_NAME } from '../BashTool/toolName.js' +import { BackgroundHint } from '../BashTool/UI.js' +import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js' +import { spawnTeammate } from '../shared/spawnMultiAgent.js' +import { setAgentColor } from './agentColorManager.js' +import { + agentToolResultSchema, + classifyHandoffIfNeeded, + emitTaskProgress, + extractPartialResult, + finalizeAgentTool, + getLastToolUseName, + runAsyncAgentLifecycle, +} from './agentToolUtils.js' +import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js' +import { + AGENT_TOOL_NAME, + LEGACY_AGENT_TOOL_NAME, + ONE_SHOT_BUILTIN_AGENT_TYPES, +} from './constants.js' +import { + buildForkedMessages, + buildWorktreeNotice, + FORK_AGENT, + isForkSubagentEnabled, + isInForkChild, +} from './forkSubagent.js' +import type { AgentDefinition } from './loadAgentsDir.js' +import { + filterAgentsByMcpRequirements, + hasRequiredMcpServers, + isBuiltInAgent, +} from './loadAgentsDir.js' +import { getPrompt } from './prompt.js' +import { runAgent } from './runAgent.js' +import { + renderGroupedAgentToolUse, + renderToolResultMessage, + renderToolUseErrorMessage, + renderToolUseMessage, + renderToolUseProgressMessage, + renderToolUseRejectedMessage, + renderToolUseTag, + userFacingName, + userFacingNameBackgroundColor, +} from './UI.js' /* eslint-disable @typescript-eslint/no-require-imports */ -const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../../proactive/index.js') as typeof import('../../proactive/index.js') : null; +const proactiveModule = + feature('PROACTIVE') || feature('KAIROS') + ? (require('../../proactive/index.js') as typeof import('../../proactive/index.js')) + : null /* eslint-enable @typescript-eslint/no-require-imports */ // Progress display constants (for showing background hint) -const PROGRESS_THRESHOLD_MS = 2000; // Show background hint after 2 seconds +const PROGRESS_THRESHOLD_MS = 2000 // Show background hint after 2 seconds // Check if background tasks are disabled at module load time const isBackgroundTasksDisabled = -// eslint-disable-next-line custom-rules/no-process-env-top-level -- Intentional: schema must be defined at module load -isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS); + // eslint-disable-next-line custom-rules/no-process-env-top-level -- Intentional: schema must be defined at module load + isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) // Auto-background agent tasks after this many ms (0 = disabled) // Enabled by env var OR GrowthBook gate (checked lazily since GB may not be ready at module load) function getAutoBackgroundMs(): number { - if (isEnvTruthy(process.env.CLAUDE_AUTO_BACKGROUND_TASKS) || getFeatureValue_CACHED_MAY_BE_STALE('tengu_auto_background_agents', false)) { - return 120_000; + if ( + isEnvTruthy(process.env.CLAUDE_AUTO_BACKGROUND_TASKS) || + getFeatureValue_CACHED_MAY_BE_STALE('tengu_auto_background_agents', false) + ) { + return 120_000 } - return 0; + return 0 } // Multi-agent type constants are defined inline inside gated blocks to enable dead code elimination // Base input schema without multi-agent parameters -const baseInputSchema = lazySchema(() => z.object({ - description: z.string().describe('A short (3-5 word) description of the task'), - prompt: z.string().describe('The task for the agent to perform'), - subagent_type: z.string().optional().describe('The type of specialized agent to use for this task'), - model: z.enum(['sonnet', 'opus', 'haiku']).optional().describe("Optional model override for this agent. Takes precedence over the agent definition's model frontmatter. If omitted, uses the agent definition's model, or inherits from the parent."), - run_in_background: z.boolean().optional().describe('Set to true to run this agent in the background. You will be notified when it completes.') -})); +const baseInputSchema = lazySchema(() => + z.object({ + description: z + .string() + .describe('A short (3-5 word) description of the task'), + prompt: z.string().describe('The task for the agent to perform'), + subagent_type: z + .string() + .optional() + .describe('The type of specialized agent to use for this task'), + model: z + .enum(['sonnet', 'opus', 'haiku']) + .optional() + .describe( + "Optional model override for this agent. Takes precedence over the agent definition's model frontmatter. If omitted, uses the agent definition's model, or inherits from the parent.", + ), + run_in_background: z + .boolean() + .optional() + .describe( + 'Set to true to run this agent in the background. You will be notified when it completes.', + ), + }), +) // Full schema combining base + multi-agent params + isolation const fullInputSchema = lazySchema(() => { // Multi-agent parameters const multiAgentInputSchema = z.object({ - name: z.string().optional().describe('Name for the spawned agent. Makes it addressable via SendMessage({to: name}) while running.'), - team_name: z.string().optional().describe('Team name for spawning. Uses current team context if omitted.'), - mode: permissionModeSchema().optional().describe('Permission mode for spawned teammate (e.g., "plan" to require plan approval).') - }); - return baseInputSchema().merge(multiAgentInputSchema).extend({ - isolation: ((process.env.USER_TYPE) === 'ant' ? z.enum(['worktree', 'remote']) : z.enum(['worktree'])).optional().describe((process.env.USER_TYPE) === 'ant' ? 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo. "remote" launches the agent in a remote CCR environment (always runs in background).' : 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo.'), - cwd: z.string().optional().describe('Absolute path to run the agent in. Overrides the working directory for all filesystem and shell operations within this agent. Mutually exclusive with isolation: "worktree".') - }); -}); + name: z + .string() + .optional() + .describe( + 'Name for the spawned agent. Makes it addressable via SendMessage({to: name}) while running.', + ), + team_name: z + .string() + .optional() + .describe( + 'Team name for spawning. Uses current team context if omitted.', + ), + mode: permissionModeSchema() + .optional() + .describe( + 'Permission mode for spawned teammate (e.g., "plan" to require plan approval).', + ), + }) + + return baseInputSchema() + .merge(multiAgentInputSchema) + .extend({ + isolation: (process.env.USER_TYPE === 'ant' + ? z.enum(['worktree', 'remote']) + : z.enum(['worktree']) + ) + .optional() + .describe( + process.env.USER_TYPE === 'ant' + ? 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo. "remote" launches the agent in a remote CCR environment (always runs in background).' + : 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo.', + ), + cwd: z + .string() + .optional() + .describe( + 'Absolute path to run the agent in. Overrides the working directory for all filesystem and shell operations within this agent. Mutually exclusive with isolation: "worktree".', + ), + }) +}) // Strip optional fields from the schema when the backing feature is off so // the model never sees them. Done via .omit() rather than conditional spread @@ -108,9 +238,9 @@ const fullInputSchema = lazySchema(() => { // type, but call() destructures via the explicit AgentToolInput type below // which always includes all optional fields. export const inputSchema = lazySchema(() => { - const schema = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ - cwd: true - }); + const schema = feature('KAIROS') + ? fullInputSchema() + : fullInputSchema().omit({ cwd: true }) // GrowthBook-in-lazySchema is acceptable here (unlike subagent_type, which // was removed in 906da6c723): the divergence window is one-session-per- @@ -119,61 +249,70 @@ export const inputSchema = lazySchema(() => { // by forceAsync) or "schema hides a param that would've worked" (gate // flips off mid-session: everything still runs async via memoized // forceAsync). No Zod rejection, no crash — unlike required→optional. - return isBackgroundTasksDisabled || isForkSubagentEnabled() ? schema.omit({ - run_in_background: true - }) : schema; -}); -type InputSchema = ReturnType; + return isBackgroundTasksDisabled || isForkSubagentEnabled() + ? schema.omit({ run_in_background: true }) + : schema +}) +type InputSchema = ReturnType // Explicit type widens the schema inference to always include all optional // fields even when .omit() strips them for gating (cwd, run_in_background). // subagent_type is optional; call() defaults it to general-purpose when the // fork gate is off, or routes to the fork path when the gate is on. type AgentToolInput = z.infer> & { - name?: string; - team_name?: string; - mode?: z.infer>; - isolation?: 'worktree' | 'remote'; - cwd?: string; -}; + name?: string + team_name?: string + mode?: z.infer> + isolation?: 'worktree' | 'remote' + cwd?: string +} // Output schema - multi-agent spawned schema added dynamically at runtime when enabled export const outputSchema = lazySchema(() => { const syncOutputSchema = agentToolResultSchema().extend({ status: z.literal('completed'), - prompt: z.string() - }); + prompt: z.string(), + }) + const asyncOutputSchema = z.object({ status: z.literal('async_launched'), agentId: z.string().describe('The ID of the async agent'), description: z.string().describe('The description of the task'), prompt: z.string().describe('The prompt for the agent'), - outputFile: z.string().describe('Path to the output file for checking agent progress'), - canReadOutputFile: z.boolean().optional().describe('Whether the calling agent has Read/Bash tools to check progress') - }); - return z.union([syncOutputSchema, asyncOutputSchema]); -}); -type OutputSchema = ReturnType; -type Output = z.input; + outputFile: z + .string() + .describe('Path to the output file for checking agent progress'), + canReadOutputFile: z + .boolean() + .optional() + .describe( + 'Whether the calling agent has Read/Bash tools to check progress', + ), + }) + + return z.union([syncOutputSchema, asyncOutputSchema]) +}) +type OutputSchema = ReturnType +type Output = z.input // Private type for teammate spawn results - excluded from exported schema for dead code elimination // The 'teammate_spawned' status string is only included when ENABLE_AGENT_SWARMS is true type TeammateSpawnedOutput = { - status: 'teammate_spawned'; - prompt: string; - teammate_id: string; - agent_id: string; - agent_type?: string; - model?: string; - name: string; - color?: string; - tmux_session_name: string; - tmux_window_name: string; - tmux_pane_id: string; - team_name?: string; - is_splitpane?: boolean; - plan_mode_required?: boolean; -}; + status: 'teammate_spawned' + prompt: string + teammate_id: string + agent_id: string + agent_type?: string + model?: string + name: string + color?: string + tmux_session_name: string + tmux_window_name: string + tmux_pane_id: string + team_name?: string + is_splitpane?: boolean + plan_mode_required?: boolean +} // Combined output type including both public and internal types // Note: TeammateSpawnedOutput type is fine - TypeScript types are erased at compile time @@ -181,123 +320,146 @@ type TeammateSpawnedOutput = { // like TeammateSpawnedOutput for dead code elimination purposes. Exported // for UI.tsx to do proper discriminated-union narrowing instead of ad-hoc casts. export type RemoteLaunchedOutput = { - status: 'remote_launched'; - taskId: string; - sessionUrl: string; - description: string; - prompt: string; - outputFile: string; -}; -type InternalOutput = Output | TeammateSpawnedOutput | RemoteLaunchedOutput; -import type { AgentToolProgress, ShellProgress } from '../../types/tools.js'; + status: 'remote_launched' + taskId: string + sessionUrl: string + description: string + prompt: string + outputFile: string +} + +type InternalOutput = Output | TeammateSpawnedOutput | RemoteLaunchedOutput + +import type { AgentToolProgress, ShellProgress } from '../../types/tools.js' // AgentTool forwards both its own progress events and shell progress // events from the sub-agent so the SDK receives tool_progress updates during bash/powershell runs. -export type Progress = AgentToolProgress | ShellProgress; +export type Progress = AgentToolProgress | ShellProgress + export const AgentTool = buildTool({ - async prompt({ - agents, - tools, - getToolPermissionContext, - allowedAgentTypes - }) { - const toolPermissionContext = await getToolPermissionContext(); + async prompt({ agents, tools, getToolPermissionContext, allowedAgentTypes }) { + const toolPermissionContext = await getToolPermissionContext() // Get MCP servers that have tools available - const mcpServersWithTools: string[] = []; + const mcpServersWithTools: string[] = [] for (const tool of tools) { if (tool.name?.startsWith('mcp__')) { - const parts = tool.name.split('__'); - const serverName = parts[1]; + const parts = tool.name.split('__') + const serverName = parts[1] if (serverName && !mcpServersWithTools.includes(serverName)) { - mcpServersWithTools.push(serverName); + mcpServersWithTools.push(serverName) } } } // Filter agents: first by MCP requirements, then by permission rules - const agentsWithMcpRequirementsMet = filterAgentsByMcpRequirements(agents, mcpServersWithTools); - const filteredAgents = filterDeniedAgents(agentsWithMcpRequirementsMet, toolPermissionContext, AGENT_TOOL_NAME); + const agentsWithMcpRequirementsMet = filterAgentsByMcpRequirements( + agents, + mcpServersWithTools, + ) + const filteredAgents = filterDeniedAgents( + agentsWithMcpRequirementsMet, + toolPermissionContext, + AGENT_TOOL_NAME, + ) // Use inline env check instead of coordinatorModule to avoid circular // dependency issues during test module loading. - const isCoordinator = feature('COORDINATOR_MODE') ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) : false; - return await getPrompt(filteredAgents, isCoordinator, allowedAgentTypes); + const isCoordinator = feature('COORDINATOR_MODE') + ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) + : false + return await getPrompt(filteredAgents, isCoordinator, allowedAgentTypes) }, name: AGENT_TOOL_NAME, searchHint: 'delegate work to a subagent', aliases: [LEGACY_AGENT_TOOL_NAME], maxResultSizeChars: 100_000, async description() { - return 'Launch a new agent'; + return 'Launch a new agent' }, get inputSchema(): InputSchema { - return inputSchema(); + return inputSchema() }, get outputSchema(): OutputSchema { - return outputSchema(); + return outputSchema() }, - async call({ - prompt, - subagent_type, - description, - model: modelParam, - run_in_background, - name, - team_name, - mode: spawnMode, - isolation, - cwd - }: AgentToolInput, toolUseContext, canUseTool, assistantMessage, onProgress?) { - const startTime = Date.now(); - const model = isCoordinatorMode() ? undefined : modelParam; + async call( + { + prompt, + subagent_type, + description, + model: modelParam, + run_in_background, + name, + team_name, + mode: spawnMode, + isolation, + cwd, + }: AgentToolInput, + toolUseContext, + canUseTool, + assistantMessage, + onProgress?, + ) { + const startTime = Date.now() + const model = isCoordinatorMode() ? undefined : modelParam // Get app state for permission mode and agent filtering - const appState = toolUseContext.getAppState(); - const permissionMode = appState.toolPermissionContext.mode; + const appState = toolUseContext.getAppState() + const permissionMode = appState.toolPermissionContext.mode // In-process teammates get a no-op setAppState; setAppStateForTasks // reaches the root store so task registration/progress/kill stay visible. - const rootSetAppState = toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState; + const rootSetAppState = + toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState // Check if user is trying to use agent teams without access if (team_name && !isAgentSwarmsEnabled()) { - throw new Error('Agent Teams is not yet available on your plan.'); + throw new Error('Agent Teams is not yet available on your plan.') } // Teammates (in-process or tmux) passing `name` would trigger spawnTeammate() // below, but TeamFile.members is a flat array with one leadAgentId — nested // teammates land in the roster with no provenance and confuse the lead. - const teamName = resolveTeamName({ - team_name - }, appState); + const teamName = resolveTeamName({ team_name }, appState) if (isTeammate() && teamName && name) { - throw new Error('Teammates cannot spawn other teammates — the team roster is flat. To spawn a subagent instead, omit the `name` parameter.'); + throw new Error( + 'Teammates cannot spawn other teammates — the team roster is flat. To spawn a subagent instead, omit the `name` parameter.', + ) } // In-process teammates cannot spawn background agents (their lifecycle is // tied to the leader's process). Tmux teammates are separate processes and // can manage their own background agents. if (isInProcessTeammate() && teamName && run_in_background === true) { - throw new Error('In-process teammates cannot spawn background agents. Use run_in_background=false for synchronous subagents.'); + throw new Error( + 'In-process teammates cannot spawn background agents. Use run_in_background=false for synchronous subagents.', + ) } // Check if this is a multi-agent spawn request // Spawn is triggered when team_name is set (from param or context) and name is provided if (teamName && name) { // Set agent definition color for grouped UI display before spawning - const agentDef = subagent_type ? toolUseContext.options.agentDefinitions.activeAgents.find(a => a.agentType === subagent_type) : undefined; + const agentDef = subagent_type + ? toolUseContext.options.agentDefinitions.activeAgents.find( + a => a.agentType === subagent_type, + ) + : undefined if (agentDef?.color) { - setAgentColor(subagent_type!, agentDef.color); + setAgentColor(subagent_type!, agentDef.color) } - const result = await spawnTeammate({ - name, - prompt, - description, - team_name: teamName, - use_splitpane: true, - plan_mode_required: spawnMode === 'plan', - model: model ?? agentDef?.model, - agent_type: subagent_type, - invokingRequestId: assistantMessage?.requestId as string | undefined - }, toolUseContext); + const result = await spawnTeammate( + { + name, + prompt, + description, + team_name: teamName, + use_splitpane: true, + plan_mode_required: spawnMode === 'plan', + model: model ?? agentDef?.model, + agent_type: subagent_type, + invokingRequestId: assistantMessage?.requestId, + }, + toolUseContext, + ) // Type assertion uses TeammateSpawnedOutput (defined above) instead of any. // This type is excluded from the exported outputSchema for dead code elimination. @@ -306,22 +468,21 @@ export const AgentTool = buildTool({ const spawnResult: TeammateSpawnedOutput = { status: 'teammate_spawned' as const, prompt, - ...result.data - }; - return { - data: spawnResult - } as unknown as { - data: Output; - }; + ...result.data, + } + return { data: spawnResult } as unknown as { data: Output } } // Fork subagent experiment routing: // - subagent_type set: use it (explicit wins) // - subagent_type omitted, gate on: fork path (undefined) // - subagent_type omitted, gate off: default general-purpose - const effectiveType = subagent_type ?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType); - const isForkPath = effectiveType === undefined; - let selectedAgent: AgentDefinition; + const effectiveType = + subagent_type ?? + (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType) + const isForkPath = effectiveType === undefined + + let selectedAgent: AgentDefinition if (isForkPath) { // Recursive fork guard: fork children keep the Agent tool in their // pool for cache-identical tool defs, so reject fork attempts at call @@ -329,42 +490,70 @@ export const AgentTool = buildTool({ // context.options at spawn time, survives autocompact's message // rewrite). Message-scan fallback catches any path where querySource // wasn't threaded. - if (toolUseContext.options.querySource === `agent:builtin:${FORK_AGENT.agentType}` || isInForkChild(toolUseContext.messages)) { - throw new Error('Fork is not available inside a forked worker. Complete your task directly using your tools.'); + if ( + toolUseContext.options.querySource === + `agent:builtin:${FORK_AGENT.agentType}` || + isInForkChild(toolUseContext.messages) + ) { + throw new Error( + 'Fork is not available inside a forked worker. Complete your task directly using your tools.', + ) } - selectedAgent = FORK_AGENT; + selectedAgent = FORK_AGENT } else { // Filter agents to exclude those denied via Agent(AgentName) syntax - const allAgents = toolUseContext.options.agentDefinitions.activeAgents; - const { - allowedAgentTypes - } = toolUseContext.options.agentDefinitions; + const allAgents = toolUseContext.options.agentDefinitions.activeAgents + const { allowedAgentTypes } = toolUseContext.options.agentDefinitions const agents = filterDeniedAgents( - // When allowedAgentTypes is set (from Agent(x,y) tool spec), restrict to those types - allowedAgentTypes ? allAgents.filter(a => allowedAgentTypes.includes(a.agentType)) : allAgents, appState.toolPermissionContext, AGENT_TOOL_NAME); - const found = agents.find(agent => agent.agentType === effectiveType); + // When allowedAgentTypes is set (from Agent(x,y) tool spec), restrict to those types + allowedAgentTypes + ? allAgents.filter(a => allowedAgentTypes.includes(a.agentType)) + : allAgents, + appState.toolPermissionContext, + AGENT_TOOL_NAME, + ) + + const found = agents.find(agent => agent.agentType === effectiveType) if (!found) { // Check if the agent exists but is denied by permission rules - const agentExistsButDenied = allAgents.find(agent => agent.agentType === effectiveType); + const agentExistsButDenied = allAgents.find( + agent => agent.agentType === effectiveType, + ) if (agentExistsButDenied) { - const denyRule = getDenyRuleForAgent(appState.toolPermissionContext, AGENT_TOOL_NAME, effectiveType); - throw new Error(`Agent type '${effectiveType}' has been denied by permission rule '${AGENT_TOOL_NAME}(${effectiveType})' from ${denyRule?.source ?? 'settings'}.`); + const denyRule = getDenyRuleForAgent( + appState.toolPermissionContext, + AGENT_TOOL_NAME, + effectiveType, + ) + throw new Error( + `Agent type '${effectiveType}' has been denied by permission rule '${AGENT_TOOL_NAME}(${effectiveType})' from ${denyRule?.source ?? 'settings'}.`, + ) } - throw new Error(`Agent type '${effectiveType}' not found. Available agents: ${agents.map(a => a.agentType).join(', ')}`); + throw new Error( + `Agent type '${effectiveType}' not found. Available agents: ${agents + .map(a => a.agentType) + .join(', ')}`, + ) } - selectedAgent = found; + selectedAgent = found } // Same lifecycle constraint as the run_in_background guard above, but for // agent definitions that force background via `background: true`. Checked // here because selectedAgent is only now resolved. - if (isInProcessTeammate() && teamName && selectedAgent.background === true) { - throw new Error(`In-process teammates cannot spawn background agents. Agent '${selectedAgent.agentType}' has background: true in its definition.`); + if ( + isInProcessTeammate() && + teamName && + selectedAgent.background === true + ) { + throw new Error( + `In-process teammates cannot spawn background agents. Agent '${selectedAgent.agentType}' has background: true in its definition.`, + ) } // Capture for type narrowing — `let selectedAgent` prevents TS from // narrowing property types across the if-else assignment above. - const requiredMcpServers = selectedAgent.requiredMcpServers; + const requiredMcpServers = selectedAgent.requiredMcpServers // Check if required MCP servers have tools available // A server that's connected but not authenticated won't have any tools @@ -372,113 +561,153 @@ export const AgentTool = buildTool({ // If any required servers are still pending (connecting), wait for them // before checking tool availability. This avoids a race condition where // the agent is invoked before MCP servers finish connecting. - const hasPendingRequiredServers = appState.mcp.clients.some(c => c.type === 'pending' && requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase()))); - let currentAppState = appState; + const hasPendingRequiredServers = appState.mcp.clients.some( + c => + c.type === 'pending' && + requiredMcpServers.some(pattern => + c.name.toLowerCase().includes(pattern.toLowerCase()), + ), + ) + + let currentAppState = appState if (hasPendingRequiredServers) { - const MAX_WAIT_MS = 30_000; - const POLL_INTERVAL_MS = 500; - const deadline = Date.now() + MAX_WAIT_MS; + const MAX_WAIT_MS = 30_000 + const POLL_INTERVAL_MS = 500 + const deadline = Date.now() + MAX_WAIT_MS + while (Date.now() < deadline) { - await sleep(POLL_INTERVAL_MS); - currentAppState = toolUseContext.getAppState(); + await sleep(POLL_INTERVAL_MS) + currentAppState = toolUseContext.getAppState() // Early exit: if any required server has already failed, no point // waiting for other pending servers — the check will fail regardless. - const hasFailedRequiredServer = currentAppState.mcp.clients.some(c => c.type === 'failed' && requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase()))); - if (hasFailedRequiredServer) break; - const stillPending = currentAppState.mcp.clients.some(c => c.type === 'pending' && requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase()))); - if (!stillPending) break; + const hasFailedRequiredServer = currentAppState.mcp.clients.some( + c => + c.type === 'failed' && + requiredMcpServers.some(pattern => + c.name.toLowerCase().includes(pattern.toLowerCase()), + ), + ) + if (hasFailedRequiredServer) break + + const stillPending = currentAppState.mcp.clients.some( + c => + c.type === 'pending' && + requiredMcpServers.some(pattern => + c.name.toLowerCase().includes(pattern.toLowerCase()), + ), + ) + if (!stillPending) break } } // Get servers that actually have tools (meaning they're connected AND authenticated) - const serversWithTools: string[] = []; + const serversWithTools: string[] = [] for (const tool of currentAppState.mcp.tools) { if (tool.name?.startsWith('mcp__')) { // Extract server name from tool name (format: mcp__serverName__toolName) - const parts = tool.name.split('__'); - const serverName = parts[1]; + const parts = tool.name.split('__') + const serverName = parts[1] if (serverName && !serversWithTools.includes(serverName)) { - serversWithTools.push(serverName); + serversWithTools.push(serverName) } } } + if (!hasRequiredMcpServers(selectedAgent, serversWithTools)) { - const missing = requiredMcpServers.filter(pattern => !serversWithTools.some(server => server.toLowerCase().includes(pattern.toLowerCase()))); - throw new Error(`Agent '${selectedAgent.agentType}' requires MCP servers matching: ${missing.join(', ')}. ` + `MCP servers with tools: ${serversWithTools.length > 0 ? serversWithTools.join(', ') : 'none'}. ` + `Use /mcp to configure and authenticate the required MCP servers.`); + const missing = requiredMcpServers.filter( + pattern => + !serversWithTools.some(server => + server.toLowerCase().includes(pattern.toLowerCase()), + ), + ) + throw new Error( + `Agent '${selectedAgent.agentType}' requires MCP servers matching: ${missing.join(', ')}. ` + + `MCP servers with tools: ${serversWithTools.length > 0 ? serversWithTools.join(', ') : 'none'}. ` + + `Use /mcp to configure and authenticate the required MCP servers.`, + ) } } // Initialize the color for this agent if it has a predefined one if (selectedAgent.color) { - setAgentColor(selectedAgent.agentType, selectedAgent.color); + setAgentColor(selectedAgent.agentType, selectedAgent.color) } // Resolve agent params for logging (these are already resolved in runAgent) - const resolvedAgentModel = getAgentModel(selectedAgent.model, toolUseContext.options.mainLoopModel, isForkPath ? undefined : model, permissionMode); + const resolvedAgentModel = getAgentModel( + selectedAgent.model, + toolUseContext.options.mainLoopModel, + isForkPath ? undefined : model, + permissionMode, + ) + logEvent('tengu_agent_tool_selected', { - agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: selectedAgent.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - color: selectedAgent.color as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + agent_type: + selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: + resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: + selectedAgent.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + color: + selectedAgent.color as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, is_built_in_agent: isBuiltInAgent(selectedAgent), is_resume: false, - is_async: (run_in_background === true || selectedAgent.background === true) && !isBackgroundTasksDisabled, - is_fork: isForkPath - }); + is_async: + (run_in_background === true || selectedAgent.background === true) && + !isBackgroundTasksDisabled, + is_fork: isForkPath, + }) // Resolve effective isolation mode (explicit param overrides agent def) - const effectiveIsolation = isolation ?? selectedAgent.isolation; + const effectiveIsolation = isolation ?? selectedAgent.isolation // Remote isolation: delegate to CCR. Gated ant-only — the guard enables // dead code elimination of the entire block for external builds. - if ((process.env.USER_TYPE) === 'ant' && effectiveIsolation === 'remote') { - const eligibility = await checkRemoteAgentEligibility(); + if (process.env.USER_TYPE === 'ant' && effectiveIsolation === 'remote') { + const eligibility = await checkRemoteAgentEligibility() if (!eligibility.eligible) { - const reasons = (eligibility as { eligible: false; errors: Parameters[0][] }).errors.map(formatPreconditionError).join('\n'); - throw new Error(`Cannot launch remote agent:\n${reasons}`); + const reasons = eligibility.errors + .map(formatPreconditionError) + .join('\n') + throw new Error(`Cannot launch remote agent:\n${reasons}`) } - let bundleFailHint: string | undefined; + + let bundleFailHint: string | undefined const session = await teleportToRemote({ initialMessage: prompt, description, signal: toolUseContext.abortController.signal, onBundleFail: msg => { - bundleFailHint = msg; - } - }); - if (!session) { - throw new Error(bundleFailHint ?? 'Failed to create remote session'); - } - const { - taskId, - sessionId - } = registerRemoteAgentTask({ - remoteTaskType: 'remote-agent', - session: { - id: session.id, - title: session.title || description + bundleFailHint = msg }, + }) + if (!session) { + throw new Error(bundleFailHint ?? 'Failed to create remote session') + } + + const { taskId, sessionId } = registerRemoteAgentTask({ + remoteTaskType: 'remote-agent', + session: { id: session.id, title: session.title || description }, command: prompt, context: toolUseContext, - toolUseId: toolUseContext.toolUseId - }); + toolUseId: toolUseContext.toolUseId, + }) + logEvent('tengu_agent_tool_remote_launched', { - agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + agent_type: + selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + const remoteResult: RemoteLaunchedOutput = { status: 'remote_launched', taskId, sessionUrl: getRemoteTaskSessionUrl(sessionId), description, prompt, - outputFile: getTaskOutputPath(taskId) - }; - return { - data: remoteResult - } as unknown as { - data: Output; - }; + outputFile: getTaskOutputPath(taskId), + } + return { data: remoteResult } as unknown as { data: Output } } // System prompt + prompt messages: branch on fork path. // @@ -489,72 +718,98 @@ export const AgentTool = buildTool({ // // Normal path: build the selected agent's own system prompt with env // details, and use a simple user message for the prompt. - let enhancedSystemPrompt: string[] | undefined; - let forkParentSystemPrompt: ReturnType | undefined; - let promptMessages: MessageType[]; + let enhancedSystemPrompt: string[] | undefined + let forkParentSystemPrompt: + | ReturnType + | undefined + let promptMessages: MessageType[] + if (isForkPath) { if (toolUseContext.renderedSystemPrompt) { - forkParentSystemPrompt = toolUseContext.renderedSystemPrompt; + forkParentSystemPrompt = toolUseContext.renderedSystemPrompt } else { // Fallback: recompute. May diverge from parent's cached bytes if // GrowthBook state changed between parent turn-start and fork spawn. - const mainThreadAgentDefinition = appState.agent ? appState.agentDefinitions.activeAgents.find(a => a.agentType === appState.agent) : undefined; - const additionalWorkingDirectories = Array.from(appState.toolPermissionContext.additionalWorkingDirectories.keys()); - const defaultSystemPrompt = await getSystemPrompt(toolUseContext.options.tools, toolUseContext.options.mainLoopModel, additionalWorkingDirectories, toolUseContext.options.mcpClients); + const mainThreadAgentDefinition = appState.agent + ? appState.agentDefinitions.activeAgents.find( + a => a.agentType === appState.agent, + ) + : undefined + const additionalWorkingDirectories = Array.from( + appState.toolPermissionContext.additionalWorkingDirectories.keys(), + ) + const defaultSystemPrompt = await getSystemPrompt( + toolUseContext.options.tools, + toolUseContext.options.mainLoopModel, + additionalWorkingDirectories, + toolUseContext.options.mcpClients, + ) forkParentSystemPrompt = buildEffectiveSystemPrompt({ mainThreadAgentDefinition, toolUseContext, customSystemPrompt: toolUseContext.options.customSystemPrompt, defaultSystemPrompt, - appendSystemPrompt: toolUseContext.options.appendSystemPrompt - }); + appendSystemPrompt: toolUseContext.options.appendSystemPrompt, + }) } - promptMessages = buildForkedMessages(prompt, assistantMessage); + promptMessages = buildForkedMessages(prompt, assistantMessage) } else { try { - const additionalWorkingDirectories = Array.from(appState.toolPermissionContext.additionalWorkingDirectories.keys()); + const additionalWorkingDirectories = Array.from( + appState.toolPermissionContext.additionalWorkingDirectories.keys(), + ) // All agents have getSystemPrompt - pass toolUseContext to all - const agentPrompt = selectedAgent.getSystemPrompt({ - toolUseContext - }); + const agentPrompt = selectedAgent.getSystemPrompt({ toolUseContext }) // Log agent memory loaded event for subagents if (selectedAgent.memory) { logEvent('tengu_agent_memory_loaded', { - ...((process.env.USER_TYPE) === 'ant' && { - agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + ...(process.env.USER_TYPE === 'ant' && { + agent_type: + selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), - scope: selectedAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: 'subagent' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + scope: + selectedAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: + 'subagent' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } // Apply environment details enhancement - enhancedSystemPrompt = await enhanceSystemPromptWithEnvDetails([agentPrompt], resolvedAgentModel, additionalWorkingDirectories); + enhancedSystemPrompt = await enhanceSystemPromptWithEnvDetails( + [agentPrompt], + resolvedAgentModel, + additionalWorkingDirectories, + ) } catch (error) { - logForDebugging(`Failed to get system prompt for agent ${selectedAgent.agentType}: ${errorMessage(error)}`); + logForDebugging( + `Failed to get system prompt for agent ${selectedAgent.agentType}: ${errorMessage(error)}`, + ) } - promptMessages = [createUserMessage({ - content: prompt - })]; + promptMessages = [createUserMessage({ content: prompt })] } + const metadata = { prompt, resolvedAgentModel, isBuiltInAgent: isBuiltInAgent(selectedAgent), startTime, agentType: selectedAgent.agentType, - isAsync: (run_in_background === true || selectedAgent.background === true) && !isBackgroundTasksDisabled - }; + isAsync: + (run_in_background === true || selectedAgent.background === true) && + !isBackgroundTasksDisabled, + } // Use inline env check instead of coordinatorModule to avoid circular // dependency issues during test module loading. - const isCoordinator = feature('COORDINATOR_MODE') ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) : false; + const isCoordinator = feature('COORDINATOR_MODE') + ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) + : false // Fork subagent experiment: force ALL spawns async for a unified // interaction model (not just fork spawns — all of them). - const forceAsync = isForkSubagentEnabled(); + const forceAsync = isForkSubagentEnabled() // Assistant mode: force all agents async. Synchronous subagents hold the // main loop's turn open until they complete — the daemon's inputQueue @@ -563,8 +818,18 @@ export const AgentTool = buildTool({ // executeForkedSlashCommand's fire-and-forget path; the // re-entry there is handled by the else branch // below (registerAsyncAgentTask + notifyOnCompletion). - const assistantForceAsync = feature('KAIROS') ? appState.kairosEnabled : false; - const shouldRunAsync = (run_in_background === true || selectedAgent.background === true || isCoordinator || forceAsync || assistantForceAsync || (proactiveModule?.isProactiveActive() ?? false)) && !isBackgroundTasksDisabled; + const assistantForceAsync = feature('KAIROS') + ? appState.kairosEnabled + : false + + const shouldRunAsync = + (run_in_background === true || + selectedAgent.background === true || + isCoordinator || + forceAsync || + assistantForceAsync || + (proactiveModule?.isProactiveActive() ?? false)) && + !isBackgroundTasksDisabled // Assemble the worker's tool pool independently of the parent's. // Workers always get their tools from assembleToolPool with their own // permission mode, so they aren't affected by the parent's tool @@ -572,41 +837,53 @@ export const AgentTool = buildTool({ // import from tools.ts (which would create a circular dependency). const workerPermissionContext = { ...appState.toolPermissionContext, - mode: selectedAgent.permissionMode ?? 'acceptEdits' - }; - const workerTools = assembleToolPool(workerPermissionContext, appState.mcp.tools); + mode: selectedAgent.permissionMode ?? 'acceptEdits', + } + const workerTools = assembleToolPool( + workerPermissionContext, + appState.mcp.tools, + ) // Create a stable agent ID early so it can be used for worktree slug - const earlyAgentId = createAgentId(); + const earlyAgentId = createAgentId() // Set up worktree isolation if requested let worktreeInfo: { - worktreePath: string; - worktreeBranch?: string; - headCommit?: string; - gitRoot?: string; - hookBased?: boolean; - } | null = null; + worktreePath: string + worktreeBranch?: string + headCommit?: string + gitRoot?: string + hookBased?: boolean + } | null = null + if (effectiveIsolation === 'worktree') { - const slug = `agent-${earlyAgentId.slice(0, 8)}`; - worktreeInfo = await createAgentWorktree(slug); + const slug = `agent-${earlyAgentId.slice(0, 8)}` + worktreeInfo = await createAgentWorktree(slug) } // Fork + worktree: inject a notice telling the child to translate paths // and re-read potentially stale files. Appended after the fork directive // so it appears as the most recent guidance the child sees. if (isForkPath && worktreeInfo) { - promptMessages.push(createUserMessage({ - content: buildWorktreeNotice(getCwd(), worktreeInfo.worktreePath) - })); + promptMessages.push( + createUserMessage({ + content: buildWorktreeNotice(getCwd(), worktreeInfo.worktreePath), + }), + ) } + const runAgentParams: Parameters[0] = { agentDefinition: selectedAgent, promptMessages, toolUseContext, canUseTool, isAsync: shouldRunAsync, - querySource: toolUseContext.options.querySource ?? getQuerySourceForAgent(selectedAgent.agentType, isBuiltInAgent(selectedAgent)), + querySource: + toolUseContext.options.querySource ?? + getQuerySourceForAgent( + selectedAgent.agentType, + isBuiltInAgent(selectedAgent), + ), model: isForkPath ? undefined : model, // Fork path: pass parent's system prompt AND parent's exact tool // array (cache-identical prefix). workerTools is rebuilt under @@ -619,72 +896,64 @@ export const AgentTool = buildTool({ // or explicit cwd), skip the pre-built system prompt so runAgent's // buildAgentSystemPrompt() runs inside wrapWithCwd where getCwd() // returns the override path. - override: isForkPath ? { - systemPrompt: forkParentSystemPrompt - } : enhancedSystemPrompt && !worktreeInfo && !cwd ? { - systemPrompt: asSystemPrompt(enhancedSystemPrompt) - } : undefined, + override: isForkPath + ? { systemPrompt: forkParentSystemPrompt } + : enhancedSystemPrompt && !worktreeInfo && !cwd + ? { systemPrompt: asSystemPrompt(enhancedSystemPrompt) } + : undefined, availableTools: isForkPath ? toolUseContext.options.tools : workerTools, // Pass parent conversation when the fork-subagent path needs full // context. useExactTools inherits thinkingConfig (runAgent.ts:624). forkContextMessages: isForkPath ? toolUseContext.messages : undefined, - ...(isForkPath && { - useExactTools: true - }), + ...(isForkPath && { useExactTools: true }), worktreePath: worktreeInfo?.worktreePath, - description - }; + description, + } // Helper to wrap execution with a cwd override: explicit cwd arg (KAIROS) // takes precedence over worktree isolation path. - const cwdOverridePath = cwd ?? worktreeInfo?.worktreePath; - const wrapWithCwd = (fn: () => T): T => cwdOverridePath ? runWithCwdOverride(cwdOverridePath, fn) : fn(); + const cwdOverridePath = cwd ?? worktreeInfo?.worktreePath + const wrapWithCwd = (fn: () => T): T => + cwdOverridePath ? runWithCwdOverride(cwdOverridePath, fn) : fn() // Helper to clean up worktree after agent completes const cleanupWorktreeIfNeeded = async (): Promise<{ - worktreePath?: string; - worktreeBranch?: string; + worktreePath?: string + worktreeBranch?: string }> => { - if (!worktreeInfo) return {}; - const { - worktreePath, - worktreeBranch, - headCommit, - gitRoot, - hookBased - } = worktreeInfo; + if (!worktreeInfo) return {} + const { worktreePath, worktreeBranch, headCommit, gitRoot, hookBased } = + worktreeInfo // Null out to make idempotent — guards against double-call if code // between cleanup and end of try throws into catch - worktreeInfo = null; + worktreeInfo = null if (hookBased) { // Hook-based worktrees are always kept since we can't detect VCS changes - logForDebugging(`Hook-based agent worktree kept at: ${worktreePath}`); - return { - worktreePath - }; + logForDebugging(`Hook-based agent worktree kept at: ${worktreePath}`) + return { worktreePath } } if (headCommit) { - const changed = await hasWorktreeChanges(worktreePath, headCommit); + const changed = await hasWorktreeChanges(worktreePath, headCommit) if (!changed) { - await removeAgentWorktree(worktreePath, worktreeBranch, gitRoot); + await removeAgentWorktree(worktreePath, worktreeBranch, gitRoot) // Clear worktreePath from metadata so resume doesn't try to use // a deleted directory. Fire-and-forget to match runAgent's // writeAgentMetadata handling. void writeAgentMetadata(asAgentId(earlyAgentId), { agentType: selectedAgent.agentType, - description - }).catch(_err => logForDebugging(`Failed to clear worktree metadata: ${_err}`)); - return {}; + description, + }).catch(_err => + logForDebugging(`Failed to clear worktree metadata: ${_err}`), + ) + return {} } } - logForDebugging(`Agent worktree has changes, keeping: ${worktreePath}`); - return { - worktreePath, - worktreeBranch - }; - }; + logForDebugging(`Agent worktree has changes, keeping: ${worktreePath}`) + return { worktreePath, worktreeBranch } + } + if (shouldRunAsync) { - const asyncAgentId = earlyAgentId; + const asyncAgentId = earlyAgentId const agentBackgroundTask = registerAsyncAgent({ agentId: asyncAgentId, description, @@ -694,25 +963,22 @@ export const AgentTool = buildTool({ // Don't link to parent's abort controller -- background agents should // survive when the user presses ESC to cancel the main thread. // They are killed explicitly via chat:killAgents. - toolUseId: toolUseContext.toolUseId - }); + toolUseId: toolUseContext.toolUseId, + }) // Register name → agentId for SendMessage routing. Post-registerAsyncAgent // so we don't leave a stale entry if spawn fails. Sync agents skipped — // coordinator is blocked, so SendMessage routing doesn't apply. if (name) { rootSetAppState(prev => { - const next = new Map(prev.agentNameRegistry); - next.set(name, asAgentId(asyncAgentId)); - return { - ...prev, - agentNameRegistry: next - }; - }); + const next = new Map(prev.agentNameRegistry) + next.set(name, asAgentId(asyncAgentId)) + return { ...prev, agentNameRegistry: next } + }) } // Wrap async agent execution in agent context for analytics attribution - const asyncAgentContext: SubagentContext = { + const asyncAgentContext = { agentId: asyncAgentId, // For subagents from teammates: use team lead's session // For subagents from main REPL: undefined (no parent session) @@ -720,37 +986,50 @@ export const AgentTool = buildTool({ agentType: 'subagent' as const, subagentName: selectedAgent.agentType, isBuiltIn: isBuiltInAgent(selectedAgent), - invokingRequestId: assistantMessage?.requestId as string | undefined, + invokingRequestId: assistantMessage?.requestId, invocationKind: 'spawn' as const, - invocationEmitted: false - }; + invocationEmitted: false, + } // Workload propagation: handlePromptSubmit wraps the entire turn in // runWithWorkload (AsyncLocalStorage). ALS context is captured at // invocation time — when this `void` fires — and survives every await // inside. No capture/restore needed; the detached closure sees the // parent turn's workload automatically, isolated from its finally. - void runWithAgentContext(asyncAgentContext, () => wrapWithCwd(() => runAsyncAgentLifecycle({ - taskId: agentBackgroundTask.agentId, - abortController: agentBackgroundTask.abortController!, - makeStream: onCacheSafeParams => runAgent({ - ...runAgentParams, - override: { - ...runAgentParams.override, - agentId: asAgentId(agentBackgroundTask.agentId), - abortController: agentBackgroundTask.abortController! - }, - onCacheSafeParams - }), - metadata, - description, - toolUseContext, - rootSetAppState, - agentIdForCleanup: asyncAgentId, - enableSummarization: isCoordinator || isForkSubagentEnabled() || getSdkAgentProgressSummariesEnabled(), - getWorktreeResult: cleanupWorktreeIfNeeded - }))); - const canReadOutputFile = toolUseContext.options.tools.some(t => toolMatchesName(t, FILE_READ_TOOL_NAME) || toolMatchesName(t, BASH_TOOL_NAME)); + void runWithAgentContext(asyncAgentContext, () => + wrapWithCwd(() => + runAsyncAgentLifecycle({ + taskId: agentBackgroundTask.agentId, + abortController: agentBackgroundTask.abortController!, + makeStream: onCacheSafeParams => + runAgent({ + ...runAgentParams, + override: { + ...runAgentParams.override, + agentId: asAgentId(agentBackgroundTask.agentId), + abortController: agentBackgroundTask.abortController!, + }, + onCacheSafeParams, + }), + metadata, + description, + toolUseContext, + rootSetAppState, + agentIdForCleanup: asyncAgentId, + enableSummarization: + isCoordinator || + isForkSubagentEnabled() || + getSdkAgentProgressSummariesEnabled(), + getWorktreeResult: cleanupWorktreeIfNeeded, + }), + ), + ) + + const canReadOutputFile = toolUseContext.options.tools.some( + t => + toolMatchesName(t, FILE_READ_TOOL_NAME) || + toolMatchesName(t, BASH_TOOL_NAME), + ) return { data: { isAsync: true as const, @@ -759,15 +1038,15 @@ export const AgentTool = buildTool({ description: description, prompt: prompt, outputFile: getTaskOutputPath(agentBackgroundTask.agentId), - canReadOutputFile - } - }; + canReadOutputFile, + }, + } } else { // Create an explicit agentId for sync agents - const syncAgentId = asAgentId(earlyAgentId); + const syncAgentId = asAgentId(earlyAgentId) // Set up agent context for sync execution (for analytics attribution) - const syncAgentContext: SubagentContext = { + const syncAgentContext = { agentId: syncAgentId, // For subagents from teammates: use team lead's session // For subagents from main REPL: undefined (no parent session) @@ -775,607 +1054,767 @@ export const AgentTool = buildTool({ agentType: 'subagent' as const, subagentName: selectedAgent.agentType, isBuiltIn: isBuiltInAgent(selectedAgent), - invokingRequestId: assistantMessage?.requestId as string | undefined, + invokingRequestId: assistantMessage?.requestId, invocationKind: 'spawn' as const, - invocationEmitted: false - }; + invocationEmitted: false, + } // Wrap entire sync agent execution in context for analytics attribution // and optionally in a worktree cwd override for filesystem isolation - return runWithAgentContext(syncAgentContext, () => wrapWithCwd(async () => { - const agentMessages: MessageType[] = []; - const agentStartTime = Date.now(); - const syncTracker = createProgressTracker(); - const syncResolveActivity = createActivityDescriptionResolver(toolUseContext.options.tools); + return runWithAgentContext(syncAgentContext, () => + wrapWithCwd(async () => { + const agentMessages: MessageType[] = [] + const agentStartTime = Date.now() + const syncTracker = createProgressTracker() + const syncResolveActivity = createActivityDescriptionResolver( + toolUseContext.options.tools, + ) - // Yield initial progress message to carry metadata (prompt) - if (promptMessages.length > 0) { - const normalizedPromptMessages = normalizeMessages(promptMessages); - const normalizedFirstMessage = normalizedPromptMessages.find((m): m is NormalizedUserMessage => m.type === 'user'); - if (normalizedFirstMessage && normalizedFirstMessage.type === 'user' && onProgress) { - onProgress({ - toolUseID: `agent_${assistantMessage.message.id}`, - data: { - message: normalizedFirstMessage, - type: 'agent_progress', - prompt, - agentId: syncAgentId - } - }); - } - } - - // Register as foreground task immediately so it can be backgrounded at any time - // Skip registration if background tasks are disabled - let foregroundTaskId: string | undefined; - // Create the background race promise once outside the loop — otherwise - // each iteration adds a new .then() reaction to the same pending - // promise, accumulating callbacks for the lifetime of the agent. - let backgroundPromise: Promise<{ - type: 'background'; - }> | undefined; - let cancelAutoBackground: (() => void) | undefined; - if (!isBackgroundTasksDisabled) { - const registration = registerAgentForeground({ - agentId: syncAgentId, - description, - prompt, - selectedAgent, - setAppState: rootSetAppState, - toolUseId: toolUseContext.toolUseId, - autoBackgroundMs: getAutoBackgroundMs() || undefined - }); - foregroundTaskId = registration.taskId; - backgroundPromise = registration.backgroundSignal.then(() => ({ - type: 'background' as const - })); - cancelAutoBackground = registration.cancelAutoBackground; - } - - // Track if we've shown the background hint UI - let backgroundHintShown = false; - // Track if the agent was backgrounded (cleanup handled by backgrounded finally) - let wasBackgrounded = false; - // Per-scope stop function — NOT shared with the backgrounded closure. - // idempotent: startAgentSummarization's stop() checks `stopped` flag. - let stopForegroundSummarization: (() => void) | undefined; - // const capture for sound type narrowing inside the callback below - const summaryTaskId = foregroundTaskId; - - // Get async iterator for the agent - const agentIterator = runAgent({ - ...runAgentParams, - override: { - ...runAgentParams.override, - agentId: syncAgentId - }, - onCacheSafeParams: summaryTaskId && getSdkAgentProgressSummariesEnabled() ? (params: CacheSafeParams) => { - const { - stop - } = startAgentSummarization(summaryTaskId, syncAgentId, params, rootSetAppState); - stopForegroundSummarization = stop; - } : undefined - })[Symbol.asyncIterator](); - - // Track if an error occurred during iteration - let syncAgentError: Error | undefined; - let wasAborted = false; - let worktreeResult: { - worktreePath?: string; - worktreeBranch?: string; - } = {}; - try { - while (true) { - const elapsed = Date.now() - agentStartTime; - - // Show background hint after threshold (but task is already registered) - // Skip if background tasks are disabled - if (!isBackgroundTasksDisabled && !backgroundHintShown && elapsed >= PROGRESS_THRESHOLD_MS && toolUseContext.setToolJSX) { - backgroundHintShown = true; - toolUseContext.setToolJSX({ - jsx: , - shouldHidePromptInput: false, - shouldContinueAnimation: true, - showSpinner: true - }); + // Yield initial progress message to carry metadata (prompt) + if (promptMessages.length > 0) { + const normalizedPromptMessages = normalizeMessages(promptMessages) + const normalizedFirstMessage = normalizedPromptMessages.find( + (m): m is NormalizedUserMessage => m.type === 'user', + ) + if ( + normalizedFirstMessage && + normalizedFirstMessage.type === 'user' && + onProgress + ) { + onProgress({ + toolUseID: `agent_${assistantMessage.message.id}`, + data: { + message: normalizedFirstMessage, + type: 'agent_progress', + prompt, + agentId: syncAgentId, + }, + }) } + } - // Race between next message and background signal - // If background tasks are disabled, just await the next message directly - const nextMessagePromise = agentIterator.next(); - const raceResult = backgroundPromise ? await Promise.race([nextMessagePromise.then(r => ({ - type: 'message' as const, - result: r - })), backgroundPromise]) : { - type: 'message' as const, - result: await nextMessagePromise - }; + // Register as foreground task immediately so it can be backgrounded at any time + // Skip registration if background tasks are disabled + let foregroundTaskId: string | undefined + // Create the background race promise once outside the loop — otherwise + // each iteration adds a new .then() reaction to the same pending + // promise, accumulating callbacks for the lifetime of the agent. + let backgroundPromise: Promise<{ type: 'background' }> | undefined + let cancelAutoBackground: (() => void) | undefined + if (!isBackgroundTasksDisabled) { + const registration = registerAgentForeground({ + agentId: syncAgentId, + description, + prompt, + selectedAgent, + setAppState: rootSetAppState, + toolUseId: toolUseContext.toolUseId, + autoBackgroundMs: getAutoBackgroundMs() || undefined, + }) + foregroundTaskId = registration.taskId + backgroundPromise = registration.backgroundSignal.then(() => ({ + type: 'background' as const, + })) + cancelAutoBackground = registration.cancelAutoBackground + } - // Check if we were backgrounded via backgroundAll() - // foregroundTaskId is guaranteed to be defined if raceResult.type is 'background' - // because backgroundPromise is only defined when foregroundTaskId is defined - if (raceResult.type === 'background' && foregroundTaskId) { - const appState = toolUseContext.getAppState(); - const task = appState.tasks[foregroundTaskId]; - if (isLocalAgentTask(task) && task.isBackgrounded) { - // Capture the taskId for use in the async callback - const backgroundedTaskId = foregroundTaskId; - wasBackgrounded = true; - // Stop foreground summarization; the backgrounded closure - // below owns its own independent stop function. - stopForegroundSummarization?.(); + // Track if we've shown the background hint UI + let backgroundHintShown = false + // Track if the agent was backgrounded (cleanup handled by backgrounded finally) + let wasBackgrounded = false + // Per-scope stop function — NOT shared with the backgrounded closure. + // idempotent: startAgentSummarization's stop() checks `stopped` flag. + let stopForegroundSummarization: (() => void) | undefined + // const capture for sound type narrowing inside the callback below + const summaryTaskId = foregroundTaskId - // Workload: inherited via ALS at `void` invocation time, - // same as the async-from-start path above. - // Continue agent in background and return async result - void runWithAgentContext(syncAgentContext, async () => { - let stopBackgroundedSummarization: (() => void) | undefined; - try { - // Clean up the foreground iterator so its finally block runs - // (releases MCP connections, session hooks, prompt cache tracking, etc.) - // Timeout prevents blocking if MCP server cleanup hangs. - // .catch() prevents unhandled rejection if timeout wins the race. - await Promise.race([agentIterator.return(undefined).catch(() => {}), sleep(1000)]); - // Initialize progress tracking from existing messages - const tracker = createProgressTracker(); - const resolveActivity2 = createActivityDescriptionResolver(toolUseContext.options.tools); - for (const existingMsg of agentMessages) { - updateProgressFromMessage(tracker, existingMsg, resolveActivity2, toolUseContext.options.tools); - } - for await (const msg of runAgent({ - ...runAgentParams, - isAsync: true, - // Agent is now running in background - override: { - ...runAgentParams.override, - agentId: asAgentId(backgroundedTaskId), - abortController: task.abortController - }, - onCacheSafeParams: getSdkAgentProgressSummariesEnabled() ? (params: CacheSafeParams) => { - const { - stop - } = startAgentSummarization(backgroundedTaskId, asAgentId(backgroundedTaskId), params, rootSetAppState); - stopBackgroundedSummarization = stop; - } : undefined - })) { - agentMessages.push(msg); + // Get async iterator for the agent + const agentIterator = runAgent({ + ...runAgentParams, + override: { + ...runAgentParams.override, + agentId: syncAgentId, + }, + onCacheSafeParams: + summaryTaskId && getSdkAgentProgressSummariesEnabled() + ? (params: CacheSafeParams) => { + const { stop } = startAgentSummarization( + summaryTaskId, + syncAgentId, + params, + rootSetAppState, + ) + stopForegroundSummarization = stop + } + : undefined, + })[Symbol.asyncIterator]() - // Track progress for backgrounded agents - updateProgressFromMessage(tracker, msg, resolveActivity2, toolUseContext.options.tools); - updateAsyncAgentProgress(backgroundedTaskId, getProgressUpdate(tracker), rootSetAppState); - const lastToolName = getLastToolUseName(msg); - if (lastToolName) { - emitTaskProgress(tracker, backgroundedTaskId, toolUseContext.toolUseId, description, startTime, lastToolName); + // Track if an error occurred during iteration + let syncAgentError: Error | undefined + let wasAborted = false + let worktreeResult: { + worktreePath?: string + worktreeBranch?: string + } = {} + + try { + while (true) { + const elapsed = Date.now() - agentStartTime + + // Show background hint after threshold (but task is already registered) + // Skip if background tasks are disabled + if ( + !isBackgroundTasksDisabled && + !backgroundHintShown && + elapsed >= PROGRESS_THRESHOLD_MS && + toolUseContext.setToolJSX + ) { + backgroundHintShown = true + toolUseContext.setToolJSX({ + jsx: , + shouldHidePromptInput: false, + shouldContinueAnimation: true, + showSpinner: true, + }) + } + + // Race between next message and background signal + // If background tasks are disabled, just await the next message directly + const nextMessagePromise = agentIterator.next() + const raceResult = backgroundPromise + ? await Promise.race([ + nextMessagePromise.then(r => ({ + type: 'message' as const, + result: r, + })), + backgroundPromise, + ]) + : { + type: 'message' as const, + result: await nextMessagePromise, + } + + // Check if we were backgrounded via backgroundAll() + // foregroundTaskId is guaranteed to be defined if raceResult.type is 'background' + // because backgroundPromise is only defined when foregroundTaskId is defined + if (raceResult.type === 'background' && foregroundTaskId) { + const appState = toolUseContext.getAppState() + const task = appState.tasks[foregroundTaskId] + if (isLocalAgentTask(task) && task.isBackgrounded) { + // Capture the taskId for use in the async callback + const backgroundedTaskId = foregroundTaskId + wasBackgrounded = true + // Stop foreground summarization; the backgrounded closure + // below owns its own independent stop function. + stopForegroundSummarization?.() + + // Workload: inherited via ALS at `void` invocation time, + // same as the async-from-start path above. + // Continue agent in background and return async result + void runWithAgentContext(syncAgentContext, async () => { + let stopBackgroundedSummarization: (() => void) | undefined + try { + // Clean up the foreground iterator so its finally block runs + // (releases MCP connections, session hooks, prompt cache tracking, etc.) + // Timeout prevents blocking if MCP server cleanup hangs. + // .catch() prevents unhandled rejection if timeout wins the race. + await Promise.race([ + agentIterator.return(undefined).catch(() => {}), + sleep(1000), + ]) + // Initialize progress tracking from existing messages + const tracker = createProgressTracker() + const resolveActivity2 = + createActivityDescriptionResolver( + toolUseContext.options.tools, + ) + for (const existingMsg of agentMessages) { + updateProgressFromMessage( + tracker, + existingMsg, + resolveActivity2, + toolUseContext.options.tools, + ) } - } - const agentResult = finalizeAgentTool(agentMessages, backgroundedTaskId, metadata); + for await (const msg of runAgent({ + ...runAgentParams, + isAsync: true, // Agent is now running in background + override: { + ...runAgentParams.override, + agentId: asAgentId(backgroundedTaskId), + abortController: task.abortController, + }, + onCacheSafeParams: getSdkAgentProgressSummariesEnabled() + ? (params: CacheSafeParams) => { + const { stop } = startAgentSummarization( + backgroundedTaskId, + asAgentId(backgroundedTaskId), + params, + rootSetAppState, + ) + stopBackgroundedSummarization = stop + } + : undefined, + })) { + agentMessages.push(msg) - // Mark task completed FIRST so TaskOutput(block=true) - // unblocks immediately. classifyHandoffIfNeeded and - // cleanupWorktreeIfNeeded can hang — they must not gate - // the status transition (gh-20236). - completeAsyncAgent(agentResult, rootSetAppState); + // Track progress for backgrounded agents + updateProgressFromMessage( + tracker, + msg, + resolveActivity2, + toolUseContext.options.tools, + ) + updateAsyncAgentProgress( + backgroundedTaskId, + getProgressUpdate(tracker), + rootSetAppState, + ) - // Extract text from agent result content for the notification - let finalMessage = extractTextContent(agentResult.content, '\n'); - if (feature('TRANSCRIPT_CLASSIFIER')) { - const backgroundedAppState = toolUseContext.getAppState(); - const handoffWarning = await classifyHandoffIfNeeded({ + const lastToolName = getLastToolUseName(msg) + if (lastToolName) { + emitTaskProgress( + tracker, + backgroundedTaskId, + toolUseContext.toolUseId, + description, + startTime, + lastToolName, + ) + } + } + const agentResult = finalizeAgentTool( agentMessages, - tools: toolUseContext.options.tools, - toolPermissionContext: backgroundedAppState.toolPermissionContext, - abortSignal: task.abortController!.signal, - subagentType: selectedAgent.agentType, - totalToolUseCount: agentResult.totalToolUseCount - }); - if (handoffWarning) { - finalMessage = `${handoffWarning}\n\n${finalMessage}`; - } - } + backgroundedTaskId, + metadata, + ) + + // Mark task completed FIRST so TaskOutput(block=true) + // unblocks immediately. classifyHandoffIfNeeded and + // cleanupWorktreeIfNeeded can hang — they must not gate + // the status transition (gh-20236). + completeAsyncAgent(agentResult, rootSetAppState) + + // Extract text from agent result content for the notification + let finalMessage = extractTextContent( + agentResult.content, + '\n', + ) + + if (feature('TRANSCRIPT_CLASSIFIER')) { + const backgroundedAppState = + toolUseContext.getAppState() + const handoffWarning = await classifyHandoffIfNeeded({ + agentMessages, + tools: toolUseContext.options.tools, + toolPermissionContext: + backgroundedAppState.toolPermissionContext, + abortSignal: task.abortController!.signal, + subagentType: selectedAgent.agentType, + totalToolUseCount: agentResult.totalToolUseCount, + }) + if (handoffWarning) { + finalMessage = `${handoffWarning}\n\n${finalMessage}` + } + } + + // Clean up worktree before notification so we can include it + const worktreeResult = await cleanupWorktreeIfNeeded() - // Clean up worktree before notification so we can include it - const worktreeResult = await cleanupWorktreeIfNeeded(); - enqueueAgentNotification({ - taskId: backgroundedTaskId, - description, - status: 'completed', - setAppState: rootSetAppState, - finalMessage, - usage: { - totalTokens: getTokenCountFromTracker(tracker), - toolUses: agentResult.totalToolUseCount, - durationMs: agentResult.totalDurationMs - }, - toolUseId: toolUseContext.toolUseId, - ...worktreeResult - }); - } catch (error) { - if (error instanceof AbortError) { - // Transition status BEFORE worktree cleanup so - // TaskOutput unblocks even if git hangs (gh-20236). - killAsyncAgent(backgroundedTaskId, rootSetAppState); - logEvent('tengu_agent_tool_terminated', { - agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - duration_ms: Date.now() - metadata.startTime, - is_async: true, - is_built_in_agent: metadata.isBuiltInAgent, - reason: 'user_cancel_background' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - const worktreeResult = await cleanupWorktreeIfNeeded(); - const partialResult = extractPartialResult(agentMessages); enqueueAgentNotification({ taskId: backgroundedTaskId, description, - status: 'killed', + status: 'completed', + setAppState: rootSetAppState, + finalMessage, + usage: { + totalTokens: getTokenCountFromTracker(tracker), + toolUses: agentResult.totalToolUseCount, + durationMs: agentResult.totalDurationMs, + }, + toolUseId: toolUseContext.toolUseId, + ...worktreeResult, + }) + } catch (error) { + if (error instanceof AbortError) { + // Transition status BEFORE worktree cleanup so + // TaskOutput unblocks even if git hangs (gh-20236). + killAsyncAgent(backgroundedTaskId, rootSetAppState) + logEvent('tengu_agent_tool_terminated', { + agent_type: + metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: + metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + duration_ms: Date.now() - metadata.startTime, + is_async: true, + is_built_in_agent: metadata.isBuiltInAgent, + reason: + 'user_cancel_background' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + const worktreeResult = await cleanupWorktreeIfNeeded() + const partialResult = + extractPartialResult(agentMessages) + enqueueAgentNotification({ + taskId: backgroundedTaskId, + description, + status: 'killed', + setAppState: rootSetAppState, + toolUseId: toolUseContext.toolUseId, + finalMessage: partialResult, + ...worktreeResult, + }) + return + } + const errMsg = errorMessage(error) + failAsyncAgent( + backgroundedTaskId, + errMsg, + rootSetAppState, + ) + const worktreeResult = await cleanupWorktreeIfNeeded() + enqueueAgentNotification({ + taskId: backgroundedTaskId, + description, + status: 'failed', + error: errMsg, setAppState: rootSetAppState, toolUseId: toolUseContext.toolUseId, - finalMessage: partialResult, - ...worktreeResult - }); - return; + ...worktreeResult, + }) + } finally { + stopBackgroundedSummarization?.() + clearInvokedSkillsForAgent(syncAgentId) + clearDumpState(syncAgentId) + // Note: worktree cleanup is done before enqueueAgentNotification + // in both try and catch paths so we can include worktree info } - const errMsg = errorMessage(error); - failAsyncAgent(backgroundedTaskId, errMsg, rootSetAppState); - const worktreeResult = await cleanupWorktreeIfNeeded(); - enqueueAgentNotification({ - taskId: backgroundedTaskId, - description, - status: 'failed', - error: errMsg, - setAppState: rootSetAppState, - toolUseId: toolUseContext.toolUseId, - ...worktreeResult - }); - } finally { - stopBackgroundedSummarization?.(); - clearInvokedSkillsForAgent(syncAgentId); - clearDumpState(syncAgentId); - // Note: worktree cleanup is done before enqueueAgentNotification - // in both try and catch paths so we can include worktree info - } - }); + }) - // Return async_launched result immediately - const canReadOutputFile = toolUseContext.options.tools.some(t => toolMatchesName(t, FILE_READ_TOOL_NAME) || toolMatchesName(t, BASH_TOOL_NAME)); - return { - data: { - isAsync: true as const, - status: 'async_launched' as const, - agentId: backgroundedTaskId, - description: description, - prompt: prompt, - outputFile: getTaskOutputPath(backgroundedTaskId), - canReadOutputFile - } - }; - } - } - - // Process the message from the race result - if (raceResult.type !== 'message') { - // This shouldn't happen - background case handled above - continue; - } - const { - result - } = raceResult; - if (result.done) break; - const message = result.value as MessageType; - agentMessages.push(message); - - // Emit task_progress for the VS Code subagent panel - updateProgressFromMessage(syncTracker, message, syncResolveActivity, toolUseContext.options.tools); - if (foregroundTaskId) { - const lastToolName = getLastToolUseName(message); - if (lastToolName) { - emitTaskProgress(syncTracker, foregroundTaskId, toolUseContext.toolUseId, description, agentStartTime, lastToolName); - // Keep AppState task.progress in sync when SDK summaries are - // enabled, so updateAgentSummary reads correct token/tool counts - // instead of zeros. - if (getSdkAgentProgressSummariesEnabled()) { - updateAsyncAgentProgress(foregroundTaskId, getProgressUpdate(syncTracker), rootSetAppState); - } - } - } - - // Forward bash_progress events from sub-agent to parent so the SDK - // receives tool_progress events just as it does for the main agent. - if (message.type === 'progress' && ((message.data as { type?: string })?.type === 'bash_progress' || (message.data as { type?: string })?.type === 'powershell_progress') && onProgress) { - onProgress({ - toolUseID: message.toolUseID as string, - data: message.data - }); - } - if (message.type !== 'assistant' && message.type !== 'user') { - continue; - } - - // Increment token count in spinner for assistant messages - // Subagent streaming events are filtered out in runAgent.ts, so we - // need to count tokens from completed messages here - if (message.type === 'assistant') { - const contentLength = getAssistantMessageContentLength(message as AssistantMessage); - if (contentLength > 0) { - toolUseContext.setResponseLength(len => len + contentLength); - } - } - const normalizedNew = normalizeMessages([message]); - for (const m of normalizedNew) { - for (const content of (m.message.content as unknown as Array<{ type: string; [key: string]: unknown }>)) { - if (content.type !== 'tool_use' && content.type !== 'tool_result') { - continue; - } - - // Forward progress updates - if (onProgress) { - onProgress({ - toolUseID: `agent_${assistantMessage.message.id}`, + // Return async_launched result immediately + const canReadOutputFile = toolUseContext.options.tools.some( + t => + toolMatchesName(t, FILE_READ_TOOL_NAME) || + toolMatchesName(t, BASH_TOOL_NAME), + ) + return { data: { - message: m, - type: 'agent_progress', - // prompt only needed on first progress message (UI.tsx:624 - // reads progressMessages[0]). Omit here to avoid duplication. - prompt: '', - agentId: syncAgentId - } - }); + isAsync: true as const, + status: 'async_launched' as const, + agentId: backgroundedTaskId, + description: description, + prompt: prompt, + outputFile: getTaskOutputPath(backgroundedTaskId), + canReadOutputFile, + }, + } } } + + // Process the message from the race result + if (raceResult.type !== 'message') { + // This shouldn't happen - background case handled above + continue + } + const { result } = raceResult + if (result.done) break + const message = result.value + + agentMessages.push(message) + + // Emit task_progress for the VS Code subagent panel + updateProgressFromMessage( + syncTracker, + message, + syncResolveActivity, + toolUseContext.options.tools, + ) + if (foregroundTaskId) { + const lastToolName = getLastToolUseName(message) + if (lastToolName) { + emitTaskProgress( + syncTracker, + foregroundTaskId, + toolUseContext.toolUseId, + description, + agentStartTime, + lastToolName, + ) + // Keep AppState task.progress in sync when SDK summaries are + // enabled, so updateAgentSummary reads correct token/tool counts + // instead of zeros. + if (getSdkAgentProgressSummariesEnabled()) { + updateAsyncAgentProgress( + foregroundTaskId, + getProgressUpdate(syncTracker), + rootSetAppState, + ) + } + } + } + + // Forward bash_progress events from sub-agent to parent so the SDK + // receives tool_progress events just as it does for the main agent. + if ( + message.type === 'progress' && + (message.data.type === 'bash_progress' || + message.data.type === 'powershell_progress') && + onProgress + ) { + onProgress({ + toolUseID: message.toolUseID, + data: message.data, + }) + } + + if (message.type !== 'assistant' && message.type !== 'user') { + continue + } + + // Increment token count in spinner for assistant messages + // Subagent streaming events are filtered out in runAgent.ts, so we + // need to count tokens from completed messages here + if (message.type === 'assistant') { + const contentLength = getAssistantMessageContentLength(message) + if (contentLength > 0) { + toolUseContext.setResponseLength(len => len + contentLength) + } + } + + const normalizedNew = normalizeMessages([message]) + for (const m of normalizedNew) { + for (const content of m.message.content) { + if ( + content.type !== 'tool_use' && + content.type !== 'tool_result' + ) { + continue + } + + // Forward progress updates + if (onProgress) { + onProgress({ + toolUseID: `agent_${assistantMessage.message.id}`, + data: { + message: m, + type: 'agent_progress', + // prompt only needed on first progress message (UI.tsx:624 + // reads progressMessages[0]). Omit here to avoid duplication. + prompt: '', + agentId: syncAgentId, + }, + }) + } + } + } + } + } catch (error) { + // Handle errors from the sync agent loop + // AbortError should be re-thrown for proper interruption handling + if (error instanceof AbortError) { + wasAborted = true + logEvent('tengu_agent_tool_terminated', { + agent_type: + metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: + metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + duration_ms: Date.now() - metadata.startTime, + is_async: false, + is_built_in_agent: metadata.isBuiltInAgent, + reason: + 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + throw error + } + + // Log the error for debugging + logForDebugging(`Sync agent error: ${errorMessage(error)}`, { + level: 'error', + }) + + // Store the error to handle after cleanup + syncAgentError = toError(error) + } finally { + // Clear the background hint UI + if (toolUseContext.setToolJSX) { + toolUseContext.setToolJSX(null) + } + + // Stop foreground summarization. Idempotent — if already stopped at + // the backgrounding transition, this is a no-op. The backgrounded + // closure owns a separate stop function (stopBackgroundedSummarization). + stopForegroundSummarization?.() + + // Unregister foreground task if agent completed without being backgrounded + if (foregroundTaskId) { + unregisterAgentForeground(foregroundTaskId, rootSetAppState) + // Notify SDK consumers (e.g. VS Code subagent panel) that this + // foreground agent is done. Goes through drainSdkEvents() — does + // NOT trigger the print.ts XML task_notification parser or the LLM loop. + if (!wasBackgrounded) { + const progress = getProgressUpdate(syncTracker) + enqueueSdkEvent({ + type: 'system', + subtype: 'task_notification', + task_id: foregroundTaskId, + tool_use_id: toolUseContext.toolUseId, + status: syncAgentError + ? 'failed' + : wasAborted + ? 'stopped' + : 'completed', + output_file: '', + summary: description, + usage: { + total_tokens: progress.tokenCount, + tool_uses: progress.toolUseCount, + duration_ms: Date.now() - agentStartTime, + }, + }) + } + } + + // Clean up scoped skills so they don't accumulate in the global map + clearInvokedSkillsForAgent(syncAgentId) + + // Clean up dumpState entry for this agent to prevent unbounded growth + // Skip if backgrounded — the backgrounded agent's finally handles cleanup + if (!wasBackgrounded) { + clearDumpState(syncAgentId) + } + + // Cancel auto-background timer if agent completed before it fired + cancelAutoBackground?.() + + // Clean up worktree if applicable (in finally to handle abort/error paths) + // Skip if backgrounded — the background continuation is still running in it + if (!wasBackgrounded) { + worktreeResult = await cleanupWorktreeIfNeeded() } } - } catch (error) { - // Handle errors from the sync agent loop - // AbortError should be re-thrown for proper interruption handling - if (error instanceof AbortError) { - wasAborted = true; + + // Re-throw abort errors + // TODO: Find a cleaner way to express this + const lastMessage = agentMessages.findLast( + _ => _.type !== 'system' && _.type !== 'progress', + ) + if (lastMessage && isSyntheticMessage(lastMessage)) { logEvent('tengu_agent_tool_terminated', { - agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + agent_type: + metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: + metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, duration_ms: Date.now() - metadata.startTime, is_async: false, is_built_in_agent: metadata.isBuiltInAgent, - reason: 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - throw error; + reason: + 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + throw new AbortError() } - // Log the error for debugging - logForDebugging(`Sync agent error: ${errorMessage(error)}`, { - level: 'error' - }); + // If an error occurred during iteration, try to return a result with + // whatever messages we have. If we have no assistant messages, + // re-throw the error so it's properly handled by the tool framework. + if (syncAgentError) { + // Check if we have any assistant messages to return + const hasAssistantMessages = agentMessages.some( + msg => msg.type === 'assistant', + ) - // Store the error to handle after cleanup - syncAgentError = toError(error); - } finally { - // Clear the background hint UI - if (toolUseContext.setToolJSX) { - toolUseContext.setToolJSX(null); + if (!hasAssistantMessages) { + // No messages collected, re-throw the error + throw syncAgentError + } + + // We have some messages, try to finalize and return them + // This allows the parent agent to see partial progress even after an error + logForDebugging( + `Sync agent recovering from error with ${agentMessages.length} messages`, + ) } - // Stop foreground summarization. Idempotent — if already stopped at - // the backgrounding transition, this is a no-op. The backgrounded - // closure owns a separate stop function (stopBackgroundedSummarization). - stopForegroundSummarization?.(); + const agentResult = finalizeAgentTool( + agentMessages, + syncAgentId, + metadata, + ) - // Unregister foreground task if agent completed without being backgrounded - if (foregroundTaskId) { - unregisterAgentForeground(foregroundTaskId, rootSetAppState); - // Notify SDK consumers (e.g. VS Code subagent panel) that this - // foreground agent is done. Goes through drainSdkEvents() — does - // NOT trigger the print.ts XML task_notification parser or the LLM loop. - if (!wasBackgrounded) { - const progress = getProgressUpdate(syncTracker); - enqueueSdkEvent({ - type: 'system', - subtype: 'task_notification', - task_id: foregroundTaskId, - tool_use_id: toolUseContext.toolUseId, - status: syncAgentError ? 'failed' : wasAborted ? 'stopped' : 'completed', - output_file: '', - summary: description, - usage: { - total_tokens: progress.tokenCount, - tool_uses: progress.toolUseCount, - duration_ms: Date.now() - agentStartTime - } - }); + if (feature('TRANSCRIPT_CLASSIFIER')) { + const currentAppState = toolUseContext.getAppState() + const handoffWarning = await classifyHandoffIfNeeded({ + agentMessages, + tools: toolUseContext.options.tools, + toolPermissionContext: currentAppState.toolPermissionContext, + abortSignal: toolUseContext.abortController.signal, + subagentType: selectedAgent.agentType, + totalToolUseCount: agentResult.totalToolUseCount, + }) + if (handoffWarning) { + agentResult.content = [ + { type: 'text' as const, text: handoffWarning }, + ...agentResult.content, + ] } } - // Clean up scoped skills so they don't accumulate in the global map - clearInvokedSkillsForAgent(syncAgentId); - - // Clean up dumpState entry for this agent to prevent unbounded growth - // Skip if backgrounded — the backgrounded agent's finally handles cleanup - if (!wasBackgrounded) { - clearDumpState(syncAgentId); + return { + data: { + status: 'completed' as const, + prompt, + ...agentResult, + ...worktreeResult, + }, } - - // Cancel auto-background timer if agent completed before it fired - cancelAutoBackground?.(); - - // Clean up worktree if applicable (in finally to handle abort/error paths) - // Skip if backgrounded — the background continuation is still running in it - if (!wasBackgrounded) { - worktreeResult = await cleanupWorktreeIfNeeded(); - } - } - - // Re-throw abort errors - // TODO: Find a cleaner way to express this - const lastMessage = agentMessages.findLast(_ => _.type !== 'system' && _.type !== 'progress'); - if (lastMessage && isSyntheticMessage(lastMessage)) { - logEvent('tengu_agent_tool_terminated', { - agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - duration_ms: Date.now() - metadata.startTime, - is_async: false, - is_built_in_agent: metadata.isBuiltInAgent, - reason: 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - throw new AbortError(); - } - - // If an error occurred during iteration, try to return a result with - // whatever messages we have. If we have no assistant messages, - // re-throw the error so it's properly handled by the tool framework. - if (syncAgentError) { - // Check if we have any assistant messages to return - const hasAssistantMessages = agentMessages.some(msg => msg.type === 'assistant'); - if (!hasAssistantMessages) { - // No messages collected, re-throw the error - throw syncAgentError; - } - - // We have some messages, try to finalize and return them - // This allows the parent agent to see partial progress even after an error - logForDebugging(`Sync agent recovering from error with ${agentMessages.length} messages`); - } - const agentResult = finalizeAgentTool(agentMessages, syncAgentId, metadata); - if (feature('TRANSCRIPT_CLASSIFIER')) { - const currentAppState = toolUseContext.getAppState(); - const handoffWarning = await classifyHandoffIfNeeded({ - agentMessages, - tools: toolUseContext.options.tools, - toolPermissionContext: currentAppState.toolPermissionContext, - abortSignal: toolUseContext.abortController.signal, - subagentType: selectedAgent.agentType, - totalToolUseCount: agentResult.totalToolUseCount - }); - if (handoffWarning) { - agentResult.content = [{ - type: 'text' as const, - text: handoffWarning - }, ...agentResult.content]; - } - } - return { - data: { - status: 'completed' as const, - prompt, - ...agentResult, - ...worktreeResult - } - }; - })); + }), + ) } }, isReadOnly() { - return true; // delegates permission checks to its underlying tools + return true // delegates permission checks to its underlying tools }, toAutoClassifierInput(input) { - const i = input as AgentToolInput; - const tags = [i.subagent_type, i.mode ? `mode=${i.mode}` : undefined].filter((t): t is string => t !== undefined); - const prefix = tags.length > 0 ? `(${tags.join(', ')}): ` : ': '; - return `${prefix}${i.prompt}`; + const i = input as AgentToolInput + const tags = [ + i.subagent_type, + i.mode ? `mode=${i.mode}` : undefined, + ].filter((t): t is string => t !== undefined) + const prefix = tags.length > 0 ? `(${tags.join(', ')}): ` : ': ' + return `${prefix}${i.prompt}` }, isConcurrencySafe() { - return true; + return true }, userFacingName, userFacingNameBackgroundColor, getActivityDescription(input) { - return input?.description ?? 'Running task'; + return input?.description ?? 'Running task' }, async checkPermissions(input, context): Promise { - const appState = context.getAppState(); + const appState = context.getAppState() // Only route through auto mode classifier when in auto mode // In all other modes, auto-approve sub-agent generation - // Note: "external" === 'ant' guard enables dead code elimination for external builds - if ((process.env.USER_TYPE) === 'ant' && appState.toolPermissionContext.mode === 'auto') { + // Note: process.env.USER_TYPE === 'ant' guard enables dead code elimination for external builds + if ( + process.env.USER_TYPE === 'ant' && + appState.toolPermissionContext.mode === 'auto' + ) { return { behavior: 'passthrough', - message: 'Agent tool requires permission to spawn sub-agents.' - }; + message: 'Agent tool requires permission to spawn sub-agents.', + } } - return { - behavior: 'allow', - updatedInput: input - }; + + return { behavior: 'allow', updatedInput: input } }, mapToolResultToToolResultBlockParam(data, toolUseID) { // Multi-agent spawn result - const internalData = data as InternalOutput; - if (typeof internalData === 'object' && internalData !== null && 'status' in internalData && internalData.status === 'teammate_spawned') { - const spawnData = internalData as TeammateSpawnedOutput; + const internalData = data as InternalOutput + if ( + typeof internalData === 'object' && + internalData !== null && + 'status' in internalData && + internalData.status === 'teammate_spawned' + ) { + const spawnData = internalData as TeammateSpawnedOutput return { tool_use_id: toolUseID, type: 'tool_result', - content: [{ - type: 'text', - text: `Spawned successfully. + content: [ + { + type: 'text', + text: `Spawned successfully. agent_id: ${spawnData.teammate_id} name: ${spawnData.name} team_name: ${spawnData.team_name} -The agent is now running and will receive instructions via mailbox.` - }] - }; +The agent is now running and will receive instructions via mailbox.`, + }, + ], + } } if ('status' in internalData && internalData.status === 'remote_launched') { - const r = internalData; + const r = internalData return { tool_use_id: toolUseID, type: 'tool_result', - content: [{ - type: 'text', - text: `Remote agent launched in CCR.\ntaskId: ${r.taskId}\nsession_url: ${r.sessionUrl}\noutput_file: ${r.outputFile}\nThe agent is running remotely. You will be notified automatically when it completes.\nBriefly tell the user what you launched and end your response.` - }] - }; + content: [ + { + type: 'text', + text: `Remote agent launched in CCR.\ntaskId: ${r.taskId}\nsession_url: ${r.sessionUrl}\noutput_file: ${r.outputFile}\nThe agent is running remotely. You will be notified automatically when it completes.\nBriefly tell the user what you launched and end your response.`, + }, + ], + } } if (data.status === 'async_launched') { - const prefix = `Async agent launched successfully.\nagentId: ${data.agentId} (internal ID - do not mention to user. Use SendMessage with to: '${data.agentId}' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.`; - const instructions = data.canReadOutputFile ? `Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.\noutput_file: ${data.outputFile}\nIf asked, you can check progress before completion by using ${FILE_READ_TOOL_NAME} or ${BASH_TOOL_NAME} tail on the output file.` : `Briefly tell the user what you launched and end your response. Do not generate any other text — agent results will arrive in a subsequent message.`; - const text = `${prefix}\n${instructions}`; + const prefix = `Async agent launched successfully.\nagentId: ${data.agentId} (internal ID - do not mention to user. Use SendMessage with to: '${data.agentId}' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.` + const instructions = data.canReadOutputFile + ? `Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.\noutput_file: ${data.outputFile}\nIf asked, you can check progress before completion by using ${FILE_READ_TOOL_NAME} or ${BASH_TOOL_NAME} tail on the output file.` + : `Briefly tell the user what you launched and end your response. Do not generate any other text — agent results will arrive in a subsequent message.` + const text = `${prefix}\n${instructions}` return { tool_use_id: toolUseID, type: 'tool_result', - content: [{ - type: 'text', - text - }] - }; + content: [ + { + type: 'text', + text, + }, + ], + } } if (data.status === 'completed') { - const worktreeData = data as Record; - const worktreeInfoText = worktreeData.worktreePath ? `\nworktreePath: ${worktreeData.worktreePath}\nworktreeBranch: ${worktreeData.worktreeBranch}` : ''; + const worktreeData = data as Record + const worktreeInfoText = worktreeData.worktreePath + ? `\nworktreePath: ${worktreeData.worktreePath}\nworktreeBranch: ${worktreeData.worktreeBranch}` + : '' // If the subagent completes with no content, the tool_result is just the // agentId/usage trailer below — a metadata-only block at the prompt tail. // Some models read that as "nothing to act on" and end their turn // immediately. Say so explicitly so the parent has something to react to. - const contentOrMarker = data.content.length > 0 ? data.content : [{ - type: 'text' as const, - text: '(Subagent completed but returned no output.)' - }]; + const contentOrMarker = + data.content.length > 0 + ? data.content + : [ + { + type: 'text' as const, + text: '(Subagent completed but returned no output.)', + }, + ] // One-shot built-ins (Explore, Plan) are never continued via SendMessage // — the agentId hint and block are dead weight (~135 chars × // 34M Explore runs/week ≈ 1-2 Gtok/week). Telemetry doesn't parse this // block (it uses logEvent in finalizeAgentTool), so dropping is safe. // agentType is optional for resume compat — missing means show trailer. - if (data.agentType && ONE_SHOT_BUILTIN_AGENT_TYPES.has(data.agentType) && !worktreeInfoText) { + if ( + data.agentType && + ONE_SHOT_BUILTIN_AGENT_TYPES.has(data.agentType) && + !worktreeInfoText + ) { return { tool_use_id: toolUseID, type: 'tool_result', - content: contentOrMarker - }; + content: contentOrMarker, + } } return { tool_use_id: toolUseID, type: 'tool_result', - content: [...contentOrMarker, { - type: 'text', - text: `agentId: ${data.agentId} (use SendMessage with to: '${data.agentId}' to continue this agent)${worktreeInfoText} + content: [ + ...contentOrMarker, + { + type: 'text', + text: `agentId: ${data.agentId} (use SendMessage with to: '${data.agentId}' to continue this agent)${worktreeInfoText} total_tokens: ${data.totalTokens} tool_uses: ${data.totalToolUseCount} -duration_ms: ${data.totalDurationMs}` - }] - }; +duration_ms: ${data.totalDurationMs}`, + }, + ], + } } - data satisfies never; - throw new Error(`Unexpected agent tool result status: ${(data as { - status: string; - }).status}`); + data satisfies never + throw new Error( + `Unexpected agent tool result status: ${(data as { status: string }).status}`, + ) }, renderToolResultMessage, renderToolUseMessage, @@ -1383,15 +1822,13 @@ duration_ms: ${data.totalDurationMs}` renderToolUseProgressMessage, renderToolUseRejectedMessage, renderToolUseErrorMessage, - renderGroupedToolUse: renderGroupedAgentToolUse -} satisfies ToolDef); -function resolveTeamName(input: { - team_name?: string; -}, appState: { - teamContext?: { - teamName: string; - }; -}): string | undefined { - if (!isAgentSwarmsEnabled()) return undefined; - return input.team_name || appState.teamContext?.teamName; + renderGroupedToolUse: renderGroupedAgentToolUse, +} satisfies ToolDef) + +function resolveTeamName( + input: { team_name?: string }, + appState: { teamContext?: { teamName: string } }, +): string | undefined { + if (!isAgentSwarmsEnabled()) return undefined + return input.team_name || appState.teamContext?.teamName } diff --git a/src/tools/AgentTool/UI.tsx b/src/tools/AgentTool/UI.tsx index ff0eb632f..aaa312e20 100644 --- a/src/tools/AgentTool/UI.tsx +++ b/src/tools/AgentTool/UI.tsx @@ -1,36 +1,57 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import * as React from 'react'; -import { ConfigurableShortcutHint } from 'src/components/ConfigurableShortcutHint.js'; -import { CtrlOToExpand, SubAgentProvider } from 'src/components/CtrlOToExpand.js'; -import { Byline } from 'src/components/design-system/Byline.js'; -import { KeyboardShortcutHint } from 'src/components/design-system/KeyboardShortcutHint.js'; -import type { z } from 'zod/v4'; -import { AgentProgressLine } from '../../components/AgentProgressLine.js'; -import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js'; -import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage.js'; -import { Markdown } from '../../components/Markdown.js'; -import { Message as MessageComponent } from '../../components/Message.js'; -import { MessageResponse } from '../../components/MessageResponse.js'; -import { ToolUseLoader } from '../../components/ToolUseLoader.js'; -import { Box, Text } from '../../ink.js'; -import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js'; -import { findToolByName, type Tools } from '../../Tool.js'; -import type { Message, ProgressMessage } from '../../types/message.js'; -import type { AgentToolProgress } from '../../types/tools.js'; -import { count } from '../../utils/array.js'; -import { getSearchOrReadFromContent, getSearchReadSummaryText } from '../../utils/collapseReadSearch.js'; -import { getDisplayPath } from '../../utils/file.js'; -import { formatDuration, formatNumber } from '../../utils/format.js'; -import { buildSubagentLookups, createAssistantMessage, EMPTY_LOOKUPS } from '../../utils/messages.js'; -import type { ModelAlias } from '../../utils/model/aliases.js'; -import { getMainLoopModel, parseUserSpecifiedModel, renderModelName } from '../../utils/model/model.js'; -import type { Theme, ThemeName } from '../../utils/theme.js'; -import type { outputSchema, Progress, RemoteLaunchedOutput } from './AgentTool.js'; -import { inputSchema } from './AgentTool.js'; -import { getAgentColor } from './agentColorManager.js'; -import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'; -const MAX_PROGRESS_MESSAGES_TO_SHOW = 3; +import type { + ToolResultBlockParam, + ToolUseBlockParam, +} from '@anthropic-ai/sdk/resources/index.mjs' +import * as React from 'react' +import { ConfigurableShortcutHint } from 'src/components/ConfigurableShortcutHint.js' +import { + CtrlOToExpand, + SubAgentProvider, +} from 'src/components/CtrlOToExpand.js' +import { Byline } from 'src/components/design-system/Byline.js' +import { KeyboardShortcutHint } from 'src/components/design-system/KeyboardShortcutHint.js' +import type { z } from 'zod/v4' +import { AgentProgressLine } from '../../components/AgentProgressLine.js' +import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js' +import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage.js' +import { Markdown } from '../../components/Markdown.js' +import { Message as MessageComponent } from '../../components/Message.js' +import { MessageResponse } from '../../components/MessageResponse.js' +import { ToolUseLoader } from '../../components/ToolUseLoader.js' +import { Box, Text } from '../../ink.js' +import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js' +import { findToolByName, type Tools } from '../../Tool.js' +import type { Message, ProgressMessage } from '../../types/message.js' +import type { AgentToolProgress } from '../../types/tools.js' +import { count } from '../../utils/array.js' +import { + getSearchOrReadFromContent, + getSearchReadSummaryText, +} from '../../utils/collapseReadSearch.js' +import { getDisplayPath } from '../../utils/file.js' +import { formatDuration, formatNumber } from '../../utils/format.js' +import { + buildSubagentLookups, + createAssistantMessage, + EMPTY_LOOKUPS, +} from '../../utils/messages.js' +import type { ModelAlias } from '../../utils/model/aliases.js' +import { + getMainLoopModel, + parseUserSpecifiedModel, + renderModelName, +} from '../../utils/model/model.js' +import type { Theme, ThemeName } from '../../utils/theme.js' +import type { + outputSchema, + Progress, + RemoteLaunchedOutput, +} from './AgentTool.js' +import { inputSchema } from './AgentTool.js' +import { getAgentColor } from './agentColorManager.js' +import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js' + +const MAX_PROGRESS_MESSAGES_TO_SHOW = 3 /** * Guard: checks if progress data has a `message` field (agent_progress or @@ -39,10 +60,10 @@ const MAX_PROGRESS_MESSAGES_TO_SHOW = 3; */ function hasProgressMessage(data: Progress): data is AgentToolProgress { if (!('message' in data)) { - return false; + return false } - const msg = (data as AgentToolProgress).message; - return msg != null && typeof msg === 'object' && 'type' in msg; + const msg = (data as AgentToolProgress).message + return msg != null && typeof msg === 'object' && 'type' in msg } /** @@ -52,93 +73,112 @@ function hasProgressMessage(data: Progress): data is AgentToolProgress { * For tool_result messages, uses the provided `toolUseByID` map to find the * corresponding tool_use block instead of relying on `normalizedMessages`. */ -function getSearchOrReadInfo(progressMessage: ProgressMessage, tools: Tools, toolUseByID: Map): { - isSearch: boolean; - isRead: boolean; - isREPL: boolean; -} | null { +function getSearchOrReadInfo( + progressMessage: ProgressMessage, + tools: Tools, + toolUseByID: Map, +): { isSearch: boolean; isRead: boolean; isREPL: boolean } | null { if (!hasProgressMessage(progressMessage.data)) { - return null; + return null } - const message = progressMessage.data.message; + const message = progressMessage.data.message // Check tool_use (assistant message) if (message.type === 'assistant') { - return getSearchOrReadFromContent(message.message.content[0], tools); + return getSearchOrReadFromContent(message.message.content[0], tools) } // Check tool_result (user message) - find corresponding tool use from the map if (message.type === 'user') { - const content = message.message.content[0]; + const content = message.message.content[0] if (content?.type === 'tool_result') { - const toolUse = toolUseByID.get(content.tool_use_id); + const toolUse = toolUseByID.get(content.tool_use_id) if (toolUse) { - return getSearchOrReadFromContent(toolUse, tools); + return getSearchOrReadFromContent(toolUse, tools) } } } - return null; + + return null } + type SummaryMessage = { - type: 'summary'; - searchCount: number; - readCount: number; - replCount: number; - uuid: string; - isActive: boolean; // true if still in progress (last message was tool_use, not tool_result) -}; -type ProcessedMessage = { - type: 'original'; - message: ProgressMessage; -} | SummaryMessage; + type: 'summary' + searchCount: number + readCount: number + replCount: number + uuid: string + isActive: boolean // true if still in progress (last message was tool_use, not tool_result) +} + +type ProcessedMessage = + | { type: 'original'; message: ProgressMessage } + | SummaryMessage /** * Process progress messages to group consecutive search/read operations into summaries. * For ants only - returns original messages for non-ants. * @param isAgentRunning - If true, the last group is always marked as active (in progress) */ -function processProgressMessages(messages: ProgressMessage[], tools: Tools, isAgentRunning: boolean): ProcessedMessage[] { +function processProgressMessages( + messages: ProgressMessage[], + tools: Tools, + isAgentRunning: boolean, +): ProcessedMessage[] { // Only process for ants - if ((process.env.USER_TYPE) !== 'ant') { - return messages.filter((m): m is ProgressMessage => hasProgressMessage(m.data) && m.data.message.type !== 'user').map(m => ({ - type: 'original', - message: m - })); + if ("external" !== 'ant') { + return messages + .filter( + (m): m is ProgressMessage => + hasProgressMessage(m.data) && m.data.message.type !== 'user', + ) + .map(m => ({ type: 'original', message: m })) } - const result: ProcessedMessage[] = []; + + const result: ProcessedMessage[] = [] let currentGroup: { - searchCount: number; - readCount: number; - replCount: number; - startUuid: string; - } | null = null; + searchCount: number + readCount: number + replCount: number + startUuid: string + } | null = null + function flushGroup(isActive: boolean): void { - if (currentGroup && (currentGroup.searchCount > 0 || currentGroup.readCount > 0 || currentGroup.replCount > 0)) { + if ( + currentGroup && + (currentGroup.searchCount > 0 || + currentGroup.readCount > 0 || + currentGroup.replCount > 0) + ) { result.push({ type: 'summary', searchCount: currentGroup.searchCount, readCount: currentGroup.readCount, replCount: currentGroup.replCount, uuid: `summary-${currentGroup.startUuid}`, - isActive - }); + isActive, + }) } - currentGroup = null; + currentGroup = null } - const agentMessages = messages.filter((m): m is ProgressMessage => hasProgressMessage(m.data)); + + const agentMessages = messages.filter( + (m): m is ProgressMessage => hasProgressMessage(m.data), + ) // Build tool_use lookup incrementally as we iterate - const toolUseByID = new Map(); + const toolUseByID = new Map() for (const msg of agentMessages) { // Track tool_use blocks as we see them if (msg.data.message.type === 'assistant') { for (const c of msg.data.message.message.content) { if (c.type === 'tool_use') { - toolUseByID.set(c.id, c as ToolUseBlockParam); + toolUseByID.set(c.id, c as ToolUseBlockParam) } } } - const info = getSearchOrReadInfo(msg, tools, toolUseByID); + const info = getSearchOrReadInfo(msg, tools, toolUseByID) + if (info && (info.isSearch || info.isRead || info.isREPL)) { // This is a search/read/REPL operation - add to current group if (!currentGroup) { @@ -146,188 +186,163 @@ function processProgressMessages(messages: ProgressMessage[], tools: T searchCount: 0, readCount: 0, replCount: 0, - startUuid: msg.uuid - }; + startUuid: msg.uuid, + } } // Only count tool_result messages (not tool_use) to avoid double counting if (msg.data.message.type === 'user') { if (info.isSearch) { - currentGroup.searchCount++; + currentGroup.searchCount++ } else if (info.isREPL) { - currentGroup.replCount++; + currentGroup.replCount++ } else if (info.isRead) { - currentGroup.readCount++; + currentGroup.readCount++ } } } else { // Non-search/read/REPL message - flush current group (completed) and add this message - flushGroup(false); + flushGroup(false) // Skip user tool_result messages — subagent progress messages lack // toolUseResult, so UserToolSuccessMessage returns null and the // height=1 Box in renderToolUseProgressMessage shows as a blank line. if (msg.data.message.type !== 'user') { - result.push({ - type: 'original', - message: msg - }); + result.push({ type: 'original', message: msg }) } } } // Flush any remaining group - it's active if the agent is still running - flushGroup(isAgentRunning); - return result; + flushGroup(isAgentRunning) + + return result } -const ESTIMATED_LINES_PER_TOOL = 9; -const TERMINAL_BUFFER_LINES = 7; -type Output = z.input>; -export function AgentPromptDisplay(t0) { - const $ = _c(3); - const { - prompt, - dim: t1 - } = t0; - t1 === undefined ? false : t1; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Prompt:; - $[0] = t2; - } else { - t2 = $[0]; - } - let t3; - if ($[1] !== prompt) { - t3 = {t2}{prompt}; - $[1] = prompt; - $[2] = t3; - } else { - t3 = $[2]; - } - return t3; + +const ESTIMATED_LINES_PER_TOOL = 9 +const TERMINAL_BUFFER_LINES = 7 + +type Output = z.input> + +export function AgentPromptDisplay({ + prompt, + dim: _dim = false, +}: { + prompt: string + theme?: ThemeName // deprecated, kept for compatibility - Markdown uses useTheme internally + dim?: boolean // deprecated, kept for compatibility - dimColor cannot be applied to Box (Markdown returns Box) +}): React.ReactNode { + return ( + + + Prompt: + + + {prompt} + + + ) } -export function AgentResponseDisplay(t0) { - const $ = _c(5); - const { - content - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Response:; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== content) { - t2 = content.map(_temp); - $[1] = content; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== t2) { - t3 = {t1}{t2}; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; -} -function _temp(block, index) { - return {block.text}; + +export function AgentResponseDisplay({ + content, +}: { + content: { type: string; text: string }[] + theme?: ThemeName // deprecated, kept for compatibility - Markdown uses useTheme internally +}): React.ReactNode { + return ( + + + Response: + + {content.map((block: { type: string; text: string }, index: number) => ( + + {block.text} + + ))} + + ) } + type VerboseAgentTranscriptProps = { - progressMessages: ProgressMessage[]; - tools: Tools; - verbose: boolean; -}; -function VerboseAgentTranscript(t0) { - const $ = _c(15); - const { - progressMessages, - tools, - verbose - } = t0; - let t1; - if ($[0] !== progressMessages) { - t1 = buildSubagentLookups(progressMessages.filter(_temp2).map(_temp3)); - $[0] = progressMessages; - $[1] = t1; - } else { - t1 = $[1]; - } - const { - lookups: agentLookups, - inProgressToolUseIDs - } = t1; - let t2; - if ($[2] !== agentLookups || $[3] !== inProgressToolUseIDs || $[4] !== progressMessages || $[5] !== tools || $[6] !== verbose) { - const filteredMessages = progressMessages.filter(_temp4); - let t3; - if ($[8] !== agentLookups || $[9] !== inProgressToolUseIDs || $[10] !== tools || $[11] !== verbose) { - t3 = progressMessage => ; - $[8] = agentLookups; - $[9] = inProgressToolUseIDs; - $[10] = tools; - $[11] = verbose; - $[12] = t3; - } else { - t3 = $[12]; - } - t2 = filteredMessages.map(t3); - $[2] = agentLookups; - $[3] = inProgressToolUseIDs; - $[4] = progressMessages; - $[5] = tools; - $[6] = verbose; - $[7] = t2; - } else { - t2 = $[7]; - } - let t3; - if ($[13] !== t2) { - t3 = <>{t2}; - $[13] = t2; - $[14] = t3; - } else { - t3 = $[14]; - } - return t3; + progressMessages: ProgressMessage[] + tools: Tools + verbose: boolean } -function _temp4(pm_1) { - if (!hasProgressMessage(pm_1.data)) { - return false; - } - const msg = pm_1.data.message; - if (msg.type === "user" && msg.toolUseResult === undefined) { - return false; - } - return true; -} -function _temp3(pm_0) { - return pm_0.data; -} -function _temp2(pm) { - return hasProgressMessage(pm.data); -} -export function renderToolResultMessage(data: Output, progressMessagesForMessage: ProgressMessage[], { + +function VerboseAgentTranscript({ + progressMessages, tools, verbose, - theme, - isTranscriptMode = false -}: { - tools: Tools; - verbose: boolean; - theme: ThemeName; - isTranscriptMode?: boolean; -}): React.ReactNode { +}: VerboseAgentTranscriptProps): React.ReactNode { + const { lookups: agentLookups, inProgressToolUseIDs } = buildSubagentLookups( + progressMessages + .filter((pm): pm is ProgressMessage => + hasProgressMessage(pm.data), + ) + .map(pm => pm.data), + ) + + // Filter out user tool_result messages that lack toolUseResult. + // Subagent progress messages don't carry the parsed tool output, + // so UserToolSuccessMessage returns null and MessageResponse renders + // a bare ⎿ with no content. + const filteredMessages = progressMessages.filter( + (pm): pm is ProgressMessage => { + if (!hasProgressMessage(pm.data)) { + return false + } + const msg = pm.data.message + if (msg.type === 'user' && msg.toolUseResult === undefined) { + return false + } + return true + }, + ) + + return ( + <> + {filteredMessages.map(progressMessage => ( + + + + ))} + + ) +} + +export function renderToolResultMessage( + data: Output, + progressMessagesForMessage: ProgressMessage[], + { + tools, + verbose, + theme, + isTranscriptMode = false, + }: { + tools: Tools + verbose: boolean + theme: ThemeName + isTranscriptMode?: boolean + }, +): React.ReactNode { // Remote-launched agents (ant-only) use a private output type not in the // public schema. Narrow via the internal discriminant. - const internal = data as Output | RemoteLaunchedOutput; + const internal = data as Output | RemoteLaunchedOutput if (internal.status === 'remote_launched') { - return + return ( + Remote agent launched{' '} @@ -336,34 +351,48 @@ export function renderToolResultMessage(data: Output, progressMessagesForMessage - ; + + ) } if (data.status === 'async_launched') { - const { - prompt - } = data; - return + const { prompt } = data + return ( + Backgrounded agent - {!isTranscriptMode && + {!isTranscriptMode && ( + {' ('} - {prompt && } + {prompt && ( + + )} {')'} - } + + )} - {isTranscriptMode && prompt && + {isTranscriptMode && prompt && ( + - } - ; + + )} + + ) } + if (data.status !== 'completed') { - return null; + return null } + const { agentId, totalDurationMs, @@ -371,501 +400,737 @@ export function renderToolResultMessage(data: Output, progressMessagesForMessage totalTokens, usage, content, - prompt - } = data; - const result = [totalToolUseCount === 1 ? '1 tool use' : `${totalToolUseCount} tool uses`, formatNumber(totalTokens) + ' tokens', formatDuration(totalDurationMs)]; - const completionMessage = `Done (${result.join(' · ')})`; + prompt, + } = data + const result = [ + totalToolUseCount === 1 ? '1 tool use' : `${totalToolUseCount} tool uses`, + formatNumber(totalTokens) + ' tokens', + formatDuration(totalDurationMs), + ] + + const completionMessage = `Done (${result.join(' · ')})` + const finalAssistantMessage = createAssistantMessage({ content: completionMessage, - usage: { - ...usage, - inference_geo: null, - iterations: null, - speed: null - } as import('@anthropic-ai/sdk/resources/beta/messages/messages.mjs').BetaUsage - }); - return - {(process.env.USER_TYPE) === 'ant' && + usage: { ...usage, inference_geo: null, iterations: null, speed: null }, + }) + + return ( + + {process.env.USER_TYPE === 'ant' && ( + [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} - } - {isTranscriptMode && prompt && + + )} + {isTranscriptMode && prompt && ( + - } - {isTranscriptMode ? - - : null} - {isTranscriptMode && content && content.length > 0 && + + )} + {isTranscriptMode ? ( + + + + ) : null} + {isTranscriptMode && content && content.length > 0 && ( + - } + + )} - + - {!isTranscriptMode && + {!isTranscriptMode && ( + {' '} - } - ; + + )} + + ) } + export function renderToolUseMessage({ description, - prompt + prompt, }: Partial<{ - description: string; - prompt: string; + description: string + prompt: string }>): React.ReactNode { if (!description || !prompt) { - return null; + return null } - return description; + return description } -export function renderToolUseTag(input: Partial<{ - description: string; - prompt: string; - subagent_type: string; - model?: ModelAlias; -}>): React.ReactNode { - const tags: React.ReactNode[] = []; + +export function renderToolUseTag( + input: Partial<{ + description: string + prompt: string + subagent_type: string + model?: ModelAlias + }>, +): React.ReactNode { + const tags: React.ReactNode[] = [] + if (input.model) { - const mainModel = getMainLoopModel(); - const agentModel = parseUserSpecifiedModel(input.model); + const mainModel = getMainLoopModel() + const agentModel = parseUserSpecifiedModel(input.model) if (agentModel !== mainModel) { - tags.push( + tags.push( + {renderModelName(agentModel)} - ); + , + ) } } + if (tags.length === 0) { - return null; + return null } - return <>{tags}; + + return <>{tags} } -const INITIALIZING_TEXT = 'Initializing…'; -export function renderToolUseProgressMessage(progressMessages: ProgressMessage[], { - tools, - verbose, - terminalSize, - inProgressToolCallCount, - isTranscriptMode = false -}: { - tools: Tools; - verbose: boolean; - terminalSize?: { - columns: number; - rows: number; - }; - inProgressToolCallCount?: number; - isTranscriptMode?: boolean; -}): React.ReactNode { + +const INITIALIZING_TEXT = 'Initializing…' + +export function renderToolUseProgressMessage( + progressMessages: ProgressMessage[], + { + tools, + verbose, + terminalSize, + inProgressToolCallCount, + isTranscriptMode = false, + }: { + tools: Tools + verbose: boolean + terminalSize?: { columns: number; rows: number } + inProgressToolCallCount?: number + isTranscriptMode?: boolean + }, +): React.ReactNode { if (!progressMessages.length) { - return + return ( + {INITIALIZING_TEXT} - ; + + ) } // Checks to see if we should show a super condensed progress message summary. // This prevents flickers when the terminal size is too small to render all the dynamic content - const toolToolRenderLinesEstimate = (inProgressToolCallCount ?? 1) * ESTIMATED_LINES_PER_TOOL + TERMINAL_BUFFER_LINES; - const shouldUseCondensedMode = !isTranscriptMode && terminalSize && terminalSize.rows && terminalSize.rows < toolToolRenderLinesEstimate; + const toolToolRenderLinesEstimate = + (inProgressToolCallCount ?? 1) * ESTIMATED_LINES_PER_TOOL + + TERMINAL_BUFFER_LINES + const shouldUseCondensedMode = + !isTranscriptMode && + terminalSize && + terminalSize.rows && + terminalSize.rows < toolToolRenderLinesEstimate + const getProgressStats = () => { const toolUseCount = count(progressMessages, msg => { if (!hasProgressMessage(msg.data)) { - return false; + return false } - const message = msg.data.message; - return message.message.content.some(content => content.type === 'tool_use'); - }); - const latestAssistant = progressMessages.findLast((msg): msg is ProgressMessage => hasProgressMessage(msg.data) && msg.data.message.type === 'assistant'); - let tokens = null; + const message = msg.data.message + return message.message.content.some( + content => content.type === 'tool_use', + ) + }) + + const latestAssistant = progressMessages.findLast( + (msg): msg is ProgressMessage => + hasProgressMessage(msg.data) && msg.data.message.type === 'assistant', + ) + + let tokens = null if (latestAssistant?.data.message.type === 'assistant') { - const usage = latestAssistant.data.message.message.usage; - tokens = (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + usage.input_tokens + usage.output_tokens; + const usage = latestAssistant.data.message.message.usage + tokens = + (usage.cache_creation_input_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0) + + usage.input_tokens + + usage.output_tokens } - return { - toolUseCount, - tokens - }; - }; + + return { toolUseCount, tokens } + } + if (shouldUseCondensedMode) { - const { - toolUseCount, - tokens - } = getProgressStats(); - return + const { toolUseCount, tokens } = getProgressStats() + + return ( + In progress… · {toolUseCount} tool{' '} {toolUseCount === 1 ? 'use' : 'uses'} {tokens && ` · ${formatNumber(tokens)} tokens`} ·{' '} - + - ; + + ) } // Process messages to group consecutive search/read operations into summaries (ants only) // isAgentRunning=true since this is the progress view while the agent is still running - const processedMessages = processProgressMessages(progressMessages, tools, true); + const processedMessages = processProgressMessages( + progressMessages, + tools, + true, + ) // For display, take the last few processed messages - const displayedMessages = isTranscriptMode ? processedMessages : processedMessages.slice(-MAX_PROGRESS_MESSAGES_TO_SHOW); + const displayedMessages = isTranscriptMode + ? processedMessages + : processedMessages.slice(-MAX_PROGRESS_MESSAGES_TO_SHOW) // Count hidden tool uses specifically (not all messages) to match the // final "Done (N tool uses)" count. Each tool use generates multiple // progress messages (tool_use + tool_result + text), so counting all // hidden messages inflates the number shown to the user. - const hiddenMessages = isTranscriptMode ? [] : processedMessages.slice(0, Math.max(0, processedMessages.length - MAX_PROGRESS_MESSAGES_TO_SHOW)); + const hiddenMessages = isTranscriptMode + ? [] + : processedMessages.slice( + 0, + Math.max(0, processedMessages.length - MAX_PROGRESS_MESSAGES_TO_SHOW), + ) const hiddenToolUseCount = count(hiddenMessages, m => { if (m.type === 'summary') { - return m.searchCount + m.readCount + m.replCount > 0; + return m.searchCount + m.readCount + m.replCount > 0 } - const data = m.message.data; + const data = m.message.data if (!hasProgressMessage(data)) { - return false; + return false } - return data.message.message.content.some(content => content.type === 'tool_use'); - }); - const firstData = progressMessages[0]?.data; - const prompt = firstData && hasProgressMessage(firstData) ? firstData.prompt : undefined; + return data.message.message.content.some( + content => content.type === 'tool_use', + ) + }) + + const firstData = progressMessages[0]?.data + const prompt = + firstData && hasProgressMessage(firstData) ? firstData.prompt : undefined // After grouping, displayedMessages can be empty when the only progress so // far is an assistant tool_use for a search/read op (grouped but not yet // counted, since counts increment on tool_result). Fall back to the // initializing text so MessageResponse doesn't render a bare ⎿. if (displayedMessages.length === 0 && !(isTranscriptMode && prompt)) { - return + return ( + {INITIALIZING_TEXT} - ; + + ) } + const { lookups: subagentLookups, - inProgressToolUseIDs: collapsedInProgressIDs - } = buildSubagentLookups(progressMessages.filter((pm): pm is ProgressMessage => hasProgressMessage(pm.data)).map(pm => pm.data)); - return + inProgressToolUseIDs: collapsedInProgressIDs, + } = buildSubagentLookups( + progressMessages + .filter((pm): pm is ProgressMessage => + hasProgressMessage(pm.data), + ) + .map(pm => pm.data), + ) + + return ( + - {isTranscriptMode && prompt && + {isTranscriptMode && prompt && ( + - } + + )} {displayedMessages.map(processed => { - if (processed.type === 'summary') { - // Render summary for grouped search/read/REPL operations using shared formatting - const summaryText = getSearchReadSummaryText(processed.searchCount, processed.readCount, processed.isActive, processed.replCount); - return + if (processed.type === 'summary') { + // Render summary for grouped search/read/REPL operations using shared formatting + const summaryText = getSearchReadSummaryText( + processed.searchCount, + processed.readCount, + processed.isActive, + processed.replCount, + ) + return ( + {summaryText} - ; - } - // Render original message without height=1 wrapper so null - // content (tool not found, renderToolUseMessage returns null) - // doesn't leave a blank line. Tool call headers are single-line - // anyway so truncation isn't needed. - return ; - })} + + ) + } + // Render original message without height=1 wrapper so null + // content (tool not found, renderToolUseMessage returns null) + // doesn't leave a blank line. Tool call headers are single-line + // anyway so truncation isn't needed. + return ( + + ) + })} - {hiddenToolUseCount > 0 && + {hiddenToolUseCount > 0 && ( + +{hiddenToolUseCount} more tool{' '} {hiddenToolUseCount === 1 ? 'use' : 'uses'} - } + + )} - ; + + ) } -export function renderToolUseRejectedMessage(_input: { - description: string; - prompt: string; - subagent_type: string; -}, { - progressMessagesForMessage, - tools, - verbose, - isTranscriptMode -}: { - columns: number; - messages: Message[]; - style?: 'condensed'; - theme: ThemeName; - progressMessagesForMessage: ProgressMessage[]; - tools: Tools; - verbose: boolean; - isTranscriptMode?: boolean; -}): React.ReactNode { + +export function renderToolUseRejectedMessage( + _input: { description: string; prompt: string; subagent_type: string }, + { + progressMessagesForMessage, + tools, + verbose, + isTranscriptMode, + }: { + columns: number + messages: Message[] + style?: 'condensed' + theme: ThemeName + progressMessagesForMessage: ProgressMessage[] + tools: Tools + verbose: boolean + isTranscriptMode?: boolean + }, +): React.ReactNode { // Get agentId from progress messages if available (agent was running before rejection) - const firstData = progressMessagesForMessage[0]?.data; - const agentId = firstData && hasProgressMessage(firstData) ? firstData.agentId : undefined; - return <> - {(process.env.USER_TYPE) === 'ant' && agentId && + const firstData = progressMessagesForMessage[0]?.data + const agentId = + firstData && hasProgressMessage(firstData) ? firstData.agentId : undefined + + return ( + <> + {process.env.USER_TYPE === 'ant' && agentId && ( + [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} - } + + )} {renderToolUseProgressMessage(progressMessagesForMessage, { - tools, - verbose, - isTranscriptMode - })} + tools, + verbose, + isTranscriptMode, + })} - ; + + ) } -export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], { - progressMessagesForMessage, - tools, - verbose, - isTranscriptMode -}: { - progressMessagesForMessage: ProgressMessage[]; - tools: Tools; - verbose: boolean; - isTranscriptMode?: boolean; -}): React.ReactNode { - return <> + +export function renderToolUseErrorMessage( + result: ToolResultBlockParam['content'], + { + progressMessagesForMessage, + tools, + verbose, + isTranscriptMode, + }: { + progressMessagesForMessage: ProgressMessage[] + tools: Tools + verbose: boolean + isTranscriptMode?: boolean + }, +): React.ReactNode { + return ( + <> {renderToolUseProgressMessage(progressMessagesForMessage, { - tools, - verbose, - isTranscriptMode - })} + tools, + verbose, + isTranscriptMode, + })} - ; + + ) } + function calculateAgentStats(progressMessages: ProgressMessage[]): { - toolUseCount: number; - tokens: number | null; + toolUseCount: number + tokens: number | null } { const toolUseCount = count(progressMessages, msg => { if (!hasProgressMessage(msg.data)) { - return false; + return false } - const message = msg.data.message; - return message.type === 'user' && message.message.content.some(content => content.type === 'tool_result'); - }); - const latestAssistant = progressMessages.findLast((msg): msg is ProgressMessage => hasProgressMessage(msg.data) && msg.data.message.type === 'assistant'); - let tokens = null; + const message = msg.data.message + return ( + message.type === 'user' && + message.message.content.some(content => content.type === 'tool_result') + ) + }) + + const latestAssistant = progressMessages.findLast( + (msg): msg is ProgressMessage => + hasProgressMessage(msg.data) && msg.data.message.type === 'assistant', + ) + + let tokens = null if (latestAssistant?.data.message.type === 'assistant') { - const usage = latestAssistant.data.message.message.usage; - tokens = (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + usage.input_tokens + usage.output_tokens; + const usage = latestAssistant.data.message.message.usage + tokens = + (usage.cache_creation_input_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0) + + usage.input_tokens + + usage.output_tokens } - return { - toolUseCount, - tokens - }; + + return { toolUseCount, tokens } } -export function renderGroupedAgentToolUse(toolUses: Array<{ - param: ToolUseBlockParam; - isResolved: boolean; - isError: boolean; - isInProgress: boolean; - progressMessages: ProgressMessage[]; - result?: { - param: ToolResultBlockParam; - output: Output; - }; -}>, options: { - shouldAnimate: boolean; - tools: Tools; -}): React.ReactNode | null { - const { - shouldAnimate, - tools - } = options; + +export function renderGroupedAgentToolUse( + toolUses: Array<{ + param: ToolUseBlockParam + isResolved: boolean + isError: boolean + isInProgress: boolean + progressMessages: ProgressMessage[] + result?: { + param: ToolResultBlockParam + output: Output + } + }>, + options: { + shouldAnimate: boolean + tools: Tools + }, +): React.ReactNode | null { + const { shouldAnimate, tools } = options // Calculate stats for each agent - const agentStats = toolUses.map(({ - param, - isResolved, - isError, - progressMessages, - result - }) => { - const stats = calculateAgentStats(progressMessages); - const lastToolInfo = extractLastToolInfo(progressMessages, tools); - const parsedInput = inputSchema().safeParse(param.input); + const agentStats = toolUses.map( + ({ param, isResolved, isError, progressMessages, result }) => { + const stats = calculateAgentStats(progressMessages) + const lastToolInfo = extractLastToolInfo(progressMessages, tools) + const parsedInput = inputSchema().safeParse(param.input) - // teammate_spawned is not part of the exported Output type (cast through unknown - // for dead code elimination), so check via string comparison on the raw value - const isTeammateSpawn = result?.output?.status as string === 'teammate_spawned'; + // teammate_spawned is not part of the exported Output type (cast through unknown + // for dead code elimination), so check via string comparison on the raw value + const isTeammateSpawn = + (result?.output?.status as string) === 'teammate_spawned' - // For teammate spawns, show @name with type in parens and description as status - let agentType: string; - let description: string | undefined; - let color: keyof Theme | undefined; - let descriptionColor: keyof Theme | undefined; - let taskDescription: string | undefined; - if (isTeammateSpawn && parsedInput.success && parsedInput.data.name) { - agentType = `@${parsedInput.data.name}`; - const subagentType = parsedInput.data.subagent_type; - description = isCustomSubagentType(subagentType) ? subagentType : undefined; - taskDescription = parsedInput.data.description; - // Use the custom agent definition's color on the type, not the name - descriptionColor = isCustomSubagentType(subagentType) ? getAgentColor(subagentType) as keyof Theme | undefined : undefined; - } else { - agentType = parsedInput.success ? userFacingName(parsedInput.data) : 'Agent'; - description = parsedInput.success ? parsedInput.data.description : undefined; - color = parsedInput.success ? userFacingNameBackgroundColor(parsedInput.data) : undefined; - taskDescription = undefined; - } + // For teammate spawns, show @name with type in parens and description as status + let agentType: string + let description: string | undefined + let color: keyof Theme | undefined + let descriptionColor: keyof Theme | undefined + let taskDescription: string | undefined + if (isTeammateSpawn && parsedInput.success && parsedInput.data.name) { + agentType = `@${parsedInput.data.name}` + const subagentType = parsedInput.data.subagent_type + description = isCustomSubagentType(subagentType) + ? subagentType + : undefined + taskDescription = parsedInput.data.description + // Use the custom agent definition's color on the type, not the name + descriptionColor = isCustomSubagentType(subagentType) + ? (getAgentColor(subagentType) as keyof Theme | undefined) + : undefined + } else { + agentType = parsedInput.success + ? userFacingName(parsedInput.data) + : 'Agent' + description = parsedInput.success + ? parsedInput.data.description + : undefined + color = parsedInput.success + ? userFacingNameBackgroundColor(parsedInput.data) + : undefined + taskDescription = undefined + } - // Check if this was launched as a background agent OR backgrounded mid-execution - const launchedAsAsync = parsedInput.success && 'run_in_background' in parsedInput.data && parsedInput.data.run_in_background === true; - const outputStatus = (result?.output as { - status?: string; - } | undefined)?.status; - const backgroundedMidExecution = outputStatus === 'async_launched' || outputStatus === 'remote_launched'; - const isAsync = launchedAsAsync || backgroundedMidExecution || isTeammateSpawn; - const name = parsedInput.success ? parsedInput.data.name : undefined; - return { - id: param.id, - agentType, - description, - toolUseCount: stats.toolUseCount, - tokens: stats.tokens, - isResolved, - isError, - isAsync, - color, - descriptionColor, - lastToolInfo, - taskDescription, - name - }; - }); - const anyUnresolved = toolUses.some(t => !t.isResolved); - const anyError = toolUses.some(t => t.isError); - const allComplete = !anyUnresolved; + // Check if this was launched as a background agent OR backgrounded mid-execution + const launchedAsAsync = + parsedInput.success && + 'run_in_background' in parsedInput.data && + parsedInput.data.run_in_background === true + const outputStatus = (result?.output as { status?: string } | undefined) + ?.status + const backgroundedMidExecution = + outputStatus === 'async_launched' || outputStatus === 'remote_launched' + const isAsync = + launchedAsAsync || backgroundedMidExecution || isTeammateSpawn + + const name = parsedInput.success ? parsedInput.data.name : undefined + + return { + id: param.id, + agentType, + description, + toolUseCount: stats.toolUseCount, + tokens: stats.tokens, + isResolved, + isError, + isAsync, + color, + descriptionColor, + lastToolInfo, + taskDescription, + name, + } + }, + ) + + const anyUnresolved = toolUses.some(t => !t.isResolved) + const anyError = toolUses.some(t => t.isError) + const allComplete = !anyUnresolved // Check if all agents are the same type - const allSameType = agentStats.length > 0 && agentStats.every(stat => stat.agentType === agentStats[0]?.agentType); - const commonType = allSameType && agentStats[0]?.agentType !== 'Agent' ? agentStats[0]?.agentType : null; + const allSameType = + agentStats.length > 0 && + agentStats.every(stat => stat.agentType === agentStats[0]?.agentType) + const commonType = + allSameType && agentStats[0]?.agentType !== 'Agent' + ? agentStats[0]?.agentType + : null // Check if all resolved agents are async (background) - const allAsync = agentStats.every(stat => stat.isAsync); - return + const allAsync = agentStats.every(stat => stat.isAsync) + + return ( + - + - {allComplete ? allAsync ? <> + {allComplete ? ( + allAsync ? ( + <> {toolUses.length} background agents launched{' '} - : <> + + ) : ( + <> {toolUses.length}{' '} {commonType ? `${commonType} agents` : 'agents'} finished - : <> + + ) + ) : ( + <> Running {toolUses.length}{' '} {commonType ? `${commonType} agents` : 'agents'}… - }{' '} + + )}{' '} {!allAsync && } - {agentStats.map((stat, index) => )} - ; + {agentStats.map((stat, index) => ( + + ))} + + ) } -export function userFacingName(input: Partial<{ - description: string; - prompt: string; - subagent_type: string; - name: string; - team_name: string; -}> | undefined): string { - if (input?.subagent_type && input.subagent_type !== GENERAL_PURPOSE_AGENT.agentType) { + +export function userFacingName( + input: + | Partial<{ + description: string + prompt: string + subagent_type: string + name: string + team_name: string + }> + | undefined, +): string { + if ( + input?.subagent_type && + input.subagent_type !== GENERAL_PURPOSE_AGENT.agentType + ) { // Display "worker" agents as "Agent" for cleaner UI if (input.subagent_type === 'worker') { - return 'Agent'; + return 'Agent' } - return input.subagent_type; + return input.subagent_type } - return 'Agent'; + return 'Agent' } -export function userFacingNameBackgroundColor(input: Partial<{ - description: string; - prompt: string; - subagent_type: string; -}> | undefined): keyof Theme | undefined { + +export function userFacingNameBackgroundColor( + input: + | Partial<{ description: string; prompt: string; subagent_type: string }> + | undefined, +): keyof Theme | undefined { if (!input?.subagent_type) { - return undefined; + return undefined } // Get the color for this agent - return getAgentColor(input.subagent_type) as keyof Theme | undefined; + return getAgentColor(input.subagent_type) as keyof Theme | undefined } -export function extractLastToolInfo(progressMessages: ProgressMessage[], tools: Tools): string | null { + +export function extractLastToolInfo( + progressMessages: ProgressMessage[], + tools: Tools, +): string | null { // Build tool_use lookup from all progress messages (needed for reverse iteration) - const toolUseByID = new Map(); + const toolUseByID = new Map() for (const pm of progressMessages) { if (!hasProgressMessage(pm.data)) { - continue; + continue } if (pm.data.message.type === 'assistant') { for (const c of pm.data.message.message.content) { if (c.type === 'tool_use') { - toolUseByID.set(c.id, c as ToolUseBlockParam); + toolUseByID.set(c.id, c as ToolUseBlockParam) } } } } // Count trailing consecutive search/read operations from the end - let searchCount = 0; - let readCount = 0; + let searchCount = 0 + let readCount = 0 for (let i = progressMessages.length - 1; i >= 0; i--) { - const msg = progressMessages[i]!; + const msg = progressMessages[i]! if (!hasProgressMessage(msg.data)) { - continue; + continue } - const info = getSearchOrReadInfo(msg, tools, toolUseByID); + const info = getSearchOrReadInfo(msg, tools, toolUseByID) if (info && (info.isSearch || info.isRead)) { // Only count tool_result messages to avoid double counting if (msg.data.message.type === 'user') { if (info.isSearch) { - searchCount++; + searchCount++ } else if (info.isRead) { - readCount++; + readCount++ } } } else { - break; + break } } + if (searchCount + readCount >= 2) { - return getSearchReadSummaryText(searchCount, readCount, true); + return getSearchReadSummaryText(searchCount, readCount, true) } // Find the last tool_result message - const lastToolResult = progressMessages.findLast((msg): msg is ProgressMessage => { - if (!hasProgressMessage(msg.data)) { - return false; - } - const message = msg.data.message; - return message.type === 'user' && message.message.content.some(c => c.type === 'tool_result'); - }); + const lastToolResult = progressMessages.findLast( + (msg): msg is ProgressMessage => { + if (!hasProgressMessage(msg.data)) { + return false + } + const message = msg.data.message + return ( + message.type === 'user' && + message.message.content.some(c => c.type === 'tool_result') + ) + }, + ) + if (lastToolResult?.data.message.type === 'user') { - const toolResultBlock = lastToolResult.data.message.message.content.find(c => c.type === 'tool_result'); + const toolResultBlock = lastToolResult.data.message.message.content.find( + c => c.type === 'tool_result', + ) + if (toolResultBlock?.type === 'tool_result') { // Look up the corresponding tool_use — already indexed above - const toolUseBlock = toolUseByID.get(toolResultBlock.tool_use_id); + const toolUseBlock = toolUseByID.get(toolResultBlock.tool_use_id) + if (toolUseBlock) { - const tool = findToolByName(tools, toolUseBlock.name); + const tool = findToolByName(tools, toolUseBlock.name) if (!tool) { - return toolUseBlock.name; // Fallback to raw name + return toolUseBlock.name // Fallback to raw name } - const input = toolUseBlock.input as Record; - const parsedInput = tool.inputSchema.safeParse(input); + + const input = toolUseBlock.input as Record + const parsedInput = tool.inputSchema.safeParse(input) // Get user-facing tool name - const userFacingToolName = tool.userFacingName(parsedInput.success ? parsedInput.data : undefined); + const userFacingToolName = tool.userFacingName( + parsedInput.success ? parsedInput.data : undefined, + ) // Try to get summary from the tool itself if (tool.getToolUseSummary) { - const summary = tool.getToolUseSummary(parsedInput.success ? parsedInput.data : undefined); + const summary = tool.getToolUseSummary( + parsedInput.success ? parsedInput.data : undefined, + ) if (summary) { - return `${userFacingToolName}: ${summary}`; + return `${userFacingToolName}: ${summary}` } } // Default: just show user-facing tool name - return userFacingToolName; + return userFacingToolName } } } - return null; + + return null } -function isCustomSubagentType(subagentType: string | undefined): subagentType is string { - return !!subagentType && subagentType !== GENERAL_PURPOSE_AGENT.agentType && subagentType !== 'worker'; + +function isCustomSubagentType( + subagentType: string | undefined, +): subagentType is string { + return ( + !!subagentType && + subagentType !== GENERAL_PURPOSE_AGENT.agentType && + subagentType !== 'worker' + ) } diff --git a/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx b/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx index 64da4dcc4..e71a5c665 100644 --- a/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx +++ b/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx @@ -1,136 +1,226 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { getAllowedChannels, getQuestionPreviewFormat } from 'src/bootstrap/state.js'; -import { MessageResponse } from 'src/components/MessageResponse.js'; -import { BLACK_CIRCLE } from 'src/constants/figures.js'; -import { getModeColor } from 'src/utils/permissions/PermissionMode.js'; -import { z } from 'zod/v4'; -import { Box, Text } from '../../ink.js'; -import type { Tool } from '../../Tool.js'; -import { buildTool, type ToolDef } from '../../Tool.js'; -import { lazySchema } from '../../utils/lazySchema.js'; -import { ASK_USER_QUESTION_TOOL_CHIP_WIDTH, ASK_USER_QUESTION_TOOL_NAME, ASK_USER_QUESTION_TOOL_PROMPT, DESCRIPTION, PREVIEW_FEATURE_PROMPT } from './prompt.js'; -const questionOptionSchema = lazySchema(() => z.object({ - label: z.string().describe('The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.'), - description: z.string().describe('Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.'), - preview: z.string().optional().describe('Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format.') -})); -const questionSchema = lazySchema(() => z.object({ - question: z.string().describe('The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"'), - header: z.string().describe(`Very short label displayed as a chip/tag (max ${ASK_USER_QUESTION_TOOL_CHIP_WIDTH} chars). Examples: "Auth method", "Library", "Approach".`), - options: z.array(questionOptionSchema()).min(2).max(4).describe(`The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.`), - multiSelect: z.boolean().default(false).describe('Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.') -})); +import { feature } from 'bun:bundle' +import * as React from 'react' +import { + getAllowedChannels, + getQuestionPreviewFormat, +} from 'src/bootstrap/state.js' +import { MessageResponse } from 'src/components/MessageResponse.js' +import { BLACK_CIRCLE } from 'src/constants/figures.js' +import { getModeColor } from 'src/utils/permissions/PermissionMode.js' +import { z } from 'zod/v4' +import { Box, Text } from '../../ink.js' +import type { Tool } from '../../Tool.js' +import { buildTool, type ToolDef } from '../../Tool.js' +import { lazySchema } from '../../utils/lazySchema.js' +import { + ASK_USER_QUESTION_TOOL_CHIP_WIDTH, + ASK_USER_QUESTION_TOOL_NAME, + ASK_USER_QUESTION_TOOL_PROMPT, + DESCRIPTION, + PREVIEW_FEATURE_PROMPT, +} from './prompt.js' + +const questionOptionSchema = lazySchema(() => + z.object({ + label: z + .string() + .describe( + 'The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.', + ), + description: z + .string() + .describe( + 'Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.', + ), + preview: z + .string() + .optional() + .describe( + 'Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format.', + ), + }), +) + +const questionSchema = lazySchema(() => + z.object({ + question: z + .string() + .describe( + 'The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"', + ), + header: z + .string() + .describe( + `Very short label displayed as a chip/tag (max ${ASK_USER_QUESTION_TOOL_CHIP_WIDTH} chars). Examples: "Auth method", "Library", "Approach".`, + ), + options: z + .array(questionOptionSchema()) + .min(2) + .max(4) + .describe( + `The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.`, + ), + multiSelect: z + .boolean() + .default(false) + .describe( + 'Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.', + ), + }), +) + const annotationsSchema = lazySchema(() => { const annotationSchema = z.object({ - preview: z.string().optional().describe('The preview content of the selected option, if the question used previews.'), - notes: z.string().optional().describe('Free-text notes the user added to their selection.') - }); - return z.record(z.string(), annotationSchema).optional().describe('Optional per-question annotations from the user (e.g., notes on preview selections). Keyed by question text.'); -}); + preview: z + .string() + .optional() + .describe( + 'The preview content of the selected option, if the question used previews.', + ), + notes: z + .string() + .optional() + .describe('Free-text notes the user added to their selection.'), + }) + + return z + .record(z.string(), annotationSchema) + .optional() + .describe( + 'Optional per-question annotations from the user (e.g., notes on preview selections). Keyed by question text.', + ) +}) + const UNIQUENESS_REFINE = { check: (data: { - questions: { - question: string; - options: { - label: string; - }[]; - }[]; + questions: { question: string; options: { label: string }[] }[] }) => { - const questions = data.questions.map(q => q.question); + const questions = data.questions.map(q => q.question) if (questions.length !== new Set(questions).size) { - return false; + return false } for (const question of data.questions) { - const labels = question.options.map(opt => opt.label); + const labels = question.options.map(opt => opt.label) if (labels.length !== new Set(labels).size) { - return false; + return false } } - return true; + return true }, - message: 'Question texts must be unique, option labels must be unique within each question' -} as const; + message: + 'Question texts must be unique, option labels must be unique within each question', +} as const + const commonFields = lazySchema(() => ({ - answers: z.record(z.string(), z.string()).optional().describe('User answers collected by the permission component'), + answers: z + .record(z.string(), z.string()) + .optional() + .describe('User answers collected by the permission component'), annotations: annotationsSchema(), - metadata: z.object({ - source: z.string().optional().describe('Optional identifier for the source of this question (e.g., "remember" for /remember command). Used for analytics tracking.') - }).optional().describe('Optional metadata for tracking and analytics purposes. Not displayed to user.') -})); -const inputSchema = lazySchema(() => z.strictObject({ - questions: z.array(questionSchema()).min(1).max(4).describe('Questions to ask the user (1-4 questions)'), - ...commonFields() -}).refine(UNIQUENESS_REFINE.check, { - message: UNIQUENESS_REFINE.message -})); -type InputSchema = ReturnType; -const outputSchema = lazySchema(() => z.object({ - questions: z.array(questionSchema()).describe('The questions that were asked'), - answers: z.record(z.string(), z.string()).describe('The answers provided by the user (question text -> answer string; multi-select answers are comma-separated)'), - annotations: annotationsSchema() -})); -type OutputSchema = ReturnType; + metadata: z + .object({ + source: z + .string() + .optional() + .describe( + 'Optional identifier for the source of this question (e.g., "remember" for /remember command). Used for analytics tracking.', + ), + }) + .optional() + .describe( + 'Optional metadata for tracking and analytics purposes. Not displayed to user.', + ), +})) + +const inputSchema = lazySchema(() => + z + .strictObject({ + questions: z + .array(questionSchema()) + .min(1) + .max(4) + .describe('Questions to ask the user (1-4 questions)'), + ...commonFields(), + }) + .refine(UNIQUENESS_REFINE.check, { + message: UNIQUENESS_REFINE.message, + }), +) +type InputSchema = ReturnType + +const outputSchema = lazySchema(() => + z.object({ + questions: z + .array(questionSchema()) + .describe('The questions that were asked'), + answers: z + .record(z.string(), z.string()) + .describe( + 'The answers provided by the user (question text -> answer string; multi-select answers are comma-separated)', + ), + annotations: annotationsSchema(), + }), +) +type OutputSchema = ReturnType // SDK schemas are identical to internal schemas now that `preview` and // `annotations` are public (configurable via `toolConfig.askUserQuestion`). -export const _sdkInputSchema = inputSchema; -export const _sdkOutputSchema = outputSchema; -export type Question = z.infer>; -export type QuestionOption = z.infer>; -export type Output = z.infer; -function AskUserQuestionResultMessage(t0) { - const $ = _c(3); - const { - answers - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {BLACK_CIRCLE} User answered Claude's questions:; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== answers) { - t2 = {t1}{Object.entries(answers).map(_temp)}; - $[1] = answers; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; -} -function _temp(t0) { - const [questionText, answer] = t0; - return · {questionText} → {answer}; +export const _sdkInputSchema = inputSchema +export const _sdkOutputSchema = outputSchema + +export type Question = z.infer> +export type QuestionOption = z.infer> +export type Output = z.infer + +function AskUserQuestionResultMessage({ + answers, +}: { + answers: Output['answers'] +}): React.ReactNode { + return ( + + + {BLACK_CIRCLE}  + User answered Claude's questions: + + + + {Object.entries(answers).map(([questionText, answer]) => ( + + · {questionText} → {answer} + + ))} + + + + ) } + export const AskUserQuestionTool: Tool = buildTool({ name: ASK_USER_QUESTION_TOOL_NAME, searchHint: 'prompt the user with a multiple-choice question', maxResultSizeChars: 100_000, shouldDefer: true, async description() { - return DESCRIPTION; + return DESCRIPTION }, async prompt() { - const format = getQuestionPreviewFormat(); + const format = getQuestionPreviewFormat() if (format === undefined) { // SDK consumer that hasn't opted into a preview format — omit preview // guidance (they may not render the field at all). - return ASK_USER_QUESTION_TOOL_PROMPT; + return ASK_USER_QUESTION_TOOL_PROMPT } - return ASK_USER_QUESTION_TOOL_PROMPT + PREVIEW_FEATURE_PROMPT[format]; + return ASK_USER_QUESTION_TOOL_PROMPT + PREVIEW_FEATURE_PROMPT[format] }, get inputSchema(): InputSchema { - return inputSchema(); + return inputSchema() }, get outputSchema(): OutputSchema { - return outputSchema(); + return outputSchema() }, userFacingName() { - return ''; + return '' }, isEnabled() { // When --channels is active the user is likely on Telegram/Discord, not @@ -138,128 +228,115 @@ export const AskUserQuestionTool: Tool = buildTool({ // the keyboard. Channel permission relay already skips // requiresUserInteraction() tools (interactiveHandler.ts) so there's // no alternate approval path. - if ((feature('KAIROS') || feature('KAIROS_CHANNELS')) && getAllowedChannels().length > 0) { - return false; + if ( + (feature('KAIROS') || feature('KAIROS_CHANNELS')) && + getAllowedChannels().length > 0 + ) { + return false } - return true; + return true }, isConcurrencySafe() { - return true; + return true }, isReadOnly() { - return true; + return true }, toAutoClassifierInput(input) { - return input.questions.map(q => q.question).join(' | '); + return input.questions.map(q => q.question).join(' | ') }, requiresUserInteraction() { - return true; + return true }, - async validateInput({ - questions - }) { + async validateInput({ questions }) { if (getQuestionPreviewFormat() !== 'html') { - return { - result: true - }; + return { result: true } } for (const q of questions) { for (const opt of q.options) { - const err = validateHtmlPreview(opt.preview); + const err = validateHtmlPreview(opt.preview) if (err) { return { result: false, message: `Option "${opt.label}" in question "${q.question}": ${err}`, - errorCode: 1 - }; + errorCode: 1, + } } } } - return { - result: true - }; + return { result: true } }, async checkPermissions(input) { return { behavior: 'ask' as const, message: 'Answer questions?', - updatedInput: input - }; + updatedInput: input, + } }, renderToolUseMessage() { - return null; + return null }, renderToolUseProgressMessage() { - return null; + return null }, - renderToolResultMessage({ - answers - }, _toolUseID) { - return ; + renderToolResultMessage({ answers }, _toolUseID) { + return }, renderToolUseRejectedMessage() { - return + return ( + {BLACK_CIRCLE}  User declined to answer questions - ; + + ) }, renderToolUseErrorMessage() { - return null; + return null }, - async call({ - questions, - answers = {}, - annotations - }, _context) { + async call({ questions, answers = {}, annotations }, _context) { return { - data: { - questions, - answers, - ...(annotations && { - annotations - }) - } - }; + data: { questions, answers, ...(annotations && { annotations }) }, + } }, - mapToolResultToToolResultBlockParam({ - answers, - annotations - }, toolUseID) { - const answersText = Object.entries(answers).map(([questionText, answer]) => { - const annotation = annotations?.[questionText]; - const parts = [`"${questionText}"="${answer}"`]; - if (annotation?.preview) { - parts.push(`selected preview:\n${annotation.preview}`); - } - if (annotation?.notes) { - parts.push(`user notes: ${annotation.notes}`); - } - return parts.join(' '); - }).join(', '); + mapToolResultToToolResultBlockParam({ answers, annotations }, toolUseID) { + const answersText = Object.entries(answers) + .map(([questionText, answer]) => { + const annotation = annotations?.[questionText] + const parts = [`"${questionText}"="${answer}"`] + if (annotation?.preview) { + parts.push(`selected preview:\n${annotation.preview}`) + } + if (annotation?.notes) { + parts.push(`user notes: ${annotation.notes}`) + } + return parts.join(' ') + }) + .join(', ') + return { type: 'tool_result', content: `User has answered your questions: ${answersText}. You can now continue with the user's answers in mind.`, - tool_use_id: toolUseID - }; - } -} satisfies ToolDef); + tool_use_id: toolUseID, + } + }, +} satisfies ToolDef) // Lightweight HTML fragment check. Not a parser — HTML5 parsers are // error-recovering by spec and accept anything. We're checking model intent // (did it emit HTML?) and catching the specific things we told it not to do. function validateHtmlPreview(preview: string | undefined): string | null { - if (preview === undefined) return null; + if (preview === undefined) return null if (/<\s*(html|body|!doctype)\b/i.test(preview)) { - return 'preview must be an HTML fragment, not a full document (no , , or )'; + return 'preview must be an HTML fragment, not a full document (no , , or )' } // SDK consumers typically set this via innerHTML — disallow executable/style // tags so a preview can't run code or restyle the host page. Inline event // handlers (onclick etc.) are still possible; consumers should sanitize. if (/<\s*(script|style)\b/i.test(preview)) { - return 'preview must not contain