Initial commit

This commit is contained in:
daniel nakov
2025-02-25 00:08:45 -05:00
commit 8bf1bb8913
212 changed files with 25983 additions and 0 deletions

4
README.md Normal file
View File

@@ -0,0 +1,4 @@
# claude-code
Extracted from the source maps of the @anthropic-ai/claude-code package

176
src/ProjectOnboarding.tsx Normal file
View File

@@ -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 (
<Box flexDirection="column" gap={1} padding={1} paddingBottom={0}>
{showOnboarding && (
<>
<Text color={theme.secondaryText}>Tips for getting started:</Text>
<OrderedList>
{/* Collect all the items that should be displayed */}
{(() => {
const items = []
if (isWorkspaceDirEmpty) {
items.push(
<OrderedList.Item key="workspace">
<Text color={theme.secondaryText}>
Ask Claude to create a new app or clone a repository.
</Text>
</OrderedList.Item>,
)
}
if (needsClaudeMd) {
items.push(
<OrderedList.Item key="claudemd">
<Text color={theme.secondaryText}>
Run <Text color={theme.text}>/init</Text> to create a
CLAUDE.md file with instructions for Claude.
</Text>
</OrderedList.Item>,
)
}
if (showTerminalTip) {
items.push(
<OrderedList.Item key="terminal">
<Text color={theme.secondaryText}>
Run <Text color={theme.text}>/terminal-setup</Text>
<Text bold={false}> to set up terminal integration</Text>
</Text>
</OrderedList.Item>,
)
}
items.push(
<OrderedList.Item key="questions">
<Text color={theme.secondaryText}>
Ask Claude questions about your codebase.
</Text>
</OrderedList.Item>,
)
items.push(
<OrderedList.Item key="changes">
<Text color={theme.secondaryText}>
Ask Claude to implement changes to your codebase.
</Text>
</OrderedList.Item>,
)
return items
})()}
</OrderedList>
</>
)}
{!showOnboarding && hasReleaseNotes && (
<Box
borderColor={getTheme().secondaryBorder}
flexDirection="column"
marginRight={1}
>
<Box flexDirection="column" gap={0}>
<Box marginBottom={1}>
<Text>🆕 What&apos;s new in v{MACRO.VERSION}:</Text>
</Box>
<Box flexDirection="column" marginLeft={1}>
{releaseNotesToShow.map((note, noteIndex) => (
<Text key={noteIndex} color={getTheme().secondaryText}>
{note}
</Text>
))}
</Box>
</Box>
</Box>
)}
{workspaceDir === homedir() && (
<Text color={getTheme().warning}>
Note: You have launched <Text bold>claude</Text> in your home
directory. For the best experience, launch it in a project directory
instead.
</Text>
)}
</Box>
)
}

120
src/commands.ts Normal file
View File

@@ -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<MessageParam[]>
}
type LocalCommand = {
type: 'local'
call(
args: string,
context: {
options: {
commands: Command[]
tools: Tool[]
slowAndCapableModel: string
}
abortController: AbortController
setForkConvoWithMessagesOnTheNextRender: (
forkConvoWithMessages: Message[],
) => void
},
): Promise<string>
}
type LocalJSXCommand = {
type: 'local-jsx'
call(
onDone: (result?: string) => void,
context: ToolUseContext & {
setForkConvoWithMessagesOnTheNextRender: (
forkConvoWithMessages: Message[],
) => void
},
): Promise<React.ReactNode>
}
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<Command[]> => {
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
}

View File

@@ -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`,
}
}
}

20
src/commands/bug.tsx Normal file
View File

@@ -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 <Bug onDone={onDone} />
},
userFacingName() {
return 'bug'
},
} satisfies Command
export default bug

37
src/commands/clear.ts Normal file
View File

@@ -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

94
src/commands/compact.ts Normal file
View File

@@ -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

19
src/commands/config.tsx Normal file
View File

@@ -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 <Config onClose={onDone} />
},
userFacingName() {
return 'config'
},
} satisfies Command
export default config

18
src/commands/cost.ts Normal file
View File

@@ -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

209
src/commands/ctx_viz.ts Normal file
View File

@@ -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 <context> tag
const firstContextIndex = text.indexOf('<context')
// Everything before first tag is Core Sysprompt
if (firstContextIndex > 0) {
const coreSysprompt = text.slice(0, firstContextIndex).trim()
if (coreSysprompt) {
sections.push({
title: 'Core Sysprompt',
content: coreSysprompt,
})
}
}
let currentPos = firstContextIndex
let nonContextContent = ''
const regex = /<context\s+name="([^"]*)">([\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<context name="${name}">${content}</context>`
}
// 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

23
src/commands/doctor.ts Normal file
View File

@@ -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

19
src/commands/help.tsx Normal file
View File

@@ -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 <Help commands={commands} onClose={onDone} />
},
userFacingName() {
return 'help'
},
} satisfies Command
export default help

37
src/commands/init.ts Normal file
View File

@@ -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

42
src/commands/listen.ts Normal file
View File

@@ -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

51
src/commands/login.tsx Normal file
View File

@@ -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 (
<Login
onDone={async () => {
clearConversation(context)
onDone()
}}
/>
)
},
userFacingName() {
return 'login'
},
}) satisfies Command
function Login(props: { onDone: () => void }) {
const exitState = useExitOnCtrlCD(props.onDone)
return (
<Box flexDirection="column">
<ConsoleOAuthFlow onDone={props.onDone} />
<Box marginLeft={3}>
<Text dimColor>
{exitState.pending ? (
<>Press {exitState.keyName} again to exit</>
) : (
''
)}
</Text>
</Box>
</Box>
)
}

41
src/commands/logout.tsx Normal file
View File

@@ -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 = (
<Text>Successfully logged out from your Anthropic account.</Text>
)
setTimeout(() => {
process.exit(0)
}, 200)
return message
},
userFacingName() {
return 'logout'
},
} satisfies Command

View File

@@ -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 (
<Onboarding
onDone={async () => {
clearConversation(context)
onDone()
}}
/>
)
},
userFacingName() {
return 'onboarding'
},
} satisfies Command

View File

@@ -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

View File

@@ -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

30
src/commands/resume.tsx Normal file
View File

@@ -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(
<ResumeConversation
commands={commands}
context={{ unmount: onDone }}
logs={logs}
tools={tools}
verbose={verbose}
/>,
)
// This return is here for type only
return null
},
} satisfies Command

49
src/commands/review.ts Normal file
View File

@@ -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 <number>") to get PR details
3. Use ${BashTool.name}("gh pr diff <number>") 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

View File

@@ -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<string> {
const { code } = await execFileNoThrow('defaults', [
'write',
'com.googlecode.iterm2',
'GlobalKeyMap',
'-dict-add',
'0xd-0x20000-0x24',
`<dict>
<key>Text</key>
<string>\\n</string>
<key>Action</key>
<integer>12</integer>
<key>Version</key>
<integer>1</integer>
<key>Keycode</key>
<integer>13</integer>
<key>Modifiers</key>
<integer>131072</integer>
</dict>`,
])
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')
}
}

View File

@@ -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 (
<Text color={color || theme.claude}>
{animatedArray[currentAsciiArtIndex]}
</Text>
)
}

View File

@@ -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 (
<>
<Box
flexDirection="column"
gap={1}
padding={1}
borderStyle="round"
borderColor={theme.warning}
>
<Text bold color={theme.warning}>
Detected a custom API key in your environment
</Text>
<Text>
Your environment sets{' '}
<Text color={theme.warning}>ANTHROPIC_API_KEY</Text>:{' '}
<Text bold>sk-ant-...{customApiKeyTruncated}</Text>
</Text>
<Text>Do you want to use this API key?</Text>
<Select
options={[
{ label: `No (${chalk.bold('recommended')})`, value: 'no' },
{ label: 'Yes', value: 'yes' },
]}
onChange={value => onChange(value as 'yes' | 'no')}
/>
</Box>
<Box marginLeft={3}>
<Text dimColor>
{exitState.pending ? (
<>Press {exitState.keyName} again to exit</>
) : (
<>Enter to confirm</>
)}
</Text>
</Box>
</>
)
}

View File

@@ -0,0 +1,25 @@
import { Box, Text } from 'ink'
import React from 'react'
import { getTheme } from '../utils/theme.js'
export function AsciiLogo(): React.ReactNode {
const theme = getTheme()
return (
<Box flexDirection="column" alignItems="flex-start">
<Text color={theme.claude}>
{` ██████╗██╗ █████╗ ██╗ ██╗██████╗ ███████╗
██╔════╝██║ ██╔══██╗██║ ██║██╔══██╗██╔════╝
██║ ██║ ███████║██║ ██║██║ ██║█████╗
██║ ██║ ██╔══██║██║ ██║██║ ██║██╔══╝
╚██████╗███████╗██║ ██║╚██████╔╝██████╔╝███████╗
╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝
██████╗ ██████╗ ██████╗ ███████╗
██╔════╝██╔═══██╗██╔══██╗██╔════╝
██║ ██║ ██║██║ ██║█████╗
██║ ██║ ██║██║ ██║██╔══╝
╚██████╗╚██████╔╝██████╔╝███████╗
╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝`}
</Text>
</Box>
)
}

View File

@@ -0,0 +1,146 @@
import { Box, Text } from 'ink'
import * as React from 'react'
import { getTheme } from '../utils/theme.js'
import { gte } from 'semver'
import { useEffect, useState } from 'react'
import { isAutoUpdaterDisabled } from '../utils/config.js'
import {
AutoUpdaterResult,
getLatestVersion,
installGlobalPackage,
} from '../utils/autoUpdater.js'
import { useInterval } from '../hooks/useInterval.js'
import { logEvent } from '../services/statsig.js'
type Props = {
debug: boolean
isUpdating: boolean
onChangeIsUpdating: (isUpdating: boolean) => void
onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void
autoUpdaterResult: AutoUpdaterResult | null
}
export function AutoUpdater({
debug,
isUpdating,
onChangeIsUpdating,
onAutoUpdaterResult,
autoUpdaterResult,
}: Props): React.ReactNode {
const theme = getTheme()
const [versions, setVersions] = useState<{
global?: string | null
latest?: string | null
}>({})
const checkForUpdates = React.useCallback(async () => {
if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'dev') {
return
}
if (isUpdating) {
return
}
// Get versions
const globalVersion = MACRO.VERSION
const latestVersion = await getLatestVersion()
const isDisabled = await isAutoUpdaterDisabled()
setVersions({ global: globalVersion, latest: latestVersion })
// Check if update needed and perform update
if (
!isDisabled &&
globalVersion &&
latestVersion &&
!gte(globalVersion, latestVersion)
) {
const startTime = Date.now()
onChangeIsUpdating(true)
const installStatus = await installGlobalPackage()
onChangeIsUpdating(false)
if (installStatus === 'success') {
logEvent('tengu_auto_updater_success', {
fromVersion: globalVersion,
toVersion: latestVersion,
durationMs: String(Date.now() - startTime),
})
} else {
logEvent('tengu_auto_updater_fail', {
fromVersion: globalVersion,
attemptedVersion: latestVersion,
status: installStatus,
durationMs: String(Date.now() - startTime),
})
}
onAutoUpdaterResult({
version: latestVersion!,
status: installStatus,
})
}
// Don't re-render when isUpdating changes
// TODO: Find a cleaner way to do this
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onAutoUpdaterResult])
// Initial check
useEffect(() => {
checkForUpdates()
}, [checkForUpdates])
// Check every 30 minutes
useInterval(checkForUpdates, 30 * 60 * 1000)
if (debug) {
return (
<Box flexDirection="row">
<Text dimColor>
globalVersion: {versions.global} &middot; latestVersion:{' '}
{versions.latest}
</Text>
</Box>
)
}
if (!autoUpdaterResult?.version && (!versions.global || !versions.latest)) {
return null
}
if (!autoUpdaterResult?.version && !isUpdating) {
return null
}
return (
<Box flexDirection="row">
{debug && (
<Text dimColor>
globalVersion: {versions.global} &middot; latestVersion:{' '}
{versions.latest}
</Text>
)}
{isUpdating && (
<>
<Box>
<Text color={theme.secondaryText} dimColor wrap="end">
Auto-updating to v{versions.latest}
</Text>
</Box>
</>
)}
{autoUpdaterResult?.status === 'success' && autoUpdaterResult?.version ? (
<Text color={theme.success}>
Update installed &middot; Restart to apply
</Text>
) : null}
{(autoUpdaterResult?.status === 'install_failed' ||
autoUpdaterResult?.status === 'no_permissions') && (
<Text color={theme.error}>
Auto-update failed &middot; Try <Text bold>claude doctor</Text> or{' '}
<Text bold>npm i -g {MACRO.PACKAGE_URL}</Text>
</Text>
)}
</Box>
)
}

332
src/components/Bug.tsx Normal file
View File

@@ -0,0 +1,332 @@
import { Box, Text, useInput } from 'ink'
import * as React from 'react'
import { useState, useCallback, useEffect } from 'react'
import { getTheme } from '../utils/theme.js'
import { getMessagesGetter } from '../messages.js'
import type { Message } from '../query.js'
import TextInput from './TextInput.js'
import { logError, getInMemoryErrors } from '../utils/log.js'
import { env } from '../utils/env.js'
import { getGitState, getIsGit, GitRepoState } from '../utils/git.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { getAnthropicApiKey } from '../utils/config.js'
import { USER_AGENT } from '../utils/http.js'
import { logEvent } from '../services/statsig.js'
import { PRODUCT_NAME } from '../constants/product.js'
import { API_ERROR_MESSAGE_PREFIX, queryHaiku } from '../services/claude.js'
import { openBrowser } from '../utils/browser.js'
import { useExitOnCtrlCD } from '../hooks/useExitOnCtrlCD.js'
const GITHUB_ISSUES_REPO_URL =
'https://github.com/anthropics/claude-code/issues'
type Props = {
onDone(result: string): void
}
type Step = 'userInput' | 'consent' | 'submitting' | 'done'
type FeedbackData = {
// Removing because of privacy concerns. Add this back in when we have a more
// robust tool for viewing feedback data that can de-identify users
// user_id: string
// session_id: string
message_count: number
datetime: string
description: string
platform: string
gitRepo: boolean
version: string | null
transcript: Message[]
}
export function Bug({ onDone }: Props): React.ReactNode {
const [step, setStep] = useState<Step>('userInput')
const [cursorOffset, setCursorOffset] = useState(0)
const [description, setDescription] = useState('')
const [feedbackId, setFeedbackId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [envInfo, setEnvInfo] = useState<{
isGit: boolean
gitState: GitRepoState | null
}>({ isGit: false, gitState: null })
const [title, setTitle] = useState<string | null>(null)
const textInputColumns = useTerminalSize().columns - 4
const messages = getMessagesGetter()()
useEffect(() => {
async function loadEnvInfo() {
const isGit = await getIsGit()
let gitState: GitRepoState | null = null
if (isGit) {
gitState = await getGitState()
}
setEnvInfo({ isGit, gitState })
}
void loadEnvInfo()
}, [])
const exitState = useExitOnCtrlCD(() => process.exit(0))
const submitReport = useCallback(async () => {
setStep('submitting')
setError(null)
setFeedbackId(null)
const reportData = {
message_count: messages.length,
datetime: new Date().toISOString(),
description,
platform: env.platform,
gitRepo: envInfo.isGit,
terminal: env.terminal,
version: MACRO.VERSION,
transcript: messages,
errors: getInMemoryErrors(),
}
const [result, t] = await Promise.all([
submitFeedback(reportData),
generateTitle(description),
])
setTitle(t)
if (result.success) {
if (result.feedbackId) {
setFeedbackId(result.feedbackId)
logEvent('tengu_bug_report_submitted', {
feedback_id: result.feedbackId,
})
}
setStep('done')
} else {
setError('Could not submit feedback. Please try again later.')
setStep('userInput')
}
}, [description, envInfo.isGit, messages])
useInput((input, key) => {
// Allow any key press to close the dialog when done or when there's an error
if (step === 'done') {
if (key.return && feedbackId && title) {
// Open GitHub issue URL when Enter is pressed
const issueUrl = createGitHubIssueUrl(feedbackId, title, description)
void openBrowser(issueUrl)
}
onDone('<bash-stdout>Bug report submitted</bash-stdout>')
return
}
if (error) {
onDone('<bash-stderr>Error submitting bug report</bash-stderr>')
return
}
if (key.escape) {
onDone('<bash-stderr>Bug report cancelled</bash-stderr>')
return
}
if (step === 'consent' && (key.return || input === ' ')) {
void submitReport()
}
})
const theme = getTheme()
return (
<>
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.permission}
paddingX={1}
paddingBottom={1}
gap={1}
>
<Text bold color={theme.permission}>
Submit Bug Report
</Text>
{step === 'userInput' && (
<Box flexDirection="column" gap={1}>
<Text>Describe the issue below:</Text>
<TextInput
value={description}
onChange={setDescription}
columns={textInputColumns}
onSubmit={() => setStep('consent')}
onExitMessage={() =>
onDone('<bash-stderr>Bug report cancelled</bash-stderr>')
}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
/>
{error && (
<Box flexDirection="column" gap={1}>
<Text color="red">{error}</Text>
<Text dimColor>Press any key to close</Text>
</Box>
)}
</Box>
)}
{step === 'consent' && (
<Box flexDirection="column">
<Text>This report will include:</Text>
<Box marginLeft={2} flexDirection="column">
<Text>
- Your bug description: <Text dimColor>{description}</Text>
</Text>
<Text>
- Environment info:{' '}
<Text dimColor>
{env.platform}, {env.terminal}, v{MACRO.VERSION}
</Text>
</Text>
{envInfo.gitState && (
<Text>
- Git repo metadata:{' '}
<Text dimColor>
{envInfo.gitState.branchName}
{envInfo.gitState.commitHash
? `, ${envInfo.gitState.commitHash.slice(0, 7)}`
: ''}
{envInfo.gitState.remoteUrl
? ` @ ${envInfo.gitState.remoteUrl}`
: ''}
{!envInfo.gitState.isHeadOnRemote && ', not synced'}
{!envInfo.gitState.isClean && ', has local changes'}
</Text>
</Text>
)}
<Text>- Current session transcript</Text>
</Box>
<Box marginTop={1}>
<Text wrap="wrap" dimColor>
We will use your feedback to debug related issues or to improve{' '}
{PRODUCT_NAME}&apos;s functionality (eg. to reduce the risk of
bugs occurring in the future). Anthropic will not train
generative models using feedback from {PRODUCT_NAME}.
</Text>
</Box>
<Box marginTop={1}>
<Text>
Press <Text bold>Enter</Text> to confirm and submit.
</Text>
</Box>
</Box>
)}
{step === 'submitting' && (
<Box flexDirection="row" gap={1}>
<Text>Submitting report</Text>
</Box>
)}
{step === 'done' && (
<Box flexDirection="column">
<Text color={getTheme().success}>Thank you for your report!</Text>
{feedbackId && <Text dimColor>Feedback ID: {feedbackId}</Text>}
<Box marginTop={1}>
<Text>Press </Text>
<Text bold>Enter </Text>
<Text>
to also create a GitHub issue, or any other key to close.
</Text>
</Box>
</Box>
)}
</Box>
<Box marginLeft={3}>
<Text dimColor>
{exitState.pending ? (
<>Press {exitState.keyName} again to exit</>
) : step === 'userInput' ? (
<>Enter to continue · Esc to cancel</>
) : step === 'consent' ? (
<>Enter to submit · Esc to cancel</>
) : null}
</Text>
</Box>
</>
)
}
function createGitHubIssueUrl(
feedbackId: string,
title: string,
description: string,
): string {
const body = encodeURIComponent(
`**Bug Description**\n${description}\n\n` +
`**Environment Info**\n` +
`- Platform: ${env.platform}\n` +
`- Terminal: ${env.terminal}\n` +
`- Version: ${MACRO.VERSION || 'unknown'}\n` +
`- Feedback ID: ${feedbackId}\n`,
)
return `${GITHUB_ISSUES_REPO_URL}/new?title=${encodeURIComponent(title)}&body=${body}&labels=user-reported,bug`
}
async function generateTitle(description: string): Promise<string> {
const response = await queryHaiku({
systemPrompt: [
'Generate a concise issue title (max 80 chars) that captures the key point of this feedback. Do not include quotes or prefixes like "Feedback:" or "Issue:". If you cannot generate a title, just use "User Feedback".',
],
userPrompt: description,
})
const title =
response.message.content[0]?.type === 'text'
? response.message.content[0].text
: 'Bug Report'
if (title.startsWith(API_ERROR_MESSAGE_PREFIX)) {
return `Bug Report: ${description.slice(0, 60)}${description.length > 60 ? '...' : ''}`
}
return title
}
async function submitFeedback(
data: FeedbackData,
): Promise<{ success: boolean; feedbackId?: string }> {
try {
const apiKey = getAnthropicApiKey()
if (!apiKey) {
return { success: false }
}
const response = await fetch(
'https://api.anthropic.com/api/claude_cli_feedback',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': USER_AGENT,
'x-api-key': apiKey,
},
body: JSON.stringify({
content: JSON.stringify(data),
}),
},
)
if (response.ok) {
const result = await response.json()
if (result?.feedback_id) {
return { success: true, feedbackId: result.feedback_id }
}
logError('Failed to submit feedback: request did not return feedback_id')
return { success: false }
}
logError('Failed to submit feedback:' + response.status)
return { success: false }
} catch (err) {
logError(
'Error submitting feedback: ' +
(err instanceof Error ? err.message : 'Unknown error'),
)
return { success: false }
}
}

