mirror of
https://github.com/Onewon/claude-code.git
synced 2026-04-26 23:01:23 +03:00
Initial commit
This commit is contained in:
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user