From 8bf1bb89135b3daf8db448c7f846c65c97e4e300 Mon Sep 17 00:00:00 2001 From: daniel nakov Date: Tue, 25 Feb 2025 00:08:45 -0500 Subject: [PATCH] Initial commit --- README.md | 4 + src/ProjectOnboarding.tsx | 176 +++ src/commands.ts | 120 ++ src/commands/approvedTools.ts | 53 + src/commands/bug.tsx | 20 + src/commands/clear.ts | 37 + src/commands/compact.ts | 94 ++ src/commands/config.tsx | 19 + src/commands/cost.ts | 18 + src/commands/ctx_viz.ts | 209 ++++ src/commands/doctor.ts | 23 + src/commands/help.tsx | 19 + src/commands/init.ts | 37 + src/commands/listen.ts | 42 + src/commands/login.tsx | 51 + src/commands/logout.tsx | 41 + src/commands/onboarding.tsx | 34 + src/commands/pr_comments.ts | 59 + src/commands/release-notes.ts | 33 + src/commands/resume.tsx | 30 + src/commands/review.ts | 49 + src/commands/terminalSetup.ts | 143 +++ src/components/AnimatedClaudeAsterisk.tsx | 69 ++ src/components/ApproveApiKey.tsx | 93 ++ src/components/AsciiLogo.tsx | 25 + src/components/AutoUpdater.tsx | 146 +++ src/components/Bug.tsx | 332 ++++++ src/components/Config.tsx | 279 +++++ src/components/ConsoleOAuthFlow.tsx | 326 ++++++ src/components/Cost.tsx | 23 + src/components/CostThresholdDialog.tsx | 46 + src/components/CustomSelect/option-map.ts | 42 + src/components/CustomSelect/select-option.tsx | 52 + src/components/CustomSelect/select.tsx | 143 +++ .../CustomSelect/use-select-state.ts | 387 ++++++ src/components/CustomSelect/use-select.ts | 35 + .../FallbackToolUseRejectedMessage.tsx | 14 + src/components/FileEditToolUpdatedMessage.tsx | 66 ++ src/components/Help.tsx | 124 ++ src/components/HighlightedCode.tsx | 33 + src/components/InvalidConfigDialog.tsx | 113 ++ src/components/Link.tsx | 32 + src/components/LogSelector.tsx | 86 ++ src/components/Logo.tsx | 148 +++ src/components/MCPServerApprovalDialog.tsx | 100 ++ src/components/MCPServerDialogCopy.tsx | 24 + src/components/MCPServerMultiselectDialog.tsx | 109 ++ src/components/Message.tsx | 219 ++++ src/components/MessageResponse.tsx | 15 + src/components/MessageSelector.tsx | 211 ++++ src/components/Onboarding.tsx | 304 +++++ src/components/PressEnterToContinue.tsx | 11 + src/components/PromptInput.tsx | 469 ++++++++ src/components/SentryErrorBoundary.ts | 33 + src/components/Spinner.tsx | 126 ++ src/components/StickerRequestForm.tsx | 523 +++++++++ src/components/StructuredDiff.tsx | 184 +++ src/components/TextInput.tsx | 230 ++++ src/components/TokenWarning.tsx | 31 + src/components/ToolUseLoader.tsx | 40 + src/components/TrustDialog.tsx | 108 ++ .../binary-feedback/BinaryFeedback.tsx | 60 + .../binary-feedback/BinaryFeedbackOption.tsx | 111 ++ .../binary-feedback/BinaryFeedbackView.tsx | 171 +++ src/components/binary-feedback/utils.ts | 220 ++++ .../messages/AssistantBashOutputMessage.tsx | 22 + .../AssistantLocalCommandOutputMessage.tsx | 45 + .../AssistantRedactedThinkingMessage.tsx | 19 + .../messages/AssistantTextMessage.tsx | 144 +++ .../messages/AssistantThinkingMessage.tsx | 40 + .../messages/AssistantToolUseMessage.tsx | 110 ++ .../messages/UserBashInputMessage.tsx | 28 + .../messages/UserCommandMessage.tsx | 30 + src/components/messages/UserPromptMessage.tsx | 35 + src/components/messages/UserTextMessage.tsx | 33 + .../UserToolCanceledMessage.tsx | 12 + .../UserToolErrorMessage.tsx | 36 + .../UserToolRejectMessage.tsx | 31 + .../UserToolResultMessage.tsx | 57 + .../UserToolSuccessMessage.tsx | 35 + .../messages/UserToolResultMessage/utils.tsx | 56 + .../BashPermissionRequest.tsx | 121 ++ .../permissions/FallbackPermissionRequest.tsx | 155 +++ .../FileEditPermissionRequest.tsx | 182 +++ .../FileEditToolDiff.tsx | 75 ++ .../FileWritePermissionRequest.tsx | 164 +++ .../FileWriteToolDiff.tsx | 81 ++ .../FilesystemPermissionRequest.tsx | 242 ++++ .../permissions/PermissionRequest.tsx | 100 ++ .../permissions/PermissionRequestTitle.tsx | 69 ++ src/components/permissions/hooks.ts | 44 + src/components/permissions/toolUseOptions.ts | 59 + src/components/permissions/utils.ts | 23 + src/constants/betas.ts | 5 + src/constants/claude-asterisk-ascii-art.tsx | 238 ++++ src/constants/figures.ts | 4 + src/constants/keys.ts | 5 + src/constants/oauth.ts | 54 + src/constants/product.ts | 2 + src/constants/prompts.ts | 154 +++ src/constants/releaseNotes.ts | 7 + src/context.ts | 224 ++++ src/cost-tracker.ts | 84 ++ src/entrypoints/cli.tsx | 1043 +++++++++++++++++ src/entrypoints/mcp.ts | 178 +++ src/history.ts | 25 + src/hooks/useApiKeyVerification.ts | 59 + src/hooks/useArrowKeyHistory.ts | 55 + src/hooks/useCanUseTool.ts | 138 +++ src/hooks/useCancelRequest.ts | 39 + src/hooks/useDoublePress.ts | 42 + src/hooks/useExitOnCtrlCD.ts | 31 + src/hooks/useInterval.ts | 25 + src/hooks/useLogMessages.ts | 16 + src/hooks/useLogStartupTime.ts | 12 + src/hooks/useNotifyAfterTimeout.ts | 65 + src/hooks/usePermissionRequestLogging.ts | 44 + src/hooks/useSlashCommandTypeahead.ts | 121 ++ src/hooks/useTerminalSize.ts | 24 + src/hooks/useTextInput.ts | 287 +++++ src/messages.ts | 24 + src/permissions.ts | 267 +++++ src/query.ts | 516 ++++++++ src/screens/ConfigureNpmPrefix.tsx | 197 ++++ src/screens/Doctor.tsx | 219 ++++ src/screens/LogList.tsx | 68 ++ src/screens/REPL.tsx | 725 ++++++++++++ src/screens/ResumeConversation.tsx | 68 ++ src/services/browserMocks.ts | 66 ++ src/services/claude.ts | 948 +++++++++++++++ src/services/mcpClient.ts | 559 +++++++++ src/services/mcpServerApproval.tsx | 50 + src/services/notifier.ts | 40 + src/services/oauth.ts | 358 ++++++ src/services/sentry.ts | 52 + src/services/statsig.ts | 169 +++ src/services/statsigStorage.ts | 83 ++ src/services/vcr.ts | 161 +++ src/tools.ts | 59 + src/tools/AgentTool/AgentTool.tsx | 208 ++++ src/tools/AgentTool/constants.ts | 1 + src/tools/AgentTool/prompt.ts | 41 + src/tools/ArchitectTool/ArchitectTool.tsx | 119 ++ src/tools/ArchitectTool/prompt.ts | 15 + src/tools/BashTool/BashTool.tsx | 219 ++++ src/tools/BashTool/BashToolResultMessage.tsx | 38 + src/tools/BashTool/OutputLine.tsx | 46 + src/tools/BashTool/prompt.ts | 174 +++ src/tools/BashTool/utils.ts | 56 + src/tools/FileEditTool/FileEditTool.tsx | 297 +++++ src/tools/FileEditTool/prompt.ts | 51 + src/tools/FileEditTool/utils.ts | 58 + src/tools/FileReadTool/FileReadTool.tsx | 343 ++++++ src/tools/FileReadTool/prompt.ts | 7 + src/tools/FileWriteTool/FileWriteTool.tsx | 279 +++++ src/tools/FileWriteTool/prompt.ts | 10 + src/tools/GlobTool/GlobTool.tsx | 116 ++ src/tools/GlobTool/prompt.ts | 8 + src/tools/GrepTool/GrepTool.tsx | 144 +++ src/tools/GrepTool/prompt.ts | 11 + src/tools/MCPTool/MCPTool.tsx | 103 ++ src/tools/MCPTool/prompt.ts | 3 + src/tools/MemoryReadTool/MemoryReadTool.tsx | 118 ++ src/tools/MemoryWriteTool/MemoryWriteTool.tsx | 76 ++ .../NotebookEditTool/NotebookEditTool.tsx | 283 +++++ src/tools/NotebookEditTool/prompt.ts | 3 + .../NotebookReadTool/NotebookReadTool.tsx | 263 +++++ src/tools/NotebookReadTool/prompt.ts | 3 + .../StickerRequestTool/StickerRequestTool.tsx | 92 ++ src/tools/StickerRequestTool/prompt.ts | 19 + src/tools/ThinkTool/ThinkTool.tsx | 55 + src/tools/ThinkTool/prompt.ts | 12 + src/tools/lsTool/lsTool.tsx | 266 +++++ src/tools/lsTool/prompt.ts | 2 + src/utils/Cursor.ts | 423 +++++++ src/utils/PersistentShell.ts | 372 ++++++ src/utils/array.ts | 3 + src/utils/ask.tsx | 99 ++ src/utils/auth.ts | 11 + src/utils/autoUpdater.ts | 317 +++++ src/utils/betas.ts | 20 + src/utils/browser.ts | 14 + src/utils/cleanup.ts | 72 ++ src/utils/commands.ts | 261 +++++ src/utils/config.ts | 577 +++++++++ src/utils/diff.ts | 42 + src/utils/env.ts | 55 + src/utils/errors.ts | 21 + src/utils/exampleCommands.ts | 110 ++ src/utils/execFileNoThrow.ts | 51 + src/utils/file.ts | 405 +++++++ src/utils/format.tsx | 44 + src/utils/generators.ts | 62 + src/utils/git.ts | 92 ++ src/utils/http.ts | 7 + src/utils/imagePaste.ts | 38 + src/utils/json.ts | 13 + src/utils/log.ts | 381 ++++++ src/utils/markdown.ts | 213 ++++ src/utils/messages.tsx | 885 ++++++++++++++ src/utils/model.ts | 92 ++ src/utils/permissions/filesystem.ts | 118 ++ src/utils/ripgrep.ts | 167 +++ src/utils/state.ts | 25 + src/utils/style.ts | 28 + src/utils/terminal.ts | 50 + src/utils/theme.ts | 117 ++ src/utils/thinking.ts | 98 ++ src/utils/tokens.ts | 43 + src/utils/unaryLogging.ts | 26 + src/utils/user.ts | 45 + src/utils/validate.ts | 165 +++ 212 files changed, 25983 insertions(+) create mode 100644 README.md create mode 100644 src/ProjectOnboarding.tsx create mode 100644 src/commands.ts create mode 100644 src/commands/approvedTools.ts create mode 100644 src/commands/bug.tsx create mode 100644 src/commands/clear.ts create mode 100644 src/commands/compact.ts create mode 100644 src/commands/config.tsx create mode 100644 src/commands/cost.ts create mode 100644 src/commands/ctx_viz.ts create mode 100644 src/commands/doctor.ts create mode 100644 src/commands/help.tsx create mode 100644 src/commands/init.ts create mode 100644 src/commands/listen.ts create mode 100644 src/commands/login.tsx create mode 100644 src/commands/logout.tsx create mode 100644 src/commands/onboarding.tsx create mode 100644 src/commands/pr_comments.ts create mode 100644 src/commands/release-notes.ts create mode 100644 src/commands/resume.tsx create mode 100644 src/commands/review.ts create mode 100644 src/commands/terminalSetup.ts create mode 100644 src/components/AnimatedClaudeAsterisk.tsx create mode 100644 src/components/ApproveApiKey.tsx create mode 100644 src/components/AsciiLogo.tsx create mode 100644 src/components/AutoUpdater.tsx create mode 100644 src/components/Bug.tsx create mode 100644 src/components/Config.tsx create mode 100644 src/components/ConsoleOAuthFlow.tsx create mode 100644 src/components/Cost.tsx create mode 100644 src/components/CostThresholdDialog.tsx create mode 100644 src/components/CustomSelect/option-map.ts create mode 100644 src/components/CustomSelect/select-option.tsx create mode 100644 src/components/CustomSelect/select.tsx create mode 100644 src/components/CustomSelect/use-select-state.ts create mode 100644 src/components/CustomSelect/use-select.ts create mode 100644 src/components/FallbackToolUseRejectedMessage.tsx create mode 100644 src/components/FileEditToolUpdatedMessage.tsx create mode 100644 src/components/Help.tsx create mode 100644 src/components/HighlightedCode.tsx create mode 100644 src/components/InvalidConfigDialog.tsx create mode 100644 src/components/Link.tsx create mode 100644 src/components/LogSelector.tsx create mode 100644 src/components/Logo.tsx create mode 100644 src/components/MCPServerApprovalDialog.tsx create mode 100644 src/components/MCPServerDialogCopy.tsx create mode 100644 src/components/MCPServerMultiselectDialog.tsx create mode 100644 src/components/Message.tsx create mode 100644 src/components/MessageResponse.tsx create mode 100644 src/components/MessageSelector.tsx create mode 100644 src/components/Onboarding.tsx create mode 100644 src/components/PressEnterToContinue.tsx create mode 100644 src/components/PromptInput.tsx create mode 100644 src/components/SentryErrorBoundary.ts create mode 100644 src/components/Spinner.tsx create mode 100644 src/components/StickerRequestForm.tsx create mode 100644 src/components/StructuredDiff.tsx create mode 100644 src/components/TextInput.tsx create mode 100644 src/components/TokenWarning.tsx create mode 100644 src/components/ToolUseLoader.tsx create mode 100644 src/components/TrustDialog.tsx create mode 100644 src/components/binary-feedback/BinaryFeedback.tsx create mode 100644 src/components/binary-feedback/BinaryFeedbackOption.tsx create mode 100644 src/components/binary-feedback/BinaryFeedbackView.tsx create mode 100644 src/components/binary-feedback/utils.ts create mode 100644 src/components/messages/AssistantBashOutputMessage.tsx create mode 100644 src/components/messages/AssistantLocalCommandOutputMessage.tsx create mode 100644 src/components/messages/AssistantRedactedThinkingMessage.tsx create mode 100644 src/components/messages/AssistantTextMessage.tsx create mode 100644 src/components/messages/AssistantThinkingMessage.tsx create mode 100644 src/components/messages/AssistantToolUseMessage.tsx create mode 100644 src/components/messages/UserBashInputMessage.tsx create mode 100644 src/components/messages/UserCommandMessage.tsx create mode 100644 src/components/messages/UserPromptMessage.tsx create mode 100644 src/components/messages/UserTextMessage.tsx create mode 100644 src/components/messages/UserToolResultMessage/UserToolCanceledMessage.tsx create mode 100644 src/components/messages/UserToolResultMessage/UserToolErrorMessage.tsx create mode 100644 src/components/messages/UserToolResultMessage/UserToolRejectMessage.tsx create mode 100644 src/components/messages/UserToolResultMessage/UserToolResultMessage.tsx create mode 100644 src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx create mode 100644 src/components/messages/UserToolResultMessage/utils.tsx create mode 100644 src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx create mode 100644 src/components/permissions/FallbackPermissionRequest.tsx create mode 100644 src/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx create mode 100644 src/components/permissions/FileEditPermissionRequest/FileEditToolDiff.tsx create mode 100644 src/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx create mode 100644 src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.tsx create mode 100644 src/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx create mode 100644 src/components/permissions/PermissionRequest.tsx create mode 100644 src/components/permissions/PermissionRequestTitle.tsx create mode 100644 src/components/permissions/hooks.ts create mode 100644 src/components/permissions/toolUseOptions.ts create mode 100644 src/components/permissions/utils.ts create mode 100644 src/constants/betas.ts create mode 100644 src/constants/claude-asterisk-ascii-art.tsx create mode 100644 src/constants/figures.ts create mode 100644 src/constants/keys.ts create mode 100644 src/constants/oauth.ts create mode 100644 src/constants/product.ts create mode 100644 src/constants/prompts.ts create mode 100644 src/constants/releaseNotes.ts create mode 100644 src/context.ts create mode 100644 src/cost-tracker.ts create mode 100644 src/entrypoints/cli.tsx create mode 100644 src/entrypoints/mcp.ts create mode 100644 src/history.ts create mode 100644 src/hooks/useApiKeyVerification.ts create mode 100644 src/hooks/useArrowKeyHistory.ts create mode 100644 src/hooks/useCanUseTool.ts create mode 100644 src/hooks/useCancelRequest.ts create mode 100644 src/hooks/useDoublePress.ts create mode 100644 src/hooks/useExitOnCtrlCD.ts create mode 100644 src/hooks/useInterval.ts create mode 100644 src/hooks/useLogMessages.ts create mode 100644 src/hooks/useLogStartupTime.ts create mode 100644 src/hooks/useNotifyAfterTimeout.ts create mode 100644 src/hooks/usePermissionRequestLogging.ts create mode 100644 src/hooks/useSlashCommandTypeahead.ts create mode 100644 src/hooks/useTerminalSize.ts create mode 100644 src/hooks/useTextInput.ts create mode 100644 src/messages.ts create mode 100644 src/permissions.ts create mode 100644 src/query.ts create mode 100644 src/screens/ConfigureNpmPrefix.tsx create mode 100644 src/screens/Doctor.tsx create mode 100644 src/screens/LogList.tsx create mode 100644 src/screens/REPL.tsx create mode 100644 src/screens/ResumeConversation.tsx create mode 100644 src/services/browserMocks.ts create mode 100644 src/services/claude.ts create mode 100644 src/services/mcpClient.ts create mode 100644 src/services/mcpServerApproval.tsx create mode 100644 src/services/notifier.ts create mode 100644 src/services/oauth.ts create mode 100644 src/services/sentry.ts create mode 100644 src/services/statsig.ts create mode 100644 src/services/statsigStorage.ts create mode 100644 src/services/vcr.ts create mode 100644 src/tools.ts create mode 100644 src/tools/AgentTool/AgentTool.tsx create mode 100644 src/tools/AgentTool/constants.ts create mode 100644 src/tools/AgentTool/prompt.ts create mode 100644 src/tools/ArchitectTool/ArchitectTool.tsx create mode 100644 src/tools/ArchitectTool/prompt.ts create mode 100644 src/tools/BashTool/BashTool.tsx create mode 100644 src/tools/BashTool/BashToolResultMessage.tsx create mode 100644 src/tools/BashTool/OutputLine.tsx create mode 100644 src/tools/BashTool/prompt.ts create mode 100644 src/tools/BashTool/utils.ts create mode 100644 src/tools/FileEditTool/FileEditTool.tsx create mode 100644 src/tools/FileEditTool/prompt.ts create mode 100644 src/tools/FileEditTool/utils.ts create mode 100644 src/tools/FileReadTool/FileReadTool.tsx create mode 100644 src/tools/FileReadTool/prompt.ts create mode 100644 src/tools/FileWriteTool/FileWriteTool.tsx create mode 100644 src/tools/FileWriteTool/prompt.ts create mode 100644 src/tools/GlobTool/GlobTool.tsx create mode 100644 src/tools/GlobTool/prompt.ts create mode 100644 src/tools/GrepTool/GrepTool.tsx create mode 100644 src/tools/GrepTool/prompt.ts create mode 100644 src/tools/MCPTool/MCPTool.tsx create mode 100644 src/tools/MCPTool/prompt.ts create mode 100644 src/tools/MemoryReadTool/MemoryReadTool.tsx create mode 100644 src/tools/MemoryWriteTool/MemoryWriteTool.tsx create mode 100644 src/tools/NotebookEditTool/NotebookEditTool.tsx create mode 100644 src/tools/NotebookEditTool/prompt.ts create mode 100644 src/tools/NotebookReadTool/NotebookReadTool.tsx create mode 100644 src/tools/NotebookReadTool/prompt.ts create mode 100644 src/tools/StickerRequestTool/StickerRequestTool.tsx create mode 100644 src/tools/StickerRequestTool/prompt.ts create mode 100644 src/tools/ThinkTool/ThinkTool.tsx create mode 100644 src/tools/ThinkTool/prompt.ts create mode 100644 src/tools/lsTool/lsTool.tsx create mode 100644 src/tools/lsTool/prompt.ts create mode 100644 src/utils/Cursor.ts create mode 100644 src/utils/PersistentShell.ts create mode 100644 src/utils/array.ts create mode 100644 src/utils/ask.tsx create mode 100644 src/utils/auth.ts create mode 100644 src/utils/autoUpdater.ts create mode 100644 src/utils/betas.ts create mode 100644 src/utils/browser.ts create mode 100644 src/utils/cleanup.ts create mode 100644 src/utils/commands.ts create mode 100644 src/utils/config.ts create mode 100644 src/utils/diff.ts create mode 100644 src/utils/env.ts create mode 100644 src/utils/errors.ts create mode 100644 src/utils/exampleCommands.ts create mode 100644 src/utils/execFileNoThrow.ts create mode 100644 src/utils/file.ts create mode 100644 src/utils/format.tsx create mode 100644 src/utils/generators.ts create mode 100644 src/utils/git.ts create mode 100644 src/utils/http.ts create mode 100644 src/utils/imagePaste.ts create mode 100644 src/utils/json.ts create mode 100644 src/utils/log.ts create mode 100644 src/utils/markdown.ts create mode 100644 src/utils/messages.tsx create mode 100644 src/utils/model.ts create mode 100644 src/utils/permissions/filesystem.ts create mode 100644 src/utils/ripgrep.ts create mode 100644 src/utils/state.ts create mode 100644 src/utils/style.ts create mode 100644 src/utils/terminal.ts create mode 100644 src/utils/theme.ts create mode 100644 src/utils/thinking.ts create mode 100644 src/utils/tokens.ts create mode 100644 src/utils/unaryLogging.ts create mode 100644 src/utils/user.ts create mode 100644 src/utils/validate.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..6278185 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# claude-code + +Extracted from the source maps of the @anthropic-ai/claude-code package + diff --git a/src/ProjectOnboarding.tsx b/src/ProjectOnboarding.tsx new file mode 100644 index 0000000..ca9372f --- /dev/null +++ b/src/ProjectOnboarding.tsx @@ -0,0 +1,176 @@ +import * as React from 'react' +import { OrderedList } from '@inkjs/ui' +import { Box, Text } from 'ink' +import { + getCurrentProjectConfig, + getGlobalConfig, + saveCurrentProjectConfig, + saveGlobalConfig, +} from './utils/config.js' +import { existsSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' +import terminalSetup from './commands/terminalSetup.js' +import { getTheme } from './utils/theme.js' +import { RELEASE_NOTES } from './constants/releaseNotes.js' +import { gt } from 'semver' +import { isDirEmpty } from './utils/file.js' + +// Function to mark onboarding as complete +export function markProjectOnboardingComplete(): void { + const projectConfig = getCurrentProjectConfig() + if (!projectConfig.hasCompletedProjectOnboarding) { + saveCurrentProjectConfig({ + ...projectConfig, + hasCompletedProjectOnboarding: true, + }) + } +} + +function markReleaseNotesSeen(): void { + const config = getGlobalConfig() + saveGlobalConfig({ + ...config, + lastReleaseNotesSeen: MACRO.VERSION, + }) +} + +type Props = { + workspaceDir: string +} + +export default function ProjectOnboarding({ + workspaceDir, +}: Props): React.ReactNode { + // Check if project onboarding has already been completed + const projectConfig = getCurrentProjectConfig() + const showOnboarding = !projectConfig.hasCompletedProjectOnboarding + + // Get previous version from config + const config = getGlobalConfig() + const previousVersion = config.lastReleaseNotesSeen + + // Get release notes to show + let releaseNotesToShow: string[] = [] + if (!previousVersion || gt(MACRO.VERSION, previousVersion)) { + releaseNotesToShow = RELEASE_NOTES[MACRO.VERSION] || [] + } + const hasReleaseNotes = releaseNotesToShow.length > 0 + + // Mark release notes as seen when they're displayed without onboarding + React.useEffect(() => { + if (hasReleaseNotes && !showOnboarding) { + markReleaseNotesSeen() + } + }, [hasReleaseNotes, showOnboarding]) + + // We only want to show either onboarding OR release notes (with preference for onboarding) + // If there's no onboarding to show and no release notes, return null + if (!showOnboarding && !hasReleaseNotes) { + return null + } + + // Load what we need for onboarding + // NOTE: This whole component is staticly rendered Once + const hasClaudeMd = existsSync(join(workspaceDir, 'CLAUDE.md')) + const isWorkspaceDirEmpty = isDirEmpty(workspaceDir) + const needsClaudeMd = !hasClaudeMd && !isWorkspaceDirEmpty + const showTerminalTip = + terminalSetup.isEnabled && !getGlobalConfig().shiftEnterKeyBindingInstalled + + const theme = getTheme() + + return ( + + {showOnboarding && ( + <> + Tips for getting started: + + {/* Collect all the items that should be displayed */} + {(() => { + const items = [] + + if (isWorkspaceDirEmpty) { + items.push( + + + Ask Claude to create a new app or clone a repository. + + , + ) + } + if (needsClaudeMd) { + items.push( + + + Run /init to create a + CLAUDE.md file with instructions for Claude. + + , + ) + } + + if (showTerminalTip) { + items.push( + + + Run /terminal-setup + to set up terminal integration + + , + ) + } + + items.push( + + + Ask Claude questions about your codebase. + + , + ) + + items.push( + + + Ask Claude to implement changes to your codebase. + + , + ) + + return items + })()} + + + )} + + {!showOnboarding && hasReleaseNotes && ( + + + + 🆕 What's new in v{MACRO.VERSION}: + + + {releaseNotesToShow.map((note, noteIndex) => ( + + • {note} + + ))} + + + + )} + + {workspaceDir === homedir() && ( + + Note: You have launched claude in your home + directory. For the best experience, launch it in a project directory + instead. + + )} + + ) +} diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..c0f875e --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,120 @@ +import bug from './commands/bug.js' +import clear from './commands/clear.js' +import compact from './commands/compact.js' +import config from './commands/config.js' +import cost from './commands/cost.js' +import ctx_viz from './commands/ctx_viz.js' +import doctor from './commands/doctor.js' +import help from './commands/help.js' +import init from './commands/init.js' +import listen from './commands/listen.js' +import login from './commands/login.js' +import logout from './commands/logout.js' +import onboarding from './commands/onboarding.js' +import pr_comments from './commands/pr_comments.js' +import releaseNotes from './commands/release-notes.js' +import review from './commands/review.js' +import terminalSetup from './commands/terminalSetup.js' +import { Tool, ToolUseContext } from './Tool.js' +import resume from './commands/resume.js' +import { getMCPCommands } from './services/mcpClient.js' +import type { MessageParam } from '@anthropic-ai/sdk/resources/index.mjs' +import { memoize } from 'lodash-es' +import type { Message } from './query.js' +import { isAnthropicAuthEnabled } from './utils/auth.js' + +type PromptCommand = { + type: 'prompt' + progressMessage: string + argNames?: string[] + getPromptForCommand(args: string): Promise +} + +type LocalCommand = { + type: 'local' + call( + args: string, + context: { + options: { + commands: Command[] + tools: Tool[] + slowAndCapableModel: string + } + abortController: AbortController + setForkConvoWithMessagesOnTheNextRender: ( + forkConvoWithMessages: Message[], + ) => void + }, + ): Promise +} + +type LocalJSXCommand = { + type: 'local-jsx' + call( + onDone: (result?: string) => void, + context: ToolUseContext & { + setForkConvoWithMessagesOnTheNextRender: ( + forkConvoWithMessages: Message[], + ) => void + }, + ): Promise +} + +export type Command = { + description: string + isEnabled: boolean + isHidden: boolean + name: string + aliases?: string[] + userFacingName(): string +} & (PromptCommand | LocalCommand | LocalJSXCommand) + +const INTERNAL_ONLY_COMMANDS = [ctx_viz, resume, listen] + +// Declared as a function so that we don't run this until getCommands is called, +// since underlying functions read from config, which can't be read at module initialization time +const COMMANDS = memoize((): Command[] => [ + clear, + compact, + config, + cost, + doctor, + help, + init, + onboarding, + pr_comments, + releaseNotes, + bug, + review, + terminalSetup, + ...(isAnthropicAuthEnabled() ? [logout, login()] : []), + ...(process.env.USER_TYPE === 'ant' ? INTERNAL_ONLY_COMMANDS : []), +]) + +export const getCommands = memoize(async (): Promise => { + return [...(await getMCPCommands()), ...COMMANDS()].filter(_ => _.isEnabled) +}) + +export function hasCommand(commandName: string, commands: Command[]): boolean { + return commands.some( + _ => _.userFacingName() === commandName || _.aliases?.includes(commandName), + ) +} + +export function getCommand(commandName: string, commands: Command[]): Command { + const command = commands.find( + _ => _.userFacingName() === commandName || _.aliases?.includes(commandName), + ) as Command | undefined + if (!command) { + throw ReferenceError( + `Command ${commandName} not found. Available commands: ${commands + .map(_ => { + const name = _.userFacingName() + return _.aliases ? `${name} (aliases: ${_.aliases.join(', ')})` : name + }) + .join(', ')}`, + ) + } + + return command +} diff --git a/src/commands/approvedTools.ts b/src/commands/approvedTools.ts new file mode 100644 index 0000000..7396008 --- /dev/null +++ b/src/commands/approvedTools.ts @@ -0,0 +1,53 @@ +import { + ProjectConfig, + getCurrentProjectConfig as getCurrentProjectConfigDefault, + saveCurrentProjectConfig as saveCurrentProjectConfigDefault, +} from '../utils/config.js' + +export type ProjectConfigHandler = { + getCurrentProjectConfig: () => ProjectConfig + saveCurrentProjectConfig: (config: ProjectConfig) => void +} + +// Default config handler using the real implementation +const defaultConfigHandler: ProjectConfigHandler = { + getCurrentProjectConfig: getCurrentProjectConfigDefault, + saveCurrentProjectConfig: saveCurrentProjectConfigDefault, +} + +/** + * Handler for the 'approved-tools list' command + */ +export function handleListApprovedTools( + cwd: string, + projectConfigHandler: ProjectConfigHandler = defaultConfigHandler, +): string { + const projectConfig = projectConfigHandler.getCurrentProjectConfig() + return `Allowed tools for ${cwd}:\n${projectConfig.allowedTools.join('\n')}` +} + +/** + * Handler for the 'approved-tools remove' command + */ +export function handleRemoveApprovedTool( + tool: string, + projectConfigHandler: ProjectConfigHandler = defaultConfigHandler, +): { success: boolean; message: string } { + const projectConfig = projectConfigHandler.getCurrentProjectConfig() + const originalToolCount = projectConfig.allowedTools.length + const updatedAllowedTools = projectConfig.allowedTools.filter(t => t !== tool) + + if (originalToolCount !== updatedAllowedTools.length) { + projectConfig.allowedTools = updatedAllowedTools + projectConfigHandler.saveCurrentProjectConfig(projectConfig) + return { + success: true, + message: `Removed ${tool} from the list of approved tools`, + } + } else { + return { + success: false, + message: `${tool} was not in the list of approved tools`, + } + } +} diff --git a/src/commands/bug.tsx b/src/commands/bug.tsx new file mode 100644 index 0000000..bc573ae --- /dev/null +++ b/src/commands/bug.tsx @@ -0,0 +1,20 @@ +import { Command } from '../commands.js' +import { Bug } from '../components/Bug.js' +import * as React from 'react' +import { PRODUCT_NAME } from '../constants/product.js' + +const bug = { + type: 'local-jsx', + name: 'bug', + description: `Submit feedback about ${PRODUCT_NAME}`, + isEnabled: true, + isHidden: false, + async call(onDone) { + return + }, + userFacingName() { + return 'bug' + }, +} satisfies Command + +export default bug diff --git a/src/commands/clear.ts b/src/commands/clear.ts new file mode 100644 index 0000000..756965f --- /dev/null +++ b/src/commands/clear.ts @@ -0,0 +1,37 @@ +import { Command } from '../commands.js' +import { getMessagesSetter } from '../messages.js' +import { getContext } from '../context.js' +import { getCodeStyle } from '../utils/style.js' +import { clearTerminal } from '../utils/terminal.js' +import { getOriginalCwd, setCwd } from '../utils/state.js' +import { Message } from '../query.js' + +export async function clearConversation(context: { + setForkConvoWithMessagesOnTheNextRender: ( + forkConvoWithMessages: Message[], + ) => void +}) { + await clearTerminal() + getMessagesSetter()([]) + context.setForkConvoWithMessagesOnTheNextRender([]) + getContext.cache.clear?.() + getCodeStyle.cache.clear?.() + await setCwd(getOriginalCwd()) +} + +const clear = { + type: 'local', + name: 'clear', + description: 'Clear conversation history and free up context', + isEnabled: true, + isHidden: false, + async call(_, context) { + clearConversation(context) + return '' + }, + userFacingName() { + return 'clear' + }, +} satisfies Command + +export default clear diff --git a/src/commands/compact.ts b/src/commands/compact.ts new file mode 100644 index 0000000..ebffb6f --- /dev/null +++ b/src/commands/compact.ts @@ -0,0 +1,94 @@ +import { Command } from '../commands.js' +import { getContext } from '../context.js' +import { getMessagesGetter, getMessagesSetter } from '../messages.js' +import { API_ERROR_MESSAGE_PREFIX, querySonnet } from '../services/claude.js' +import { + createUserMessage, + normalizeMessagesForAPI, +} from '../utils/messages.js' +import { getCodeStyle } from '../utils/style.js' +import { clearTerminal } from '../utils/terminal.js' + +const compact = { + type: 'local', + name: 'compact', + description: 'Clear conversation history but keep a summary in context', + isEnabled: true, + isHidden: false, + async call( + _, + { + options: { tools, slowAndCapableModel }, + abortController, + setForkConvoWithMessagesOnTheNextRender, + }, + ) { + // Get existing messages before clearing + const messages = getMessagesGetter()() + + // Add summary request as a new message + const summaryRequest = createUserMessage( + "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.", + ) + + const summaryResponse = await querySonnet( + normalizeMessagesForAPI([...messages, summaryRequest]), + ['You are a helpful AI assistant tasked with summarizing conversations.'], + 0, + tools, + abortController.signal, + { + dangerouslySkipPermissions: false, + model: slowAndCapableModel, + prependCLISysprompt: true, + }, + ) + + // Extract summary from response, throw if we can't get it + const content = summaryResponse.message.content + const summary = + typeof content === 'string' + ? content + : content.length > 0 && content[0]?.type === 'text' + ? content[0].text + : null + + if (!summary) { + throw new Error( + `Failed to generate conversation summary - response did not contain valid text content - ${summaryResponse}`, + ) + } else if (summary.startsWith(API_ERROR_MESSAGE_PREFIX)) { + throw new Error(summary) + } + + // Substitute low token usage info so that the context-size UI warning goes + // away. The actual numbers don't matter too much: `countTokens` checks the + // most recent assistant message for usage numbers, so this estimate will + // be overridden quickly. + summaryResponse.message.usage = { + input_tokens: 0, + output_tokens: summaryResponse.message.usage.output_tokens, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + } + + // Clear screen and messages + await clearTerminal() + getMessagesSetter()([]) + setForkConvoWithMessagesOnTheNextRender([ + createUserMessage( + `Use the /compact command to clear the conversation history, and start a new conversation with the summary in context.`, + ), + summaryResponse, + ]) + getContext.cache.clear?.() + getCodeStyle.cache.clear?.() + + return '' // not used, just for typesafety. TODO: avoid this hack + }, + userFacingName() { + return 'compact' + }, +} satisfies Command + +export default compact diff --git a/src/commands/config.tsx b/src/commands/config.tsx new file mode 100644 index 0000000..b2684a6 --- /dev/null +++ b/src/commands/config.tsx @@ -0,0 +1,19 @@ +import { Command } from '../commands.js' +import { Config } from '../components/Config.js' +import * as React from 'react' + +const config = { + type: 'local-jsx', + name: 'config', + description: 'Open config panel', + isEnabled: true, + isHidden: false, + async call(onDone) { + return + }, + userFacingName() { + return 'config' + }, +} satisfies Command + +export default config diff --git a/src/commands/cost.ts b/src/commands/cost.ts new file mode 100644 index 0000000..199ebdf --- /dev/null +++ b/src/commands/cost.ts @@ -0,0 +1,18 @@ +import type { Command } from '../commands.js' +import { formatTotalCost } from '../cost-tracker.js' + +const cost = { + type: 'local', + name: 'cost', + description: 'Show the total cost and duration of the current session', + isEnabled: true, + isHidden: false, + async call() { + return formatTotalCost() + }, + userFacingName() { + return 'cost' + }, +} satisfies Command + +export default cost diff --git a/src/commands/ctx_viz.ts b/src/commands/ctx_viz.ts new file mode 100644 index 0000000..12964fc --- /dev/null +++ b/src/commands/ctx_viz.ts @@ -0,0 +1,209 @@ +import type { Command } from '../commands.js' +import type { Tool } from '../Tool.js' +import Table from 'cli-table3' +import { getSystemPrompt } from '../constants/prompts.js' +import { getContext } from '../context.js' +import { zodToJsonSchema } from 'zod-to-json-schema' +import { getMessagesGetter } from '../messages.js' + +// Quick and dirty estimate of bytes per token for rough token counts +const BYTES_PER_TOKEN = 4 + +interface Section { + title: string + content: string +} + +interface ToolSummary { + name: string + description: string +} + +function getContextSections(text: string): Section[] { + const sections: Section[] = [] + + // Find first tag + const firstContextIndex = text.indexOf(' 0) { + const coreSysprompt = text.slice(0, firstContextIndex).trim() + if (coreSysprompt) { + sections.push({ + title: 'Core Sysprompt', + content: coreSysprompt, + }) + } + } + + let currentPos = firstContextIndex + let nonContextContent = '' + + const regex = /([\s\S]*?)<\/context>/g + let match: RegExpExecArray | null + + while ((match = regex.exec(text)) !== null) { + // Collect text between context tags + if (match.index > currentPos) { + nonContextContent += text.slice(currentPos, match.index) + } + + const [, name = 'Unnamed Section', content = ''] = match + sections.push({ + title: name === 'codeStyle' ? "CodeStyle + CLAUDE.md's" : name, + content: content.trim(), + }) + + currentPos = match.index + match[0].length + } + + // Collect remaining text after last tag + if (currentPos < text.length) { + nonContextContent += text.slice(currentPos) + } + + // Add non-contextualized content if present + const trimmedNonContext = nonContextContent.trim() + if (trimmedNonContext) { + sections.push({ + title: 'Non-contextualized Content', + content: trimmedNonContext, + }) + } + + return sections +} + +function formatTokenCount(bytes: number): string { + const tokens = bytes / BYTES_PER_TOKEN + const k = tokens / 1000 + return `${Math.round(k * 10) / 10}k` +} + +function formatByteCount(bytes: number): string { + const kb = bytes / 1024 + return `${Math.round(kb * 10) / 10}kb` +} + +function createSummaryTable( + systemText: string, + systemSections: Section[], + tools: ToolSummary[], + messages: unknown, +): string { + const table = new Table({ + head: ['Component', 'Tokens', 'Size', '% Used'], + style: { head: ['bold'] }, + chars: { + mid: '─', + 'left-mid': '├', + 'mid-mid': '┼', + 'right-mid': '┤', + }, + }) + + const messagesStr = JSON.stringify(messages) + const toolsStr = JSON.stringify(tools) + + // Calculate total for percentages + const total = systemText.length + toolsStr.length + messagesStr.length + const getPercentage = (n: number) => `${Math.round((n / total) * 100)}%` + + // System prompt and its sections + table.push([ + 'System prompt', + formatTokenCount(systemText.length), + formatByteCount(systemText.length), + getPercentage(systemText.length), + ]) + for (const section of systemSections) { + table.push([ + ` ${section.title}`, + formatTokenCount(section.content.length), + formatByteCount(section.content.length), + getPercentage(section.content.length), + ]) + } + + // Tools + table.push([ + 'Tool definitions', + formatTokenCount(toolsStr.length), + formatByteCount(toolsStr.length), + getPercentage(toolsStr.length), + ]) + for (const tool of tools) { + table.push([ + ` ${tool.name}`, + formatTokenCount(tool.description.length), + formatByteCount(tool.description.length), + getPercentage(tool.description.length), + ]) + } + + // Messages and total + table.push( + [ + 'Messages', + formatTokenCount(messagesStr.length), + formatByteCount(messagesStr.length), + getPercentage(messagesStr.length), + ], + ['Total', formatTokenCount(total), formatByteCount(total), '100%'], + ) + + return table.toString() +} + +const command: Command = { + name: 'ctx-viz', + description: + '[ANT-ONLY] Show token usage breakdown for the current conversation context', + isEnabled: process.env.USER_TYPE === 'ant', + isHidden: false, + type: 'local', + + userFacingName() { + return this.name + }, + + async call(_args: string, cmdContext: { options: { tools: Tool[] } }) { + // Get tools and system prompt with injected context + const [systemPromptRaw, sysContext] = await Promise.all([ + getSystemPrompt(), + getContext(), + ]) + + const rawTools = cmdContext.options.tools + + // Full system prompt with context sections injected + let systemPrompt = systemPromptRaw.join('\n') + for (const [name, content] of Object.entries(sysContext)) { + systemPrompt += `\n${content}` + } + + // Get full tool definitions including prompts and schemas + const tools = rawTools.map(t => { + // Get full prompt and schema + const fullPrompt = t.prompt({ dangerouslySkipPermissions: false }) + const schema = JSON.stringify( + 'inputJSONSchema' in t && t.inputJSONSchema + ? t.inputJSONSchema + : zodToJsonSchema(t.inputSchema), + ) + + return { + name: t.name, + description: `${fullPrompt}\n\nSchema:\n${schema}`, + } + }) + + // Get current messages from REPL + const messages = getMessagesGetter()() + + const sections = getContextSections(systemPrompt) + return createSummaryTable(systemPrompt, sections, tools, messages) + }, +} + +export default command diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts new file mode 100644 index 0000000..ad45627 --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,23 @@ +import React from 'react' +import type { Command } from '../commands.js' +import { Doctor } from '../screens/Doctor.js' + +const doctor: Command = { + name: 'doctor', + description: 'Checks the health of your Claude Code installation', + isEnabled: true, + isHidden: false, + userFacingName() { + return 'doctor' + }, + type: 'local-jsx', + call(onDone) { + const element = React.createElement(Doctor, { + onDone, + doctorMode: true, + }) + return Promise.resolve(element) + }, +} + +export default doctor diff --git a/src/commands/help.tsx b/src/commands/help.tsx new file mode 100644 index 0000000..933e54b --- /dev/null +++ b/src/commands/help.tsx @@ -0,0 +1,19 @@ +import { Command } from '../commands.js' +import { Help } from '../components/Help.js' +import * as React from 'react' + +const help = { + type: 'local-jsx', + name: 'help', + description: 'Show help and available commands', + isEnabled: true, + isHidden: false, + async call(onDone, { options: { commands } }) { + return + }, + userFacingName() { + return 'help' + }, +} satisfies Command + +export default help diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..b42d6b9 --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,37 @@ +import type { Command } from '../commands.js' +import { markProjectOnboardingComplete } from '../ProjectOnboarding.js' + +const command = { + type: 'prompt', + name: 'init', + description: 'Initialize a new CLAUDE.md file with codebase documentation', + isEnabled: true, + isHidden: false, + progressMessage: 'analyzing your codebase', + userFacingName() { + return 'init' + }, + async getPromptForCommand(_args: string) { + // Mark onboarding as complete when init command is run + markProjectOnboardingComplete() + return [ + { + role: 'user', + content: [ + { + type: 'text', + text: `Please analyze this codebase and create a CLAUDE.md file containing: +1. Build/lint/test commands - especially for running a single test +2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. + +The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long. +If there's already a CLAUDE.md, improve it. +If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`, + }, + ], + }, + ] + }, +} satisfies Command + +export default command diff --git a/src/commands/listen.ts b/src/commands/listen.ts new file mode 100644 index 0000000..89061f8 --- /dev/null +++ b/src/commands/listen.ts @@ -0,0 +1,42 @@ +import { Command } from '../commands.js' +import { logError } from '../utils/log.js' +import { execFileNoThrow } from '../utils/execFileNoThrow.js' + +const isEnabled = + process.platform === 'darwin' && + ['iTerm.app', 'Apple_Terminal'].includes(process.env.TERM_PROGRAM || '') + +const listen: Command = { + type: 'local', + name: 'listen', + description: 'Activates speech recognition and transcribes speech to text', + isEnabled: isEnabled, + isHidden: isEnabled, + userFacingName() { + return 'listen' + }, + async call(_, { abortController }) { + // Start dictation using AppleScript + const script = `tell application "System Events" to tell ¬ +(the first process whose frontmost is true) to tell ¬ +menu bar 1 to tell ¬ +menu bar item "Edit" to tell ¬ +menu "Edit" to tell ¬ +menu item "Start Dictation" to ¬ +if exists then click it` + + const { stderr, code } = await execFileNoThrow( + 'osascript', + ['-e', script], + abortController.signal, + ) + + if (code !== 0) { + logError(`Failed to start dictation: ${stderr}`) + return 'Failed to start dictation' + } + return 'Dictation started. Press esc to stop.' + }, +} + +export default listen diff --git a/src/commands/login.tsx b/src/commands/login.tsx new file mode 100644 index 0000000..34f2695 --- /dev/null +++ b/src/commands/login.tsx @@ -0,0 +1,51 @@ +import * as React from 'react' +import type { Command } from '../commands.js' +import { ConsoleOAuthFlow } from '../components/ConsoleOAuthFlow.js' +import { clearTerminal } from '../utils/terminal.js' +import { isLoggedInToAnthropic } from '../utils/auth.js' +import { useExitOnCtrlCD } from '../hooks/useExitOnCtrlCD.js' +import { Box, Text } from 'ink' +import { clearConversation } from './clear.js' + +export default () => + ({ + type: 'local-jsx', + name: 'login', + description: isLoggedInToAnthropic() + ? 'Switch Anthropic accounts' + : 'Sign in with your Anthropic account', + isEnabled: true, + isHidden: false, + async call(onDone, context) { + await clearTerminal() + return ( + { + clearConversation(context) + onDone() + }} + /> + ) + }, + userFacingName() { + return 'login' + }, + }) satisfies Command + +function Login(props: { onDone: () => void }) { + const exitState = useExitOnCtrlCD(props.onDone) + return ( + + + + + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + '' + )} + + + + ) +} diff --git a/src/commands/logout.tsx b/src/commands/logout.tsx new file mode 100644 index 0000000..4c0d8c0 --- /dev/null +++ b/src/commands/logout.tsx @@ -0,0 +1,41 @@ +import * as React from 'react' +import type { Command } from '../commands.js' +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' +import { clearTerminal } from '../utils/terminal.js' +import { Text } from 'ink' + +export default { + type: 'local-jsx', + name: 'logout', + description: 'Sign out from your Anthropic account', + isEnabled: true, + isHidden: false, + async call() { + await clearTerminal() + + const config = getGlobalConfig() + + config.oauthAccount = undefined + config.primaryApiKey = undefined + config.hasCompletedOnboarding = false + + if (config.customApiKeyResponses?.approved) { + config.customApiKeyResponses.approved = [] + } + + saveGlobalConfig(config) + + const message = ( + Successfully logged out from your Anthropic account. + ) + + setTimeout(() => { + process.exit(0) + }, 200) + + return message + }, + userFacingName() { + return 'logout' + }, +} satisfies Command diff --git a/src/commands/onboarding.tsx b/src/commands/onboarding.tsx new file mode 100644 index 0000000..58b30b3 --- /dev/null +++ b/src/commands/onboarding.tsx @@ -0,0 +1,34 @@ +import * as React from 'react' +import type { Command } from '../commands.js' +import { Onboarding } from '../components/Onboarding.js' +import { clearTerminal } from '../utils/terminal.js' +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' +import { clearConversation } from './clear.js' + +export default { + type: 'local-jsx', + name: 'onboarding', + description: '[ANT-ONLY] Run through the onboarding flow', + isEnabled: process.env.USER_TYPE === 'ant', + isHidden: false, + async call(onDone, context) { + await clearTerminal() + const config = getGlobalConfig() + saveGlobalConfig({ + ...config, + theme: 'dark', + }) + + return ( + { + clearConversation(context) + onDone() + }} + /> + ) + }, + userFacingName() { + return 'onboarding' + }, +} satisfies Command diff --git a/src/commands/pr_comments.ts b/src/commands/pr_comments.ts new file mode 100644 index 0000000..69d4a01 --- /dev/null +++ b/src/commands/pr_comments.ts @@ -0,0 +1,59 @@ +import { Command } from '../commands.js' + +export default { + type: 'prompt', + name: 'pr-comments', + description: 'Get comments from a GitHub pull request', + progressMessage: 'fetching PR comments', + isEnabled: true, + isHidden: false, + userFacingName() { + return 'pr-comments' + }, + async getPromptForCommand(args: string) { + return [ + { + role: 'user', + content: [ + { + type: 'text', + text: `You are an AI assistant integrated into a git-based version control system. Your task is to fetch and display comments from a GitHub pull request. + +Follow these steps: + +1. Use \`gh pr view --json number,headRepository\` to get the PR number and repository info +2. Use \`gh api /repos/{owner}/{repo}/issues/{number}/comments\` to get PR-level comments +3. Use \`gh api /repos/{owner}/{repo}/pulls/{number}/comments\` to get review comments. Pay particular attention to the following fields: \`body\`, \`diff_hunk\`, \`path\`, \`line\`, etc. If the comment references some code, consider fetching it using eg \`gh api /repos/{owner}/{repo}/contents/{path}?ref={branch} | jq .content -r | base64 -d\` +4. Parse and format all comments in a readable way +5. Return ONLY the formatted comments, with no additional text + +Format the comments as: + +## Comments + +[For each comment thread:] +- @author file.ts#line: + \`\`\`diff + [diff_hunk from the API response] + \`\`\` + > quoted comment text + + [any replies indented] + +If there are no comments, return "No comments found." + +Remember: +1. Only show the actual comments, no explanatory text +2. Include both PR-level and code review comments +3. Preserve the threading/nesting of comment replies +4. Show the file and line number context for code review comments +5. Use jq to parse the JSON responses from the GitHub API + +${args ? 'Additional user input: ' + args : ''} +`, + }, + ], + }, + ] + }, +} satisfies Command diff --git a/src/commands/release-notes.ts b/src/commands/release-notes.ts new file mode 100644 index 0000000..9408931 --- /dev/null +++ b/src/commands/release-notes.ts @@ -0,0 +1,33 @@ +import type { Command } from '../commands.js' +import { RELEASE_NOTES } from '../constants/releaseNotes.js' + +const releaseNotes: Command = { + description: 'Show release notes for the current or specified version', + isEnabled: false, + isHidden: false, + name: 'release-notes', + userFacingName() { + return 'release-notes' + }, + type: 'local', + async call(args) { + const currentVersion = MACRO.VERSION + + // If a specific version is requested, show that version's notes + const requestedVersion = args ? args.trim() : currentVersion + + // Get the requested version's notes + const notes = RELEASE_NOTES[requestedVersion] + + if (!notes || notes.length === 0) { + return `No release notes available for version ${requestedVersion}.` + } + + const header = `Release notes for version ${requestedVersion}:` + const formattedNotes = notes.map(note => `• ${note}`).join('\n') + + return `${header}\n\n${formattedNotes}` + }, +} + +export default releaseNotes diff --git a/src/commands/resume.tsx b/src/commands/resume.tsx new file mode 100644 index 0000000..fd4ee20 --- /dev/null +++ b/src/commands/resume.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' +import type { Command } from '../commands.js' +import { ResumeConversation } from '../screens/ResumeConversation.js' +import { render } from 'ink' +import { CACHE_PATHS, loadLogList } from '../utils/log.js' + +export default { + type: 'local-jsx', + name: 'resume', + description: '[ANT-ONLY] Resume a previous conversation', + isEnabled: process.env.USER_TYPE === 'ant', + isHidden: process.env.USER_TYPE !== 'ant', + userFacingName() { + return 'resume' + }, + async call(onDone, { options: { commands, tools, verbose } }) { + const logs = await loadLogList(CACHE_PATHS.messages()) + render( + , + ) + // This return is here for type only + return null + }, +} satisfies Command diff --git a/src/commands/review.ts b/src/commands/review.ts new file mode 100644 index 0000000..8111a50 --- /dev/null +++ b/src/commands/review.ts @@ -0,0 +1,49 @@ +import { Command } from '../commands.js' +import { BashTool } from '../tools/BashTool/BashTool.js' + +export default { + type: 'prompt', + name: 'review', + description: 'Review a pull request', + isEnabled: true, + isHidden: false, + progressMessage: 'reviewing pull request', + userFacingName() { + return 'review' + }, + async getPromptForCommand(args) { + return [ + { + role: 'user', + content: [ + { + type: 'text', + text: ` + You are an expert code reviewer. Follow these steps: + + 1. If no PR number is provided in the args, use ${BashTool.name}("gh pr list") to show open PRs + 2. If a PR number is provided, use ${BashTool.name}("gh pr view ") to get PR details + 3. Use ${BashTool.name}("gh pr diff ") to get the diff + 4. Analyze the changes and provide a thorough code review that includes: + - Overview of what the PR does + - Analysis of code quality and style + - Specific suggestions for improvements + - Any potential issues or risks + + Keep your review concise but thorough. Focus on: + - Code correctness + - Following project conventions + - Performance implications + - Test coverage + - Security considerations + + Format your review with clear sections and bullet points. + + PR number: ${args} + `, + }, + ], + }, + ] + }, +} satisfies Command diff --git a/src/commands/terminalSetup.ts b/src/commands/terminalSetup.ts new file mode 100644 index 0000000..b79bd1a --- /dev/null +++ b/src/commands/terminalSetup.ts @@ -0,0 +1,143 @@ +import { Command } from '../commands.js' +import { EOL, platform, homedir } from 'os' +import { execFileNoThrow } from '../utils/execFileNoThrow.js' +import chalk from 'chalk' +import { getTheme } from '../utils/theme.js' +import { env } from '../utils/env.js' +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' +import { markProjectOnboardingComplete } from '../ProjectOnboarding.js' +import { readFileSync, writeFileSync } from 'fs' +import { join } from 'path' +import { safeParseJSON } from '../utils/json.js' +import { logError } from '../utils/log.js' + +const terminalSetup: Command = { + type: 'local', + name: 'terminal-setup', + userFacingName() { + return 'terminal-setup' + }, + description: + 'Install Shift+Enter key binding for newlines (iTerm2 and VSCode only)', + isEnabled: + (platform() === 'darwin' && env.terminal === 'iTerm.app') || + env.terminal === 'vscode', + isHidden: false, + async call() { + let result = '' + + switch (env.terminal) { + case 'iTerm.app': + result = await installBindingsForITerm2() + break + case 'vscode': + result = installBindingsForVSCodeTerminal() + break + } + + // Update global config to indicate Shift+Enter key binding is installed + const config = getGlobalConfig() + config.shiftEnterKeyBindingInstalled = true + saveGlobalConfig(config) + + // Mark onboarding as complete + markProjectOnboardingComplete() + + return result + }, +} + +export function isShiftEnterKeyBindingInstalled(): boolean { + return getGlobalConfig().shiftEnterKeyBindingInstalled === true +} + +export default terminalSetup + +async function installBindingsForITerm2(): Promise { + const { code } = await execFileNoThrow('defaults', [ + 'write', + 'com.googlecode.iterm2', + 'GlobalKeyMap', + '-dict-add', + '0xd-0x20000-0x24', + ` + Text + \\n + Action + 12 + Version + 1 + Keycode + 13 + Modifiers + 131072 + `, + ]) + + if (code !== 0) { + throw new Error('Failed to install iTerm2 Shift+Enter key binding') + } + + return `${chalk.hex(getTheme().success)( + 'Installed iTerm2 Shift+Enter key binding', + )}${EOL}${chalk.dim('See iTerm2 → Preferences → Keys')}${EOL}` +} + +type VSCodeKeybinding = { + key: string + command: string + args: { text: string } + when: string +} + +function installBindingsForVSCodeTerminal(): string { + const vscodeKeybindingsPath = join( + homedir(), + platform() === 'win32' + ? join('AppData', 'Roaming', 'Code', 'User') + : platform() === 'darwin' + ? join('Library', 'Application Support', 'Code', 'User') + : join('.config', 'Code', 'User'), + 'keybindings.json', + ) + + try { + const content = readFileSync(vscodeKeybindingsPath, 'utf-8') + const keybindings: VSCodeKeybinding[] = + (safeParseJSON(content) as VSCodeKeybinding[]) ?? [] + + // Check if keybinding already exists + const existingBinding = keybindings.find( + binding => + binding.key === 'shift+enter' && + binding.command === 'workbench.action.terminal.sendSequence' && + binding.when === 'terminalFocus', + ) + if (existingBinding) { + return `${chalk.hex(getTheme().warning)( + 'Found existing VSCode terminal Shift+Enter key binding. Remove it to continue.', + )}${EOL}${chalk.dim(`See ${vscodeKeybindingsPath}`)}${EOL}` + } + + // Add the keybinding + keybindings.push({ + key: 'shift+enter', + command: 'workbench.action.terminal.sendSequence', + args: { text: '\\\r\n' }, + when: 'terminalFocus', + }) + + writeFileSync( + vscodeKeybindingsPath, + JSON.stringify(keybindings, null, 4), + 'utf-8', + ) + + return `${chalk.hex(getTheme().success)( + 'Installed VSCode terminal Shift+Enter key binding', + )}${EOL}${chalk.dim(`See ${vscodeKeybindingsPath}`)}${EOL}` + } catch (e) { + logError(e) + throw new Error('Failed to install VSCode terminal Shift+Enter key binding') + } +} diff --git a/src/components/AnimatedClaudeAsterisk.tsx b/src/components/AnimatedClaudeAsterisk.tsx new file mode 100644 index 0000000..5d9ba79 --- /dev/null +++ b/src/components/AnimatedClaudeAsterisk.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import { Text } from 'ink' +import { + smallAnimatedArray, + largeAnimatedAray, +} from '../constants/claude-asterisk-ascii-art.js' +import { getTheme } from '../utils/theme.js' + +export type ClaudeAsteriskSize = 'small' | 'medium' | 'large' + +interface AnimatedClaudeAsteriskProps { + size?: ClaudeAsteriskSize + cycles?: number + color?: string + intervalMs?: number +} + +export function AnimatedClaudeAsterisk({ + size = 'small', + cycles, + color, + intervalMs, +}: AnimatedClaudeAsteriskProps): React.ReactNode { + const [currentAsciiArtIndex, setCurrentAsciiArtIndex] = React.useState(0) + const direction = React.useRef(1) + const animateLoopCount = React.useRef(0) + const theme = getTheme() + + // Determine which array to use based on size + const animatedArray = + size === 'large' ? largeAnimatedAray : smallAnimatedArray + + // Animation interval for ascii art + React.useEffect(() => { + const timer = setInterval( + () => { + setCurrentAsciiArtIndex(prevIndex => { + // Stop animating after specified number of cycles if provided + if ( + cycles !== undefined && + cycles !== null && + animateLoopCount.current >= cycles + ) { + return 0 + } + + // Cycle through array indices + if (prevIndex === animatedArray.length - 1) { + direction.current = -1 + animateLoopCount.current += 1 + } + if (prevIndex === 0) { + direction.current = 1 + } + return prevIndex + direction.current + }) + }, + intervalMs || (size === 'large' ? 100 : 200), + ) // Default: 100ms for large, 200ms for small/medium + + return () => clearInterval(timer) + }, [animatedArray.length, cycles, intervalMs, size]) + + return ( + + {animatedArray[currentAsciiArtIndex]} + + ) +} diff --git a/src/components/ApproveApiKey.tsx b/src/components/ApproveApiKey.tsx new file mode 100644 index 0000000..55055b7 --- /dev/null +++ b/src/components/ApproveApiKey.tsx @@ -0,0 +1,93 @@ +import React from 'react' +import { Box, Text } from 'ink' +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' +import { getTheme } from '../utils/theme.js' +import { Select } from '@inkjs/ui' +import { useExitOnCtrlCD } from '../hooks/useExitOnCtrlCD.js' +import chalk from 'chalk' + +type Props = { + customApiKeyTruncated: string + onDone(): void +} + +export function ApproveApiKey({ + customApiKeyTruncated, + onDone, +}: Props): React.ReactNode { + const theme = getTheme() + + function onChange(value: 'yes' | 'no') { + const config = getGlobalConfig() + switch (value) { + case 'yes': { + saveGlobalConfig({ + ...config, + customApiKeyResponses: { + ...config.customApiKeyResponses, + approved: [ + ...(config.customApiKeyResponses?.approved ?? []), + customApiKeyTruncated, + ], + }, + }) + onDone() + break + } + case 'no': { + saveGlobalConfig({ + ...config, + customApiKeyResponses: { + ...config.customApiKeyResponses, + rejected: [ + ...(config.customApiKeyResponses?.rejected ?? []), + customApiKeyTruncated, + ], + }, + }) + onDone() + break + } + } + } + + const exitState = useExitOnCtrlCD(() => process.exit(0)) + + return ( + <> + + + Detected a custom API key in your environment + + + Your environment sets{' '} + ANTHROPIC_API_KEY:{' '} + sk-ant-...{customApiKeyTruncated} + + Do you want to use this API key? + + + + ) +} diff --git a/src/components/CustomSelect/option-map.ts b/src/components/CustomSelect/option-map.ts new file mode 100644 index 0000000..c357db0 --- /dev/null +++ b/src/components/CustomSelect/option-map.ts @@ -0,0 +1,42 @@ +import { type Option } from '@inkjs/ui' +import { optionHeaderKey, type OptionHeader } from './select.js' + +type OptionMapItem = (Option | OptionHeader) & { + previous: OptionMapItem | undefined + next: OptionMapItem | undefined + index: number +} + +export default class OptionMap extends Map { + readonly first: OptionMapItem | undefined + + constructor(options: (Option | OptionHeader)[]) { + const items: Array<[string, OptionMapItem]> = [] + let firstItem: OptionMapItem | undefined + let previous: OptionMapItem | undefined + let index = 0 + + for (const option of options) { + const item = { + ...option, + previous, + next: undefined, + index, + } + + if (previous) { + previous.next = item + } + + firstItem ||= item + + const key = 'value' in option ? option.value : optionHeaderKey(option) + items.push([key, item]) + index++ + previous = item + } + + super(items) + this.first = firstItem + } +} diff --git a/src/components/CustomSelect/select-option.tsx b/src/components/CustomSelect/select-option.tsx new file mode 100644 index 0000000..aa090b8 --- /dev/null +++ b/src/components/CustomSelect/select-option.tsx @@ -0,0 +1,52 @@ +import figures from 'figures' +import { Box, Text } from 'ink' +import React, { type ReactNode } from 'react' +import { type Theme } from './theme.js' +import { useComponentTheme } from '@inkjs/ui' + +export type SelectOptionProps = { + /** + * Determines if option is focused. + */ + readonly isFocused: boolean + + /** + * Determines if option is selected. + */ + readonly isSelected: boolean + + /** + * Determines if pointer is shown when selected + */ + readonly smallPointer?: boolean + + /** + * Option label. + */ + readonly children: ReactNode +} + +export function SelectOption({ + isFocused, + isSelected, + smallPointer, + children, +}: SelectOptionProps) { + const { styles } = useComponentTheme('Select') + + return ( + + {isFocused && ( + + {smallPointer ? figures.triangleDownSmall : figures.pointer} + + )} + + {children} + + {isSelected && ( + {figures.tick} + )} + + ) +} diff --git a/src/components/CustomSelect/select.tsx b/src/components/CustomSelect/select.tsx new file mode 100644 index 0000000..0a0c2c0 --- /dev/null +++ b/src/components/CustomSelect/select.tsx @@ -0,0 +1,143 @@ +import { Box, Text } from 'ink' +import React, { type ReactNode } from 'react' +import { SelectOption } from './select-option.js' +import { type Theme } from './theme.js' +import { useSelectState } from './use-select-state.js' +import { useSelect } from './use-select.js' +import { Option, useComponentTheme } from '@inkjs/ui' + +export type OptionSubtree = { + /** + * Header to show above sub-options. + */ + readonly header?: string + + /** + * Options. + */ + readonly options: (Option | OptionSubtree)[] +} + +export type OptionHeader = { + readonly header: string + + readonly optionValues: string[] +} + +export const optionHeaderKey = (optionHeader: OptionHeader): string => + `HEADER-${optionHeader.optionValues.join(',')}` + +export type SelectProps = { + /** + * When disabled, user input is ignored. + * + * @default false + */ + readonly isDisabled?: boolean + + /** + * Number of visible options. + * + * @default 5 + */ + readonly visibleOptionCount?: number + + /** + * Highlight text in option labels. + */ + readonly highlightText?: string + + /** + * Options. + */ + readonly options: (Option | OptionSubtree)[] + + /** + * Default value. + */ + readonly defaultValue?: string + + /** + * Callback when selected option changes. + */ + readonly onChange?: (value: string) => void + + /** + * Callback when focused option changes. + */ + readonly onFocus?: (value: string) => void + + /** + * Value to focus + */ + readonly focusValue?: string +} + +export function Select({ + isDisabled = false, + visibleOptionCount = 5, + highlightText, + options, + defaultValue, + onChange, + onFocus, + focusValue, +}: SelectProps) { + const state = useSelectState({ + visibleOptionCount, + options, + defaultValue, + onChange, + onFocus, + focusValue, + }) + + useSelect({ isDisabled, state }) + + const { styles } = useComponentTheme('Select') + + return ( + + {state.visibleOptions.map(option => { + const key = 'value' in option ? option.value : optionHeaderKey(option) + const isFocused = + !isDisabled && + state.focusedValue !== undefined && + ('value' in option + ? state.focusedValue === option.value + : option.optionValues.includes(state.focusedValue)) + const isSelected = + !!state.value && + ('value' in option + ? state.value === option.value + : option.optionValues.includes(state.value)) + const smallPointer = 'header' in option + const labelText = 'label' in option ? option.label : option.header + let label: ReactNode = labelText + + if (highlightText && labelText.includes(highlightText)) { + const index = labelText.indexOf(highlightText) + + label = ( + <> + {labelText.slice(0, index)} + {highlightText} + {labelText.slice(index + highlightText.length)} + + ) + } + + return ( + + {label} + + ) + })} + + ) +} diff --git a/src/components/CustomSelect/use-select-state.ts b/src/components/CustomSelect/use-select-state.ts new file mode 100644 index 0000000..86ef808 --- /dev/null +++ b/src/components/CustomSelect/use-select-state.ts @@ -0,0 +1,387 @@ +import { isDeepStrictEqual } from 'node:util' +import { + useReducer, + type Reducer, + useCallback, + useMemo, + useState, + useEffect, +} from 'react' +import OptionMap from './option-map.js' +import { Option } from '@inkjs/ui' +import type { OptionHeader, OptionSubtree } from './select.js' + +type State = { + /** + * Map where key is option's value and value is option's index. + */ + optionMap: OptionMap + + /** + * Number of visible options. + */ + visibleOptionCount: number + + /** + * Value of the currently focused option. + */ + focusedValue: string | undefined + + /** + * Index of the first visible option. + */ + visibleFromIndex: number + + /** + * Index of the last visible option. + */ + visibleToIndex: number + + /** + * Value of the previously selected option. + */ + previousValue: string | undefined + + /** + * Value of the selected option. + */ + value: string | undefined +} + +type Action = + | FocusNextOptionAction + | FocusPreviousOptionAction + | SelectFocusedOptionAction + | SetFocusAction + | ResetAction + +type SetFocusAction = { + type: 'set-focus' + value: string +} + +type FocusNextOptionAction = { + type: 'focus-next-option' +} + +type FocusPreviousOptionAction = { + type: 'focus-previous-option' +} + +type SelectFocusedOptionAction = { + type: 'select-focused-option' +} + +type ResetAction = { + type: 'reset' + state: State +} + +const reducer: Reducer = (state, action) => { + switch (action.type) { + case 'focus-next-option': { + if (!state.focusedValue) { + return state + } + + const item = state.optionMap.get(state.focusedValue) + + if (!item) { + return state + } + + let next = item.next + while (next && !('value' in next)) { + // Skip headers + next = next.next + } + + if (!next) { + return state + } + + const needsToScroll = next.index >= state.visibleToIndex + + if (!needsToScroll) { + return { + ...state, + focusedValue: next.value, + } + } + + const nextVisibleToIndex = Math.min( + state.optionMap.size, + state.visibleToIndex + 1, + ) + + const nextVisibleFromIndex = nextVisibleToIndex - state.visibleOptionCount + + return { + ...state, + focusedValue: next.value, + visibleFromIndex: nextVisibleFromIndex, + visibleToIndex: nextVisibleToIndex, + } + } + + case 'focus-previous-option': { + if (!state.focusedValue) { + return state + } + + const item = state.optionMap.get(state.focusedValue) + + if (!item) { + return state + } + + let previous = item.previous + while (previous && !('value' in previous)) { + // Skip headers + previous = previous.previous + } + + if (!previous) { + return state + } + + const needsToScroll = previous.index <= state.visibleFromIndex + + if (!needsToScroll) { + return { + ...state, + focusedValue: previous.value, + } + } + + const nextVisibleFromIndex = Math.max(0, state.visibleFromIndex - 1) + + const nextVisibleToIndex = nextVisibleFromIndex + state.visibleOptionCount + + return { + ...state, + focusedValue: previous.value, + visibleFromIndex: nextVisibleFromIndex, + visibleToIndex: nextVisibleToIndex, + } + } + + case 'select-focused-option': { + return { + ...state, + previousValue: state.value, + value: state.focusedValue, + } + } + + case 'reset': { + return action.state + } + + case 'set-focus': { + return { + ...state, + focusedValue: action.value, + } + } + } +} + +export type UseSelectStateProps = { + /** + * Number of items to display. + * + * @default 5 + */ + visibleOptionCount?: number + + /** + * Options. + */ + options: (Option | OptionSubtree)[] + + /** + * Initially selected option's value. + */ + defaultValue?: string + + /** + * Callback for selecting an option. + */ + onChange?: (value: string) => void + + /** + * Callback for focusing an option. + */ + onFocus?: (value: string) => void + + /** + * Value to focus + */ + focusValue?: string +} + +export type SelectState = Pick< + State, + 'focusedValue' | 'visibleFromIndex' | 'visibleToIndex' | 'value' +> & { + /** + * Visible options. + */ + visibleOptions: Array<(Option | OptionHeader) & { index: number }> + + /** + * Focus next option and scroll the list down, if needed. + */ + focusNextOption: () => void + + /** + * Focus previous option and scroll the list up, if needed. + */ + focusPreviousOption: () => void + + /** + * Select currently focused option. + */ + selectFocusedOption: () => void +} + +const flattenOptions = ( + options: (Option | OptionSubtree)[], +): (Option | OptionHeader)[] => + options.flatMap(option => { + if ('options' in option) { + const flatSubtree = flattenOptions(option.options) + const optionValues = flatSubtree.flatMap(o => + 'value' in o ? o.value : [], + ) + const header = + option.header !== undefined + ? [{ header: option.header, optionValues }] + : [] + + return [...header, ...flatSubtree] + } + return option + }) + +const createDefaultState = ({ + visibleOptionCount: customVisibleOptionCount, + defaultValue, + options, +}: Pick< + UseSelectStateProps, + 'visibleOptionCount' | 'defaultValue' | 'options' +>) => { + const flatOptions = flattenOptions(options) + + const visibleOptionCount = + typeof customVisibleOptionCount === 'number' + ? Math.min(customVisibleOptionCount, flatOptions.length) + : flatOptions.length + + const optionMap = new OptionMap(flatOptions) + const firstOption = optionMap.first + const focusedValue = + firstOption && 'value' in firstOption ? firstOption.value : undefined + + return { + optionMap, + visibleOptionCount, + focusedValue, + visibleFromIndex: 0, + visibleToIndex: visibleOptionCount, + previousValue: defaultValue, + value: defaultValue, + } +} + +export const useSelectState = ({ + visibleOptionCount = 5, + options, + defaultValue, + onChange, + onFocus, + focusValue, +}: UseSelectStateProps) => { + const flatOptions = flattenOptions(options) + + const [state, dispatch] = useReducer( + reducer, + { visibleOptionCount, defaultValue, options }, + createDefaultState, + ) + + const [lastOptions, setLastOptions] = useState(flatOptions) + + if ( + flatOptions !== lastOptions && + !isDeepStrictEqual(flatOptions, lastOptions) + ) { + dispatch({ + type: 'reset', + state: createDefaultState({ visibleOptionCount, defaultValue, options }), + }) + + setLastOptions(flatOptions) + } + + const focusNextOption = useCallback(() => { + dispatch({ + type: 'focus-next-option', + }) + }, []) + + const focusPreviousOption = useCallback(() => { + dispatch({ + type: 'focus-previous-option', + }) + }, []) + + const selectFocusedOption = useCallback(() => { + dispatch({ + type: 'select-focused-option', + }) + }, []) + + const visibleOptions = useMemo(() => { + return flatOptions + .map((option, index) => ({ + ...option, + index, + })) + .slice(state.visibleFromIndex, state.visibleToIndex) + }, [flatOptions, state.visibleFromIndex, state.visibleToIndex]) + + useEffect(() => { + if (state.value && state.previousValue !== state.value) { + onChange?.(state.value) + } + }, [state.previousValue, state.value, options, onChange]) + + useEffect(() => { + if (state.focusedValue) { + onFocus?.(state.focusedValue) + } + }, [state.focusedValue, onFocus]) + + useEffect(() => { + if (focusValue) { + dispatch({ + type: 'set-focus', + value: focusValue, + }) + } + }, [focusValue]) + + return { + focusedValue: state.focusedValue, + visibleFromIndex: state.visibleFromIndex, + visibleToIndex: state.visibleToIndex, + value: state.value, + visibleOptions, + focusNextOption, + focusPreviousOption, + selectFocusedOption, + } +} diff --git a/src/components/CustomSelect/use-select.ts b/src/components/CustomSelect/use-select.ts new file mode 100644 index 0000000..225caaf --- /dev/null +++ b/src/components/CustomSelect/use-select.ts @@ -0,0 +1,35 @@ +import { useInput } from 'ink' +import { type SelectState } from './use-select-state.js' + +export type UseSelectProps = { + /** + * When disabled, user input is ignored. + * + * @default false + */ + isDisabled?: boolean + + /** + * Select state. + */ + state: SelectState +} + +export const useSelect = ({ isDisabled = false, state }: UseSelectProps) => { + useInput( + (_input, key) => { + if (key.downArrow) { + state.focusNextOption() + } + + if (key.upArrow) { + state.focusPreviousOption() + } + + if (key.return) { + state.selectFocusedOption() + } + }, + { isActive: !isDisabled }, + ) +} diff --git a/src/components/FallbackToolUseRejectedMessage.tsx b/src/components/FallbackToolUseRejectedMessage.tsx new file mode 100644 index 0000000..0f066c2 --- /dev/null +++ b/src/components/FallbackToolUseRejectedMessage.tsx @@ -0,0 +1,14 @@ +import * as React from 'react' +import { getTheme } from '../utils/theme.js' +import { Text } from 'ink' + +export function FallbackToolUseRejectedMessage(): React.ReactNode { + return ( + +   ⎿   + + No (tell Claude what to do differently) + + + ) +} diff --git a/src/components/FileEditToolUpdatedMessage.tsx b/src/components/FileEditToolUpdatedMessage.tsx new file mode 100644 index 0000000..7e815bc --- /dev/null +++ b/src/components/FileEditToolUpdatedMessage.tsx @@ -0,0 +1,66 @@ +import { Hunk } from 'diff' +import { Box, Text } from 'ink' +import * as React from 'react' +import { intersperse } from '../utils/array.js' +import { StructuredDiff } from './StructuredDiff.js' +import { getTheme } from '../utils/theme.js' +import { getCwd } from '../utils/state.js' +import { relative } from 'path' +import { useTerminalSize } from '../hooks/useTerminalSize.js' + +type Props = { + filePath: string + structuredPatch: Hunk[] + verbose: boolean +} + +export function FileEditToolUpdatedMessage({ + filePath, + structuredPatch, + verbose, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + const numAdditions = structuredPatch.reduce( + (count, hunk) => count + hunk.lines.filter(_ => _.startsWith('+')).length, + 0, + ) + const numRemovals = structuredPatch.reduce( + (count, hunk) => count + hunk.lines.filter(_ => _.startsWith('-')).length, + 0, + ) + + return ( + + + {' '}⎿ Updated{' '} + {verbose ? filePath : relative(getCwd(), filePath)} + {numAdditions > 0 || numRemovals > 0 ? ' with ' : ''} + {numAdditions > 0 ? ( + <> + {numAdditions}{' '} + {numAdditions > 1 ? 'additions' : 'addition'} + + ) : null} + {numAdditions > 0 && numRemovals > 0 ? ' and ' : null} + {numRemovals > 0 ? ( + <> + {numRemovals}{' '} + {numRemovals > 1 ? 'removals' : 'removal'} + + ) : null} + + {intersperse( + structuredPatch.map(_ => ( + + + + )), + i => ( + + ... + + ), + )} + + ) +} diff --git a/src/components/Help.tsx b/src/components/Help.tsx new file mode 100644 index 0000000..7b51601 --- /dev/null +++ b/src/components/Help.tsx @@ -0,0 +1,124 @@ +import { Command } from '../commands.js' +import { PRODUCT_NAME } from '../constants/product.js' +import * as React from 'react' +import { Box, Text, useInput } from 'ink' +import { getTheme } from '../utils/theme.js' +import { PressEnterToContinue } from './PressEnterToContinue.js' + +export function Help({ + commands, + onClose, +}: { + commands: Command[] + onClose: () => void +}): React.ReactNode { + const theme = getTheme() + const isInternal = process.env.USER_TYPE === 'ant' + const moreHelp = isInternal + ? '[ANT-ONLY] For more help: go/claude-cli or #claude-cli-feedback' + : `Learn more at: ${MACRO.README_URL}` + + const filteredCommands = commands.filter(cmd => !cmd.isHidden) + const [count, setCount] = React.useState(0) + + React.useEffect(() => { + const timer = setTimeout(() => { + if (count < 3) { + setCount(count + 1) + } + }, 250) + + return () => clearTimeout(timer) + }, [count]) + + useInput((_, key) => { + if (key.return) onClose() + }) + + return ( + + + {`${PRODUCT_NAME} v${MACRO.VERSION}`} + + + + + {PRODUCT_NAME} is a beta research preview. Always review Claude's + responses, especially when running code. Claude has read access to + files in the current directory and can run commands and edit files + with your permission. + + + + {count >= 1 && ( + + Usage Modes: + + • REPL: claude (interactive session) + + + • Non-interactive: claude -p "question" + + + + Run claude -h for all command line options + + + + )} + + {count >= 2 && ( + + Common Tasks: + + • Ask questions about your codebase{' '} + + > How does foo.py work? + + + + • Edit files{' '} + + > Update bar.ts to... + + + + • Fix errors{' '} + > cargo build + + + • Run commands{' '} + > /help + + + • Run bash commands{' '} + > !ls + + + )} + + {count >= 3 && ( + + Interactive Mode Commands: + + + {filteredCommands.map((cmd, i) => ( + + {`/${cmd.name}`} + - {cmd.description} + + ))} + + + )} + + + {moreHelp} + + + + + + + ) +} diff --git a/src/components/HighlightedCode.tsx b/src/components/HighlightedCode.tsx new file mode 100644 index 0000000..8717aad --- /dev/null +++ b/src/components/HighlightedCode.tsx @@ -0,0 +1,33 @@ +import { highlight, supportsLanguage } from 'cli-highlight' +import { Text } from 'ink' +import React, { useMemo } from 'react' +import { logError } from '../utils/log.js' + +type Props = { + code: string + language: string +} + +export function HighlightedCode({ code, language }: Props): React.ReactElement { + const highlightedCode = useMemo(() => { + try { + if (supportsLanguage(language)) { + return highlight(code, { language }) + } else { + logError( + `Language not supported while highlighting code, falling back to markdown: ${language}`, + ) + return highlight(code, { language: 'markdown' }) + } + } catch (e) { + if (e instanceof Error && e.message.includes('Unknown language')) { + logError( + `Language not supported while highlighting code, falling back to markdown: ${e}`, + ) + return highlight(code, { language: 'markdown' }) + } + } + }, [code, language]) + + return {highlightedCode} +} diff --git a/src/components/InvalidConfigDialog.tsx b/src/components/InvalidConfigDialog.tsx new file mode 100644 index 0000000..6e68fa4 --- /dev/null +++ b/src/components/InvalidConfigDialog.tsx @@ -0,0 +1,113 @@ +import React from 'react' +import { Box, Newline, Text, useInput } from 'ink' +import { getTheme } from '../utils/theme.js' +import { Select } from '@inkjs/ui' +import { render } from 'ink' +import { writeFileSync } from 'fs' +import { ConfigParseError } from '../utils/errors.js' +import { useExitOnCtrlCD } from '../hooks/useExitOnCtrlCD.js' +interface InvalidConfigHandlerProps { + error: ConfigParseError +} + +interface InvalidConfigDialogProps { + filePath: string + errorDescription: string + onExit: () => void + onReset: () => void +} + +/** + * Dialog shown when the Claude config file contains invalid JSON + */ +function InvalidConfigDialog({ + filePath, + errorDescription, + onExit, + onReset, +}: InvalidConfigDialogProps): React.ReactNode { + const theme = getTheme() + + // Handle escape key + useInput((_, key) => { + if (key.escape) { + onExit() + } + }) + + const exitState = useExitOnCtrlCD(() => process.exit(0)) + + // Handler for Select onChange + const handleSelect = (value: string) => { + if (value === 'exit') { + onExit() + } else { + onReset() + } + } + + return ( + <> + + Configuration Error + + + + The configuration file at {filePath} contains + invalid JSON. + + {errorDescription} + + + + Choose an option: + onSelect(parseInt(index, 10))} + visibleOptionCount={visibleCount} + /> + {hiddenCount > 0 && ( + + and {hiddenCount} more… + + )} + + ) +} diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx new file mode 100644 index 0000000..d1748d6 --- /dev/null +++ b/src/components/Logo.tsx @@ -0,0 +1,148 @@ +import { Box, Text } from 'ink' +import * as React from 'react' +import { getTheme } from '../utils/theme.js' +import { PRODUCT_NAME } from '../constants/product.js' +import { isDefaultApiKey, getAnthropicApiKey } from '../utils/config.js' +import { getCwd } from '../utils/state.js' +import type { WrappedClient } from '../services/mcpClient.js' + +export const MIN_LOGO_WIDTH = 46 + +export function Logo({ + mcpClients, + isDefaultModel = false, +}: { + mcpClients: WrappedClient[] + isDefaultModel?: boolean +}): React.ReactNode { + const width = Math.max(MIN_LOGO_WIDTH, getCwd().length + 12) + const theme = getTheme() + const currentModel = process.env.ANTHROPIC_MODEL + const apiKey = getAnthropicApiKey() + const isCustomApiKey = !isDefaultApiKey() + const isCustomModel = !isDefaultModel && Boolean(currentModel) + const hasOverrides = + process.env.USER_TYPE === 'ant' && + Boolean( + isCustomApiKey || + process.env.DISABLE_PROMPT_CACHING || + process.env.API_TIMEOUT_MS || + process.env.MAX_THINKING_TOKENS || + process.env.ANTHROPIC_BASE_URL || + isCustomModel, + ) + + return ( + + + + Welcome to{' '} + {PRODUCT_NAME} research preview! + + <> + + + /help for help + {process.env.USER_TYPE === 'ant' && <> · https://go/claude-cli} + + cwd: {getCwd()} + + {hasOverrides && ( + + + Overrides (via env): + + {isCustomModel && ( + + • Model: {currentModel} + + )} + {isCustomApiKey && apiKey ? ( + + • API Key:{' '} + sk-ant-…{apiKey!.slice(-width + 25)} + + ) : null} + {process.env.DISABLE_PROMPT_CACHING ? ( + + • Prompt caching:{' '} + + off + + + ) : null} + {process.env.API_TIMEOUT_MS ? ( + + • API timeout:{' '} + {process.env.API_TIMEOUT_MS}ms + + ) : null} + {process.env.MAX_THINKING_TOKENS ? ( + + • Max thinking tokens:{' '} + {process.env.MAX_THINKING_TOKENS} + + ) : null} + {process.env.ANTHROPIC_BASE_URL ? ( + + • API Base URL:{' '} + {process.env.ANTHROPIC_BASE_URL} + + ) : null} + + )} + + {mcpClients.length ? ( + + + MCP Servers: + + {mcpClients.map((client, idx) => ( + + • {client.name} + + + {client.type === 'connected' ? 'connected' : 'failed'} + + + ))} + + ) : null} + + + ) +} diff --git a/src/components/MCPServerApprovalDialog.tsx b/src/components/MCPServerApprovalDialog.tsx new file mode 100644 index 0000000..337c9d4 --- /dev/null +++ b/src/components/MCPServerApprovalDialog.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { Box, Text, useInput } from 'ink' +import { getTheme } from '../utils/theme.js' +import { Select } from '@inkjs/ui' +import { + saveCurrentProjectConfig, + getCurrentProjectConfig, +} from '../utils/config.js' +import { MCPServerDialogCopy } from './MCPServerDialogCopy.js' +import { useExitOnCtrlCD } from '../hooks/useExitOnCtrlCD.js' + +type Props = { + serverName: string + onDone(): void +} + +export function MCPServerApprovalDialog({ + serverName, + onDone, +}: Props): React.ReactNode { + const theme = getTheme() + function onChange(value: 'yes' | 'no') { + const config = getCurrentProjectConfig() + switch (value) { + case 'yes': { + if (!config.approvedMcprcServers) { + config.approvedMcprcServers = [] + } + if (!config.approvedMcprcServers.includes(serverName)) { + config.approvedMcprcServers.push(serverName) + } + saveCurrentProjectConfig(config) + onDone() + break + } + case 'no': { + if (!config.rejectedMcprcServers) { + config.rejectedMcprcServers = [] + } + if (!config.rejectedMcprcServers.includes(serverName)) { + config.rejectedMcprcServers.push(serverName) + } + saveCurrentProjectConfig(config) + onDone() + break + } + } + } + + const exitState = useExitOnCtrlCD(() => process.exit(0)) + + useInput((_input, key) => { + if (key.escape) { + onDone() + return + } + }) + + return ( + <> + + + New MCP Server Detected + + + This project contains a .mcprc file with an MCP server that requires + your approval: + + {serverName} + + + + Do you want to approve this MCP server? + + + + + + + + + ) + + const securityStep = ( + + Security notes: + + + + Claude Code is currently in research preview + + This beta version may have limitations or unexpected behaviors. + + Run /bug at any time to report issues. + + + + + Claude can make mistakes + + You should always review Claude's responses, especially when + + running code. + + + + + + Due to prompt injection risks, only use it with code you trust + + + For more details see: + + + + + + + + + ) + + const usageStep = ( + + Using {PRODUCT_NAME} effectively: + + + + + Start in your project directory + + + Files are automatically added to context when needed. + + + + + + + Use {PRODUCT_NAME} as a development partner + + + Get help with file analysis, editing, bash commands, + + and git history. + + + + + + + Provide clear context + + + Be as specific as you would with another engineer. + The better the context, the better the results. + + + + + + + For more details on {PRODUCT_NAME}, see: + + + + + + + + ) + + // Create the steps array - determine which steps to include based on reAuth and oauthEnabled + const apiKeyNeedingApproval = useMemo(() => { + if (process.env.USER_TYPE !== 'ant') { + return '' + } + // Add API key step if needed + if (!process.env.ANTHROPIC_API_KEY) { + return '' + } + const customApiKeyTruncated = normalizeApiKeyForConfig( + process.env.ANTHROPIC_API_KEY!, + ) + if (getCustomApiKeyStatus(customApiKeyTruncated) === 'new') { + return customApiKeyTruncated + } + }, []) + + const steps: OnboardingStep[] = [] + steps.push({ id: 'theme', component: themeStep }) + + // Add OAuth step if Anthropic auth is enabled and user is not logged in + if (oauthEnabled) { + steps.push({ + id: 'oauth', + component: , + }) + } + + // Add API key step if needed + if (apiKeyNeedingApproval) { + steps.push({ + id: 'api-key', + component: ( + + ), + }) + } + + // Add security step + steps.push({ id: 'security', component: securityStep }) + + // Add usage step as the last content step + steps.push({ id: 'usage', component: usageStep }) + return ( + + {/* OAuth uses static rendering so we need to hide welcome box here + and re-render it inside ConsoleOAuthFlow to preserve layout */} + {steps[currentStepIndex]?.id !== 'oauth' && } + + {steps[currentStepIndex]?.component} + {exitState.pending && ( + + Press {exitState.keyName} again to exit + + )} + + + ) +} + +export function WelcomeBox(): React.ReactNode { + const theme = getTheme() + return ( + + + Welcome to{' '} + {PRODUCT_NAME} research preview! + + + ) +} diff --git a/src/components/PressEnterToContinue.tsx b/src/components/PressEnterToContinue.tsx new file mode 100644 index 0000000..bc979cd --- /dev/null +++ b/src/components/PressEnterToContinue.tsx @@ -0,0 +1,11 @@ +import * as React from 'react' +import { getTheme } from '../utils/theme.js' +import { Text } from 'ink' + +export function PressEnterToContinue(): React.ReactNode { + return ( + + Press Enter to continue… + + ) +} diff --git a/src/components/PromptInput.tsx b/src/components/PromptInput.tsx new file mode 100644 index 0000000..09450a0 --- /dev/null +++ b/src/components/PromptInput.tsx @@ -0,0 +1,469 @@ +import { Box, Text, useInput } from 'ink' +import { sample } from 'lodash-es' +import { getExampleCommands } from '../utils/exampleCommands.js' +import * as React from 'react' +import { type Message } from '../query.js' +import { processUserInput } from '../utils/messages.js' +import { useArrowKeyHistory } from '../hooks/useArrowKeyHistory.js' +import { useSlashCommandTypeahead } from '../hooks/useSlashCommandTypeahead.js' +import { addToHistory } from '../history.js' +import TextInput from './TextInput.js' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { countCachedTokens, countTokens } from '../utils/tokens.js' +import { SentryErrorBoundary } from './SentryErrorBoundary.js' +import { AutoUpdater } from './AutoUpdater.js' +import { AutoUpdaterResult } from '../utils/autoUpdater.js' +import type { Command } from '../commands.js' +import type { SetToolJSXFn, Tool } from '../Tool.js' +import { TokenWarning, WARNING_THRESHOLD } from './TokenWarning.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { getTheme } from '../utils/theme.js' +import { getSlowAndCapableModel } from '../utils/model.js' +import { setTerminalTitle } from '../utils/terminal.js' +import terminalSetup, { + isShiftEnterKeyBindingInstalled, +} from '../commands/terminalSetup.js' + +type Props = { + commands: Command[] + forkNumber: number + messageLogName: string + isDisabled: boolean + isLoading: boolean + onQuery: ( + newMessages: Message[], + abortController: AbortController, + ) => Promise + debug: boolean + verbose: boolean + messages: Message[] + setToolJSX: SetToolJSXFn + onAutoUpdaterResult: (result: AutoUpdaterResult) => void + autoUpdaterResult: AutoUpdaterResult | null + tools: Tool[] + input: string + onInputChange: (value: string) => void + mode: 'bash' | 'prompt' + onModeChange: (mode: 'bash' | 'prompt') => void + submitCount: number + onSubmitCountChange: (updater: (prev: number) => number) => void + setIsLoading: (isLoading: boolean) => void + setAbortController: (abortController: AbortController) => void + onShowMessageSelector: () => void + setForkConvoWithMessagesOnTheNextRender: ( + forkConvoWithMessages: Message[], + ) => void + readFileTimestamps: { [filename: string]: number } +} + +function getPastedTextPrompt(text: string): string { + const newlineCount = (text.match(/\r\n|\r|\n/g) || []).length + return `[Pasted text +${newlineCount} lines] ` +} +function PromptInput({ + commands, + forkNumber, + messageLogName, + isDisabled, + isLoading, + onQuery, + debug, + verbose, + messages, + setToolJSX, + onAutoUpdaterResult, + autoUpdaterResult, + tools, + input, + onInputChange, + mode, + onModeChange, + submitCount, + onSubmitCountChange, + setIsLoading, + setAbortController, + onShowMessageSelector, + setForkConvoWithMessagesOnTheNextRender, + readFileTimestamps, +}: Props): React.ReactNode { + const [isAutoUpdating, setIsAutoUpdating] = useState(false) + const [exitMessage, setExitMessage] = useState<{ + show: boolean + key?: string + }>({ show: false }) + const [message, setMessage] = useState<{ + show: boolean + text?: string + }>({ show: false }) + const [pastedImage, setPastedImage] = useState(null) + const [placeholder, setPlaceholder] = useState('') + const [cursorOffset, setCursorOffset] = useState(input.length) + const [pastedText, setPastedText] = useState(null) + + useEffect(() => { + getExampleCommands().then(commands => { + setPlaceholder(`Try "${sample(commands)}"`) + }) + }, []) + const { columns } = useTerminalSize() + + const commandWidth = useMemo( + () => Math.max(...commands.map(cmd => cmd.userFacingName().length)) + 5, + [commands], + ) + + const { + suggestions, + selectedSuggestion, + updateSuggestions, + clearSuggestions, + } = useSlashCommandTypeahead({ + commands, + onInputChange, + onSubmit, + setCursorOffset, + }) + + const onChange = useCallback( + (value: string) => { + if (value.startsWith('!')) { + onModeChange('bash') + return + } + updateSuggestions(value) + onInputChange(value) + }, + [onModeChange, onInputChange, updateSuggestions], + ) + + const { resetHistory, onHistoryUp, onHistoryDown } = useArrowKeyHistory( + (value: string, mode: 'bash' | 'prompt') => { + onChange(value) + onModeChange(mode) + }, + input, + ) + + // Only use history navigation when there are 0 or 1 slash command suggestions + const handleHistoryUp = () => { + if (suggestions.length <= 1) { + onHistoryUp() + } + } + + const handleHistoryDown = () => { + if (suggestions.length <= 1) { + onHistoryDown() + } + } + + async function onSubmit(input: string, isSubmittingSlashCommand = false) { + if (input === '') { + return + } + if (isDisabled) { + return + } + if (isLoading) { + return + } + if (suggestions.length > 0 && !isSubmittingSlashCommand) { + return + } + + // Handle exit commands + if (['exit', 'quit', ':q', ':q!', ':wq', ':wq!'].includes(input.trim())) { + exit() + } + + let finalInput = input + if (pastedText) { + // Create the prompt pattern that would have been used for this pasted text + const pastedPrompt = getPastedTextPrompt(pastedText) + if (finalInput.includes(pastedPrompt)) { + finalInput = finalInput.replace(pastedPrompt, pastedText) + } // otherwise, ignore the pastedText if the user has modified the prompt + } + onInputChange('') + onModeChange('prompt') + clearSuggestions() + setPastedImage(null) + setPastedText(null) + onSubmitCountChange(_ => _ + 1) + setIsLoading(true) + + const abortController = new AbortController() + setAbortController(abortController) + const model = await getSlowAndCapableModel() + const messages = await processUserInput( + finalInput, + mode, + setToolJSX, + { + options: { + commands, + forkNumber, + messageLogName, + tools, + verbose, + slowAndCapableModel: model, + maxThinkingTokens: 0, + }, + messageId: undefined, + abortController, + readFileTimestamps, + setForkConvoWithMessagesOnTheNextRender, + }, + pastedImage ?? null, + ) + + if (messages.length) { + onQuery(messages, abortController) + } else { + // Local JSX commands + addToHistory(input) + resetHistory() + return + } + + for (const message of messages) { + if (message.type === 'user') { + const inputToAdd = mode === 'bash' ? `!${input}` : input + addToHistory(inputToAdd) + resetHistory() + } + } + } + + function onImagePaste(image: string) { + onModeChange('prompt') + setPastedImage(image) + } + + function onTextPaste(rawText: string) { + // Replace any \r with \n first to match useTextInput's conversion behavior + const text = rawText.replace(/\r/g, '\n') + + // Get prompt with newline count + const pastedPrompt = getPastedTextPrompt(text) + + // Update the input with a visual indicator that text has been pasted + const newInput = + input.slice(0, cursorOffset) + pastedPrompt + input.slice(cursorOffset) + onInputChange(newInput) + + // Update cursor position to be after the inserted indicator + setCursorOffset(cursorOffset + pastedPrompt.length) + + // Still set the pastedText state for actual submission + setPastedText(text) + } + + useInput((_, key) => { + if (input === '' && (key.escape || key.backspace || key.delete)) { + onModeChange('prompt') + } + // esc is a little overloaded: + // - when we're loading a response, it's used to cancel the request + // - otherwise, it's used to show the message selector + // - when double pressed, it's used to clear the input + if (key.escape && messages.length > 0 && !input && !isLoading) { + onShowMessageSelector() + } + }) + + const textInputColumns = useTerminalSize().columns - 6 + const tokenUsage = useMemo(() => countTokens(messages), [messages]) + const theme = getTheme() + + return ( + + + + {mode === 'bash' ? ( +  !  + ) : ( + +  >  + + )} + + + resetHistory()} + placeholder={submitCount > 0 ? undefined : placeholder} + onExit={() => process.exit(0)} + onExitMessage={(show, key) => setExitMessage({ show, key })} + onMessage={(show, text) => setMessage({ show, text })} + onImagePaste={onImagePaste} + columns={textInputColumns} + isDimmed={isDisabled || isLoading} + disableCursorMovementForUpDownKeys={suggestions.length > 0} + cursorOffset={cursorOffset} + onChangeCursorOffset={setCursorOffset} + onPaste={onTextPaste} + /> + + + {suggestions.length === 0 && ( + + + {exitMessage.show ? ( + Press {exitMessage.key} again to exit + ) : message.show ? ( + {message.text} + ) : ( + <> + + ! for bash mode + + · / for commands · esc to undo + + )} + + + + {!autoUpdaterResult && + !isAutoUpdating && + !debug && + tokenUsage < WARNING_THRESHOLD && ( + + {terminalSetup.isEnabled && + isShiftEnterKeyBindingInstalled() + ? 'shift + ⏎ for newline' + : '\\⏎ for newline'} + + )} + {debug && ( + + {`${countTokens(messages)} tokens (${ + Math.round( + (10000 * (countCachedTokens(messages) || 1)) / + (countTokens(messages) || 1), + ) / 100 + }% cached)`} + + )} + + + + + + )} + {suggestions.length > 0 && ( + + + {suggestions.map((suggestion, index) => { + const command = commands.find( + cmd => cmd.userFacingName() === suggestion.replace('/', ''), + ) + return ( + + + + /{suggestion} + {command?.aliases && command.aliases.length > 0 && ( + ({command.aliases.join(', ')}) + )} + + + {command && ( + + + + {command.description} + {command.type === 'prompt' && command.argNames?.length + ? ` (arguments: ${command.argNames.join(', ')})` + : null} + + + + )} + + ) + })} + + + + + + + + + )} + + ) +} + +export default memo(PromptInput) + +function exit(): never { + setTerminalTitle('') + process.exit(0) +} diff --git a/src/components/SentryErrorBoundary.ts b/src/components/SentryErrorBoundary.ts new file mode 100644 index 0000000..14ded6d --- /dev/null +++ b/src/components/SentryErrorBoundary.ts @@ -0,0 +1,33 @@ +import * as React from 'react' +import { captureException } from '../services/sentry.js' + +interface Props { + children: React.ReactNode +} + +interface State { + hasError: boolean +} + +export class SentryErrorBoundary extends React.Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError(): State { + return { hasError: true } + } + + componentDidCatch(error: Error): void { + captureException(error) + } + + render(): React.ReactNode { + if (this.state.hasError) { + return null + } + + return this.props.children + } +} diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx new file mode 100644 index 0000000..cb8fd47 --- /dev/null +++ b/src/components/Spinner.tsx @@ -0,0 +1,126 @@ +import { Box, Text } from 'ink' +import * as React from 'react' +import { useEffect, useRef, useState } from 'react' +import { getTheme } from '../utils/theme.js' +import { sample } from 'lodash-es' + +// NB: The third character in this string is an emoji that +// renders on Windows consoles with a green background +const CHARACTERS = + process.platform === 'darwin' + ? ['·', '✢', '✳', '∗', '✻', '✽'] + : ['·', '✢', '*', '∗', '✻', '✽'] + +const MESSAGES = [ + 'Accomplishing', + 'Actioning', + 'Actualizing', + 'Baking', + 'Brewing', + 'Calculating', + 'Cerebrating', + 'Churning', + 'Clauding', + 'Coalescing', + 'Cogitating', + 'Computing', + 'Conjuring', + 'Considering', + 'Cooking', + 'Crafting', + 'Creating', + 'Crunching', + 'Deliberating', + 'Determining', + 'Doing', + 'Effecting', + 'Finagling', + 'Forging', + 'Forming', + 'Generating', + 'Hatching', + 'Herding', + 'Honking', + 'Hustling', + 'Ideating', + 'Inferring', + 'Manifesting', + 'Marinating', + 'Moseying', + 'Mulling', + 'Mustering', + 'Musing', + 'Noodling', + 'Percolating', + 'Pondering', + 'Processing', + 'Puttering', + 'Reticulating', + 'Ruminating', + 'Schlepping', + 'Shucking', + 'Simmering', + 'Smooshing', + 'Spinning', + 'Stewing', + 'Synthesizing', + 'Thinking', + 'Transmuting', + 'Vibing', + 'Working', +] + +export function Spinner(): React.ReactNode { + const frames = [...CHARACTERS, ...[...CHARACTERS].reverse()] + const [frame, setFrame] = useState(0) + const [elapsedTime, setElapsedTime] = useState(0) + const message = useRef(sample(MESSAGES)) + const startTime = useRef(Date.now()) + + useEffect(() => { + const timer = setInterval(() => { + setFrame(f => (f + 1) % frames.length) + }, 120) + + return () => clearInterval(timer) + }, [frames.length]) + + useEffect(() => { + const timer = setInterval(() => { + setElapsedTime(Math.floor((Date.now() - startTime.current) / 1000)) + }, 1000) + + return () => clearInterval(timer) + }, []) + + return ( + + + {frames[frame]} + + {message.current}… + + ({elapsedTime}s · esc to interrupt) + + + ) +} + +export function SimpleSpinner(): React.ReactNode { + const frames = [...CHARACTERS, ...[...CHARACTERS].reverse()] + const [frame, setFrame] = useState(0) + + useEffect(() => { + const timer = setInterval(() => { + setFrame(f => (f + 1) % frames.length) + }, 120) + + return () => clearInterval(timer) + }, [frames.length]) + + return ( + + {frames[frame]} + + ) +} diff --git a/src/components/StickerRequestForm.tsx b/src/components/StickerRequestForm.tsx new file mode 100644 index 0000000..9510aa0 --- /dev/null +++ b/src/components/StickerRequestForm.tsx @@ -0,0 +1,523 @@ +import React from 'react' +import { Box, Text, useInput } from 'ink' +import TextInput from './TextInput.js' +import Link from 'ink-link' +// import figures from 'figures' (not used after refactoring) +import { validateField, ValidationError } from '../utils/validate.js' +import { openBrowser } from '../utils/browser.js' +import { getTheme } from '../utils/theme.js' +import { logEvent } from '../services/statsig.js' +import { logError } from '../utils/log.js' +import { + AnimatedClaudeAsterisk, + ClaudeAsteriskSize, +} from './AnimatedClaudeAsterisk.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' + +export type FormData = { + name: string + email: string + address1: string + address2: string + city: string + state: string + zip: string + phone: string + usLocation: boolean +} + +interface StickerRequestFormProps { + onSubmit: (data: FormData) => void + onClose: () => void + googleFormURL?: string +} + +export function StickerRequestForm({ + onSubmit, + onClose, +}: StickerRequestFormProps) { + const [googleFormURL, setGoogleFormURL] = React.useState('') + const { rows } = useTerminalSize() + + // Determine the appropriate asterisk size based on terminal height + // Small ASCII art is 5 lines tall, large is 22 lines + // Need to account for the form content too which needs about 18-22 lines minimum + const getAsteriskSize = (): ClaudeAsteriskSize => { + // Large terminals (can fit large ASCII art + form content comfortably) + if (rows >= 50) { + return 'large' + } + // Medium terminals (can fit medium ASCII art + form content) + else if (rows >= 35) { + return 'medium' + } + // Small terminals or any other case + else { + return 'small' + } + } + + // Animation logic is now handled by the AnimatedClaudeAsterisk component + + // Function to generate Google Form URL + const generateGoogleFormURL = (data: FormData) => { + // URL encode all form values + const name = encodeURIComponent(data.name || '') + const email = encodeURIComponent(data.email || '') + const phone = encodeURIComponent(data.phone || '') + const address1 = encodeURIComponent(data.address1 || '') + const address2 = encodeURIComponent(data.address2 || '') + const city = encodeURIComponent(data.city || '') + const state = encodeURIComponent(data.state || '') + // Set country as United States since we're only shipping there + const country = encodeURIComponent('USA') + + return `https://docs.google.com/forms/d/e/1FAIpQLSfYhWr1a-t4IsvS2FKyEH45HRmHKiPUycvAlFKaD0NugqvfDA/viewform?usp=pp_url&entry.2124017765=${name}&entry.1522143766=${email}&entry.1730584532=${phone}&entry.1700407131=${address1}&entry.109484232=${address2}&entry.1209468849=${city}&entry.222866183=${state}&entry.1042966503=${country}` + } + + const [formState, setFormState] = React.useState>({}) + const [currentField, setCurrentField] = React.useState('name') + const [inputValue, setInputValue] = React.useState('') + const [cursorOffset, setCursorOffset] = React.useState(0) + const [error, setError] = React.useState(null) + const [showingSummary, setShowingSummary] = React.useState(false) + const [showingNonUsMessage, setShowingNonUsMessage] = React.useState(false) + + const [selectedYesNo, setSelectedYesNo] = React.useState<'yes' | 'no'>('yes') + const theme = getTheme() + + const fields: Array<{ key: keyof FormData; label: string }> = [ + { key: 'name', label: 'Name' }, + { key: 'usLocation', label: 'Are you in the United States? (y/n)' }, + { key: 'email', label: 'Email' }, + { key: 'phone', label: 'Phone Number' }, + { key: 'address1', label: 'Address Line 1' }, + { key: 'address2', label: 'Address Line 2 (optional)' }, + { key: 'city', label: 'City' }, + { key: 'state', label: 'State' }, + { key: 'zip', label: 'ZIP Code' }, + ] + + // Helper to navigate to the next field + const goToNextField = (currentKey: keyof FormData) => { + // Log form progression + const currentIndex = fields.findIndex(f => f.key === currentKey) + const nextIndex = currentIndex + 1 + + if (currentIndex === -1) throw new Error('Invalid field state') + const nextField = fields[nextIndex] + if (!nextField) throw new Error('Invalid field state') + + // Log field completion event + logEvent('sticker_form_field_completed', { + field_name: currentKey, + field_index: currentIndex.toString(), + next_field: nextField.key, + form_progress: `${nextIndex}/${fields.length}`, + }) + + setCurrentField(nextField.key) + const newValue = formState[nextField.key]?.toString() || '' + setInputValue(newValue) + setCursorOffset(newValue.length) + setError(null) + } + + useInput((input, key) => { + // Exit on Escape, Ctrl-C, or Ctrl-D + if (key.escape || (key.ctrl && (input === 'c' || input === 'd'))) { + onClose() + return + } + + // Handle return key on non-US message screen + if (showingNonUsMessage && key.return) { + onClose() + return + } + + // Handle Y/N keypresses and arrow navigation for US location question + if (currentField === 'usLocation' && !showingSummary) { + // Arrow key navigation for Yes/No + if (key.leftArrow || key.rightArrow) { + setSelectedYesNo(prev => (prev === 'yes' ? 'no' : 'yes')) + return + } + + if (key.return) { + if (selectedYesNo === 'yes') { + const newState = { ...formState, [currentField]: true } + setFormState(newState) + + // Move to next field + goToNextField(currentField) + } else { + setShowingNonUsMessage(true) + } + return + } + + // Handle direct Y/N keypresses + const normalized = input.toLowerCase() + if (['y', 'yes'].includes(normalized)) { + const newState = { ...formState, [currentField]: true } + setFormState(newState) + + // Move to next field + goToNextField(currentField) + return + } + if (['n', 'no'].includes(normalized)) { + setShowingNonUsMessage(true) + return + } + } + + // Allows tabbing between form fields with validation + if (!showingSummary) { + if (key.tab) { + if (key.shift) { + const currentIndex = fields.findIndex(f => f.key === currentField) + if (currentIndex === -1) throw new Error('Invalid field state') + const prevIndex = (currentIndex - 1 + fields.length) % fields.length + const prevField = fields[prevIndex] + if (!prevField) throw new Error('Invalid field index') + setCurrentField(prevField.key) + const newValue = formState[prevField.key]?.toString() || '' + setInputValue(newValue) + setCursorOffset(newValue.length) + setError(null) + return + } + + if (currentField !== 'address2' && currentField !== 'usLocation') { + const currentValue = inputValue.trim() + const validationError = validateField(currentField, currentValue) + if (validationError) { + setError({ + message: 'Please fill out this field before continuing', + }) + return + } + const newState = { ...formState, [currentField]: currentValue } + setFormState(newState) + } + + // Find the next field index with modulo wrap-around + const currentIndex = fields.findIndex(f => f.key === currentField) + if (currentIndex === -1) throw new Error('Invalid field state') + const nextIndex = (currentIndex + 1) % fields.length + const nextField = fields[nextIndex] + if (!nextField) throw new Error('Invalid field index') + + // Use our helper to navigate to this field + setCurrentField(nextField.key) + const newValue = formState[nextField.key]?.toString() || '' + setInputValue(newValue) + setCursorOffset(newValue.length) + setError(null) + return + } + } + + if (showingSummary) { + if (key.return) { + onSubmit(formState as FormData) + } + } + }) + + const handleSubmit = (value: string) => { + if (!value && currentField === 'address2') { + const newState = { ...formState, [currentField]: '' } + setFormState(newState) + goToNextField(currentField) + return + } + + const validationError = validateField(currentField, value) + if (validationError) { + setError(validationError) + return + } + + if (currentField === 'state' && formState.zip) { + const zipError = validateField('zip', formState.zip) + if (zipError) { + setError({ + message: 'The existing ZIP code is not valid for this state', + }) + return + } + } + + const newState = { ...formState, [currentField]: value } + setFormState(newState) + setError(null) + + const currentIndex = fields.findIndex(f => f.key === currentField) + if (currentIndex === -1) throw new Error('Invalid field state') + + if (currentIndex < fields.length - 1) { + goToNextField(currentField) + } else { + setShowingSummary(true) + } + } + + const currentFieldDef = fields.find(f => f.key === currentField) + if (!currentFieldDef) throw new Error('Invalid field state') + + // Generate Google Form URL for summary view and open it automatically + if (showingSummary && !googleFormURL) { + const url = generateGoogleFormURL(formState as FormData) + setGoogleFormURL(url) + + // Log reaching the summary page + logEvent('sticker_form_summary_reached', { + fields_completed: Object.keys(formState).length.toString(), + }) + + // Auto-open the URL in the user's browser + openBrowser(url).catch(err => { + logError(err) + }) + } + + const classifiedHeaderText = `╔══════════════════════════════╗ +║ CLASSIFIED ║ +╚══════════════════════════════╝` + const headerText = `You've discovered Claude's top secret sticker distribution operation!` + + // Helper function to render the header section + const renderHeader = () => ( + <> + + {classifiedHeaderText} + + {headerText} + + + {!showingSummary && ( + + + + )} + + ) + + // Helper function to render the footer section + const renderFooter = () => ( + + {showingNonUsMessage || showingSummary ? ( + + Press Enter to return to base + + ) : ( + + {currentField === 'usLocation' ? ( + <> + ←/→ arrows to select · Enter to confirm · Y/N keys also work · Esc + Esc to abort mission + + ) : ( + <> + Enter to continue · Tab/Shift+Tab to navigate · Esc to abort + mission + + )} + + )} + + ) + + // Helper function to render the main content based on current state + const renderContent = () => { + if (showingSummary) { + return ( + <> + + + Please review your shipping information: + + + + + {fields + .filter(f => f.key !== 'usLocation') + .map(field => ( + + + + {field.label}: + {' '} + + {formState[field.key] || '(empty)'} + + + + ))} + + + {/* Google Form URL with improved instructions */} + + + Submit your sticker request: + + + + + ➜ Click here to open Google Form + + + + + + (You can still edit your info on the form) + + + + + ) + } else if (showingNonUsMessage) { + return ( + <> + + + Mission Not Available + + + + + + We're sorry, but the Claude sticker deployment mission is + only available within the United States. + + + + Future missions may expand to other territories. Stay tuned for + updates. + + + + + ) + } else { + return ( + <> + + + Please provide your coordinates for the sticker deployment + mission. + + + Currently only shipping within the United States. + + + + + + {fields.map((f, i) => ( + + + {f.key === currentField ? ( + `[${f.label}]` + ) : formState[f.key] ? ( + + ) : ( + '○' + )} + + {i < fields.length - 1 && } + + ))} + + + + Field {fields.findIndex(f => f.key === currentField) + 1} of{' '} + {fields.length} + + + + + + {currentField === 'usLocation' ? ( + // Special Yes/No Buttons for US Location + + + {selectedYesNo === 'yes' ? '●' : '○'} YES + + + + {selectedYesNo === 'no' ? '●' : '○'} NO + + + ) : ( + // Regular TextInput for other fields + + )} + {error && ( + + + ✗ {error.message} + + + )} + + + ) + } + } + + // Main render with consistent structure + return ( + + + {renderHeader()} + {renderContent()} + + {renderFooter()} + + ) +} diff --git a/src/components/StructuredDiff.tsx b/src/components/StructuredDiff.tsx new file mode 100644 index 0000000..0797edf --- /dev/null +++ b/src/components/StructuredDiff.tsx @@ -0,0 +1,184 @@ +import { Box, Text } from 'ink' +import * as React from 'react' +import { Hunk } from 'diff' +import { getTheme, ThemeNames } from '../utils/theme.js' +import { useMemo } from 'react' +import { wrapText } from '../utils/format.js' + +type Props = { + patch: Hunk + dim: boolean + width: number + overrideTheme?: ThemeNames // custom theme for previews +} + +export function StructuredDiff({ + patch, + dim, + width, + overrideTheme, +}: Props): React.ReactNode { + const diff = useMemo( + () => formatDiff(patch.lines, patch.oldStart, width, dim, overrideTheme), + [patch.lines, patch.oldStart, width, dim, overrideTheme], + ) + + return diff.map((_, i) => {_}) +} + +function formatDiff( + lines: string[], + startingLineNumber: number, + width: number, + dim: boolean, + overrideTheme?: ThemeNames, +): React.ReactNode[] { + const theme = getTheme(overrideTheme) + + const ls = numberDiffLines( + lines.map(code => { + if (code.startsWith('+')) { + return { + code: ' ' + code.slice(1), + i: 0, + type: 'add', + } + } + if (code.startsWith('-')) { + return { + code: ' ' + code.slice(1), + i: 0, + type: 'remove', + } + } + return { code, i: 0, type: 'nochange' } + }), + startingLineNumber, + ) + + const maxLineNumber = Math.max(...ls.map(({ i }) => i)) + const maxWidth = maxLineNumber.toString().length + + return ls.flatMap(({ type, code, i }) => { + const wrappedLines = wrapText(code, width - maxWidth) + return wrappedLines.map((line, lineIndex) => { + const key = `${type}-${i}-${lineIndex}` + switch (type) { + case 'add': + return ( + + + + {line} + + + ) + case 'remove': + return ( + + + + {line} + + + ) + case 'nochange': + return ( + + + + {line} + + + ) + } + }) + }) +} + +function LineNumber({ + i, + width, +}: { + i: number | undefined + width: number +}): React.ReactNode { + return ( + + {i !== undefined ? i.toString().padStart(width) : ' '.repeat(width)}{' '} + + ) +} + +function numberDiffLines( + diff: { code: string; type: string }[], + startLine: number, +): { code: string; type: string; i: number }[] { + let i = startLine + const result: { code: string; type: string; i: number }[] = [] + const queue = [...diff] + + while (queue.length > 0) { + const { code, type } = queue.shift()! + const line = { + code: code, + type, + i, + } + + // Update counters based on change type + switch (type) { + case 'nochange': + i++ + result.push(line) + break + case 'add': + i++ + result.push(line) + break + case 'remove': { + result.push(line) + let numRemoved = 0 + while (queue[0]?.type === 'remove') { + i++ + const { code, type } = queue.shift()! + const line = { + code: code, + type, + i, + } + result.push(line) + numRemoved++ + } + i -= numRemoved + break + } + } + } + + return result +} diff --git a/src/components/TextInput.tsx b/src/components/TextInput.tsx new file mode 100644 index 0000000..39ab0f2 --- /dev/null +++ b/src/components/TextInput.tsx @@ -0,0 +1,230 @@ +import React from 'react' +import { Text, useInput } from 'ink' +import chalk from 'chalk' +import { useTextInput } from '../hooks/useTextInput.js' +import { getTheme } from '../utils/theme.js' +import { type Key } from 'ink' + +export type Props = { + /** + * Optional callback for handling history navigation on up arrow at start of input + */ + readonly onHistoryUp?: () => void + + /** + * Optional callback for handling history navigation on down arrow at end of input + */ + readonly onHistoryDown?: () => void + + /** + * Text to display when `value` is empty. + */ + readonly placeholder?: string + + /** + * Allow multi-line input via line ending with backslash (default: `true`) + */ + readonly multiline?: boolean + + /** + * Listen to user's input. Useful in case there are multiple input components + * at the same time and input must be "routed" to a specific component. + */ + readonly focus?: boolean + + /** + * Replace all chars and mask the value. Useful for password inputs. + */ + readonly mask?: string + + /** + * Whether to show cursor and allow navigation inside text input with arrow keys. + */ + readonly showCursor?: boolean + + /** + * Highlight pasted text + */ + readonly highlightPastedText?: boolean + + /** + * Value to display in a text input. + */ + readonly value: string + + /** + * Function to call when value updates. + */ + readonly onChange: (value: string) => void + + /** + * Function to call when `Enter` is pressed, where first argument is a value of the input. + */ + readonly onSubmit?: (value: string) => void + + /** + * Function to call when Ctrl+C is pressed to exit. + */ + readonly onExit?: () => void + + /** + * Optional callback to show exit message + */ + readonly onExitMessage?: (show: boolean, key?: string) => void + + /** + * Optional callback to show custom message + */ + readonly onMessage?: (show: boolean, message?: string) => void + + /** + * Optional callback to reset history position + */ + readonly onHistoryReset?: () => void + + /** + * Number of columns to wrap text at + */ + readonly columns: number + + /** + * Optional callback when an image is pasted + */ + readonly onImagePaste?: (base64Image: string) => void + + /** + * Optional callback when a large text (over 800 chars) is pasted + */ + readonly onPaste?: (text: string) => void + + /** + * Whether the input is dimmed and non-interactive + */ + readonly isDimmed?: boolean + + /** + * Whether to disable cursor movement for up/down arrow keys + */ + readonly disableCursorMovementForUpDownKeys?: boolean + + readonly cursorOffset: number + + /** + * Callback to set the offset of the cursor + */ + onChangeCursorOffset: (offset: number) => void +} + +export default function TextInput({ + value: originalValue, + placeholder = '', + focus = true, + mask, + multiline = false, + highlightPastedText = false, + showCursor = true, + onChange, + onSubmit, + onExit, + onHistoryUp, + onHistoryDown, + onExitMessage, + onMessage, + onHistoryReset, + columns, + onImagePaste, + onPaste, + isDimmed = false, + disableCursorMovementForUpDownKeys = false, + cursorOffset, + onChangeCursorOffset, +}: Props): JSX.Element { + const { onInput, renderedValue } = useTextInput({ + value: originalValue, + onChange, + onSubmit, + onExit, + onExitMessage, + onMessage, + onHistoryReset, + onHistoryUp, + onHistoryDown, + focus, + mask, + multiline, + cursorChar: showCursor ? ' ' : '', + highlightPastedText, + invert: chalk.inverse, + themeText: (text: string) => chalk.hex(getTheme().text)(text), + columns, + onImagePaste, + disableCursorMovementForUpDownKeys, + externalOffset: cursorOffset, + onOffsetChange: onChangeCursorOffset, + }) + + // Paste detection state + const [pasteState, setPasteState] = React.useState<{ + chunks: string[] + timeoutId: ReturnType | null + }>({ chunks: [], timeoutId: null }) + + const resetPasteTimeout = ( + currentTimeoutId: ReturnType | null, + ) => { + if (currentTimeoutId) { + clearTimeout(currentTimeoutId) + } + return setTimeout(() => { + setPasteState(({ chunks }) => { + const pastedText = chunks.join('') + // Schedule callback after current render to avoid state updates during render + Promise.resolve().then(() => onPaste!(pastedText)) + return { chunks: [], timeoutId: null } + }) + }, 100) + } + + const wrappedOnInput = (input: string, key: Key): void => { + // Handle pastes (>800 chars) + // Usually we get one or two input characters at a time. If we + // get a bunch, the user has probably pasted. + // Unfortunately node batches long pastes, so it's possible + // that we would see e.g. 1024 characters and then just a few + // more in the next frame that belong with the original paste. + // This batching number is not consistent. + if (onPaste && (input.length > 800 || pasteState.timeoutId)) { + setPasteState(({ chunks, timeoutId }) => { + return { + chunks: [...chunks, input], + timeoutId: resetPasteTimeout(timeoutId), + } + }) + return + } + + onInput(input, key) + } + + useInput(wrappedOnInput, { isActive: focus }) + + let renderedPlaceholder = placeholder + ? chalk.hex(getTheme().secondaryText)(placeholder) + : undefined + + // Fake mouse cursor, because we like punishment + if (showCursor && focus) { + renderedPlaceholder = + placeholder.length > 0 + ? chalk.inverse(placeholder[0]) + + chalk.hex(getTheme().secondaryText)(placeholder.slice(1)) + : chalk.inverse(' ') + } + + const showPlaceholder = originalValue.length == 0 && placeholder + return ( + + {showPlaceholder ? renderedPlaceholder : renderedValue} + + ) +} diff --git a/src/components/TokenWarning.tsx b/src/components/TokenWarning.tsx new file mode 100644 index 0000000..68e97c7 --- /dev/null +++ b/src/components/TokenWarning.tsx @@ -0,0 +1,31 @@ +import { Box, Text } from 'ink' +import * as React from 'react' +import { getTheme } from '../utils/theme.js' + +type Props = { + tokenUsage: number +} + +const MAX_TOKENS = 190_000 // leave wiggle room for /compact +export const WARNING_THRESHOLD = MAX_TOKENS * 0.6 // 60% +const ERROR_THRESHOLD = MAX_TOKENS * 0.8 // 80% + +export function TokenWarning({ tokenUsage }: Props): React.ReactNode { + const theme = getTheme() + + if (tokenUsage < WARNING_THRESHOLD) { + return null + } + + const isError = tokenUsage >= ERROR_THRESHOLD + + return ( + + + Context low ( + {Math.max(0, 100 - Math.round((tokenUsage / MAX_TOKENS) * 100))}% + remaining) · Run /compact to compact & continue + + + ) +} diff --git a/src/components/ToolUseLoader.tsx b/src/components/ToolUseLoader.tsx new file mode 100644 index 0000000..0e6d8d9 --- /dev/null +++ b/src/components/ToolUseLoader.tsx @@ -0,0 +1,40 @@ +import { Box, Text } from 'ink' +import React from 'react' +import { useInterval } from '../hooks/useInterval.js' +import { getTheme } from '../utils/theme.js' +import { BLACK_CIRCLE } from '../constants/figures.js' + +type Props = { + isError: boolean + isUnresolved: boolean + shouldAnimate: boolean +} + +export function ToolUseLoader({ + isError, + isUnresolved, + shouldAnimate, +}: Props): React.ReactNode { + const [isVisible, setIsVisible] = React.useState(true) + + useInterval(() => { + if (!shouldAnimate) { + return + } + // To avoid flickering when the tool use confirm is visible, we set the loader to be visible + // when the tool use confirm is visible. + setIsVisible(_ => !_) + }, 600) + + const color = isUnresolved + ? getTheme().secondaryText + : isError + ? getTheme().error + : getTheme().success + + return ( + + {isVisible ? BLACK_CIRCLE : ' '} + + ) +} diff --git a/src/components/TrustDialog.tsx b/src/components/TrustDialog.tsx new file mode 100644 index 0000000..ba9213e --- /dev/null +++ b/src/components/TrustDialog.tsx @@ -0,0 +1,108 @@ +import React from 'react' +import { Box, Text, useInput } from 'ink' +import { getTheme } from '../utils/theme.js' +import { Select } from '@inkjs/ui' +import { + saveCurrentProjectConfig, + getCurrentProjectConfig, +} from '../utils/config.js' +import { PRODUCT_NAME } from '../constants/product.js' +import { logEvent } from '../services/statsig.js' +import { useExitOnCtrlCD } from '../hooks/useExitOnCtrlCD.js' +import { homedir } from 'os' +import { getCwd } from '../utils/state.js' +import Link from './Link.js' + +type Props = { + onDone(): void +} + +export function TrustDialog({ onDone }: Props): React.ReactNode { + const theme = getTheme() + React.useEffect(() => { + // Log when dialog is shown + logEvent('trust_dialog_shown', {}) + }, []) + + function onChange(value: 'yes' | 'no') { + const config = getCurrentProjectConfig() + switch (value) { + case 'yes': { + // Log when user accepts + const isHomeDir = homedir() === getCwd() + logEvent('trust_dialog_accept', { + isHomeDir: String(isHomeDir), + }) + + if (!isHomeDir) { + saveCurrentProjectConfig({ + ...config, + hasTrustDialogAccepted: true, + }) + } + onDone() + break + } + case 'no': { + process.exit(1) + break + } + } + } + + const exitState = useExitOnCtrlCD(() => process.exit(0)) + + useInput((_input, key) => { + if (key.escape) { + process.exit(0) + return + } + }) + + return ( + <> + + + Do you trust the files in this folder? + + {process.cwd()} + + + + {PRODUCT_NAME} may read files in this folder. Reading untrusted + files may lead to {PRODUCT_NAME} to behave in an unexpected ways. + + + With your permission {PRODUCT_NAME} may execute files in this + folder. Executing untrusted code is unsafe. + + + + + + + + + {exitState.pending ? ( + + Press {exitState.keyName} again to exit + + ) : ( + // Render a blank line so that the UI doesn't reflow when the exit message is shown + + )} + + ) +} diff --git a/src/components/binary-feedback/utils.ts b/src/components/binary-feedback/utils.ts new file mode 100644 index 0000000..d1d9d28 --- /dev/null +++ b/src/components/binary-feedback/utils.ts @@ -0,0 +1,220 @@ +import { TextBlock, ToolUseBlock } from '@anthropic-ai/sdk/resources/index.mjs' +import { AssistantMessage, BinaryFeedbackResult } from '../../query.js' +import { MAIN_QUERY_TEMPERATURE } from '../../services/claude.js' +import { getDynamicConfig, logEvent } from '../../services/statsig.js' + +import { isEqual, zip } from 'lodash-es' +import { getGitState } from '../../utils/git.js' + +export type BinaryFeedbackChoice = + | 'prefer-left' + | 'prefer-right' + | 'neither' + | 'no-preference' + +export type BinaryFeedbackChoose = (choice: BinaryFeedbackChoice) => void + +type BinaryFeedbackConfig = { + sampleFrequency: number +} + +async function getBinaryFeedbackStatsigConfig(): Promise { + return await getDynamicConfig('tengu-binary-feedback-config', { + sampleFrequency: 0, + }) +} + +function getMessageBlockSequence(m: AssistantMessage) { + return m.message.content.map(cb => { + if (cb.type === 'text') return 'text' + if (cb.type === 'tool_use') return cb.name + return cb.type // Handle other block types like 'thinking' or 'redacted_thinking' + }) +} + +export async function logBinaryFeedbackEvent( + m1: AssistantMessage, + m2: AssistantMessage, + choice: BinaryFeedbackChoice, +): Promise { + const modelA = m1.message.model + const modelB = m2.message.model + const gitState = await getGitState() + logEvent('tengu_binary_feedback', { + msg_id_A: m1.message.id, + msg_id_B: m2.message.id, + choice: { + 'prefer-left': m1.message.id, + 'prefer-right': m2.message.id, + neither: undefined, + 'no-preference': undefined, + }[choice], + choiceStr: choice, + gitHead: gitState?.commitHash, + gitBranch: gitState?.branchName, + gitRepoRemoteUrl: gitState?.remoteUrl || undefined, + gitRepoIsHeadOnRemote: gitState?.isHeadOnRemote?.toString(), + gitRepoIsClean: gitState?.isClean?.toString(), + modelA, + modelB, + temperatureA: String(MAIN_QUERY_TEMPERATURE), + temperatureB: String(MAIN_QUERY_TEMPERATURE), + seqA: String(getMessageBlockSequence(m1)), + seqB: String(getMessageBlockSequence(m2)), + }) +} + +export async function logBinaryFeedbackSamplingDecision( + decision: boolean, + reason?: string, +): Promise { + logEvent('tengu_binary_feedback_sampling_decision', { + decision: decision.toString(), + reason, + }) +} + +export async function logBinaryFeedbackDisplayDecision( + decision: boolean, + m1: AssistantMessage, + m2: AssistantMessage, + reason?: string, +): Promise { + logEvent('tengu_binary_feedback_display_decision', { + decision: decision.toString(), + reason, + msg_id_A: m1.message.id, + msg_id_B: m2.message.id, + seqA: String(getMessageBlockSequence(m1)), + seqB: String(getMessageBlockSequence(m2)), + }) +} + +function textContentBlocksEqual(cb1: TextBlock, cb2: TextBlock): boolean { + return cb1.text === cb2.text +} + +function contentBlocksEqual( + cb1: TextBlock | ToolUseBlock, + cb2: TextBlock | ToolUseBlock, +): boolean { + if (cb1.type !== cb2.type) { + return false + } + if (cb1.type === 'text') { + return textContentBlocksEqual(cb1, cb2 as TextBlock) + } + cb2 = cb2 as ToolUseBlock + return cb1.name === cb2.name && isEqual(cb1.input, cb2.input) +} + +function allContentBlocksEqual( + content1: (TextBlock | ToolUseBlock)[], + content2: (TextBlock | ToolUseBlock)[], +): boolean { + if (content1.length !== content2.length) { + return false + } + return zip(content1, content2).every(([cb1, cb2]) => + contentBlocksEqual(cb1!, cb2!), + ) +} + +export async function shouldUseBinaryFeedback(): Promise { + if (process.env.DISABLE_BINARY_FEEDBACK) { + logBinaryFeedbackSamplingDecision(false, 'disabled_by_env_var') + return false + } + if (process.env.FORCE_BINARY_FEEDBACK) { + logBinaryFeedbackSamplingDecision(true, 'forced_by_env_var') + return true + } + if (process.env.USER_TYPE !== 'ant') { + logBinaryFeedbackSamplingDecision(false, 'not_ant') + return false + } + if (process.env.NODE_ENV === 'test') { + // Binary feedback breaks a couple tests related to checking for permission, + // so we have to disable it in tests at the risk of hiding bugs + logBinaryFeedbackSamplingDecision(false, 'test') + return false + } + + const config = await getBinaryFeedbackStatsigConfig() + if (config.sampleFrequency === 0) { + logBinaryFeedbackSamplingDecision(false, 'top_level_frequency_zero') + return false + } + if (Math.random() > config.sampleFrequency) { + logBinaryFeedbackSamplingDecision(false, 'top_level_frequency_rng') + return false + } + logBinaryFeedbackSamplingDecision(true) + return true +} + +export function messagePairValidForBinaryFeedback( + m1: AssistantMessage, + m2: AssistantMessage, +): boolean { + const logPass = () => logBinaryFeedbackDisplayDecision(true, m1, m2) + const logFail = (reason: string) => + logBinaryFeedbackDisplayDecision(false, m1, m2, reason) + + // Ignore thinking blocks, on the assumption that users don't find them very relevant + // compared to other content types + const nonThinkingBlocks1 = m1.message.content.filter( + b => b.type !== 'thinking' && b.type !== 'redacted_thinking', + ) + const nonThinkingBlocks2 = m2.message.content.filter( + b => b.type !== 'thinking' && b.type !== 'redacted_thinking', + ) + const hasToolUse = + nonThinkingBlocks1.some(b => b.type === 'tool_use') || + nonThinkingBlocks2.some(b => b.type === 'tool_use') + + // If they're all text blocks, compare those + if (!hasToolUse) { + if (allContentBlocksEqual(nonThinkingBlocks1, nonThinkingBlocks2)) { + logFail('contents_identical') + return false + } + logPass() + return true + } + + // If there are tools, they're the most material difference between the messages. + // Only show binary feedback if there's a tool use difference, ignoring text. + if ( + allContentBlocksEqual( + nonThinkingBlocks1.filter(b => b.type === 'tool_use'), + nonThinkingBlocks2.filter(b => b.type === 'tool_use'), + ) + ) { + logFail('contents_identical') + return false + } + + logPass() + return true +} + +export function getBinaryFeedbackResultForChoice( + m1: AssistantMessage, + m2: AssistantMessage, + choice: BinaryFeedbackChoice, +): BinaryFeedbackResult { + switch (choice) { + case 'prefer-left': + return { message: m1, shouldSkipPermissionCheck: true } + case 'prefer-right': + return { message: m2, shouldSkipPermissionCheck: true } + case 'no-preference': + return { + message: Math.random() < 0.5 ? m1 : m2, + shouldSkipPermissionCheck: false, + } + case 'neither': + return { message: null, shouldSkipPermissionCheck: false } + } +} diff --git a/src/components/messages/AssistantBashOutputMessage.tsx b/src/components/messages/AssistantBashOutputMessage.tsx new file mode 100644 index 0000000..19fb891 --- /dev/null +++ b/src/components/messages/AssistantBashOutputMessage.tsx @@ -0,0 +1,22 @@ +import * as React from 'react' +import BashToolResultMessage from '../../tools/BashTool/BashToolResultMessage.js' +import { extractTag } from '../../utils/messages.js' + +export function AssistantBashOutputMessage({ + content, + verbose, +}: { + content: string + verbose?: boolean +}): React.ReactNode { + const stdout = extractTag(content, 'bash-stdout') ?? '' + const stderr = extractTag(content, 'bash-stderr') ?? '' + const stdoutLines = stdout.split('\n').length + const stderrLines = stderr.split('\n').length + return ( + + ) +} diff --git a/src/components/messages/AssistantLocalCommandOutputMessage.tsx b/src/components/messages/AssistantLocalCommandOutputMessage.tsx new file mode 100644 index 0000000..d03164b --- /dev/null +++ b/src/components/messages/AssistantLocalCommandOutputMessage.tsx @@ -0,0 +1,45 @@ +import * as React from 'react' +import { extractTag } from '../../utils/messages.js' +import { getTheme } from '../../utils/theme.js' +import { Box, Text } from 'ink' + +export function AssistantLocalCommandOutputMessage({ + content, +}: { + content: string +}): React.ReactNode[] { + const stdout = extractTag(content, 'local-command-stdout') + const stderr = extractTag(content, 'local-command-stderr') + if (!stdout && !stderr) { + return [] + } + const theme = getTheme() + let insides = [ + format(stdout?.trim(), theme.text), + format(stderr?.trim(), theme.error), + ].filter(Boolean) + + if (insides.length === 0) { + insides = [(No output)] + } + + return [ + + + {' '}⎿ + + {insides.map((_, index) => ( + + {_} + + ))} + , + ] +} + +function format(content: string | undefined, color: string): React.ReactNode { + if (!content) { + return null + } + return {content} +} diff --git a/src/components/messages/AssistantRedactedThinkingMessage.tsx b/src/components/messages/AssistantRedactedThinkingMessage.tsx new file mode 100644 index 0000000..faf46fb --- /dev/null +++ b/src/components/messages/AssistantRedactedThinkingMessage.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import { Box, Text } from 'ink' +import { getTheme } from '../../utils/theme.js' + +type Props = { + addMargin: boolean +} + +export function AssistantRedactedThinkingMessage({ + addMargin = false, +}: Props): React.ReactNode { + return ( + + + ✻ Thinking… + + + ) +} diff --git a/src/components/messages/AssistantTextMessage.tsx b/src/components/messages/AssistantTextMessage.tsx new file mode 100644 index 0000000..11945b1 --- /dev/null +++ b/src/components/messages/AssistantTextMessage.tsx @@ -0,0 +1,144 @@ +import { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import React from 'react' +import { AssistantBashOutputMessage } from './AssistantBashOutputMessage.js' +import { AssistantLocalCommandOutputMessage } from './AssistantLocalCommandOutputMessage.js' +import { getTheme } from '../../utils/theme.js' +import { Box, Text } from 'ink' +import { Cost } from '../Cost.js' +import { + API_ERROR_MESSAGE_PREFIX, + CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE, + INVALID_API_KEY_ERROR_MESSAGE, + PROMPT_TOO_LONG_ERROR_MESSAGE, +} from '../../services/claude.js' +import { + CANCEL_MESSAGE, + INTERRUPT_MESSAGE, + INTERRUPT_MESSAGE_FOR_TOOL_USE, + isEmptyMessageText, + NO_RESPONSE_REQUESTED, +} from '../../utils/messages.js' +import { BLACK_CIRCLE } from '../../constants/figures.js' +import { applyMarkdown } from '../../utils/markdown.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' + +type Props = { + param: TextBlockParam + costUSD: number + durationMs: number + debug: boolean + addMargin: boolean + shouldShowDot: boolean + verbose?: boolean + width?: number | string +} + +export function AssistantTextMessage({ + param: { text }, + costUSD, + durationMs, + debug, + addMargin, + shouldShowDot, + verbose, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + if (isEmptyMessageText(text)) { + return null + } + + // Show bash output + if (text.startsWith(' + } + + // Show command output + if ( + text.startsWith(' + } + + if (text.startsWith(API_ERROR_MESSAGE_PREFIX)) { + return ( + +   ⎿   + + {text === API_ERROR_MESSAGE_PREFIX + ? `${API_ERROR_MESSAGE_PREFIX}: Please wait a moment and try again.` + : text} + + + ) + } + + switch (text) { + // Local JSX commands don't need a response, but we still want Claude to see them + // Tool results render their own interrupt messages + case NO_RESPONSE_REQUESTED: + case INTERRUPT_MESSAGE_FOR_TOOL_USE: + return null + + case INTERRUPT_MESSAGE: + case CANCEL_MESSAGE: + return ( + +   ⎿   + Interrupted by user + + ) + + case PROMPT_TOO_LONG_ERROR_MESSAGE: + return ( + +   ⎿   + + Context low · Run /compact to compact & continue + + + ) + + case CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE: + return ( + +   ⎿   + + Credit balance too low · Add funds: + https://console.anthropic.com/settings/billing + + + ) + + case INVALID_API_KEY_ERROR_MESSAGE: + return ( + +   ⎿   + {INVALID_API_KEY_ERROR_MESSAGE} + + ) + + default: + return ( + + + {shouldShowDot && ( + + {BLACK_CIRCLE} + + )} + + {applyMarkdown(text)} + + + + + ) + } +} diff --git a/src/components/messages/AssistantThinkingMessage.tsx b/src/components/messages/AssistantThinkingMessage.tsx new file mode 100644 index 0000000..e334f9e --- /dev/null +++ b/src/components/messages/AssistantThinkingMessage.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { Box, Text } from 'ink' +import { getTheme } from '../../utils/theme.js' +import { applyMarkdown } from '../../utils/markdown.js' +import { + ThinkingBlock, + ThinkingBlockParam, +} from '@anthropic-ai/sdk/resources/index.mjs' + +type Props = { + param: ThinkingBlock | ThinkingBlockParam + addMargin: boolean +} + +export function AssistantThinkingMessage({ + param: { thinking }, + addMargin = false, +}: Props): React.ReactNode { + if (!thinking) { + return null + } + + return ( + + + ✻ Thinking… + + + + {applyMarkdown(thinking)} + + + + ) +} diff --git a/src/components/messages/AssistantToolUseMessage.tsx b/src/components/messages/AssistantToolUseMessage.tsx new file mode 100644 index 0000000..b0119d6 --- /dev/null +++ b/src/components/messages/AssistantToolUseMessage.tsx @@ -0,0 +1,110 @@ +import { Box, Text } from 'ink' +import React from 'react' +import { logError } from '../../utils/log.js' +import { ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import { Tool } from '../../Tool.js' +import { Cost } from '../Cost.js' +import { ToolUseLoader } from '../ToolUseLoader.js' +import { getTheme } from '../../utils/theme.js' +import { BLACK_CIRCLE } from '../../constants/figures.js' +import { ThinkTool } from '../../tools/ThinkTool/ThinkTool.js' +import { AssistantThinkingMessage } from './AssistantThinkingMessage.js' + +type Props = { + param: ToolUseBlockParam + costUSD: number + durationMs: number + addMargin: boolean + tools: Tool[] + debug: boolean + verbose: boolean + erroredToolUseIDs: Set + inProgressToolUseIDs: Set + unresolvedToolUseIDs: Set + shouldAnimate: boolean + shouldShowDot: boolean +} + +export function AssistantToolUseMessage({ + param, + costUSD, + durationMs, + addMargin, + tools, + debug, + verbose, + erroredToolUseIDs, + inProgressToolUseIDs, + unresolvedToolUseIDs, + shouldAnimate, + shouldShowDot, +}: Props): React.ReactNode { + const tool = tools.find(_ => _.name === param.name) + if (!tool) { + logError(`Tool ${param.name} not found`) + return null + } + const isQueued = + !inProgressToolUseIDs.has(param.id) && unresolvedToolUseIDs.has(param.id) + // Keeping color undefined makes the OS use the default color regardless of appearance + const color = isQueued ? getTheme().secondaryText : undefined + + // TODO: Avoid this special case + if (tool === ThinkTool) { + // params were already validated in query(), so this won't throe + const { thought } = ThinkTool.inputSchema.parse(param.input) + return ( + + ) + } + + const userFacingToolName = tool.userFacingName(param.input as never) + return ( + + + + {shouldShowDot && + (isQueued ? ( + + {BLACK_CIRCLE} + + ) : ( + + ))} + + {userFacingToolName} + + + + {Object.keys(param.input as { [key: string]: unknown }).length > + 0 && ( + + ( + {tool.renderToolUseMessage(param.input as never, { + verbose, + })} + ) + + )} + + + + + + ) +} diff --git a/src/components/messages/UserBashInputMessage.tsx b/src/components/messages/UserBashInputMessage.tsx new file mode 100644 index 0000000..6263d5e --- /dev/null +++ b/src/components/messages/UserBashInputMessage.tsx @@ -0,0 +1,28 @@ +import { Box, Text } from 'ink' +import * as React from 'react' +import { extractTag } from '../../utils/messages.js' +import { getTheme } from '../../utils/theme.js' +import { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' + +type Props = { + addMargin: boolean + param: TextBlockParam +} + +export function UserBashInputMessage({ + param: { text }, + addMargin, +}: Props): React.ReactNode { + const input = extractTag(text, 'bash-input') + if (!input) { + return null + } + return ( + + + ! + {input} + + + ) +} diff --git a/src/components/messages/UserCommandMessage.tsx b/src/components/messages/UserCommandMessage.tsx new file mode 100644 index 0000000..afff033 --- /dev/null +++ b/src/components/messages/UserCommandMessage.tsx @@ -0,0 +1,30 @@ +import { Box, Text } from 'ink' +import * as React from 'react' +import { getTheme } from '../../utils/theme.js' +import { extractTag } from '../../utils/messages.js' +import { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' + +type Props = { + addMargin: boolean + param: TextBlockParam +} + +export function UserCommandMessage({ + addMargin, + param: { text }, +}: Props): React.ReactNode { + const commandMessage = extractTag(text, 'command-message') + const args = extractTag(text, 'command-args') + if (!commandMessage) { + return null + } + + const theme = getTheme() + return ( + + + > /{commandMessage} {args} + + + ) +} diff --git a/src/components/messages/UserPromptMessage.tsx b/src/components/messages/UserPromptMessage.tsx new file mode 100644 index 0000000..534418e --- /dev/null +++ b/src/components/messages/UserPromptMessage.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import { Box, Text } from 'ink' +import { getTheme } from '../../utils/theme.js' +import { logError } from '../../utils/log.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' + +type Props = { + addMargin: boolean + param: TextBlockParam +} + +export function UserPromptMessage({ + addMargin, + param: { text }, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + if (!text) { + logError('No content found in user prompt message') + return null + } + + return ( + + + > + + + + {text} + + + + ) +} diff --git a/src/components/messages/UserTextMessage.tsx b/src/components/messages/UserTextMessage.tsx new file mode 100644 index 0000000..10d0955 --- /dev/null +++ b/src/components/messages/UserTextMessage.tsx @@ -0,0 +1,33 @@ +import { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import { UserBashInputMessage } from './UserBashInputMessage.js' +import { UserCommandMessage } from './UserCommandMessage.js' +import { UserPromptMessage } from './UserPromptMessage.js' +import * as React from 'react' +import { NO_CONTENT_MESSAGE } from '../../services/claude.js' + +type Props = { + addMargin: boolean + param: TextBlockParam +} + +export function UserTextMessage({ addMargin, param }: Props): React.ReactNode { + if (param.text.trim() === NO_CONTENT_MESSAGE) { + return null + } + + // Bash inputs! + if (param.text.includes('')) { + return + } + + // Slash commands/ + if ( + param.text.includes('') || + param.text.includes('') + ) { + return + } + + // User prompts> + return +} diff --git a/src/components/messages/UserToolResultMessage/UserToolCanceledMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolCanceledMessage.tsx new file mode 100644 index 0000000..9cd9da6 --- /dev/null +++ b/src/components/messages/UserToolResultMessage/UserToolCanceledMessage.tsx @@ -0,0 +1,12 @@ +import { Text } from 'ink' +import * as React from 'react' +import { getTheme } from '../../../utils/theme.js' + +export function UserToolCanceledMessage(): React.ReactNode { + return ( + +   ⎿   + Interrupted by user + + ) +} diff --git a/src/components/messages/UserToolResultMessage/UserToolErrorMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolErrorMessage.tsx new file mode 100644 index 0000000..93c4f4d --- /dev/null +++ b/src/components/messages/UserToolResultMessage/UserToolErrorMessage.tsx @@ -0,0 +1,36 @@ +import { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import { Box, Text } from 'ink' +import * as React from 'react' +import { getTheme } from '../../../utils/theme.js' + +const MAX_RENDERED_LINES = 10 + +type Props = { + param: ToolResultBlockParam + verbose: boolean +} + +export function UserToolErrorMessage({ + param, + verbose, +}: Props): React.ReactNode { + const error = + typeof param.content === 'string' ? param.content.trim() : 'Error' + return ( + +   ⎿   + + + {verbose + ? error + : error.split('\n').slice(0, MAX_RENDERED_LINES).join('\n') || ''} + + {!verbose && error.split('\n').length > MAX_RENDERED_LINES && ( + + ... (+{error.split('\n').length - MAX_RENDERED_LINES} lines) + + )} + + + ) +} diff --git a/src/components/messages/UserToolResultMessage/UserToolRejectMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolRejectMessage.tsx new file mode 100644 index 0000000..04aba48 --- /dev/null +++ b/src/components/messages/UserToolResultMessage/UserToolRejectMessage.tsx @@ -0,0 +1,31 @@ +import * as React from 'react' +import { Tool } from '../../../Tool.js' +import { Message } from '../../../query.js' +import { FallbackToolUseRejectedMessage } from '../../FallbackToolUseRejectedMessage.js' +import { useGetToolFromMessages } from './utils.js' +import { useTerminalSize } from '../../../hooks/useTerminalSize.js' + +type Props = { + toolUseID: string + messages: Message[] + tools: Tool[] + verbose: boolean +} + +export function UserToolRejectMessage({ + toolUseID, + tools, + messages, + verbose, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + const { tool, toolUse } = useGetToolFromMessages(toolUseID, tools, messages) + const input = tool.inputSchema.safeParse(toolUse.input) + if (input.success) { + return tool.renderToolUseRejectedMessage(input.data, { + columns, + verbose, + }) + } + return +} diff --git a/src/components/messages/UserToolResultMessage/UserToolResultMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolResultMessage.tsx new file mode 100644 index 0000000..9c7ad16 --- /dev/null +++ b/src/components/messages/UserToolResultMessage/UserToolResultMessage.tsx @@ -0,0 +1,57 @@ +import { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import * as React from 'react' +import { Tool } from '../../../Tool.js' +import { Message, UserMessage } from '../../../query.js' +import { CANCEL_MESSAGE, REJECT_MESSAGE } from '../../../utils/messages.js' +import { UserToolCanceledMessage } from './UserToolCanceledMessage.js' +import { UserToolErrorMessage } from './UserToolErrorMessage.js' +import { UserToolRejectMessage } from './UserToolRejectMessage.js' +import { UserToolSuccessMessage } from './UserToolSuccessMessage.js' + +type Props = { + param: ToolResultBlockParam + message: UserMessage + messages: Message[] + tools: Tool[] + verbose: boolean + width: number | string +} + +export function UserToolResultMessage({ + param, + message, + messages, + tools, + verbose, + width, +}: Props): React.ReactNode { + if (param.content === CANCEL_MESSAGE) { + return + } + + if (param.content === REJECT_MESSAGE) { + return ( + + ) + } + + if (param.is_error) { + return + } + + return ( + + ) +} diff --git a/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx new file mode 100644 index 0000000..5817bd4 --- /dev/null +++ b/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx @@ -0,0 +1,35 @@ +import { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import { Box } from 'ink' +import * as React from 'react' +import { Tool } from '../../../Tool.js' +import { Message, UserMessage } from '../../../query.js' +import { useGetToolFromMessages } from './utils.js' + +type Props = { + param: ToolResultBlockParam + message: UserMessage + messages: Message[] + verbose: boolean + tools: Tool[] + width: number | string +} + +export function UserToolSuccessMessage({ + param, + message, + messages, + tools, + verbose, + width, +}: Props): React.ReactNode { + const { tool } = useGetToolFromMessages(param.tool_use_id, tools, messages) + + return ( + // TODO: Distinguish UserMessage from UserToolResultMessage + + {tool.renderToolResultMessage?.(message.toolUseResult!.data as never, { + verbose, + })} + + ) +} diff --git a/src/components/messages/UserToolResultMessage/utils.tsx b/src/components/messages/UserToolResultMessage/utils.tsx new file mode 100644 index 0000000..ce1d5b3 --- /dev/null +++ b/src/components/messages/UserToolResultMessage/utils.tsx @@ -0,0 +1,56 @@ +import { ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import { Message } from '../../../query.js' +import { useMemo } from 'react' +import { Tool } from '../../../Tool.js' +import { GlobTool } from '../../../tools/GlobTool/GlobTool.js' +import { GrepTool } from '../../../tools/GrepTool/GrepTool.js' +import { logEvent } from '../../../services/statsig.js' + +function getToolUseFromMessages( + toolUseID: string, + messages: Message[], +): ToolUseBlockParam | null { + let toolUse: ToolUseBlockParam | null = null + for (const message of messages) { + if ( + message.type !== 'assistant' || + !Array.isArray(message.message.content) + ) { + continue + } + for (const content of message.message.content) { + if (content.type === 'tool_use' && content.id === toolUseID) { + toolUse = content + } + } + } + return toolUse +} + +export function useGetToolFromMessages( + toolUseID: string, + tools: Tool[], + messages: Message[], +) { + return useMemo(() => { + const toolUse = getToolUseFromMessages(toolUseID, messages) + if (!toolUse) { + throw new ReferenceError( + `Tool use not found for tool_use_id ${toolUseID}`, + ) + } + // Hack: we don't expose GlobTool and GrepTool in getTools anymore, + // but we still want to be able to load old transcripts. + // TODO: Remove this when logging hits zero + const tool = [...tools, GlobTool, GrepTool].find( + _ => _.name === toolUse.name, + ) + if (tool === GlobTool || tool === GrepTool) { + logEvent('tengu_legacy_tool_lookup', {}) + } + if (!tool) { + throw new ReferenceError(`Tool not found for ${toolUse.name}`) + } + return { tool, toolUse } + }, [toolUseID, messages, tools]) +} diff --git a/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx b/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx new file mode 100644 index 0000000..66d4b86 --- /dev/null +++ b/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx @@ -0,0 +1,121 @@ +import { Box, Text } from 'ink' +import React, { useMemo } from 'react' +import { UnaryEvent } from '../../../hooks/usePermissionRequestLogging.js' +import { savePermission } from '../../../permissions.js' +import { BashTool } from '../../../tools/BashTool/BashTool.js' +import { getTheme } from '../../../utils/theme.js' +import { usePermissionRequestLogging } from '../hooks.js' +import { + type ToolUseConfirm, + toolUseConfirmGetPrefix, +} from '../PermissionRequest.js' +import { PermissionRequestTitle } from '../PermissionRequestTitle.js' +import { logUnaryPermissionEvent } from '../utils.js' +import { Select } from '../../CustomSelect/select.js' +import { toolUseOptions } from '../toolUseOptions.js' + +type Props = { + toolUseConfirm: ToolUseConfirm + onDone(): void +} + +export function BashPermissionRequest({ + toolUseConfirm, + onDone, +}: Props): React.ReactNode { + const theme = getTheme() + + // ok to use parse since we've already validated args earliers + const { command } = BashTool.inputSchema.parse(toolUseConfirm.input) + + const unaryEvent = useMemo( + () => ({ completion_type: 'tool_use_single', language_name: 'none' }), + [], + ) + + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + + return ( + + + + {BashTool.renderToolUseMessage({ command })} + {toolUseConfirm.description} + + + + Do you want to proceed? + { + switch (newValue) { + case 'yes': + logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onAllow('temporary') + onDone() + break + case 'yes-dont-ask-again': + logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + savePermission( + toolUseConfirm.tool, + toolUseConfirm.input, + toolUseConfirmGetPrefix(toolUseConfirm), + ).then(() => { + toolUseConfirm.onAllow('permanent') + onDone() + }) + break + case 'no': + logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'reject', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onReject() + onDone() + break + } + }} + /> + + + ) +} diff --git a/src/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx b/src/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx new file mode 100644 index 0000000..5c4486e --- /dev/null +++ b/src/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx @@ -0,0 +1,182 @@ +import { Select } from '@inkjs/ui' +import chalk from 'chalk' +import { Box, Text } from 'ink' +import { basename, extname } from 'path' +import React, { useMemo } from 'react' +import { + UnaryEvent, + usePermissionRequestLogging, +} from '../../../hooks/usePermissionRequestLogging.js' +import { savePermission } from '../../../permissions.js' +import { env } from '../../../utils/env.js' +import { getTheme } from '../../../utils/theme.js' +import { logUnaryEvent } from '../../../utils/unaryLogging.js' +import { + type ToolUseConfirm, + toolUseConfirmGetPrefix, +} from '../PermissionRequest.js' +import { + PermissionRequestTitle, + textColorForRiskScore, +} from '../PermissionRequestTitle.js' +import { FileEditToolDiff } from './FileEditToolDiff.js' +import { useTerminalSize } from '../../../hooks/useTerminalSize.js' +import { pathInOriginalCwd } from '../../../utils/permissions/filesystem.js' + +function getOptions(path: string) { + // Only show don't ask again option for edits in original working directory + const showDontAskAgainOptions = pathInOriginalCwd(path) + ? [ + { + label: "Yes, and don't ask again this session", + value: 'yes-dont-ask-again', + }, + ] + : [] + + return [ + { + label: 'Yes', + value: 'yes', + }, + ...showDontAskAgainOptions, + { + label: `No, and tell Claude what to do differently (${chalk.bold.hex(getTheme().warning)('esc')})`, + value: 'no', + }, + ] +} + +type Props = { + toolUseConfirm: ToolUseConfirm + onDone(): void + verbose: boolean +} + +export function FileEditPermissionRequest({ + toolUseConfirm, + onDone, + verbose, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + const { file_path, new_string, old_string } = toolUseConfirm.input as { + file_path: string + new_string: string + old_string: string + } + + const unaryEvent = useMemo( + () => ({ + completion_type: 'str_replace_single', + language_name: extractLanguageName(file_path), + }), + [file_path], + ) + + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + + return ( + + + + + + Do you want to make this edit to{' '} + {basename(file_path)}? + + { + switch (newValue) { + case 'yes': + extractLanguageName(file_path).then(language => { + logUnaryEvent({ + completion_type: 'write_file_single', + event: 'accept', + metadata: { + language_name: language, + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + }) + toolUseConfirm.onAllow('temporary') + onDone() + break + case 'yes-dont-ask-again': + extractLanguageName(file_path).then(language => { + logUnaryEvent({ + completion_type: 'write_file_single', + event: 'accept', + metadata: { + language_name: language, + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + }) + savePermission( + toolUseConfirm.tool, + toolUseConfirm.input, + toolUseConfirmGetPrefix(toolUseConfirm), + ).then(() => { + toolUseConfirm.onAllow('permanent') + onDone() + }) + break + case 'no': + extractLanguageName(file_path).then(language => { + logUnaryEvent({ + completion_type: 'write_file_single', + event: 'reject', + metadata: { + language_name: language, + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + }) + toolUseConfirm.onReject() + onDone() + break + } + }} + /> + + + ) +} + +async function extractLanguageName(file_path: string): Promise { + const ext = extname(file_path) + if (!ext) { + return 'unknown' + } + const Highlight = (await import('highlight.js')) as unknown as { + default: { getLanguage(ext: string): { name: string | undefined } } + } + return Highlight.default.getLanguage(ext.slice(1))?.name ?? 'unknown' +} diff --git a/src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.tsx b/src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.tsx new file mode 100644 index 0000000..e4dd5f7 --- /dev/null +++ b/src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.tsx @@ -0,0 +1,81 @@ +import * as React from 'react' +import { existsSync, readFileSync } from 'fs' +import { useMemo } from 'react' +import { StructuredDiff } from '../../StructuredDiff.js' +import { Box, Text } from 'ink' +import { getTheme } from '../../../utils/theme.js' +import { intersperse } from '../../../utils/array.js' +import { getCwd } from '../../../utils/state.js' +import { extname, relative } from 'path' +import { detectFileEncoding } from '../../../utils/file.js' +import { HighlightedCode } from '../../HighlightedCode.js' +import { getPatch } from '../../../utils/diff.js' + +type Props = { + file_path: string + content: string + verbose: boolean + width: number +} + +export function FileWriteToolDiff({ + file_path, + content, + verbose, + width, +}: Props): React.ReactNode { + const fileExists = useMemo(() => existsSync(file_path), [file_path]) + const oldContent = useMemo(() => { + if (!fileExists) { + return '' + } + const enc = detectFileEncoding(file_path) + return readFileSync(file_path, enc) + }, [file_path, fileExists]) + const hunks = useMemo(() => { + if (!fileExists) { + return null + } + return getPatch({ + filePath: file_path, + fileContents: oldContent, + oldStr: oldContent, + newStr: content, + }) + }, [fileExists, file_path, oldContent, content]) + + return ( + + + {verbose ? file_path : relative(getCwd(), file_path)} + + {hunks ? ( + intersperse( + hunks.map(_ => ( + + )), + i => ( + + ... + + ), + ) + ) : ( + + )} + + ) +} diff --git a/src/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx b/src/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx new file mode 100644 index 0000000..3ebc92e --- /dev/null +++ b/src/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx @@ -0,0 +1,242 @@ +import { Box, Text } from 'ink' +import React, { useMemo } from 'react' +import { Select } from '@inkjs/ui' +import { getTheme } from '../../../utils/theme.js' +import { + PermissionRequestTitle, + textColorForRiskScore, +} from '../PermissionRequestTitle.js' +import { logUnaryEvent } from '../../../utils/unaryLogging.js' +import { env } from '../../../utils/env.js' +import { + type PermissionRequestProps, + type ToolUseConfirm, +} from '../PermissionRequest.js' +import chalk from 'chalk' +import { + UnaryEvent, + usePermissionRequestLogging, +} from '../../../hooks/usePermissionRequestLogging.js' +import { FileEditTool } from '../../../tools/FileEditTool/FileEditTool.js' +import { FileWriteTool } from '../../../tools/FileWriteTool/FileWriteTool.js' +import { GrepTool } from '../../../tools/GrepTool/GrepTool.js' +import { GlobTool } from '../../../tools/GlobTool/GlobTool.js' +import { LSTool } from '../../../tools/lsTool/lsTool.js' +import { FileReadTool } from '../../../tools/FileReadTool/FileReadTool.js' +import { NotebookEditTool } from '../../../tools/NotebookEditTool/NotebookEditTool.js' +import { NotebookReadTool } from '../../../tools/NotebookReadTool/NotebookReadTool.js' +import { FallbackPermissionRequest } from '../FallbackPermissionRequest.js' +import { + grantWritePermissionForOriginalDir, + pathInOriginalCwd, + toAbsolutePath, +} from '../../../utils/permissions/filesystem.js' +import { getCwd } from '../../../utils/state.js' + +function pathArgNameForToolUse(toolUseConfirm: ToolUseConfirm): string | null { + switch (toolUseConfirm.tool) { + case FileWriteTool: + case FileEditTool: + case FileReadTool: { + return 'file_path' + } + case GlobTool: + case GrepTool: + case LSTool: { + return 'path' + } + case NotebookEditTool: + case NotebookReadTool: { + return 'notebook_path' + } + } + return null +} + +function isMultiFile(toolUseConfirm: ToolUseConfirm): boolean { + switch (toolUseConfirm.tool) { + case GlobTool: + case GrepTool: + case LSTool: { + return true + } + } + return false +} + +function pathFromToolUse(toolUseConfirm: ToolUseConfirm): string | null { + const pathArgName = pathArgNameForToolUse(toolUseConfirm) + const input = toolUseConfirm.input + if (pathArgName && pathArgName in input) { + if (typeof input[pathArgName] === 'string') { + return toAbsolutePath(input[pathArgName]) + } else { + return toAbsolutePath(getCwd()) + } + } + return null +} + +export function FilesystemPermissionRequest({ + toolUseConfirm, + onDone, + verbose, +}: PermissionRequestProps): React.ReactNode { + const path = pathFromToolUse(toolUseConfirm) + if (!path) { + // Fall back to generic permission request if no path is found + return ( + + ) + } + return ( + + ) +} + +function getDontAskAgainOptions(toolUseConfirm: ToolUseConfirm, path: string) { + if (toolUseConfirm.tool.isReadOnly()) { + // "Always allow" is not an option for read-only tools, + // because they always have write permission in the project directory. + return [] + } + // Only show don't ask again option for edits in original working directory + return pathInOriginalCwd(path) + ? [ + { + label: "Yes, and don't ask again for file edits this session", + value: 'yes-dont-ask-again', + }, + ] + : [] +} + +type Props = { + toolUseConfirm: ToolUseConfirm + path: string + onDone(): void + verbose: boolean +} + +function FilesystemPermissionRequestImpl({ + toolUseConfirm, + path, + onDone, + verbose, +}: Props): React.ReactNode { + const userFacingName = toolUseConfirm.tool.userFacingName( + toolUseConfirm.input as never, + ) + + const userFacingReadOrWrite = toolUseConfirm.tool.isReadOnly() + ? 'Read' + : 'Edit' + const title = `${userFacingReadOrWrite} ${isMultiFile(toolUseConfirm) ? 'files' : 'file'}` + + const unaryEvent = useMemo( + () => ({ + completion_type: 'tool_use_single', + language_name: 'none', + }), + [], + ) + + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + + return ( + + + + + {userFacingName}( + {toolUseConfirm.tool.renderToolUseMessage( + toolUseConfirm.input as never, + { verbose }, + )} + ) + + + + + Do you want to proceed? + { + setShowConfirmation(false) + if (value === 'yes') { + handleSetupNewPrefix(customPrefix) + } else { + onCancel() + } + }} + /> + + )} + + )} + {error && Error: {error}} + + ) +} diff --git a/src/screens/Doctor.tsx b/src/screens/Doctor.tsx new file mode 100644 index 0000000..4d379b3 --- /dev/null +++ b/src/screens/Doctor.tsx @@ -0,0 +1,219 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { Box, Text, useInput } from 'ink' +import { Select } from '@inkjs/ui' +import { getTheme } from '../utils/theme.js' +import { ConfigureNpmPrefix } from './ConfigureNpmPrefix.js' +import { platform } from 'process' +import { + checkNpmPermissions, + getDefaultNpmPrefix, + getPermissionsCommand, +} from '../utils/autoUpdater.js' +import { saveGlobalConfig, getGlobalConfig } from '../utils/config.js' +import { logEvent } from '../services/statsig.js' +import { PRODUCT_NAME } from '../constants/product.js' +import { PressEnterToContinue } from '../components/PressEnterToContinue.js' + +type Props = { + onDone: () => void + doctorMode?: boolean +} + +type Option = { + label: string + value: 'auto' | 'manual' | 'ignore' + description: string +} + +export function Doctor({ onDone, doctorMode = false }: Props): React.ReactNode { + const [hasPermissions, setHasPermissions] = useState(null) + const [npmPrefix, setNpmPrefix] = useState(null) + const [selectedOption, setSelectedOption] = useState( + null, + ) + const [customPrefix, setCustomPrefix] = useState( + getDefaultNpmPrefix(), + ) + const theme = getTheme() + const [showingPermissionsMessage, setShowingPermissionsMessage] = + useState(false) + + const options: Option[] = [ + { + label: `Manually fix permissions on current npm prefix (Recommended)`, + value: 'manual', + description: + platform === 'win32' + ? 'Uses icacls to grant write permissions' + : 'Uses sudo to change ownership', + }, + { + label: 'Create new npm prefix directory', + value: 'auto', + description: + 'Creates a new directory for global npm packages in your home directory', + }, + { + label: 'Skip configuration until next session', + value: 'ignore', + description: 'Skip this warning (you will be reminded again later)', + }, + ] + + const checkPermissions = useCallback(async () => { + const result = await checkNpmPermissions() + logEvent('tengu_auto_updater_permissions_check', { + hasPermissions: result.hasPermissions.toString(), + npmPrefix: result.npmPrefix ?? 'null', + }) + setHasPermissions(result.hasPermissions) + if (result.npmPrefix) { + setNpmPrefix(result.npmPrefix) + } + if (result.hasPermissions) { + const config = getGlobalConfig() + saveGlobalConfig({ + ...config, + autoUpdaterStatus: 'enabled', + }) + if (!doctorMode) { + onDone() + } + } + }, [onDone, doctorMode]) + + useEffect(() => { + logEvent('tengu_auto_updater_config_start', {}) + checkPermissions() + }, [checkPermissions]) + + useInput( + (_input, key) => { + if ( + (showingPermissionsMessage || + (doctorMode && hasPermissions === true)) && + key.return + ) { + onDone() + } + }, + { + isActive: + showingPermissionsMessage || (doctorMode && hasPermissions === true), + }, + ) + + if (hasPermissions === null) { + return ( + + Checking npm permissions… + + ) + } + + if (hasPermissions === true) { + if (doctorMode) { + return ( + + ✓ npm permissions: OK + Your installation is healthy and ready for auto-updates. + + + ) + } + return ( + + ✓ Auto-updates enabled + + ) + } + return ( + + + Enable automatic updates? + + + {PRODUCT_NAME} can't update itself because it doesn't have + permissions. Do you want to fix this to get automatic updates? + + + {!selectedOption && ( + + Select an option below to fix the permissions issue: +