279
src/components/Config.tsx Normal file
View File

@@ -0,0 +1,279 @@
import { Box, Text, useInput } from 'ink'
import * as React from 'react'
import { useState } from 'react'
import figures from 'figures'
import { getTheme } from '../utils/theme.js'
import {
GlobalConfig,
saveGlobalConfig,
normalizeApiKeyForConfig,
} from '../utils/config.js'
import { getGlobalConfig } from '../utils/config.js'
import chalk from 'chalk'
import { PRODUCT_NAME } from '../constants/product.js'
import { useExitOnCtrlCD } from '../hooks/useExitOnCtrlCD.js'
type Props = {
onClose: () => void
}
type Setting =
| {
id: string
label: string
value: boolean
onChange(value: boolean): void
type: 'boolean'
}
| {
id: string
label: string
value: string
options: string[]
onChange(value: string): void
type: 'enum'
}
export function Config({ onClose }: Props): React.ReactNode {
const [globalConfig, setGlobalConfig] = useState(getGlobalConfig())
const initialConfig = React.useRef(getGlobalConfig())
const [selectedIndex, setSelectedIndex] = useState(0)
const exitState = useExitOnCtrlCD(() => process.exit(0))
// TODO: Add MCP servers
const settings: Setting[] = [
// Global settings
...(process.env.ANTHROPIC_API_KEY
? [
{
id: 'apiKey',
label: `Use custom API key: ${chalk.bold(normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY))}`,
value: Boolean(
process.env.ANTHROPIC_API_KEY &&
globalConfig.customApiKeyResponses?.approved?.includes(
normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY),
),
),
type: 'boolean' as const,
onChange(useCustomKey: boolean) {
const config = { ...getGlobalConfig() }
if (!config.customApiKeyResponses) {
config.customApiKeyResponses = {
approved: [],
rejected: [],
}
}
if (!config.customApiKeyResponses.approved) {
config.customApiKeyResponses.approved = []
}
if (!config.customApiKeyResponses.rejected) {
config.customApiKeyResponses.rejected = []
}
if (process.env.ANTHROPIC_API_KEY) {
const truncatedKey = normalizeApiKeyForConfig(
process.env.ANTHROPIC_API_KEY,
)
if (useCustomKey) {
config.customApiKeyResponses.approved = [
...config.customApiKeyResponses.approved.filter(
k => k !== truncatedKey,
),
truncatedKey,
]
config.customApiKeyResponses.rejected =
config.customApiKeyResponses.rejected.filter(
k => k !== truncatedKey,
)
} else {
config.customApiKeyResponses.approved =
config.customApiKeyResponses.approved.filter(
k => k !== truncatedKey,
)
config.customApiKeyResponses.rejected = [
...config.customApiKeyResponses.rejected.filter(
k => k !== truncatedKey,
),
truncatedKey,
]
}
}
saveGlobalConfig(config)
setGlobalConfig(config)
},
},
]
: []),
{
id: 'verbose',
label: 'Verbose output',
value: globalConfig.verbose,
type: 'boolean',
onChange(verbose: boolean) {
const config = { ...getGlobalConfig(), verbose }
saveGlobalConfig(config)
setGlobalConfig(config)
},
},
{
id: 'theme',
label: 'Theme',
value: globalConfig.theme,
options: ['light', 'dark', 'light-daltonized', 'dark-daltonized'],
type: 'enum',
onChange(theme: GlobalConfig['theme']) {
const config = { ...getGlobalConfig(), theme }
saveGlobalConfig(config)
setGlobalConfig(config)
},
},
{
id: 'notifChannel',
label: 'Notifications',
value: globalConfig.preferredNotifChannel,
options: [
'iterm2',
'terminal_bell',
'iterm2_with_bell',
'notifications_disabled',
],
type: 'enum',
onChange(notifChannel: GlobalConfig['preferredNotifChannel']) {
const config = {
...getGlobalConfig(),
preferredNotifChannel: notifChannel,
}
saveGlobalConfig(config)
setGlobalConfig(config)
},
},
]
useInput((input, key) => {
if (key.escape) {
// Log any changes that were made
// TODO: Make these proper messages
const changes: string[] = []
// Check for API key changes
const initialUsingCustomKey = Boolean(
process.env.ANTHROPIC_API_KEY &&
initialConfig.current.customApiKeyResponses?.approved?.includes(
normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY),
),
)
const currentUsingCustomKey = Boolean(
process.env.ANTHROPIC_API_KEY &&
globalConfig.customApiKeyResponses?.approved?.includes(
normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY),
),
)
if (initialUsingCustomKey !== currentUsingCustomKey) {
changes.push(
`${currentUsingCustomKey ? 'Enabled' : 'Disabled'} custom API key`,
)
}
if (globalConfig.verbose !== initialConfig.current.verbose) {
changes.push(` ⎿ Set verbose to ${chalk.bold(globalConfig.verbose)}`)
}
if (globalConfig.theme !== initialConfig.current.theme) {
changes.push(` ⎿ Set theme to ${chalk.bold(globalConfig.theme)}`)
}
if (
globalConfig.preferredNotifChannel !==
initialConfig.current.preferredNotifChannel
) {
changes.push(
` ⎿ Set notifications to ${chalk.bold(globalConfig.preferredNotifChannel)}`,
)
}
if (changes.length > 0) {
console.log(chalk.gray(changes.join('\n')))
}
onClose()
return
}
function toggleSetting() {
const setting = settings[selectedIndex]
if (!setting || !setting.onChange) {
return
}
if (setting.type === 'boolean') {
setting.onChange(!setting.value)
return
}
if (setting.type === 'enum') {
const currentIndex = setting.options.indexOf(setting.value)
const nextIndex = (currentIndex + 1) % setting.options.length
setting.onChange(setting.options[nextIndex]!)
return
}
}
if (key.return || input === ' ') {
toggleSetting()
return
}
if (key.upArrow) {
setSelectedIndex(prev => Math.max(0, prev - 1))
}
if (key.downArrow) {
setSelectedIndex(prev => Math.min(settings.length - 1, prev + 1))
}
})
return (
<>
<Box
flexDirection="column"
borderStyle="round"
borderColor={getTheme().secondaryBorder}
paddingX={1}
marginTop={1}
>
<Box flexDirection="column" minHeight={2} marginBottom={1}>
<Text bold>Settings</Text>
<Text dimColor>Configure {PRODUCT_NAME} preferences</Text>
</Box>
{settings.map((setting, i) => {
const isSelected = i === selectedIndex
return (
<Box key={setting.id} height={2} minHeight={2}>
<Box width={44}>
<Text color={isSelected ? 'blue' : undefined}>
{isSelected ? figures.pointer : ' '} {setting.label}
</Text>
</Box>
<Box>
{setting.type === 'boolean' ? (
<Text color={isSelected ? 'blue' : undefined}>
{setting.value.toString()}
</Text>
) : (
<Text color={isSelected ? 'blue' : undefined}>
{setting.value.toString()}
</Text>
)}
</Box>
</Box>
)
})}
</Box>
<Box marginLeft={3}>
<Text dimColor>
{exitState.pending ? (
<>Press {exitState.keyName} again to exit</>
) : (
<>/ to select · Enter/Space to change · Esc to close</>
)}
</Text>
</Box>
</>
)
}

View File

@@ -0,0 +1,326 @@
import React, { useEffect, useState, useCallback } from 'react'
import { Static, Box, Text, useInput } from 'ink'
import TextInput from './TextInput.js'
import { OAuthService, createAndStoreApiKey } from '../services/oauth.js'
import { getTheme } from '../utils/theme.js'
import { logEvent } from '../services/statsig.js'
import { AsciiLogo } from './AsciiLogo.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { logError } from '../utils/log.js'
import { clearTerminal } from '../utils/terminal.js'
import { SimpleSpinner } from './Spinner.js'
import { WelcomeBox } from './Onboarding.js'
import { PRODUCT_NAME } from '../constants/product.js'
import { sendNotification } from '../services/notifier.js'
type Props = {
onDone(): void
}
type OAuthStatus =
| { state: 'idle' }
| { state: 'ready_to_start' }
| { state: 'waiting_for_login'; url: string }
| { state: 'creating_api_key' }
| { state: 'about_to_retry'; nextState: OAuthStatus }
| { state: 'success'; apiKey: string }
| {
state: 'error'
message: string
toRetry?: OAuthStatus
}
const PASTE_HERE_MSG = 'Paste code here if prompted > '
export function ConsoleOAuthFlow({ onDone }: Props): React.ReactNode {
const [oauthStatus, setOAuthStatus] = useState<OAuthStatus>({
state: 'idle',
})
const theme = getTheme()
const [pastedCode, setPastedCode] = useState('')
const [cursorOffset, setCursorOffset] = useState(0)
const [oauthService] = useState(() => new OAuthService())
// After a few seconds we suggest the user to copy/paste url if the
// browser did not open automatically. In this flow we expect the user to
// copy the code from the browser and paste it in the terminal
const [showPastePrompt, setShowPastePrompt] = useState(false)
// we need a special clearing state to correctly re-render Static elements
const [isClearing, setIsClearing] = useState(false)
const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1
useEffect(() => {
if (isClearing) {
clearTerminal()
setIsClearing(false)
}
}, [isClearing])
// Retry logic
useEffect(() => {
if (oauthStatus.state === 'about_to_retry') {
setIsClearing(true)
setTimeout(() => {
setOAuthStatus(oauthStatus.nextState)
}, 1000)
}
}, [oauthStatus])
useInput(async (_, key) => {
if (key.return) {
if (oauthStatus.state === 'idle') {
logEvent('tengu_oauth_start', {})
setOAuthStatus({ state: 'ready_to_start' })
} else if (oauthStatus.state === 'success') {
logEvent('tengu_oauth_success', {})
await clearTerminal() // needed to clear out Static components
onDone()
} else if (oauthStatus.state === 'error' && oauthStatus.toRetry) {
setPastedCode('')
setOAuthStatus({
state: 'about_to_retry',
nextState: oauthStatus.toRetry,
})
}
}
})
async function handleSubmitCode(value: string, url: string) {
try {
// Expecting format "authorizationCode#state" from the authorization callback URL
const [authorizationCode, state] = value.split('#')
if (!authorizationCode || !state) {
setOAuthStatus({
state: 'error',
message: 'Invalid code. Please make sure the full code was copied',
toRetry: { state: 'waiting_for_login', url },
})
return
}
// Track which path the user is taking (manual code entry)
logEvent('tengu_oauth_manual_entry', {})
oauthService.processCallback({
authorizationCode,
state,
useManualRedirect: true,
})
} catch (err) {
logError(err)
setOAuthStatus({
state: 'error',
message: (err as Error).message,
toRetry: { state: 'waiting_for_login', url },
})
}
}
const startOAuth = useCallback(async () => {
try {
const result = await oauthService
.startOAuthFlow(async url => {
setOAuthStatus({ state: 'waiting_for_login', url })
setTimeout(() => setShowPastePrompt(true), 3000)
})
.catch(err => {
// Handle token exchange errors specifically
if (err.message.includes('Token exchange failed')) {
setOAuthStatus({
state: 'error',
message:
'Failed to exchange authorization code for access token. Please try again.',
toRetry: { state: 'ready_to_start' },
})
logEvent('tengu_oauth_token_exchange_error', { error: err.message })
} else {
// Handle other errors
setOAuthStatus({
state: 'error',
message: err.message,
toRetry: { state: 'ready_to_start' },
})
}
throw err
})
setOAuthStatus({ state: 'creating_api_key' })
const apiKey = await createAndStoreApiKey(result.accessToken).catch(
err => {
setOAuthStatus({
state: 'error',
message: 'Failed to create API key: ' + err.message,
toRetry: { state: 'ready_to_start' },
})
logEvent('tengu_oauth_api_key_error', { error: err.message })
throw err
},
)
if (apiKey) {
setOAuthStatus({ state: 'success', apiKey })
sendNotification({ message: 'Claude Code login successful' })
} else {
setOAuthStatus({
state: 'error',
message:
"Unable to create API key. The server accepted the request but didn't return a key.",
toRetry: { state: 'ready_to_start' },
})
logEvent('tengu_oauth_api_key_error', {
error: 'server_returned_no_key',
})
}
} catch (err) {
const errorMessage = (err as Error).message
logEvent('tengu_oauth_error', { error: errorMessage })
}
}, [oauthService, setShowPastePrompt])
useEffect(() => {
if (oauthStatus.state === 'ready_to_start') {
startOAuth()
}
}, [oauthStatus.state, startOAuth])
// Helper function to render the appropriate status message
function renderStatusMessage(): React.ReactNode {
switch (oauthStatus.state) {
case 'idle':
return (
<Box flexDirection="column" gap={1}>
<Text bold>
{PRODUCT_NAME} is billed based on API usage through your Anthropic
Console account.
</Text>
<Box>
<Text>
Pricing may evolve as we move towards general availability.
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.permission}>
Press <Text bold>Enter</Text> to login to your Anthropic Console
account
</Text>
</Box>
</Box>
)
case 'waiting_for_login':
return (
<Box flexDirection="column" gap={1}>
{!showPastePrompt && (
<Box>
<SimpleSpinner />
<Text>Opening browser to sign in</Text>
</Box>
)}
{showPastePrompt && (
<Box>
<Text>{PASTE_HERE_MSG}</Text>
<TextInput
value={pastedCode}
onChange={setPastedCode}
onSubmit={(value: string) =>
handleSubmitCode(value, oauthStatus.url)
}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
columns={textInputColumns}
/>
</Box>
)}
</Box>
)
case 'creating_api_key':
return (
<Box flexDirection="column" gap={1}>
<Box>
<SimpleSpinner />
<Text>Creating API key for Claude Code</Text>
</Box>
</Box>
)
case 'about_to_retry':
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.permission}>Retrying</Text>
</Box>
)
case 'success':
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.success}>
Login successful. Press <Text bold>Enter</Text> to continue
</Text>
</Box>
)
case 'error':
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.error}>OAuth error: {oauthStatus.message}</Text>
{oauthStatus.toRetry && (
<Box marginTop={1}>
<Text color={theme.permission}>
Press <Text bold>Enter</Text> to retry.
</Text>
</Box>
)}
</Box>
)
default:
return null
}
}
// We need to render the copy-able URL statically to prevent Ink <Text> from inserting
// newlines in the middle of the URL (this breaks Safari). Because <Static> components are
// only rendered once top-to-bottom, we also need to make everything above the URL static.
const staticItems: Record<string, JSX.Element> = {}
if (!isClearing) {
staticItems.header = (
<Box key="header" flexDirection="column" gap={1}>
<WelcomeBox />
<Box paddingBottom={1} paddingLeft={1}>
<AsciiLogo />
</Box>
</Box>
)
}
if (oauthStatus.state === 'waiting_for_login' && showPastePrompt) {
staticItems.urlToCopy = (
<Box flexDirection="column" key="urlToCopy" gap={1} paddingBottom={1}>
<Box paddingX={1}>
<Text dimColor>
Browser didn&apos;t open? Use the url below to sign in:
</Text>
</Box>
<Box width={1000}>
<Text dimColor>{oauthStatus.url}</Text>
</Box>
</Box>
)
}
return (
<Box flexDirection="column" gap={1}>
<Static items={Object.keys(staticItems)}>
{item => staticItems[item]}
</Static>
<Box paddingLeft={1} flexDirection="column" gap={1}>
{renderStatusMessage()}
</Box>
</Box>
)
}

