mirror of
https://github.com/Onewon/claude-code.git
synced 2026-04-25 22:31:15 +03:00
Initial commit
This commit is contained in:
4
README.md
Normal file
4
README.md
Normal 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
176
src/ProjectOnboarding.tsx
Normal 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'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
120
src/commands.ts
Normal 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
|
||||
}
|
||||
53
src/commands/approvedTools.ts
Normal file
53
src/commands/approvedTools.ts
Normal 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
20
src/commands/bug.tsx
Normal 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
37
src/commands/clear.ts
Normal 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
94
src/commands/compact.ts
Normal 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
19
src/commands/config.tsx
Normal 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
18
src/commands/cost.ts
Normal 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
209
src/commands/ctx_viz.ts
Normal 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
23
src/commands/doctor.ts
Normal 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
19
src/commands/help.tsx
Normal 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
37
src/commands/init.ts
Normal 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
42
src/commands/listen.ts
Normal 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
51
src/commands/login.tsx
Normal 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
41
src/commands/logout.tsx
Normal 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
|
||||
34
src/commands/onboarding.tsx
Normal file
34
src/commands/onboarding.tsx
Normal 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
|
||||
59
src/commands/pr_comments.ts
Normal file
59
src/commands/pr_comments.ts
Normal 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
|
||||
33
src/commands/release-notes.ts
Normal file
33
src/commands/release-notes.ts
Normal 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
30
src/commands/resume.tsx
Normal 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
49
src/commands/review.ts
Normal 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
|
||||
143
src/commands/terminalSetup.ts
Normal file
143
src/commands/terminalSetup.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
69
src/components/AnimatedClaudeAsterisk.tsx
Normal file
69
src/components/AnimatedClaudeAsterisk.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
src/components/ApproveApiKey.tsx
Normal file
93
src/components/ApproveApiKey.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
25
src/components/AsciiLogo.tsx
Normal file
25
src/components/AsciiLogo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
146
src/components/AutoUpdater.tsx
Normal file
146
src/components/AutoUpdater.tsx
Normal 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} · 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} · 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 · Restart to apply
|
||||
</Text>
|
||||
) : null}
|
||||
{(autoUpdaterResult?.status === 'install_failed' ||
|
||||
autoUpdaterResult?.status === 'no_permissions') && (
|
||||
<Text color={theme.error}>
|
||||
✗ Auto-update failed · 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
332
src/components/Bug.tsx
Normal 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}'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
279
src/components/Config.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
326
src/components/ConsoleOAuthFlow.tsx
Normal file
326
src/components/ConsoleOAuthFlow.tsx
Normal 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'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
23
src/components/Cost.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
src/components/CostThresholdDialog.tsx
Normal file
46
src/components/CostThresholdDialog.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
42
src/components/CustomSelect/option-map.ts
Normal file
42
src/components/CustomSelect/option-map.ts
Normal 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
|
||||
}
|
||||
}
|
||||
52
src/components/CustomSelect/select-option.tsx
Normal file
52
src/components/CustomSelect/select-option.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
143
src/components/CustomSelect/select.tsx
Normal file
143
src/components/CustomSelect/select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
387
src/components/CustomSelect/use-select-state.ts
Normal file
387
src/components/CustomSelect/use-select-state.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
35
src/components/CustomSelect/use-select.ts
Normal file
35
src/components/CustomSelect/use-select.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
14
src/components/FallbackToolUseRejectedMessage.tsx
Normal file
14
src/components/FallbackToolUseRejectedMessage.tsx
Normal 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>
|
||||
⎿
|
||||
<Text color={getTheme().error}>
|
||||
No (tell Claude what to do differently)
|
||||
</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
66
src/components/FileEditToolUpdatedMessage.tsx
Normal file
66
src/components/FileEditToolUpdatedMessage.tsx
Normal 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
124
src/components/Help.tsx
Normal 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'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 "question"</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}>
|
||||
> How does foo.py work?
|
||||
</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
• Edit files{' '}
|
||||
<Text color={getTheme().secondaryText}>
|
||||
> Update bar.ts to...
|
||||
</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
• Fix errors{' '}
|
||||
<Text color={getTheme().secondaryText}>> cargo build</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
• Run commands{' '}
|
||||
<Text color={getTheme().secondaryText}>> /help</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
• Run bash commands{' '}
|
||||
<Text color={getTheme().secondaryText}>> !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>
|
||||
)
|
||||
}
|
||||
33
src/components/HighlightedCode.tsx
Normal file
33
src/components/HighlightedCode.tsx
Normal 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>
|
||||
}
|
||||
113
src/components/InvalidConfigDialog.tsx
Normal file
113
src/components/InvalidConfigDialog.tsx
Normal 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
32
src/components/Link.tsx
Normal 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>
|
||||
}
|
||||
}
|
||||
86
src/components/LogSelector.tsx
Normal file
86
src/components/LogSelector.tsx
Normal 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
148
src/components/Logo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
src/components/MCPServerApprovalDialog.tsx
Normal file
100
src/components/MCPServerApprovalDialog.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
24
src/components/MCPServerDialogCopy.tsx
Normal file
24
src/components/MCPServerDialogCopy.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
109
src/components/MCPServerMultiselectDialog.tsx
Normal file
109
src/components/MCPServerMultiselectDialog.tsx
Normal 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
219
src/components/Message.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
15
src/components/MessageResponse.tsx
Normal file
15
src/components/MessageResponse.tsx
Normal 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>{' '}⎿ </Text>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
211
src/components/MessageSelector.tsx
Normal file
211
src/components/MessageSelector.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
304
src/components/Onboarding.tsx
Normal file
304
src/components/Onboarding.tsx
Normal 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'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'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>
|
||||
)
|
||||
}
|
||||
11
src/components/PressEnterToContinue.tsx
Normal file
11
src/components/PressEnterToContinue.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
469
src/components/PromptInput.tsx
Normal file
469
src/components/PromptInput.tsx
Normal 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}> ! </Text>
|
||||
) : (
|
||||
<Text color={isLoading ? theme.secondaryText : undefined}>
|
||||
>
|
||||
</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)
|
||||
}
|
||||
33
src/components/SentryErrorBoundary.ts
Normal file
33
src/components/SentryErrorBoundary.ts
Normal 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
126
src/components/Spinner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
523
src/components/StickerRequestForm.tsx
Normal file
523
src/components/StickerRequestForm.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
184
src/components/StructuredDiff.tsx
Normal file
184
src/components/StructuredDiff.tsx
Normal 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
|
||||
}
|
||||
230
src/components/TextInput.tsx
Normal file
230
src/components/TextInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
src/components/TokenWarning.tsx
Normal file
31
src/components/TokenWarning.tsx
Normal 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) · Run /compact to compact & continue
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
40
src/components/ToolUseLoader.tsx
Normal file
40
src/components/ToolUseLoader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
src/components/TrustDialog.tsx
Normal file
108
src/components/TrustDialog.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
60
src/components/binary-feedback/BinaryFeedback.tsx
Normal file
60
src/components/binary-feedback/BinaryFeedback.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
111
src/components/binary-feedback/BinaryFeedbackOption.tsx
Normal file
111
src/components/binary-feedback/BinaryFeedbackOption.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
171
src/components/binary-feedback/BinaryFeedbackView.tsx
Normal file
171
src/components/binary-feedback/BinaryFeedbackView.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
220
src/components/binary-feedback/utils.ts
Normal file
220
src/components/binary-feedback/utils.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
22
src/components/messages/AssistantBashOutputMessage.tsx
Normal file
22
src/components/messages/AssistantBashOutputMessage.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
19
src/components/messages/AssistantRedactedThinkingMessage.tsx
Normal file
19
src/components/messages/AssistantRedactedThinkingMessage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
144
src/components/messages/AssistantTextMessage.tsx
Normal file
144
src/components/messages/AssistantTextMessage.tsx
Normal 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>
|
||||
⎿
|
||||
<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>
|
||||
⎿
|
||||
<Text color={getTheme().error}>Interrupted by user</Text>
|
||||
</Text>
|
||||
)
|
||||
|
||||
case PROMPT_TOO_LONG_ERROR_MESSAGE:
|
||||
return (
|
||||
<Text>
|
||||
⎿
|
||||
<Text color={getTheme().error}>
|
||||
Context low · Run /compact to compact & continue
|
||||
</Text>
|
||||
</Text>
|
||||
)
|
||||
|
||||
case CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE:
|
||||
return (
|
||||
<Text>
|
||||
⎿
|
||||
<Text color={getTheme().error}>
|
||||
Credit balance too low · Add funds:
|
||||
https://console.anthropic.com/settings/billing
|
||||
</Text>
|
||||
</Text>
|
||||
)
|
||||
|
||||
case INVALID_API_KEY_ERROR_MESSAGE:
|
||||
return (
|
||||
<Text>
|
||||
⎿
|
||||
<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>
|
||||
)
|
||||
}
|
||||
}
|
||||
40
src/components/messages/AssistantThinkingMessage.tsx
Normal file
40
src/components/messages/AssistantThinkingMessage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
src/components/messages/AssistantToolUseMessage.tsx
Normal file
110
src/components/messages/AssistantToolUseMessage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
src/components/messages/UserBashInputMessage.tsx
Normal file
28
src/components/messages/UserBashInputMessage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
src/components/messages/UserCommandMessage.tsx
Normal file
30
src/components/messages/UserCommandMessage.tsx
Normal 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}>
|
||||
> /{commandMessage} {args}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
35
src/components/messages/UserPromptMessage.tsx
Normal file
35
src/components/messages/UserPromptMessage.tsx
Normal 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}>></Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" width={columns - 4}>
|
||||
<Text color={getTheme().secondaryText} wrap="wrap">
|
||||
{text}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
33
src/components/messages/UserTextMessage.tsx
Normal file
33
src/components/messages/UserTextMessage.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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>
|
||||
⎿
|
||||
<Text color={getTheme().error}>Interrupted by user</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -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> ⎿ </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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
56
src/components/messages/UserToolResultMessage/utils.tsx
Normal file
56
src/components/messages/UserToolResultMessage/utils.tsx
Normal 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])
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
155
src/components/permissions/FallbackPermissionRequest.tsx
Normal file
155
src/components/permissions/FallbackPermissionRequest.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
100
src/components/permissions/PermissionRequest.tsx
Normal file
100
src/components/permissions/PermissionRequest.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
69
src/components/permissions/PermissionRequestTitle.tsx
Normal file
69
src/components/permissions/PermissionRequestTitle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
src/components/permissions/hooks.ts
Normal file
44
src/components/permissions/hooks.ts
Normal 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])
|
||||
}
|
||||
59
src/components/permissions/toolUseOptions.ts
Normal file
59
src/components/permissions/toolUseOptions.ts
Normal 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',
|
||||
},
|
||||
]
|
||||
}
|
||||
23
src/components/permissions/utils.ts
Normal file
23
src/components/permissions/utils.ts
Normal 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
5
src/constants/betas.ts
Normal 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'
|
||||
238
src/constants/claude-asterisk-ascii-art.tsx
Normal file
238
src/constants/claude-asterisk-ascii-art.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
export const largeAnimatedAray = [
|
||||
`
|
||||
.=#*=. :==.
|
||||
-%%%%=. .#%#=
|
||||
.=%%%#= :%%#: -=+-
|
||||
... .=%%%*- =@%+ :+%%%%.
|
||||
:*%%+= .=%%%*- +%%= .=%%%%%=
|
||||
.=#%%%#=..=#%%*: *%#:-*%%%%+:
|
||||
.=*%%%%+==#%%+.%%+=#%%%%=.
|
||||
:=#%%%##%%%*%%%%%%%*- .
|
||||
-=#%%%%%%%%%%%%+-====+*%%%+.
|
||||
.============-=*%%%%%%%%%%%%%%%%#+===:
|
||||
=======+++****%%%%%%%%%%#+==:.
|
||||
-=*%%%%%%%%%*+#%%%%%%%#*=.
|
||||
.=+#%#++#%%%%%%%%+-..-==+*##=.
|
||||
.=+%%%+=-+%#=*%+%%%##%+:
|
||||
.+%%%*=. =*%+:-%%:=#%#==#%+:
|
||||
.=+=. .=%%=. +%#. -*%%=:=*%+-
|
||||
-*%#= .#%* :*%%+: :=*.
|
||||
.=%%=. =%%= .=%%=.
|
||||
:=. +%%= .-=:
|
||||
=#+.
|
||||
`,
|
||||
`
|
||||
.=*+=. .==.
|
||||
-####=. .*#*=
|
||||
.=###*- :##*: -==-
|
||||
... .=###+- =%#+ :+####.
|
||||
.+##+- .=###+: =##= .=*####-
|
||||
.=*###*=..=*##+. +#*::+####=.
|
||||
.=+###*=-=*##+.*#==*###*=.
|
||||
.=*###**###+#######+-
|
||||
:=*############+--====*###=.
|
||||
.===========--=+################*+===.
|
||||
-=========++++##########*+==.
|
||||
:=*#########*+*#######*+=.
|
||||
.==*#*==*########=-..-===+**=.
|
||||
.==*##+=:=#*-*#+###**#+:
|
||||
.=###+=. -+#+::##:=*#*==*#=:
|
||||
.===. .=##=. =#*. -+#*=:=+#=-
|
||||
-+#*= .*#+ :+##+: :=*.
|
||||
=#*=. =##= .=##=.
|
||||
:=. =##= -=:
|
||||
=*+.
|
||||
`,
|
||||
`
|
||||
.=+==. .=-.
|
||||
:****= .+*+=
|
||||
.=***+- :**+: -==:
|
||||
... .=***+: -**= :=****.
|
||||
.+**=- .=***=: =**= .=*****-
|
||||
.=+***+=..=+**=. =*+.:=****=.
|
||||
==****=-=+**=.**==+****=.
|
||||
.=+***++***+*******=:
|
||||
:=+************=:-====+***=.
|
||||
.==========--:-+****************+====.
|
||||
-============+**********+==-.
|
||||
:=+*********+=+*******+==.
|
||||
.-=+*+==+********=:..:====++=.
|
||||
==***=-:=*+-+*=***++*=:
|
||||
.=***+=. -+*=::**.-+*+==+*=:
|
||||
.===. .=**=. =*+. :+**=.=+*=-
|
||||
:+*+- .+*+ :=**=: :=+.
|
||||
=**=. -**= .=**=.
|
||||
:-. =**- :=.
|
||||
=+=.
|
||||
`,
|
||||
`
|
||||
.===-. .=-.
|
||||
:++++= =+=-
|
||||
.=+++=: .++=: :==:
|
||||
.. .=+++=: -++= :=++++
|
||||
.=++=: .=+++=: =++= .=+++++:
|
||||
.==+++==..==++=. =+=.:=++++=.
|
||||
-=++++=--=++=.++=-=++++=.
|
||||
.==+++==+++=+++++++=:
|
||||
:==++++++++++++=::=====+++=.
|
||||
.-====---=---:-=++++++++++++++++====-.
|
||||
:=============++++++++++===-.
|
||||
:==+++++++++===+++++++==-.
|
||||
.-==+===+++++++++=: .:=======.
|
||||
-=+++=-:=+=:=+=+++==+=:
|
||||
.=+++==. :=+=::++.-=+====+=.
|
||||
===. .=++=. =++. :=++=.-=+=:
|
||||
:=+=- .++= :=++=. .==.
|
||||
-++=. -++= .=++=.
|
||||
.-. =++- :-.
|
||||
-==.
|
||||
`,
|
||||
`
|
||||
.===-. .-:
|
||||
:====- ===-
|
||||
.-====: .===. :==:
|
||||
.. .-====: :=== .=====
|
||||
.====: .-====. ===- .-=====:
|
||||
-=====-..-====. ===..======.
|
||||
-======:-====.===:=====-.
|
||||
.-==================:
|
||||
.===============::-========.
|
||||
.-=---------:::=====================-.
|
||||
:=-========================:.
|
||||
.=======================-.
|
||||
:================: .:-=====-.
|
||||
-=====:.===:==========.
|
||||
.=====-. :===.:==.:===--===.
|
||||
-=-. .===-. === :====.-===:
|
||||
:===: .=== .====. .==
|
||||
-==-. :=== .====.
|
||||
.:. ===: ::.
|
||||
-==.
|
||||
`,
|
||||
`
|
||||
.-==: .-:
|
||||
.====: ===:
|
||||
:====: .===. .--.
|
||||
. .-====. :=== .-====
|
||||
.====. :====. -==: .:=====:
|
||||
-=====-. :====. ===..=====-.
|
||||
:======::====.==-:=====:
|
||||
.-==================.
|
||||
.-=============-..:---====-.
|
||||
.:-------::::.:===================--:.
|
||||
.---------===============--:.
|
||||
.-======================:
|
||||
:-===--==========. ..:--===-.
|
||||
:=====:.-==:==-=======.
|
||||
.-====:. .===..==.:===::==-.
|
||||
--:. .-==-. -== .===-.:==-.
|
||||
.===: === .====. .-=
|
||||
:==-. :==- .-==-.
|
||||
.:. -==: .:.
|
||||
:==
|
||||
`,
|
||||
`
|
||||
:--: .:.
|
||||
.===-: -=-.
|
||||
:===-. .==-. .::.
|
||||
. :-==-. .==: .:-===
|
||||
.-==:. :-==-. :=-: :-===-.
|
||||
:-===-:. :-==-. -=-..-====:.
|
||||
.-===-:.:-==:.-=:.-===-:
|
||||
.:-===---==:-==-=-=-.
|
||||
.:-===========-:..::::--==-.
|
||||
.:::::::::....--========-======-:::..
|
||||
.:::::::::-----========--::..
|
||||
.:--====-------=======--.
|
||||
.:-=-::---==--=-:. .:::---:.
|
||||
.:-=-:..:--.-=:-=---=-.
|
||||
:===-:. .-=-..==..-=-::-=:.
|
||||
:::. .:=-: :=- .-=-:.:---.
|
||||
.-=-. -=- .-==-. .:-
|
||||
:=-:. .-=: .:-=:.
|
||||
... :==. ...
|
||||
.--
|
||||
`,
|
||||
`
|
||||
.::. ..
|
||||
.::::. :::.
|
||||
.::::. :::. ....
|
||||
.::::. .::. ..::::
|
||||
:::.. .:::.. .::. .:::::.
|
||||
.:::::. .:::. .:: ..::::.
|
||||
..::::...:::. ::..:::::.
|
||||
.:::::::::.:::::::..
|
||||
..:::::::::::::.......::::.
|
||||
..............::::::::::::::::::....
|
||||
...........::::::::::::::...
|
||||
..:::::::::::.::::::::::.
|
||||
..:::..:::::::::.. .....::.
|
||||
..:::....::.::.::::::..
|
||||
.::::. .::...:: .:::..::.
|
||||
... .::. .:: .:::. .::..
|
||||
.:::. ::. ..::. .:
|
||||
.::. .::. .::.
|
||||
. .::. ..
|
||||
.:.
|
||||
`,
|
||||
`
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
`,
|
||||
]
|
||||
|
||||
export const smallAnimatedArray = [
|
||||
` @
|
||||
@ @ @
|
||||
@@@
|
||||
@ @ @
|
||||
@`,
|
||||
` *
|
||||
* * *
|
||||
***
|
||||
* * *
|
||||
*`,
|
||||
` +
|
||||
+ + +
|
||||
+++
|
||||
+ + +
|
||||
+`,
|
||||
` /
|
||||
/ / /
|
||||
///
|
||||
/ / /
|
||||
/`,
|
||||
` |
|
||||
| | |
|
||||
|||
|
||||
| | |
|
||||
|`,
|
||||
` \\
|
||||
\\ \\ \\
|
||||
\\\\\\
|
||||
\\ \\ \\
|
||||
\\`,
|
||||
` -
|
||||
- - -
|
||||
---
|
||||
- - -
|
||||
-`,
|
||||
]
|
||||
4
src/constants/figures.ts
Normal file
4
src/constants/figures.ts
Normal 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
5
src/constants/keys.ts
Normal 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
54
src/constants/oauth.ts
Normal 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
2
src/constants/product.ts
Normal 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
154
src/constants/prompts.ts
Normal 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
Reference in New Issue
Block a user