23
src/components/Cost.tsx Normal file
View File

@@ -0,0 +1,23 @@
import * as React from 'react'
import { Box, Text } from 'ink'
type Props = {
costUSD: number
durationMs: number
debug: boolean
}
export function Cost({ costUSD, durationMs, debug }: Props): React.ReactNode {
if (!debug) {
return null
}
const durationInSeconds = (durationMs / 1000).toFixed(1)
return (
<Box flexDirection="column" minWidth={23} width={23}>
<Text dimColor>
Cost: ${costUSD.toFixed(4)} ({durationInSeconds}s)
</Text>
</Box>
)
}

View File

@@ -0,0 +1,46 @@
import { Box, Text, useInput } from 'ink'
import React from 'react'
import { Select } from './CustomSelect/index.js'
import { getTheme } from '../utils/theme.js'
import Link from './Link.js'
interface Props {
onDone: () => void
}
export function CostThresholdDialog({ onDone }: Props): React.ReactNode {
// Handle Ctrl+C, Ctrl+D and Esc
useInput((input, key) => {
if ((key.ctrl && (input === 'c' || input === 'd')) || key.escape) {
onDone()
}
})
return (
<Box
flexDirection="column"
borderStyle="round"
padding={1}
borderColor={getTheme().secondaryBorder}
>
<Box marginBottom={1} flexDirection="column">
<Text bold>
You&apos;ve spent $5 on the Anthropic API this session.
</Text>
<Text>Learn more about how to monitor your spending:</Text>
<Link url="https://docs.anthropic.com/s/claude-code-cost" />
</Box>
<Box>
<Select
options={[
{
value: 'ok',
label: 'Got it, thanks!',
},
]}
onChange={onDone}
/>
</Box>
</Box>
)
}

View File

@@ -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<string, OptionMapItem> {
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
}
}

View File

@@ -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<Theme>('Select')
return (
<Box {...styles.option({ isFocused })}>
{isFocused && (
<Text {...styles.focusIndicator()}>
{smallPointer ? figures.triangleDownSmall : figures.pointer}
</Text>
)}
<Text {...styles.label({ isFocused, isSelected })}>{children}</Text>
{isSelected && (
<Text {...styles.selectedIndicator()}>{figures.tick}</Text>
)}
</Box>
)
}

View File

@@ -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<Theme>('Select')
return (
<Box {...styles.container()}>
{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)}
<Text {...styles.highlightedText()}>{highlightText}</Text>
{labelText.slice(index + highlightText.length)}
</>
)
}
return (
<SelectOption
key={key}
isFocused={isFocused}
isSelected={isSelected}
smallPointer={smallPointer}
>
{label}
</SelectOption>
)
})}
</Box>
)
}

View File

@@ -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> = (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,
}
}

View File

@@ -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 },
)
}

View File

@@ -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 (
<Text>
&nbsp;&nbsp; &nbsp;
<Text color={getTheme().error}>
No (tell Claude what to do differently)
</Text>
</Text>
)
}

View File

@@ -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 (
<Box flexDirection="column">
<Text>
{' '} Updated{' '}
<Text bold>{verbose ? filePath : relative(getCwd(), filePath)}</Text>
{numAdditions > 0 || numRemovals > 0 ? ' with ' : ''}
{numAdditions > 0 ? (
<>
<Text bold>{numAdditions}</Text>{' '}
{numAdditions > 1 ? 'additions' : 'addition'}
</>
) : null}
{numAdditions > 0 && numRemovals > 0 ? ' and ' : null}
{numRemovals > 0 ? (
<>
<Text bold>{numRemovals}</Text>{' '}
{numRemovals > 1 ? 'removals' : 'removal'}
</>
) : null}
</Text>
{intersperse(
structuredPatch.map(_ => (
<Box flexDirection="column" paddingLeft={5} key={_.newStart}>
<StructuredDiff patch={_} dim={false} width={columns - 12} />
</Box>
)),
i => (
<Box paddingLeft={5} key={`ellipsis-${i}`}>
<Text color={getTheme().secondaryText}>...</Text>
</Box>
),
)}
</Box>
)
}

124
src/components/Help.tsx Normal file
View File

@@ -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 (
<Box flexDirection="column" padding={1}>
<Text bold color={theme.claude}>
{`${PRODUCT_NAME} v${MACRO.VERSION}`}
</Text>
<Box marginTop={1} flexDirection="column">
<Text>
{PRODUCT_NAME} is a beta research preview. Always review Claude&apos;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.
</Text>
</Box>
{count >= 1 && (
<Box flexDirection="column" marginTop={1}>
<Text bold>Usage Modes:</Text>
<Text>
REPL: <Text bold>claude</Text> (interactive session)
</Text>
<Text>
Non-interactive: <Text bold>claude -p &quot;question&quot;</Text>
</Text>
<Box marginTop={1}>
<Text>
Run <Text bold>claude -h</Text> for all command line options
</Text>
</Box>
</Box>
)}
{count >= 2 && (
<Box marginTop={1} flexDirection="column">
<Text bold>Common Tasks:</Text>
<Text>
Ask questions about your codebase{' '}
<Text color={getTheme().secondaryText}>
&gt; How does foo.py work?
</Text>
</Text>
<Text>
Edit files{' '}
<Text color={getTheme().secondaryText}>
&gt; Update bar.ts to...
</Text>
</Text>
<Text>
Fix errors{' '}
<Text color={getTheme().secondaryText}>&gt; cargo build</Text>
</Text>
<Text>
Run commands{' '}
<Text color={getTheme().secondaryText}>&gt; /help</Text>
</Text>
<Text>
Run bash commands{' '}
<Text color={getTheme().secondaryText}>&gt; !ls</Text>
</Text>
</Box>
)}
{count >= 3 && (
<Box marginTop={1} flexDirection="column">
<Text bold>Interactive Mode Commands:</Text>
<Box flexDirection="column">
{filteredCommands.map((cmd, i) => (
<Box key={i} marginLeft={1}>
<Text bold>{`/${cmd.name}`}</Text>
<Text> - {cmd.description}</Text>
</Box>
))}
</Box>
</Box>
)}
<Box marginTop={1}>
<Text color={theme.secondaryText}>{moreHelp}</Text>
</Box>
<Box marginTop={2}>
<PressEnterToContinue />
</Box>
</Box>
)
}

View File

@@ -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 <Text>{highlightedCode}</Text>
}

View File

@@ -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 (
<>
<Box
flexDirection="column"
borderColor={theme.error}
borderStyle="round"
padding={1}
width={70}
gap={1}
>
<Text bold>Configuration Error</Text>
<Box flexDirection="column" gap={1}>
<Text>
The configuration file at <Text bold>{filePath}</Text> contains
invalid JSON.
</Text>
<Text>{errorDescription}</Text>
</Box>
<Box flexDirection="column">
<Text bold>Choose an option:</Text>
<Select
options={[
{ label: 'Exit and fix manually', value: 'exit' },
{ label: 'Reset with default configuration', value: 'reset' },
]}
onChange={handleSelect}
/>
</Box>
</Box>
{exitState.pending ? (
<Text dimColor>Press {exitState.keyName} again to exit</Text>
) : (
<Newline />
)}
</>
)
}
export function showInvalidConfigDialog({
error,
}: InvalidConfigHandlerProps): Promise<void> {
return new Promise(resolve => {
render(
<InvalidConfigDialog
filePath={error.filePath}
errorDescription={error.message}
onExit={() => {
resolve()
process.exit(1)
}}
onReset={() => {
writeFileSync(
error.filePath,
JSON.stringify(error.defaultConfig, null, 2),
)
resolve()
process.exit(0)
}}
/>,
{ exitOnCtrlC: false },
)
})
}

32
src/components/Link.tsx Normal file
View File

@@ -0,0 +1,32 @@
import InkLink from 'ink-link'
import { Text } from 'ink'
import React from 'react'
import { env } from '../utils/env.js'
type LinkProps = {
url: string
children?: React.ReactNode
}
// Terminals that support hyperlinks
const LINK_SUPPORTING_TERMINALS = ['iTerm.app', 'WezTerm', 'Hyper', 'VSCode']
export default function Link({ url, children }: LinkProps): React.ReactNode {
const supportsLinks = LINK_SUPPORTING_TERMINALS.includes(env.terminal ?? '')
// Determine what text to display - use children or fall back to the URL itself
const displayContent = children || url
// Use InkLink to get clickable links when we can, or to get a nice fallback when we can't
if (supportsLinks || displayContent !== url) {
return (
<InkLink url={url}>
<Text>{displayContent}</Text>
</InkLink>
)
} else {
// But if we don't have a title and just have a url *and* are not a terminal that supports links
// that doesn't support clickable links anyway, just show the URL
return <Text underline>{displayContent}</Text>
}
}

View File

@@ -0,0 +1,86 @@
import React from 'react'
import { Box, Text } from 'ink'
import { Select } from '@inkjs/ui'
import type { LogOption } from '../types/logs.js'
import { getTheme } from '../utils/theme.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { formatDate } from '../utils/log.js'
type LogSelectorProps = {
logs: LogOption[]
onSelect: (logValue: number) => void
}
export function LogSelector({
logs,
onSelect,
}: LogSelectorProps): React.ReactNode {
const { rows, columns } = useTerminalSize()
if (logs.length === 0) {
return null
}
const visibleCount = rows - 3 // Account for header and footer
const hiddenCount = Math.max(0, logs.length - visibleCount)
// Create formatted options
// Calculate column widths
const indexWidth = 7 // [0] to [99] with extra spaces
const modifiedWidth = 21 // "Yesterday at 7:49 pm" with space
const createdWidth = 21 // "Yesterday at 7:49 pm" with space
const countWidth = 9 // "999 msgs" (right-aligned)
const options = logs.map((log, i) => {
const index = `[${i}]`.padEnd(indexWidth)
const modified = formatDate(log.modified).padEnd(modifiedWidth)
const created = formatDate(log.created).padEnd(createdWidth)
const msgCount = `${log.messageCount}`.padStart(countWidth)
const prompt = log.firstPrompt
let branchInfo = ''
if (log.forkNumber) branchInfo += ` (fork #${log.forkNumber})`
if (log.sidechainNumber)
branchInfo += ` (sidechain #${log.sidechainNumber})`
const labelTxt = `${index}${modified}${created}${msgCount} ${prompt}${branchInfo}`
const truncated =
labelTxt.length > columns - 2 // Account for "> " selection cursor
? `${labelTxt.slice(0, columns - 5)}...`
: labelTxt
return {
label: truncated,
value: log.value.toString(),
}
})
return (
<Box flexDirection="column" height="100%" width="100%">
<Box paddingLeft={9}>
<Text bold color={getTheme().text}>
Modified
</Text>
<Text>{' '}</Text>
<Text bold color={getTheme().text}>
Created
</Text>
<Text>{' '}</Text>
<Text bold color={getTheme().text}>
# Messages
</Text>
<Text> </Text>
<Text bold color={getTheme().text}>
First message
</Text>
</Box>
<Select
options={options}
onChange={index => onSelect(parseInt(index, 10))}
visibleOptionCount={visibleCount}
/>
{hiddenCount > 0 && (
<Box paddingLeft={2}>
<Text color={getTheme().secondaryText}>and {hiddenCount} more</Text>
</Box>
)}
</Box>
)
}

148
src/components/Logo.tsx Normal file
View File

@@ -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 (
<Box flexDirection="column">
<Box
borderColor={theme.claude}
borderStyle="round"
flexDirection="column"
gap={1}
paddingLeft={1}
width={width}
>
<Text>
<Text color={theme.claude}></Text> Welcome to{' '}
<Text bold>{PRODUCT_NAME}</Text> <Text>research preview!</Text>
</Text>
<>
<Box paddingLeft={2} flexDirection="column" gap={1}>
<Text color={theme.secondaryText} italic>
/help for help
{process.env.USER_TYPE === 'ant' && <> · https://go/claude-cli</>}
</Text>
<Text color={theme.secondaryText}>cwd: {getCwd()}</Text>
</Box>
{hasOverrides && (
<Box
borderColor={theme.secondaryBorder}
borderStyle="single"
borderBottom={false}
borderLeft={false}
borderRight={false}
borderTop={true}
flexDirection="column"
marginLeft={2}
marginRight={1}
paddingTop={1}
>
<Box marginBottom={1}>
<Text color={theme.secondaryText}>Overrides (via env):</Text>
</Box>
{isCustomModel && (
<Text color={theme.secondaryText}>
Model: <Text bold>{currentModel}</Text>
</Text>
)}
{isCustomApiKey && apiKey ? (
<Text color={theme.secondaryText}>
API Key:{' '}
<Text bold>sk-ant-{apiKey!.slice(-width + 25)}</Text>
</Text>
) : null}
{process.env.DISABLE_PROMPT_CACHING ? (
<Text color={theme.secondaryText}>
Prompt caching:{' '}
<Text color={theme.error} bold>
off
</Text>
</Text>
) : null}
{process.env.API_TIMEOUT_MS ? (
<Text color={theme.secondaryText}>
API timeout:{' '}
<Text bold>{process.env.API_TIMEOUT_MS}ms</Text>
</Text>
) : null}
{process.env.MAX_THINKING_TOKENS ? (
<Text color={theme.secondaryText}>
Max thinking tokens:{' '}
<Text bold>{process.env.MAX_THINKING_TOKENS}</Text>
</Text>
) : null}
{process.env.ANTHROPIC_BASE_URL ? (
<Text color={theme.secondaryText}>
API Base URL:{' '}
<Text bold>{process.env.ANTHROPIC_BASE_URL}</Text>
</Text>
) : null}
</Box>
)}
</>
{mcpClients.length ? (
<Box
borderColor={theme.secondaryBorder}
borderStyle="single"
borderBottom={false}
borderLeft={false}
borderRight={false}
borderTop={true}
flexDirection="column"
marginLeft={2}
marginRight={1}
paddingTop={1}
>
<Box marginBottom={1}>
<Text color={theme.secondaryText}>MCP Servers:</Text>
</Box>
{mcpClients.map((client, idx) => (
<Box key={idx} width={width - 6}>
<Text color={theme.secondaryText}> {client.name}</Text>
<Box flexGrow={1} />
<Text
bold
color={
client.type === 'connected' ? theme.success : theme.error
}
>
{client.type === 'connected' ? 'connected' : 'failed'}
</Text>
</Box>
))}
</Box>
) : null}
</Box>
</Box>
)
}

View File

@@ -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 (
<>
<Box
flexDirection="column"
gap={1}
padding={1}
borderStyle="round"
borderColor={theme.warning}
>
<Text bold color={theme.warning}>
New MCP Server Detected
</Text>
<Text>
This project contains a .mcprc file with an MCP server that requires
your approval:
</Text>
<Text bold>{serverName}</Text>
<MCPServerDialogCopy />
<Text>Do you want to approve this MCP server?</Text>
<Select
options={[
{ label: 'Yes, approve this server', value: 'yes' },
{ label: 'No, reject this server', value: 'no' },
]}
onChange={value => onChange(value as 'yes' | 'no')}
/>
</Box>
<Box marginLeft={3}>
<Text dimColor>
{exitState.pending ? (
<>Press {exitState.keyName} again to exit</>
) : (
<>Enter to confirm · Esc to reject</>
)}
</Text>
</Box>
</>
)
}

View File

@@ -0,0 +1,24 @@
import React from 'react'
import { Text } from 'ink'
import Link from 'ink-link'
export function MCPServerDialogCopy(): React.ReactNode {
return (
<>
<Text>
MCP servers provide additional functionality to Claude. They may execute
code, make network requests, or access system resources via tool calls.
All tool calls will require your explicit approval before execution. For
more information, see{' '}
<Link url="https://docs.anthropic.com/s/claude-code-mcp">
MCP documentation
</Link>
</Text>
<Text dimColor>
Remember: You can always change these choices later by running `claude
mcp reset-mcprc-choices`
</Text>
</>
)
}

View File

@@ -0,0 +1,109 @@
import React from 'react'
import { Box, Text, useInput } from 'ink'
import { getTheme } from '../utils/theme.js'
import { MultiSelect } from '@inkjs/ui'
import {
saveCurrentProjectConfig,
getCurrentProjectConfig,
} from '../utils/config.js'
import { partition } from 'lodash-es'
import { MCPServerDialogCopy } from './MCPServerDialogCopy.js'
import { useExitOnCtrlCD } from '../hooks/useExitOnCtrlCD.js'
type Props = {
serverNames: string[]
onDone(): void
}
export function MCPServerMultiselectDialog({
serverNames,
onDone,
}: Props): React.ReactNode {
const theme = getTheme()
function onSubmit(selectedServers: string[]) {
const config = getCurrentProjectConfig()
// Initialize arrays if they don't exist
if (!config.approvedMcprcServers) {
config.approvedMcprcServers = []
}
if (!config.rejectedMcprcServers) {
config.rejectedMcprcServers = []
}
// Use partition to separate approved and rejected servers
const [approvedServers, rejectedServers] = partition(serverNames, server =>
selectedServers.includes(server),
)
// Add new servers directly to the respective lists
config.approvedMcprcServers.push(...approvedServers)
config.rejectedMcprcServers.push(...rejectedServers)
saveCurrentProjectConfig(config)
onDone()
}
const exitState = useExitOnCtrlCD(() => process.exit())
useInput((_input, key) => {
if (key.escape) {
// On escape, treat all servers as rejected
const config = getCurrentProjectConfig()
if (!config.rejectedMcprcServers) {
config.rejectedMcprcServers = []
}
for (const server of serverNames) {
if (!config.rejectedMcprcServers.includes(server)) {
config.rejectedMcprcServers.push(server)
}
}
saveCurrentProjectConfig(config)
onDone()
return
}
})
return (
<>
<Box
flexDirection="column"
gap={1}
padding={1}
borderStyle="round"
borderColor={theme.warning}
>
<Text bold color={theme.warning}>
New MCP Servers Detected
</Text>
<Text>
This project contains a .mcprc file with {serverNames.length} MCP
servers that require your approval.
</Text>
<MCPServerDialogCopy />
<Text>Please select the servers you want to enable:</Text>
<MultiSelect
options={serverNames.map(server => ({
label: server,
value: server,
}))}
defaultValue={serverNames}
onSubmit={onSubmit}
/>
</Box>
<Box marginLeft={3}>
<Text dimColor>
{exitState.pending ? (
<>Press {exitState.keyName} again to exit</>
) : (
<>Space to select · Enter to confirm · Esc to reject all</>
)}
</Text>
</Box>
</>
)
}

219
src/components/Message.tsx Normal file
View File

@@ -0,0 +1,219 @@
import { Box } from 'ink'
import * as React from 'react'
import type { AssistantMessage, Message, UserMessage } from '../query.js'
import type {
ContentBlock,
DocumentBlockParam,
ImageBlockParam,
TextBlockParam,
ThinkingBlockParam,
ToolResultBlockParam,
ToolUseBlockParam,
} from '@anthropic-ai/sdk/resources/index.mjs'
import { Tool } from '../Tool.js'
import { logError } from '../utils/log.js'
import { UserToolResultMessage } from './messages/UserToolResultMessage/UserToolResultMessage.js'
import { AssistantToolUseMessage } from './messages/AssistantToolUseMessage.js'
import { AssistantTextMessage } from './messages/AssistantTextMessage.js'
import { UserTextMessage } from './messages/UserTextMessage.js'
import { NormalizedMessage } from '../utils/messages.js'
import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js'
import { AssistantRedactedThinkingMessage } from './messages/AssistantRedactedThinkingMessage.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
type Props = {
message: UserMessage | AssistantMessage
messages: NormalizedMessage[]
// TODO: Find a way to remove this, and leave spacing to the consumer
addMargin: boolean
tools: Tool[]
verbose: boolean
debug: boolean
erroredToolUseIDs: Set<string>
inProgressToolUseIDs: Set<string>
unresolvedToolUseIDs: Set<string>
shouldAnimate: boolean
shouldShowDot: boolean
width?: number | string
}
export function Message({
message,
messages,
addMargin,
tools,
verbose,
debug,
erroredToolUseIDs,
inProgressToolUseIDs,
unresolvedToolUseIDs,
shouldAnimate,
shouldShowDot,
width,
}: Props): React.ReactNode {
// Assistant message
if (message.type === 'assistant') {
return (
<Box flexDirection="column" width="100%">
{message.message.content.map((_, index) => (
<AssistantMessage
key={index}
param={_}
costUSD={message.costUSD}
durationMs={message.durationMs}
addMargin={addMargin}
tools={tools}
debug={debug}
options={{ verbose }}
erroredToolUseIDs={erroredToolUseIDs}
inProgressToolUseIDs={inProgressToolUseIDs}
unresolvedToolUseIDs={unresolvedToolUseIDs}
shouldAnimate={shouldAnimate}
shouldShowDot={shouldShowDot}
width={width}
/>
))}
</Box>
)
}
// User message
// TODO: normalize upstream
const content =
typeof message.message.content === 'string'
? [{ type: 'text', text: message.message.content } as TextBlockParam]
: message.message.content
return (
<Box flexDirection="column" width="100%">
{content.map((_, index) => (
<UserMessage
key={index}
message={message}
messages={messages}
addMargin={addMargin}
tools={tools}
param={_ as TextBlockParam}
options={{ verbose }}
/>
))}
</Box>
)
}
function UserMessage({
message,
messages,
addMargin,
tools,
param,
options: { verbose },
}: {
message: UserMessage
messages: Message[]
addMargin: boolean
tools: Tool[]
param:
| TextBlockParam
| DocumentBlockParam
| ImageBlockParam
| ToolUseBlockParam
| ToolResultBlockParam
options: {
verbose: boolean
}
}): React.ReactNode {
const { columns } = useTerminalSize()
switch (param.type) {
case 'text':
return <UserTextMessage addMargin={addMargin} param={param} />
case 'tool_result':
return (
<UserToolResultMessage
param={param}
message={message}
messages={messages}
tools={tools}
verbose={verbose}
width={columns - 5}
/>
)
}
}
function AssistantMessage({
param,
costUSD,
durationMs,
addMargin,
tools,
debug,
options: { verbose },
erroredToolUseIDs,
inProgressToolUseIDs,
unresolvedToolUseIDs,
shouldAnimate,
shouldShowDot,
width,
}: {
param:
| ContentBlock
| TextBlockParam
| ImageBlockParam
| ThinkingBlockParam
| ToolUseBlockParam
| ToolResultBlockParam
costUSD: number
durationMs: number
addMargin: boolean
tools: Tool[]
debug: boolean
options: {
verbose: boolean
}
erroredToolUseIDs: Set<string>
inProgressToolUseIDs: Set<string>
unresolvedToolUseIDs: Set<string>
shouldAnimate: boolean
shouldShowDot: boolean
width?: number | string
}): React.ReactNode {
switch (param.type) {
case 'tool_use':
return (
<AssistantToolUseMessage
param={param}
costUSD={costUSD}
durationMs={durationMs}
addMargin={addMargin}
tools={tools}
debug={debug}
verbose={verbose}
erroredToolUseIDs={erroredToolUseIDs}
inProgressToolUseIDs={inProgressToolUseIDs}
unresolvedToolUseIDs={unresolvedToolUseIDs}
shouldAnimate={shouldAnimate}
shouldShowDot={shouldShowDot}
/>
)
case 'text':
return (
<AssistantTextMessage
param={param}
costUSD={costUSD}
durationMs={durationMs}
debug={debug}
addMargin={addMargin}
shouldShowDot={shouldShowDot}
verbose={verbose}
width={width}
/>
)
case 'redacted_thinking':
return <AssistantRedactedThinkingMessage addMargin={addMargin} />
case 'thinking':
return <AssistantThinkingMessage addMargin={addMargin} param={param} />
default:
logError(`Unable to render message type: ${param.type}`)
return null
}
}

View File

@@ -0,0 +1,15 @@
import { Box, Text } from 'ink'
import * as React from 'react'
type Props = {
children: React.ReactNode
}
export function MessageResponse({ children }: Props): React.ReactNode {
return (
<Box flexDirection="row" height={1} overflow="hidden">
<Text>{' '} &nbsp;</Text>
{children}
</Box>
)
}

View File

@@ -0,0 +1,211 @@
import { Box, Text, useInput } from 'ink'
import * as React from 'react'
import { useMemo, useState, useEffect } from 'react'
import figures from 'figures'
import { getTheme } from '../utils/theme.js'
import { Message as MessageComponent } from './Message.js'
import { randomUUID } from 'crypto'
import { type Tool } from '../Tool.js'
import {
createUserMessage,
isEmptyMessageText,
isNotEmptyMessage,
normalizeMessages,
} from '../utils/messages.js'
import { logEvent } from '../services/statsig.js'
import type { AssistantMessage, UserMessage } from '../query.js'
import { useExitOnCtrlCD } from '../hooks/useExitOnCtrlCD.js'
type Props = {
erroredToolUseIDs: Set<string>
messages: (UserMessage | AssistantMessage)[]
onSelect: (message: UserMessage) => void
onEscape: () => void
tools: Tool[]
unresolvedToolUseIDs: Set<string>
}
const MAX_VISIBLE_MESSAGES = 7
export function MessageSelector({
erroredToolUseIDs,
messages,
onSelect,
onEscape,
tools,
unresolvedToolUseIDs,
}: Props): React.ReactNode {
const currentUUID = useMemo(randomUUID, [])
// Log when selector is opened
useEffect(() => {
logEvent('tengu_message_selector_opened', {})
}, [])
function handleSelect(message: UserMessage) {
const indexFromEnd = messages.length - 1 - messages.indexOf(message)
logEvent('tengu_message_selector_selected', {
index_from_end: indexFromEnd.toString(),
message_type: message.type,
is_current_prompt: (message.uuid === currentUUID).toString(),
})
onSelect(message)
}
function handleEscape() {
logEvent('tengu_message_selector_cancelled', {})
onEscape()
}
// Add current prompt as a virtual message
const allItems = useMemo(
() => [
// Filter out tool results
...messages
.filter(
_ =>
!(
_.type === 'user' &&
Array.isArray(_.message.content) &&
_.message.content[0]?.type === 'tool_result'
),
)
// Filter out assistant messages, until we have a way to kick off the tool use loop from REPL
.filter(_ => _.type !== 'assistant'),
{ ...createUserMessage(''), uuid: currentUUID } as UserMessage,
],
[messages, currentUUID],
)
const [selectedIndex, setSelectedIndex] = useState(allItems.length - 1)
const exitState = useExitOnCtrlCD(() => process.exit(0))
useInput((input, key) => {
if (key.tab || key.escape) {
handleEscape()
return
}
if (key.return) {
handleSelect(allItems[selectedIndex]!)
return
}
if (key.upArrow) {
if (key.ctrl || key.shift || key.meta) {
// Jump to top with any modifier key
setSelectedIndex(0)
} else {
setSelectedIndex(prev => Math.max(0, prev - 1))
}
}
if (key.downArrow) {
if (key.ctrl || key.shift || key.meta) {
// Jump to bottom with any modifier key
setSelectedIndex(allItems.length - 1)
} else {
setSelectedIndex(prev => Math.min(allItems.length - 1, prev + 1))
}
}
// Handle number keys (1-9)
const num = Number(input)
if (!isNaN(num) && num >= 1 && num <= Math.min(9, allItems.length)) {
if (!allItems[num - 1]) {
return
}
handleSelect(allItems[num - 1]!)
}
})
const firstVisibleIndex = Math.max(
0,
Math.min(
selectedIndex - Math.floor(MAX_VISIBLE_MESSAGES / 2),
allItems.length - MAX_VISIBLE_MESSAGES,
),
)
const normalizedMessages = useMemo(
() => normalizeMessages(messages).filter(isNotEmptyMessage),
[messages],
)
return (
<>
<Box
flexDirection="column"
borderStyle="round"
borderColor={getTheme().secondaryBorder}
height={4 + Math.min(MAX_VISIBLE_MESSAGES, allItems.length) * 2}
paddingX={1}
marginTop={1}
>
<Box flexDirection="column" minHeight={2} marginBottom={1}>
<Text bold>Jump to a previous message</Text>
<Text dimColor>This will fork the conversation</Text>
</Box>
{allItems
.slice(firstVisibleIndex, firstVisibleIndex + MAX_VISIBLE_MESSAGES)
.map((msg, index) => {
const actualIndex = firstVisibleIndex + index
const isSelected = actualIndex === selectedIndex
const isCurrent = msg.uuid === currentUUID
return (
<Box key={msg.uuid} flexDirection="row" height={2} minHeight={2}>
<Box width={7}>
{isSelected ? (
<Text color="blue" bold>
{figures.pointer} {firstVisibleIndex + index + 1}{' '}
</Text>
) : (
<Text>
{' '}
{firstVisibleIndex + index + 1}{' '}
</Text>
)}
</Box>
<Box height={1} overflow="hidden" width={100}>
{isCurrent ? (
<Box width="100%">
<Text dimColor italic>
{'(current)'}
</Text>
</Box>
) : Array.isArray(msg.message.content) &&
msg.message.content[0]?.type === 'text' &&
isEmptyMessageText(msg.message.content[0].text) ? (
<Text dimColor italic>
(empty message)
</Text>
) : (
<MessageComponent
message={msg}
messages={normalizedMessages}
addMargin={false}
tools={tools}
verbose={false}
debug={false}
erroredToolUseIDs={erroredToolUseIDs}
inProgressToolUseIDs={new Set()}
unresolvedToolUseIDs={unresolvedToolUseIDs}
shouldAnimate={false}
shouldShowDot={false}
/>
)}
</Box>
</Box>
)
})}
</Box>
<Box marginLeft={3}>
<Text dimColor>
{exitState.pending ? (
<>Press {exitState.keyName} again to exit</>
) : (
<>/ to select · Enter to confirm · Tab/Esc to cancel</>
)}
</Text>
</Box>
</>
)
}

View File

@@ -0,0 +1,304 @@
import React, { useMemo, useState } from 'react'
import { PRODUCT_NAME } from '../constants/product.js'
import { Box, Newline, Text, useInput } from 'ink'
import {
getGlobalConfig,
saveGlobalConfig,
getCustomApiKeyStatus,
normalizeApiKeyForConfig,
DEFAULT_GLOBAL_CONFIG,
} from '../utils/config.js'
import { OrderedList } from '@inkjs/ui'
import { useExitOnCtrlCD } from '../hooks/useExitOnCtrlCD.js'
import { MIN_LOGO_WIDTH } from './Logo.js'
import { ConsoleOAuthFlow } from './ConsoleOAuthFlow.js'
import { ApproveApiKey } from './ApproveApiKey.js'
import { Select } from './CustomSelect/index.js'
import { StructuredDiff } from './StructuredDiff.js'
import { getTheme, ThemeNames } from '../utils/theme.js'
import { isAnthropicAuthEnabled } from '../utils/auth.js'
import Link from './Link.js'
import { clearTerminal } from '../utils/terminal.js'
import { PressEnterToContinue } from './PressEnterToContinue.js'
type StepId = 'theme' | 'oauth' | 'api-key' | 'usage' | 'security'
interface OnboardingStep {
id: StepId
component: React.ReactNode
}
type Props = {
onDone(): void
}
export function Onboarding({ onDone }: Props): React.ReactNode {
const [currentStepIndex, setCurrentStepIndex] = useState(0)
const config = getGlobalConfig()
const oauthEnabled = isAnthropicAuthEnabled()
const [selectedTheme, setSelectedTheme] = useState(
DEFAULT_GLOBAL_CONFIG.theme,
)
const theme = getTheme()
function goToNextStep() {
if (currentStepIndex < steps.length - 1) {
const nextIndex = currentStepIndex + 1
setCurrentStepIndex(nextIndex)
}
}
function handleThemeSelection(newTheme: string) {
saveGlobalConfig({
...config,
theme: newTheme as ThemeNames,
})
goToNextStep()
}
function handleThemePreview(newTheme: string) {
setSelectedTheme(newTheme as ThemeNames)
}
const exitState = useExitOnCtrlCD(() => process.exit(0))
useInput(async (_, key) => {
const currentStep = steps[currentStepIndex]
if (
key.return &&
currentStep &&
['usage', 'security'].includes(currentStep.id)
) {
if (currentStepIndex === steps.length - 1) {
onDone()
} else {
// HACK: for some reason there's now a jump here otherwise :(
if (currentStep.id === 'security') {
await clearTerminal()
}
goToNextStep()
}
}
})
// Define all onboarding steps
const themeStep = (
<Box flexDirection="column" gap={1} paddingLeft={1}>
<Text>Let&apos;s get started.</Text>
<Box flexDirection="column">
<Text bold>Choose the option that looks best when you select it:</Text>
<Text dimColor>To change this later, run /config</Text>
</Box>
<Select
options={[
{ label: 'Light text', value: 'dark' },
{ label: 'Dark text', value: 'light' },
{
label: 'Light text (colorblind-friendly)',
value: 'dark-daltonized',
},
{
label: 'Dark text (colorblind-friendly)',
value: 'light-daltonized',
},
]}
onFocus={handleThemePreview}
onChange={handleThemeSelection}
/>
<Box flexDirection="column">
<Box
paddingLeft={1}
marginRight={1}
borderStyle="round"
borderColor="gray"
flexDirection="column"
>
<StructuredDiff
patch={{
oldStart: 1,
newStart: 1,
oldLines: 3,
newLines: 3,
lines: [
'function greet() {',
'- console.log("Hello, World!");',
'+ console.log("Hello, Claude!");',
'}',
],
}}
dim={false}
width={40}
overrideTheme={selectedTheme}
/>
</Box>
</Box>
</Box>
)
const securityStep = (
<Box flexDirection="column" gap={1} paddingLeft={1}>
<Text bold>Security notes:</Text>
<Box flexDirection="column" width={70}>
<OrderedList>
<OrderedList.Item>
<Text>Claude Code is currently in research preview</Text>
<Text color={theme.secondaryText} wrap="wrap">
This beta version may have limitations or unexpected behaviors.
<Newline />
Run /bug at any time to report issues.
<Newline />
</Text>
</OrderedList.Item>
<OrderedList.Item>
<Text>Claude can make mistakes</Text>
<Text color={theme.secondaryText} wrap="wrap">
You should always review Claude&apos;s responses, especially when
<Newline />
running code.
<Newline />
</Text>
</OrderedList.Item>
<OrderedList.Item>
<Text>
Due to prompt injection risks, only use it with code you trust
</Text>
<Text color={theme.secondaryText} wrap="wrap">
For more details see:
<Newline />
<Link url="https://docs.anthropic.com/s/claude-code-security" />
</Text>
</OrderedList.Item>
</OrderedList>
</Box>
<PressEnterToContinue />
</Box>
)
const usageStep = (
<Box flexDirection="column" gap={1} paddingLeft={1}>
<Text bold>Using {PRODUCT_NAME} effectively:</Text>
<Box flexDirection="column" width={70}>
<OrderedList>
<OrderedList.Item>
<Text>
Start in your project directory
<Newline />
<Text color={theme.secondaryText}>
Files are automatically added to context when needed.
</Text>
<Newline />
</Text>
</OrderedList.Item>
<OrderedList.Item>
<Text>
Use {PRODUCT_NAME} as a development partner
<Newline />
<Text color={theme.secondaryText}>
Get help with file analysis, editing, bash commands,
<Newline />
and git history.
<Newline />
</Text>
</Text>
</OrderedList.Item>
<OrderedList.Item>
<Text>
Provide clear context
<Newline />
<Text color={theme.secondaryText}>
Be as specific as you would with another engineer. <Newline />
The better the context, the better the results. <Newline />
</Text>
</Text>
</OrderedList.Item>
</OrderedList>
<Box>
<Text>
For more details on {PRODUCT_NAME}, see:
<Newline />
<Link url={MACRO.README_URL} />
</Text>
</Box>
</Box>
<PressEnterToContinue />
</Box>
)
// 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: <ConsoleOAuthFlow onDone={goToNextStep} />,
})
}
// Add API key step if needed
if (apiKeyNeedingApproval) {
steps.push({
id: 'api-key',
component: (
<ApproveApiKey
customApiKeyTruncated={apiKeyNeedingApproval}
onDone={goToNextStep}
/>
),
})
}
// Add security step
steps.push({ id: 'security', component: securityStep })
// Add usage step as the last content step
steps.push({ id: 'usage', component: usageStep })
return (
<Box flexDirection="column" gap={1}>
{/* 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' && <WelcomeBox />}
<Box flexDirection="column" padding={0} gap={0}>
{steps[currentStepIndex]?.component}
{exitState.pending && (
<Box padding={1}>
<Text dimColor>Press {exitState.keyName} again to exit</Text>
</Box>
)}
</Box>
</Box>
)
}
export function WelcomeBox(): React.ReactNode {
const theme = getTheme()
return (
<Box
borderColor={theme.claude}
borderStyle="round"
paddingX={1}
width={MIN_LOGO_WIDTH}
>
<Text>
<Text color={theme.claude}></Text> Welcome to{' '}
<Text bold>{PRODUCT_NAME}</Text> research preview!
</Text>
</Box>
)
}

View File

@@ -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 (
<Text color={getTheme().permission}>
Press <Text bold>Enter</Text> to continue
</Text>
)
}

View File

@@ -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<void>
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<string | null>(null)
const [placeholder, setPlaceholder] = useState('')
const [cursorOffset, setCursorOffset] = useState<number>(input.length)
const [pastedText, setPastedText] = useState<string | null>(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 (
<Box flexDirection="column">
<Box
alignItems="flex-start"
justifyContent="flex-start"
borderColor={mode === 'bash' ? theme.bashBorder : theme.secondaryBorder}
borderDimColor
borderStyle="round"
marginTop={1}
width="100%"
>
<Box
alignItems="flex-start"
alignSelf="flex-start"
flexWrap="nowrap"
justifyContent="flex-start"
width={3}
>
{mode === 'bash' ? (
<Text color={theme.bashBorder}>&nbsp;!&nbsp;</Text>
) : (
<Text color={isLoading ? theme.secondaryText : undefined}>
&nbsp;&gt;&nbsp;
</Text>
)}
</Box>
<Box paddingRight={1}>
<TextInput
multiline
onSubmit={onSubmit}
onChange={onChange}
value={input}
onHistoryUp={handleHistoryUp}
onHistoryDown={handleHistoryDown}
onHistoryReset={() => 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}
/>
</Box>
</Box>
{suggestions.length === 0 && (
<Box
flexDirection="row"
justifyContent="space-between"
paddingX={2}
paddingY={0}
>
<Box justifyContent="flex-start" gap={1}>
{exitMessage.show ? (
<Text dimColor>Press {exitMessage.key} again to exit</Text>
) : message.show ? (
<Text dimColor>{message.text}</Text>
) : (
<>
<Text
color={mode === 'bash' ? theme.bashBorder : undefined}
dimColor={mode !== 'bash'}
>
! for bash mode
</Text>
<Text dimColor>· / for commands · esc to undo</Text>
</>
)}
</Box>
<SentryErrorBoundary>
<Box justifyContent="flex-end" gap={1}>
{!autoUpdaterResult &&
!isAutoUpdating &&
!debug &&
tokenUsage < WARNING_THRESHOLD && (
<Text dimColor>
{terminalSetup.isEnabled &&
isShiftEnterKeyBindingInstalled()
? 'shift + ⏎ for newline'
: '\\⏎ for newline'}
</Text>
)}
{debug && (
<Text dimColor>
{`${countTokens(messages)} tokens (${
Math.round(
(10000 * (countCachedTokens(messages) || 1)) /
(countTokens(messages) || 1),
) / 100
}% cached)`}
</Text>
)}
<TokenWarning tokenUsage={tokenUsage} />
<AutoUpdater
debug={debug}
onAutoUpdaterResult={onAutoUpdaterResult}
autoUpdaterResult={autoUpdaterResult}
isUpdating={isAutoUpdating}
onChangeIsUpdating={setIsAutoUpdating}
/>
</Box>
</SentryErrorBoundary>
</Box>
)}
{suggestions.length > 0 && (
<Box
flexDirection="row"
justifyContent="space-between"
paddingX={2}
paddingY={0}
>
<Box flexDirection="column">
{suggestions.map((suggestion, index) => {
const command = commands.find(
cmd => cmd.userFacingName() === suggestion.replace('/', ''),
)
return (
<Box
key={suggestion}
flexDirection={columns < 80 ? 'column' : 'row'}
>
<Box width={columns < 80 ? undefined : commandWidth}>
<Text
color={
index === selectedSuggestion
? theme.suggestion
: undefined
}
dimColor={index !== selectedSuggestion}
>
/{suggestion}
{command?.aliases && command.aliases.length > 0 && (
<Text dimColor> ({command.aliases.join(', ')})</Text>
)}
</Text>
</Box>
{command && (
<Box
width={columns - (columns < 80 ? 4 : commandWidth + 4)}
paddingLeft={columns < 80 ? 4 : 0}
>
<Text
color={
index === selectedSuggestion
? theme.suggestion
: undefined
}
dimColor={index !== selectedSuggestion}
wrap="wrap"
>
<Text dimColor={index !== selectedSuggestion}>
{command.description}
{command.type === 'prompt' && command.argNames?.length
? ` (arguments: ${command.argNames.join(', ')})`
: null}
</Text>
</Text>
</Box>
)}
</Box>
)
})}
</Box>
<SentryErrorBoundary>
<Box justifyContent="flex-end" gap={1}>
<TokenWarning tokenUsage={countTokens(messages)} />
<AutoUpdater
debug={debug}
onAutoUpdaterResult={onAutoUpdaterResult}
autoUpdaterResult={autoUpdaterResult}
isUpdating={isAutoUpdating}
onChangeIsUpdating={setIsAutoUpdating}
/>
</Box>
</SentryErrorBoundary>
</Box>
)}
</Box>
)
}
export default memo(PromptInput)
function exit(): never {
setTerminalTitle('')
process.exit(0)
}

View File

@@ -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<Props, State> {
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
}
}

126
src/components/Spinner.tsx Normal file
View File

@@ -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 (
<Box flexDirection="row" marginTop={1}>
<Box flexWrap="nowrap" height={1} width={2}>
<Text color={getTheme().claude}>{frames[frame]}</Text>
</Box>
<Text color={getTheme().claude}>{message.current} </Text>
<Text color={getTheme().secondaryText}>
({elapsedTime}s · <Text bold>esc</Text> to interrupt)
</Text>
</Box>
)
}
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 (
<Box flexWrap="nowrap" height={1} width={2}>
<Text color={getTheme().claude}>{frames[frame]}</Text>
</Box>
)
}

View File

@@ -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<Partial<FormData>>({})
const [currentField, setCurrentField] = React.useState<keyof FormData>('name')
const [inputValue, setInputValue] = React.useState('')
const [cursorOffset, setCursorOffset] = React.useState(0)
const [error, setError] = React.useState<ValidationError | null>(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 = () => (
<>
<Box flexDirection="column" alignItems="center" justifyContent="center">
<Text>{classifiedHeaderText}</Text>
<Text bold color={theme.claude}>
{headerText}
</Text>
</Box>
{!showingSummary && (
<Box justifyContent="center">
<AnimatedClaudeAsterisk
size={getAsteriskSize()}
cycles={getAsteriskSize() === 'large' ? 4 : undefined}
/>
</Box>
)}
</>
)
// Helper function to render the footer section
const renderFooter = () => (
<Box marginLeft={1}>
{showingNonUsMessage || showingSummary ? (
<Text color={theme.suggestion} bold>
Press Enter to return to base
</Text>
) : (
<Text color={theme.secondaryText}>
{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
</>
)}
</Text>
)}
</Box>
)
// Helper function to render the main content based on current state
const renderContent = () => {
if (showingSummary) {
return (
<>
<Box>
<Text color={theme.suggestion} bold>
Please review your shipping information:
</Text>
</Box>
<Box flexDirection="column">
{fields
.filter(f => f.key !== 'usLocation')
.map(field => (
<Box key={field.key} marginLeft={3}>
<Text>
<Text bold color={theme.text}>
{field.label}:
</Text>{' '}
<Text
color={
!formState[field.key] ? theme.secondaryText : theme.text
}
>
{formState[field.key] || '(empty)'}
</Text>
</Text>
</Box>
))}
</Box>
{/* Google Form URL with improved instructions */}
<Box marginTop={1} marginBottom={1} flexDirection="column">
<Box>
<Text color={theme.text}>Submit your sticker request:</Text>
</Box>
<Box marginTop={1}>
<Link url={googleFormURL}>
<Text color={theme.success} underline>
Click here to open Google Form
</Text>
</Link>
</Box>
<Box marginTop={1}>
<Text color={theme.secondaryText} italic>
(You can still edit your info on the form)
</Text>
</Box>
</Box>
</>
)
} else if (showingNonUsMessage) {
return (
<>
<Box marginY={1}>
<Text color={theme.error} bold>
Mission Not Available
</Text>
</Box>
<Box flexDirection="column" marginY={1}>
<Text color={theme.text}>
We&apos;re sorry, but the Claude sticker deployment mission is
only available within the United States.
</Text>
<Box marginTop={1}>
<Text color={theme.text}>
Future missions may expand to other territories. Stay tuned for
updates.
</Text>
</Box>
</Box>
</>
)
} else {
return (
<>
<Box flexDirection="column">
<Text color={theme.text}>
Please provide your coordinates for the sticker deployment
mission.
</Text>
<Text color={theme.secondaryText}>
Currently only shipping within the United States.
</Text>
</Box>
<Box flexDirection="column">
<Box flexDirection="row" marginLeft={2}>
{fields.map((f, i) => (
<React.Fragment key={f.key}>
<Text
color={
f.key === currentField
? theme.suggestion
: theme.secondaryText
}
>
{f.key === currentField ? (
`[${f.label}]`
) : formState[f.key] ? (
<Text color={theme.secondaryText}></Text>
) : (
'○'
)}
</Text>
{i < fields.length - 1 && <Text> </Text>}
</React.Fragment>
))}
</Box>
<Box marginLeft={2}>
<Text color={theme.secondaryText}>
Field {fields.findIndex(f => f.key === currentField) + 1} of{' '}
{fields.length}
</Text>
</Box>
</Box>
<Box flexDirection="column" marginX={2}>
{currentField === 'usLocation' ? (
// Special Yes/No Buttons for US Location
<Box flexDirection="row">
<Text
color={
selectedYesNo === 'yes'
? theme.success
: theme.secondaryText
}
bold
>
{selectedYesNo === 'yes' ? '●' : '○'} YES
</Text>
<Text> </Text>
<Text
color={
selectedYesNo === 'no' ? theme.error : theme.secondaryText
}
bold
>
{selectedYesNo === 'no' ? '●' : '○'} NO
</Text>
</Box>
) : (
// Regular TextInput for other fields
<TextInput
value={inputValue}
onChange={setInputValue}
onSubmit={handleSubmit}
placeholder={currentFieldDef.label}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
columns={40}
/>
)}
{error && (
<Box marginTop={1}>
<Text color={theme.error} bold>
{error.message}
</Text>
</Box>
)}
</Box>
</>
)
}
}
// Main render with consistent structure
return (
<Box flexDirection="column" paddingLeft={1}>
<Box
borderColor={theme.claude}
borderStyle="round"
flexDirection="column"
gap={1}
padding={1}
paddingLeft={2}
width={100}
>
{renderHeader()}
{renderContent()}
</Box>
{renderFooter()}
</Box>
)
}

View File

@@ -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) => <Box key={i}>{_}</Box>)
}
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 (
<Text key={key}>
<LineNumber
i={lineIndex === 0 ? i : undefined}
width={maxWidth}
/>
<Text
color={overrideTheme ? theme.text : undefined}
backgroundColor={
dim ? theme.diff.addedDimmed : theme.diff.added
}
dimColor={dim}
>
{line}
</Text>
</Text>
)
case 'remove':
return (
<Text key={key}>
<LineNumber
i={lineIndex === 0 ? i : undefined}
width={maxWidth}
/>
<Text
color={overrideTheme ? theme.text : undefined}
backgroundColor={
dim ? theme.diff.removedDimmed : theme.diff.removed
}
dimColor={dim}
>
{line}
</Text>
</Text>
)
case 'nochange':
return (
<Text key={key}>
<LineNumber
i={lineIndex === 0 ? i : undefined}
width={maxWidth}
/>
<Text
color={overrideTheme ? theme.text : undefined}
dimColor={dim}
>
{line}
</Text>
</Text>
)
}
})
})
}
function LineNumber({
i,
width,
}: {
i: number | undefined
width: number
}): React.ReactNode {
return (
<Text color={getTheme().secondaryText}>
{i !== undefined ? i.toString().padStart(width) : ' '.repeat(width)}{' '}
</Text>
)
}
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
}

View File

@@ -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<typeof setTimeout> | null
}>({ chunks: [], timeoutId: null })
const resetPasteTimeout = (
currentTimeoutId: ReturnType<typeof setTimeout> | 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 (
<Text wrap="truncate-end" dimColor={isDimmed}>
{showPlaceholder ? renderedPlaceholder : renderedValue}
</Text>
)
}

View File

@@ -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 (
<Box flexDirection="row">
<Text color={isError ? theme.error : theme.warning}>
Context low (
{Math.max(0, 100 - Math.round((tokenUsage / MAX_TOKENS) * 100))}%
remaining) &middot; Run /compact to compact & continue
</Text>
</Box>
)
}

View File

@@ -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 (
<Box minWidth={2}>
<Text color={color}>{isVisible ? BLACK_CIRCLE : ' '}</Text>
</Box>
)
}

View File

@@ -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 (
<>
<Box
flexDirection="column"
gap={1}
padding={1}
borderStyle="round"
borderColor={theme.warning}
>
<Text bold color={theme.warning}>
Do you trust the files in this folder?
</Text>
<Text bold>{process.cwd()}</Text>
<Box flexDirection="column" gap={1}>
<Text>
{PRODUCT_NAME} may read files in this folder. Reading untrusted
files may lead to {PRODUCT_NAME} to behave in an unexpected ways.
</Text>
<Text>
With your permission {PRODUCT_NAME} may execute files in this
folder. Executing untrusted code is unsafe.
</Text>
<Link url="https://docs.anthropic.com/s/claude-code-security" />
</Box>
<Select
options={[
{ label: 'Yes, proceed', value: 'yes' },
{ label: 'No, exit', value: 'no' },
]}
onChange={value => onChange(value as 'yes' | 'no')}
/>
</Box>
<Box marginLeft={3}>
<Text dimColor>
{exitState.pending ? (
<>Press {exitState.keyName} again to exit</>
) : (
<>Enter to confirm · Esc to exit</>
)}
</Text>
</Box>
</>
)
}

View File

@@ -0,0 +1,60 @@
import { default as React, useCallback } from 'react'
import { useNotifyAfterTimeout } from '../../hooks/useNotifyAfterTimeout.js'
import { AssistantMessage, BinaryFeedbackResult } from '../../query.js'
import type { Tool } from '../../Tool.js'
import type { NormalizedMessage } from '../../utils/messages.js'
import { BinaryFeedbackView } from './BinaryFeedbackView.js'
import {
type BinaryFeedbackChoose,
getBinaryFeedbackResultForChoice,
logBinaryFeedbackEvent,
} from './utils.js'
type Props = {
m1: AssistantMessage
m2: AssistantMessage
resolve: (result: BinaryFeedbackResult) => void
debug: boolean
erroredToolUseIDs: Set<string>
inProgressToolUseIDs: Set<string>
normalizedMessages: NormalizedMessage[]
tools: Tool[]
unresolvedToolUseIDs: Set<string>
verbose: boolean
}
export function BinaryFeedback({
m1,
m2,
resolve,
debug,
erroredToolUseIDs,
inProgressToolUseIDs,
normalizedMessages,
tools,
unresolvedToolUseIDs,
verbose,
}: Props): React.ReactNode {
const onChoose = useCallback<BinaryFeedbackChoose>(
choice => {
logBinaryFeedbackEvent(m1, m2, choice)
resolve(getBinaryFeedbackResultForChoice(m1, m2, choice))
},
[m1, m2, resolve],
)
useNotifyAfterTimeout('Claude needs your input on a response comparison')
return (
<BinaryFeedbackView
debug={debug}
erroredToolUseIDs={erroredToolUseIDs}
inProgressToolUseIDs={inProgressToolUseIDs}
m1={m1}
m2={m2}
normalizedMessages={normalizedMessages}
tools={tools}
unresolvedToolUseIDs={unresolvedToolUseIDs}
verbose={verbose}
onChoose={onChoose}
/>
)
}

View File

@@ -0,0 +1,111 @@
import { FileEditTool } from '../../tools/FileEditTool/FileEditTool.js'
import { FileEditToolDiff } from '../permissions/FileEditPermissionRequest/FileEditToolDiff.js'
import { Message } from '../Message.js'
import {
normalizeMessages,
type NormalizedMessage,
} from '../../utils/messages.js'
import type { Tool } from '../../Tool.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { FileWriteTool } from '../../tools/FileWriteTool/FileWriteTool.js'
import { FileWriteToolDiff } from '../permissions/FileWritePermissionRequest/FileWriteToolDiff.js'
import type { AssistantMessage } from '../../query.js'
import * as React from 'react'
import { Box } from 'ink'
type Props = {
debug: boolean
erroredToolUseIDs: Set<string>
inProgressToolUseIDs: Set<string>
message: AssistantMessage
normalizedMessages: NormalizedMessage[]
tools: Tool[]
unresolvedToolUseIDs: Set<string>
verbose: boolean
}
export function BinaryFeedbackOption({
debug,
erroredToolUseIDs,
inProgressToolUseIDs,
message,
normalizedMessages,
tools,
unresolvedToolUseIDs,
verbose,
}: Props): React.ReactNode {
const { columns } = useTerminalSize()
return normalizeMessages([message])
.filter(_ => _.type !== 'progress')
.map((_, index) => (
<Box flexDirection="column" key={index}>
<Message
addMargin={false}
erroredToolUseIDs={erroredToolUseIDs}
debug={debug}
inProgressToolUseIDs={inProgressToolUseIDs}
message={_}
messages={normalizedMessages}
shouldAnimate={false}
shouldShowDot={true}
tools={tools}
unresolvedToolUseIDs={unresolvedToolUseIDs}
verbose={verbose}
width={columns / 2 - 6}
/>
<AdditionalContext message={_} verbose={verbose} />
</Box>
))
}
function AdditionalContext({
message,
verbose,
}: {
message: NormalizedMessage
verbose: boolean
}) {
const { columns } = useTerminalSize()
if (message.type !== 'assistant') {
return null
}
const content = message.message.content[0]!
switch (content.type) {
case 'tool_use':
switch (content.name) {
case FileEditTool.name: {
const input = FileEditTool.inputSchema.safeParse(content.input)
if (!input.success) {
return null
}
return (
<FileEditToolDiff
file_path={input.data.file_path}
new_string={input.data.new_string}
old_string={input.data.old_string}
verbose={verbose}
width={columns / 2 - 12}
/>
)
}
case FileWriteTool.name: {
const input = FileWriteTool.inputSchema.safeParse(content.input)
if (!input.success) {
return null
}
return (
<FileWriteToolDiff
file_path={input.data.file_path}
content={input.data.content}
verbose={verbose}
width={columns / 2 - 12}
/>
)
}
default:
return null
}
default:
return null
}
}

View File

@@ -0,0 +1,171 @@
import { Option, SelectProps } from '@inkjs/ui'
import chalk from 'chalk'
import { Box, Text, useInput } from 'ink'
import Link from 'ink-link'
import React, { useState } from 'react'
import { getTheme } from '../../utils/theme.js'
import { Select } from '../CustomSelect/index.js'
import type { Tool } from '../../Tool.js'
import type { NormalizedMessage } from '../../utils/messages.js'
import { BinaryFeedbackOption } from './BinaryFeedbackOption.js'
import type { AssistantMessage } from '../../query.js'
import type { BinaryFeedbackChoose } from './utils.js'
import { useExitOnCtrlCD } from '../../hooks/useExitOnCtrlCD.js'
import { BinaryFeedbackChoice } from './utils.js'
const HELP_URL = 'https://go/cli-feedback'
type BinaryFeedbackOption = Option & { value: BinaryFeedbackChoice }
// Make options a function to avoid early theme access during module initialization
export function getOptions(): BinaryFeedbackOption[] {
return [
{
// This option combines the follow user intents:
// - The two options look about equally good to me
// - I don't feel confident enough to choose
// - I don't want to choose right now
label: 'Choose for me',
value: 'no-preference',
},
{
label: 'Left option looks better',
value: 'prefer-left',
},
{
label: 'Right option looks better',
value: 'prefer-right',
},
{
label: `Neither, and tell Claude what to do differently (${chalk.bold.hex(getTheme().warning)('esc')})`,
value: 'neither',
},
]
}
type Props = {
m1: AssistantMessage
m2: AssistantMessage
onChoose?: BinaryFeedbackChoose
debug: boolean
erroredToolUseIDs: Set<string>
inProgressToolUseIDs: Set<string>
normalizedMessages: NormalizedMessage[]
tools: Tool[]
unresolvedToolUseIDs: Set<string>
verbose: boolean
}
export function BinaryFeedbackView({
m1,
m2,
onChoose,
debug,
erroredToolUseIDs,
inProgressToolUseIDs,
normalizedMessages,
tools,
unresolvedToolUseIDs,
verbose,
}: Props) {
const theme = getTheme()
const [focused, setFocus] = useState('no-preference')
const [focusValue, setFocusValue] = useState<string | undefined>(undefined)
const exitState = useExitOnCtrlCD(() => process.exit(1))
useInput((_input, key) => {
if (key.leftArrow) {
setFocusValue('prefer-left')
} else if (key.rightArrow) {
setFocusValue('prefer-right')
} else if (key.escape) {
onChoose?.('neither')
}
})
return (
<>
<Box
flexDirection="column"
height="100%"
width="100%"
borderStyle="round"
borderColor={theme.permission}
>
<Box width="100%" justifyContent="space-between" paddingX={1}>
<Text bold color={theme.permission}>
[ANT-ONLY] Help train Claude
</Text>
<Text>
<Link url={HELP_URL}>[?]</Link>
</Text>
</Box>
<Box flexDirection="row" width="100%" flexGrow={1} paddingTop={1}>
<Box
flexDirection="column"
flexGrow={1}
flexBasis={1}
gap={1}
borderStyle={focused === 'prefer-left' ? 'bold' : 'single'}
borderColor={
focused === 'prefer-left' ? theme.success : theme.secondaryBorder
}
marginRight={1}
padding={1}
>
<BinaryFeedbackOption
erroredToolUseIDs={erroredToolUseIDs}
debug={debug}
inProgressToolUseIDs={inProgressToolUseIDs}
message={m1}
normalizedMessages={normalizedMessages}
tools={tools}
unresolvedToolUseIDs={unresolvedToolUseIDs}
verbose={verbose}
/>
</Box>
<Box
flexDirection="column"
flexGrow={1}
flexBasis={1}
gap={1}
borderStyle={focused === 'prefer-right' ? 'bold' : 'single'}
borderColor={
focused === 'prefer-right' ? theme.success : theme.secondaryBorder
}
marginLeft={1}
padding={1}
>
<BinaryFeedbackOption
erroredToolUseIDs={erroredToolUseIDs}
debug={debug}
inProgressToolUseIDs={inProgressToolUseIDs}
message={m2}
normalizedMessages={normalizedMessages}
tools={tools}
unresolvedToolUseIDs={unresolvedToolUseIDs}
verbose={verbose}
/>
</Box>
</Box>
<Box flexDirection="column" paddingTop={1} paddingX={1}>
<Text>How do you want to proceed?</Text>
<Select
options={getOptions()}
onFocus={setFocus}
focusValue={focusValue}
onChange={onChoose as SelectProps['onChange']}
/>
</Box>
</Box>
{exitState.pending ? (
<Box marginLeft={3}>
<Text dimColor>Press {exitState.keyName} again to exit</Text>
</Box>
) : (
// Render a blank line so that the UI doesn't reflow when the exit message is shown
<Text> </Text>
)}
</>
)
}

View File

@@ -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<BinaryFeedbackConfig> {
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<void> {
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<void> {
logEvent('tengu_binary_feedback_sampling_decision', {
decision: decision.toString(),
reason,
})
}
export async function logBinaryFeedbackDisplayDecision(
decision: boolean,
m1: AssistantMessage,
m2: AssistantMessage,
reason?: string,
): Promise<void> {
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<boolean> {
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 }
}
}

View File

@@ -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 (
<BashToolResultMessage
content={{ stdout, stdoutLines, stderr, stderrLines }}
verbose={!!verbose}
/>
)
}

View File

@@ -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 = [<Text key="0">(No output)</Text>]
}
return [
<Box key="0" gap={1}>
<Box>
<Text color={theme.secondaryText}>{' '} </Text>
</Box>
{insides.map((_, index) => (
<Box key={index} flexDirection="column">
{_}
</Box>
))}
</Box>,
]
}
function format(content: string | undefined, color: string): React.ReactNode {
if (!content) {
return null
}
return <Text color={color}>{content}</Text>
}

View File

@@ -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 (
<Box marginTop={addMargin ? 1 : 0}>
<Text color={getTheme().secondaryText} italic>
Thinking
</Text>
</Box>
)
}

View File

@@ -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('<bash-stdout') || text.startsWith('<bash-stderr')) {
return <AssistantBashOutputMessage content={text} verbose={verbose} />
}
// Show command output
if (
text.startsWith('<local-command-stdout') ||
text.startsWith('<local-command-stderr')
) {
return <AssistantLocalCommandOutputMessage content={text} />
}
if (text.startsWith(API_ERROR_MESSAGE_PREFIX)) {
return (
<Text>
&nbsp;&nbsp; &nbsp;
<Text color={getTheme().error}>
{text === API_ERROR_MESSAGE_PREFIX
? `${API_ERROR_MESSAGE_PREFIX}: Please wait a moment and try again.`
: text}
</Text>
</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 (
<Text>
&nbsp;&nbsp; &nbsp;
<Text color={getTheme().error}>Interrupted by user</Text>
</Text>
)
case PROMPT_TOO_LONG_ERROR_MESSAGE:
return (
<Text>
&nbsp;&nbsp; &nbsp;
<Text color={getTheme().error}>
Context low &middot; Run /compact to compact & continue
</Text>
</Text>
)
case CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE:
return (
<Text>
&nbsp;&nbsp; &nbsp;
<Text color={getTheme().error}>
Credit balance too low &middot; Add funds:
https://console.anthropic.com/settings/billing
</Text>
</Text>
)
case INVALID_API_KEY_ERROR_MESSAGE:
return (
<Text>
&nbsp;&nbsp; &nbsp;
<Text color={getTheme().error}>{INVALID_API_KEY_ERROR_MESSAGE}</Text>
</Text>
)
default:
return (
<Box
alignItems="flex-start"
flexDirection="row"
justifyContent="space-between"
marginTop={addMargin ? 1 : 0}
width="100%"
>
<Box flexDirection="row">
{shouldShowDot && (
<Box minWidth={2}>
<Text color={getTheme().text}>{BLACK_CIRCLE}</Text>
</Box>
)}
<Box flexDirection="column" width={columns - 6}>
<Text>{applyMarkdown(text)}</Text>
</Box>
</Box>
<Cost costUSD={costUSD} durationMs={durationMs} debug={debug} />
</Box>
)
}
}

View File

@@ -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 (
<Box
flexDirection="column"
gap={1}
marginTop={addMargin ? 1 : 0}
width="100%"
>
<Text color={getTheme().secondaryText} italic>
Thinking
</Text>
<Box paddingLeft={2}>
<Text color={getTheme().secondaryText} italic>
{applyMarkdown(thinking)}
</Text>
</Box>
</Box>
)
}

View File

@@ -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<string>
inProgressToolUseIDs: Set<string>
unresolvedToolUseIDs: Set<string>
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 (
<AssistantThinkingMessage
param={{ thinking: thought, signature: '', type: 'thinking' }}
addMargin={addMargin}
/>
)
}
const userFacingToolName = tool.userFacingName(param.input as never)
return (
<Box
flexDirection="row"
justifyContent="space-between"
marginTop={addMargin ? 1 : 0}
width="100%"
>
<Box>
<Box
flexWrap="nowrap"
minWidth={userFacingToolName.length + (shouldShowDot ? 2 : 0)}
>
{shouldShowDot &&
(isQueued ? (
<Box minWidth={2}>
<Text color={color}>{BLACK_CIRCLE}</Text>
</Box>
) : (
<ToolUseLoader
shouldAnimate={shouldAnimate}
isUnresolved={unresolvedToolUseIDs.has(param.id)}
isError={erroredToolUseIDs.has(param.id)}
/>
))}
<Text color={color} bold={!isQueued}>
{userFacingToolName}
</Text>
</Box>
<Box flexWrap="nowrap">
{Object.keys(param.input as { [key: string]: unknown }).length >
0 && (
<Text color={color}>
(
{tool.renderToolUseMessage(param.input as never, {
verbose,
})}
)
</Text>
)}
<Text color={color}></Text>
</Box>
</Box>
<Cost costUSD={costUSD} durationMs={durationMs} debug={debug} />
</Box>
)
}

View File

@@ -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 (
<Box flexDirection="column" marginTop={addMargin ? 1 : 0} width="100%">
<Box>
<Text color={getTheme().bashBorder}>!</Text>
<Text color={getTheme().secondaryText}> {input}</Text>
</Box>
</Box>
)
}

View File

@@ -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 (
<Box flexDirection="column" marginTop={addMargin ? 1 : 0} width="100%">
<Text color={theme.secondaryText}>
&gt; /{commandMessage} {args}
</Text>
</Box>
)
}

View File

@@ -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 (
<Box flexDirection="row" marginTop={addMargin ? 1 : 0} width="100%">
<Box minWidth={2} width={2}>
<Text color={getTheme().secondaryText}>&gt;</Text>
</Box>
<Box flexDirection="column" width={columns - 4}>
<Text color={getTheme().secondaryText} wrap="wrap">
{text}
</Text>
</Box>
</Box>
)
}

View File

@@ -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('<bash-input>')) {
return <UserBashInputMessage addMargin={addMargin} param={param} />
}
// Slash commands/
if (
param.text.includes('<command-name>') ||
param.text.includes('<command-message>')
) {
return <UserCommandMessage addMargin={addMargin} param={param} />
}
// User prompts>
return <UserPromptMessage addMargin={addMargin} param={param} />
}

View File

@@ -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 (
<Text>
&nbsp;&nbsp; &nbsp;
<Text color={getTheme().error}>Interrupted by user</Text>
</Text>
)
}

View File

@@ -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 (
<Box flexDirection="row" width="100%">
<Text>&nbsp;&nbsp; &nbsp;</Text>
<Box flexDirection="column">
<Text color={getTheme().error}>
{verbose
? error
: error.split('\n').slice(0, MAX_RENDERED_LINES).join('\n') || ''}
</Text>
{!verbose && error.split('\n').length > MAX_RENDERED_LINES && (
<Text color={getTheme().secondaryText}>
... (+{error.split('\n').length - MAX_RENDERED_LINES} lines)
</Text>
)}
</Box>
</Box>
)
}

View File

@@ -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 <FallbackToolUseRejectedMessage />
}

View File

@@ -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 <UserToolCanceledMessage />
}
if (param.content === REJECT_MESSAGE) {
return (
<UserToolRejectMessage
toolUseID={param.tool_use_id}
tools={tools}
messages={messages}
verbose={verbose}
/>
)
}
if (param.is_error) {
return <UserToolErrorMessage param={param} verbose={verbose} />
}
return (
<UserToolSuccessMessage
param={param}
message={message}
messages={messages}
tools={tools}
verbose={verbose}
width={width}
/>
)
}

View File

@@ -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
<Box flexDirection="column" width={width}>
{tool.renderToolResultMessage?.(message.toolUseResult!.data as never, {
verbose,
})}
</Box>
)
}

View File

@@ -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])
}

View File

@@ -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<UnaryEvent>(
() => ({ completion_type: 'tool_use_single', language_name: 'none' }),
[],
)
usePermissionRequestLogging(toolUseConfirm, unaryEvent)
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.permission}
marginTop={1}
paddingLeft={1}
paddingRight={1}
paddingBottom={1}
>
<PermissionRequestTitle
title="Bash command"
riskScore={toolUseConfirm.riskScore}
/>
<Box flexDirection="column" paddingX={2} paddingY={1}>
<Text>{BashTool.renderToolUseMessage({ command })}</Text>
<Text color={theme.secondaryText}>{toolUseConfirm.description}</Text>
</Box>
<Box flexDirection="column">
<Text>Do you want to proceed?</Text>
<Select
options={toolUseOptions({ toolUseConfirm, command })}
onChange={newValue => {
switch (newValue) {
case 'yes':
logUnaryPermissionEvent(
'tool_use_single',
toolUseConfirm,
'accept',
)
toolUseConfirm.onAllow('temporary')
onDone()
break
case 'yes-dont-ask-again-prefix': {
const prefix = toolUseConfirmGetPrefix(toolUseConfirm)
if (prefix !== null) {
logUnaryPermissionEvent(
'tool_use_single',
toolUseConfirm,
'accept',
)
savePermission(
toolUseConfirm.tool,
toolUseConfirm.input,
prefix,
).then(() => {
toolUseConfirm.onAllow('permanent')
onDone()
})
}
break
}
case 'yes-dont-ask-again-full':
logUnaryPermissionEvent(
'tool_use_single',
toolUseConfirm,
'accept',
)
savePermission(
toolUseConfirm.tool,
toolUseConfirm.input,
null, // Save without prefix
).then(() => {
toolUseConfirm.onAllow('permanent')
onDone()
})
break
case 'no':
logUnaryPermissionEvent(
'tool_use_single',
toolUseConfirm,
'reject',
)
toolUseConfirm.onReject()
onDone()
break
}
}}
/>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,155 @@
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 { getCwd } from '../../utils/state.js'
import { savePermission } from '../../permissions.js'
import {
type ToolUseConfirm,
toolUseConfirmGetPrefix,
} from './PermissionRequest.js'
import chalk from 'chalk'
import {
UnaryEvent,
usePermissionRequestLogging,
} from '../../hooks/usePermissionRequestLogging.js'
type Props = {
toolUseConfirm: ToolUseConfirm
onDone(): void
verbose: boolean
}
export function FallbackPermissionRequest({
toolUseConfirm,
onDone,
verbose,
}: Props): React.ReactNode {
const theme = getTheme()
// TODO: Avoid these special cases
const originalUserFacingName = toolUseConfirm.tool.userFacingName(
toolUseConfirm.input as never,
)
const userFacingName = originalUserFacingName.endsWith(' (MCP)')
? originalUserFacingName.slice(0, -6)
: originalUserFacingName
const unaryEvent = useMemo<UnaryEvent>(
() => ({
completion_type: 'tool_use_single',
language_name: 'none',
}),
[],
)
usePermissionRequestLogging(toolUseConfirm, unaryEvent)
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={textColorForRiskScore(toolUseConfirm.riskScore)}
marginTop={1}
paddingLeft={1}
paddingRight={1}
paddingBottom={1}
>
<PermissionRequestTitle
title="Tool use"
riskScore={toolUseConfirm.riskScore}
/>
<Box flexDirection="column" paddingX={2} paddingY={1}>
<Text>
{userFacingName}(
{toolUseConfirm.tool.renderToolUseMessage(
toolUseConfirm.input as never,
{ verbose },
)}
)
{originalUserFacingName.endsWith(' (MCP)') ? (
<Text color={theme.secondaryText}> (MCP)</Text>
) : (
''
)}
</Text>
<Text color={theme.secondaryText}>{toolUseConfirm.description}</Text>
</Box>
<Box flexDirection="column">
<Text>Do you want to proceed?</Text>
<Select
options={[
{
label: 'Yes',
value: 'yes',
},
{
label: `Yes, and don't ask again for ${chalk.bold(userFacingName)} commands in ${chalk.bold(getCwd())}`,
value: 'yes-dont-ask-again',
},
{
label: `No, and tell Claude what to do differently (${chalk.bold.hex(getTheme().warning)('esc')})`,
value: 'no',
},
]}
onChange={newValue => {
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
}
}}
/>
</Box>
</Box>
)
}

View File

@@ -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<UnaryEvent>(
() => ({
completion_type: 'str_replace_single',
language_name: extractLanguageName(file_path),
}),
[file_path],
)
usePermissionRequestLogging(toolUseConfirm, unaryEvent)
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={textColorForRiskScore(toolUseConfirm.riskScore)}
marginTop={1}
paddingLeft={1}
paddingRight={1}
paddingBottom={1}
>
<PermissionRequestTitle
title="Edit file"
riskScore={toolUseConfirm.riskScore}
/>
<FileEditToolDiff
file_path={file_path}
new_string={new_string}
old_string={old_string}
verbose={verbose}
width={columns - 12}
/>
<Box flexDirection="column">
<Text>
Do you want to make this edit to{' '}
<Text bold>{basename(file_path)}</Text>?
</Text>
<Select
options={getOptions(file_path)}
onChange={newValue => {
switch (newValue) {
case 'yes':
extractLanguageName(file_path).then(language => {
logUnaryEvent({
completion_type: 'str_replace_single',
event: 'accept',
metadata: {
language_name: language,
message_id: toolUseConfirm.assistantMessage.message.id,
platform: env.platform,
},
})
})
// Note: We call onDone before onAllow to hide the
// permission request before we render the next message
onDone()
toolUseConfirm.onAllow('temporary')
break
case 'yes-dont-ask-again':
extractLanguageName(file_path).then(language => {
logUnaryEvent({
completion_type: 'str_replace_single',
event: 'accept',
metadata: {
language_name: language,
message_id: toolUseConfirm.assistantMessage.message.id,
platform: env.platform,
},
})
})
savePermission(
toolUseConfirm.tool,
toolUseConfirm.input,
toolUseConfirmGetPrefix(toolUseConfirm),
).then(() => {
// Note: We call onDone before onAllow to hide the
// permission request before we render the next message
onDone()
toolUseConfirm.onAllow('permanent')
})
break
case 'no':
extractLanguageName(file_path).then(language => {
logUnaryEvent({
completion_type: 'str_replace_single',
event: 'reject',
metadata: {
language_name: language,
message_id: toolUseConfirm.assistantMessage.message.id,
platform: env.platform,
},
})
})
// Note: We call onDone before onAllow to hide the
// permission request before we render the next message
onDone()
toolUseConfirm.onReject()
break
}
}}
/>
</Box>
</Box>
)
}
async function extractLanguageName(file_path: string): Promise<string> {
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'
}

View File

@@ -0,0 +1,75 @@
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 { relative } from 'path'
import { getPatch } from '../../../utils/diff.js'
type Props = {
file_path: string
new_string: string
old_string: string
verbose: boolean
useBorder?: boolean
width: number
}
export function FileEditToolDiff({
file_path,
new_string,
old_string,
verbose,
useBorder = true,
width,
}: Props): React.ReactNode {
const file = useMemo(
() => (existsSync(file_path) ? readFileSync(file_path, 'utf8') : ''),
[file_path],
)
const patch = useMemo(
() =>
getPatch({
filePath: file_path,
fileContents: file,
oldStr: old_string,
newStr: new_string,
}),
[file_path, file, old_string, new_string],
)
return (
<Box flexDirection="column">
<Box
borderColor={getTheme().secondaryBorder}
borderStyle={useBorder ? 'round' : undefined}
flexDirection="column"
paddingX={1}
>
<Box paddingBottom={1}>
<Text bold>
{verbose ? file_path : relative(getCwd(), file_path)}
</Text>
</Box>
{intersperse(
patch.map(_ => (
<StructuredDiff
key={_.newStart}
patch={_}
dim={false}
width={width}
/>
)),
i => (
<Text color={getTheme().secondaryText} key={`ellipsis-${i}`}>
...
</Text>
),
)}
</Box>
</Box>
)
}

View File

@@ -0,0 +1,164 @@
import { Box, Text } from 'ink'
import React, { useMemo } from 'react'
import { Select } from '@inkjs/ui'
import { basename, extname } from 'path'
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 { savePermission } from '../../../permissions.js'
import {
type ToolUseConfirm,
toolUseConfirmGetPrefix,
} from '../PermissionRequest.js'
import { existsSync } from 'fs'
import chalk from 'chalk'
import {
UnaryEvent,
usePermissionRequestLogging,
} from '../../../hooks/usePermissionRequestLogging.js'
import { FileWriteToolDiff } from './FileWriteToolDiff.js'
import { useTerminalSize } from '../../../hooks/useTerminalSize.js'
type Props = {
toolUseConfirm: ToolUseConfirm
onDone(): void
verbose: boolean
}
export function FileWritePermissionRequest({
toolUseConfirm,
onDone,
verbose,
}: Props): React.ReactNode {
const { file_path, content } = toolUseConfirm.input as {
file_path: string
content: string
}
const fileExists = useMemo(() => existsSync(file_path), [file_path])
const unaryEvent = useMemo<UnaryEvent>(
() => ({
completion_type: 'write_file_single',
language_name: extractLanguageName(file_path),
}),
[file_path],
)
const { columns } = useTerminalSize()
usePermissionRequestLogging(toolUseConfirm, unaryEvent)
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={textColorForRiskScore(toolUseConfirm.riskScore)}
marginTop={1}
paddingLeft={1}
paddingRight={1}
paddingBottom={1}
>
<PermissionRequestTitle
title={`${fileExists ? 'Edit' : 'Create'} file`}
riskScore={toolUseConfirm.riskScore}
/>
<Box flexDirection="column">
<FileWriteToolDiff
file_path={file_path}
content={content}
verbose={verbose}
width={columns - 12}
/>
</Box>
<Box flexDirection="column">
<Text>
Do you want to {fileExists ? 'make this edit to' : 'create'}{' '}
<Text bold>{basename(file_path)}</Text>?
</Text>
<Select
options={[
{
label: 'Yes',
value: 'yes',
},
{
label: "Yes, and don't ask again this session",
value: 'yes-dont-ask-again',
},
{
label: `No, and tell Claude what to do differently (${chalk.bold.hex(getTheme().warning)('esc')})`,
value: 'no',
},
]}
onChange={newValue => {
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
}
}}
/>
</Box>
</Box>
)
}
async function extractLanguageName(file_path: string): Promise<string> {
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'
}

View File

@@ -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 (
<Box
borderColor={getTheme().secondaryBorder}
borderStyle="round"
flexDirection="column"
paddingX={1}
>
<Box paddingBottom={1}>
<Text bold>{verbose ? file_path : relative(getCwd(), file_path)}</Text>
</Box>
{hunks ? (
intersperse(
hunks.map(_ => (
<StructuredDiff
key={_.newStart}
patch={_}
dim={false}
width={width}
/>
)),
i => (
<Text color={getTheme().secondaryText} key={`ellipsis-${i}`}>
...
</Text>
),
)
) : (
<HighlightedCode
code={content || '(No content)'}
language={extname(file_path).slice(1)}
/>
)}
</Box>
)
}

View File

@@ -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 (
<FallbackPermissionRequest
toolUseConfirm={toolUseConfirm}
onDone={onDone}
verbose={verbose}
/>
)
}
return (
<FilesystemPermissionRequestImpl
toolUseConfirm={toolUseConfirm}
path={path}
onDone={onDone}
verbose={verbose}
/>
)
}
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<UnaryEvent>(
() => ({
completion_type: 'tool_use_single',
language_name: 'none',
}),
[],
)
usePermissionRequestLogging(toolUseConfirm, unaryEvent)
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={textColorForRiskScore(toolUseConfirm.riskScore)}
marginTop={1}
paddingLeft={1}
paddingRight={1}
paddingBottom={1}
>
<PermissionRequestTitle
title={title}
riskScore={toolUseConfirm.riskScore}
/>
<Box flexDirection="column" paddingX={2} paddingY={1}>
<Text>
{userFacingName}(
{toolUseConfirm.tool.renderToolUseMessage(
toolUseConfirm.input as never,
{ verbose },
)}
)
</Text>
</Box>
<Box flexDirection="column">
<Text>Do you want to proceed?</Text>
<Select
options={[
{
label: 'Yes',
value: 'yes',
},
...getDontAskAgainOptions(toolUseConfirm, path),
{
label: `No, and tell Claude what to do differently (${chalk.bold.hex(getTheme().warning)('esc')})`,
value: 'no',
},
]}
onChange={newValue => {
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,
},
})
grantWritePermissionForOriginalDir()
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
}
}}
/>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,100 @@
import { useInput } from 'ink'
import * as React from 'react'
import { Tool } from '../../Tool.js'
import { AssistantMessage } from '../../query.js'
import { FileEditTool } from '../../tools/FileEditTool/FileEditTool.js'
import { FileWriteTool } from '../../tools/FileWriteTool/FileWriteTool.js'
import { BashTool } from '../../tools/BashTool/BashTool.js'
import { FileEditPermissionRequest } from './FileEditPermissionRequest/FileEditPermissionRequest.js'
import { BashPermissionRequest } from './BashPermissionRequest/BashPermissionRequest.js'
import { FallbackPermissionRequest } from './FallbackPermissionRequest.js'
import { useNotifyAfterTimeout } from '../../hooks/useNotifyAfterTimeout.js'
import { FileWritePermissionRequest } from './FileWritePermissionRequest/FileWritePermissionRequest.js'
import { type CommandSubcommandPrefixResult } from '../../utils/commands.js'
import { FilesystemPermissionRequest } from './FilesystemPermissionRequest/FilesystemPermissionRequest.js'
import { NotebookEditTool } from '../../tools/NotebookEditTool/NotebookEditTool.js'
import { GlobTool } from '../../tools/GlobTool/GlobTool.js'
import { GrepTool } from '../../tools/GrepTool/GrepTool.js'
import { LSTool } from '../../tools/lsTool/lsTool.js'
import { FileReadTool } from '../../tools/FileReadTool/FileReadTool.js'
import { NotebookReadTool } from '../../tools/NotebookReadTool/NotebookReadTool.js'
function permissionComponentForTool(tool: Tool) {
switch (tool) {
case FileEditTool:
return FileEditPermissionRequest
case FileWriteTool:
return FileWritePermissionRequest
case BashTool:
return BashPermissionRequest
case GlobTool:
case GrepTool:
case LSTool:
case FileReadTool:
case NotebookReadTool:
case NotebookEditTool:
return FilesystemPermissionRequest
default:
return FallbackPermissionRequest
}
}
export type PermissionRequestProps = {
toolUseConfirm: ToolUseConfirm
onDone(): void
verbose: boolean
}
export function toolUseConfirmGetPrefix(
toolUseConfirm: ToolUseConfirm,
): string | null {
return (
(toolUseConfirm.commandPrefix &&
!toolUseConfirm.commandPrefix.commandInjectionDetected &&
toolUseConfirm.commandPrefix.commandPrefix) ||
null
)
}
export type ToolUseConfirm = {
assistantMessage: AssistantMessage
tool: Tool
description: string
input: { [key: string]: unknown }
commandPrefix: CommandSubcommandPrefixResult | null
// TODO: remove riskScore from ToolUseConfirm
riskScore: number | null
onAbort(): void
onAllow(type: 'permanent' | 'temporary'): void
onReject(): void
}
// TODO: Move this to Tool.renderPermissionRequest
export function PermissionRequest({
toolUseConfirm,
onDone,
verbose,
}: PermissionRequestProps): React.ReactNode {
// Handle Ctrl+C
useInput((input, key) => {
if (key.ctrl && input === 'c') {
onDone()
toolUseConfirm.onReject()
}
})
const toolName = toolUseConfirm.tool.userFacingName(
toolUseConfirm.input as never,
)
useNotifyAfterTimeout(`Claude needs your permission to use ${toolName}`)
const PermissionComponent = permissionComponentForTool(toolUseConfirm.tool)
return (
<PermissionComponent
toolUseConfirm={toolUseConfirm}
onDone={onDone}
verbose={verbose}
/>
)
}

View File

@@ -0,0 +1,69 @@
import * as React from 'react'
import { Box, Text } from 'ink'
import { getTheme } from '../../utils/theme.js'
export type RiskScoreCategory = 'low' | 'moderate' | 'high'
export function categoryForRiskScore(riskScore: number): RiskScoreCategory {
return riskScore >= 70 ? 'high' : riskScore >= 30 ? 'moderate' : 'low'
}
function colorSchemeForRiskScoreCategory(category: RiskScoreCategory): {
highlightColor: string
textColor: string
} {
const theme = getTheme()
switch (category) {
case 'low':
return {
highlightColor: theme.success,
textColor: theme.permission,
}
case 'moderate':
return {
highlightColor: theme.warning,
textColor: theme.warning,
}
case 'high':
return {
highlightColor: theme.error,
textColor: theme.error,
}
}
}
export function textColorForRiskScore(riskScore: number | null): string {
if (riskScore === null) {
return getTheme().permission
}
const category = categoryForRiskScore(riskScore)
return colorSchemeForRiskScoreCategory(category).textColor
}
export function PermissionRiskScore({
riskScore,
}: {
riskScore: number
}): React.ReactNode {
const category = categoryForRiskScore(riskScore)
return <Text color={textColorForRiskScore(riskScore)}>Risk: {category}</Text>
}
type Props = {
title: string
riskScore: number | null
}
export function PermissionRequestTitle({
title,
riskScore,
}: Props): React.ReactNode {
return (
<Box flexDirection="column">
<Text bold color={getTheme().permission}>
{title}
</Text>
{riskScore !== null && <PermissionRiskScore riskScore={riskScore} />}
</Box>
)
}

View File

@@ -0,0 +1,44 @@
import { useEffect } from 'react'
import { logUnaryEvent, CompletionType } from '../../utils/unaryLogging.js'
import { ToolUseConfirm } from '../../components/permissions/PermissionRequest.js'
import { env } from '../../utils/env.js'
import { logEvent } from '../../services/statsig.js'
type UnaryEventType = {
completion_type: CompletionType
language_name: string | Promise<string>
}
/**
* Logs permission request events using Statsig and unary logging.
* Handles both the Statsig event and the unary event logging.
* Can handle either a string or Promise<string> for language_name.
*/
export function usePermissionRequestLogging(
toolUseConfirm: ToolUseConfirm,
unaryEvent: UnaryEventType,
): void {
useEffect(() => {
// Log Statsig event
logEvent('tengu_tool_use_show_permission_request', {
messageID: toolUseConfirm.assistantMessage.message.id,
toolName: toolUseConfirm.tool.name,
})
// Handle string or Promise language name
const languagePromise = Promise.resolve(unaryEvent.language_name)
// Log unary event once language is resolved
languagePromise.then(language => {
logUnaryEvent({
completion_type: unaryEvent.completion_type,
event: 'response',
metadata: {
language_name: language,
message_id: toolUseConfirm.assistantMessage.message.id,
platform: env.platform,
},
})
})
}, [toolUseConfirm, unaryEvent])
}

View File

@@ -0,0 +1,59 @@
import { type Option } from '@inkjs/ui'
import chalk from 'chalk'
import {
type ToolUseConfirm,
toolUseConfirmGetPrefix,
} from './PermissionRequest.js'
import { isUnsafeCompoundCommand } from '../../utils/commands.js'
import { getCwd } from '../../utils/state.js'
import { getTheme } from '../../utils/theme.js'
import { type OptionSubtree } from '../CustomSelect/select.js'
/**
* Generates options for the tool use confirmation dialog
*/
export function toolUseOptions({
toolUseConfirm,
command,
}: {
toolUseConfirm: ToolUseConfirm
command: string
}): (Option | OptionSubtree)[] {
// Hide "don't ask again" options if the command is an unsafe compound command, or a potential command injection
const showDontAskAgainOption =
!isUnsafeCompoundCommand(command) &&
toolUseConfirm.commandPrefix &&
!toolUseConfirm.commandPrefix.commandInjectionDetected
const prefix = toolUseConfirmGetPrefix(toolUseConfirm)
const showDontAskAgainPrefixOption = showDontAskAgainOption && prefix !== null
let dontShowAgainOptions: (Option | OptionSubtree)[] = []
if (showDontAskAgainPrefixOption) {
// Prefix option takes precedence over full command option
dontShowAgainOptions = [
{
label: `Yes, and don't ask again for ${chalk.bold(prefix)} commands in ${chalk.bold(getCwd())}`,
value: 'yes-dont-ask-again-prefix',
},
]
} else if (showDontAskAgainOption) {
dontShowAgainOptions = [
{
label: `Yes, and don't ask again for ${chalk.bold(command)} commands in ${chalk.bold(getCwd())}`,
value: 'yes-dont-ask-again-full',
},
]
}
return [
{
label: 'Yes',
value: 'yes',
},
...dontShowAgainOptions,
{
label: `No, and tell Claude what to do differently (${chalk.bold.hex(getTheme().warning)('esc')})`,
value: 'no',
},
]
}

View File

@@ -0,0 +1,23 @@
import { env } from '../../utils/env.js'
import { CompletionType, logUnaryEvent } from '../../utils/unaryLogging.js'
import { ToolUseConfirm } from './PermissionRequest.js'
export function logUnaryPermissionEvent(
completion_type: CompletionType,
{
assistantMessage: {
message: { id: message_id },
},
}: ToolUseConfirm,
event: 'accept' | 'reject',
): void {
logUnaryEvent({
completion_type,
event,
metadata: {
language_name: 'none',
message_id,
platform: env.platform,
},
})
}

5
src/constants/betas.ts Normal file
View File

@@ -0,0 +1,5 @@
export const GATE_TOKEN_EFFICIENT_TOOLS = 'tengu-token-efficient-tools'
export const BETA_HEADER_TOKEN_EFFICIENT_TOOLS =
'token-efficient-tools-2024-12-11'
export const GATE_USE_EXTERNAL_UPDATER = 'tengu-use-external-updater'
export const CLAUDE_CODE_20250219_BETA_HEADER = 'claude-code-20250219'

View File

@@ -0,0 +1,238 @@
export const largeAnimatedAray = [
`
.=#*=. :==.
-%%%%=. .#%#=
.=%%%#= :%%#: -=+-
... .=%%%*- =@%+ :+%%%%.
:*%%+= .=%%%*- +%%= .=%%%%%=
.=#%%%#=..=#%%*: *%#:-*%%%%+:
.=*%%%%+==#%%+.%%+=#%%%%=.
:=#%%%##%%%*%%%%%%%*- .
-=#%%%%%%%%%%%%+-====+*%%%+.
.============-=*%%%%%%%%%%%%%%%%#+===:
=======+++****%%%%%%%%%%#+==:.
-=*%%%%%%%%%*+#%%%%%%%#*=.
.=+#%#++#%%%%%%%%+-..-==+*##=.
.=+%%%+=-+%#=*%+%%%##%+:
.+%%%*=. =*%+:-%%:=#%#==#%+:
.=+=. .=%%=. +%#. -*%%=:=*%+-
-*%#= .#%* :*%%+: :=*.
.=%%=. =%%= .=%%=.
:=. +%%= .-=:
=#+.
`,
`
.=*+=. .==.
-####=. .*#*=
.=###*- :##*: -==-
... .=###+- =%#+ :+####.
.+##+- .=###+: =##= .=*####-
.=*###*=..=*##+. +#*::+####=.
.=+###*=-=*##+.*#==*###*=.
.=*###**###+#######+-
:=*############+--====*###=.
.===========--=+################*+===.
-=========++++##########*+==.
:=*#########*+*#######*+=.
.==*#*==*########=-..-===+**=.
.==*##+=:=#*-*#+###**#+:
.=###+=. -+#+::##:=*#*==*#=:
.===. .=##=. =#*. -+#*=:=+#=-
-+#*= .*#+ :+##+: :=*.
=#*=. =##= .=##=.
:=. =##= -=:
=*+.
`,
`
.=+==. .=-.
:****= .+*+=
.=***+- :**+: -==:
... .=***+: -**= :=****.
.+**=- .=***=: =**= .=*****-
.=+***+=..=+**=. =*+.:=****=.
==****=-=+**=.**==+****=.
.=+***++***+*******=:
:=+************=:-====+***=.
.==========--:-+****************+====.
-============+**********+==-.
:=+*********+=+*******+==.
.-=+*+==+********=:..:====++=.
==***=-:=*+-+*=***++*=:
.=***+=. -+*=::**.-+*+==+*=:
.===. .=**=. =*+. :+**=.=+*=-
:+*+- .+*+ :=**=: :=+.
=**=. -**= .=**=.
:-. =**- :=.
=+=.
`,
`
.===-. .=-.
:++++= =+=-
.=+++=: .++=: :==:
.. .=+++=: -++= :=++++
.=++=: .=+++=: =++= .=+++++:
.==+++==..==++=. =+=.:=++++=.
-=++++=--=++=.++=-=++++=.
.==+++==+++=+++++++=:
:==++++++++++++=::=====+++=.
.-====---=---:-=++++++++++++++++====-.
:=============++++++++++===-.
:==+++++++++===+++++++==-.
.-==+===+++++++++=: .:=======.
-=+++=-:=+=:=+=+++==+=:
.=+++==. :=+=::++.-=+====+=.
===. .=++=. =++. :=++=.-=+=:
:=+=- .++= :=++=. .==.
-++=. -++= .=++=.
.-. =++- :-.
-==.
`,
`
.===-. .-:
:====- ===-
.-====: .===. :==:
.. .-====: :=== .=====
.====: .-====. ===- .-=====:
-=====-..-====. ===..======.
-======:-====.===:=====-.
.-==================:
.===============::-========.
.-=---------:::=====================-.
:=-========================:.
.=======================-.
:================: .:-=====-.
-=====:.===:==========.
.=====-. :===.:==.:===--===.
-=-. .===-. === :====.-===:
:===: .=== .====. .==
-==-. :=== .====.
.:. ===: ::.
-==.
`,
`
.-==: .-:
.====: ===:
:====: .===. .--.
. .-====. :=== .-====
.====. :====. -==: .:=====:
-=====-. :====. ===..=====-.
:======::====.==-:=====:
.-==================.
.-=============-..:---====-.
.:-------::::.:===================--:.
.---------===============--:.
.-======================:
:-===--==========. ..:--===-.
:=====:.-==:==-=======.
.-====:. .===..==.:===::==-.
--:. .-==-. -== .===-.:==-.
.===: === .====. .-=
:==-. :==- .-==-.
.:. -==: .:.
:==
`,
`
:--: .:.
.===-: -=-.
:===-. .==-. .::.
. :-==-. .==: .:-===
.-==:. :-==-. :=-: :-===-.
:-===-:. :-==-. -=-..-====:.
.-===-:.:-==:.-=:.-===-:
.:-===---==:-==-=-=-.
.:-===========-:..::::--==-.
.:::::::::....--========-======-:::..
.:::::::::-----========--::..
.:--====-------=======--.
.:-=-::---==--=-:. .:::---:.
.:-=-:..:--.-=:-=---=-.
:===-:. .-=-..==..-=-::-=:.
:::. .:=-: :=- .-=-:.:---.
.-=-. -=- .-==-. .:-
:=-:. .-=: .:-=:.
... :==. ...
.--
`,
`
.::. ..
.::::. :::.
.::::. :::. ....
.::::. .::. ..::::
:::.. .:::.. .::. .:::::.
.:::::. .:::. .:: ..::::.
..::::...:::. ::..:::::.
.:::::::::.:::::::..
..:::::::::::::.......::::.
..............::::::::::::::::::....
...........::::::::::::::...
..:::::::::::.::::::::::.
..:::..:::::::::.. .....::.
..:::....::.::.::::::..
.::::. .::...:: .:::..::.
... .::. .:: .:::. .::..
.:::. ::. ..::. .:
.::. .::. .::.
. .::. ..
.:.
`,
`
`,
]
export const smallAnimatedArray = [
` @
@ @ @
@@@
@ @ @
@`,
` *
* * *
***
* * *
*`,
` +
+ + +
+++
+ + +
+`,
` /
/ / /
///
/ / /
/`,
` |
| | |
|||
| | |
|`,
` \\
\\ \\ \\
\\\\\\
\\ \\ \\
\\`,
` -
- - -
---
- - -
-`,
]

4
src/constants/figures.ts Normal file
View File

@@ -0,0 +1,4 @@
import { env } from '../utils/env.js'
// The former is better vertically aligned, but isn't usually supported on Windows/Linux
export const BLACK_CIRCLE = env.platform === 'macos' ? '⏺' : '●'

5
src/constants/keys.ts Normal file
View File

@@ -0,0 +1,5 @@
export const SENTRY_DSN =
'https://e531a1d9ec1de9064fae9d4affb0b0f4@o1158394.ingest.us.sentry.io/4508259541909504'
export const STATSIG_CLIENT_KEY =
'client-RRNS7R65EAtReO5XA4xDC3eU6ZdJQi6lLEP6b5j32Me'

54
src/constants/oauth.ts Normal file
View File

@@ -0,0 +1,54 @@
const BASE_CONFIG = {
REDIRECT_PORT: 54545,
MANUAL_REDIRECT_URL: '/oauth/code/callback',
SCOPES: ['org:create_api_key', 'user:profile'] as const,
}
// Production OAuth configuration - Used in normal operation
const PROD_OAUTH_CONFIG = {
...BASE_CONFIG,
AUTHORIZE_URL: 'https://console.anthropic.com/oauth/authorize',
TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token',
API_KEY_URL: 'https://api.anthropic.com/api/oauth/claude_cli/create_api_key',
SUCCESS_URL:
'https://console.anthropic.com/buy_credits?returnUrl=/oauth/code/success',
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
} as const
// Only include staging config in ant builds with staging flag
export const STAGING_OAUTH_CONFIG =
process.env.USER_TYPE === 'ant' && process.env.USE_STAGING_OAUTH === '1'
? ({
...BASE_CONFIG,
AUTHORIZE_URL: 'https://console.staging.ant.dev/oauth/authorize',
TOKEN_URL: 'https://console.staging.ant.dev/v1/oauth/token',
API_KEY_URL:
'https://api-staging.anthropic.com/api/oauth/claude_cli/create_api_key',
SUCCESS_URL:
'https://console.staging.ant.dev/buy_credits?returnUrl=/oauth/code/success',
CLIENT_ID: '22422756-60c9-4084-8eb7-27705fd5cf9a',
} as const)
: undefined
// Only include test config in test environments
const TEST_OAUTH_CONFIG =
process.env.NODE_ENV === 'test'
? ({
...BASE_CONFIG,
AUTHORIZE_URL: 'http://localhost:3456/oauth/authorize',
TOKEN_URL: 'http://localhost:3456/oauth/token',
API_KEY_URL: '',
SUCCESS_URL:
'http://localhost:3456/buy_credits?returnUrl=/oauth/code/success',
REDIRECT_PORT: 7777,
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
} as const)
: undefined
// Default to prod config, override with test/staging if enabled
export const OAUTH_CONFIG =
(process.env.NODE_ENV === 'test' && TEST_OAUTH_CONFIG) ||
(process.env.USER_TYPE === 'ant' &&
process.env.USE_STAGING_OAUTH === '1' &&
STAGING_OAUTH_CONFIG) ||
PROD_OAUTH_CONFIG

2
src/constants/product.ts Normal file
View File

@@ -0,0 +1,2 @@
export const PRODUCT_NAME = 'Claude Code'
export const PRODUCT_URL = 'https://docs.anthropic.com/s/claude-code'

154
src/constants/prompts.ts Normal file
View File

@@ -0,0 +1,154 @@
import { env } from '../utils/env.js'
import { getIsGit } from '../utils/git.js'
import {
INTERRUPT_MESSAGE,
INTERRUPT_MESSAGE_FOR_TOOL_USE,
} from '../utils/messages.js'
import { getCwd } from '../utils/state.js'
import { PRODUCT_NAME } from './product.js'
import { BashTool } from '../tools/BashTool/BashTool.js'
import { getSlowAndCapableModel } from '../utils/model.js'
export function getCLISyspromptPrefix(): string {
return `You are ${PRODUCT_NAME}, Anthropic's official CLI for Claude.`
}
export async function getSystemPrompt(): Promise<string[]> {
return [
`You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
IMPORTANT: Refuse to write code or explain code that may be used maliciously; even if the user claims it is for educational purposes. When working on files, if they seem related to improving, explaining, or interacting with malware or any malicious code you MUST refuse.
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer questions about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code).
Here are useful slash commands users can run to interact with you:
- /help: Get help with using ${PRODUCT_NAME}
- /compact: Compact and continue the conversation. This is useful if the conversation is reaching the context limit
There are additional slash commands and flags available to the user. If the user asks about ${PRODUCT_NAME} functionality, always run \`claude -h\` with ${BashTool.name} to see supported commands and flags. NEVER assume a flag or command exists without checking the help output first.
To give feedback, users should ${MACRO.ISSUES_EXPLAINER}.
# Memory
If the current working directory contains a file called CLAUDE.md, it will be automatically added to your context. This file serves multiple purposes:
1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
3. Maintaining useful information about the codebase structure and organization
When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CLAUDE.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CLAUDE.md so you can remember it for next time.
# Tone and style
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like ${BashTool.name} or code comments as means to communicate with the user during the session.
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
<example>
user: 2 + 2
assistant: 4
</example>
<example>
user: what is 2+2?
assistant: 4
</example>
<example>
user: is 11 a prime number?
assistant: true
</example>
<example>
user: what command should I run to list files in the current directory?
assistant: ls
</example>
<example>
user: what command should I run to watch files in the current directory?
assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
npm run dev
</example>
<example>
user: How many golf balls fit inside a jetta?
assistant: 150000
</example>
<example>
user: what files are in the directory src/?
assistant: [runs ls and sees foo.c, bar.c, baz.c]
user: which file contains the implementation of foo?
assistant: src/foo.c
</example>
<example>
user: write tests for new feature
assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
</example>
# Proactiveness
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
1. Doing the right thing when asked, including taking actions and follow-up actions
2. Not surprising the user with actions you take without asking
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
# Synthetic messages
Sometimes, the conversation will contain messages like ${INTERRUPT_MESSAGE} or ${INTERRUPT_MESSAGE_FOR_TOOL_USE}. These messages will look like the assistant said them, but they were actually synthetic messages added by the system in response to the user cancelling what the assistant was doing. You should not respond to these messages. You must NEVER send messages like this yourself.
# Following conventions
When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
# Code style
- Do not add comments to the code you write, unless the user asks you to, or the code is complex and requires additional context.
# Doing tasks
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
1. Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
2. Implement the solution using all tools available to you
3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CLAUDE.md so that you will know to run it next time.
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
# Tool usage policy
- When doing file search, prefer to use the Agent tool in order to reduce context usage.
- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in the same function_calls block.
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
`,
`\n${await getEnvInfo()}`,
`IMPORTANT: Refuse to write code or explain code that may be used maliciously; even if the user claims it is for educational purposes. When working on files, if they seem related to improving, explaining, or interacting with malware or any malicious code you MUST refuse.
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer questions about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code).`,
]
}
export async function getEnvInfo(): Promise<string> {
const [model, isGit] = await Promise.all([
getSlowAndCapableModel(),
getIsGit(),
])
return `Here is useful information about the environment you are running in:
<env>
Working directory: ${getCwd()}
Is directory a git repo: ${isGit ? 'Yes' : 'No'}
Platform: ${env.platform}
Today's date: ${new Date().toLocaleDateString()}
Model: ${model}
</env>`
}
export async function getAgentPrompt(): Promise<string[]> {
return [
`You are an agent for ${PRODUCT_NAME}, Anthropic's official CLI for Claude. Given the user's prompt, you should use the tools available to you to answer the user's question.
Notes:
1. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
2. When relevant, share file names and code snippets relevant to the query
3. Any file paths you return in your final response MUST be absolute. DO NOT use relative paths.`,
`${await getEnvInfo()}`,
]
}

Some files were not shown because too many files have changed in this diff Show More