mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
style: 完成所有文件的lint
This commit is contained in:
@@ -1,25 +1,25 @@
|
||||
import * as React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { formatNumber } from '../utils/format.js'
|
||||
import type { Theme } from '../utils/theme.js'
|
||||
import * as React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { formatNumber } from '../utils/format.js';
|
||||
import type { Theme } from '../utils/theme.js';
|
||||
|
||||
type Props = {
|
||||
agentType: string
|
||||
description?: string
|
||||
name?: string
|
||||
descriptionColor?: keyof Theme
|
||||
taskDescription?: string
|
||||
toolUseCount: number
|
||||
tokens: number | null
|
||||
color?: keyof Theme
|
||||
isLast: boolean
|
||||
isResolved: boolean
|
||||
isError: boolean
|
||||
isAsync?: boolean
|
||||
shouldAnimate: boolean
|
||||
lastToolInfo?: string | null
|
||||
hideType?: boolean
|
||||
}
|
||||
agentType: string;
|
||||
description?: string;
|
||||
name?: string;
|
||||
descriptionColor?: keyof Theme;
|
||||
taskDescription?: string;
|
||||
toolUseCount: number;
|
||||
tokens: number | null;
|
||||
color?: keyof Theme;
|
||||
isLast: boolean;
|
||||
isResolved: boolean;
|
||||
isError: boolean;
|
||||
isAsync?: boolean;
|
||||
shouldAnimate: boolean;
|
||||
lastToolInfo?: string | null;
|
||||
hideType?: boolean;
|
||||
};
|
||||
|
||||
export function AgentProgressLine({
|
||||
agentType,
|
||||
@@ -38,19 +38,19 @@ export function AgentProgressLine({
|
||||
lastToolInfo,
|
||||
hideType = false,
|
||||
}: Props): React.ReactNode {
|
||||
const treeChar = isLast ? '└─' : '├─'
|
||||
const isBackgrounded = isAsync && isResolved
|
||||
const treeChar = isLast ? '└─' : '├─';
|
||||
const isBackgrounded = isAsync && isResolved;
|
||||
|
||||
// Determine the status text
|
||||
const getStatusText = (): string => {
|
||||
if (!isResolved) {
|
||||
return lastToolInfo || 'Initializing…'
|
||||
return lastToolInfo || 'Initializing…';
|
||||
}
|
||||
if (isBackgrounded) {
|
||||
return taskDescription ?? 'Running in the background'
|
||||
return taskDescription ?? 'Running in the background';
|
||||
}
|
||||
return 'Done'
|
||||
}
|
||||
return 'Done';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -64,20 +64,13 @@ export function AgentProgressLine({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
bold
|
||||
backgroundColor={color}
|
||||
color={color ? 'inverseText' : undefined}
|
||||
>
|
||||
<Text bold backgroundColor={color} color={color ? 'inverseText' : undefined}>
|
||||
{agentType}
|
||||
</Text>
|
||||
{description && (
|
||||
<>
|
||||
{' ('}
|
||||
<Text
|
||||
backgroundColor={descriptionColor}
|
||||
color={descriptionColor ? 'inverseText' : undefined}
|
||||
>
|
||||
<Text backgroundColor={descriptionColor} color={descriptionColor ? 'inverseText' : undefined}>
|
||||
{description}
|
||||
</Text>
|
||||
{')'}
|
||||
@@ -101,5 +94,5 @@ export function AgentProgressLine({
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import React from 'react'
|
||||
import { Text, Dialog } from '@anthropic/ink'
|
||||
import { saveGlobalConfig } from '../utils/config.js'
|
||||
import { Select } from './CustomSelect/index.js'
|
||||
import React from 'react';
|
||||
import { Text, Dialog } from '@anthropic/ink';
|
||||
import { saveGlobalConfig } from '../utils/config.js';
|
||||
import { Select } from './CustomSelect/index.js';
|
||||
|
||||
type Props = {
|
||||
customApiKeyTruncated: string
|
||||
onDone(approved: boolean): void
|
||||
}
|
||||
customApiKeyTruncated: string;
|
||||
onDone(approved: boolean): void;
|
||||
};
|
||||
|
||||
export function ApproveApiKey({
|
||||
customApiKeyTruncated,
|
||||
onDone,
|
||||
}: Props): React.ReactNode {
|
||||
export function ApproveApiKey({ customApiKeyTruncated, onDone }: Props): React.ReactNode {
|
||||
function onChange(value: 'yes' | 'no') {
|
||||
switch (value) {
|
||||
case 'yes': {
|
||||
@@ -19,38 +16,28 @@ export function ApproveApiKey({
|
||||
...current,
|
||||
customApiKeyResponses: {
|
||||
...current.customApiKeyResponses,
|
||||
approved: [
|
||||
...(current.customApiKeyResponses?.approved ?? []),
|
||||
customApiKeyTruncated,
|
||||
],
|
||||
approved: [...(current.customApiKeyResponses?.approved ?? []), customApiKeyTruncated],
|
||||
},
|
||||
}))
|
||||
onDone(true)
|
||||
break
|
||||
}));
|
||||
onDone(true);
|
||||
break;
|
||||
}
|
||||
case 'no': {
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
customApiKeyResponses: {
|
||||
...current.customApiKeyResponses,
|
||||
rejected: [
|
||||
...(current.customApiKeyResponses?.rejected ?? []),
|
||||
customApiKeyTruncated,
|
||||
],
|
||||
rejected: [...(current.customApiKeyResponses?.rejected ?? []), customApiKeyTruncated],
|
||||
},
|
||||
}))
|
||||
onDone(false)
|
||||
break
|
||||
}));
|
||||
onDone(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Detected a custom API key in your environment"
|
||||
color="warning"
|
||||
onCancel={() => onChange('no')}
|
||||
>
|
||||
<Dialog title="Detected a custom API key in your environment" color="warning" onCancel={() => onChange('no')}>
|
||||
<Text>
|
||||
<Text bold>ANTHROPIC_API_KEY</Text>
|
||||
<Text>: sk-ant-...{customApiKeyTruncated}</Text>
|
||||
@@ -74,5 +61,5 @@ export function ApproveApiKey({
|
||||
onCancel={() => onChange('no')}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,52 +1,48 @@
|
||||
import React from 'react'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import { Box, Dialog, Link, Text } from '@anthropic/ink'
|
||||
import { updateSettingsForSource } from '../utils/settings/settings.js'
|
||||
import { Select } from './CustomSelect/index.js'
|
||||
import React from 'react';
|
||||
import { logEvent } from 'src/services/analytics/index.js';
|
||||
import { Box, Dialog, Link, Text } from '@anthropic/ink';
|
||||
import { updateSettingsForSource } from '../utils/settings/settings.js';
|
||||
import { Select } from './CustomSelect/index.js';
|
||||
|
||||
// NOTE: This copy is legally reviewed — do not modify without Legal team approval.
|
||||
export const AUTO_MODE_DESCRIPTION =
|
||||
"Auto mode lets Claude handle permission prompts automatically — Claude checks each tool call for risky actions and prompt injection before executing. Actions Claude identifies as safe are executed, while actions Claude identifies as risky are blocked and Claude may try a different approach. Ideal for long-running tasks. Sessions are slightly more expensive. Claude can make mistakes that allow harmful commands to run, it's recommended to only use in isolated environments. Shift+Tab to change mode."
|
||||
"Auto mode lets Claude handle permission prompts automatically — Claude checks each tool call for risky actions and prompt injection before executing. Actions Claude identifies as safe are executed, while actions Claude identifies as risky are blocked and Claude may try a different approach. Ideal for long-running tasks. Sessions are slightly more expensive. Claude can make mistakes that allow harmful commands to run, it's recommended to only use in isolated environments. Shift+Tab to change mode.";
|
||||
|
||||
type Props = {
|
||||
onAccept(): void
|
||||
onDecline(): void
|
||||
onAccept(): void;
|
||||
onDecline(): void;
|
||||
// Startup gate: decline exits the process, so relabel accordingly.
|
||||
declineExits?: boolean
|
||||
}
|
||||
declineExits?: boolean;
|
||||
};
|
||||
|
||||
export function AutoModeOptInDialog({
|
||||
onAccept,
|
||||
onDecline,
|
||||
declineExits,
|
||||
}: Props): React.ReactNode {
|
||||
export function AutoModeOptInDialog({ onAccept, onDecline, declineExits }: Props): React.ReactNode {
|
||||
React.useEffect(() => {
|
||||
logEvent('tengu_auto_mode_opt_in_dialog_shown', {})
|
||||
}, [])
|
||||
logEvent('tengu_auto_mode_opt_in_dialog_shown', {});
|
||||
}, []);
|
||||
|
||||
function onChange(value: 'accept' | 'accept-default' | 'decline') {
|
||||
switch (value) {
|
||||
case 'accept': {
|
||||
logEvent('tengu_auto_mode_opt_in_dialog_accept', {})
|
||||
logEvent('tengu_auto_mode_opt_in_dialog_accept', {});
|
||||
updateSettingsForSource('userSettings', {
|
||||
skipAutoPermissionPrompt: true,
|
||||
})
|
||||
onAccept()
|
||||
break
|
||||
});
|
||||
onAccept();
|
||||
break;
|
||||
}
|
||||
case 'accept-default': {
|
||||
logEvent('tengu_auto_mode_opt_in_dialog_accept_default', {})
|
||||
logEvent('tengu_auto_mode_opt_in_dialog_accept_default', {});
|
||||
updateSettingsForSource('userSettings', {
|
||||
skipAutoPermissionPrompt: true,
|
||||
permissions: { defaultMode: 'auto' },
|
||||
})
|
||||
onAccept()
|
||||
break
|
||||
});
|
||||
onAccept();
|
||||
break;
|
||||
}
|
||||
case 'decline': {
|
||||
logEvent('tengu_auto_mode_opt_in_dialog_decline', {})
|
||||
onDecline()
|
||||
break
|
||||
logEvent('tengu_auto_mode_opt_in_dialog_decline', {});
|
||||
onDecline();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,11 +71,9 @@ export function AutoModeOptInDialog({
|
||||
value: 'decline' as const,
|
||||
},
|
||||
]}
|
||||
onChange={value =>
|
||||
onChange(value as 'accept' | 'accept-default' | 'decline')
|
||||
}
|
||||
onChange={value => onChange(value as 'accept' | 'accept-default' | 'decline')}
|
||||
onCancel={onDecline}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import * as React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { useInterval } from 'usehooks-ts'
|
||||
import { useUpdateNotification } from '../hooks/useUpdateNotification.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
} from 'src/services/analytics/index.js';
|
||||
import { useInterval } from 'usehooks-ts';
|
||||
import { useUpdateNotification } from '../hooks/useUpdateNotification.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import {
|
||||
type AutoUpdaterResult,
|
||||
getLatestVersion,
|
||||
@@ -14,26 +14,23 @@ import {
|
||||
type InstallStatus,
|
||||
installGlobalPackage,
|
||||
shouldSkipVersion,
|
||||
} from '../utils/autoUpdater.js'
|
||||
import { getGlobalConfig, isAutoUpdaterDisabled } from '../utils/config.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { getCurrentInstallationType } from '../utils/doctorDiagnostic.js'
|
||||
import {
|
||||
installOrUpdateClaudePackage,
|
||||
localInstallationExists,
|
||||
} from '../utils/localInstaller.js'
|
||||
import { removeInstalledSymlink } from '../utils/nativeInstaller/index.js'
|
||||
import { gt, gte } from '../utils/semver.js'
|
||||
import { getInitialSettings } from '../utils/settings/settings.js'
|
||||
} from '../utils/autoUpdater.js';
|
||||
import { getGlobalConfig, isAutoUpdaterDisabled } from '../utils/config.js';
|
||||
import { logForDebugging } from '../utils/debug.js';
|
||||
import { getCurrentInstallationType } from '../utils/doctorDiagnostic.js';
|
||||
import { installOrUpdateClaudePackage, localInstallationExists } from '../utils/localInstaller.js';
|
||||
import { removeInstalledSymlink } from '../utils/nativeInstaller/index.js';
|
||||
import { gt, gte } from '../utils/semver.js';
|
||||
import { getInitialSettings } from '../utils/settings/settings.js';
|
||||
|
||||
type Props = {
|
||||
isUpdating: boolean
|
||||
onChangeIsUpdating: (isUpdating: boolean) => void
|
||||
onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void
|
||||
autoUpdaterResult: AutoUpdaterResult | null
|
||||
showSuccessMessage: boolean
|
||||
verbose: boolean
|
||||
}
|
||||
isUpdating: boolean;
|
||||
onChangeIsUpdating: (isUpdating: boolean) => void;
|
||||
onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void;
|
||||
autoUpdaterResult: AutoUpdaterResult | null;
|
||||
showSuccessMessage: boolean;
|
||||
verbose: boolean;
|
||||
};
|
||||
|
||||
export function AutoUpdater({
|
||||
isUpdating,
|
||||
@@ -44,61 +41,56 @@ export function AutoUpdater({
|
||||
verbose,
|
||||
}: Props): React.ReactNode {
|
||||
const [versions, setVersions] = useState<{
|
||||
global?: string | null
|
||||
latest?: string | null
|
||||
}>({})
|
||||
const [hasLocalInstall, setHasLocalInstall] = useState(false)
|
||||
const updateSemver = useUpdateNotification(autoUpdaterResult?.version)
|
||||
global?: string | null;
|
||||
latest?: string | null;
|
||||
}>({});
|
||||
const [hasLocalInstall, setHasLocalInstall] = useState(false);
|
||||
const updateSemver = useUpdateNotification(autoUpdaterResult?.version);
|
||||
|
||||
useEffect(() => {
|
||||
void localInstallationExists().then(setHasLocalInstall)
|
||||
}, [])
|
||||
void localInstallationExists().then(setHasLocalInstall);
|
||||
}, []);
|
||||
|
||||
// Track latest isUpdating value in a ref so the memoized checkForUpdates
|
||||
// callback always sees the current value. Without this, the 30-minute
|
||||
// interval fires with a stale closure where isUpdating is false, allowing
|
||||
// a concurrent installGlobalPackage() to run while one is already in
|
||||
// progress.
|
||||
const isUpdatingRef = useRef(isUpdating)
|
||||
isUpdatingRef.current = isUpdating
|
||||
const isUpdatingRef = useRef(isUpdating);
|
||||
isUpdatingRef.current = isUpdating;
|
||||
|
||||
const checkForUpdates = React.useCallback(async () => {
|
||||
if (isUpdatingRef.current) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV === 'test' ||
|
||||
process.env.NODE_ENV === 'development'
|
||||
) {
|
||||
logForDebugging(
|
||||
'AutoUpdater: Skipping update check in test/dev environment',
|
||||
)
|
||||
return
|
||||
if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development') {
|
||||
logForDebugging('AutoUpdater: Skipping update check in test/dev environment');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentVersion = MACRO.VERSION
|
||||
const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest'
|
||||
let latestVersion = await getLatestVersion(channel)
|
||||
const isDisabled = isAutoUpdaterDisabled()
|
||||
const currentVersion = MACRO.VERSION;
|
||||
const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest';
|
||||
let latestVersion = await getLatestVersion(channel);
|
||||
const isDisabled = isAutoUpdaterDisabled();
|
||||
|
||||
// Check if max version is set (server-side kill switch for auto-updates)
|
||||
const maxVersion = await getMaxVersion()
|
||||
const maxVersion = await getMaxVersion();
|
||||
if (maxVersion && latestVersion && gt(latestVersion, maxVersion)) {
|
||||
logForDebugging(
|
||||
`AutoUpdater: maxVersion ${maxVersion} is set, capping update from ${latestVersion} to ${maxVersion}`,
|
||||
)
|
||||
);
|
||||
if (gte(currentVersion, maxVersion)) {
|
||||
logForDebugging(
|
||||
`AutoUpdater: current version ${currentVersion} is already at or above maxVersion ${maxVersion}, skipping update`,
|
||||
)
|
||||
setVersions({ global: currentVersion, latest: latestVersion })
|
||||
return
|
||||
);
|
||||
setVersions({ global: currentVersion, latest: latestVersion });
|
||||
return;
|
||||
}
|
||||
latestVersion = maxVersion
|
||||
latestVersion = maxVersion;
|
||||
}
|
||||
|
||||
setVersions({ global: currentVersion, latest: latestVersion })
|
||||
setVersions({ global: currentVersion, latest: latestVersion });
|
||||
|
||||
// Check if update needed and perform update
|
||||
if (
|
||||
@@ -108,126 +100,112 @@ export function AutoUpdater({
|
||||
!gte(currentVersion, latestVersion) &&
|
||||
!shouldSkipVersion(latestVersion)
|
||||
) {
|
||||
const startTime = Date.now()
|
||||
onChangeIsUpdating(true)
|
||||
const startTime = Date.now();
|
||||
onChangeIsUpdating(true);
|
||||
|
||||
// Remove native installer symlink since we're using JS-based updates
|
||||
// But only if user hasn't migrated to native installation
|
||||
const config = getGlobalConfig()
|
||||
const config = getGlobalConfig();
|
||||
if (config.installMethod !== 'native') {
|
||||
await removeInstalledSymlink()
|
||||
await removeInstalledSymlink();
|
||||
}
|
||||
|
||||
// Detect actual running installation type
|
||||
const installationType = await getCurrentInstallationType()
|
||||
logForDebugging(
|
||||
`AutoUpdater: Detected installation type: ${installationType}`,
|
||||
)
|
||||
const installationType = await getCurrentInstallationType();
|
||||
logForDebugging(`AutoUpdater: Detected installation type: ${installationType}`);
|
||||
|
||||
// Skip update for development builds
|
||||
if (installationType === 'development') {
|
||||
logForDebugging('AutoUpdater: Cannot auto-update development build')
|
||||
onChangeIsUpdating(false)
|
||||
return
|
||||
logForDebugging('AutoUpdater: Cannot auto-update development build');
|
||||
onChangeIsUpdating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Choose the appropriate update method based on what's actually running
|
||||
let installStatus: InstallStatus
|
||||
let updateMethod: 'local' | 'global'
|
||||
let installStatus: InstallStatus;
|
||||
let updateMethod: 'local' | 'global';
|
||||
|
||||
if (installationType === 'npm-local') {
|
||||
// Use local update for local installations
|
||||
logForDebugging('AutoUpdater: Using local update method')
|
||||
updateMethod = 'local'
|
||||
installStatus = await installOrUpdateClaudePackage(channel)
|
||||
logForDebugging('AutoUpdater: Using local update method');
|
||||
updateMethod = 'local';
|
||||
installStatus = await installOrUpdateClaudePackage(channel);
|
||||
} else if (installationType === 'npm-global') {
|
||||
// Use global update for global installations
|
||||
logForDebugging('AutoUpdater: Using global update method')
|
||||
updateMethod = 'global'
|
||||
installStatus = await installGlobalPackage()
|
||||
logForDebugging('AutoUpdater: Using global update method');
|
||||
updateMethod = 'global';
|
||||
installStatus = await installGlobalPackage();
|
||||
} else if (installationType === 'native') {
|
||||
// This shouldn't happen - native should use NativeAutoUpdater
|
||||
logForDebugging(
|
||||
'AutoUpdater: Unexpected native installation in non-native updater',
|
||||
)
|
||||
onChangeIsUpdating(false)
|
||||
return
|
||||
logForDebugging('AutoUpdater: Unexpected native installation in non-native updater');
|
||||
onChangeIsUpdating(false);
|
||||
return;
|
||||
} else {
|
||||
// Fallback to config-based detection for unknown types
|
||||
logForDebugging(
|
||||
`AutoUpdater: Unknown installation type, falling back to config`,
|
||||
)
|
||||
const isMigrated = config.installMethod === 'local'
|
||||
updateMethod = isMigrated ? 'local' : 'global'
|
||||
logForDebugging(`AutoUpdater: Unknown installation type, falling back to config`);
|
||||
const isMigrated = config.installMethod === 'local';
|
||||
updateMethod = isMigrated ? 'local' : 'global';
|
||||
|
||||
if (isMigrated) {
|
||||
installStatus = await installOrUpdateClaudePackage(channel)
|
||||
installStatus = await installOrUpdateClaudePackage(channel);
|
||||
} else {
|
||||
installStatus = await installGlobalPackage()
|
||||
installStatus = await installGlobalPackage();
|
||||
}
|
||||
}
|
||||
|
||||
onChangeIsUpdating(false)
|
||||
onChangeIsUpdating(false);
|
||||
|
||||
if (installStatus === 'success') {
|
||||
logEvent('tengu_auto_updater_success', {
|
||||
fromVersion:
|
||||
currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
toVersion:
|
||||
latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
fromVersion: currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
toVersion: latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
durationMs: Date.now() - startTime,
|
||||
wasMigrated: updateMethod === 'local',
|
||||
installationType:
|
||||
installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
installationType: installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
} else {
|
||||
logEvent('tengu_auto_updater_fail', {
|
||||
fromVersion:
|
||||
currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
attemptedVersion:
|
||||
latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
status:
|
||||
installStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
fromVersion: currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
attemptedVersion: latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
status: installStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
durationMs: Date.now() - startTime,
|
||||
wasMigrated: updateMethod === 'local',
|
||||
installationType:
|
||||
installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
installationType: installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
}
|
||||
|
||||
onAutoUpdaterResult({
|
||||
version: latestVersion,
|
||||
status: installStatus,
|
||||
})
|
||||
});
|
||||
}
|
||||
// isUpdating intentionally omitted from deps; we read isUpdatingRef
|
||||
// instead so the guard is always current without changing callback
|
||||
// identity (which would re-trigger the initial-check useEffect below).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onAutoUpdaterResult])
|
||||
}, [onAutoUpdaterResult]);
|
||||
|
||||
// Initial check
|
||||
useEffect(() => {
|
||||
void checkForUpdates()
|
||||
}, [checkForUpdates])
|
||||
void checkForUpdates();
|
||||
}, [checkForUpdates]);
|
||||
|
||||
// Check every 30 minutes
|
||||
useInterval(checkForUpdates, 30 * 60 * 1000)
|
||||
useInterval(checkForUpdates, 30 * 60 * 1000);
|
||||
|
||||
if (!autoUpdaterResult?.version && (!versions.global || !versions.latest)) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!autoUpdaterResult?.version && !isUpdating) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
{verbose && (
|
||||
<Text dimColor wrap="truncate">
|
||||
globalVersion: {versions.global} · latestVersion:{' '}
|
||||
{versions.latest}
|
||||
globalVersion: {versions.global} · latestVersion: {versions.latest}
|
||||
</Text>
|
||||
)}
|
||||
{isUpdating ? (
|
||||
@@ -247,8 +225,7 @@ export function AutoUpdater({
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
{(autoUpdaterResult?.status === 'install_failed' ||
|
||||
autoUpdaterResult?.status === 'no_permissions') && (
|
||||
{(autoUpdaterResult?.status === 'install_failed' || autoUpdaterResult?.status === 'no_permissions') && (
|
||||
<Text color="error" wrap="truncate">
|
||||
✗ Auto-update failed · Try <Text bold>claude doctor</Text> or{' '}
|
||||
<Text bold>
|
||||
@@ -259,5 +236,5 @@ export function AutoUpdater({
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import * as React from 'react'
|
||||
import type { AutoUpdaterResult } from '../utils/autoUpdater.js'
|
||||
import { isAutoUpdaterDisabled } from '../utils/config.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { getCurrentInstallationType } from '../utils/doctorDiagnostic.js'
|
||||
import { AutoUpdater } from './AutoUpdater.js'
|
||||
import { NativeAutoUpdater } from './NativeAutoUpdater.js'
|
||||
import { PackageManagerAutoUpdater } from './PackageManagerAutoUpdater.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import * as React from 'react';
|
||||
import type { AutoUpdaterResult } from '../utils/autoUpdater.js';
|
||||
import { isAutoUpdaterDisabled } from '../utils/config.js';
|
||||
import { logForDebugging } from '../utils/debug.js';
|
||||
import { getCurrentInstallationType } from '../utils/doctorDiagnostic.js';
|
||||
import { AutoUpdater } from './AutoUpdater.js';
|
||||
import { NativeAutoUpdater } from './NativeAutoUpdater.js';
|
||||
import { PackageManagerAutoUpdater } from './PackageManagerAutoUpdater.js';
|
||||
|
||||
type Props = {
|
||||
isUpdating: boolean
|
||||
onChangeIsUpdating: (isUpdating: boolean) => void
|
||||
onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void
|
||||
autoUpdaterResult: AutoUpdaterResult | null
|
||||
showSuccessMessage: boolean
|
||||
verbose: boolean
|
||||
}
|
||||
isUpdating: boolean;
|
||||
onChangeIsUpdating: (isUpdating: boolean) => void;
|
||||
onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void;
|
||||
autoUpdaterResult: AutoUpdaterResult | null;
|
||||
showSuccessMessage: boolean;
|
||||
verbose: boolean;
|
||||
};
|
||||
|
||||
export function AutoUpdaterWrapper({
|
||||
isUpdating,
|
||||
@@ -25,41 +25,30 @@ export function AutoUpdaterWrapper({
|
||||
showSuccessMessage,
|
||||
verbose,
|
||||
}: Props): React.ReactNode {
|
||||
const [useNativeInstaller, setUseNativeInstaller] = React.useState<
|
||||
boolean | null
|
||||
>(null)
|
||||
const [isPackageManager, setIsPackageManager] = React.useState<
|
||||
boolean | null
|
||||
>(null)
|
||||
const [useNativeInstaller, setUseNativeInstaller] = React.useState<boolean | null>(null);
|
||||
const [isPackageManager, setIsPackageManager] = React.useState<boolean | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function checkInstallation() {
|
||||
// Skip installation type detection if auto-updates are disabled (ant-only)
|
||||
// This avoids potentially slow package manager detection (spawnSync calls)
|
||||
if (
|
||||
feature('SKIP_DETECTION_WHEN_AUTOUPDATES_DISABLED') &&
|
||||
isAutoUpdaterDisabled()
|
||||
) {
|
||||
logForDebugging(
|
||||
'AutoUpdaterWrapper: Skipping detection, auto-updates disabled',
|
||||
)
|
||||
return
|
||||
if (feature('SKIP_DETECTION_WHEN_AUTOUPDATES_DISABLED') && isAutoUpdaterDisabled()) {
|
||||
logForDebugging('AutoUpdaterWrapper: Skipping detection, auto-updates disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const installationType = await getCurrentInstallationType()
|
||||
logForDebugging(
|
||||
`AutoUpdaterWrapper: Installation type: ${installationType}`,
|
||||
)
|
||||
setUseNativeInstaller(installationType === 'native')
|
||||
setIsPackageManager(installationType === 'package-manager')
|
||||
const installationType = await getCurrentInstallationType();
|
||||
logForDebugging(`AutoUpdaterWrapper: Installation type: ${installationType}`);
|
||||
setUseNativeInstaller(installationType === 'native');
|
||||
setIsPackageManager(installationType === 'package-manager');
|
||||
}
|
||||
|
||||
void checkInstallation()
|
||||
}, [])
|
||||
void checkInstallation();
|
||||
}, []);
|
||||
|
||||
// Don't render until we know the installation type
|
||||
if (useNativeInstaller === null || isPackageManager === null) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isPackageManager) {
|
||||
@@ -72,10 +61,10 @@ export function AutoUpdaterWrapper({
|
||||
onChangeIsUpdating={onChangeIsUpdating}
|
||||
showSuccessMessage={showSuccessMessage}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const Updater = useNativeInstaller ? NativeAutoUpdater : AutoUpdater
|
||||
const Updater = useNativeInstaller ? NativeAutoUpdater : AutoUpdater;
|
||||
|
||||
return (
|
||||
<Updater
|
||||
@@ -86,5 +75,5 @@ export function AutoUpdaterWrapper({
|
||||
onChangeIsUpdating={onChangeIsUpdating}
|
||||
showSuccessMessage={showSuccessMessage}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,30 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Box, Link, Text } from '@anthropic/ink'
|
||||
import {
|
||||
type AwsAuthStatus,
|
||||
AwsAuthStatusManager,
|
||||
} from '../utils/awsAuthStatusManager.js'
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Link, Text } from '@anthropic/ink';
|
||||
import { type AwsAuthStatus, AwsAuthStatusManager } from '../utils/awsAuthStatusManager.js';
|
||||
|
||||
const URL_RE = /https?:\/\/\S+/
|
||||
const URL_RE = /https?:\/\/\S+/;
|
||||
|
||||
export function AwsAuthStatusBox(): React.ReactNode {
|
||||
const [status, setStatus] = useState<AwsAuthStatus>(
|
||||
AwsAuthStatusManager.getInstance().getStatus(),
|
||||
)
|
||||
const [status, setStatus] = useState<AwsAuthStatus>(AwsAuthStatusManager.getInstance().getStatus());
|
||||
|
||||
useEffect(() => {
|
||||
// Subscribe to status updates
|
||||
const unsubscribe = AwsAuthStatusManager.getInstance().subscribe(setStatus)
|
||||
return unsubscribe
|
||||
}, [])
|
||||
const unsubscribe = AwsAuthStatusManager.getInstance().subscribe(setStatus);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Don't show anything if not authenticating and no error
|
||||
if (!status.isAuthenticating && !status.error && status.output.length === 0) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't show if authentication succeeded (no error and not authenticating)
|
||||
if (!status.isAuthenticating && !status.error) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="permission"
|
||||
paddingX={1}
|
||||
marginY={1}
|
||||
>
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="permission" paddingX={1} marginY={1}>
|
||||
<Text bold color="permission">
|
||||
Cloud Authentication
|
||||
</Text>
|
||||
@@ -43,25 +32,25 @@ export function AwsAuthStatusBox(): React.ReactNode {
|
||||
{status.output.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{status.output.slice(-5).map((line, index) => {
|
||||
const m = line.match(URL_RE)
|
||||
const m = line.match(URL_RE);
|
||||
if (!m) {
|
||||
return (
|
||||
<Text key={index} dimColor>
|
||||
{line}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
const url = m[0]
|
||||
const start = m.index ?? 0
|
||||
const before = line.slice(0, start)
|
||||
const after = line.slice(start + url.length)
|
||||
const url = m[0];
|
||||
const start = m.index ?? 0;
|
||||
const before = line.slice(0, start);
|
||||
const after = line.slice(start + url.length);
|
||||
return (
|
||||
<Text key={index} dimColor>
|
||||
{before}
|
||||
<Link url={url}>{url}</Link>
|
||||
{after}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
@@ -72,5 +61,5 @@ export function AwsAuthStatusBox(): React.ReactNode {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import React from 'react'
|
||||
import { renderPlaceholder } from '../hooks/renderPlaceholder.js'
|
||||
import { usePasteHandler } from '../hooks/usePasteHandler.js'
|
||||
import { useDeclaredCursor } from '@anthropic/ink'
|
||||
import { Ansi, Box, Text, useInput } from '@anthropic/ink'
|
||||
import type {
|
||||
BaseInputState,
|
||||
BaseTextInputProps,
|
||||
} from '../types/textInputTypes.js'
|
||||
import type { TextHighlight } from '../utils/textHighlighting.js'
|
||||
import { HighlightedInput } from './PromptInput/ShimmeredInput.js'
|
||||
import React from 'react';
|
||||
import { renderPlaceholder } from '../hooks/renderPlaceholder.js';
|
||||
import { usePasteHandler } from '../hooks/usePasteHandler.js';
|
||||
import { useDeclaredCursor } from '@anthropic/ink';
|
||||
import { Ansi, Box, Text, useInput } from '@anthropic/ink';
|
||||
import type { BaseInputState, BaseTextInputProps } from '../types/textInputTypes.js';
|
||||
import type { TextHighlight } from '../utils/textHighlighting.js';
|
||||
import { HighlightedInput } from './PromptInput/ShimmeredInput.js';
|
||||
|
||||
type BaseTextInputComponentProps = BaseTextInputProps & {
|
||||
inputState: BaseInputState
|
||||
children?: React.ReactNode
|
||||
terminalFocus: boolean
|
||||
highlights?: TextHighlight[]
|
||||
invert?: (text: string) => string
|
||||
hidePlaceholderText?: boolean
|
||||
}
|
||||
inputState: BaseInputState;
|
||||
children?: React.ReactNode;
|
||||
terminalFocus: boolean;
|
||||
highlights?: TextHighlight[];
|
||||
invert?: (text: string) => string;
|
||||
hidePlaceholderText?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A base component for text inputs that handles rendering and basic input
|
||||
@@ -30,7 +27,7 @@ export function BaseTextInput({
|
||||
hidePlaceholderText,
|
||||
...props
|
||||
}: BaseTextInputComponentProps): React.ReactNode {
|
||||
const { onInput, renderedValue, cursorLine, cursorColumn } = inputState
|
||||
const { onInput, renderedValue, cursorLine, cursorColumn } = inputState;
|
||||
|
||||
// Park the native terminal cursor at the input caret. Terminal emulators
|
||||
// position IME preedit text at the physical cursor, and screen readers /
|
||||
@@ -43,27 +40,27 @@ export function BaseTextInput({
|
||||
line: cursorLine,
|
||||
column: cursorColumn,
|
||||
active: Boolean(props.focus && props.showCursor && terminalFocus),
|
||||
})
|
||||
});
|
||||
|
||||
const { wrappedOnInput, isPasting } = usePasteHandler({
|
||||
onPaste: props.onPaste,
|
||||
onInput: (input, key) => {
|
||||
// Prevent Enter key from triggering submission during paste
|
||||
if (isPasting && key.return) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
onInput(input, key)
|
||||
onInput(input, key);
|
||||
},
|
||||
onImagePaste: props.onImagePaste,
|
||||
})
|
||||
});
|
||||
|
||||
// Notify parent when paste state changes
|
||||
const { onIsPastingChange } = props
|
||||
const { onIsPastingChange } = props;
|
||||
React.useEffect(() => {
|
||||
if (onIsPastingChange) {
|
||||
onIsPastingChange(isPasting)
|
||||
onIsPastingChange(isPasting);
|
||||
}
|
||||
}, [isPasting, onIsPastingChange])
|
||||
}, [isPasting, onIsPastingChange]);
|
||||
|
||||
const { showPlaceholder, renderedPlaceholder } = renderPlaceholder({
|
||||
placeholder: props.placeholder,
|
||||
@@ -73,9 +70,9 @@ export function BaseTextInput({
|
||||
terminalFocus,
|
||||
invert,
|
||||
hidePlaceholderText,
|
||||
})
|
||||
});
|
||||
|
||||
useInput(wrappedOnInput, { isActive: props.focus })
|
||||
useInput(wrappedOnInput, { isActive: props.focus });
|
||||
|
||||
// Show argument hint only when we have a value and the hint is provided
|
||||
// Only show the argument hint when:
|
||||
@@ -84,30 +81,21 @@ export function BaseTextInput({
|
||||
// 3. The command doesn't have arguments yet (no text after the space)
|
||||
// 4. We're actually typing a command (the value starts with /)
|
||||
const commandWithoutArgs =
|
||||
(props.value && props.value.trim().indexOf(' ') === -1) ||
|
||||
(props.value && props.value.endsWith(' '))
|
||||
(props.value && props.value.trim().indexOf(' ') === -1) || (props.value && props.value.endsWith(' '));
|
||||
|
||||
const showArgumentHint = Boolean(
|
||||
props.argumentHint &&
|
||||
props.value &&
|
||||
commandWithoutArgs &&
|
||||
props.value.startsWith('/'),
|
||||
)
|
||||
props.argumentHint && props.value && commandWithoutArgs && props.value.startsWith('/'),
|
||||
);
|
||||
|
||||
// Filter out highlights that contain the cursor position
|
||||
const cursorFiltered =
|
||||
props.showCursor && props.highlights
|
||||
? props.highlights.filter(
|
||||
h =>
|
||||
h.dimColor ||
|
||||
props.cursorOffset < h.start ||
|
||||
props.cursorOffset >= h.end,
|
||||
)
|
||||
: props.highlights
|
||||
? props.highlights.filter(h => h.dimColor || props.cursorOffset < h.start || props.cursorOffset >= h.end)
|
||||
: props.highlights;
|
||||
|
||||
// Adjust highlights for viewport windowing: highlight positions reference the
|
||||
// full input text, but renderedValue only contains the windowed subset.
|
||||
const { viewportCharOffset, viewportCharEnd } = inputState
|
||||
const { viewportCharOffset, viewportCharEnd } = inputState;
|
||||
const filteredHighlights =
|
||||
cursorFiltered && viewportCharOffset > 0
|
||||
? cursorFiltered
|
||||
@@ -117,17 +105,14 @@ export function BaseTextInput({
|
||||
start: Math.max(0, h.start - viewportCharOffset),
|
||||
end: h.end - viewportCharOffset,
|
||||
}))
|
||||
: cursorFiltered
|
||||
: cursorFiltered;
|
||||
|
||||
const hasHighlights = filteredHighlights && filteredHighlights.length > 0
|
||||
const hasHighlights = filteredHighlights && filteredHighlights.length > 0;
|
||||
|
||||
if (hasHighlights) {
|
||||
return (
|
||||
<Box ref={cursorRef}>
|
||||
<HighlightedInput
|
||||
text={renderedValue}
|
||||
highlights={filteredHighlights}
|
||||
/>
|
||||
<HighlightedInput text={renderedValue} highlights={filteredHighlights} />
|
||||
{showArgumentHint && (
|
||||
<Text dimColor>
|
||||
{props.value?.endsWith(' ') ? '' : ' '}
|
||||
@@ -136,7 +121,7 @@ export function BaseTextInput({
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -158,5 +143,5 @@ export function BaseTextInput({
|
||||
{children}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
import React from 'react'
|
||||
import { Box } from '@anthropic/ink'
|
||||
import { BashTool } from '@claude-code-best/builtin-tools/tools/BashTool/BashTool.js'
|
||||
import type { ShellProgress } from '../types/tools.js'
|
||||
import { UserBashInputMessage } from './messages/UserBashInputMessage.js'
|
||||
import { ShellProgressMessage } from './shell/ShellProgressMessage.js'
|
||||
import React from 'react';
|
||||
import { Box } from '@anthropic/ink';
|
||||
import { BashTool } from '@claude-code-best/builtin-tools/tools/BashTool/BashTool.js';
|
||||
import type { ShellProgress } from '../types/tools.js';
|
||||
import { UserBashInputMessage } from './messages/UserBashInputMessage.js';
|
||||
import { ShellProgressMessage } from './shell/ShellProgressMessage.js';
|
||||
|
||||
type Props = {
|
||||
input: string
|
||||
progress: ShellProgress | null
|
||||
verbose: boolean
|
||||
}
|
||||
input: string;
|
||||
progress: ShellProgress | null;
|
||||
verbose: boolean;
|
||||
};
|
||||
|
||||
export function BashModeProgress({
|
||||
input,
|
||||
progress,
|
||||
verbose,
|
||||
}: Props): React.ReactNode {
|
||||
export function BashModeProgress({ input, progress, verbose }: Props): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<UserBashInputMessage
|
||||
addMargin={false}
|
||||
param={{ text: `<bash-input>${input}</bash-input>`, type: 'text' }}
|
||||
/>
|
||||
<UserBashInputMessage addMargin={false} param={{ text: `<bash-input>${input}</bash-input>`, type: 'text' }} />
|
||||
{progress ? (
|
||||
<ShellProgressMessage
|
||||
fullOutput={progress.fullOutput}
|
||||
@@ -38,5 +31,5 @@ export function BashModeProgress({
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,67 +1,64 @@
|
||||
import { basename } from 'path'
|
||||
import { toString as qrToString } from 'qrcode'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getOriginalCwd } from '../bootstrap/state.js'
|
||||
import { basename } from 'path';
|
||||
import { toString as qrToString } from 'qrcode';
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getOriginalCwd } from '../bootstrap/state.js';
|
||||
import {
|
||||
buildActiveFooterText,
|
||||
buildIdleFooterText,
|
||||
FAILED_FOOTER_TEXT,
|
||||
getBridgeStatus,
|
||||
} from '../bridge/bridgeStatusUtil.js'
|
||||
import {
|
||||
BRIDGE_FAILED_INDICATOR,
|
||||
BRIDGE_READY_INDICATOR,
|
||||
} from '../constants/figures.js'
|
||||
import { useRegisterOverlay } from '../context/overlayContext.js'
|
||||
} from '../bridge/bridgeStatusUtil.js';
|
||||
import { BRIDGE_FAILED_INDICATOR, BRIDGE_READY_INDICATOR } from '../constants/figures.js';
|
||||
import { useRegisterOverlay } from '../context/overlayContext.js';
|
||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw 'd' key for disconnect, not a configurable keybinding action
|
||||
import { Box, Text, useInput } from '@anthropic/ink'
|
||||
import { useKeybindings } from '../keybindings/useKeybinding.js'
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js'
|
||||
import { saveGlobalConfig } from '../utils/config.js'
|
||||
import { getBranch } from '../utils/git.js'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
import { Box, Text, useInput } from '@anthropic/ink';
|
||||
import { useKeybindings } from '../keybindings/useKeybinding.js';
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js';
|
||||
import { saveGlobalConfig } from '../utils/config.js';
|
||||
import { getBranch } from '../utils/git.js';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
|
||||
type Props = {
|
||||
onDone: () => void
|
||||
}
|
||||
onDone: () => void;
|
||||
};
|
||||
|
||||
export function BridgeDialog({ onDone }: Props): React.ReactNode {
|
||||
useRegisterOverlay('bridge-dialog')
|
||||
useRegisterOverlay('bridge-dialog');
|
||||
|
||||
const connected = useAppState(s => s.replBridgeConnected)
|
||||
const sessionActive = useAppState(s => s.replBridgeSessionActive)
|
||||
const reconnecting = useAppState(s => s.replBridgeReconnecting)
|
||||
const connectUrl = useAppState(s => s.replBridgeConnectUrl)
|
||||
const sessionUrl = useAppState(s => s.replBridgeSessionUrl)
|
||||
const error = useAppState(s => s.replBridgeError)
|
||||
const explicit = useAppState(s => s.replBridgeExplicit)
|
||||
const environmentId = useAppState(s => s.replBridgeEnvironmentId)
|
||||
const sessionId = useAppState(s => s.replBridgeSessionId)
|
||||
const verbose = useAppState(s => s.verbose)
|
||||
const setAppState = useSetAppState()
|
||||
const connected = useAppState(s => s.replBridgeConnected);
|
||||
const sessionActive = useAppState(s => s.replBridgeSessionActive);
|
||||
const reconnecting = useAppState(s => s.replBridgeReconnecting);
|
||||
const connectUrl = useAppState(s => s.replBridgeConnectUrl);
|
||||
const sessionUrl = useAppState(s => s.replBridgeSessionUrl);
|
||||
const error = useAppState(s => s.replBridgeError);
|
||||
const explicit = useAppState(s => s.replBridgeExplicit);
|
||||
const environmentId = useAppState(s => s.replBridgeEnvironmentId);
|
||||
const sessionId = useAppState(s => s.replBridgeSessionId);
|
||||
const verbose = useAppState(s => s.verbose);
|
||||
const setAppState = useSetAppState();
|
||||
|
||||
const [showQR, setShowQR] = useState(false)
|
||||
const [qrText, setQrText] = useState('')
|
||||
const [branchName, setBranchName] = useState('')
|
||||
const [showQR, setShowQR] = useState(false);
|
||||
const [qrText, setQrText] = useState('');
|
||||
const [branchName, setBranchName] = useState('');
|
||||
|
||||
const repoName = basename(getOriginalCwd())
|
||||
const repoName = basename(getOriginalCwd());
|
||||
|
||||
// Fetch branch name on mount
|
||||
useEffect(() => {
|
||||
getBranch()
|
||||
.then(setBranchName)
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// The URL to display/QR: session URL when connected, connect URL when ready
|
||||
const displayUrl = sessionActive ? sessionUrl : connectUrl
|
||||
const displayUrl = sessionActive ? sessionUrl : connectUrl;
|
||||
|
||||
// Generate QR code when URL changes or QR is toggled on
|
||||
useEffect(() => {
|
||||
if (!showQR || !displayUrl) {
|
||||
setQrText('')
|
||||
return
|
||||
setQrText('');
|
||||
return;
|
||||
}
|
||||
qrToString(displayUrl, {
|
||||
type: 'terminal',
|
||||
@@ -69,18 +66,18 @@ export function BridgeDialog({ onDone }: Props): React.ReactNode {
|
||||
small: true,
|
||||
})
|
||||
.then(setQrText)
|
||||
.catch(() => setQrText(''))
|
||||
}, [showQR, displayUrl])
|
||||
.catch(() => setQrText(''));
|
||||
}, [showQR, displayUrl]);
|
||||
|
||||
useKeybindings(
|
||||
{
|
||||
'confirm:yes': onDone,
|
||||
'confirm:toggle': () => {
|
||||
setShowQR(prev => !prev)
|
||||
setShowQR(prev => !prev);
|
||||
},
|
||||
},
|
||||
{ context: 'Confirmation' },
|
||||
)
|
||||
);
|
||||
|
||||
useInput(input => {
|
||||
if (input === 'd') {
|
||||
@@ -90,33 +87,32 @@ export function BridgeDialog({ onDone }: Props): React.ReactNode {
|
||||
// GB-rollout user out permanently.
|
||||
if (explicit) {
|
||||
saveGlobalConfig(current => {
|
||||
if (current.remoteControlAtStartup === false) return current
|
||||
return { ...current, remoteControlAtStartup: false }
|
||||
})
|
||||
if (current.remoteControlAtStartup === false) return current;
|
||||
return { ...current, remoteControlAtStartup: false };
|
||||
});
|
||||
}
|
||||
setAppState(prev => {
|
||||
if (!prev.replBridgeEnabled) return prev
|
||||
return { ...prev, replBridgeEnabled: false }
|
||||
})
|
||||
onDone()
|
||||
if (!prev.replBridgeEnabled) return prev;
|
||||
return { ...prev, replBridgeEnabled: false };
|
||||
});
|
||||
onDone();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const { label: statusLabel, color: statusColor } = getBridgeStatus({
|
||||
error,
|
||||
connected,
|
||||
sessionActive,
|
||||
reconnecting,
|
||||
})
|
||||
const indicator = error ? BRIDGE_FAILED_INDICATOR : BRIDGE_READY_INDICATOR
|
||||
const qrLines = qrText ? qrText.split('\n').filter(l => l.length > 0) : []
|
||||
});
|
||||
const indicator = error ? BRIDGE_FAILED_INDICATOR : BRIDGE_READY_INDICATOR;
|
||||
const qrLines = qrText ? qrText.split('\n').filter(l => l.length > 0) : [];
|
||||
|
||||
// Build suffix with repo and branch (matches standalone bridge format)
|
||||
const contextParts: string[] = []
|
||||
if (repoName) contextParts.push(repoName)
|
||||
if (branchName) contextParts.push(branchName)
|
||||
const contextSuffix =
|
||||
contextParts.length > 0 ? ' \u00b7 ' + contextParts.join(' \u00b7 ') : ''
|
||||
const contextParts: string[] = [];
|
||||
if (repoName) contextParts.push(repoName);
|
||||
if (branchName) contextParts.push(branchName);
|
||||
const contextSuffix = contextParts.length > 0 ? ' \u00b7 ' + contextParts.join(' \u00b7 ') : '';
|
||||
|
||||
// Footer text matches standalone bridge
|
||||
const footerText = error
|
||||
@@ -125,7 +121,7 @@ export function BridgeDialog({ onDone }: Props): React.ReactNode {
|
||||
? sessionActive
|
||||
? buildActiveFooterText(displayUrl)
|
||||
: buildIdleFooterText(displayUrl)
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Dialog title="Remote Control" onCancel={onDone} hideInputGuide>
|
||||
@@ -138,9 +134,7 @@ export function BridgeDialog({ onDone }: Props): React.ReactNode {
|
||||
<Text dimColor>{contextSuffix}</Text>
|
||||
</Text>
|
||||
{error && <Text color="error">{error}</Text>}
|
||||
{verbose && environmentId && (
|
||||
<Text dimColor>Environment: {environmentId}</Text>
|
||||
)}
|
||||
{verbose && environmentId && <Text dimColor>Environment: {environmentId}</Text>}
|
||||
{verbose && sessionId && <Text dimColor>Session: {sessionId}</Text>}
|
||||
</Box>
|
||||
{showQR && qrLines.length > 0 && (
|
||||
@@ -151,10 +145,8 @@ export function BridgeDialog({ onDone }: Props): React.ReactNode {
|
||||
</Box>
|
||||
)}
|
||||
{footerText && <Text dimColor>{footerText}</Text>}
|
||||
<Text dimColor>
|
||||
d to disconnect · space for QR code · Enter/Esc to close
|
||||
</Text>
|
||||
<Text dimColor>d to disconnect · space for QR code · Enter/Esc to close</Text>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,61 +1,54 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import { Box, Link, Newline, Text } from '@anthropic/ink'
|
||||
import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'
|
||||
import { updateSettingsForSource } from '../utils/settings/settings.js'
|
||||
import { Select } from './CustomSelect/index.js'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
import React, { useCallback } from 'react';
|
||||
import { logEvent } from 'src/services/analytics/index.js';
|
||||
import { Box, Link, Newline, Text } from '@anthropic/ink';
|
||||
import { gracefulShutdownSync } from '../utils/gracefulShutdown.js';
|
||||
import { updateSettingsForSource } from '../utils/settings/settings.js';
|
||||
import { Select } from './CustomSelect/index.js';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
|
||||
type Props = {
|
||||
onAccept(): void
|
||||
}
|
||||
onAccept(): void;
|
||||
};
|
||||
|
||||
export function BypassPermissionsModeDialog({
|
||||
onAccept,
|
||||
}: Props): React.ReactNode {
|
||||
export function BypassPermissionsModeDialog({ onAccept }: Props): React.ReactNode {
|
||||
React.useEffect(() => {
|
||||
logEvent('tengu_bypass_permissions_mode_dialog_shown', {})
|
||||
}, [])
|
||||
logEvent('tengu_bypass_permissions_mode_dialog_shown', {});
|
||||
}, []);
|
||||
|
||||
function onChange(value: 'accept' | 'decline') {
|
||||
switch (value) {
|
||||
case 'accept': {
|
||||
logEvent('tengu_bypass_permissions_mode_dialog_accept', {})
|
||||
logEvent('tengu_bypass_permissions_mode_dialog_accept', {});
|
||||
|
||||
updateSettingsForSource('userSettings', {
|
||||
skipDangerousModePermissionPrompt: true,
|
||||
})
|
||||
onAccept()
|
||||
break
|
||||
});
|
||||
onAccept();
|
||||
break;
|
||||
}
|
||||
case 'decline': {
|
||||
gracefulShutdownSync(1)
|
||||
break
|
||||
gracefulShutdownSync(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = useCallback(() => {
|
||||
gracefulShutdownSync(0)
|
||||
}, [])
|
||||
gracefulShutdownSync(0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="WARNING: Claude Code running in Bypass Permissions mode"
|
||||
color="error"
|
||||
onCancel={handleEscape}
|
||||
>
|
||||
<Dialog title="WARNING: Claude Code running in Bypass Permissions mode" color="error" onCancel={handleEscape}>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
In Bypass Permissions mode, Claude Code will not ask for your approval
|
||||
before running potentially dangerous commands.
|
||||
In Bypass Permissions mode, Claude Code will not ask for your approval before running potentially dangerous
|
||||
commands.
|
||||
<Newline />
|
||||
This mode should only be used in a sandboxed container/VM that has
|
||||
restricted internet access and can easily be restored if damaged.
|
||||
This mode should only be used in a sandboxed container/VM that has restricted internet access and can easily
|
||||
be restored if damaged.
|
||||
</Text>
|
||||
<Text>
|
||||
By proceeding, you accept all responsibility for actions taken while
|
||||
running in Bypass Permissions mode.
|
||||
By proceeding, you accept all responsibility for actions taken while running in Bypass Permissions mode.
|
||||
</Text>
|
||||
|
||||
<Link url="https://code.claude.com/docs/en/security" />
|
||||
@@ -69,5 +62,5 @@ export function BypassPermissionsModeDialog({
|
||||
onChange={value => onChange(value as 'accept' | 'decline')}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,32 @@
|
||||
import React from 'react'
|
||||
import { Text } from '@anthropic/ink'
|
||||
import { Select } from './CustomSelect/index.js'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
import React from 'react';
|
||||
import { Text } from '@anthropic/ink';
|
||||
import { Select } from './CustomSelect/index.js';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
|
||||
export type ChannelDowngradeChoice = 'downgrade' | 'stay' | 'cancel'
|
||||
export type ChannelDowngradeChoice = 'downgrade' | 'stay' | 'cancel';
|
||||
|
||||
type Props = {
|
||||
currentVersion: string
|
||||
onChoice: (choice: ChannelDowngradeChoice) => void
|
||||
}
|
||||
currentVersion: string;
|
||||
onChoice: (choice: ChannelDowngradeChoice) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dialog shown when switching from latest to stable channel.
|
||||
* Allows user to choose whether to downgrade or stay on current version.
|
||||
*/
|
||||
export function ChannelDowngradeDialog({
|
||||
currentVersion,
|
||||
onChoice,
|
||||
}: Props): React.ReactNode {
|
||||
export function ChannelDowngradeDialog({ currentVersion, onChoice }: Props): React.ReactNode {
|
||||
function handleSelect(value: ChannelDowngradeChoice): void {
|
||||
onChoice(value)
|
||||
onChoice(value);
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
onChoice('cancel')
|
||||
onChoice('cancel');
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Switch to Stable Channel"
|
||||
onCancel={handleCancel}
|
||||
color="permission"
|
||||
hideBorder
|
||||
hideInputGuide
|
||||
>
|
||||
<Dialog title="Switch to Stable Channel" onCancel={handleCancel} color="permission" hideBorder hideInputGuide>
|
||||
<Text>
|
||||
The stable channel may have an older version than what you're
|
||||
currently running ({currentVersion}).
|
||||
The stable channel may have an older version than what you're currently running ({currentVersion}).
|
||||
</Text>
|
||||
<Text dimColor>How would you like to handle this?</Text>
|
||||
<Select
|
||||
@@ -53,5 +43,5 @@ export function ChannelDowngradeDialog({
|
||||
onChange={handleSelect}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import * as React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { Select } from '../CustomSelect/select.js'
|
||||
import { PermissionDialog } from '../permissions/PermissionDialog.js'
|
||||
import * as React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { Select } from '../CustomSelect/select.js';
|
||||
import { PermissionDialog } from '../permissions/PermissionDialog.js';
|
||||
|
||||
type Props = {
|
||||
pluginName: string
|
||||
pluginDescription?: string
|
||||
marketplaceName: string
|
||||
sourceCommand: string
|
||||
onResponse: (response: 'yes' | 'no' | 'disable') => void
|
||||
}
|
||||
pluginName: string;
|
||||
pluginDescription?: string;
|
||||
marketplaceName: string;
|
||||
sourceCommand: string;
|
||||
onResponse: (response: 'yes' | 'no' | 'disable') => void;
|
||||
};
|
||||
|
||||
const AUTO_DISMISS_MS = 30_000
|
||||
const AUTO_DISMISS_MS = 30_000;
|
||||
|
||||
export function PluginHintMenu({
|
||||
pluginName,
|
||||
@@ -20,28 +20,24 @@ export function PluginHintMenu({
|
||||
sourceCommand,
|
||||
onResponse,
|
||||
}: Props): React.ReactNode {
|
||||
const onResponseRef = React.useRef(onResponse)
|
||||
onResponseRef.current = onResponse
|
||||
const onResponseRef = React.useRef(onResponse);
|
||||
onResponseRef.current = onResponse;
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeoutId = setTimeout(
|
||||
ref => ref.current('no'),
|
||||
AUTO_DISMISS_MS,
|
||||
onResponseRef,
|
||||
)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [])
|
||||
const timeoutId = setTimeout(ref => ref.current('no'), AUTO_DISMISS_MS, onResponseRef);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
function onSelect(value: string): void {
|
||||
switch (value) {
|
||||
case 'yes':
|
||||
onResponse('yes')
|
||||
break
|
||||
onResponse('yes');
|
||||
break;
|
||||
case 'disable':
|
||||
onResponse('disable')
|
||||
break
|
||||
onResponse('disable');
|
||||
break;
|
||||
default:
|
||||
onResponse('no')
|
||||
onResponse('no');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,15 +58,14 @@ export function PluginHintMenu({
|
||||
label: "No, and don't show plugin installation hints again",
|
||||
value: 'disable',
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<PermissionDialog title="Plugin Recommendation">
|
||||
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
The <Text bold>{sourceCommand}</Text> command suggests installing a
|
||||
plugin.
|
||||
The <Text bold>{sourceCommand}</Text> command suggests installing a plugin.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
@@ -90,13 +85,9 @@ export function PluginHintMenu({
|
||||
<Text>Would you like to install it?</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={onSelect}
|
||||
onCancel={() => onResponse('no')}
|
||||
/>
|
||||
<Select options={options} onChange={onSelect} onCancel={() => onResponse('no')} />
|
||||
</Box>
|
||||
</Box>
|
||||
</PermissionDialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,61 +1,54 @@
|
||||
import React from 'react'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import React from 'react';
|
||||
import { logEvent } from 'src/services/analytics/index.js';
|
||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- enter to continue
|
||||
import { Box, Dialog, Link, Newline, Text, useInput } from '@anthropic/ink'
|
||||
import { isChromeExtensionInstalled } from '../utils/claudeInChrome/setup.js'
|
||||
import { saveGlobalConfig } from '../utils/config.js'
|
||||
import { Box, Dialog, Link, Newline, Text, useInput } from '@anthropic/ink';
|
||||
import { isChromeExtensionInstalled } from '../utils/claudeInChrome/setup.js';
|
||||
import { saveGlobalConfig } from '../utils/config.js';
|
||||
|
||||
const CHROME_EXTENSION_URL = 'https://claude.ai/chrome'
|
||||
const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions'
|
||||
const CHROME_EXTENSION_URL = 'https://claude.ai/chrome';
|
||||
const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions';
|
||||
|
||||
type Props = {
|
||||
onDone(): void
|
||||
}
|
||||
onDone(): void;
|
||||
};
|
||||
|
||||
export function ClaudeInChromeOnboarding({ onDone }: Props): React.ReactNode {
|
||||
const [isExtensionInstalled, setIsExtensionInstalled] = React.useState(false)
|
||||
const [isExtensionInstalled, setIsExtensionInstalled] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
logEvent('tengu_claude_in_chrome_onboarding_shown', {})
|
||||
void isChromeExtensionInstalled().then(setIsExtensionInstalled)
|
||||
logEvent('tengu_claude_in_chrome_onboarding_shown', {});
|
||||
void isChromeExtensionInstalled().then(setIsExtensionInstalled);
|
||||
saveGlobalConfig(current => {
|
||||
return { ...current, hasCompletedClaudeInChromeOnboarding: true }
|
||||
})
|
||||
}, [])
|
||||
return { ...current, hasCompletedClaudeInChromeOnboarding: true };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle Enter to continue
|
||||
useInput((_input, key) => {
|
||||
if (key.return) {
|
||||
onDone()
|
||||
onDone();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Claude in Chrome (Beta)"
|
||||
onCancel={onDone}
|
||||
color="chromeYellow"
|
||||
>
|
||||
<Dialog title="Claude in Chrome (Beta)" onCancel={onDone} color="chromeYellow">
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
Claude in Chrome works with the Chrome extension to let you control
|
||||
your browser directly from Claude Code. You can navigate websites,
|
||||
fill forms, capture screenshots, record GIFs, and debug with console
|
||||
logs and network requests.
|
||||
Claude in Chrome works with the Chrome extension to let you control your browser directly from Claude Code.
|
||||
You can navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and
|
||||
network requests.
|
||||
{!isExtensionInstalled && (
|
||||
<>
|
||||
<Newline />
|
||||
<Newline />
|
||||
Requires the Chrome extension. Get started at{' '}
|
||||
<Link url={CHROME_EXTENSION_URL} />
|
||||
Requires the Chrome extension. Get started at <Link url={CHROME_EXTENSION_URL} />
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Text dimColor>
|
||||
Site-level permissions are inherited from the Chrome extension. Manage
|
||||
permissions in the Chrome extension settings to control which sites
|
||||
Claude can browse, click, and type on
|
||||
Site-level permissions are inherited from the Chrome extension. Manage permissions in the Chrome extension
|
||||
settings to control which sites Claude can browse, click, and type on
|
||||
{isExtensionInstalled && (
|
||||
<>
|
||||
{' '}
|
||||
@@ -73,5 +66,5 @@ export function ClaudeInChromeOnboarding({ onDone }: Props): React.ReactNode {
|
||||
</Text>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import { Box, Dialog, Link, Text } from '@anthropic/ink'
|
||||
import type { ExternalClaudeMdInclude } from '../utils/claudemd.js'
|
||||
import { saveCurrentProjectConfig } from '../utils/config.js'
|
||||
import { Select } from './CustomSelect/index.js'
|
||||
import React, { useCallback } from 'react';
|
||||
import { logEvent } from 'src/services/analytics/index.js';
|
||||
import { Box, Dialog, Link, Text } from '@anthropic/ink';
|
||||
import type { ExternalClaudeMdInclude } from '../utils/claudemd.js';
|
||||
import { saveCurrentProjectConfig } from '../utils/config.js';
|
||||
import { Select } from './CustomSelect/index.js';
|
||||
|
||||
type Props = {
|
||||
onDone(): void
|
||||
isStandaloneDialog?: boolean
|
||||
externalIncludes?: ExternalClaudeMdInclude[]
|
||||
}
|
||||
onDone(): void;
|
||||
isStandaloneDialog?: boolean;
|
||||
externalIncludes?: ExternalClaudeMdInclude[];
|
||||
};
|
||||
|
||||
export function ClaudeMdExternalIncludesDialog({
|
||||
onDone,
|
||||
@@ -18,36 +18,36 @@ export function ClaudeMdExternalIncludesDialog({
|
||||
}: Props): React.ReactNode {
|
||||
React.useEffect(() => {
|
||||
// Log when dialog is shown
|
||||
logEvent('tengu_claude_md_includes_dialog_shown', {})
|
||||
}, [])
|
||||
logEvent('tengu_claude_md_includes_dialog_shown', {});
|
||||
}, []);
|
||||
|
||||
const handleSelection = useCallback(
|
||||
(value: 'yes' | 'no') => {
|
||||
if (value === 'no') {
|
||||
logEvent('tengu_claude_md_external_includes_dialog_declined', {})
|
||||
logEvent('tengu_claude_md_external_includes_dialog_declined', {});
|
||||
// Mark that we've shown the dialog but it was declined
|
||||
saveCurrentProjectConfig(current => ({
|
||||
...current,
|
||||
hasClaudeMdExternalIncludesApproved: false,
|
||||
hasClaudeMdExternalIncludesWarningShown: true,
|
||||
}))
|
||||
}));
|
||||
} else {
|
||||
logEvent('tengu_claude_md_external_includes_dialog_accepted', {})
|
||||
logEvent('tengu_claude_md_external_includes_dialog_accepted', {});
|
||||
saveCurrentProjectConfig(current => ({
|
||||
...current,
|
||||
hasClaudeMdExternalIncludesApproved: true,
|
||||
hasClaudeMdExternalIncludesWarningShown: true,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
onDone()
|
||||
onDone();
|
||||
},
|
||||
[onDone],
|
||||
)
|
||||
);
|
||||
|
||||
const handleEscape = useCallback(() => {
|
||||
handleSelection('no')
|
||||
}, [handleSelection])
|
||||
handleSelection('no');
|
||||
}, [handleSelection]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -58,8 +58,8 @@ export function ClaudeMdExternalIncludesDialog({
|
||||
hideInputGuide={!isStandaloneDialog}
|
||||
>
|
||||
<Text>
|
||||
This project's CLAUDE.md imports files outside the current working
|
||||
directory. Never allow this for third-party repositories.
|
||||
This project's CLAUDE.md imports files outside the current working directory. Never allow this for
|
||||
third-party repositories.
|
||||
</Text>
|
||||
|
||||
{externalIncludes && externalIncludes.length > 0 && (
|
||||
@@ -75,8 +75,7 @@ export function ClaudeMdExternalIncludesDialog({
|
||||
)}
|
||||
|
||||
<Text dimColor>
|
||||
Important: Only use Claude Code with files you trust. Accessing
|
||||
untrusted files may pose security risks{' '}
|
||||
Important: Only use Claude Code with files you trust. Accessing untrusted files may pose security risks{' '}
|
||||
<Link url="https://code.claude.com/docs/en/security" />{' '}
|
||||
</Text>
|
||||
|
||||
@@ -88,5 +87,5 @@ export function ClaudeMdExternalIncludesDialog({
|
||||
onChange={value => handleSelection(value as 'yes' | 'no')}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import * as React from 'react'
|
||||
import { pathToFileURL } from 'url'
|
||||
import { Link, supportsHyperlinks, Text } from '@anthropic/ink'
|
||||
import { getStoredImagePath } from '../utils/imageStore.js'
|
||||
import type { Theme } from '../utils/theme.js'
|
||||
import * as React from 'react';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { Link, supportsHyperlinks, Text } from '@anthropic/ink';
|
||||
import { getStoredImagePath } from '../utils/imageStore.js';
|
||||
import type { Theme } from '../utils/theme.js';
|
||||
|
||||
type Props = {
|
||||
imageId: number
|
||||
backgroundColor?: keyof Theme
|
||||
isSelected?: boolean
|
||||
}
|
||||
imageId: number;
|
||||
backgroundColor?: keyof Theme;
|
||||
isSelected?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders an image reference like [Image #1] as a clickable link.
|
||||
@@ -18,17 +18,13 @@ type Props = {
|
||||
* - Terminal doesn't support hyperlinks
|
||||
* - Image file is not found in the store
|
||||
*/
|
||||
export function ClickableImageRef({
|
||||
imageId,
|
||||
backgroundColor,
|
||||
isSelected = false,
|
||||
}: Props): React.ReactNode {
|
||||
const imagePath = getStoredImagePath(imageId)
|
||||
const displayText = `[Image #${imageId}]`
|
||||
export function ClickableImageRef({ imageId, backgroundColor, isSelected = false }: Props): React.ReactNode {
|
||||
const imagePath = getStoredImagePath(imageId);
|
||||
const displayText = `[Image #${imageId}]`;
|
||||
|
||||
// If we have a stored image and terminal supports hyperlinks, make it clickable
|
||||
if (imagePath && supportsHyperlinks()) {
|
||||
const fileUrl = pathToFileURL(imagePath).href
|
||||
const fileUrl = pathToFileURL(imagePath).href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -39,15 +35,11 @@ export function ClickableImageRef({
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Text
|
||||
backgroundColor={backgroundColor}
|
||||
inverse={isSelected}
|
||||
bold={isSelected}
|
||||
>
|
||||
<Text backgroundColor={backgroundColor} inverse={isSelected} bold={isSelected}>
|
||||
{displayText}
|
||||
</Text>
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: styled but not clickable
|
||||
@@ -55,5 +47,5 @@ export function ClickableImageRef({
|
||||
<Text backgroundColor={backgroundColor} inverse={isSelected}>
|
||||
{displayText}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import * as React from 'react'
|
||||
import { BLACK_CIRCLE } from '../constants/figures.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { Screen } from '../screens/REPL.js'
|
||||
import type { NormalizedUserMessage } from '../types/message.js'
|
||||
import { getUserMessageText } from '../utils/messages.js'
|
||||
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'
|
||||
import { MessageResponse } from './MessageResponse.js'
|
||||
import * as React from 'react';
|
||||
import { BLACK_CIRCLE } from '../constants/figures.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { Screen } from '../screens/REPL.js';
|
||||
import type { NormalizedUserMessage } from '../types/message.js';
|
||||
import { getUserMessageText } from '../utils/messages.js';
|
||||
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js';
|
||||
import { MessageResponse } from './MessageResponse.js';
|
||||
|
||||
type Props = {
|
||||
message: NormalizedUserMessage
|
||||
screen: Screen
|
||||
}
|
||||
message: NormalizedUserMessage;
|
||||
screen: Screen;
|
||||
};
|
||||
|
||||
export function CompactSummary({ message, screen }: Props): React.ReactNode {
|
||||
const isTranscriptMode = screen === 'transcript'
|
||||
const textContent = getUserMessageText(message) || ''
|
||||
const metadata = message.summarizeMetadata as {
|
||||
messagesSummarized?: number
|
||||
direction?: string
|
||||
userContext?: string
|
||||
} | undefined
|
||||
const isTranscriptMode = screen === 'transcript';
|
||||
const textContent = getUserMessageText(message) || '';
|
||||
const metadata = message.summarizeMetadata as
|
||||
| {
|
||||
messagesSummarized?: number;
|
||||
direction?: string;
|
||||
userContext?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
// "Summarize from here" with metadata
|
||||
if (metadata) {
|
||||
@@ -36,9 +38,7 @@ export function CompactSummary({ message, screen }: Props): React.ReactNode {
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>
|
||||
Summarized {metadata.messagesSummarized} messages{' '}
|
||||
{metadata.direction === 'up_to'
|
||||
? 'up to this point'
|
||||
: 'from this point'}
|
||||
{metadata.direction === 'up_to' ? 'up to this point' : 'from this point'}
|
||||
</Text>
|
||||
{metadata.userContext && (
|
||||
<Text dimColor>
|
||||
@@ -67,7 +67,7 @@ export function CompactSummary({ message, screen }: Props): React.ReactNode {
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Default compact summary (auto-compact)
|
||||
@@ -101,5 +101,5 @@ export function CompactSummary({ message, screen }: Props): React.ReactNode {
|
||||
</MessageResponse>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
import * as React from 'react'
|
||||
import type {
|
||||
KeybindingAction,
|
||||
KeybindingContextName,
|
||||
} from '../keybindings/types.js'
|
||||
import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'
|
||||
import { KeyboardShortcutHint } from '@anthropic/ink'
|
||||
import * as React from 'react';
|
||||
import type { KeybindingAction, KeybindingContextName } from '../keybindings/types.js';
|
||||
import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js';
|
||||
import { KeyboardShortcutHint } from '@anthropic/ink';
|
||||
|
||||
type Props = {
|
||||
/** The keybinding action (e.g., 'app:toggleTranscript') */
|
||||
action: KeybindingAction
|
||||
action: KeybindingAction;
|
||||
/** The keybinding context (e.g., 'Global') */
|
||||
context: KeybindingContextName
|
||||
context: KeybindingContextName;
|
||||
/** Default shortcut if keybinding not configured */
|
||||
fallback: string
|
||||
fallback: string;
|
||||
/** The action description text (e.g., 'expand') */
|
||||
description: string
|
||||
description: string;
|
||||
/** Whether to wrap in parentheses */
|
||||
parens?: boolean
|
||||
parens?: boolean;
|
||||
/** Whether to show in bold */
|
||||
bold?: boolean
|
||||
}
|
||||
bold?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* KeyboardShortcutHint that displays the user-configured shortcut.
|
||||
@@ -41,13 +38,6 @@ export function ConfigurableShortcutHint({
|
||||
parens,
|
||||
bold,
|
||||
}: Props): React.ReactNode {
|
||||
const shortcut = useShortcutDisplay(action, context, fallback)
|
||||
return (
|
||||
<KeyboardShortcutHint
|
||||
shortcut={shortcut}
|
||||
action={description}
|
||||
parens={parens}
|
||||
bold={bold}
|
||||
/>
|
||||
)
|
||||
const shortcut = useShortcutDisplay(action, context, fallback);
|
||||
return <KeyboardShortcutHint shortcut={shortcut} action={description} parens={parens} bold={bold} />;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,15 @@
|
||||
import figures from 'figures'
|
||||
import * as React from 'react'
|
||||
import { Box, Text, StatusIcon } from '@anthropic/ink'
|
||||
import type { ContextSuggestion } from '../utils/contextSuggestions.js'
|
||||
import { formatTokens } from '../utils/format.js'
|
||||
import figures from 'figures';
|
||||
import * as React from 'react';
|
||||
import { Box, Text, StatusIcon } from '@anthropic/ink';
|
||||
import type { ContextSuggestion } from '../utils/contextSuggestions.js';
|
||||
import { formatTokens } from '../utils/format.js';
|
||||
|
||||
type Props = {
|
||||
suggestions: ContextSuggestion[]
|
||||
}
|
||||
suggestions: ContextSuggestion[];
|
||||
};
|
||||
|
||||
export function ContextSuggestions({ suggestions }: Props): React.ReactNode {
|
||||
if (suggestions.length === 0) return null
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
@@ -22,8 +22,7 @@ export function ContextSuggestions({ suggestions }: Props): React.ReactNode {
|
||||
{suggestion.savingsTokens ? (
|
||||
<Text dimColor>
|
||||
{' '}
|
||||
{figures.arrowRight} save ~
|
||||
{formatTokens(suggestion.savingsTokens)}
|
||||
{figures.arrowRight} save ~{formatTokens(suggestion.savingsTokens)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
@@ -33,5 +32,5 @@ export function ContextSuggestions({ suggestions }: Props): React.ReactNode {
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import * as React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { ContextData } from '../utils/analyzeContext.js'
|
||||
import { generateContextSuggestions } from '../utils/contextSuggestions.js'
|
||||
import { getDisplayPath } from '../utils/file.js'
|
||||
import { formatTokens } from '../utils/format.js'
|
||||
import {
|
||||
getSourceDisplayName,
|
||||
type SettingSource,
|
||||
} from '../utils/settings/constants.js'
|
||||
import { plural } from '../utils/stringUtils.js'
|
||||
import { ContextSuggestions } from './ContextSuggestions.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import * as React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { ContextData } from '../utils/analyzeContext.js';
|
||||
import { generateContextSuggestions } from '../utils/contextSuggestions.js';
|
||||
import { getDisplayPath } from '../utils/file.js';
|
||||
import { formatTokens } from '../utils/format.js';
|
||||
import { getSourceDisplayName, type SettingSource } from '../utils/settings/constants.js';
|
||||
import { plural } from '../utils/stringUtils.js';
|
||||
import { ContextSuggestions } from './ContextSuggestions.js';
|
||||
|
||||
const RESERVED_CATEGORY_NAME = 'Autocompact buffer'
|
||||
const RESERVED_CATEGORY_NAME = 'Autocompact buffer';
|
||||
|
||||
/**
|
||||
* One-liner for the legend header showing what context-collapse has done.
|
||||
@@ -25,41 +22,35 @@ function CollapseStatus(): React.ReactNode {
|
||||
if (feature('CONTEXT_COLLAPSE')) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { getStats, isContextCollapseEnabled } =
|
||||
require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js')
|
||||
require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js');
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
if (!isContextCollapseEnabled()) return null
|
||||
if (!isContextCollapseEnabled()) return null;
|
||||
|
||||
const s = getStats()
|
||||
const { health: h } = s
|
||||
const s = getStats();
|
||||
const { health: h } = s;
|
||||
|
||||
const parts: string[] = []
|
||||
const parts: string[] = [];
|
||||
if (s.collapsedSpans > 0) {
|
||||
parts.push(
|
||||
`${s.collapsedSpans} ${plural(s.collapsedSpans, 'span')} summarized (${s.collapsedMessages} msgs)`,
|
||||
)
|
||||
parts.push(`${s.collapsedSpans} ${plural(s.collapsedSpans, 'span')} summarized (${s.collapsedMessages} msgs)`);
|
||||
}
|
||||
if (s.stagedSpans > 0) parts.push(`${s.stagedSpans} staged`)
|
||||
if (s.stagedSpans > 0) parts.push(`${s.stagedSpans} staged`);
|
||||
const summary =
|
||||
parts.length > 0
|
||||
? parts.join(', ')
|
||||
: h.totalSpawns > 0
|
||||
? `${h.totalSpawns} ${plural(h.totalSpawns, 'spawn')}, nothing staged yet`
|
||||
: 'waiting for first trigger'
|
||||
: 'waiting for first trigger';
|
||||
|
||||
let line2: React.ReactNode = null
|
||||
let line2: React.ReactNode = null;
|
||||
if (h.totalErrors > 0) {
|
||||
line2 = (
|
||||
<Text color="warning">
|
||||
Collapse errors: {h.totalErrors}/{h.totalSpawns} spawns failed
|
||||
{h.lastError ? ` (last: ${h.lastError.slice(0, 60)})` : ''}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
} else if (h.emptySpawnWarningEmitted) {
|
||||
line2 = (
|
||||
<Text color="warning">
|
||||
Collapse idle: {h.totalEmptySpawns} consecutive empty runs
|
||||
</Text>
|
||||
)
|
||||
line2 = <Text color="warning">Collapse idle: {h.totalEmptySpawns} consecutive empty runs</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -67,51 +58,45 @@ function CollapseStatus(): React.ReactNode {
|
||||
<Text dimColor>Context strategy: collapse ({summary})</Text>
|
||||
{line2}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// Order for displaying source groups: Project > User > Managed > Plugin > Built-in
|
||||
const SOURCE_DISPLAY_ORDER = [
|
||||
'Project',
|
||||
'User',
|
||||
'Managed',
|
||||
'Plugin',
|
||||
'Built-in',
|
||||
]
|
||||
const SOURCE_DISPLAY_ORDER = ['Project', 'User', 'Managed', 'Plugin', 'Built-in'];
|
||||
|
||||
/** Group items by source type for display, sorted by tokens descending within each group */
|
||||
function groupBySource<
|
||||
T extends { source: SettingSource | 'plugin' | 'built-in'; tokens: number },
|
||||
>(items: T[]): Map<string, T[]> {
|
||||
const groups = new Map<string, T[]>()
|
||||
function groupBySource<T extends { source: SettingSource | 'plugin' | 'built-in'; tokens: number }>(
|
||||
items: T[],
|
||||
): Map<string, T[]> {
|
||||
const groups = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const key = getSourceDisplayName(item.source)
|
||||
const existing = groups.get(key) || []
|
||||
existing.push(item)
|
||||
groups.set(key, existing)
|
||||
const key = getSourceDisplayName(item.source);
|
||||
const existing = groups.get(key) || [];
|
||||
existing.push(item);
|
||||
groups.set(key, existing);
|
||||
}
|
||||
// Sort each group by tokens descending
|
||||
for (const [key, group] of groups.entries()) {
|
||||
groups.set(
|
||||
key,
|
||||
group.sort((a, b) => b.tokens - a.tokens),
|
||||
)
|
||||
);
|
||||
}
|
||||
// Return groups in consistent order
|
||||
const orderedGroups = new Map<string, T[]>()
|
||||
const orderedGroups = new Map<string, T[]>();
|
||||
for (const source of SOURCE_DISPLAY_ORDER) {
|
||||
const group = groups.get(source)
|
||||
const group = groups.get(source);
|
||||
if (group) {
|
||||
orderedGroups.set(source, group)
|
||||
orderedGroups.set(source, group);
|
||||
}
|
||||
}
|
||||
return orderedGroups
|
||||
return orderedGroups;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: ContextData
|
||||
data: ContextData;
|
||||
}
|
||||
|
||||
export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
@@ -130,25 +115,17 @@ export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
agents,
|
||||
skills,
|
||||
messageBreakdown,
|
||||
} = data
|
||||
} = data;
|
||||
|
||||
// Filter out categories with 0 tokens for the legend, and exclude Free space, Autocompact buffer, and deferred
|
||||
const visibleCategories = categories.filter(
|
||||
cat =>
|
||||
cat.tokens > 0 &&
|
||||
cat.name !== 'Free space' &&
|
||||
cat.name !== RESERVED_CATEGORY_NAME &&
|
||||
!cat.isDeferred,
|
||||
)
|
||||
cat => cat.tokens > 0 && cat.name !== 'Free space' && cat.name !== RESERVED_CATEGORY_NAME && !cat.isDeferred,
|
||||
);
|
||||
// Check if MCP tools are deferred (loaded on-demand via tool search)
|
||||
const hasDeferredMcpTools = categories.some(
|
||||
cat => cat.isDeferred && cat.name.includes('MCP'),
|
||||
)
|
||||
const hasDeferredMcpTools = categories.some(cat => cat.isDeferred && cat.name.includes('MCP'));
|
||||
// Check if builtin tools are deferred
|
||||
const hasDeferredBuiltinTools = deferredBuiltinTools.length > 0
|
||||
const autocompactCategory = categories.find(
|
||||
cat => cat.name === RESERVED_CATEGORY_NAME,
|
||||
)
|
||||
const hasDeferredBuiltinTools = deferredBuiltinTools.length > 0;
|
||||
const autocompactCategory = categories.find(cat => cat.name === RESERVED_CATEGORY_NAME);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={1}>
|
||||
@@ -164,20 +141,20 @@ export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
<Text key={colIndex} dimColor>
|
||||
{'⛶ '}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (square.categoryName === RESERVED_CATEGORY_NAME) {
|
||||
return (
|
||||
<Text key={colIndex} color={square.color}>
|
||||
{'⛝ '}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text key={colIndex} color={square.color}>
|
||||
{square.squareFullness >= 0.7 ? '⛁ ' : '⛀ '}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
))}
|
||||
@@ -186,8 +163,7 @@ export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
{/* Legend to the right */}
|
||||
<Box flexDirection="column" gap={0} flexShrink={0}>
|
||||
<Text dimColor>
|
||||
{model} · {formatTokens(totalTokens)}/{formatTokens(rawMaxTokens)}{' '}
|
||||
tokens ({percentage}%)
|
||||
{model} · {formatTokens(totalTokens)}/{formatTokens(rawMaxTokens)} tokens ({percentage}%)
|
||||
</Text>
|
||||
<CollapseStatus />
|
||||
<Text> </Text>
|
||||
@@ -195,15 +171,13 @@ export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
Estimated usage by category
|
||||
</Text>
|
||||
{visibleCategories.map((cat, index) => {
|
||||
const tokenDisplay = formatTokens(cat.tokens)
|
||||
const tokenDisplay = formatTokens(cat.tokens);
|
||||
// Show "N/A" for deferred categories since they don't count toward context
|
||||
const percentDisplay = cat.isDeferred
|
||||
? 'N/A'
|
||||
: `${((cat.tokens / rawMaxTokens) * 100).toFixed(1)}%`
|
||||
const isReserved = cat.name === RESERVED_CATEGORY_NAME
|
||||
const displayName = cat.name
|
||||
const percentDisplay = cat.isDeferred ? 'N/A' : `${((cat.tokens / rawMaxTokens) * 100).toFixed(1)}%`;
|
||||
const isReserved = cat.name === RESERVED_CATEGORY_NAME;
|
||||
const displayName = cat.name;
|
||||
// Deferred categories don't appear in grid, so show blank instead of symbol
|
||||
const symbol = cat.isDeferred ? ' ' : isReserved ? '⛝' : '⛁'
|
||||
const symbol = cat.isDeferred ? ' ' : isReserved ? '⛝' : '⛁';
|
||||
|
||||
return (
|
||||
<Box key={index}>
|
||||
@@ -213,23 +187,15 @@ export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
{tokenDisplay} tokens ({percentDisplay})
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
})}
|
||||
{(categories.find(c => c.name === 'Free space')?.tokens ?? 0) > 0 && (
|
||||
<Box>
|
||||
<Text dimColor>⛶</Text>
|
||||
<Text> Free space: </Text>
|
||||
<Text dimColor>
|
||||
{formatTokens(
|
||||
categories.find(c => c.name === 'Free space')?.tokens || 0,
|
||||
)}{' '}
|
||||
(
|
||||
{(
|
||||
((categories.find(c => c.name === 'Free space')?.tokens ||
|
||||
0) /
|
||||
rawMaxTokens) *
|
||||
100
|
||||
).toFixed(1)}
|
||||
{formatTokens(categories.find(c => c.name === 'Free space')?.tokens || 0)} (
|
||||
{(((categories.find(c => c.name === 'Free space')?.tokens || 0) / rawMaxTokens) * 100).toFixed(1)}
|
||||
%)
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -253,10 +219,7 @@ export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text bold>MCP tools</Text>
|
||||
<Text dimColor>
|
||||
{' '}
|
||||
· /mcp{hasDeferredMcpTools ? ' (loaded on-demand)' : ''}
|
||||
</Text>
|
||||
<Text dimColor> · /mcp{hasDeferredMcpTools ? ' (loaded on-demand)' : ''}</Text>
|
||||
</Box>
|
||||
{/* Show loaded tools first */}
|
||||
{mcpTools.some(t => t.isLoaded) && (
|
||||
@@ -297,63 +260,57 @@ export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
)}
|
||||
|
||||
{/* Show builtin tools: always-loaded + deferred (ant-only) */}
|
||||
{((systemTools && systemTools.length > 0) || hasDeferredBuiltinTools) &&
|
||||
process.env.USER_TYPE === 'ant' && (
|
||||
{((systemTools && systemTools.length > 0) || hasDeferredBuiltinTools) && process.env.USER_TYPE === 'ant' && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text bold>[ANT-ONLY] System tools</Text>
|
||||
{hasDeferredBuiltinTools && <Text dimColor> (some loaded on-demand)</Text>}
|
||||
</Box>
|
||||
{/* Always-loaded + deferred-but-loaded tools */}
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text bold>[ANT-ONLY] System tools</Text>
|
||||
{hasDeferredBuiltinTools && (
|
||||
<Text dimColor> (some loaded on-demand)</Text>
|
||||
)}
|
||||
</Box>
|
||||
{/* Always-loaded + deferred-but-loaded tools */}
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text dimColor>Loaded</Text>
|
||||
{systemTools?.map((tool, i) => (
|
||||
<Box key={`sys-${i}`}>
|
||||
<Text dimColor>Loaded</Text>
|
||||
{systemTools?.map((tool, i) => (
|
||||
<Box key={`sys-${i}`}>
|
||||
<Text>└ {tool.name}: </Text>
|
||||
<Text dimColor>{formatTokens(tool.tokens)} tokens</Text>
|
||||
</Box>
|
||||
))}
|
||||
{deferredBuiltinTools
|
||||
.filter(t => t.isLoaded)
|
||||
.map((tool, i) => (
|
||||
<Box key={`def-${i}`}>
|
||||
<Text>└ {tool.name}: </Text>
|
||||
<Text dimColor>{formatTokens(tool.tokens)} tokens</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{/* Deferred (not yet loaded) tools */}
|
||||
{hasDeferredBuiltinTools && deferredBuiltinTools.some(t => !t.isLoaded) && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text dimColor>Available</Text>
|
||||
{deferredBuiltinTools
|
||||
.filter(t => t.isLoaded)
|
||||
.filter(t => !t.isLoaded)
|
||||
.map((tool, i) => (
|
||||
<Box key={`def-${i}`}>
|
||||
<Text>└ {tool.name}: </Text>
|
||||
<Text dimColor>{formatTokens(tool.tokens)} tokens</Text>
|
||||
<Box key={i}>
|
||||
<Text dimColor>└ {tool.name}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{/* Deferred (not yet loaded) tools */}
|
||||
{hasDeferredBuiltinTools &&
|
||||
deferredBuiltinTools.some(t => !t.isLoaded) && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text dimColor>Available</Text>
|
||||
{deferredBuiltinTools
|
||||
.filter(t => !t.isLoaded)
|
||||
.map((tool, i) => (
|
||||
<Box key={i}>
|
||||
<Text dimColor>└ {tool.name}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{systemPromptSections &&
|
||||
systemPromptSections.length > 0 &&
|
||||
process.env.USER_TYPE === 'ant' && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold>[ANT-ONLY] System prompt sections</Text>
|
||||
{systemPromptSections.map((section, i) => (
|
||||
<Box key={i}>
|
||||
<Text>└ {section.name}: </Text>
|
||||
<Text dimColor>{formatTokens(section.tokens)} tokens</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{systemPromptSections && systemPromptSections.length > 0 && process.env.USER_TYPE === 'ant' && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold>[ANT-ONLY] System prompt sections</Text>
|
||||
{systemPromptSections.map((section, i) => (
|
||||
<Box key={i}>
|
||||
<Text>└ {section.name}: </Text>
|
||||
<Text dimColor>{formatTokens(section.tokens)} tokens</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{agents.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
@@ -361,19 +318,17 @@ export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
<Text bold>Custom agents</Text>
|
||||
<Text dimColor> · /agents</Text>
|
||||
</Box>
|
||||
{Array.from(groupBySource(agents).entries()).map(
|
||||
([sourceDisplay, sourceAgents]) => (
|
||||
<Box key={sourceDisplay} flexDirection="column" marginTop={1}>
|
||||
<Text dimColor>{sourceDisplay}</Text>
|
||||
{sourceAgents.map((agent, i) => (
|
||||
<Box key={i}>
|
||||
<Text>└ {agent.agentType}: </Text>
|
||||
<Text dimColor>{formatTokens(agent.tokens)} tokens</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
),
|
||||
)}
|
||||
{Array.from(groupBySource(agents).entries()).map(([sourceDisplay, sourceAgents]) => (
|
||||
<Box key={sourceDisplay} flexDirection="column" marginTop={1}>
|
||||
<Text dimColor>{sourceDisplay}</Text>
|
||||
{sourceAgents.map((agent, i) => (
|
||||
<Box key={i}>
|
||||
<Text>└ {agent.agentType}: </Text>
|
||||
<Text dimColor>{formatTokens(agent.tokens)} tokens</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -398,19 +353,17 @@ export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
<Text bold>Skills</Text>
|
||||
<Text dimColor> · /skills</Text>
|
||||
</Box>
|
||||
{Array.from(groupBySource(skills.skillFrontmatter).entries()).map(
|
||||
([sourceDisplay, sourceSkills]) => (
|
||||
<Box key={sourceDisplay} flexDirection="column" marginTop={1}>
|
||||
<Text dimColor>{sourceDisplay}</Text>
|
||||
{sourceSkills.map((skill, i) => (
|
||||
<Box key={i}>
|
||||
<Text>└ {skill.name}: </Text>
|
||||
<Text dimColor>{formatTokens(skill.tokens)} tokens</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
),
|
||||
)}
|
||||
{Array.from(groupBySource(skills.skillFrontmatter).entries()).map(([sourceDisplay, sourceSkills]) => (
|
||||
<Box key={sourceDisplay} flexDirection="column" marginTop={1}>
|
||||
<Text dimColor>{sourceDisplay}</Text>
|
||||
{sourceSkills.map((skill, i) => (
|
||||
<Box key={i}>
|
||||
<Text>└ {skill.name}: </Text>
|
||||
<Text dimColor>{formatTokens(skill.tokens)} tokens</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -421,37 +374,27 @@ export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
<Box flexDirection="column" marginLeft={1}>
|
||||
<Box>
|
||||
<Text>Tool calls: </Text>
|
||||
<Text dimColor>
|
||||
{formatTokens(messageBreakdown.toolCallTokens)} tokens
|
||||
</Text>
|
||||
<Text dimColor>{formatTokens(messageBreakdown.toolCallTokens)} tokens</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text>Tool results: </Text>
|
||||
<Text dimColor>
|
||||
{formatTokens(messageBreakdown.toolResultTokens)} tokens
|
||||
</Text>
|
||||
<Text dimColor>{formatTokens(messageBreakdown.toolResultTokens)} tokens</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text>Attachments: </Text>
|
||||
<Text dimColor>
|
||||
{formatTokens(messageBreakdown.attachmentTokens)} tokens
|
||||
</Text>
|
||||
<Text dimColor>{formatTokens(messageBreakdown.attachmentTokens)} tokens</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text>Assistant messages (non-tool): </Text>
|
||||
<Text dimColor>
|
||||
{formatTokens(messageBreakdown.assistantMessageTokens)} tokens
|
||||
</Text>
|
||||
<Text dimColor>{formatTokens(messageBreakdown.assistantMessageTokens)} tokens</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text>User messages (non-tool-result): </Text>
|
||||
<Text dimColor>
|
||||
{formatTokens(messageBreakdown.userMessageTokens)} tokens
|
||||
</Text>
|
||||
<Text dimColor>{formatTokens(messageBreakdown.userMessageTokens)} tokens</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -462,8 +405,7 @@ export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
<Box key={i} marginLeft={1}>
|
||||
<Text>└ {tool.name}: </Text>
|
||||
<Text dimColor>
|
||||
calls {formatTokens(tool.callTokens)}, results{' '}
|
||||
{formatTokens(tool.resultTokens)}
|
||||
calls {formatTokens(tool.callTokens)}, results {formatTokens(tool.resultTokens)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
@@ -473,16 +415,12 @@ export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
{messageBreakdown.attachmentsByType.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold>[ANT-ONLY] Top attachments</Text>
|
||||
{messageBreakdown.attachmentsByType
|
||||
.slice(0, 5)
|
||||
.map((attachment, i) => (
|
||||
<Box key={i} marginLeft={1}>
|
||||
<Text>└ {attachment.name}: </Text>
|
||||
<Text dimColor>
|
||||
{formatTokens(attachment.tokens)} tokens
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{messageBreakdown.attachmentsByType.slice(0, 5).map((attachment, i) => (
|
||||
<Box key={i} marginLeft={1}>
|
||||
<Text>└ {attachment.name}: </Text>
|
||||
<Text dimColor>{formatTokens(attachment.tokens)} tokens</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
@@ -490,5 +428,5 @@ export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
</Box>
|
||||
<ContextSuggestions suggestions={generateContextSuggestions(data)} />
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,27 +6,17 @@
|
||||
* always; a timestamp shows until passed. Enter to view/steer, x to dismiss.
|
||||
*/
|
||||
|
||||
import figures from 'figures'
|
||||
import * as React from 'react'
|
||||
import { BLACK_CIRCLE, PAUSE_ICON, PLAY_ICON } from '../constants/figures.js'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { Box, Text, stringWidth, wrapText } from '@anthropic/ink'
|
||||
import {
|
||||
type AppState,
|
||||
useAppState,
|
||||
useSetAppState,
|
||||
} from '../state/AppState.js'
|
||||
import {
|
||||
enterTeammateView,
|
||||
exitTeammateView,
|
||||
} from '../state/teammateViewHelpers.js'
|
||||
import {
|
||||
isPanelAgentTask,
|
||||
type LocalAgentTaskState,
|
||||
} from '../tasks/LocalAgentTask/LocalAgentTask.js'
|
||||
import { formatDuration, formatNumber } from '../utils/format.js'
|
||||
import { evictTerminalTask } from '../utils/task/framework.js'
|
||||
import { isTerminalStatus } from './tasks/taskStatusUtils.js'
|
||||
import figures from 'figures';
|
||||
import * as React from 'react';
|
||||
import { BLACK_CIRCLE, PAUSE_ICON, PLAY_ICON } from '../constants/figures.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { Box, Text, stringWidth, wrapText } from '@anthropic/ink';
|
||||
import { type AppState, useAppState, useSetAppState } from '../state/AppState.js';
|
||||
import { enterTeammateView, exitTeammateView } from '../state/teammateViewHelpers.js';
|
||||
import { isPanelAgentTask, type LocalAgentTaskState } from '../tasks/LocalAgentTask/LocalAgentTask.js';
|
||||
import { formatDuration, formatNumber } from '../utils/format.js';
|
||||
import { evictTerminalTask } from '../utils/task/framework.js';
|
||||
import { isTerminalStatus } from './tasks/taskStatusUtils.js';
|
||||
|
||||
/**
|
||||
* Which panel-managed tasks currently have a visible row.
|
||||
@@ -36,62 +26,57 @@ import { isTerminalStatus } from './tasks/taskStatusUtils.js'
|
||||
* the filter time-dependent. Shared by panel render, useCoordinatorTaskCount,
|
||||
* and index resolvers so the math can't drift.
|
||||
*/
|
||||
export function getVisibleAgentTasks(
|
||||
tasks: AppState['tasks'],
|
||||
): LocalAgentTaskState[] {
|
||||
export function getVisibleAgentTasks(tasks: AppState['tasks']): LocalAgentTaskState[] {
|
||||
return Object.values(tasks)
|
||||
.filter(
|
||||
(t): t is LocalAgentTaskState =>
|
||||
isPanelAgentTask(t) && t.evictAfter !== 0,
|
||||
)
|
||||
.sort((a, b) => a.startTime - b.startTime)
|
||||
.filter((t): t is LocalAgentTaskState => isPanelAgentTask(t) && t.evictAfter !== 0)
|
||||
.sort((a, b) => a.startTime - b.startTime);
|
||||
}
|
||||
|
||||
export function CoordinatorTaskPanel(): React.ReactNode {
|
||||
const tasks = useAppState(s => s.tasks)
|
||||
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
|
||||
const agentNameRegistry = useAppState(s => s.agentNameRegistry)
|
||||
const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex)
|
||||
const tasksSelected = useAppState(s => s.footerSelection === 'tasks')
|
||||
const selectedIndex = tasksSelected ? coordinatorTaskIndex : undefined
|
||||
const setAppState = useSetAppState()
|
||||
const tasks = useAppState(s => s.tasks);
|
||||
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId);
|
||||
const agentNameRegistry = useAppState(s => s.agentNameRegistry);
|
||||
const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex);
|
||||
const tasksSelected = useAppState(s => s.footerSelection === 'tasks');
|
||||
const selectedIndex = tasksSelected ? coordinatorTaskIndex : undefined;
|
||||
const setAppState = useSetAppState();
|
||||
|
||||
const visibleTasks = getVisibleAgentTasks(tasks)
|
||||
const hasTasks = Object.values(tasks).some(isPanelAgentTask)
|
||||
const visibleTasks = getVisibleAgentTasks(tasks);
|
||||
const hasTasks = Object.values(tasks).some(isPanelAgentTask);
|
||||
|
||||
// 1s tick: re-render for elapsed time + evict tasks past their deadline.
|
||||
// The eviction deletes from prev.tasks, which makes useCoordinatorTaskCount
|
||||
// (and other consumers) see the updated count without their own tick.
|
||||
const tasksRef = React.useRef(tasks)
|
||||
tasksRef.current = tasks
|
||||
const [, setTick] = React.useState(0)
|
||||
const tasksRef = React.useRef(tasks);
|
||||
tasksRef.current = tasks;
|
||||
const [, setTick] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
if (!hasTasks) return
|
||||
if (!hasTasks) return;
|
||||
const interval = setInterval(
|
||||
(tasksRef, setAppState, setTick) => {
|
||||
const now = Date.now()
|
||||
const now = Date.now();
|
||||
for (const t of Object.values(tasksRef.current)) {
|
||||
if (isPanelAgentTask(t) && (t.evictAfter ?? Infinity) <= now) {
|
||||
evictTerminalTask(t.id, setAppState)
|
||||
evictTerminalTask(t.id, setAppState);
|
||||
}
|
||||
}
|
||||
setTick((prev: number) => prev + 1)
|
||||
setTick((prev: number) => prev + 1);
|
||||
},
|
||||
1000,
|
||||
tasksRef,
|
||||
setAppState,
|
||||
setTick,
|
||||
)
|
||||
return () => clearInterval(interval)
|
||||
}, [hasTasks, setAppState])
|
||||
);
|
||||
return () => clearInterval(interval);
|
||||
}, [hasTasks, setAppState]);
|
||||
const nameByAgentId = React.useMemo(() => {
|
||||
const inv = new Map<string, string>()
|
||||
for (const [n, id] of agentNameRegistry) inv.set(id, n)
|
||||
return inv
|
||||
}, [agentNameRegistry])
|
||||
const inv = new Map<string, string>();
|
||||
for (const [n, id] of agentNameRegistry) inv.set(id, n);
|
||||
return inv;
|
||||
}, [agentNameRegistry]);
|
||||
|
||||
if (visibleTasks.length === 0) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -112,7 +97,7 @@ export function CoordinatorTaskPanel(): React.ReactNode {
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,12 +106,12 @@ export function CoordinatorTaskPanel(): React.ReactNode {
|
||||
* stays accurate without needing its own tick.
|
||||
*/
|
||||
export function useCoordinatorTaskCount(): number {
|
||||
const tasks = useAppState(s => s.tasks)
|
||||
const tasks = useAppState(s => s.tasks);
|
||||
return React.useMemo(() => {
|
||||
if ((process.env.USER_TYPE as string) !== 'ant') return 0
|
||||
const count = getVisibleAgentTasks(tasks).length
|
||||
return count > 0 ? count + 1 : 0
|
||||
}, [tasks])
|
||||
if ((process.env.USER_TYPE as string) !== 'ant') return 0;
|
||||
const count = getVisibleAgentTasks(tasks).length;
|
||||
return count > 0 ? count + 1 : 0;
|
||||
}, [tasks]);
|
||||
}
|
||||
|
||||
function MainLine({
|
||||
@@ -134,95 +119,71 @@ function MainLine({
|
||||
isViewed,
|
||||
onClick,
|
||||
}: {
|
||||
isSelected?: boolean
|
||||
isViewed?: boolean
|
||||
onClick: () => void
|
||||
isSelected?: boolean;
|
||||
isViewed?: boolean;
|
||||
onClick: () => void;
|
||||
}): React.ReactNode {
|
||||
const [hover, setHover] = React.useState(false)
|
||||
const prefix = isSelected || hover ? figures.pointer + ' ' : ' '
|
||||
const bullet = isViewed ? BLACK_CIRCLE : figures.circle
|
||||
const [hover, setHover] = React.useState(false);
|
||||
const prefix = isSelected || hover ? figures.pointer + ' ' : ' ';
|
||||
const bullet = isViewed ? BLACK_CIRCLE : figures.circle;
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
<Box onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
|
||||
<Text dimColor={!isSelected && !isViewed && !hover} bold={isViewed}>
|
||||
{prefix}
|
||||
{bullet} main
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
type AgentLineProps = {
|
||||
task: LocalAgentTaskState
|
||||
name?: string
|
||||
isSelected?: boolean
|
||||
isViewed?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
task: LocalAgentTaskState;
|
||||
name?: string;
|
||||
isSelected?: boolean;
|
||||
isViewed?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
function AgentLine({
|
||||
task,
|
||||
name,
|
||||
isSelected,
|
||||
isViewed,
|
||||
onClick,
|
||||
}: AgentLineProps): React.ReactNode {
|
||||
const { columns } = useTerminalSize()
|
||||
const [hover, setHover] = React.useState(false)
|
||||
const isRunning = !isTerminalStatus(task.status)
|
||||
const pausedMs = task.totalPausedMs ?? 0
|
||||
function AgentLine({ task, name, isSelected, isViewed, onClick }: AgentLineProps): React.ReactNode {
|
||||
const { columns } = useTerminalSize();
|
||||
const [hover, setHover] = React.useState(false);
|
||||
const isRunning = !isTerminalStatus(task.status);
|
||||
const pausedMs = task.totalPausedMs ?? 0;
|
||||
const elapsedMs = Math.max(
|
||||
0,
|
||||
isRunning
|
||||
? Date.now() - task.startTime - pausedMs
|
||||
: (task.endTime ?? task.startTime) - task.startTime - pausedMs,
|
||||
)
|
||||
isRunning ? Date.now() - task.startTime - pausedMs : (task.endTime ?? task.startTime) - task.startTime - pausedMs,
|
||||
);
|
||||
|
||||
const elapsed = formatDuration(elapsedMs)
|
||||
const tokenCount = task.progress?.tokenCount
|
||||
const elapsed = formatDuration(elapsedMs);
|
||||
const tokenCount = task.progress?.tokenCount;
|
||||
|
||||
// Derive direction arrow from activity state, same logic as Spinner
|
||||
const lastActivity = task.progress?.lastActivity
|
||||
const arrow = lastActivity ? figures.arrowDown : figures.arrowUp
|
||||
const lastActivity = task.progress?.lastActivity;
|
||||
const arrow = lastActivity ? figures.arrowDown : figures.arrowUp;
|
||||
|
||||
const tokenText =
|
||||
tokenCount !== undefined && tokenCount > 0
|
||||
? ` · ${arrow} ${formatNumber(tokenCount)} tokens`
|
||||
: ''
|
||||
const tokenText = tokenCount !== undefined && tokenCount > 0 ? ` · ${arrow} ${formatNumber(tokenCount)} tokens` : '';
|
||||
|
||||
const queuedCount = task.pendingMessages.length
|
||||
const queuedText = queuedCount > 0 ? ` · ${queuedCount} queued` : ''
|
||||
const queuedCount = task.pendingMessages.length;
|
||||
const queuedText = queuedCount > 0 ? ` · ${queuedCount} queued` : '';
|
||||
|
||||
// Precedence: AI summary > static description (no tool-call activity noise)
|
||||
const displayDescription = task.progress?.summary || task.description
|
||||
const displayDescription = task.progress?.summary || task.description;
|
||||
|
||||
const highlighted = isSelected || hover
|
||||
const prefix = highlighted ? figures.pointer + ' ' : ' '
|
||||
const bullet = isViewed ? BLACK_CIRCLE : figures.circle
|
||||
const dim = !highlighted && !isViewed
|
||||
const highlighted = isSelected || hover;
|
||||
const prefix = highlighted ? figures.pointer + ' ' : ' ';
|
||||
const bullet = isViewed ? BLACK_CIRCLE : figures.circle;
|
||||
const dim = !highlighted && !isViewed;
|
||||
|
||||
const sep = isRunning ? PLAY_ICON : PAUSE_ICON
|
||||
const sep = isRunning ? PLAY_ICON : PAUSE_ICON;
|
||||
// Name is the steering handle — kept out of truncation and undimmed so it
|
||||
// stays readable even when the row is inactive. Short by convention (the
|
||||
// Agent tool prompt asks for "one or two words, lowercase").
|
||||
const namePart = name ? `${name}: ` : ''
|
||||
const hintPart =
|
||||
isSelected && !isViewed ? ` · x to ${isRunning ? 'stop' : 'clear'}` : ''
|
||||
const suffixPart = ` ${sep} ${elapsed}${tokenText}${queuedText}${hintPart}`
|
||||
const namePart = name ? `${name}: ` : '';
|
||||
const hintPart = isSelected && !isViewed ? ` · x to ${isRunning ? 'stop' : 'clear'}` : '';
|
||||
const suffixPart = ` ${sep} ${elapsed}${tokenText}${queuedText}${hintPart}`;
|
||||
const availableForDesc =
|
||||
columns -
|
||||
stringWidth(prefix) -
|
||||
stringWidth(`${bullet} `) -
|
||||
stringWidth(namePart) -
|
||||
stringWidth(suffixPart)
|
||||
const truncated = wrapText(
|
||||
displayDescription,
|
||||
Math.max(0, availableForDesc),
|
||||
'truncate-end',
|
||||
)
|
||||
columns - stringWidth(prefix) - stringWidth(`${bullet} `) - stringWidth(namePart) - stringWidth(suffixPart);
|
||||
const truncated = wrapText(displayDescription, Math.max(0, availableForDesc), 'truncate-end');
|
||||
|
||||
const line = (
|
||||
<Text dimColor={dim} bold={isViewed}>
|
||||
@@ -241,16 +202,12 @@ function AgentLine({
|
||||
{queuedCount > 0 && <Text color="warning">{queuedText}</Text>}
|
||||
{hintPart && <Text dimColor>{hintPart}</Text>}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
|
||||
if (!onClick) return line
|
||||
if (!onClick) return line;
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
<Box onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
|
||||
{line}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import React from 'react'
|
||||
import { Box, Dialog, Link, Text } from '@anthropic/ink'
|
||||
import { Select } from './CustomSelect/index.js'
|
||||
import React from 'react';
|
||||
import { Box, Dialog, Link, Text } from '@anthropic/ink';
|
||||
import { Select } from './CustomSelect/index.js';
|
||||
|
||||
type Props = {
|
||||
onDone: () => void
|
||||
}
|
||||
onDone: () => void;
|
||||
};
|
||||
|
||||
export function CostThresholdDialog({ onDone }: Props): React.ReactNode {
|
||||
return (
|
||||
<Dialog
|
||||
title="You've spent $5 on the Anthropic API this session."
|
||||
onCancel={onDone}
|
||||
>
|
||||
<Dialog title="You've spent $5 on the Anthropic API this session." onCancel={onDone}>
|
||||
<Box flexDirection="column">
|
||||
<Text>Learn more about how to monitor your spending:</Text>
|
||||
<Link url="https://code.claude.com/docs/en/costs" />
|
||||
@@ -26,5 +23,5 @@ export function CostThresholdDialog({ onDone }: Props): React.ReactNode {
|
||||
onChange={onDone}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,35 @@
|
||||
import chalk from 'chalk'
|
||||
import React, { useContext } from 'react'
|
||||
import { Text } from '@anthropic/ink'
|
||||
import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'
|
||||
import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'
|
||||
import { KeyboardShortcutHint } from '@anthropic/ink'
|
||||
import { InVirtualListContext } from './messageActions.js'
|
||||
import chalk from 'chalk';
|
||||
import React, { useContext } from 'react';
|
||||
import { Text } from '@anthropic/ink';
|
||||
import { getShortcutDisplay } from '../keybindings/shortcutFormat.js';
|
||||
import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js';
|
||||
import { KeyboardShortcutHint } from '@anthropic/ink';
|
||||
import { InVirtualListContext } from './messageActions.js';
|
||||
|
||||
// Context to track if we're inside a sub agent
|
||||
// Similar to MessageResponseContext, this helps us avoid showing
|
||||
// too many "(ctrl+o to expand)" hints in sub agent output
|
||||
const SubAgentContext = React.createContext(false)
|
||||
const SubAgentContext = React.createContext(false);
|
||||
|
||||
export function SubAgentProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<SubAgentContext.Provider value={true}>{children}</SubAgentContext.Provider>
|
||||
)
|
||||
export function SubAgentProvider({ children }: { children: React.ReactNode }): React.ReactNode {
|
||||
return <SubAgentContext.Provider value={true}>{children}</SubAgentContext.Provider>;
|
||||
}
|
||||
|
||||
export function CtrlOToExpand(): React.ReactNode {
|
||||
const isInSubAgent = useContext(SubAgentContext)
|
||||
const inVirtualList = useContext(InVirtualListContext)
|
||||
const expandShortcut = useShortcutDisplay(
|
||||
'app:toggleTranscript',
|
||||
'Global',
|
||||
'ctrl+o',
|
||||
)
|
||||
const isInSubAgent = useContext(SubAgentContext);
|
||||
const inVirtualList = useContext(InVirtualListContext);
|
||||
const expandShortcut = useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o');
|
||||
if (isInSubAgent || inVirtualList) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Text dimColor>
|
||||
<KeyboardShortcutHint shortcut={expandShortcut} action="expand" parens />
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function ctrlOToExpand(): string {
|
||||
const shortcut = getShortcutDisplay(
|
||||
'app:toggleTranscript',
|
||||
'Global',
|
||||
'ctrl+o',
|
||||
)
|
||||
return chalk.dim(`(${shortcut} to expand)`)
|
||||
const shortcut = getShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o');
|
||||
return chalk.dim(`(${shortcut} to expand)`);
|
||||
}
|
||||
|
||||
@@ -1,69 +1,66 @@
|
||||
import figures from 'figures'
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { PastedContent } from '../../utils/config.js'
|
||||
import type { ImageDimensions } from '../../utils/imageResizer.js'
|
||||
import type { OptionWithDescription } from './select.js'
|
||||
import { SelectInputOption } from './select-input-option.js'
|
||||
import { SelectOption } from './select-option.js'
|
||||
import { useMultiSelectState } from './use-multi-select-state.js'
|
||||
import figures from 'figures';
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { PastedContent } from '../../utils/config.js';
|
||||
import type { ImageDimensions } from '../../utils/imageResizer.js';
|
||||
import type { OptionWithDescription } from './select.js';
|
||||
import { SelectInputOption } from './select-input-option.js';
|
||||
import { SelectOption } from './select-option.js';
|
||||
import { useMultiSelectState } from './use-multi-select-state.js';
|
||||
|
||||
export type SelectMultiProps<T> = {
|
||||
readonly isDisabled?: boolean
|
||||
readonly visibleOptionCount?: number
|
||||
readonly options: OptionWithDescription<T>[]
|
||||
readonly defaultValue?: T[]
|
||||
readonly onCancel: () => void
|
||||
readonly onChange?: (values: T[]) => void
|
||||
readonly onFocus?: (value: T) => void
|
||||
readonly focusValue?: T
|
||||
readonly isDisabled?: boolean;
|
||||
readonly visibleOptionCount?: number;
|
||||
readonly options: OptionWithDescription<T>[];
|
||||
readonly defaultValue?: T[];
|
||||
readonly onCancel: () => void;
|
||||
readonly onChange?: (values: T[]) => void;
|
||||
readonly onFocus?: (value: T) => void;
|
||||
readonly focusValue?: T;
|
||||
/**
|
||||
* Text for the submit button. When provided, a submit button is shown and
|
||||
* Enter toggles selection (submit only fires when the button is focused).
|
||||
* When omitted, Enter submits directly and Space toggles selection.
|
||||
*/
|
||||
readonly submitButtonText?: string
|
||||
readonly submitButtonText?: string;
|
||||
/**
|
||||
* Callback when user submits. Receives the currently selected values.
|
||||
*/
|
||||
readonly onSubmit?: (values: T[]) => void
|
||||
readonly onSubmit?: (values: T[]) => void;
|
||||
/**
|
||||
* When true, hides the numeric indexes next to each option.
|
||||
*/
|
||||
readonly hideIndexes?: boolean
|
||||
readonly hideIndexes?: boolean;
|
||||
/**
|
||||
* Callback when user presses down from the last item (submit button).
|
||||
* If provided, navigation will not wrap to the first item.
|
||||
*/
|
||||
readonly onDownFromLastItem?: () => void
|
||||
readonly onDownFromLastItem?: () => void;
|
||||
/**
|
||||
* Callback when user presses up from the first item.
|
||||
* If provided, navigation will not wrap to the last item.
|
||||
*/
|
||||
readonly onUpFromFirstItem?: () => void
|
||||
readonly onUpFromFirstItem?: () => void;
|
||||
/**
|
||||
* Focus the last option initially instead of the first.
|
||||
*/
|
||||
readonly initialFocusLast?: boolean
|
||||
readonly initialFocusLast?: boolean;
|
||||
/**
|
||||
* Callback to open external editor for editing input option values.
|
||||
* When provided, ctrl+g will trigger this callback in input options
|
||||
* with the current value and a setter function to update the internal state.
|
||||
*/
|
||||
readonly onOpenEditor?: (
|
||||
currentValue: string,
|
||||
setValue: (value: string) => void,
|
||||
) => void
|
||||
readonly onOpenEditor?: (currentValue: string, setValue: (value: string) => void) => void;
|
||||
readonly onImagePaste?: (
|
||||
base64Image: string,
|
||||
mediaType?: string,
|
||||
filename?: string,
|
||||
dimensions?: ImageDimensions,
|
||||
sourcePath?: string,
|
||||
) => void
|
||||
readonly pastedContents?: Record<number, PastedContent>
|
||||
readonly onRemoveImage?: (id: number) => void
|
||||
}
|
||||
) => void;
|
||||
readonly pastedContents?: Record<number, PastedContent>;
|
||||
readonly onRemoveImage?: (id: number) => void;
|
||||
};
|
||||
|
||||
export function SelectMulti<T>({
|
||||
isDisabled = false,
|
||||
@@ -100,53 +97,44 @@ export function SelectMulti<T>({
|
||||
onUpFromFirstItem,
|
||||
initialFocusLast,
|
||||
hideIndexes,
|
||||
})
|
||||
});
|
||||
|
||||
const maxIndexWidth = options.length.toString().length
|
||||
const maxIndexWidth = options.length.toString().length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column">
|
||||
{state.visibleOptions.map((option, index) => {
|
||||
const isOptionFocused =
|
||||
!isDisabled &&
|
||||
state.focusedValue === option.value &&
|
||||
!state.isSubmitFocused
|
||||
const isSelected = state.selectedValues.includes(option.value)
|
||||
const isOptionFocused = !isDisabled && state.focusedValue === option.value && !state.isSubmitFocused;
|
||||
const isSelected = state.selectedValues.includes(option.value);
|
||||
|
||||
const isFirstVisibleOption = option.index === state.visibleFromIndex
|
||||
const isLastVisibleOption = option.index === state.visibleToIndex - 1
|
||||
const areMoreOptionsBelow = state.visibleToIndex < options.length
|
||||
const areMoreOptionsAbove = state.visibleFromIndex > 0
|
||||
const isFirstVisibleOption = option.index === state.visibleFromIndex;
|
||||
const isLastVisibleOption = option.index === state.visibleToIndex - 1;
|
||||
const areMoreOptionsBelow = state.visibleToIndex < options.length;
|
||||
const areMoreOptionsAbove = state.visibleFromIndex > 0;
|
||||
|
||||
const i = state.visibleFromIndex + index + 1
|
||||
const i = state.visibleFromIndex + index + 1;
|
||||
|
||||
if (option.type === 'input') {
|
||||
const inputValue = state.inputValues.get(option.value) || ''
|
||||
const inputValue = state.inputValues.get(option.value) || '';
|
||||
|
||||
return (
|
||||
<Box key={String(option.value)} gap={1}>
|
||||
<SelectInputOption
|
||||
option={option}
|
||||
isFocused={isOptionFocused}
|
||||
isSelected={
|
||||
false /* We show selection state differently for multi-select */
|
||||
}
|
||||
shouldShowDownArrow={
|
||||
areMoreOptionsBelow && isLastVisibleOption
|
||||
}
|
||||
shouldShowUpArrow={
|
||||
areMoreOptionsAbove && isFirstVisibleOption
|
||||
}
|
||||
isSelected={false /* We show selection state differently for multi-select */}
|
||||
shouldShowDownArrow={areMoreOptionsBelow && isLastVisibleOption}
|
||||
shouldShowUpArrow={areMoreOptionsAbove && isFirstVisibleOption}
|
||||
maxIndexWidth={maxIndexWidth}
|
||||
index={i}
|
||||
inputValue={inputValue}
|
||||
onInputChange={value => {
|
||||
state.updateInputValue(option.value, value)
|
||||
state.updateInputValue(option.value, value);
|
||||
}}
|
||||
onSubmit={() => {}} /* We handle submit higher up */
|
||||
onExit={() => {
|
||||
onCancel()
|
||||
onCancel();
|
||||
}}
|
||||
layout="compact"
|
||||
onOpenEditor={onOpenEditor}
|
||||
@@ -154,56 +142,39 @@ export function SelectMulti<T>({
|
||||
pastedContents={pastedContents}
|
||||
onRemoveImage={onRemoveImage}
|
||||
>
|
||||
<Text color={isSelected ? 'success' : undefined}>
|
||||
[{isSelected ? figures.tick : ' '}]{' '}
|
||||
</Text>
|
||||
<Text color={isSelected ? 'success' : undefined}>[{isSelected ? figures.tick : ' '}] </Text>
|
||||
</SelectInputOption>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={String(option.value)} gap={1}>
|
||||
<SelectOption
|
||||
isFocused={isOptionFocused}
|
||||
isSelected={
|
||||
false /* We show selection state differently for multi-select */
|
||||
}
|
||||
isSelected={false /* We show selection state differently for multi-select */}
|
||||
shouldShowDownArrow={areMoreOptionsBelow && isLastVisibleOption}
|
||||
shouldShowUpArrow={areMoreOptionsAbove && isFirstVisibleOption}
|
||||
description={option.description}
|
||||
>
|
||||
{!hideIndexes && (
|
||||
<Text dimColor>{`${i}.`.padEnd(maxIndexWidth)}</Text>
|
||||
)}
|
||||
<Text color={isSelected ? 'success' : undefined}>
|
||||
[{isSelected ? figures.tick : ' '}]
|
||||
</Text>
|
||||
<Text color={isOptionFocused ? 'suggestion' : undefined}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{!hideIndexes && <Text dimColor>{`${i}.`.padEnd(maxIndexWidth)}</Text>}
|
||||
<Text color={isSelected ? 'success' : undefined}>[{isSelected ? figures.tick : ' '}]</Text>
|
||||
<Text color={isOptionFocused ? 'suggestion' : undefined}>{option.label}</Text>
|
||||
</SelectOption>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{submitButtonText && onSubmit && (
|
||||
<Box marginTop={0} gap={1}>
|
||||
{state.isSubmitFocused ? (
|
||||
<Text color="suggestion">{figures.pointer}</Text>
|
||||
) : (
|
||||
<Text> </Text>
|
||||
)}
|
||||
{state.isSubmitFocused ? <Text color="suggestion">{figures.pointer}</Text> : <Text> </Text>}
|
||||
<Box marginLeft={3}>
|
||||
<Text
|
||||
color={state.isSubmitFocused ? 'suggestion' : undefined}
|
||||
bold={true}
|
||||
>
|
||||
<Text color={state.isSubmitFocused ? 'suggestion' : undefined} bold={true}>
|
||||
{submitButtonText}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,55 +1,49 @@
|
||||
import React, { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import React, { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- UP arrow exit not in Attachments bindings
|
||||
import { Box, Text, useInput } from '@anthropic/ink'
|
||||
import {
|
||||
useKeybinding,
|
||||
useKeybindings,
|
||||
} from '../../keybindings/useKeybinding.js'
|
||||
import type { PastedContent } from '../../utils/config.js'
|
||||
import { getImageFromClipboard } from '../../utils/imagePaste.js'
|
||||
import type { ImageDimensions } from '../../utils/imageResizer.js'
|
||||
import { ClickableImageRef } from '../ClickableImageRef.js'
|
||||
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
|
||||
import { Byline } from '@anthropic/ink'
|
||||
import TextInput from '../TextInput.js'
|
||||
import type { OptionWithDescription } from './select.js'
|
||||
import { SelectOption } from './select-option.js'
|
||||
import { Box, Text, useInput } from '@anthropic/ink';
|
||||
import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
import type { PastedContent } from '../../utils/config.js';
|
||||
import { getImageFromClipboard } from '../../utils/imagePaste.js';
|
||||
import type { ImageDimensions } from '../../utils/imageResizer.js';
|
||||
import { ClickableImageRef } from '../ClickableImageRef.js';
|
||||
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
|
||||
import { Byline } from '@anthropic/ink';
|
||||
import TextInput from '../TextInput.js';
|
||||
import type { OptionWithDescription } from './select.js';
|
||||
import { SelectOption } from './select-option.js';
|
||||
|
||||
type Props<T> = {
|
||||
option: Extract<OptionWithDescription<T>, { type: 'input' }>
|
||||
isFocused: boolean
|
||||
isSelected: boolean
|
||||
shouldShowDownArrow: boolean
|
||||
shouldShowUpArrow: boolean
|
||||
maxIndexWidth: number
|
||||
index: number
|
||||
inputValue: string
|
||||
onInputChange: (value: string) => void
|
||||
onSubmit: (value: string) => void
|
||||
onExit?: () => void
|
||||
layout: 'compact' | 'expanded'
|
||||
children?: ReactNode
|
||||
option: Extract<OptionWithDescription<T>, { type: 'input' }>;
|
||||
isFocused: boolean;
|
||||
isSelected: boolean;
|
||||
shouldShowDownArrow: boolean;
|
||||
shouldShowUpArrow: boolean;
|
||||
maxIndexWidth: number;
|
||||
index: number;
|
||||
inputValue: string;
|
||||
onInputChange: (value: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
onExit?: () => void;
|
||||
layout: 'compact' | 'expanded';
|
||||
children?: ReactNode;
|
||||
/**
|
||||
* When true, shows the label before the input field.
|
||||
* When false (default), uses the label as the placeholder.
|
||||
*/
|
||||
showLabel?: boolean
|
||||
showLabel?: boolean;
|
||||
/**
|
||||
* Callback to open external editor for editing the input value.
|
||||
* When provided, ctrl+g will trigger this callback with the current value
|
||||
* and a setter function to update the internal state.
|
||||
*/
|
||||
onOpenEditor?: (
|
||||
currentValue: string,
|
||||
setValue: (value: string) => void,
|
||||
) => void
|
||||
onOpenEditor?: (currentValue: string, setValue: (value: string) => void) => void;
|
||||
/**
|
||||
* When true, automatically reset cursor to end of line when:
|
||||
* - Option becomes focused
|
||||
* - Input value changes
|
||||
* This prevents cursor position bugs when the input value updates asynchronously.
|
||||
*/
|
||||
resetCursorOnUpdate?: boolean
|
||||
resetCursorOnUpdate?: boolean;
|
||||
/**
|
||||
* Optional callback when an image is pasted into the input.
|
||||
*/
|
||||
@@ -59,32 +53,32 @@ type Props<T> = {
|
||||
filename?: string,
|
||||
dimensions?: ImageDimensions,
|
||||
sourcePath?: string,
|
||||
) => void
|
||||
) => void;
|
||||
/**
|
||||
* Pasted content to display inline above the input when focused.
|
||||
*/
|
||||
pastedContents?: Record<number, PastedContent>
|
||||
pastedContents?: Record<number, PastedContent>;
|
||||
/**
|
||||
* Callback to remove a pasted image by its ID.
|
||||
*/
|
||||
onRemoveImage?: (id: number) => void
|
||||
onRemoveImage?: (id: number) => void;
|
||||
/**
|
||||
* Whether image selection mode is active.
|
||||
*/
|
||||
imagesSelected?: boolean
|
||||
imagesSelected?: boolean;
|
||||
/**
|
||||
* Currently selected image index within the image attachments array.
|
||||
*/
|
||||
selectedImageIndex?: number
|
||||
selectedImageIndex?: number;
|
||||
/**
|
||||
* Callback to set image selection mode on/off.
|
||||
*/
|
||||
onImagesSelectedChange?: (selected: boolean) => void
|
||||
onImagesSelectedChange?: (selected: boolean) => void;
|
||||
/**
|
||||
* Callback to change the selected image index.
|
||||
*/
|
||||
onSelectedImageIndexChange?: (index: number) => void
|
||||
}
|
||||
onSelectedImageIndexChange?: (index: number) => void;
|
||||
};
|
||||
|
||||
export function SelectInputOption<T>({
|
||||
option,
|
||||
@@ -111,17 +105,15 @@ export function SelectInputOption<T>({
|
||||
onImagesSelectedChange,
|
||||
onSelectedImageIndexChange,
|
||||
}: Props<T>): React.ReactNode {
|
||||
const imageAttachments = pastedContents
|
||||
? Object.values(pastedContents).filter(c => c.type === 'image')
|
||||
: []
|
||||
const imageAttachments = pastedContents ? Object.values(pastedContents).filter(c => c.type === 'image') : [];
|
||||
|
||||
// Allow individual options to force showing the label via showLabelWithValue
|
||||
const showLabel = showLabelProp || option.showLabelWithValue === true
|
||||
const [cursorOffset, setCursorOffset] = useState(inputValue.length)
|
||||
const showLabel = showLabelProp || option.showLabelWithValue === true;
|
||||
const [cursorOffset, setCursorOffset] = useState(inputValue.length);
|
||||
|
||||
// Track whether the latest inputValue change was from user typing/pasting,
|
||||
// so we can skip resetting cursor to end on user-initiated changes.
|
||||
const isUserEditing = useRef(false)
|
||||
const isUserEditing = useRef(false);
|
||||
|
||||
// Reset cursor to end of line when:
|
||||
// 1. Option becomes focused (user navigates to it)
|
||||
@@ -131,119 +123,101 @@ export function SelectInputOption<T>({
|
||||
useEffect(() => {
|
||||
if (resetCursorOnUpdate && isFocused) {
|
||||
if (isUserEditing.current) {
|
||||
isUserEditing.current = false
|
||||
isUserEditing.current = false;
|
||||
} else {
|
||||
setCursorOffset(inputValue.length)
|
||||
setCursorOffset(inputValue.length);
|
||||
}
|
||||
}
|
||||
}, [resetCursorOnUpdate, isFocused, inputValue])
|
||||
}, [resetCursorOnUpdate, isFocused, inputValue]);
|
||||
|
||||
// ctrl+g to open external editor (reuses chat:externalEditor keybinding)
|
||||
useKeybinding(
|
||||
'chat:externalEditor',
|
||||
() => {
|
||||
onOpenEditor?.(inputValue, onInputChange)
|
||||
onOpenEditor?.(inputValue, onInputChange);
|
||||
},
|
||||
{ context: 'Chat', isActive: isFocused && !!onOpenEditor },
|
||||
)
|
||||
);
|
||||
|
||||
// ctrl+v to paste image from clipboard (same as PromptInput)
|
||||
useKeybinding(
|
||||
'chat:imagePaste',
|
||||
() => {
|
||||
if (!onImagePaste) return
|
||||
if (!onImagePaste) return;
|
||||
void getImageFromClipboard().then(imageData => {
|
||||
if (imageData) {
|
||||
onImagePaste(
|
||||
imageData.base64,
|
||||
imageData.mediaType,
|
||||
undefined,
|
||||
imageData.dimensions,
|
||||
)
|
||||
onImagePaste(imageData.base64, imageData.mediaType, undefined, imageData.dimensions);
|
||||
}
|
||||
})
|
||||
});
|
||||
},
|
||||
{ context: 'Chat', isActive: isFocused && !!onImagePaste },
|
||||
)
|
||||
);
|
||||
|
||||
// Backspace with empty input removes the last pasted image (non-image-selection mode)
|
||||
useKeybinding(
|
||||
'attachments:remove',
|
||||
() => {
|
||||
if (imageAttachments.length > 0 && onRemoveImage) {
|
||||
onRemoveImage(imageAttachments.at(-1)!.id)
|
||||
onRemoveImage(imageAttachments.at(-1)!.id);
|
||||
}
|
||||
},
|
||||
{
|
||||
context: 'Attachments',
|
||||
isActive:
|
||||
isFocused &&
|
||||
!imagesSelected &&
|
||||
inputValue === '' &&
|
||||
imageAttachments.length > 0 &&
|
||||
!!onRemoveImage,
|
||||
isActive: isFocused && !imagesSelected && inputValue === '' && imageAttachments.length > 0 && !!onRemoveImage,
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
// Image selection mode keybindings — reuses existing Attachments actions
|
||||
useKeybindings(
|
||||
{
|
||||
'attachments:next': () => {
|
||||
if (imageAttachments.length > 1) {
|
||||
onSelectedImageIndexChange?.(
|
||||
(selectedImageIndex + 1) % imageAttachments.length,
|
||||
)
|
||||
onSelectedImageIndexChange?.((selectedImageIndex + 1) % imageAttachments.length);
|
||||
}
|
||||
},
|
||||
'attachments:previous': () => {
|
||||
if (imageAttachments.length > 1) {
|
||||
onSelectedImageIndexChange?.(
|
||||
(selectedImageIndex - 1 + imageAttachments.length) %
|
||||
imageAttachments.length,
|
||||
)
|
||||
onSelectedImageIndexChange?.((selectedImageIndex - 1 + imageAttachments.length) % imageAttachments.length);
|
||||
}
|
||||
},
|
||||
'attachments:remove': () => {
|
||||
const img = imageAttachments[selectedImageIndex]
|
||||
const img = imageAttachments[selectedImageIndex];
|
||||
if (img && onRemoveImage) {
|
||||
onRemoveImage(img.id)
|
||||
onRemoveImage(img.id);
|
||||
// If no images left after removal, exit image selection
|
||||
if (imageAttachments.length <= 1) {
|
||||
onImagesSelectedChange?.(false)
|
||||
onImagesSelectedChange?.(false);
|
||||
} else {
|
||||
// Adjust index if we deleted the last image
|
||||
onSelectedImageIndexChange?.(
|
||||
Math.min(selectedImageIndex, imageAttachments.length - 2),
|
||||
)
|
||||
onSelectedImageIndexChange?.(Math.min(selectedImageIndex, imageAttachments.length - 2));
|
||||
}
|
||||
}
|
||||
},
|
||||
'attachments:exit': () => {
|
||||
onImagesSelectedChange?.(false)
|
||||
onImagesSelectedChange?.(false);
|
||||
},
|
||||
},
|
||||
{ context: 'Attachments', isActive: isFocused && !!imagesSelected },
|
||||
)
|
||||
);
|
||||
|
||||
// UP arrow exits image selection mode (UP isn't bound to attachments:exit)
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.upArrow) {
|
||||
onImagesSelectedChange?.(false)
|
||||
onImagesSelectedChange?.(false);
|
||||
}
|
||||
},
|
||||
{ isActive: isFocused && !!imagesSelected },
|
||||
)
|
||||
);
|
||||
|
||||
// Exit image mode when option loses focus
|
||||
useEffect(() => {
|
||||
if (!isFocused && imagesSelected) {
|
||||
onImagesSelectedChange?.(false)
|
||||
onImagesSelectedChange?.(false);
|
||||
}
|
||||
}, [isFocused, imagesSelected, onImagesSelectedChange])
|
||||
}, [isFocused, imagesSelected, onImagesSelectedChange]);
|
||||
|
||||
const descriptionPaddingLeft =
|
||||
layout === 'expanded' ? maxIndexWidth + 3 : maxIndexWidth + 4
|
||||
const descriptionPaddingLeft = layout === 'expanded' ? maxIndexWidth + 3 : maxIndexWidth + 4;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexShrink={0}>
|
||||
@@ -254,28 +228,21 @@ export function SelectInputOption<T>({
|
||||
shouldShowUpArrow={shouldShowUpArrow}
|
||||
declareCursor={false}
|
||||
>
|
||||
<Box
|
||||
flexDirection="row"
|
||||
flexShrink={layout === 'compact' ? 0 : undefined}
|
||||
>
|
||||
<Box flexDirection="row" flexShrink={layout === 'compact' ? 0 : undefined}>
|
||||
<Text dimColor>{`${index}.`.padEnd(maxIndexWidth + 2)}</Text>
|
||||
{children}
|
||||
{showLabel ? (
|
||||
<>
|
||||
<Text color={isFocused ? 'suggestion' : undefined}>
|
||||
{option.label}
|
||||
</Text>
|
||||
<Text color={isFocused ? 'suggestion' : undefined}>{option.label}</Text>
|
||||
{isFocused ? (
|
||||
<>
|
||||
<Text color="suggestion">
|
||||
{option.labelValueSeparator ?? ', '}
|
||||
</Text>
|
||||
<Text color="suggestion">{option.labelValueSeparator ?? ', '}</Text>
|
||||
<TextInput
|
||||
value={inputValue}
|
||||
onChange={value => {
|
||||
isUserEditing.current = true
|
||||
onInputChange(value)
|
||||
option.onChange(value)
|
||||
isUserEditing.current = true;
|
||||
onInputChange(value);
|
||||
option.onChange(value);
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
onExit={onExit}
|
||||
@@ -288,13 +255,13 @@ export function SelectInputOption<T>({
|
||||
columns={80}
|
||||
onImagePaste={onImagePaste}
|
||||
onPaste={(pastedText: string) => {
|
||||
isUserEditing.current = true
|
||||
const before = inputValue.slice(0, cursorOffset)
|
||||
const after = inputValue.slice(cursorOffset)
|
||||
const newValue = before + pastedText + after
|
||||
onInputChange(newValue)
|
||||
option.onChange(newValue)
|
||||
setCursorOffset(before.length + pastedText.length)
|
||||
isUserEditing.current = true;
|
||||
const before = inputValue.slice(0, cursorOffset);
|
||||
const after = inputValue.slice(cursorOffset);
|
||||
const newValue = before + pastedText + after;
|
||||
onInputChange(newValue);
|
||||
option.onChange(newValue);
|
||||
setCursorOffset(before.length + pastedText.length);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
@@ -311,16 +278,13 @@ export function SelectInputOption<T>({
|
||||
<TextInput
|
||||
value={inputValue}
|
||||
onChange={value => {
|
||||
isUserEditing.current = true
|
||||
onInputChange(value)
|
||||
option.onChange(value)
|
||||
isUserEditing.current = true;
|
||||
onInputChange(value);
|
||||
option.onChange(value);
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
onExit={onExit}
|
||||
placeholder={
|
||||
option.placeholder ||
|
||||
(typeof option.label === 'string' ? option.label : undefined)
|
||||
}
|
||||
placeholder={option.placeholder || (typeof option.label === 'string' ? option.label : undefined)}
|
||||
focus={!imagesSelected}
|
||||
showCursor={true}
|
||||
multiline={true}
|
||||
@@ -329,19 +293,17 @@ export function SelectInputOption<T>({
|
||||
columns={80}
|
||||
onImagePaste={onImagePaste}
|
||||
onPaste={(pastedText: string) => {
|
||||
isUserEditing.current = true
|
||||
const before = inputValue.slice(0, cursorOffset)
|
||||
const after = inputValue.slice(cursorOffset)
|
||||
const newValue = before + pastedText + after
|
||||
onInputChange(newValue)
|
||||
option.onChange(newValue)
|
||||
setCursorOffset(before.length + pastedText.length)
|
||||
isUserEditing.current = true;
|
||||
const before = inputValue.slice(0, cursorOffset);
|
||||
const after = inputValue.slice(cursorOffset);
|
||||
const newValue = before + pastedText + after;
|
||||
onInputChange(newValue);
|
||||
option.onChange(newValue);
|
||||
setCursorOffset(before.length + pastedText.length);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Text color={inputValue ? undefined : 'inactive'}>
|
||||
{inputValue || option.placeholder || option.label}
|
||||
</Text>
|
||||
<Text color={inputValue ? undefined : 'inactive'}>{inputValue || option.placeholder || option.label}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</SelectOption>
|
||||
@@ -349,9 +311,7 @@ export function SelectInputOption<T>({
|
||||
<Box paddingLeft={descriptionPaddingLeft}>
|
||||
<Text
|
||||
dimColor={option.dimDescription !== false}
|
||||
color={
|
||||
isSelected ? 'success' : isFocused ? 'suggestion' : undefined
|
||||
}
|
||||
color={isSelected ? 'success' : isFocused ? 'suggestion' : undefined}
|
||||
>
|
||||
{option.description}
|
||||
</Text>
|
||||
@@ -408,5 +368,5 @@ export function SelectInputOption<T>({
|
||||
)}
|
||||
{layout === 'expanded' && <Text> </Text>}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
import React, { type ReactNode } from 'react'
|
||||
import { ListItem } from '@anthropic/ink'
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { ListItem } from '@anthropic/ink';
|
||||
|
||||
export type SelectOptionProps = {
|
||||
/**
|
||||
* Determines if option is focused.
|
||||
*/
|
||||
readonly isFocused: boolean
|
||||
readonly isFocused: boolean;
|
||||
|
||||
/**
|
||||
* Determines if option is selected.
|
||||
*/
|
||||
readonly isSelected: boolean
|
||||
readonly isSelected: boolean;
|
||||
|
||||
/**
|
||||
* Option label.
|
||||
*/
|
||||
readonly children: ReactNode
|
||||
readonly children: ReactNode;
|
||||
|
||||
/**
|
||||
* Optional description to display below the label.
|
||||
*/
|
||||
readonly description?: string
|
||||
readonly description?: string;
|
||||
|
||||
/**
|
||||
* Determines if the down arrow should be shown.
|
||||
*/
|
||||
readonly shouldShowDownArrow?: boolean
|
||||
readonly shouldShowDownArrow?: boolean;
|
||||
|
||||
/**
|
||||
* Determines if the up arrow should be shown.
|
||||
*/
|
||||
readonly shouldShowUpArrow?: boolean
|
||||
readonly shouldShowUpArrow?: boolean;
|
||||
|
||||
/**
|
||||
* Whether ListItem should declare the terminal cursor position.
|
||||
* Set false when a child declares its own cursor (e.g. BaseTextInput).
|
||||
*/
|
||||
readonly declareCursor?: boolean
|
||||
}
|
||||
readonly declareCursor?: boolean;
|
||||
};
|
||||
|
||||
export function SelectOption({
|
||||
isFocused,
|
||||
@@ -60,5 +60,5 @@ export function SelectOption({
|
||||
>
|
||||
{children}
|
||||
</ListItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,120 +1,106 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import type { CommandResultDisplay } from '../commands.js'
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { CommandResultDisplay } from '../commands.js';
|
||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw input for "any key" dismiss and y/n prompt
|
||||
import { Box, Text, useInput, LoadingState } from '@anthropic/ink'
|
||||
import { getDesktopInstallStatus, openCurrentSessionInDesktop } from '../utils/desktopDeepLink.js'
|
||||
import { openBrowser } from '../utils/browser.js'
|
||||
import { Box, Text, useInput, LoadingState } from '@anthropic/ink';
|
||||
import { getDesktopInstallStatus, openCurrentSessionInDesktop } from '../utils/desktopDeepLink.js';
|
||||
import { openBrowser } from '../utils/browser.js';
|
||||
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { gracefulShutdown } from '../utils/gracefulShutdown.js'
|
||||
import { flushSessionStorage } from '../utils/sessionStorage.js'
|
||||
import { errorMessage } from '../utils/errors.js';
|
||||
import { gracefulShutdown } from '../utils/gracefulShutdown.js';
|
||||
import { flushSessionStorage } from '../utils/sessionStorage.js';
|
||||
|
||||
const DESKTOP_DOCS_URL = 'https://clau.de/desktop'
|
||||
const DESKTOP_DOCS_URL = 'https://clau.de/desktop';
|
||||
|
||||
export function getDownloadUrl(): string {
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
return 'https://claude.ai/api/desktop/win32/x64/exe/latest/redirect'
|
||||
return 'https://claude.ai/api/desktop/win32/x64/exe/latest/redirect';
|
||||
default:
|
||||
return 'https://claude.ai/api/desktop/darwin/universal/dmg/latest/redirect'
|
||||
return 'https://claude.ai/api/desktop/darwin/universal/dmg/latest/redirect';
|
||||
}
|
||||
}
|
||||
|
||||
type DesktopHandoffState =
|
||||
| 'checking'
|
||||
| 'prompt-download'
|
||||
| 'flushing'
|
||||
| 'opening'
|
||||
| 'success'
|
||||
| 'error'
|
||||
type DesktopHandoffState = 'checking' | 'prompt-download' | 'flushing' | 'opening' | 'success' | 'error';
|
||||
|
||||
type Props = {
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
}
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
};
|
||||
|
||||
export function DesktopHandoff({ onDone }: Props): React.ReactNode {
|
||||
const [state, setState] = useState<DesktopHandoffState>('checking')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [downloadMessage, setDownloadMessage] = useState<string>('')
|
||||
const [state, setState] = useState<DesktopHandoffState>('checking');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [downloadMessage, setDownloadMessage] = useState<string>('');
|
||||
|
||||
// Handle keyboard input for error and prompt-download states
|
||||
useInput(input => {
|
||||
if (state === 'error') {
|
||||
onDone(error ?? 'Unknown error', { display: 'system' })
|
||||
return
|
||||
onDone(error ?? 'Unknown error', { display: 'system' });
|
||||
return;
|
||||
}
|
||||
if (state === 'prompt-download') {
|
||||
if (input === 'y' || input === 'Y') {
|
||||
openBrowser(getDownloadUrl()).catch(() => {})
|
||||
openBrowser(getDownloadUrl()).catch(() => {});
|
||||
onDone(
|
||||
`Starting download. Re-run /desktop once you\u2019ve installed the app.\nLearn more at ${DESKTOP_DOCS_URL}`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
);
|
||||
} else if (input === 'n' || input === 'N') {
|
||||
onDone(
|
||||
`The desktop app is required for /desktop. Learn more at ${DESKTOP_DOCS_URL}`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
onDone(`The desktop app is required for /desktop. Learn more at ${DESKTOP_DOCS_URL}`, { display: 'system' });
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function performHandoff(): Promise<void> {
|
||||
// Check Desktop install status
|
||||
setState('checking')
|
||||
const installStatus = await getDesktopInstallStatus()
|
||||
setState('checking');
|
||||
const installStatus = await getDesktopInstallStatus();
|
||||
|
||||
if (installStatus.status === 'not-installed') {
|
||||
setDownloadMessage('Claude Desktop is not installed.')
|
||||
setState('prompt-download')
|
||||
return
|
||||
setDownloadMessage('Claude Desktop is not installed.');
|
||||
setState('prompt-download');
|
||||
return;
|
||||
}
|
||||
|
||||
if (installStatus.status === 'version-too-old') {
|
||||
setDownloadMessage(
|
||||
`Claude Desktop needs to be updated (found v${installStatus.version}, need v1.1.2396+).`,
|
||||
)
|
||||
setState('prompt-download')
|
||||
return
|
||||
setDownloadMessage(`Claude Desktop needs to be updated (found v${installStatus.version}, need v1.1.2396+).`);
|
||||
setState('prompt-download');
|
||||
return;
|
||||
}
|
||||
|
||||
// Flush session storage to ensure transcript is fully written
|
||||
setState('flushing')
|
||||
await flushSessionStorage()
|
||||
setState('flushing');
|
||||
await flushSessionStorage();
|
||||
|
||||
// Open the deep link (uses claude-dev:// in dev mode)
|
||||
setState('opening')
|
||||
const result = await openCurrentSessionInDesktop()
|
||||
setState('opening');
|
||||
const result = await openCurrentSessionInDesktop();
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? 'Failed to open Claude Desktop')
|
||||
setState('error')
|
||||
return
|
||||
setError(result.error ?? 'Failed to open Claude Desktop');
|
||||
setState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Success - exit the CLI
|
||||
setState('success')
|
||||
setState('success');
|
||||
|
||||
// Give the user a moment to see the success message
|
||||
setTimeout(
|
||||
async (onDone: Props['onDone']) => {
|
||||
onDone('Session transferred to Claude Desktop', { display: 'system' })
|
||||
await gracefulShutdown(0, 'other')
|
||||
onDone('Session transferred to Claude Desktop', { display: 'system' });
|
||||
await gracefulShutdown(0, 'other');
|
||||
},
|
||||
500,
|
||||
onDone,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
performHandoff().catch(err => {
|
||||
setError(errorMessage(err))
|
||||
setState('error')
|
||||
})
|
||||
}, [onDone])
|
||||
setError(errorMessage(err));
|
||||
setState('error');
|
||||
});
|
||||
}, [onDone]);
|
||||
|
||||
if (state === 'error') {
|
||||
return (
|
||||
@@ -122,7 +108,7 @@ export function DesktopHandoff({ onDone }: Props): React.ReactNode {
|
||||
<Text color="error">Error: {error}</Text>
|
||||
<Text dimColor>Press any key to continue…</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'prompt-download') {
|
||||
@@ -131,18 +117,15 @@ export function DesktopHandoff({ onDone }: Props): React.ReactNode {
|
||||
<Text>{downloadMessage}</Text>
|
||||
<Text>Download now? (y/n)</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const messages: Record<
|
||||
Exclude<DesktopHandoffState, 'error' | 'prompt-download'>,
|
||||
string
|
||||
> = {
|
||||
const messages: Record<Exclude<DesktopHandoffState, 'error' | 'prompt-download'>, string> = {
|
||||
checking: 'Checking for Claude Desktop…',
|
||||
flushing: 'Saving session…',
|
||||
opening: 'Opening Claude Desktop…',
|
||||
success: 'Opening in Claude Desktop…',
|
||||
}
|
||||
};
|
||||
|
||||
return <LoadingState message={messages[state]} />
|
||||
return <LoadingState message={messages[state]} />;
|
||||
}
|
||||
|
||||
@@ -1,84 +1,78 @@
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { getDynamicConfig_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { Select } from '../CustomSelect/select.js'
|
||||
import { DesktopHandoff } from '../DesktopHandoff.js'
|
||||
import { PermissionDialog } from '../permissions/PermissionDialog.js'
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { getDynamicConfig_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
import { Select } from '../CustomSelect/select.js';
|
||||
import { DesktopHandoff } from '../DesktopHandoff.js';
|
||||
import { PermissionDialog } from '../permissions/PermissionDialog.js';
|
||||
|
||||
type DesktopUpsellConfig = {
|
||||
enable_shortcut_tip: boolean
|
||||
enable_startup_dialog: boolean
|
||||
}
|
||||
enable_shortcut_tip: boolean;
|
||||
enable_startup_dialog: boolean;
|
||||
};
|
||||
|
||||
const DESKTOP_UPSELL_DEFAULT: DesktopUpsellConfig = {
|
||||
enable_shortcut_tip: false,
|
||||
enable_startup_dialog: false,
|
||||
}
|
||||
};
|
||||
|
||||
export function getDesktopUpsellConfig(): DesktopUpsellConfig {
|
||||
return getDynamicConfig_CACHED_MAY_BE_STALE(
|
||||
'tengu_desktop_upsell',
|
||||
DESKTOP_UPSELL_DEFAULT,
|
||||
)
|
||||
return getDynamicConfig_CACHED_MAY_BE_STALE('tengu_desktop_upsell', DESKTOP_UPSELL_DEFAULT);
|
||||
}
|
||||
|
||||
function isSupportedPlatform(): boolean {
|
||||
return (
|
||||
process.platform === 'darwin' ||
|
||||
(process.platform === 'win32' && process.arch === 'x64')
|
||||
)
|
||||
return process.platform === 'darwin' || (process.platform === 'win32' && process.arch === 'x64');
|
||||
}
|
||||
|
||||
export function shouldShowDesktopUpsellStartup(): boolean {
|
||||
if (!isSupportedPlatform()) return false
|
||||
if (!getDesktopUpsellConfig().enable_startup_dialog) return false
|
||||
const config = getGlobalConfig()
|
||||
if (config.desktopUpsellDismissed) return false
|
||||
if ((config.desktopUpsellSeenCount ?? 0) >= 3) return false
|
||||
return true
|
||||
if (!isSupportedPlatform()) return false;
|
||||
if (!getDesktopUpsellConfig().enable_startup_dialog) return false;
|
||||
const config = getGlobalConfig();
|
||||
if (config.desktopUpsellDismissed) return false;
|
||||
if ((config.desktopUpsellSeenCount ?? 0) >= 3) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
type DesktopUpsellSelection = 'try' | 'not-now' | 'never'
|
||||
type DesktopUpsellSelection = 'try' | 'not-now' | 'never';
|
||||
|
||||
type Props = {
|
||||
onDone: () => void
|
||||
}
|
||||
onDone: () => void;
|
||||
};
|
||||
|
||||
export function DesktopUpsellStartup({ onDone }: Props): React.ReactNode {
|
||||
const [showHandoff, setShowHandoff] = useState(false)
|
||||
const [showHandoff, setShowHandoff] = useState(false);
|
||||
|
||||
// Increment seen count on mount (guard in updater for StrictMode safety)
|
||||
useEffect(() => {
|
||||
const newCount = (getGlobalConfig().desktopUpsellSeenCount ?? 0) + 1
|
||||
const newCount = (getGlobalConfig().desktopUpsellSeenCount ?? 0) + 1;
|
||||
saveGlobalConfig(prev => {
|
||||
if ((prev.desktopUpsellSeenCount ?? 0) >= newCount) return prev
|
||||
return { ...prev, desktopUpsellSeenCount: newCount }
|
||||
})
|
||||
logEvent('tengu_desktop_upsell_shown', { seen_count: newCount })
|
||||
}, [])
|
||||
if ((prev.desktopUpsellSeenCount ?? 0) >= newCount) return prev;
|
||||
return { ...prev, desktopUpsellSeenCount: newCount };
|
||||
});
|
||||
logEvent('tengu_desktop_upsell_shown', { seen_count: newCount });
|
||||
}, []);
|
||||
|
||||
if (showHandoff) {
|
||||
return <DesktopHandoff onDone={() => onDone()} />
|
||||
return <DesktopHandoff onDone={() => onDone()} />;
|
||||
}
|
||||
|
||||
function handleSelect(value: DesktopUpsellSelection): void {
|
||||
switch (value) {
|
||||
case 'try':
|
||||
setShowHandoff(true)
|
||||
return
|
||||
setShowHandoff(true);
|
||||
return;
|
||||
case 'never':
|
||||
saveGlobalConfig(prev => {
|
||||
if (prev.desktopUpsellDismissed) return prev
|
||||
return { ...prev, desktopUpsellDismissed: true }
|
||||
})
|
||||
onDone()
|
||||
return
|
||||
if (prev.desktopUpsellDismissed) return prev;
|
||||
return { ...prev, desktopUpsellDismissed: true };
|
||||
});
|
||||
onDone();
|
||||
return;
|
||||
case 'not-now':
|
||||
onDone()
|
||||
return
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,23 +80,16 @@ export function DesktopUpsellStartup({ onDone }: Props): React.ReactNode {
|
||||
{ label: 'Open in Claude Code Desktop', value: 'try' as const },
|
||||
{ label: 'Not now', value: 'not-now' as const },
|
||||
{ label: "Don't ask again", value: 'never' as const },
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<PermissionDialog title="Try Claude Code Desktop">
|
||||
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text>
|
||||
Same Claude Code with visual diffs, live app preview, parallel
|
||||
sessions, and more.
|
||||
</Text>
|
||||
<Text>Same Claude Code with visual diffs, live app preview, parallel sessions, and more.</Text>
|
||||
</Box>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={handleSelect}
|
||||
onCancel={() => handleSelect('not-now')}
|
||||
/>
|
||||
<Select options={options} onChange={handleSelect} onCancel={() => handleSelect('not-now')} />
|
||||
</Box>
|
||||
</PermissionDialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +1,44 @@
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { getSlowOperations } from '../bootstrap/state.js'
|
||||
import { Text, useInterval } from '@anthropic/ink'
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { getSlowOperations } from '../bootstrap/state.js';
|
||||
import { Text, useInterval } from '@anthropic/ink';
|
||||
|
||||
// Show DevBar for dev builds or all ants
|
||||
function shouldShowDevBar(): boolean {
|
||||
return (
|
||||
process.env.NODE_ENV === 'development' || process.env.USER_TYPE === 'ant'
|
||||
)
|
||||
return process.env.NODE_ENV === 'development' || process.env.USER_TYPE === 'ant';
|
||||
}
|
||||
|
||||
export function DevBar(): React.ReactNode {
|
||||
const [slowOps, setSlowOps] =
|
||||
useState<
|
||||
ReadonlyArray<{
|
||||
operation: string
|
||||
durationMs: number
|
||||
timestamp: number
|
||||
operation: string;
|
||||
durationMs: number;
|
||||
timestamp: number;
|
||||
}>
|
||||
>(getSlowOperations)
|
||||
>(getSlowOperations);
|
||||
|
||||
useInterval(
|
||||
() => {
|
||||
setSlowOps(getSlowOperations())
|
||||
setSlowOps(getSlowOperations());
|
||||
},
|
||||
shouldShowDevBar() ? 500 : null,
|
||||
)
|
||||
);
|
||||
|
||||
// Only show when there's something to display
|
||||
if (!shouldShowDevBar() || slowOps.length === 0) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// Single-line format so short terminals don't lose rows to dev noise.
|
||||
const recentOps = slowOps
|
||||
.slice(-3)
|
||||
.map(op => `${op.operation} (${Math.round(op.durationMs)}ms)`)
|
||||
.join(' · ')
|
||||
.join(' · ');
|
||||
|
||||
return (
|
||||
<Text wrap="truncate-end" color="warning">
|
||||
[ANT-ONLY] slow sync: {recentOps}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,42 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import type { ChannelEntry } from '../bootstrap/state.js'
|
||||
import { Box, Text, Dialog } from '@anthropic/ink'
|
||||
import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'
|
||||
import { Select } from './CustomSelect/index.js'
|
||||
import React, { useCallback } from 'react';
|
||||
import type { ChannelEntry } from '../bootstrap/state.js';
|
||||
import { Box, Text, Dialog } from '@anthropic/ink';
|
||||
import { gracefulShutdownSync } from '../utils/gracefulShutdown.js';
|
||||
import { Select } from './CustomSelect/index.js';
|
||||
|
||||
type Props = {
|
||||
channels: ChannelEntry[]
|
||||
onAccept(): void
|
||||
}
|
||||
channels: ChannelEntry[];
|
||||
onAccept(): void;
|
||||
};
|
||||
|
||||
export function DevChannelsDialog({
|
||||
channels,
|
||||
onAccept,
|
||||
}: Props): React.ReactNode {
|
||||
export function DevChannelsDialog({ channels, onAccept }: Props): React.ReactNode {
|
||||
function onChange(value: 'accept' | 'exit') {
|
||||
switch (value) {
|
||||
case 'accept':
|
||||
onAccept()
|
||||
break
|
||||
onAccept();
|
||||
break;
|
||||
case 'exit':
|
||||
gracefulShutdownSync(1)
|
||||
break
|
||||
gracefulShutdownSync(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = useCallback(() => {
|
||||
gracefulShutdownSync(0)
|
||||
}, [])
|
||||
gracefulShutdownSync(0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="WARNING: Loading development channels"
|
||||
color="error"
|
||||
onCancel={handleEscape}
|
||||
>
|
||||
<Dialog title="WARNING: Loading development channels" color="error" onCancel={handleEscape}>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
--dangerously-load-development-channels is for local channel
|
||||
development only. Do not use this option to run channels you have
|
||||
downloaded off the internet.
|
||||
--dangerously-load-development-channels is for local channel development only. Do not use this option to run
|
||||
channels you have downloaded off the internet.
|
||||
</Text>
|
||||
<Text>Please use --channels to run a list of approved channels.</Text>
|
||||
<Text dimColor>
|
||||
Channels:{' '}
|
||||
{channels
|
||||
.map(c =>
|
||||
c.kind === 'plugin'
|
||||
? `plugin:${c.name}@${c.marketplace}`
|
||||
: `server:${c.name}`,
|
||||
)
|
||||
.map(c => (c.kind === 'plugin' ? `plugin:${c.name}@${c.marketplace}` : `server:${c.name}`))
|
||||
.join(', ')}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -61,5 +49,5 @@ export function DevChannelsDialog({
|
||||
onChange={value => onChange(value as 'accept' | 'exit')}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,27 @@
|
||||
import { relative } from 'path'
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { DiagnosticTrackingService } from '../services/diagnosticTracking.js'
|
||||
import type { Attachment } from '../utils/attachments.js'
|
||||
import { getCwd } from '../utils/cwd.js'
|
||||
import { CtrlOToExpand } from './CtrlOToExpand.js'
|
||||
import { MessageResponse } from './MessageResponse.js'
|
||||
import { relative } from 'path';
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { DiagnosticTrackingService } from '../services/diagnosticTracking.js';
|
||||
import type { Attachment } from '../utils/attachments.js';
|
||||
import { getCwd } from '../utils/cwd.js';
|
||||
import { CtrlOToExpand } from './CtrlOToExpand.js';
|
||||
import { MessageResponse } from './MessageResponse.js';
|
||||
|
||||
type DiagnosticsAttachment = Extract<Attachment, { type: 'diagnostics' }>
|
||||
type DiagnosticsAttachment = Extract<Attachment, { type: 'diagnostics' }>;
|
||||
|
||||
type DiagnosticsDisplayProps = {
|
||||
attachment: DiagnosticsAttachment
|
||||
verbose: boolean
|
||||
}
|
||||
attachment: DiagnosticsAttachment;
|
||||
verbose: boolean;
|
||||
};
|
||||
|
||||
export function DiagnosticsDisplay({
|
||||
attachment,
|
||||
verbose,
|
||||
}: DiagnosticsDisplayProps): React.ReactNode {
|
||||
export function DiagnosticsDisplay({ attachment, verbose }: DiagnosticsDisplayProps): React.ReactNode {
|
||||
// Only show if there are diagnostics to report
|
||||
if (attachment.files.length === 0) return null
|
||||
if (attachment.files.length === 0) return null;
|
||||
|
||||
// Count total issues
|
||||
const totalIssues = attachment.files.reduce(
|
||||
(sum, file) => sum + file.diagnostics.length,
|
||||
0,
|
||||
)
|
||||
const totalIssues = attachment.files.reduce((sum, file) => sum + file.diagnostics.length, 0);
|
||||
|
||||
const fileCount = attachment.files.length
|
||||
const fileCount = attachment.files.length;
|
||||
|
||||
if (verbose) {
|
||||
// Show all diagnostics in verbose mode (ctrl+o)
|
||||
@@ -37,14 +31,7 @@ export function DiagnosticsDisplay({
|
||||
<React.Fragment key={fileIndex}>
|
||||
<MessageResponse>
|
||||
<Text dimColor wrap="wrap">
|
||||
<Text bold>
|
||||
{relative(
|
||||
getCwd(),
|
||||
file.uri
|
||||
.replace('file://', '')
|
||||
.replace('_claude_fs_right:', ''),
|
||||
)}
|
||||
</Text>{' '}
|
||||
<Text bold>{relative(getCwd(), file.uri.replace('file://', '').replace('_claude_fs_right:', ''))}</Text>{' '}
|
||||
<Text dimColor>
|
||||
{file.uri.startsWith('file://')
|
||||
? '(file://)'
|
||||
@@ -59,12 +46,9 @@ export function DiagnosticsDisplay({
|
||||
<MessageResponse key={diagIndex}>
|
||||
<Text dimColor wrap="wrap">
|
||||
{' '}
|
||||
{DiagnosticTrackingService.getSeveritySymbol(
|
||||
diagnostic.severity,
|
||||
)}
|
||||
{DiagnosticTrackingService.getSeveritySymbol(diagnostic.severity)}
|
||||
{' [Line '}
|
||||
{diagnostic.range.start.line + 1}:
|
||||
{diagnostic.range.start.character + 1}
|
||||
{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}
|
||||
{'] '}
|
||||
{diagnostic.message}
|
||||
{diagnostic.code ? ` [${diagnostic.code}]` : ''}
|
||||
@@ -75,17 +59,16 @@ export function DiagnosticsDisplay({
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Show summary in normal mode
|
||||
return (
|
||||
<MessageResponse>
|
||||
<Text dimColor wrap="wrap">
|
||||
Found <Text bold>{totalIssues}</Text> new diagnostic{' '}
|
||||
{totalIssues === 1 ? 'issue' : 'issues'} in {fileCount}{' '}
|
||||
Found <Text bold>{totalIssues}</Text> new diagnostic {totalIssues === 1 ? 'issue' : 'issues'} in {fileCount}{' '}
|
||||
{fileCount === 1 ? 'file' : 'files'} <CtrlOToExpand />
|
||||
</Text>
|
||||
</MessageResponse>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +1,66 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import {
|
||||
isMaxSubscriber,
|
||||
isProSubscriber,
|
||||
isTeamSubscriber,
|
||||
} from '../utils/auth.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
|
||||
import type { EffortLevel } from '../utils/effort.js'
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { isMaxSubscriber, isProSubscriber, isTeamSubscriber } from '../utils/auth.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js';
|
||||
import type { EffortLevel } from '../utils/effort.js';
|
||||
import {
|
||||
convertEffortValueToLevel,
|
||||
getDefaultEffortForModel,
|
||||
getOpusDefaultEffortConfig,
|
||||
toPersistableEffort,
|
||||
} from '../utils/effort.js'
|
||||
import { parseUserSpecifiedModel } from '../utils/model/model.js'
|
||||
import { updateSettingsForSource } from '../utils/settings/settings.js'
|
||||
import type { OptionWithDescription } from './CustomSelect/select.js'
|
||||
import { Select } from './CustomSelect/select.js'
|
||||
import { effortLevelToSymbol } from './EffortIndicator.js'
|
||||
import { PermissionDialog } from './permissions/PermissionDialog.js'
|
||||
} from '../utils/effort.js';
|
||||
import { parseUserSpecifiedModel } from '../utils/model/model.js';
|
||||
import { updateSettingsForSource } from '../utils/settings/settings.js';
|
||||
import type { OptionWithDescription } from './CustomSelect/select.js';
|
||||
import { Select } from './CustomSelect/select.js';
|
||||
import { effortLevelToSymbol } from './EffortIndicator.js';
|
||||
import { PermissionDialog } from './permissions/PermissionDialog.js';
|
||||
|
||||
type EffortCalloutSelection = EffortLevel | undefined | 'dismiss'
|
||||
type EffortCalloutSelection = EffortLevel | undefined | 'dismiss';
|
||||
|
||||
type Props = {
|
||||
model: string
|
||||
onDone: (selection: EffortCalloutSelection) => void
|
||||
}
|
||||
model: string;
|
||||
onDone: (selection: EffortCalloutSelection) => void;
|
||||
};
|
||||
|
||||
const AUTO_DISMISS_MS = 30_000
|
||||
const AUTO_DISMISS_MS = 30_000;
|
||||
|
||||
export function EffortCallout({ model, onDone }: Props): React.ReactNode {
|
||||
const defaultEffortConfig = getOpusDefaultEffortConfig()
|
||||
const defaultEffortConfig = getOpusDefaultEffortConfig();
|
||||
// Latest-ref pattern — write via effect so React Compiler can memoize.
|
||||
const onDoneRef = useRef(onDone)
|
||||
const onDoneRef = useRef(onDone);
|
||||
useEffect(() => {
|
||||
onDoneRef.current = onDone
|
||||
})
|
||||
onDoneRef.current = onDone;
|
||||
});
|
||||
|
||||
const handleCancel = useCallback((): void => {
|
||||
onDoneRef.current('dismiss')
|
||||
}, [])
|
||||
onDoneRef.current('dismiss');
|
||||
}, []);
|
||||
|
||||
// Permanently dismiss on mount so it only shows once
|
||||
useEffect(() => {
|
||||
markV2Dismissed()
|
||||
}, [])
|
||||
markV2Dismissed();
|
||||
}, []);
|
||||
|
||||
// 30-second auto-dismiss timer
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(handleCancel, AUTO_DISMISS_MS)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [handleCancel])
|
||||
const timeoutId = setTimeout(handleCancel, AUTO_DISMISS_MS);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [handleCancel]);
|
||||
|
||||
const defaultEffort = getDefaultEffortForModel(model)
|
||||
const defaultLevel = defaultEffort
|
||||
? convertEffortValueToLevel(defaultEffort)
|
||||
: 'high'
|
||||
const defaultEffort = getDefaultEffortForModel(model);
|
||||
const defaultLevel = defaultEffort ? convertEffortValueToLevel(defaultEffort) : 'high';
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value: EffortLevel): void => {
|
||||
const effortLevel = value === defaultLevel ? undefined : value
|
||||
const effortLevel = value === defaultLevel ? undefined : value;
|
||||
updateSettingsForSource('userSettings', {
|
||||
effortLevel: toPersistableEffort(effortLevel),
|
||||
})
|
||||
onDoneRef.current(value)
|
||||
});
|
||||
onDoneRef.current(value);
|
||||
},
|
||||
[defaultLevel],
|
||||
)
|
||||
);
|
||||
|
||||
const options: OptionWithDescription<EffortLevel>[] = [
|
||||
{
|
||||
@@ -75,7 +69,7 @@ export function EffortCallout({ model, onDone }: Props): React.ReactNode {
|
||||
},
|
||||
{ label: <EffortOptionLabel level="high" text="High" />, value: 'high' },
|
||||
{ label: <EffortOptionLabel level="low" text="Low" />, value: 'low' },
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<PermissionDialog title={defaultEffortConfig.dialogTitle}>
|
||||
@@ -85,41 +79,26 @@ export function EffortCallout({ model, onDone }: Props): React.ReactNode {
|
||||
</Box>
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
<EffortIndicatorSymbol level="low" /> low {'·'}{' '}
|
||||
<EffortIndicatorSymbol level="medium" /> medium {'·'}{' '}
|
||||
<EffortIndicatorSymbol level="low" /> low {'·'} <EffortIndicatorSymbol level="medium" /> medium {'·'}{' '}
|
||||
<EffortIndicatorSymbol level="high" /> high
|
||||
</Text>
|
||||
</Box>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={handleSelect}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
<Select options={options} onChange={handleSelect} onCancel={handleCancel} />
|
||||
</Box>
|
||||
</PermissionDialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function EffortIndicatorSymbol({
|
||||
level,
|
||||
}: {
|
||||
level: EffortLevel
|
||||
}): React.ReactNode {
|
||||
return <Text color="suggestion">{effortLevelToSymbol(level)}</Text>
|
||||
function EffortIndicatorSymbol({ level }: { level: EffortLevel }): React.ReactNode {
|
||||
return <Text color="suggestion">{effortLevelToSymbol(level)}</Text>;
|
||||
}
|
||||
|
||||
function EffortOptionLabel({
|
||||
level,
|
||||
text,
|
||||
}: {
|
||||
level: EffortLevel
|
||||
text: string
|
||||
}): React.ReactNode {
|
||||
function EffortOptionLabel({ level, text }: { level: EffortLevel; text: string }): React.ReactNode {
|
||||
return (
|
||||
<>
|
||||
<EffortIndicatorSymbol level={level} /> {text}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,46 +111,46 @@ function EffortOptionLabel({
|
||||
*/
|
||||
export function shouldShowEffortCallout(model: string): boolean {
|
||||
// Only show for Opus 4.6 for now
|
||||
const parsed = parseUserSpecifiedModel(model)
|
||||
const parsed = parseUserSpecifiedModel(model);
|
||||
if (!parsed.toLowerCase().includes('opus-4-6')) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
const config = getGlobalConfig()
|
||||
if (config.effortCalloutV2Dismissed) return false
|
||||
const config = getGlobalConfig();
|
||||
if (config.effortCalloutV2Dismissed) return false;
|
||||
|
||||
// Don't show to brand-new users — they never knew the old default, so this
|
||||
// isn't a change for them. Mark as dismissed so it stays suppressed.
|
||||
if (config.numStartups <= 1) {
|
||||
markV2Dismissed()
|
||||
return false
|
||||
markV2Dismissed();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pro users already had medium default before this PR. Show the new copy,
|
||||
// but skip if they already saw the v1 dialog — no point nagging twice.
|
||||
if (isProSubscriber()) {
|
||||
if (config.effortCalloutDismissed) {
|
||||
markV2Dismissed()
|
||||
return false
|
||||
markV2Dismissed();
|
||||
return false;
|
||||
}
|
||||
return getOpusDefaultEffortConfig().enabled
|
||||
return getOpusDefaultEffortConfig().enabled;
|
||||
}
|
||||
|
||||
// Max/Team are the target of the tengu_grey_step2 config.
|
||||
// Don't mark dismissed when config is disabled — they should see the dialog
|
||||
// once it's enabled for them.
|
||||
if (isMaxSubscriber() || isTeamSubscriber()) {
|
||||
return getOpusDefaultEffortConfig().enabled
|
||||
return getOpusDefaultEffortConfig().enabled;
|
||||
}
|
||||
|
||||
// Everyone else (free tier, API key, non-subscribers): not in scope.
|
||||
markV2Dismissed()
|
||||
return false
|
||||
markV2Dismissed();
|
||||
return false;
|
||||
}
|
||||
|
||||
function markV2Dismissed(): void {
|
||||
saveGlobalConfig(current => {
|
||||
if (current.effortCalloutV2Dismissed) return current
|
||||
return { ...current, effortCalloutV2Dismissed: true }
|
||||
})
|
||||
if (current.effortCalloutV2Dismissed) return current;
|
||||
return { ...current, effortCalloutV2Dismissed: true };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,33 +1,29 @@
|
||||
import sample from 'lodash-es/sample.js'
|
||||
import React from 'react'
|
||||
import { gracefulShutdown } from '../utils/gracefulShutdown.js'
|
||||
import { WorktreeExitDialog } from './WorktreeExitDialog.js'
|
||||
import sample from 'lodash-es/sample.js';
|
||||
import React from 'react';
|
||||
import { gracefulShutdown } from '../utils/gracefulShutdown.js';
|
||||
import { WorktreeExitDialog } from './WorktreeExitDialog.js';
|
||||
|
||||
const GOODBYE_MESSAGES = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!']
|
||||
const GOODBYE_MESSAGES = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!'];
|
||||
|
||||
function getRandomGoodbyeMessage(): string {
|
||||
return sample(GOODBYE_MESSAGES) ?? 'Goodbye!'
|
||||
return sample(GOODBYE_MESSAGES) ?? 'Goodbye!';
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onDone: (message?: string) => void
|
||||
onCancel?: () => void
|
||||
showWorktree: boolean
|
||||
}
|
||||
onDone: (message?: string) => void;
|
||||
onCancel?: () => void;
|
||||
showWorktree: boolean;
|
||||
};
|
||||
|
||||
export function ExitFlow({
|
||||
showWorktree,
|
||||
onDone,
|
||||
onCancel,
|
||||
}: Props): React.ReactNode {
|
||||
export function ExitFlow({ showWorktree, onDone, onCancel }: Props): React.ReactNode {
|
||||
async function onExit(resultMessage?: string) {
|
||||
onDone(resultMessage ?? getRandomGoodbyeMessage())
|
||||
await gracefulShutdown(0, 'prompt_input_exit')
|
||||
onDone(resultMessage ?? getRandomGoodbyeMessage());
|
||||
await gracefulShutdown(0, 'prompt_input_exit');
|
||||
}
|
||||
|
||||
if (showWorktree) {
|
||||
return <WorktreeExitDialog onDone={onExit} onCancel={onCancel} />
|
||||
return <WorktreeExitDialog onDone={onExit} onCancel={onCancel} />;
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,86 +1,78 @@
|
||||
import { join } from 'path'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import type { ExitState } from '../hooks/useExitOnCtrlCDWithKeybindings.js'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { setClipboard, Box, Text, Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
||||
import { getCwd } from '../utils/cwd.js'
|
||||
import { writeFileSync_DEPRECATED } from '../utils/slowOperations.js'
|
||||
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'
|
||||
import { Select } from './CustomSelect/select.js'
|
||||
import TextInput from './TextInput.js'
|
||||
import { join } from 'path';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import type { ExitState } from '../hooks/useExitOnCtrlCDWithKeybindings.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { setClipboard, Box, Text, Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js';
|
||||
import { getCwd } from '../utils/cwd.js';
|
||||
import { writeFileSync_DEPRECATED } from '../utils/slowOperations.js';
|
||||
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js';
|
||||
import { Select } from './CustomSelect/select.js';
|
||||
import TextInput from './TextInput.js';
|
||||
|
||||
type ExportDialogProps = {
|
||||
content: string
|
||||
defaultFilename: string
|
||||
onDone: (result: { success: boolean; message: string }) => void
|
||||
}
|
||||
content: string;
|
||||
defaultFilename: string;
|
||||
onDone: (result: { success: boolean; message: string }) => void;
|
||||
};
|
||||
|
||||
type ExportOption = 'clipboard' | 'file'
|
||||
type ExportOption = 'clipboard' | 'file';
|
||||
|
||||
export function ExportDialog({
|
||||
content,
|
||||
defaultFilename,
|
||||
onDone,
|
||||
}: ExportDialogProps): React.ReactNode {
|
||||
const [, setSelectedOption] = useState<ExportOption | null>(null)
|
||||
const [filename, setFilename] = useState<string>(defaultFilename)
|
||||
const [cursorOffset, setCursorOffset] = useState<number>(
|
||||
defaultFilename.length,
|
||||
)
|
||||
const [showFilenameInput, setShowFilenameInput] = useState(false)
|
||||
const { columns } = useTerminalSize()
|
||||
export function ExportDialog({ content, defaultFilename, onDone }: ExportDialogProps): React.ReactNode {
|
||||
const [, setSelectedOption] = useState<ExportOption | null>(null);
|
||||
const [filename, setFilename] = useState<string>(defaultFilename);
|
||||
const [cursorOffset, setCursorOffset] = useState<number>(defaultFilename.length);
|
||||
const [showFilenameInput, setShowFilenameInput] = useState(false);
|
||||
const { columns } = useTerminalSize();
|
||||
|
||||
// Handle going back from filename input to option selection
|
||||
const handleGoBack = useCallback(() => {
|
||||
setShowFilenameInput(false)
|
||||
setSelectedOption(null)
|
||||
}, [])
|
||||
setShowFilenameInput(false);
|
||||
setSelectedOption(null);
|
||||
}, []);
|
||||
|
||||
const handleSelectOption = async (value: string): Promise<void> => {
|
||||
if (value === 'clipboard') {
|
||||
// Copy to clipboard immediately
|
||||
const raw = await setClipboard(content)
|
||||
if (raw) process.stdout.write(raw)
|
||||
onDone({ success: true, message: 'Conversation copied to clipboard' })
|
||||
const raw = await setClipboard(content);
|
||||
if (raw) process.stdout.write(raw);
|
||||
onDone({ success: true, message: 'Conversation copied to clipboard' });
|
||||
} else if (value === 'file') {
|
||||
setSelectedOption('file')
|
||||
setShowFilenameInput(true)
|
||||
setSelectedOption('file');
|
||||
setShowFilenameInput(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilenameSubmit = () => {
|
||||
const finalFilename = filename.endsWith('.txt')
|
||||
? filename
|
||||
: filename.replace(/\.[^.]+$/, '') + '.txt'
|
||||
const filepath = join(getCwd(), finalFilename)
|
||||
const finalFilename = filename.endsWith('.txt') ? filename : filename.replace(/\.[^.]+$/, '') + '.txt';
|
||||
const filepath = join(getCwd(), finalFilename);
|
||||
|
||||
try {
|
||||
writeFileSync_DEPRECATED(filepath, content, {
|
||||
encoding: 'utf-8',
|
||||
flush: true,
|
||||
})
|
||||
});
|
||||
onDone({
|
||||
success: true,
|
||||
message: `Conversation exported to: ${filepath}`,
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
onDone({
|
||||
success: false,
|
||||
message: `Failed to export conversation: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Dialog calls onCancel when Escape is pressed. If we are in the filename
|
||||
// input sub-screen, go back to the option list instead of closing entirely.
|
||||
const handleCancel = useCallback(() => {
|
||||
if (showFilenameInput) {
|
||||
handleGoBack()
|
||||
handleGoBack();
|
||||
} else {
|
||||
onDone({ success: false, message: 'Export cancelled' })
|
||||
onDone({ success: false, message: 'Export cancelled' });
|
||||
}
|
||||
}, [showFilenameInput, handleGoBack, onDone])
|
||||
}, [showFilenameInput, handleGoBack, onDone]);
|
||||
|
||||
const options = [
|
||||
{
|
||||
@@ -93,7 +85,7 @@ export function ExportDialog({
|
||||
value: 'file',
|
||||
description: 'Save the conversation to a file in the current directory',
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
// Custom input guide that changes based on dialog state
|
||||
function renderInputGuide(exitState: ExitState): React.ReactNode {
|
||||
@@ -101,35 +93,23 @@ export function ExportDialog({
|
||||
return (
|
||||
<Byline>
|
||||
<KeyboardShortcutHint shortcut="Enter" action="save" />
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Confirmation"
|
||||
fallback="Esc"
|
||||
description="go back"
|
||||
/>
|
||||
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="go back" />
|
||||
</Byline>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (exitState.pending) {
|
||||
return <Text>Press {exitState.keyName} again to exit</Text>
|
||||
return <Text>Press {exitState.keyName} again to exit</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Confirmation"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
)
|
||||
return <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />;
|
||||
}
|
||||
|
||||
// Use Settings context so 'n' key doesn't cancel (allows typing 'n' in filename input)
|
||||
useKeybinding('confirm:no', handleCancel, {
|
||||
context: 'Settings',
|
||||
isActive: showFilenameInput,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -141,11 +121,7 @@ export function ExportDialog({
|
||||
isCancelActive={!showFilenameInput}
|
||||
>
|
||||
{!showFilenameInput ? (
|
||||
<Select
|
||||
options={options}
|
||||
onChange={handleSelectOption}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
<Select options={options} onChange={handleSelectOption} onCancel={handleCancel} />
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
<Text>Enter filename:</Text>
|
||||
@@ -165,5 +141,5 @@ export function ExportDialog({
|
||||
</Box>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,63 +1,49 @@
|
||||
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/messages/messages.mjs'
|
||||
import * as React from 'react'
|
||||
import { stripUnderlineAnsi } from 'src/components/shell/OutputLine.js'
|
||||
import { extractTag } from 'src/utils/messages.js'
|
||||
import { removeSandboxViolationTags } from 'src/utils/sandbox/sandbox-ui-utils.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'
|
||||
import { countCharInString } from '../utils/stringUtils.js'
|
||||
import { MessageResponse } from './MessageResponse.js'
|
||||
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/messages/messages.mjs';
|
||||
import * as React from 'react';
|
||||
import { stripUnderlineAnsi } from 'src/components/shell/OutputLine.js';
|
||||
import { extractTag } from 'src/utils/messages.js';
|
||||
import { removeSandboxViolationTags } from 'src/utils/sandbox/sandbox-ui-utils.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js';
|
||||
import { countCharInString } from '../utils/stringUtils.js';
|
||||
import { MessageResponse } from './MessageResponse.js';
|
||||
|
||||
const MAX_RENDERED_LINES = 10
|
||||
const MAX_RENDERED_LINES = 10;
|
||||
|
||||
type Props = {
|
||||
result: ToolResultBlockParam['content']
|
||||
verbose: boolean
|
||||
}
|
||||
result: ToolResultBlockParam['content'];
|
||||
verbose: boolean;
|
||||
};
|
||||
|
||||
export function FallbackToolUseErrorMessage({
|
||||
result,
|
||||
verbose,
|
||||
}: Props): React.ReactNode {
|
||||
const transcriptShortcut = useShortcutDisplay(
|
||||
'app:toggleTranscript',
|
||||
'Global',
|
||||
'ctrl+o',
|
||||
)
|
||||
let error: string
|
||||
export function FallbackToolUseErrorMessage({ result, verbose }: Props): React.ReactNode {
|
||||
const transcriptShortcut = useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o');
|
||||
let error: string;
|
||||
|
||||
if (typeof result !== 'string') {
|
||||
error = 'Tool execution failed'
|
||||
error = 'Tool execution failed';
|
||||
} else {
|
||||
const extractedError = extractTag(result, 'tool_use_error') ?? result
|
||||
const extractedError = extractTag(result, 'tool_use_error') ?? result;
|
||||
// Remove sandbox_violations tags from error display (Claude still sees them in the tool result)
|
||||
const withoutSandboxViolations = removeSandboxViolationTags(extractedError)
|
||||
const withoutSandboxViolations = removeSandboxViolationTags(extractedError);
|
||||
// Strip <error> tags but keep their content (tags are for the model, not the UI)
|
||||
const withoutErrorTags = withoutSandboxViolations.replace(/<\/?error>/g, '')
|
||||
const trimmed = withoutErrorTags.trim()
|
||||
const withoutErrorTags = withoutSandboxViolations.replace(/<\/?error>/g, '');
|
||||
const trimmed = withoutErrorTags.trim();
|
||||
if (!verbose && trimmed.includes('InputValidationError: ')) {
|
||||
error = 'Invalid tool parameters'
|
||||
} else if (
|
||||
trimmed.startsWith('Error: ') ||
|
||||
trimmed.startsWith('Cancelled: ')
|
||||
) {
|
||||
error = trimmed
|
||||
error = 'Invalid tool parameters';
|
||||
} else if (trimmed.startsWith('Error: ') || trimmed.startsWith('Cancelled: ')) {
|
||||
error = trimmed;
|
||||
} else {
|
||||
error = `Error: ${trimmed}`
|
||||
error = `Error: ${trimmed}`;
|
||||
}
|
||||
}
|
||||
|
||||
const plusLines = countCharInString(error, '\n') + 1 - MAX_RENDERED_LINES
|
||||
const plusLines = countCharInString(error, '\n') + 1 - MAX_RENDERED_LINES;
|
||||
|
||||
return (
|
||||
<MessageResponse>
|
||||
<Box flexDirection="column">
|
||||
<Text color="error">
|
||||
{stripUnderlineAnsi(
|
||||
verbose
|
||||
? error
|
||||
: error.split('\n').slice(0, MAX_RENDERED_LINES).join('\n'),
|
||||
)}
|
||||
{stripUnderlineAnsi(verbose ? error : error.split('\n').slice(0, MAX_RENDERED_LINES).join('\n'))}
|
||||
</Text>
|
||||
{!verbose && plusLines > 0 && (
|
||||
// The careful <Text> layout is a workaround for the dim-bold
|
||||
@@ -75,5 +61,5 @@ export function FallbackToolUseErrorMessage({
|
||||
)}
|
||||
</Box>
|
||||
</MessageResponse>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from 'react'
|
||||
import { InterruptedByUser } from './InterruptedByUser.js'
|
||||
import { MessageResponse } from './MessageResponse.js'
|
||||
import * as React from 'react';
|
||||
import { InterruptedByUser } from './InterruptedByUser.js';
|
||||
import { MessageResponse } from './MessageResponse.js';
|
||||
|
||||
export function FallbackToolUseRejectedMessage(): React.ReactNode {
|
||||
return (
|
||||
<MessageResponse height={1}>
|
||||
<InterruptedByUser />
|
||||
</MessageResponse>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import chalk from 'chalk'
|
||||
import * as React from 'react'
|
||||
import { LIGHTNING_BOLT } from '../constants/figures.js'
|
||||
import { Text, color } from '@anthropic/ink'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { resolveThemeSetting } from '../utils/systemTheme.js'
|
||||
import chalk from 'chalk';
|
||||
import * as React from 'react';
|
||||
import { LIGHTNING_BOLT } from '../constants/figures.js';
|
||||
import { Text, color } from '@anthropic/ink';
|
||||
import { getGlobalConfig } from '../utils/config.js';
|
||||
import { resolveThemeSetting } from '../utils/systemTheme.js';
|
||||
|
||||
type Props = {
|
||||
cooldown?: boolean
|
||||
}
|
||||
cooldown?: boolean;
|
||||
};
|
||||
|
||||
export function FastIcon({ cooldown }: Props): React.ReactNode {
|
||||
if (cooldown) {
|
||||
@@ -15,18 +15,18 @@ export function FastIcon({ cooldown }: Props): React.ReactNode {
|
||||
<Text color="promptBorder" dimColor>
|
||||
{LIGHTNING_BOLT}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
return <Text color="fastMode">{LIGHTNING_BOLT}</Text>
|
||||
return <Text color="fastMode">{LIGHTNING_BOLT}</Text>;
|
||||
}
|
||||
|
||||
export function getFastIconString(applyColor = true, cooldown = false): string {
|
||||
if (!applyColor) {
|
||||
return LIGHTNING_BOLT
|
||||
return LIGHTNING_BOLT;
|
||||
}
|
||||
const themeName = resolveThemeSetting(getGlobalConfig().theme)
|
||||
const themeName = resolveThemeSetting(getGlobalConfig().theme);
|
||||
if (cooldown) {
|
||||
return chalk.dim(color('promptBorder', themeName)(LIGHTNING_BOLT))
|
||||
return chalk.dim(color('promptBorder', themeName)(LIGHTNING_BOLT));
|
||||
}
|
||||
return color('fastMode', themeName)(LIGHTNING_BOLT)
|
||||
return color('fastMode', themeName)(LIGHTNING_BOLT);
|
||||
}
|
||||
|
||||
@@ -1,184 +1,166 @@
|
||||
import axios from 'axios'
|
||||
import { readFile, stat } from 'fs/promises'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { getLastAPIRequest } from 'src/bootstrap/state.js'
|
||||
import { logEventTo1P } from 'src/services/analytics/firstPartyEventLogger.js'
|
||||
import axios from 'axios';
|
||||
import { readFile, stat } from 'fs/promises';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getLastAPIRequest } from 'src/bootstrap/state.js';
|
||||
import { logEventTo1P } from 'src/services/analytics/firstPartyEventLogger.js';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import {
|
||||
getLastAssistantMessage,
|
||||
normalizeMessagesForAPI,
|
||||
} from 'src/utils/messages.js'
|
||||
import type { CommandResultDisplay } from '../commands.js'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { Box, Text, useInput } from '@anthropic/ink'
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
||||
import { queryHaiku } from '../services/api/claude.js'
|
||||
import { startsWithApiErrorPrefix } from '../services/api/errors.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import { checkAndRefreshOAuthTokenIfNeeded } from '../utils/auth.js'
|
||||
import { openBrowser } from '../utils/browser.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { env } from '../utils/env.js'
|
||||
import { type GitRepoState, getGitState, getIsGit } from '../utils/git.js'
|
||||
import { getAuthHeaders, getUserAgent } from '../utils/http.js'
|
||||
import { getInMemoryErrors, logError } from '../utils/log.js'
|
||||
import { isEssentialTrafficOnly } from '../utils/privacyLevel.js'
|
||||
} from 'src/services/analytics/index.js';
|
||||
import { getLastAssistantMessage, normalizeMessagesForAPI } from 'src/utils/messages.js';
|
||||
import type { CommandResultDisplay } from '../commands.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { Box, Text, useInput } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js';
|
||||
import { queryHaiku } from '../services/api/claude.js';
|
||||
import { startsWithApiErrorPrefix } from '../services/api/errors.js';
|
||||
import type { Message } from '../types/message.js';
|
||||
import { checkAndRefreshOAuthTokenIfNeeded } from '../utils/auth.js';
|
||||
import { openBrowser } from '../utils/browser.js';
|
||||
import { logForDebugging } from '../utils/debug.js';
|
||||
import { env } from '../utils/env.js';
|
||||
import { type GitRepoState, getGitState, getIsGit } from '../utils/git.js';
|
||||
import { getAuthHeaders, getUserAgent } from '../utils/http.js';
|
||||
import { getInMemoryErrors, logError } from '../utils/log.js';
|
||||
import { isEssentialTrafficOnly } from '../utils/privacyLevel.js';
|
||||
import {
|
||||
extractTeammateTranscriptsFromTasks,
|
||||
getTranscriptPath,
|
||||
loadAllSubagentTranscriptsFromDisk,
|
||||
MAX_TRANSCRIPT_READ_BYTES,
|
||||
} from '../utils/sessionStorage.js'
|
||||
import { jsonStringify } from '../utils/slowOperations.js'
|
||||
import { asSystemPrompt } from '../utils/systemPromptType.js'
|
||||
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'
|
||||
import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'
|
||||
import TextInput from './TextInput.js'
|
||||
} from '../utils/sessionStorage.js';
|
||||
import { jsonStringify } from '../utils/slowOperations.js';
|
||||
import { asSystemPrompt } from '../utils/systemPromptType.js';
|
||||
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js';
|
||||
import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink';
|
||||
import TextInput from './TextInput.js';
|
||||
|
||||
// This value was determined experimentally by testing the URL length limit
|
||||
const GITHUB_URL_LIMIT = 7250
|
||||
const GITHUB_URL_LIMIT = 7250;
|
||||
const GITHUB_ISSUES_REPO_URL =
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? 'https://github.com/anthropics/claude-cli-internal/issues'
|
||||
: 'https://github.com/anthropics/claude-code/issues'
|
||||
: 'https://github.com/anthropics/claude-code/issues';
|
||||
|
||||
type Props = {
|
||||
abortSignal: AbortSignal
|
||||
messages: Message[]
|
||||
initialDescription?: string
|
||||
onDone(result: string, options?: { display?: CommandResultDisplay }): void
|
||||
abortSignal: AbortSignal;
|
||||
messages: Message[];
|
||||
initialDescription?: string;
|
||||
onDone(result: string, options?: { display?: CommandResultDisplay }): void;
|
||||
backgroundTasks?: {
|
||||
[taskId: string]: {
|
||||
type: string
|
||||
identity?: { agentId: string }
|
||||
messages?: Message[]
|
||||
}
|
||||
}
|
||||
}
|
||||
type: string;
|
||||
identity?: { agentId: string };
|
||||
messages?: Message[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type Step = 'userInput' | 'consent' | 'submitting' | 'done'
|
||||
type Step = 'userInput' | 'consent' | 'submitting' | 'done';
|
||||
|
||||
type FeedbackData = {
|
||||
// latestAssistantMessageId is the message ID from the latest main model call
|
||||
latestAssistantMessageId: string | null
|
||||
message_count: number
|
||||
datetime: string
|
||||
description: string
|
||||
platform: string
|
||||
gitRepo: boolean
|
||||
version: string | null
|
||||
transcript: Message[]
|
||||
subagentTranscripts?: { [agentId: string]: Message[] }
|
||||
rawTranscriptJsonl?: string
|
||||
}
|
||||
latestAssistantMessageId: string | null;
|
||||
message_count: number;
|
||||
datetime: string;
|
||||
description: string;
|
||||
platform: string;
|
||||
gitRepo: boolean;
|
||||
version: string | null;
|
||||
transcript: Message[];
|
||||
subagentTranscripts?: { [agentId: string]: Message[] };
|
||||
rawTranscriptJsonl?: string;
|
||||
};
|
||||
|
||||
// Utility function to redact sensitive information from strings
|
||||
export function redactSensitiveInfo(text: string): string {
|
||||
let redacted = text
|
||||
let redacted = text;
|
||||
|
||||
// Anthropic API keys (sk-ant...) with or without quotes
|
||||
// First handle the case with quotes
|
||||
redacted = redacted.replace(/"(sk-ant[^\s"']{24,})"/g, '"[REDACTED_API_KEY]"')
|
||||
redacted = redacted.replace(/"(sk-ant[^\s"']{24,})"/g, '"[REDACTED_API_KEY]"');
|
||||
// Then handle the cases without quotes - more general pattern
|
||||
redacted = redacted.replace(
|
||||
// eslint-disable-next-line custom-rules/no-lookbehind-regex -- .replace(re, string) on /bug path: no-match returns same string (Object.is)
|
||||
/(?<![A-Za-z0-9"'])(sk-ant-?[A-Za-z0-9_-]{10,})(?![A-Za-z0-9"'])/g,
|
||||
'[REDACTED_API_KEY]',
|
||||
)
|
||||
);
|
||||
|
||||
// AWS keys - AWSXXXX format - add the pattern we need for the test
|
||||
redacted = redacted.replace(
|
||||
/AWS key: "(AWS[A-Z0-9]{20,})"/g,
|
||||
'AWS key: "[REDACTED_AWS_KEY]"',
|
||||
)
|
||||
redacted = redacted.replace(/AWS key: "(AWS[A-Z0-9]{20,})"/g, 'AWS key: "[REDACTED_AWS_KEY]"');
|
||||
|
||||
// AWS AKIAXXX keys
|
||||
redacted = redacted.replace(/(AKIA[A-Z0-9]{16})/g, '[REDACTED_AWS_KEY]')
|
||||
redacted = redacted.replace(/(AKIA[A-Z0-9]{16})/g, '[REDACTED_AWS_KEY]');
|
||||
|
||||
// Google Cloud keys
|
||||
redacted = redacted.replace(
|
||||
// eslint-disable-next-line custom-rules/no-lookbehind-regex -- same as above
|
||||
/(?<![A-Za-z0-9])(AIza[A-Za-z0-9_-]{35})(?![A-Za-z0-9])/g,
|
||||
'[REDACTED_GCP_KEY]',
|
||||
)
|
||||
);
|
||||
|
||||
// Vertex AI service account keys
|
||||
redacted = redacted.replace(
|
||||
// eslint-disable-next-line custom-rules/no-lookbehind-regex -- same as above
|
||||
/(?<![A-Za-z0-9])([a-z0-9-]+@[a-z0-9-]+\.iam\.gserviceaccount\.com)(?![A-Za-z0-9])/g,
|
||||
'[REDACTED_GCP_SERVICE_ACCOUNT]',
|
||||
)
|
||||
);
|
||||
|
||||
// Generic API keys in headers
|
||||
redacted = redacted.replace(
|
||||
/(["']?x-api-key["']?\s*[:=]\s*["']?)[^"',\s)}\]]+/gi,
|
||||
'$1[REDACTED_API_KEY]',
|
||||
)
|
||||
redacted = redacted.replace(/(["']?x-api-key["']?\s*[:=]\s*["']?)[^"',\s)}\]]+/gi, '$1[REDACTED_API_KEY]');
|
||||
|
||||
// Authorization headers and Bearer tokens
|
||||
redacted = redacted.replace(
|
||||
/(["']?authorization["']?\s*[:=]\s*["']?(bearer\s+)?)[^"',\s)}\]]+/gi,
|
||||
'$1[REDACTED_TOKEN]',
|
||||
)
|
||||
);
|
||||
|
||||
// AWS environment variables
|
||||
redacted = redacted.replace(
|
||||
/(AWS[_-][A-Za-z0-9_]+\s*[=:]\s*)["']?[^"',\s)}\]]+["']?/gi,
|
||||
'$1[REDACTED_AWS_VALUE]',
|
||||
)
|
||||
redacted = redacted.replace(/(AWS[_-][A-Za-z0-9_]+\s*[=:]\s*)["']?[^"',\s)}\]]+["']?/gi, '$1[REDACTED_AWS_VALUE]');
|
||||
|
||||
// GCP environment variables
|
||||
redacted = redacted.replace(
|
||||
/(GOOGLE[_-][A-Za-z0-9_]+\s*[=:]\s*)["']?[^"',\s)}\]]+["']?/gi,
|
||||
'$1[REDACTED_GCP_VALUE]',
|
||||
)
|
||||
redacted = redacted.replace(/(GOOGLE[_-][A-Za-z0-9_]+\s*[=:]\s*)["']?[^"',\s)}\]]+["']?/gi, '$1[REDACTED_GCP_VALUE]');
|
||||
|
||||
// Environment variables with keys
|
||||
redacted = redacted.replace(
|
||||
/((API[-_]?KEY|TOKEN|SECRET|PASSWORD)\s*[=:]\s*)["']?[^"',\s)}\]]+["']?/gi,
|
||||
'$1[REDACTED]',
|
||||
)
|
||||
);
|
||||
|
||||
return redacted
|
||||
return redacted;
|
||||
}
|
||||
|
||||
// Get sanitized error logs with sensitive information redacted
|
||||
function getSanitizedErrorLogs(): Array<{
|
||||
error?: string
|
||||
timestamp?: string
|
||||
error?: string;
|
||||
timestamp?: string;
|
||||
}> {
|
||||
// Sanitize error logs to remove any API keys
|
||||
return getInMemoryErrors().map(errorInfo => {
|
||||
// Create a copy of the error info to avoid modifying the original
|
||||
const errorCopy = { ...errorInfo } as { error?: string; timestamp?: string }
|
||||
const errorCopy = { ...errorInfo } as { error?: string; timestamp?: string };
|
||||
|
||||
// Sanitize error if present and is a string
|
||||
if (errorCopy && typeof errorCopy.error === 'string') {
|
||||
errorCopy.error = redactSensitiveInfo(errorCopy.error)
|
||||
errorCopy.error = redactSensitiveInfo(errorCopy.error);
|
||||
}
|
||||
|
||||
return errorCopy
|
||||
})
|
||||
return errorCopy;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRawTranscriptJsonl(): Promise<string | null> {
|
||||
try {
|
||||
const transcriptPath = getTranscriptPath()
|
||||
const { size } = await stat(transcriptPath)
|
||||
const transcriptPath = getTranscriptPath();
|
||||
const { size } = await stat(transcriptPath);
|
||||
if (size > MAX_TRANSCRIPT_READ_BYTES) {
|
||||
logForDebugging(
|
||||
`Skipping raw transcript read: file too large (${size} bytes)`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
return null
|
||||
logForDebugging(`Skipping raw transcript read: file too large (${size} bytes)`, { level: 'warn' });
|
||||
return null;
|
||||
}
|
||||
return await readFile(transcriptPath, 'utf-8')
|
||||
return await readFile(transcriptPath, 'utf-8');
|
||||
} catch {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,49 +171,48 @@ export function Feedback({
|
||||
onDone,
|
||||
backgroundTasks = {},
|
||||
}: Props): React.ReactNode {
|
||||
const [step, setStep] = useState<Step>('userInput')
|
||||
const [cursorOffset, setCursorOffset] = useState(0)
|
||||
const [description, setDescription] = useState(initialDescription ?? '')
|
||||
const [feedbackId, setFeedbackId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [step, setStep] = useState<Step>('userInput');
|
||||
const [cursorOffset, setCursorOffset] = useState(0);
|
||||
const [description, setDescription] = useState(initialDescription ?? '');
|
||||
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
|
||||
isGit: boolean;
|
||||
gitState: GitRepoState | null;
|
||||
}>({ isGit: false, gitState: null });
|
||||
const [title, setTitle] = useState<string | null>(null);
|
||||
const textInputColumns = useTerminalSize().columns - 4;
|
||||
|
||||
useEffect(() => {
|
||||
async function loadEnvInfo() {
|
||||
const isGit = await getIsGit()
|
||||
let gitState: GitRepoState | null = null
|
||||
const isGit = await getIsGit();
|
||||
let gitState: GitRepoState | null = null;
|
||||
if (isGit) {
|
||||
gitState = await getGitState()
|
||||
gitState = await getGitState();
|
||||
}
|
||||
setEnvInfo({ isGit, gitState })
|
||||
setEnvInfo({ isGit, gitState });
|
||||
}
|
||||
void loadEnvInfo()
|
||||
}, [])
|
||||
void loadEnvInfo();
|
||||
}, []);
|
||||
|
||||
const submitReport = useCallback(async () => {
|
||||
setStep('submitting')
|
||||
setError(null)
|
||||
setFeedbackId(null)
|
||||
setStep('submitting');
|
||||
setError(null);
|
||||
setFeedbackId(null);
|
||||
|
||||
// Get sanitized errors for the report
|
||||
const sanitizedErrors = getSanitizedErrorLogs()
|
||||
const sanitizedErrors = getSanitizedErrorLogs();
|
||||
|
||||
// Extract last assistant message ID from messages array
|
||||
const lastAssistantMessage = getLastAssistantMessage(messages)
|
||||
const lastAssistantMessageId = lastAssistantMessage?.requestId ?? null
|
||||
const lastAssistantMessage = getLastAssistantMessage(messages);
|
||||
const lastAssistantMessageId = lastAssistantMessage?.requestId ?? null;
|
||||
|
||||
const [diskTranscripts, rawTranscriptJsonl] = await Promise.all([
|
||||
loadAllSubagentTranscriptsFromDisk(),
|
||||
loadRawTranscriptJsonl(),
|
||||
])
|
||||
const teammateTranscripts =
|
||||
extractTeammateTranscriptsFromTasks(backgroundTasks)
|
||||
const subagentTranscripts = { ...diskTranscripts, ...teammateTranscripts }
|
||||
]);
|
||||
const teammateTranscripts = extractTeammateTranscriptsFromTasks(backgroundTasks);
|
||||
const subagentTranscripts = { ...diskTranscripts, ...teammateTranscripts };
|
||||
|
||||
const reportData = {
|
||||
latestAssistantMessageId: lastAssistantMessageId,
|
||||
@@ -249,46 +230,40 @@ export function Feedback({
|
||||
subagentTranscripts,
|
||||
}),
|
||||
...(rawTranscriptJsonl && { rawTranscriptJsonl }),
|
||||
}
|
||||
};
|
||||
|
||||
const [result, t] = await Promise.all([
|
||||
submitFeedback(reportData as FeedbackData, abortSignal),
|
||||
generateTitle(description, abortSignal),
|
||||
])
|
||||
]);
|
||||
|
||||
setTitle(t)
|
||||
setTitle(t);
|
||||
|
||||
if (result.success) {
|
||||
if (result.feedbackId) {
|
||||
setFeedbackId(result.feedbackId)
|
||||
setFeedbackId(result.feedbackId);
|
||||
logEvent('tengu_bug_report_submitted', {
|
||||
feedback_id:
|
||||
result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
feedback_id: result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
last_assistant_message_id:
|
||||
lastAssistantMessageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
// 1P-only: freeform text approved for BQ. Join on feedback_id.
|
||||
logEventTo1P('tengu_bug_report_description', {
|
||||
feedback_id:
|
||||
result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
description: redactSensitiveInfo(
|
||||
description,
|
||||
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
feedback_id: result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
description: redactSensitiveInfo(description) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
}
|
||||
setStep('done')
|
||||
setStep('done');
|
||||
} else {
|
||||
if (result.isZdrOrg) {
|
||||
setError(
|
||||
'Feedback collection is not available for organizations with custom data retention policies.',
|
||||
)
|
||||
setError('Feedback collection is not available for organizations with custom data retention policies.');
|
||||
} else {
|
||||
setError('Could not submit feedback. Please try again later.')
|
||||
setError('Could not submit feedback. Please try again later.');
|
||||
}
|
||||
// Stay on userInput step so user can retry with their content preserved
|
||||
setStep('userInput')
|
||||
setStep('userInput');
|
||||
}
|
||||
}, [description, envInfo.isGit, messages])
|
||||
}, [description, envInfo.isGit, messages]);
|
||||
|
||||
// Handle cancel - this will be called by Dialog's automatic Esc handling
|
||||
const handleCancel = useCallback(() => {
|
||||
@@ -297,43 +272,38 @@ export function Feedback({
|
||||
if (error) {
|
||||
onDone('Error submitting feedback / bug report', {
|
||||
display: 'system',
|
||||
})
|
||||
});
|
||||
} else {
|
||||
onDone('Feedback / bug report submitted', { display: 'system' })
|
||||
onDone('Feedback / bug report submitted', { display: 'system' });
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
onDone('Feedback / bug report cancelled', { display: 'system' })
|
||||
}, [step, error, onDone])
|
||||
onDone('Feedback / bug report cancelled', { display: 'system' });
|
||||
}, [step, error, onDone]);
|
||||
|
||||
// During text input, use Settings context where only Escape (not 'n') triggers confirm:no.
|
||||
// This allows typing 'n' in the text field while still supporting Escape to cancel.
|
||||
useKeybinding('confirm:no', handleCancel, {
|
||||
context: 'Settings',
|
||||
isActive: step === 'userInput',
|
||||
})
|
||||
});
|
||||
|
||||
useInput((input, key) => {
|
||||
// Allow any key press to close the dialog when done or when there's an error
|
||||
if (step === 'done') {
|
||||
if (key.return && title) {
|
||||
// Open GitHub issue URL when Enter is pressed
|
||||
const issueUrl = createGitHubIssueUrl(
|
||||
feedbackId ?? '',
|
||||
title,
|
||||
description,
|
||||
getSanitizedErrorLogs(),
|
||||
)
|
||||
void openBrowser(issueUrl)
|
||||
const issueUrl = createGitHubIssueUrl(feedbackId ?? '', title, description, getSanitizedErrorLogs());
|
||||
void openBrowser(issueUrl);
|
||||
}
|
||||
if (error) {
|
||||
onDone('Error submitting feedback / bug report', {
|
||||
display: 'system',
|
||||
})
|
||||
});
|
||||
} else {
|
||||
onDone('Feedback / bug report submitted', { display: 'system' })
|
||||
onDone('Feedback / bug report submitted', { display: 'system' });
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// When in userInput step with error, allow user to edit and retry
|
||||
@@ -341,14 +311,14 @@ export function Feedback({
|
||||
if (error && step !== 'userInput') {
|
||||
onDone('Error submitting feedback / bug report', {
|
||||
display: 'system',
|
||||
})
|
||||
return
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (step === 'consent' && (key.return || input === ' ')) {
|
||||
void submitReport()
|
||||
void submitReport();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -361,22 +331,12 @@ export function Feedback({
|
||||
) : step === 'userInput' ? (
|
||||
<Byline>
|
||||
<KeyboardShortcutHint shortcut="Enter" action="continue" />
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Confirmation"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
|
||||
</Byline>
|
||||
) : step === 'consent' ? (
|
||||
<Byline>
|
||||
<KeyboardShortcutHint shortcut="Enter" action="submit" />
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Confirmation"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
|
||||
</Byline>
|
||||
) : null
|
||||
}
|
||||
@@ -387,17 +347,15 @@ export function Feedback({
|
||||
<TextInput
|
||||
value={description}
|
||||
onChange={value => {
|
||||
setDescription(value)
|
||||
setDescription(value);
|
||||
// Clear error when user starts editing to allow retry
|
||||
if (error) {
|
||||
setError(null)
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
columns={textInputColumns}
|
||||
onSubmit={() => setStep('consent')}
|
||||
onExitMessage={() =>
|
||||
onDone('Feedback cancelled', { display: 'system' })
|
||||
}
|
||||
onExitMessage={() => onDone('Feedback cancelled', { display: 'system' })}
|
||||
cursorOffset={cursorOffset}
|
||||
onChangeCursorOffset={setCursorOffset}
|
||||
showCursor
|
||||
@@ -405,9 +363,7 @@ export function Feedback({
|
||||
{error && (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="error">{error}</Text>
|
||||
<Text dimColor>
|
||||
Edit and press Enter to retry, or Esc to cancel
|
||||
</Text>
|
||||
<Text dimColor>Edit and press Enter to retry, or Esc to cancel</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
@@ -418,8 +374,7 @@ export function Feedback({
|
||||
<Text>This report will include:</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Text>
|
||||
- Your feedback / bug description:{' '}
|
||||
<Text dimColor>{description}</Text>
|
||||
- Your feedback / bug description: <Text dimColor>{description}</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
- Environment info:{' '}
|
||||
@@ -432,12 +387,8 @@ export function Feedback({
|
||||
- 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.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>
|
||||
@@ -447,9 +398,8 @@ export function Feedback({
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text wrap="wrap" dimColor>
|
||||
We will use your feedback to debug related issues or to improve{' '}
|
||||
Claude Code's functionality (eg. to reduce the risk of bugs
|
||||
occurring in the future).
|
||||
We will use your feedback to debug related issues or to improve Claude Code's functionality (eg. to
|
||||
reduce the risk of bugs occurring in the future).
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
@@ -468,24 +418,17 @@ export function Feedback({
|
||||
|
||||
{step === 'done' && (
|
||||
<Box flexDirection="column">
|
||||
{error ? (
|
||||
<Text color="error">{error}</Text>
|
||||
) : (
|
||||
<Text color="success">Thank you for your report!</Text>
|
||||
)}
|
||||
{error ? <Text color="error">{error}</Text> : <Text color="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 open your browser and draft a GitHub issue, or any other key to
|
||||
close.
|
||||
</Text>
|
||||
<Text>to open your browser and draft a GitHub issue, or any other key to close.</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function createGitHubIssueUrl(
|
||||
@@ -493,12 +436,12 @@ export function createGitHubIssueUrl(
|
||||
title: string,
|
||||
description: string,
|
||||
errors: Array<{
|
||||
error?: string
|
||||
timestamp?: string
|
||||
error?: string;
|
||||
timestamp?: string;
|
||||
}>,
|
||||
): string {
|
||||
const sanitizedTitle = redactSensitiveInfo(title)
|
||||
const sanitizedDescription = redactSensitiveInfo(description)
|
||||
const sanitizedTitle = redactSensitiveInfo(title);
|
||||
const sanitizedDescription = redactSensitiveInfo(description);
|
||||
|
||||
const bodyPrefix =
|
||||
`**Bug Description**\n${sanitizedDescription}\n\n` +
|
||||
@@ -507,84 +450,62 @@ export function createGitHubIssueUrl(
|
||||
`- Terminal: ${env.terminal}\n` +
|
||||
`- Version: ${MACRO.VERSION || 'unknown'}\n` +
|
||||
`- Feedback ID: ${feedbackId}\n` +
|
||||
`\n**Errors**\n\`\`\`json\n`
|
||||
const errorSuffix = `\n\`\`\`\n`
|
||||
const errorsJson = jsonStringify(errors)
|
||||
`\n**Errors**\n\`\`\`json\n`;
|
||||
const errorSuffix = `\n\`\`\`\n`;
|
||||
const errorsJson = jsonStringify(errors);
|
||||
|
||||
const baseUrl = `${GITHUB_ISSUES_REPO_URL}/new?title=${encodeURIComponent(sanitizedTitle)}&labels=user-reported,bug&body=`
|
||||
const truncationNote = `\n**Note:** Content was truncated.\n`
|
||||
const baseUrl = `${GITHUB_ISSUES_REPO_URL}/new?title=${encodeURIComponent(sanitizedTitle)}&labels=user-reported,bug&body=`;
|
||||
const truncationNote = `\n**Note:** Content was truncated.\n`;
|
||||
|
||||
const encodedPrefix = encodeURIComponent(bodyPrefix)
|
||||
const encodedSuffix = encodeURIComponent(errorSuffix)
|
||||
const encodedNote = encodeURIComponent(truncationNote)
|
||||
const encodedErrors = encodeURIComponent(errorsJson)
|
||||
const encodedPrefix = encodeURIComponent(bodyPrefix);
|
||||
const encodedSuffix = encodeURIComponent(errorSuffix);
|
||||
const encodedNote = encodeURIComponent(truncationNote);
|
||||
const encodedErrors = encodeURIComponent(errorsJson);
|
||||
|
||||
// Calculate space available for errors
|
||||
const spaceForErrors =
|
||||
GITHUB_URL_LIMIT -
|
||||
baseUrl.length -
|
||||
encodedPrefix.length -
|
||||
encodedSuffix.length -
|
||||
encodedNote.length
|
||||
GITHUB_URL_LIMIT - baseUrl.length - encodedPrefix.length - encodedSuffix.length - encodedNote.length;
|
||||
|
||||
// If description alone exceeds limit, truncate everything
|
||||
if (spaceForErrors <= 0) {
|
||||
const ellipsis = encodeURIComponent('…')
|
||||
const buffer = 50 // Extra safety margin
|
||||
const maxEncodedLength =
|
||||
GITHUB_URL_LIMIT -
|
||||
baseUrl.length -
|
||||
ellipsis.length -
|
||||
encodedNote.length -
|
||||
buffer
|
||||
const fullBody = bodyPrefix + errorsJson + errorSuffix
|
||||
let encodedFullBody = encodeURIComponent(fullBody)
|
||||
const ellipsis = encodeURIComponent('…');
|
||||
const buffer = 50; // Extra safety margin
|
||||
const maxEncodedLength = GITHUB_URL_LIMIT - baseUrl.length - ellipsis.length - encodedNote.length - buffer;
|
||||
const fullBody = bodyPrefix + errorsJson + errorSuffix;
|
||||
let encodedFullBody = encodeURIComponent(fullBody);
|
||||
|
||||
if (encodedFullBody.length > maxEncodedLength) {
|
||||
encodedFullBody = encodedFullBody.slice(0, maxEncodedLength)
|
||||
encodedFullBody = encodedFullBody.slice(0, maxEncodedLength);
|
||||
// Don't cut in middle of %XX sequence
|
||||
const lastPercent = encodedFullBody.lastIndexOf('%')
|
||||
const lastPercent = encodedFullBody.lastIndexOf('%');
|
||||
if (lastPercent >= encodedFullBody.length - 2) {
|
||||
encodedFullBody = encodedFullBody.slice(0, lastPercent)
|
||||
encodedFullBody = encodedFullBody.slice(0, lastPercent);
|
||||
}
|
||||
}
|
||||
|
||||
return baseUrl + encodedFullBody + ellipsis + encodedNote
|
||||
return baseUrl + encodedFullBody + ellipsis + encodedNote;
|
||||
}
|
||||
|
||||
// If errors fit, no truncation needed
|
||||
if (encodedErrors.length <= spaceForErrors) {
|
||||
return baseUrl + encodedPrefix + encodedErrors + encodedSuffix
|
||||
return baseUrl + encodedPrefix + encodedErrors + encodedSuffix;
|
||||
}
|
||||
|
||||
// Truncate errors to fit (prioritize keeping description)
|
||||
// Slice encoded errors directly, then trim to avoid cutting %XX sequences
|
||||
const ellipsis = encodeURIComponent('…')
|
||||
const buffer = 50 // Extra safety margin
|
||||
let truncatedEncodedErrors = encodedErrors.slice(
|
||||
0,
|
||||
spaceForErrors - ellipsis.length - buffer,
|
||||
)
|
||||
const ellipsis = encodeURIComponent('…');
|
||||
const buffer = 50; // Extra safety margin
|
||||
let truncatedEncodedErrors = encodedErrors.slice(0, spaceForErrors - ellipsis.length - buffer);
|
||||
// If we cut in middle of %XX, back up to before the %
|
||||
const lastPercent = truncatedEncodedErrors.lastIndexOf('%')
|
||||
const lastPercent = truncatedEncodedErrors.lastIndexOf('%');
|
||||
if (lastPercent >= truncatedEncodedErrors.length - 2) {
|
||||
truncatedEncodedErrors = truncatedEncodedErrors.slice(0, lastPercent)
|
||||
truncatedEncodedErrors = truncatedEncodedErrors.slice(0, lastPercent);
|
||||
}
|
||||
|
||||
return (
|
||||
baseUrl +
|
||||
encodedPrefix +
|
||||
truncatedEncodedErrors +
|
||||
ellipsis +
|
||||
encodedSuffix +
|
||||
encodedNote
|
||||
)
|
||||
return baseUrl + encodedPrefix + truncatedEncodedErrors + ellipsis + encodedSuffix + encodedNote;
|
||||
}
|
||||
|
||||
async function generateTitle(
|
||||
description: string,
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<string> {
|
||||
async function generateTitle(description: string, abortSignal: AbortSignal): Promise<string> {
|
||||
try {
|
||||
const response = await queryHaiku({
|
||||
systemPrompt: asSystemPrompt([
|
||||
@@ -611,24 +532,21 @@ async function generateTitle(
|
||||
querySource: 'feedback',
|
||||
mcpTools: [],
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const _firstBlock = response?.message?.content?.[0] as unknown as Record<string, unknown> | undefined
|
||||
const title =
|
||||
_firstBlock?.type === 'text'
|
||||
? (_firstBlock.text as string)
|
||||
: 'Bug Report'
|
||||
const _firstBlock = response?.message?.content?.[0] as unknown as Record<string, unknown> | undefined;
|
||||
const title = _firstBlock?.type === 'text' ? (_firstBlock.text as string) : 'Bug Report';
|
||||
|
||||
// Check if the title contains an API error message
|
||||
if (startsWithApiErrorPrefix(title)) {
|
||||
return createFallbackTitle(description)
|
||||
return createFallbackTitle(description);
|
||||
}
|
||||
|
||||
return title
|
||||
return title;
|
||||
} catch (error) {
|
||||
// If there's any error in title generation, use a fallback title
|
||||
logError(error)
|
||||
return createFallbackTitle(description)
|
||||
logError(error);
|
||||
return createFallbackTitle(description);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,45 +554,45 @@ function createFallbackTitle(description: string): string {
|
||||
// Create a safe fallback title based on the bug description
|
||||
|
||||
// Try to extract a meaningful title from the first line
|
||||
const firstLine = description.split('\n')[0] || ''
|
||||
const firstLine = description.split('\n')[0] || '';
|
||||
|
||||
// If the first line is very short, use it directly
|
||||
if (firstLine.length <= 60 && firstLine.length > 5) {
|
||||
return firstLine
|
||||
return firstLine;
|
||||
}
|
||||
|
||||
// For longer descriptions, create a truncated version
|
||||
// Truncate at word boundaries when possible
|
||||
let truncated = firstLine.slice(0, 60)
|
||||
let truncated = firstLine.slice(0, 60);
|
||||
if (firstLine.length > 60) {
|
||||
// Find the last space before the 60 char limit
|
||||
const lastSpace = truncated.lastIndexOf(' ')
|
||||
const lastSpace = truncated.lastIndexOf(' ');
|
||||
if (lastSpace > 30) {
|
||||
// Only trim at word if we're not cutting too much
|
||||
truncated = truncated.slice(0, lastSpace)
|
||||
truncated = truncated.slice(0, lastSpace);
|
||||
}
|
||||
truncated += '...'
|
||||
truncated += '...';
|
||||
}
|
||||
|
||||
return truncated.length < 10 ? 'Bug Report' : truncated
|
||||
return truncated.length < 10 ? 'Bug Report' : truncated;
|
||||
}
|
||||
|
||||
// Helper function to sanitize and log errors without exposing API keys
|
||||
function sanitizeAndLogError(err: unknown): void {
|
||||
if (err instanceof Error) {
|
||||
// Create a copy with potentially sensitive info redacted
|
||||
const safeError = new Error(redactSensitiveInfo(err.message))
|
||||
const safeError = new Error(redactSensitiveInfo(err.message));
|
||||
|
||||
// Also redact the stack trace if present
|
||||
if (err.stack) {
|
||||
safeError.stack = redactSensitiveInfo(err.stack)
|
||||
safeError.stack = redactSensitiveInfo(err.stack);
|
||||
}
|
||||
|
||||
logError(safeError)
|
||||
logError(safeError);
|
||||
} else {
|
||||
// For non-Error objects, convert to string and redact sensitive info
|
||||
const errorString = redactSensitiveInfo(String(err))
|
||||
logError(new Error(errorString))
|
||||
const errorString = redactSensitiveInfo(String(err));
|
||||
logError(new Error(errorString));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -683,24 +601,24 @@ async function submitFeedback(
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ success: boolean; feedbackId?: string; isZdrOrg?: boolean }> {
|
||||
if (isEssentialTrafficOnly()) {
|
||||
return { success: false }
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure OAuth token is fresh before getting auth headers
|
||||
// This prevents 401 errors from stale cached tokens
|
||||
await checkAndRefreshOAuthTokenIfNeeded()
|
||||
await checkAndRefreshOAuthTokenIfNeeded();
|
||||
|
||||
const authResult = getAuthHeaders()
|
||||
const authResult = getAuthHeaders();
|
||||
if (authResult.error) {
|
||||
return { success: false }
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': getUserAgent(),
|
||||
...authResult.headers,
|
||||
}
|
||||
};
|
||||
|
||||
const response = await axios.post(
|
||||
'https://api.anthropic.com/api/claude_cli_feedback',
|
||||
@@ -712,47 +630,37 @@ async function submitFeedback(
|
||||
timeout: 30000, // 30 second timeout to prevent hanging
|
||||
signal,
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
const result = response.data
|
||||
const result = response.data;
|
||||
if (result?.feedback_id) {
|
||||
return { success: true, feedbackId: result.feedback_id }
|
||||
return { success: true, feedbackId: result.feedback_id };
|
||||
}
|
||||
sanitizeAndLogError(
|
||||
new Error(
|
||||
'Failed to submit feedback: request did not return feedback_id',
|
||||
),
|
||||
)
|
||||
return { success: false }
|
||||
sanitizeAndLogError(new Error('Failed to submit feedback: request did not return feedback_id'));
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
sanitizeAndLogError(
|
||||
new Error('Failed to submit feedback:' + response.status),
|
||||
)
|
||||
return { success: false }
|
||||
sanitizeAndLogError(new Error('Failed to submit feedback:' + response.status));
|
||||
return { success: false };
|
||||
} catch (err) {
|
||||
// Handle cancellation/abort - don't log as error
|
||||
if (axios.isCancel(err)) {
|
||||
return { success: false }
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
if (axios.isAxiosError(err) && err.response?.status === 403) {
|
||||
const errorData = err.response.data
|
||||
const errorData = err.response.data;
|
||||
if (
|
||||
errorData?.error?.type === 'permission_error' &&
|
||||
errorData?.error?.message?.includes('Custom data retention settings')
|
||||
) {
|
||||
sanitizeAndLogError(
|
||||
new Error(
|
||||
'Cannot submit feedback because custom data retention settings are enabled',
|
||||
),
|
||||
)
|
||||
return { success: false, isZdrOrg: true }
|
||||
sanitizeAndLogError(new Error('Cannot submit feedback because custom data retention settings are enabled'));
|
||||
return { success: false, isZdrOrg: true };
|
||||
}
|
||||
}
|
||||
// Use our safe error logging function to avoid leaking API keys
|
||||
sanitizeAndLogError(err)
|
||||
return { success: false }
|
||||
sanitizeAndLogError(err);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,25 @@
|
||||
import React from 'react'
|
||||
import React from 'react';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import {
|
||||
FeedbackSurveyView,
|
||||
isValidResponseInput,
|
||||
} from './FeedbackSurveyView.js'
|
||||
import type { TranscriptShareResponse } from './TranscriptSharePrompt.js'
|
||||
import { TranscriptSharePrompt } from './TranscriptSharePrompt.js'
|
||||
import { useDebouncedDigitInput } from './useDebouncedDigitInput.js'
|
||||
import type { FeedbackSurveyResponse } from './utils.js'
|
||||
} from 'src/services/analytics/index.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { FeedbackSurveyView, isValidResponseInput } from './FeedbackSurveyView.js';
|
||||
import type { TranscriptShareResponse } from './TranscriptSharePrompt.js';
|
||||
import { TranscriptSharePrompt } from './TranscriptSharePrompt.js';
|
||||
import { useDebouncedDigitInput } from './useDebouncedDigitInput.js';
|
||||
import type { FeedbackSurveyResponse } from './utils.js';
|
||||
|
||||
type Props = {
|
||||
state:
|
||||
| 'closed'
|
||||
| 'open'
|
||||
| 'thanks'
|
||||
| 'transcript_prompt'
|
||||
| 'submitting'
|
||||
| 'submitted'
|
||||
lastResponse: FeedbackSurveyResponse | null
|
||||
handleSelect: (selected: FeedbackSurveyResponse) => void
|
||||
handleTranscriptSelect?: (selected: TranscriptShareResponse) => void
|
||||
inputValue: string
|
||||
setInputValue: (value: string) => void
|
||||
onRequestFeedback?: () => void
|
||||
message?: string
|
||||
}
|
||||
state: 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted';
|
||||
lastResponse: FeedbackSurveyResponse | null;
|
||||
handleSelect: (selected: FeedbackSurveyResponse) => void;
|
||||
handleTranscriptSelect?: (selected: TranscriptShareResponse) => void;
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
onRequestFeedback?: () => void;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function FeedbackSurvey({
|
||||
state,
|
||||
@@ -41,7 +32,7 @@ export function FeedbackSurvey({
|
||||
message,
|
||||
}: Props): React.ReactNode {
|
||||
if (state === 'closed') {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state === 'thanks') {
|
||||
@@ -52,17 +43,15 @@ export function FeedbackSurvey({
|
||||
setInputValue={setInputValue}
|
||||
onRequestFeedback={onRequestFeedback}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'submitted') {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color="success">
|
||||
{'\u2713'} Thanks for sharing your transcript!
|
||||
</Text>
|
||||
<Text color="success">{'\u2713'} Thanks for sharing your transcript!</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'submitting') {
|
||||
@@ -70,24 +59,20 @@ export function FeedbackSurvey({
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Sharing transcript{'\u2026'}</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'transcript_prompt') {
|
||||
if (!handleTranscriptSelect) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
// Hide prompt if user is typing non-response characters
|
||||
if (inputValue && !['1', '2', '3'].includes(inputValue)) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<TranscriptSharePrompt
|
||||
onSelect={handleTranscriptSelect}
|
||||
inputValue={inputValue}
|
||||
setInputValue={setInputValue}
|
||||
/>
|
||||
)
|
||||
<TranscriptSharePrompt onSelect={handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} />
|
||||
);
|
||||
}
|
||||
|
||||
// state === 'open'
|
||||
@@ -95,7 +80,7 @@ export function FeedbackSurvey({
|
||||
// This prevents the survey from showing up when the user is typing a message,
|
||||
// which can result in accidental survey submissions (e.g. "s3cmd").
|
||||
if (inputValue && !isValidResponseInput(inputValue)) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -105,17 +90,17 @@ export function FeedbackSurvey({
|
||||
setInputValue={setInputValue}
|
||||
message={message}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
type ThanksProps = {
|
||||
lastResponse: FeedbackSurveyResponse | null
|
||||
inputValue: string
|
||||
setInputValue: (value: string) => void
|
||||
onRequestFeedback?: () => void
|
||||
}
|
||||
lastResponse: FeedbackSurveyResponse | null;
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
onRequestFeedback?: () => void;
|
||||
};
|
||||
|
||||
const isFollowUpDigit = (char: string): char is '1' => char === '1'
|
||||
const isFollowUpDigit = (char: string): char is '1' => char === '1';
|
||||
|
||||
function FeedbackSurveyThanks({
|
||||
lastResponse,
|
||||
@@ -123,7 +108,7 @@ function FeedbackSurveyThanks({
|
||||
setInputValue,
|
||||
onRequestFeedback,
|
||||
}: ThanksProps): React.ReactNode {
|
||||
const showFollowUp = onRequestFeedback && lastResponse === 'good'
|
||||
const showFollowUp = onRequestFeedback && lastResponse === 'good';
|
||||
|
||||
// Listen for "1" keypress to launch /feedback
|
||||
useDebouncedDigitInput({
|
||||
@@ -134,34 +119,28 @@ function FeedbackSurveyThanks({
|
||||
once: true,
|
||||
onDigit: () => {
|
||||
logEvent('tengu_feedback_survey_event', {
|
||||
event_type:
|
||||
'followup_accepted' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
response:
|
||||
lastResponse as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
onRequestFeedback?.()
|
||||
event_type: 'followup_accepted' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
response: lastResponse as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onRequestFeedback?.();
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const feedbackCommand =
|
||||
process.env.USER_TYPE === 'ant' ? '/issue' : '/feedback'
|
||||
const feedbackCommand = process.env.USER_TYPE === 'ant' ? '/issue' : '/feedback';
|
||||
|
||||
return (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color="success">Thanks for the feedback!</Text>
|
||||
{showFollowUp ? (
|
||||
<Text dimColor>
|
||||
(Optional) Press [<Text color="ansi:cyan">1</Text>] to tell us what
|
||||
went well {' \u00b7 '}
|
||||
(Optional) Press [<Text color="ansi:cyan">1</Text>] to tell us what went well {' \u00b7 '}
|
||||
{feedbackCommand}
|
||||
</Text>
|
||||
) : lastResponse === 'bad' ? (
|
||||
<Text dimColor>Use /issue to report model behavior issues.</Text>
|
||||
) : (
|
||||
<Text dimColor>
|
||||
Use {feedbackCommand} to share detailed feedback anytime.
|
||||
</Text>
|
||||
<Text dimColor>Use {feedbackCommand} to share detailed feedback anytime.</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useDebouncedDigitInput } from './useDebouncedDigitInput.js'
|
||||
import type { FeedbackSurveyResponse } from './utils.js'
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useDebouncedDigitInput } from './useDebouncedDigitInput.js';
|
||||
import type { FeedbackSurveyResponse } from './utils.js';
|
||||
|
||||
type Props = {
|
||||
onSelect: (option: FeedbackSurveyResponse) => void
|
||||
inputValue: string
|
||||
setInputValue: (value: string) => void
|
||||
message?: string
|
||||
}
|
||||
onSelect: (option: FeedbackSurveyResponse) => void;
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const RESPONSE_INPUTS = ['0', '1', '2', '3'] as const
|
||||
type ResponseInput = (typeof RESPONSE_INPUTS)[number]
|
||||
const RESPONSE_INPUTS = ['0', '1', '2', '3'] as const;
|
||||
type ResponseInput = (typeof RESPONSE_INPUTS)[number];
|
||||
|
||||
const inputToResponse: Record<ResponseInput, FeedbackSurveyResponse> = {
|
||||
'0': 'dismissed',
|
||||
'1': 'bad',
|
||||
'2': 'fine',
|
||||
'3': 'good',
|
||||
} as const
|
||||
} as const;
|
||||
|
||||
export const isValidResponseInput = (input: string): input is ResponseInput =>
|
||||
(RESPONSE_INPUTS as readonly string[]).includes(input)
|
||||
(RESPONSE_INPUTS as readonly string[]).includes(input);
|
||||
|
||||
const DEFAULT_MESSAGE = 'How is Claude doing this session? (optional)'
|
||||
const DEFAULT_MESSAGE = 'How is Claude doing this session? (optional)';
|
||||
|
||||
export function FeedbackSurveyView({
|
||||
onSelect,
|
||||
@@ -36,7 +36,7 @@ export function FeedbackSurveyView({
|
||||
setInputValue,
|
||||
isValidDigit: isValidResponseInput,
|
||||
onDigit: digit => onSelect(inputToResponse[digit]),
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
@@ -68,5 +68,5 @@ export function FeedbackSurveyView({
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,55 +1,45 @@
|
||||
import React from 'react'
|
||||
import { BLACK_CIRCLE } from '../../constants/figures.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useDebouncedDigitInput } from './useDebouncedDigitInput.js'
|
||||
import React from 'react';
|
||||
import { BLACK_CIRCLE } from '../../constants/figures.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useDebouncedDigitInput } from './useDebouncedDigitInput.js';
|
||||
|
||||
export type TranscriptShareResponse = 'yes' | 'no' | 'dont_ask_again'
|
||||
export type TranscriptShareResponse = 'yes' | 'no' | 'dont_ask_again';
|
||||
|
||||
type Props = {
|
||||
onSelect: (option: TranscriptShareResponse) => void
|
||||
inputValue: string
|
||||
setInputValue: (value: string) => void
|
||||
}
|
||||
onSelect: (option: TranscriptShareResponse) => void;
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
};
|
||||
|
||||
const RESPONSE_INPUTS = ['1', '2', '3'] as const
|
||||
type ResponseInput = (typeof RESPONSE_INPUTS)[number]
|
||||
const RESPONSE_INPUTS = ['1', '2', '3'] as const;
|
||||
type ResponseInput = (typeof RESPONSE_INPUTS)[number];
|
||||
|
||||
const inputToResponse: Record<ResponseInput, TranscriptShareResponse> = {
|
||||
'1': 'yes',
|
||||
'2': 'no',
|
||||
'3': 'dont_ask_again',
|
||||
} as const
|
||||
} as const;
|
||||
|
||||
const isValidResponseInput = (input: string): input is ResponseInput =>
|
||||
(RESPONSE_INPUTS as readonly string[]).includes(input)
|
||||
(RESPONSE_INPUTS as readonly string[]).includes(input);
|
||||
|
||||
export function TranscriptSharePrompt({
|
||||
onSelect,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
}: Props): React.ReactNode {
|
||||
export function TranscriptSharePrompt({ onSelect, inputValue, setInputValue }: Props): React.ReactNode {
|
||||
useDebouncedDigitInput({
|
||||
inputValue,
|
||||
setInputValue,
|
||||
isValidDigit: isValidResponseInput,
|
||||
onDigit: digit => onSelect(inputToResponse[digit]),
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text color="ansi:cyan">{BLACK_CIRCLE} </Text>
|
||||
<Text bold>
|
||||
Can Anthropic look at your session transcript to help us improve
|
||||
Claude Code?
|
||||
</Text>
|
||||
<Text bold>Can Anthropic look at your session transcript to help us improve Claude Code?</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginLeft={2}>
|
||||
<Text dimColor>
|
||||
Learn more:
|
||||
https://code.claude.com/docs/en/data-usage#session-quality-surveys
|
||||
</Text>
|
||||
<Text dimColor>Learn more: https://code.claude.com/docs/en/data-usage#session-quality-surveys</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginLeft={2}>
|
||||
@@ -70,5 +60,5 @@ export function TranscriptSharePrompt({
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,37 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useDynamicConfig } from 'src/hooks/useDynamicConfig.js'
|
||||
import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useDynamicConfig } from 'src/hooks/useDynamicConfig.js';
|
||||
import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { isPolicyAllowed } from '../../services/policyLimits/index.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { getLastAssistantMessage } from '../../utils/messages.js'
|
||||
import { getMainLoopModel } from '../../utils/model/model.js'
|
||||
import { getInitialSettings } from '../../utils/settings/settings.js'
|
||||
import { logOTelEvent } from '../../utils/telemetry/events.js'
|
||||
import {
|
||||
submitTranscriptShare,
|
||||
type TranscriptShareTrigger,
|
||||
} from './submitTranscriptShare.js'
|
||||
import type { TranscriptShareResponse } from './TranscriptSharePrompt.js'
|
||||
import { useSurveyState } from './useSurveyState.js'
|
||||
import type { FeedbackSurveyResponse, FeedbackSurveyType } from './utils.js'
|
||||
} from 'src/services/analytics/index.js';
|
||||
import { isPolicyAllowed } from '../../services/policyLimits/index.js';
|
||||
import type { Message } from '../../types/message.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js';
|
||||
import { getLastAssistantMessage } from '../../utils/messages.js';
|
||||
import { getMainLoopModel } from '../../utils/model/model.js';
|
||||
import { getInitialSettings } from '../../utils/settings/settings.js';
|
||||
import { logOTelEvent } from '../../utils/telemetry/events.js';
|
||||
import { submitTranscriptShare, type TranscriptShareTrigger } from './submitTranscriptShare.js';
|
||||
import type { TranscriptShareResponse } from './TranscriptSharePrompt.js';
|
||||
import { useSurveyState } from './useSurveyState.js';
|
||||
import type { FeedbackSurveyResponse, FeedbackSurveyType } from './utils.js';
|
||||
|
||||
type FeedbackSurveyConfig = {
|
||||
minTimeBeforeFeedbackMs: number
|
||||
minTimeBetweenFeedbackMs: number
|
||||
minTimeBetweenGlobalFeedbackMs: number
|
||||
minUserTurnsBeforeFeedback: number
|
||||
minUserTurnsBetweenFeedback: number
|
||||
hideThanksAfterMs: number
|
||||
onForModels: string[]
|
||||
probability: number
|
||||
}
|
||||
minTimeBeforeFeedbackMs: number;
|
||||
minTimeBetweenFeedbackMs: number;
|
||||
minTimeBetweenGlobalFeedbackMs: number;
|
||||
minUserTurnsBeforeFeedback: number;
|
||||
minUserTurnsBetweenFeedback: number;
|
||||
hideThanksAfterMs: number;
|
||||
onForModels: string[];
|
||||
probability: number;
|
||||
};
|
||||
|
||||
type TranscriptAskConfig = {
|
||||
probability: number
|
||||
}
|
||||
probability: number;
|
||||
};
|
||||
|
||||
const DEFAULT_FEEDBACK_SURVEY_CONFIG: FeedbackSurveyConfig = {
|
||||
minTimeBeforeFeedbackMs: 600000,
|
||||
@@ -45,11 +42,11 @@ const DEFAULT_FEEDBACK_SURVEY_CONFIG: FeedbackSurveyConfig = {
|
||||
hideThanksAfterMs: 3000,
|
||||
onForModels: ['*'],
|
||||
probability: 0.005,
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_TRANSCRIPT_ASK_CONFIG: TranscriptAskConfig = {
|
||||
probability: 0,
|
||||
}
|
||||
};
|
||||
|
||||
export function useFeedbackSurvey(
|
||||
messages: Message[],
|
||||
@@ -58,177 +55,145 @@ export function useFeedbackSurvey(
|
||||
surveyType: FeedbackSurveyType = 'session',
|
||||
hasActivePrompt: boolean = false,
|
||||
): {
|
||||
state:
|
||||
| 'closed'
|
||||
| 'open'
|
||||
| 'thanks'
|
||||
| 'transcript_prompt'
|
||||
| 'submitting'
|
||||
| 'submitted'
|
||||
lastResponse: FeedbackSurveyResponse | null
|
||||
handleSelect: (selected: FeedbackSurveyResponse) => boolean
|
||||
handleTranscriptSelect: (selected: TranscriptShareResponse) => void
|
||||
state: 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted';
|
||||
lastResponse: FeedbackSurveyResponse | null;
|
||||
handleSelect: (selected: FeedbackSurveyResponse) => boolean;
|
||||
handleTranscriptSelect: (selected: TranscriptShareResponse) => void;
|
||||
} {
|
||||
const lastAssistantMessageIdRef = useRef('unknown')
|
||||
lastAssistantMessageIdRef.current =
|
||||
getLastAssistantMessage(messages)?.message?.id || 'unknown'
|
||||
const lastAssistantMessageIdRef = useRef('unknown');
|
||||
lastAssistantMessageIdRef.current = getLastAssistantMessage(messages)?.message?.id || 'unknown';
|
||||
const [feedbackSurvey, setFeedbackSurvey] = useState<{
|
||||
timeLastShown: number | null
|
||||
submitCountAtLastAppearance: number | null
|
||||
}>(() => ({ timeLastShown: null, submitCountAtLastAppearance: null }))
|
||||
const config = useDynamicConfig<FeedbackSurveyConfig>(
|
||||
'tengu_feedback_survey_config',
|
||||
DEFAULT_FEEDBACK_SURVEY_CONFIG,
|
||||
)
|
||||
timeLastShown: number | null;
|
||||
submitCountAtLastAppearance: number | null;
|
||||
}>(() => ({ timeLastShown: null, submitCountAtLastAppearance: null }));
|
||||
const config = useDynamicConfig<FeedbackSurveyConfig>('tengu_feedback_survey_config', DEFAULT_FEEDBACK_SURVEY_CONFIG);
|
||||
const badTranscriptAskConfig = useDynamicConfig<TranscriptAskConfig>(
|
||||
'tengu_bad_survey_transcript_ask_config',
|
||||
DEFAULT_TRANSCRIPT_ASK_CONFIG,
|
||||
)
|
||||
);
|
||||
const goodTranscriptAskConfig = useDynamicConfig<TranscriptAskConfig>(
|
||||
'tengu_good_survey_transcript_ask_config',
|
||||
DEFAULT_TRANSCRIPT_ASK_CONFIG,
|
||||
)
|
||||
const settingsRate = getInitialSettings().feedbackSurveyRate
|
||||
const sessionStartTime = useRef(Date.now())
|
||||
const submitCountAtSessionStart = useRef(submitCount)
|
||||
const submitCountRef = useRef(submitCount)
|
||||
submitCountRef.current = submitCount
|
||||
const messagesRef = useRef(messages)
|
||||
messagesRef.current = messages
|
||||
);
|
||||
const settingsRate = getInitialSettings().feedbackSurveyRate;
|
||||
const sessionStartTime = useRef(Date.now());
|
||||
const submitCountAtSessionStart = useRef(submitCount);
|
||||
const submitCountRef = useRef(submitCount);
|
||||
submitCountRef.current = submitCount;
|
||||
const messagesRef = useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
// Probability gate: roll once when eligibility conditions are met, not on every
|
||||
// useMemo re-evaluation. Without this, each dependency change (submitCount,
|
||||
// isLoading toggle, etc.) re-rolls Math.random(), making the survey almost
|
||||
// certain to appear after enough renders.
|
||||
const probabilityPassedRef = useRef(false)
|
||||
const lastEligibleSubmitCountRef = useRef<number | null>(null)
|
||||
const probabilityPassedRef = useRef(false);
|
||||
const lastEligibleSubmitCountRef = useRef<number | null>(null);
|
||||
|
||||
const updateLastShownTime = useCallback(
|
||||
(timestamp: number, submitCountValue: number) => {
|
||||
setFeedbackSurvey(prev => {
|
||||
if (
|
||||
prev.timeLastShown === timestamp &&
|
||||
prev.submitCountAtLastAppearance === submitCountValue
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
timeLastShown: timestamp,
|
||||
submitCountAtLastAppearance: submitCountValue,
|
||||
}
|
||||
})
|
||||
// Persist cross-session pacing state (previously done by onChangeAppState observer)
|
||||
if (getGlobalConfig().feedbackSurveyState?.lastShownTime !== timestamp) {
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
feedbackSurveyState: {
|
||||
lastShownTime: timestamp,
|
||||
},
|
||||
}))
|
||||
const updateLastShownTime = useCallback((timestamp: number, submitCountValue: number) => {
|
||||
setFeedbackSurvey(prev => {
|
||||
if (prev.timeLastShown === timestamp && prev.submitCountAtLastAppearance === submitCountValue) {
|
||||
return prev;
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
return {
|
||||
timeLastShown: timestamp,
|
||||
submitCountAtLastAppearance: submitCountValue,
|
||||
};
|
||||
});
|
||||
// Persist cross-session pacing state (previously done by onChangeAppState observer)
|
||||
if (getGlobalConfig().feedbackSurveyState?.lastShownTime !== timestamp) {
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
feedbackSurveyState: {
|
||||
lastShownTime: timestamp,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onOpen = useCallback(
|
||||
(appearanceId: string) => {
|
||||
updateLastShownTime(Date.now(), submitCountRef.current)
|
||||
updateLastShownTime(Date.now(), submitCountRef.current);
|
||||
logEvent('tengu_feedback_survey_event', {
|
||||
event_type:
|
||||
'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id:
|
||||
appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
event_type: 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
last_assistant_message_id:
|
||||
lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
survey_type:
|
||||
surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
void logOTelEvent('feedback_survey', {
|
||||
event_type: 'appeared',
|
||||
appearance_id: appearanceId,
|
||||
survey_type: surveyType,
|
||||
})
|
||||
});
|
||||
},
|
||||
[updateLastShownTime, surveyType],
|
||||
)
|
||||
);
|
||||
|
||||
const onSelect = useCallback(
|
||||
(appearanceId: string, selected: FeedbackSurveyResponse) => {
|
||||
updateLastShownTime(Date.now(), submitCountRef.current)
|
||||
updateLastShownTime(Date.now(), submitCountRef.current);
|
||||
logEvent('tengu_feedback_survey_event', {
|
||||
event_type:
|
||||
'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id:
|
||||
appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
response:
|
||||
selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
event_type: 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
last_assistant_message_id:
|
||||
lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
survey_type:
|
||||
surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
void logOTelEvent('feedback_survey', {
|
||||
event_type: 'responded',
|
||||
appearance_id: appearanceId,
|
||||
response: selected,
|
||||
survey_type: surveyType,
|
||||
})
|
||||
});
|
||||
},
|
||||
[updateLastShownTime, surveyType],
|
||||
)
|
||||
);
|
||||
|
||||
const shouldShowTranscriptPrompt = useCallback(
|
||||
(selected: FeedbackSurveyResponse) => {
|
||||
// Only bad and good ratings trigger the transcript ask
|
||||
if (selected !== 'bad' && selected !== 'good') {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't show if user previously chose "Don't ask again"
|
||||
if (getGlobalConfig().transcriptShareDismissed) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't show if product feedback is blocked by org policy (ZDR)
|
||||
if (!isPolicyAllowed('allow_product_feedback')) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// Probability gate from GrowthBook config (separate per rating)
|
||||
const probability =
|
||||
selected === 'bad'
|
||||
? badTranscriptAskConfig.probability
|
||||
: goodTranscriptAskConfig.probability
|
||||
return Math.random() <= probability
|
||||
const probability = selected === 'bad' ? badTranscriptAskConfig.probability : goodTranscriptAskConfig.probability;
|
||||
return Math.random() <= probability;
|
||||
},
|
||||
[badTranscriptAskConfig.probability, goodTranscriptAskConfig.probability],
|
||||
)
|
||||
);
|
||||
|
||||
const onTranscriptPromptShown = useCallback(
|
||||
(appearanceId: string, surveyResponse: FeedbackSurveyResponse) => {
|
||||
const trigger: TranscriptShareTrigger =
|
||||
surveyResponse === 'good'
|
||||
? 'good_feedback_survey'
|
||||
: 'bad_feedback_survey'
|
||||
surveyResponse === 'good' ? 'good_feedback_survey' : 'bad_feedback_survey';
|
||||
logEvent('tengu_feedback_survey_event', {
|
||||
event_type:
|
||||
'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id:
|
||||
appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
event_type: 'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
last_assistant_message_id:
|
||||
lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
survey_type:
|
||||
surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
trigger:
|
||||
trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
trigger: trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
void logOTelEvent('feedback_survey', {
|
||||
event_type: 'transcript_prompt_appeared',
|
||||
appearance_id: appearanceId,
|
||||
survey_type: surveyType,
|
||||
})
|
||||
});
|
||||
},
|
||||
[surveyType],
|
||||
)
|
||||
);
|
||||
|
||||
const onTranscriptSelect = useCallback(
|
||||
async (
|
||||
@@ -237,166 +202,143 @@ export function useFeedbackSurvey(
|
||||
surveyResponse: FeedbackSurveyResponse | null,
|
||||
): Promise<boolean> => {
|
||||
const trigger: TranscriptShareTrigger =
|
||||
surveyResponse === 'good'
|
||||
? 'good_feedback_survey'
|
||||
: 'bad_feedback_survey'
|
||||
surveyResponse === 'good' ? 'good_feedback_survey' : 'bad_feedback_survey';
|
||||
|
||||
logEvent('tengu_feedback_survey_event', {
|
||||
event_type:
|
||||
`transcript_share_${selected}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id:
|
||||
appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
event_type: `transcript_share_${selected}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
last_assistant_message_id:
|
||||
lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
survey_type:
|
||||
surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
trigger:
|
||||
trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
trigger: trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
|
||||
if (selected === 'dont_ask_again') {
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
transcriptShareDismissed: true,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
if (selected === 'yes') {
|
||||
const result = await submitTranscriptShare(
|
||||
messagesRef.current,
|
||||
trigger,
|
||||
appearanceId,
|
||||
)
|
||||
const result = await submitTranscriptShare(messagesRef.current, trigger, appearanceId);
|
||||
logEvent('tengu_feedback_survey_event', {
|
||||
event_type: (result.success
|
||||
? 'transcript_share_submitted'
|
||||
: 'transcript_share_failed') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id:
|
||||
appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
trigger:
|
||||
trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
return result.success
|
||||
appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
trigger: trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
return result.success;
|
||||
}
|
||||
|
||||
return false
|
||||
return false;
|
||||
},
|
||||
[surveyType],
|
||||
)
|
||||
);
|
||||
|
||||
const { state, lastResponse, open, handleSelect, handleTranscriptSelect } =
|
||||
useSurveyState({
|
||||
hideThanksAfterMs: config.hideThanksAfterMs,
|
||||
onOpen,
|
||||
onSelect,
|
||||
shouldShowTranscriptPrompt,
|
||||
onTranscriptPromptShown,
|
||||
onTranscriptSelect,
|
||||
})
|
||||
const { state, lastResponse, open, handleSelect, handleTranscriptSelect } = useSurveyState({
|
||||
hideThanksAfterMs: config.hideThanksAfterMs,
|
||||
onOpen,
|
||||
onSelect,
|
||||
shouldShowTranscriptPrompt,
|
||||
onTranscriptPromptShown,
|
||||
onTranscriptSelect,
|
||||
});
|
||||
|
||||
const currentModel = getMainLoopModel()
|
||||
const currentModel = getMainLoopModel();
|
||||
const isModelAllowed = useMemo(() => {
|
||||
if (config.onForModels.length === 0) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
if (config.onForModels.includes('*')) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
return config.onForModels.includes(currentModel)
|
||||
}, [config.onForModels, currentModel])
|
||||
return config.onForModels.includes(currentModel);
|
||||
}, [config.onForModels, currentModel]);
|
||||
|
||||
const shouldOpen = useMemo(() => {
|
||||
if (state !== 'closed') {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't show survey when permission or ask question prompts are visible
|
||||
if (hasActivePrompt) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// Force display for testing
|
||||
if (
|
||||
process.env.CLAUDE_FORCE_DISPLAY_SURVEY &&
|
||||
!feedbackSurvey.timeLastShown
|
||||
) {
|
||||
return true
|
||||
if (process.env.CLAUDE_FORCE_DISPLAY_SURVEY && !feedbackSurvey.timeLastShown) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isModelAllowed) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY)) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isFeedbackSurveyDisabled()) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if product feedback is allowed by org policy
|
||||
if (!isPolicyAllowed('allow_product_feedback')) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check session-local pacing
|
||||
if (feedbackSurvey.timeLastShown) {
|
||||
// Check time elapsed since last appearance in this session
|
||||
const timeSinceLastShown = Date.now() - feedbackSurvey.timeLastShown
|
||||
const timeSinceLastShown = Date.now() - feedbackSurvey.timeLastShown;
|
||||
if (timeSinceLastShown < config.minTimeBetweenFeedbackMs) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
// Check user turn requirement for subsequent appearances
|
||||
if (
|
||||
feedbackSurvey.submitCountAtLastAppearance !== null &&
|
||||
submitCount <
|
||||
feedbackSurvey.submitCountAtLastAppearance +
|
||||
config.minUserTurnsBetweenFeedback
|
||||
submitCount < feedbackSurvey.submitCountAtLastAppearance + config.minUserTurnsBetweenFeedback
|
||||
) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// First appearance in this session
|
||||
const timeSinceSessionStart = Date.now() - sessionStartTime.current
|
||||
const timeSinceSessionStart = Date.now() - sessionStartTime.current;
|
||||
if (timeSinceSessionStart < config.minTimeBeforeFeedbackMs) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
submitCount <
|
||||
submitCountAtSessionStart.current + config.minUserTurnsBeforeFeedback
|
||||
) {
|
||||
return false
|
||||
if (submitCount < submitCountAtSessionStart.current + config.minUserTurnsBeforeFeedback) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Probability check: roll once per eligibility window to avoid re-rolling
|
||||
// on every useMemo re-evaluation (which would make triggering near-certain).
|
||||
if (lastEligibleSubmitCountRef.current !== submitCount) {
|
||||
lastEligibleSubmitCountRef.current = submitCount
|
||||
probabilityPassedRef.current =
|
||||
Math.random() <= (settingsRate ?? config.probability)
|
||||
lastEligibleSubmitCountRef.current = submitCount;
|
||||
probabilityPassedRef.current = Math.random() <= (settingsRate ?? config.probability);
|
||||
}
|
||||
if (!probabilityPassedRef.current) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check global pacing (across all sessions)
|
||||
// Leave this till last because it reads from the filesystem which is expensive.
|
||||
const globalFeedbackState = getGlobalConfig().feedbackSurveyState
|
||||
const globalFeedbackState = getGlobalConfig().feedbackSurveyState;
|
||||
if (globalFeedbackState?.lastShownTime) {
|
||||
const timeSinceGlobalLastShown =
|
||||
Date.now() - globalFeedbackState.lastShownTime
|
||||
const timeSinceGlobalLastShown = Date.now() - globalFeedbackState.lastShownTime;
|
||||
if (timeSinceGlobalLastShown < config.minTimeBetweenGlobalFeedbackMs) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
return true;
|
||||
}, [
|
||||
state,
|
||||
isLoading,
|
||||
@@ -412,13 +354,13 @@ export function useFeedbackSurvey(
|
||||
config.minUserTurnsBeforeFeedback,
|
||||
config.probability,
|
||||
settingsRate,
|
||||
])
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldOpen) {
|
||||
open()
|
||||
open();
|
||||
}
|
||||
}, [shouldOpen, open])
|
||||
}, [shouldOpen, open]);
|
||||
|
||||
return { state, lastResponse, handleSelect, handleTranscriptSelect }
|
||||
return { state, lastResponse, handleSelect, handleTranscriptSelect };
|
||||
}
|
||||
|
||||
@@ -1,58 +1,52 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js';
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { isAutoMemoryEnabled } from '../../memdir/paths.js'
|
||||
import { isPolicyAllowed } from '../../services/policyLimits/index.js'
|
||||
import { FILE_READ_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileReadTool/prompt.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { isAutoManagedMemoryFile } from '../../utils/memoryFileDetection.js'
|
||||
import {
|
||||
extractTextContent,
|
||||
getLastAssistantMessage,
|
||||
} from '../../utils/messages.js'
|
||||
import { logOTelEvent } from '../../utils/telemetry/events.js'
|
||||
import { submitTranscriptShare } from './submitTranscriptShare.js'
|
||||
import type { TranscriptShareResponse } from './TranscriptSharePrompt.js'
|
||||
import { useSurveyState } from './useSurveyState.js'
|
||||
import type { FeedbackSurveyResponse } from './utils.js'
|
||||
} from 'src/services/analytics/index.js';
|
||||
import { isAutoMemoryEnabled } from '../../memdir/paths.js';
|
||||
import { isPolicyAllowed } from '../../services/policyLimits/index.js';
|
||||
import { FILE_READ_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileReadTool/prompt.js';
|
||||
import type { Message } from '../../types/message.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js';
|
||||
import { isAutoManagedMemoryFile } from '../../utils/memoryFileDetection.js';
|
||||
import { extractTextContent, getLastAssistantMessage } from '../../utils/messages.js';
|
||||
import { logOTelEvent } from '../../utils/telemetry/events.js';
|
||||
import { submitTranscriptShare } from './submitTranscriptShare.js';
|
||||
import type { TranscriptShareResponse } from './TranscriptSharePrompt.js';
|
||||
import { useSurveyState } from './useSurveyState.js';
|
||||
import type { FeedbackSurveyResponse } from './utils.js';
|
||||
|
||||
const HIDE_THANKS_AFTER_MS = 3000
|
||||
const MEMORY_SURVEY_GATE = 'tengu_dunwich_bell'
|
||||
const MEMORY_SURVEY_EVENT = 'tengu_memory_survey_event'
|
||||
const SURVEY_PROBABILITY = 0.2
|
||||
const TRANSCRIPT_SHARE_TRIGGER = 'memory_survey'
|
||||
const HIDE_THANKS_AFTER_MS = 3000;
|
||||
const MEMORY_SURVEY_GATE = 'tengu_dunwich_bell';
|
||||
const MEMORY_SURVEY_EVENT = 'tengu_memory_survey_event';
|
||||
const SURVEY_PROBABILITY = 0.2;
|
||||
const TRANSCRIPT_SHARE_TRIGGER = 'memory_survey';
|
||||
|
||||
const MEMORY_WORD_RE = /\bmemor(?:y|ies)\b/i
|
||||
const MEMORY_WORD_RE = /\bmemor(?:y|ies)\b/i;
|
||||
|
||||
function hasMemoryFileRead(messages: Message[]): boolean {
|
||||
for (const message of messages) {
|
||||
if (message.type !== 'assistant') {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
const content = message.message!.content
|
||||
const content = message.message!.content;
|
||||
if (!Array.isArray(content)) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
for (const block of content) {
|
||||
if (block.type !== 'tool_use' || block.name !== FILE_READ_TOOL_NAME) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
const input = block.input as { file_path?: unknown }
|
||||
if (
|
||||
typeof input.file_path === 'string' &&
|
||||
isAutoManagedMemoryFile(input.file_path)
|
||||
) {
|
||||
return true
|
||||
const input = block.input as { file_path?: unknown };
|
||||
if (typeof input.file_path === 'string' && isAutoManagedMemoryFile(input.file_path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useMemorySurvey(
|
||||
@@ -61,223 +55,182 @@ export function useMemorySurvey(
|
||||
hasActivePrompt = false,
|
||||
{ enabled = true }: { enabled?: boolean } = {},
|
||||
): {
|
||||
state:
|
||||
| 'closed'
|
||||
| 'open'
|
||||
| 'thanks'
|
||||
| 'transcript_prompt'
|
||||
| 'submitting'
|
||||
| 'submitted'
|
||||
lastResponse: FeedbackSurveyResponse | null
|
||||
handleSelect: (selected: FeedbackSurveyResponse) => void
|
||||
handleTranscriptSelect: (selected: TranscriptShareResponse) => void
|
||||
state: 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted';
|
||||
lastResponse: FeedbackSurveyResponse | null;
|
||||
handleSelect: (selected: FeedbackSurveyResponse) => void;
|
||||
handleTranscriptSelect: (selected: TranscriptShareResponse) => void;
|
||||
} {
|
||||
// Track assistant message UUIDs that were already evaluated so we don't
|
||||
// re-roll probability on re-renders or re-scan messages for the same turn.
|
||||
const seenAssistantUuids = useRef<Set<string>>(new Set())
|
||||
const seenAssistantUuids = useRef<Set<string>>(new Set());
|
||||
// Once a memory file read is observed it stays true for the session —
|
||||
// skip the O(n) scan on subsequent turns.
|
||||
const memoryReadSeen = useRef(false)
|
||||
const messagesRef = useRef(messages)
|
||||
messagesRef.current = messages
|
||||
const memoryReadSeen = useRef(false);
|
||||
const messagesRef = useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
|
||||
const onOpen = useCallback((appearanceId: string) => {
|
||||
logEvent(MEMORY_SURVEY_EVENT, {
|
||||
event_type:
|
||||
'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id:
|
||||
appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
event_type: 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
void logOTelEvent('feedback_survey', {
|
||||
event_type: 'appeared',
|
||||
appearance_id: appearanceId,
|
||||
survey_type: 'memory',
|
||||
})
|
||||
}, [])
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onSelect = useCallback(
|
||||
(appearanceId: string, selected: FeedbackSurveyResponse) => {
|
||||
logEvent(MEMORY_SURVEY_EVENT, {
|
||||
event_type:
|
||||
'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id:
|
||||
appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
response:
|
||||
selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
void logOTelEvent('feedback_survey', {
|
||||
event_type: 'responded',
|
||||
appearance_id: appearanceId,
|
||||
response: selected,
|
||||
survey_type: 'memory',
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
const onSelect = useCallback((appearanceId: string, selected: FeedbackSurveyResponse) => {
|
||||
logEvent(MEMORY_SURVEY_EVENT, {
|
||||
event_type: 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
void logOTelEvent('feedback_survey', {
|
||||
event_type: 'responded',
|
||||
appearance_id: appearanceId,
|
||||
response: selected,
|
||||
survey_type: 'memory',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const shouldShowTranscriptPrompt = useCallback(
|
||||
(selected: FeedbackSurveyResponse) => {
|
||||
if (process.env.USER_TYPE !== 'ant') {
|
||||
return false
|
||||
}
|
||||
if (selected !== 'bad' && selected !== 'good') {
|
||||
return false
|
||||
}
|
||||
if (getGlobalConfig().transcriptShareDismissed) {
|
||||
return false
|
||||
}
|
||||
if (!isPolicyAllowed('allow_product_feedback')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
[],
|
||||
)
|
||||
const shouldShowTranscriptPrompt = useCallback((selected: FeedbackSurveyResponse) => {
|
||||
if (process.env.USER_TYPE !== 'ant') {
|
||||
return false;
|
||||
}
|
||||
if (selected !== 'bad' && selected !== 'good') {
|
||||
return false;
|
||||
}
|
||||
if (getGlobalConfig().transcriptShareDismissed) {
|
||||
return false;
|
||||
}
|
||||
if (!isPolicyAllowed('allow_product_feedback')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const onTranscriptPromptShown = useCallback((appearanceId: string) => {
|
||||
logEvent(MEMORY_SURVEY_EVENT, {
|
||||
event_type:
|
||||
'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id:
|
||||
appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
trigger:
|
||||
TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
event_type: 'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
void logOTelEvent('feedback_survey', {
|
||||
event_type: 'transcript_prompt_appeared',
|
||||
appearance_id: appearanceId,
|
||||
survey_type: 'memory',
|
||||
})
|
||||
}, [])
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onTranscriptSelect = useCallback(
|
||||
async (
|
||||
appearanceId: string,
|
||||
selected: TranscriptShareResponse,
|
||||
): Promise<boolean> => {
|
||||
async (appearanceId: string, selected: TranscriptShareResponse): Promise<boolean> => {
|
||||
logEvent(MEMORY_SURVEY_EVENT, {
|
||||
event_type:
|
||||
`transcript_share_${selected}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id:
|
||||
appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
trigger:
|
||||
TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
event_type: `transcript_share_${selected}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
|
||||
if (selected === 'dont_ask_again') {
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
transcriptShareDismissed: true,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
if (selected === 'yes') {
|
||||
const result = await submitTranscriptShare(
|
||||
messagesRef.current,
|
||||
TRANSCRIPT_SHARE_TRIGGER,
|
||||
appearanceId,
|
||||
)
|
||||
const result = await submitTranscriptShare(messagesRef.current, TRANSCRIPT_SHARE_TRIGGER, appearanceId);
|
||||
logEvent(MEMORY_SURVEY_EVENT, {
|
||||
event_type: (result.success
|
||||
? 'transcript_share_submitted'
|
||||
: 'transcript_share_failed') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id:
|
||||
appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
trigger:
|
||||
TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
return result.success
|
||||
appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
return result.success;
|
||||
}
|
||||
|
||||
return false
|
||||
return false;
|
||||
},
|
||||
[],
|
||||
)
|
||||
);
|
||||
|
||||
const { state, lastResponse, open, handleSelect, handleTranscriptSelect } =
|
||||
useSurveyState({
|
||||
hideThanksAfterMs: HIDE_THANKS_AFTER_MS,
|
||||
onOpen,
|
||||
onSelect,
|
||||
shouldShowTranscriptPrompt,
|
||||
onTranscriptPromptShown,
|
||||
onTranscriptSelect,
|
||||
})
|
||||
const { state, lastResponse, open, handleSelect, handleTranscriptSelect } = useSurveyState({
|
||||
hideThanksAfterMs: HIDE_THANKS_AFTER_MS,
|
||||
onOpen,
|
||||
onSelect,
|
||||
shouldShowTranscriptPrompt,
|
||||
onTranscriptPromptShown,
|
||||
onTranscriptSelect,
|
||||
});
|
||||
|
||||
const lastAssistant = useMemo(
|
||||
() => getLastAssistantMessage(messages),
|
||||
[messages],
|
||||
)
|
||||
const lastAssistant = useMemo(() => getLastAssistantMessage(messages), [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
if (!enabled) return;
|
||||
|
||||
// /clear resets messages but REPL stays mounted — reset refs so a memory
|
||||
// read from the previous conversation doesn't leak into the new one.
|
||||
if (messages.length === 0) {
|
||||
memoryReadSeen.current = false
|
||||
seenAssistantUuids.current.clear()
|
||||
return
|
||||
memoryReadSeen.current = false;
|
||||
seenAssistantUuids.current.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== 'closed' || isLoading || hasActivePrompt) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// 3P default: survey off (no GrowthBook on Bedrock/Vertex/Foundry).
|
||||
if (!getFeatureValue_CACHED_MAY_BE_STALE(MEMORY_SURVEY_GATE, false)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAutoMemoryEnabled()) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFeedbackSurveyDisabled()) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPolicyAllowed('allow_product_feedback')) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lastAssistant || seenAssistantUuids.current.has(lastAssistant.uuid)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const text = extractTextContent(Array.isArray(lastAssistant.message.content) ? lastAssistant.message.content : [], ' ')
|
||||
const text = extractTextContent(
|
||||
Array.isArray(lastAssistant.message.content) ? lastAssistant.message.content : [],
|
||||
' ',
|
||||
);
|
||||
if (!MEMORY_WORD_RE.test(text)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as evaluated before the memory-read scan so a turn that mentions
|
||||
// "memory" but has no memory read doesn't trigger repeated O(n) scans
|
||||
// on subsequent renders with the same last assistant message.
|
||||
seenAssistantUuids.current.add(lastAssistant.uuid)
|
||||
seenAssistantUuids.current.add(lastAssistant.uuid);
|
||||
|
||||
if (!memoryReadSeen.current) {
|
||||
memoryReadSeen.current = hasMemoryFileRead(messages)
|
||||
memoryReadSeen.current = hasMemoryFileRead(messages);
|
||||
}
|
||||
if (!memoryReadSeen.current) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.random() < SURVEY_PROBABILITY) {
|
||||
open()
|
||||
open();
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
state,
|
||||
isLoading,
|
||||
hasActivePrompt,
|
||||
lastAssistant,
|
||||
messages,
|
||||
open,
|
||||
])
|
||||
}, [enabled, state, isLoading, hasActivePrompt, lastAssistant, messages, open]);
|
||||
|
||||
return { state, lastResponse, handleSelect, handleTranscriptSelect }
|
||||
return { state, lastResponse, handleSelect, handleTranscriptSelect };
|
||||
}
|
||||
|
||||
@@ -1,39 +1,36 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js'
|
||||
import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js';
|
||||
import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { shouldUseSessionMemoryCompaction } from '../../services/compact/sessionMemoryCompact.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { isCompactBoundaryMessage } from '../../utils/messages.js'
|
||||
import { logOTelEvent } from '../../utils/telemetry/events.js'
|
||||
import { useSurveyState } from './useSurveyState.js'
|
||||
import type { FeedbackSurveyResponse } from './utils.js'
|
||||
} from 'src/services/analytics/index.js';
|
||||
import { shouldUseSessionMemoryCompaction } from '../../services/compact/sessionMemoryCompact.js';
|
||||
import type { Message } from '../../types/message.js';
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js';
|
||||
import { isCompactBoundaryMessage } from '../../utils/messages.js';
|
||||
import { logOTelEvent } from '../../utils/telemetry/events.js';
|
||||
import { useSurveyState } from './useSurveyState.js';
|
||||
import type { FeedbackSurveyResponse } from './utils.js';
|
||||
|
||||
const HIDE_THANKS_AFTER_MS = 3000
|
||||
const POST_COMPACT_SURVEY_GATE = 'tengu_post_compact_survey'
|
||||
const SURVEY_PROBABILITY = 0.2 // Show survey 20% of the time after compaction
|
||||
const HIDE_THANKS_AFTER_MS = 3000;
|
||||
const POST_COMPACT_SURVEY_GATE = 'tengu_post_compact_survey';
|
||||
const SURVEY_PROBABILITY = 0.2; // Show survey 20% of the time after compaction
|
||||
|
||||
function hasMessageAfterBoundary(
|
||||
messages: Message[],
|
||||
boundaryUuid: string,
|
||||
): boolean {
|
||||
const boundaryIndex = messages.findIndex(msg => msg.uuid === boundaryUuid)
|
||||
function hasMessageAfterBoundary(messages: Message[], boundaryUuid: string): boolean {
|
||||
const boundaryIndex = messages.findIndex(msg => msg.uuid === boundaryUuid);
|
||||
if (boundaryIndex === -1) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if there's a user or assistant message after the boundary
|
||||
for (let i = boundaryIndex + 1; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
const msg = messages[i];
|
||||
if (msg && (msg.type === 'user' || msg.type === 'assistant')) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
export function usePostCompactSurvey(
|
||||
@@ -42,154 +39,119 @@ export function usePostCompactSurvey(
|
||||
hasActivePrompt = false,
|
||||
{ enabled = true }: { enabled?: boolean } = {},
|
||||
): {
|
||||
state:
|
||||
| 'closed'
|
||||
| 'open'
|
||||
| 'thanks'
|
||||
| 'transcript_prompt'
|
||||
| 'submitting'
|
||||
| 'submitted'
|
||||
lastResponse: FeedbackSurveyResponse | null
|
||||
handleSelect: (selected: FeedbackSurveyResponse) => void
|
||||
state: 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted';
|
||||
lastResponse: FeedbackSurveyResponse | null;
|
||||
handleSelect: (selected: FeedbackSurveyResponse) => void;
|
||||
} {
|
||||
const [gateEnabled, setGateEnabled] = useState<boolean | null>(null)
|
||||
const seenCompactBoundaries = useRef<Set<string>>(new Set())
|
||||
const [gateEnabled, setGateEnabled] = useState<boolean | null>(null);
|
||||
const seenCompactBoundaries = useRef<Set<string>>(new Set());
|
||||
// Track the compact boundary we're waiting on (to show survey after next message)
|
||||
const pendingCompactBoundaryUuid = useRef<string | null>(null)
|
||||
const pendingCompactBoundaryUuid = useRef<string | null>(null);
|
||||
|
||||
const onOpen = useCallback((appearanceId: string) => {
|
||||
const smCompactionEnabled = shouldUseSessionMemoryCompaction()
|
||||
const smCompactionEnabled = shouldUseSessionMemoryCompaction();
|
||||
logEvent('tengu_post_compact_survey_event', {
|
||||
event_type:
|
||||
'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id:
|
||||
appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
event_type: 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
session_memory_compaction_enabled:
|
||||
smCompactionEnabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
void logOTelEvent('feedback_survey', {
|
||||
event_type: 'appeared',
|
||||
appearance_id: appearanceId,
|
||||
survey_type: 'post_compact',
|
||||
})
|
||||
}, [])
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onSelect = useCallback(
|
||||
(appearanceId: string, selected: FeedbackSurveyResponse) => {
|
||||
const smCompactionEnabled = shouldUseSessionMemoryCompaction()
|
||||
logEvent('tengu_post_compact_survey_event', {
|
||||
event_type:
|
||||
'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id:
|
||||
appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
response:
|
||||
selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
session_memory_compaction_enabled:
|
||||
smCompactionEnabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
void logOTelEvent('feedback_survey', {
|
||||
event_type: 'responded',
|
||||
appearance_id: appearanceId,
|
||||
response: selected,
|
||||
survey_type: 'post_compact',
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
const onSelect = useCallback((appearanceId: string, selected: FeedbackSurveyResponse) => {
|
||||
const smCompactionEnabled = shouldUseSessionMemoryCompaction();
|
||||
logEvent('tengu_post_compact_survey_event', {
|
||||
event_type: 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
session_memory_compaction_enabled:
|
||||
smCompactionEnabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
void logOTelEvent('feedback_survey', {
|
||||
event_type: 'responded',
|
||||
appearance_id: appearanceId,
|
||||
response: selected,
|
||||
survey_type: 'post_compact',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { state, lastResponse, open, handleSelect } = useSurveyState({
|
||||
hideThanksAfterMs: HIDE_THANKS_AFTER_MS,
|
||||
onOpen,
|
||||
onSelect,
|
||||
})
|
||||
});
|
||||
|
||||
// Check the feature gate on mount
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
setGateEnabled(
|
||||
checkStatsigFeatureGate_CACHED_MAY_BE_STALE(POST_COMPACT_SURVEY_GATE),
|
||||
)
|
||||
}, [enabled])
|
||||
if (!enabled) return;
|
||||
setGateEnabled(checkStatsigFeatureGate_CACHED_MAY_BE_STALE(POST_COMPACT_SURVEY_GATE));
|
||||
}, [enabled]);
|
||||
|
||||
// Find compact boundary messages
|
||||
const currentCompactBoundaries = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
messages
|
||||
.filter(msg => isCompactBoundaryMessage(msg))
|
||||
.map(msg => msg.uuid),
|
||||
),
|
||||
() => new Set(messages.filter(msg => isCompactBoundaryMessage(msg)).map(msg => msg.uuid)),
|
||||
[messages],
|
||||
)
|
||||
);
|
||||
|
||||
// Detect new compact boundaries and defer showing survey until next message
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
if (!enabled) return;
|
||||
|
||||
// Don't process if already showing
|
||||
if (state !== 'closed' || isLoading) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show survey when permission or ask question prompts are visible
|
||||
if (hasActivePrompt) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the gate is enabled
|
||||
if (gateEnabled !== true) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFeedbackSurveyDisabled()) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if survey is explicitly disabled
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// First, check if we have a pending compact and a new message has arrived
|
||||
if (pendingCompactBoundaryUuid.current !== null) {
|
||||
if (
|
||||
hasMessageAfterBoundary(messages, pendingCompactBoundaryUuid.current)
|
||||
) {
|
||||
if (hasMessageAfterBoundary(messages, pendingCompactBoundaryUuid.current)) {
|
||||
// A new message arrived after the compact - decide whether to show survey
|
||||
pendingCompactBoundaryUuid.current = null
|
||||
pendingCompactBoundaryUuid.current = null;
|
||||
|
||||
// Only show survey 20% of the time
|
||||
if (Math.random() < SURVEY_PROBABILITY) {
|
||||
open()
|
||||
open();
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Find new compact boundaries that we haven't seen yet
|
||||
const newBoundaries = Array.from(currentCompactBoundaries).filter(
|
||||
uuid => !seenCompactBoundaries.current.has(uuid),
|
||||
)
|
||||
const newBoundaries = Array.from(currentCompactBoundaries).filter(uuid => !seenCompactBoundaries.current.has(uuid));
|
||||
|
||||
if (newBoundaries.length > 0) {
|
||||
// Mark these boundaries as seen
|
||||
seenCompactBoundaries.current = new Set(currentCompactBoundaries)
|
||||
seenCompactBoundaries.current = new Set(currentCompactBoundaries);
|
||||
|
||||
// Don't show survey immediately - wait for next message
|
||||
// Store the most recent new boundary UUID
|
||||
pendingCompactBoundaryUuid.current =
|
||||
newBoundaries[newBoundaries.length - 1]!
|
||||
pendingCompactBoundaryUuid.current = newBoundaries[newBoundaries.length - 1]!;
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
currentCompactBoundaries,
|
||||
state,
|
||||
isLoading,
|
||||
hasActivePrompt,
|
||||
gateEnabled,
|
||||
messages,
|
||||
open,
|
||||
])
|
||||
}, [enabled, currentCompactBoundaries, state, isLoading, hasActivePrompt, gateEnabled, messages, open]);
|
||||
|
||||
return { state, lastResponse, handleSelect }
|
||||
return { state, lastResponse, handleSelect };
|
||||
}
|
||||
|
||||
@@ -1,34 +1,22 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { TranscriptShareResponse } from './TranscriptSharePrompt.js'
|
||||
import type { FeedbackSurveyResponse } from './utils.js'
|
||||
import { randomUUID } from 'crypto';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { TranscriptShareResponse } from './TranscriptSharePrompt.js';
|
||||
import type { FeedbackSurveyResponse } from './utils.js';
|
||||
|
||||
type SurveyState =
|
||||
| 'closed'
|
||||
| 'open'
|
||||
| 'thanks'
|
||||
| 'transcript_prompt'
|
||||
| 'submitting'
|
||||
| 'submitted'
|
||||
type SurveyState = 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted';
|
||||
|
||||
type UseSurveyStateOptions = {
|
||||
hideThanksAfterMs: number
|
||||
onOpen: (appearanceId: string) => void | Promise<void>
|
||||
onSelect: (
|
||||
appearanceId: string,
|
||||
selected: FeedbackSurveyResponse,
|
||||
) => void | Promise<void>
|
||||
shouldShowTranscriptPrompt?: (selected: FeedbackSurveyResponse) => boolean
|
||||
onTranscriptPromptShown?: (
|
||||
appearanceId: string,
|
||||
surveyResponse: FeedbackSurveyResponse,
|
||||
) => void
|
||||
hideThanksAfterMs: number;
|
||||
onOpen: (appearanceId: string) => void | Promise<void>;
|
||||
onSelect: (appearanceId: string, selected: FeedbackSurveyResponse) => void | Promise<void>;
|
||||
shouldShowTranscriptPrompt?: (selected: FeedbackSurveyResponse) => boolean;
|
||||
onTranscriptPromptShown?: (appearanceId: string, surveyResponse: FeedbackSurveyResponse) => void;
|
||||
onTranscriptSelect?: (
|
||||
appearanceId: string,
|
||||
selected: TranscriptShareResponse,
|
||||
surveyResponse: FeedbackSurveyResponse | null,
|
||||
) => boolean | Promise<boolean>
|
||||
}
|
||||
) => boolean | Promise<boolean>;
|
||||
};
|
||||
|
||||
export function useSurveyState({
|
||||
hideThanksAfterMs,
|
||||
@@ -38,107 +26,93 @@ export function useSurveyState({
|
||||
onTranscriptPromptShown,
|
||||
onTranscriptSelect,
|
||||
}: UseSurveyStateOptions): {
|
||||
state: SurveyState
|
||||
lastResponse: FeedbackSurveyResponse | null
|
||||
open: () => void
|
||||
handleSelect: (selected: FeedbackSurveyResponse) => boolean
|
||||
handleTranscriptSelect: (selected: TranscriptShareResponse) => void
|
||||
state: SurveyState;
|
||||
lastResponse: FeedbackSurveyResponse | null;
|
||||
open: () => void;
|
||||
handleSelect: (selected: FeedbackSurveyResponse) => boolean;
|
||||
handleTranscriptSelect: (selected: TranscriptShareResponse) => void;
|
||||
} {
|
||||
const [state, setState] = useState<SurveyState>('closed')
|
||||
const [lastResponse, setLastResponse] =
|
||||
useState<FeedbackSurveyResponse | null>(null)
|
||||
const appearanceId = useRef(randomUUID())
|
||||
const lastResponseRef = useRef<FeedbackSurveyResponse | null>(null)
|
||||
const [state, setState] = useState<SurveyState>('closed');
|
||||
const [lastResponse, setLastResponse] = useState<FeedbackSurveyResponse | null>(null);
|
||||
const appearanceId = useRef(randomUUID());
|
||||
const lastResponseRef = useRef<FeedbackSurveyResponse | null>(null);
|
||||
|
||||
const showThanksThenClose = useCallback(() => {
|
||||
setState('thanks')
|
||||
setState('thanks');
|
||||
setTimeout(
|
||||
(setState, setLastResponse) => {
|
||||
setState('closed')
|
||||
setLastResponse(null)
|
||||
setState('closed');
|
||||
setLastResponse(null);
|
||||
},
|
||||
hideThanksAfterMs,
|
||||
setState,
|
||||
setLastResponse,
|
||||
)
|
||||
}, [hideThanksAfterMs])
|
||||
);
|
||||
}, [hideThanksAfterMs]);
|
||||
|
||||
const showSubmittedThenClose = useCallback(() => {
|
||||
setState('submitted')
|
||||
setTimeout(setState, hideThanksAfterMs, 'closed')
|
||||
}, [hideThanksAfterMs])
|
||||
setState('submitted');
|
||||
setTimeout(setState, hideThanksAfterMs, 'closed');
|
||||
}, [hideThanksAfterMs]);
|
||||
|
||||
const open = useCallback(() => {
|
||||
if (state !== 'closed') {
|
||||
return
|
||||
return;
|
||||
}
|
||||
setState('open')
|
||||
appearanceId.current = randomUUID()
|
||||
void onOpen(appearanceId.current)
|
||||
}, [state, onOpen])
|
||||
setState('open');
|
||||
appearanceId.current = randomUUID();
|
||||
void onOpen(appearanceId.current);
|
||||
}, [state, onOpen]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selected: FeedbackSurveyResponse): boolean => {
|
||||
setLastResponse(selected)
|
||||
lastResponseRef.current = selected
|
||||
setLastResponse(selected);
|
||||
lastResponseRef.current = selected;
|
||||
// Always fire the survey response event first
|
||||
void onSelect(appearanceId.current, selected)
|
||||
void onSelect(appearanceId.current, selected);
|
||||
|
||||
if (selected === 'dismissed') {
|
||||
setState('closed')
|
||||
setLastResponse(null)
|
||||
setState('closed');
|
||||
setLastResponse(null);
|
||||
} else if (shouldShowTranscriptPrompt?.(selected)) {
|
||||
setState('transcript_prompt')
|
||||
onTranscriptPromptShown?.(appearanceId.current, selected)
|
||||
return true
|
||||
setState('transcript_prompt');
|
||||
onTranscriptPromptShown?.(appearanceId.current, selected);
|
||||
return true;
|
||||
} else {
|
||||
showThanksThenClose()
|
||||
showThanksThenClose();
|
||||
}
|
||||
return false
|
||||
return false;
|
||||
},
|
||||
[
|
||||
showThanksThenClose,
|
||||
onSelect,
|
||||
shouldShowTranscriptPrompt,
|
||||
onTranscriptPromptShown,
|
||||
],
|
||||
)
|
||||
[showThanksThenClose, onSelect, shouldShowTranscriptPrompt, onTranscriptPromptShown],
|
||||
);
|
||||
|
||||
const handleTranscriptSelect = useCallback(
|
||||
(selected: TranscriptShareResponse) => {
|
||||
switch (selected) {
|
||||
case 'yes':
|
||||
setState('submitting')
|
||||
setState('submitting');
|
||||
void (async () => {
|
||||
try {
|
||||
const success = await onTranscriptSelect?.(
|
||||
appearanceId.current,
|
||||
selected,
|
||||
lastResponseRef.current,
|
||||
)
|
||||
const success = await onTranscriptSelect?.(appearanceId.current, selected, lastResponseRef.current);
|
||||
if (success) {
|
||||
showSubmittedThenClose()
|
||||
showSubmittedThenClose();
|
||||
} else {
|
||||
showThanksThenClose()
|
||||
showThanksThenClose();
|
||||
}
|
||||
} catch {
|
||||
showThanksThenClose()
|
||||
showThanksThenClose();
|
||||
}
|
||||
})()
|
||||
break
|
||||
})();
|
||||
break;
|
||||
case 'no':
|
||||
case 'dont_ask_again':
|
||||
void onTranscriptSelect?.(
|
||||
appearanceId.current,
|
||||
selected,
|
||||
lastResponseRef.current,
|
||||
)
|
||||
showThanksThenClose()
|
||||
break
|
||||
void onTranscriptSelect?.(appearanceId.current, selected, lastResponseRef.current);
|
||||
showThanksThenClose();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[showThanksThenClose, showSubmittedThenClose, onTranscriptSelect],
|
||||
)
|
||||
);
|
||||
|
||||
return { state, lastResponse, open, handleSelect, handleTranscriptSelect }
|
||||
return { state, lastResponse, open, handleSelect, handleTranscriptSelect };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type FeedbackSurveyResponse = any;
|
||||
export type FeedbackSurveyType = any;
|
||||
export type FeedbackSurveyResponse = any
|
||||
export type FeedbackSurveyType = any
|
||||
|
||||
@@ -1,62 +1,42 @@
|
||||
import type { StructuredPatchHunk } from 'diff'
|
||||
import * as React from 'react'
|
||||
import { Suspense, use, useState } from 'react'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { FileEdit } from '@claude-code-best/builtin-tools/tools/FileEditTool/types.js'
|
||||
import {
|
||||
findActualString,
|
||||
preserveQuoteStyle,
|
||||
} from '@claude-code-best/builtin-tools/tools/FileEditTool/utils.js'
|
||||
import {
|
||||
adjustHunkLineNumbers,
|
||||
CONTEXT_LINES,
|
||||
getPatchForDisplay,
|
||||
} from '../utils/diff.js'
|
||||
import { logError } from '../utils/log.js'
|
||||
import {
|
||||
CHUNK_SIZE,
|
||||
openForScan,
|
||||
readCapped,
|
||||
scanForContext,
|
||||
} from '../utils/readEditContext.js'
|
||||
import { firstLineOf } from '../utils/stringUtils.js'
|
||||
import { StructuredDiffList } from './StructuredDiffList.js'
|
||||
import type { StructuredPatchHunk } from 'diff';
|
||||
import * as React from 'react';
|
||||
import { Suspense, use, useState } from 'react';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { FileEdit } from '@claude-code-best/builtin-tools/tools/FileEditTool/types.js';
|
||||
import { findActualString, preserveQuoteStyle } from '@claude-code-best/builtin-tools/tools/FileEditTool/utils.js';
|
||||
import { adjustHunkLineNumbers, CONTEXT_LINES, getPatchForDisplay } from '../utils/diff.js';
|
||||
import { logError } from '../utils/log.js';
|
||||
import { CHUNK_SIZE, openForScan, readCapped, scanForContext } from '../utils/readEditContext.js';
|
||||
import { firstLineOf } from '../utils/stringUtils.js';
|
||||
import { StructuredDiffList } from './StructuredDiffList.js';
|
||||
|
||||
type Props = {
|
||||
file_path: string
|
||||
edits: FileEdit[]
|
||||
}
|
||||
file_path: string;
|
||||
edits: FileEdit[];
|
||||
};
|
||||
|
||||
type DiffData = {
|
||||
patch: StructuredPatchHunk[]
|
||||
firstLine: string | null
|
||||
fileContent: string | undefined
|
||||
}
|
||||
patch: StructuredPatchHunk[];
|
||||
firstLine: string | null;
|
||||
fileContent: string | undefined;
|
||||
};
|
||||
|
||||
export function FileEditToolDiff(props: Props): React.ReactNode {
|
||||
// Snapshot on mount — the diff must stay consistent even if the file changes
|
||||
// while the dialog is open. useMemo on props.edits would re-read the file on
|
||||
// every render because callers pass fresh array literals.
|
||||
const [dataPromise] = useState(() =>
|
||||
loadDiffData(props.file_path, props.edits),
|
||||
)
|
||||
const [dataPromise] = useState(() => loadDiffData(props.file_path, props.edits));
|
||||
return (
|
||||
<Suspense fallback={<DiffFrame placeholder />}>
|
||||
<DiffBody promise={dataPromise} file_path={props.file_path} />
|
||||
</Suspense>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DiffBody({
|
||||
promise,
|
||||
file_path,
|
||||
}: {
|
||||
promise: Promise<DiffData>
|
||||
file_path: string
|
||||
}): React.ReactNode {
|
||||
const { patch, firstLine, fileContent } = use(promise)
|
||||
const { columns } = useTerminalSize()
|
||||
function DiffBody({ promise, file_path }: { promise: Promise<DiffData>; file_path: string }): React.ReactNode {
|
||||
const { patch, firstLine, fileContent } = use(promise);
|
||||
const { columns } = useTerminalSize();
|
||||
return (
|
||||
<DiffFrame>
|
||||
<StructuredDiffList
|
||||
@@ -68,57 +48,42 @@ function DiffBody({
|
||||
fileContent={fileContent}
|
||||
/>
|
||||
</DiffFrame>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DiffFrame({
|
||||
children,
|
||||
placeholder,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
placeholder?: boolean
|
||||
}): React.ReactNode {
|
||||
function DiffFrame({ children, placeholder }: { children?: React.ReactNode; placeholder?: boolean }): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderColor="subtle"
|
||||
borderStyle="dashed"
|
||||
flexDirection="column"
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
>
|
||||
<Box borderColor="subtle" borderStyle="dashed" flexDirection="column" borderLeft={false} borderRight={false}>
|
||||
{placeholder ? <Text dimColor>…</Text> : children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function loadDiffData(
|
||||
file_path: string,
|
||||
edits: FileEdit[],
|
||||
): Promise<DiffData> {
|
||||
const valid = edits.filter(e => e.old_string != null && e.new_string != null)
|
||||
const single = valid.length === 1 ? valid[0]! : undefined
|
||||
async function loadDiffData(file_path: string, edits: FileEdit[]): Promise<DiffData> {
|
||||
const valid = edits.filter(e => e.old_string != null && e.new_string != null);
|
||||
const single = valid.length === 1 ? valid[0]! : undefined;
|
||||
|
||||
// SedEditPermissionRequest passes the entire file as old_string. Scanning for
|
||||
// a needle ≥ CHUNK_SIZE allocates O(needle) for the overlap buffer — skip the
|
||||
// file read entirely and diff the inputs we already have.
|
||||
if (single && single.old_string.length >= CHUNK_SIZE) {
|
||||
return diffToolInputsOnly(file_path, [single])
|
||||
return diffToolInputsOnly(file_path, [single]);
|
||||
}
|
||||
|
||||
try {
|
||||
const handle = await openForScan(file_path)
|
||||
if (handle === null) return diffToolInputsOnly(file_path, valid)
|
||||
const handle = await openForScan(file_path);
|
||||
if (handle === null) return diffToolInputsOnly(file_path, valid);
|
||||
try {
|
||||
// Multi-edit and empty old_string genuinely need full-file for sequential
|
||||
// replacements — structuredPatch needs before/after strings. replace_all
|
||||
// routes through the chunked path below (shows first-occurrence window;
|
||||
// matches within the slice still replace via edit.replace_all).
|
||||
if (!single || single.old_string === '') {
|
||||
const file = await readCapped(handle)
|
||||
if (file === null) return diffToolInputsOnly(file_path, valid)
|
||||
const normalized = valid.map(e => normalizeEdit(file, e))
|
||||
const file = await readCapped(handle);
|
||||
if (file === null) return diffToolInputsOnly(file_path, valid);
|
||||
const normalized = valid.map(e => normalizeEdit(file, e));
|
||||
return {
|
||||
patch: getPatchForDisplay({
|
||||
filePath: file_path,
|
||||
@@ -127,30 +92,30 @@ async function loadDiffData(
|
||||
}),
|
||||
firstLine: firstLineOf(file),
|
||||
fileContent: file,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const ctx = await scanForContext(handle, single.old_string, CONTEXT_LINES)
|
||||
const ctx = await scanForContext(handle, single.old_string, CONTEXT_LINES);
|
||||
if (ctx.truncated || ctx.content === '') {
|
||||
return diffToolInputsOnly(file_path, [single])
|
||||
return diffToolInputsOnly(file_path, [single]);
|
||||
}
|
||||
const normalized = normalizeEdit(ctx.content, single)
|
||||
const normalized = normalizeEdit(ctx.content, single);
|
||||
const hunks = getPatchForDisplay({
|
||||
filePath: file_path,
|
||||
fileContents: ctx.content,
|
||||
edits: [normalized],
|
||||
})
|
||||
});
|
||||
return {
|
||||
patch: adjustHunkLineNumbers(hunks, ctx.lineOffset - 1),
|
||||
firstLine: ctx.lineOffset === 1 ? firstLineOf(ctx.content) : null,
|
||||
fileContent: ctx.content,
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
await handle.close()
|
||||
await handle.close();
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e as Error)
|
||||
return diffToolInputsOnly(file_path, valid)
|
||||
logError(e as Error);
|
||||
return diffToolInputsOnly(file_path, valid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,16 +130,11 @@ function diffToolInputsOnly(filePath: string, edits: FileEdit[]): DiffData {
|
||||
),
|
||||
firstLine: null,
|
||||
fileContent: undefined,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEdit(fileContent: string, edit: FileEdit): FileEdit {
|
||||
const actualOld =
|
||||
findActualString(fileContent, edit.old_string) || edit.old_string
|
||||
const actualNew = preserveQuoteStyle(
|
||||
edit.old_string,
|
||||
actualOld,
|
||||
edit.new_string,
|
||||
)
|
||||
return { ...edit, old_string: actualOld, new_string: actualNew }
|
||||
const actualOld = findActualString(fileContent, edit.old_string) || edit.old_string;
|
||||
const actualNew = preserveQuoteStyle(edit.old_string, actualOld, edit.new_string);
|
||||
return { ...edit, old_string: actualOld, new_string: actualNew };
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import * as React from 'react'
|
||||
import { Text } from '@anthropic/ink'
|
||||
import { count } from '../utils/array.js'
|
||||
import { MessageResponse } from './MessageResponse.js'
|
||||
import * as React from 'react';
|
||||
import { Text } from '@anthropic/ink';
|
||||
import { count } from '../utils/array.js';
|
||||
import { MessageResponse } from './MessageResponse.js';
|
||||
|
||||
type Props = {
|
||||
filePath: string
|
||||
structuredPatch: { lines: string[] }[]
|
||||
style?: 'condensed'
|
||||
verbose: boolean
|
||||
previewHint?: string
|
||||
}
|
||||
filePath: string;
|
||||
structuredPatch: { lines: string[] }[];
|
||||
style?: 'condensed';
|
||||
verbose: boolean;
|
||||
previewHint?: string;
|
||||
};
|
||||
|
||||
export function FileEditToolUpdatedMessage({
|
||||
filePath,
|
||||
@@ -18,32 +18,24 @@ export function FileEditToolUpdatedMessage({
|
||||
verbose,
|
||||
previewHint,
|
||||
}: Props): React.ReactNode {
|
||||
const numAdditions = structuredPatch.reduce(
|
||||
(acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('+')),
|
||||
0,
|
||||
)
|
||||
const numRemovals = structuredPatch.reduce(
|
||||
(acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('-')),
|
||||
0,
|
||||
)
|
||||
const numAdditions = structuredPatch.reduce((acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('+')), 0);
|
||||
const numRemovals = structuredPatch.reduce((acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('-')), 0);
|
||||
|
||||
const text = (
|
||||
<Text>
|
||||
{numAdditions > 0 ? (
|
||||
<>
|
||||
Added <Text bold>{numAdditions}</Text>{' '}
|
||||
{numAdditions > 1 ? 'lines' : 'line'}
|
||||
Added <Text bold>{numAdditions}</Text> {numAdditions > 1 ? 'lines' : 'line'}
|
||||
</>
|
||||
) : null}
|
||||
{numAdditions > 0 && numRemovals > 0 ? ', ' : null}
|
||||
{numRemovals > 0 ? (
|
||||
<>
|
||||
{numAdditions === 0 ? 'R' : 'r'}emoved <Text bold>{numRemovals}</Text>{' '}
|
||||
{numRemovals > 1 ? 'lines' : 'line'}
|
||||
{numAdditions === 0 ? 'R' : 'r'}emoved <Text bold>{numRemovals}</Text> {numRemovals > 1 ? 'lines' : 'line'}
|
||||
</>
|
||||
) : null}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
|
||||
// Plan files: invert condensed behavior
|
||||
// - Regular mode: just show the hint (user can type /plan to see full content)
|
||||
@@ -54,13 +46,11 @@ export function FileEditToolUpdatedMessage({
|
||||
<MessageResponse>
|
||||
<Text dimColor>{previewHint}</Text>
|
||||
</MessageResponse>
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (style === 'condensed' && !verbose) {
|
||||
return text
|
||||
return text;
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageResponse>{text}</MessageResponse>
|
||||
)
|
||||
return <MessageResponse>{text}</MessageResponse>;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { relative } from 'path'
|
||||
import * as React from 'react'
|
||||
import { getCwd } from 'src/utils/cwd.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { MessageResponse } from './MessageResponse.js'
|
||||
import { relative } from 'path';
|
||||
import * as React from 'react';
|
||||
import { getCwd } from 'src/utils/cwd.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { MessageResponse } from './MessageResponse.js';
|
||||
|
||||
type Props = {
|
||||
file_path: string
|
||||
operation: 'write' | 'update'
|
||||
style?: 'condensed'
|
||||
verbose: boolean
|
||||
}
|
||||
file_path: string;
|
||||
operation: 'write' | 'update';
|
||||
style?: 'condensed';
|
||||
verbose: boolean;
|
||||
};
|
||||
|
||||
export function FileEditToolUseRejectedMessage({
|
||||
file_path,
|
||||
operation,
|
||||
style,
|
||||
verbose,
|
||||
}: Props): React.ReactNode {
|
||||
export function FileEditToolUseRejectedMessage({ file_path, operation, style, verbose }: Props): React.ReactNode {
|
||||
const text = (
|
||||
<Box flexDirection="row">
|
||||
<Text color="subtle">User rejected {operation} to </Text>
|
||||
@@ -24,12 +19,12 @@ export function FileEditToolUseRejectedMessage({
|
||||
{verbose ? file_path : relative(getCwd(), file_path)}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
// For condensed style, just show the text
|
||||
if (style === 'condensed' && !verbose) {
|
||||
return <MessageResponse>{text}</MessageResponse>
|
||||
return <MessageResponse>{text}</MessageResponse>;
|
||||
}
|
||||
|
||||
return <MessageResponse>{text}</MessageResponse>
|
||||
return <MessageResponse>{text}</MessageResponse>;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from 'react'
|
||||
import { pathToFileURL } from 'url'
|
||||
import { Link } from '@anthropic/ink'
|
||||
import React from 'react';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { Link } from '@anthropic/ink';
|
||||
|
||||
type Props = {
|
||||
/** The absolute file path */
|
||||
filePath: string
|
||||
filePath: string;
|
||||
/** Optional display text (defaults to filePath) */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a file path as an OSC 8 hyperlink.
|
||||
@@ -15,5 +15,5 @@ type Props = {
|
||||
* even when they appear inside parentheses or other text.
|
||||
*/
|
||||
export function FilePathLink({ filePath, children }: Props): React.ReactNode {
|
||||
return <Link url={pathToFileURL(filePath).href}>{children ?? filePath}</Link>
|
||||
return <Link url={pathToFileURL(filePath).href}>{children ?? filePath}</Link>;
|
||||
}
|
||||
|
||||
@@ -10,23 +10,19 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { ModalContext } from '../context/modalContext.js'
|
||||
import {
|
||||
PromptOverlayProvider,
|
||||
usePromptOverlay,
|
||||
usePromptOverlayDialog,
|
||||
} from '../context/promptOverlayContext.js'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { Box, ScrollBox, type ScrollBoxHandle, Text, instances } from '@anthropic/ink'
|
||||
import type { Message } from '../types/message.js'
|
||||
import { openBrowser, openPath } from '../utils/browser.js'
|
||||
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'
|
||||
import { plural } from '../utils/stringUtils.js'
|
||||
import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js'
|
||||
import PromptInputFooterSuggestions from './PromptInput/PromptInputFooterSuggestions.js'
|
||||
import type { StickyPrompt } from './VirtualMessageList.js'
|
||||
} from 'react';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { ModalContext } from '../context/modalContext.js';
|
||||
import { PromptOverlayProvider, usePromptOverlay, usePromptOverlayDialog } from '../context/promptOverlayContext.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { Box, ScrollBox, type ScrollBoxHandle, Text, instances } from '@anthropic/ink';
|
||||
import type { Message } from '../types/message.js';
|
||||
import { openBrowser, openPath } from '../utils/browser.js';
|
||||
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js';
|
||||
import { plural } from '../utils/stringUtils.js';
|
||||
import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js';
|
||||
import PromptInputFooterSuggestions from './PromptInput/PromptInputFooterSuggestions.js';
|
||||
import type { StickyPrompt } from './VirtualMessageList.js';
|
||||
|
||||
/** Rows of transcript context kept visible above the modal pane's ▔ divider. */
|
||||
const MODAL_TRANSCRIPT_PEEK = 2;
|
||||
@@ -232,10 +228,10 @@ export function countUnseenAssistantTurns(messages: readonly Message[], dividerI
|
||||
}
|
||||
|
||||
function assistantHasVisibleText(m: Message): boolean {
|
||||
if (m.type !== 'assistant') return false
|
||||
if (!Array.isArray(m.message!.content)) return false
|
||||
if (m.type !== 'assistant') return false;
|
||||
if (!Array.isArray(m.message!.content)) return false;
|
||||
for (const b of m.message!.content) {
|
||||
if (typeof b !== 'string' && b.type === 'text' && b.text.trim() !== '') return true
|
||||
if (typeof b !== 'string' && b.type === 'text' && b.text.trim() !== '') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,123 +1,114 @@
|
||||
import { resolve as resolvePath } from 'path'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRegisterOverlay } from '../context/overlayContext.js'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { Text } from '@anthropic/ink'
|
||||
import { logEvent } from '../services/analytics/index.js'
|
||||
import { getCwd } from '../utils/cwd.js'
|
||||
import { openFileInExternalEditor } from '../utils/editor.js'
|
||||
import { truncatePathMiddle, truncateToWidth } from '../utils/format.js'
|
||||
import { highlightMatch } from '../utils/highlightMatch.js'
|
||||
import { relativePath } from '../utils/permissions/filesystem.js'
|
||||
import { readFileInRange } from '../utils/readFileInRange.js'
|
||||
import { ripGrepStream } from '../utils/ripgrep.js'
|
||||
import { FuzzyPicker, LoadingState } from '@anthropic/ink'
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import * as React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRegisterOverlay } from '../context/overlayContext.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { Text } from '@anthropic/ink';
|
||||
import { logEvent } from '../services/analytics/index.js';
|
||||
import { getCwd } from '../utils/cwd.js';
|
||||
import { openFileInExternalEditor } from '../utils/editor.js';
|
||||
import { truncatePathMiddle, truncateToWidth } from '../utils/format.js';
|
||||
import { highlightMatch } from '../utils/highlightMatch.js';
|
||||
import { relativePath } from '../utils/permissions/filesystem.js';
|
||||
import { readFileInRange } from '../utils/readFileInRange.js';
|
||||
import { ripGrepStream } from '../utils/ripgrep.js';
|
||||
import { FuzzyPicker, LoadingState } from '@anthropic/ink';
|
||||
|
||||
type Props = {
|
||||
onDone: () => void
|
||||
onInsert: (text: string) => void
|
||||
}
|
||||
onDone: () => void;
|
||||
onInsert: (text: string) => void;
|
||||
};
|
||||
|
||||
type Match = {
|
||||
file: string
|
||||
line: number
|
||||
text: string
|
||||
}
|
||||
file: string;
|
||||
line: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const VISIBLE_RESULTS = 12
|
||||
const DEBOUNCE_MS = 100
|
||||
const PREVIEW_CONTEXT_LINES = 4
|
||||
const VISIBLE_RESULTS = 12;
|
||||
const DEBOUNCE_MS = 100;
|
||||
const PREVIEW_CONTEXT_LINES = 4;
|
||||
// rg -m is per-file; we also cap the parsed array to keep memory bounded.
|
||||
const MAX_MATCHES_PER_FILE = 10
|
||||
const MAX_TOTAL_MATCHES = 500
|
||||
const MAX_MATCHES_PER_FILE = 10;
|
||||
const MAX_TOTAL_MATCHES = 500;
|
||||
|
||||
/**
|
||||
* Global Search dialog (ctrl+shift+f / cmd+shift+f).
|
||||
* Debounced ripgrep search across the workspace.
|
||||
*/
|
||||
export function GlobalSearchDialog({
|
||||
onDone,
|
||||
onInsert,
|
||||
}: Props): React.ReactNode {
|
||||
useRegisterOverlay('global-search')
|
||||
const { columns, rows } = useTerminalSize()
|
||||
const previewOnRight = columns >= 140
|
||||
export function GlobalSearchDialog({ onDone, onInsert }: Props): React.ReactNode {
|
||||
useRegisterOverlay('global-search');
|
||||
const { columns, rows } = useTerminalSize();
|
||||
const previewOnRight = columns >= 140;
|
||||
// Chrome (title + search + matchLabel + hints + pane border + gaps) eats
|
||||
// ~14 rows. Shrink the list on short terminals so the dialog doesn't clip.
|
||||
const visibleResults = Math.min(VISIBLE_RESULTS, Math.max(4, rows - 14))
|
||||
const visibleResults = Math.min(VISIBLE_RESULTS, Math.max(4, rows - 14));
|
||||
|
||||
const [matches, setMatches] = useState<Match[]>([])
|
||||
const [truncated, setTruncated] = useState(false)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [focused, setFocused] = useState<Match | undefined>(undefined)
|
||||
const [matches, setMatches] = useState<Match[]>([]);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [focused, setFocused] = useState<Match | undefined>(undefined);
|
||||
const [preview, setPreview] = useState<{
|
||||
file: string
|
||||
line: number
|
||||
content: string
|
||||
} | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
file: string;
|
||||
line: number;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
abortRef.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load context lines around the focused match. AbortController prevents
|
||||
// holding ↓ from piling up reads.
|
||||
useEffect(() => {
|
||||
if (!focused) {
|
||||
setPreview(null)
|
||||
return
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const absolute = resolvePath(getCwd(), focused.file)
|
||||
const start = Math.max(0, focused.line - PREVIEW_CONTEXT_LINES - 1)
|
||||
void readFileInRange(
|
||||
absolute,
|
||||
start,
|
||||
PREVIEW_CONTEXT_LINES * 2 + 1,
|
||||
undefined,
|
||||
controller.signal,
|
||||
)
|
||||
const controller = new AbortController();
|
||||
const absolute = resolvePath(getCwd(), focused.file);
|
||||
const start = Math.max(0, focused.line - PREVIEW_CONTEXT_LINES - 1);
|
||||
void readFileInRange(absolute, start, PREVIEW_CONTEXT_LINES * 2 + 1, undefined, controller.signal)
|
||||
.then(r => {
|
||||
if (controller.signal.aborted) return
|
||||
if (controller.signal.aborted) return;
|
||||
setPreview({
|
||||
file: focused.file,
|
||||
line: focused.line,
|
||||
content: r.content,
|
||||
})
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return
|
||||
if (controller.signal.aborted) return;
|
||||
setPreview({
|
||||
file: focused.file,
|
||||
line: focused.line,
|
||||
content: '(preview unavailable)',
|
||||
})
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [focused])
|
||||
});
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [focused]);
|
||||
|
||||
const handleQueryChange = (q: string) => {
|
||||
setQuery(q)
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
abortRef.current?.abort()
|
||||
setQuery(q);
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
abortRef.current?.abort();
|
||||
|
||||
if (!q.trim()) {
|
||||
setMatches(m => (m.length ? [] : m))
|
||||
setIsSearching(false)
|
||||
setTruncated(false)
|
||||
return
|
||||
setMatches(m => (m.length ? [] : m));
|
||||
setIsSearching(false);
|
||||
setTruncated(false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
setIsSearching(true)
|
||||
setTruncated(false)
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setIsSearching(true);
|
||||
setTruncated(false);
|
||||
// Client-filter existing results while rg walks — keeps something on
|
||||
// screen instead of flashing blank. rg results are merged in (deduped by
|
||||
// file:line) rather than replaced, so the count is monotonic within a
|
||||
@@ -127,13 +118,11 @@ export function GlobalSearchDialog({
|
||||
// includes the new query lowered. Non-narrowing (broadening/different):
|
||||
// filter is best-effort — may briefly show a subset until rg fills in
|
||||
// the rest.
|
||||
const queryLower = q.toLowerCase()
|
||||
const queryLower = q.toLowerCase();
|
||||
setMatches(m => {
|
||||
const filtered = m.filter(match =>
|
||||
match.text.toLowerCase().includes(queryLower),
|
||||
)
|
||||
return filtered.length === m.length ? m : filtered
|
||||
})
|
||||
const filtered = m.filter(match => match.text.toLowerCase().includes(queryLower));
|
||||
return filtered.length === m.length ? m : filtered;
|
||||
});
|
||||
|
||||
timeoutRef.current = setTimeout(
|
||||
(query, controller, setMatches, setTruncated, setIsSearching) => {
|
||||
@@ -142,52 +131,41 @@ export function GlobalSearchDialog({
|
||||
// display (otherwise the cwd prefix eats the width budget).
|
||||
// relativePath() returns POSIX-normalized output so truncatePathMiddle
|
||||
// (which uses lastIndexOf('/')) works on Windows too.
|
||||
const cwd = getCwd()
|
||||
let collected = 0
|
||||
const cwd = getCwd();
|
||||
let collected = 0;
|
||||
void ripGrepStream(
|
||||
// -e disambiguates pattern from options when the query starts with '-'
|
||||
// (e.g. searching for "--verbose" or "-rf"). See GrepTool.ts for the
|
||||
// same precaution.
|
||||
[
|
||||
'-n',
|
||||
'--no-heading',
|
||||
'-i',
|
||||
'-m',
|
||||
String(MAX_MATCHES_PER_FILE),
|
||||
'-F',
|
||||
'-e',
|
||||
query,
|
||||
],
|
||||
['-n', '--no-heading', '-i', '-m', String(MAX_MATCHES_PER_FILE), '-F', '-e', query],
|
||||
cwd,
|
||||
controller.signal,
|
||||
lines => {
|
||||
if (controller.signal.aborted) return
|
||||
const parsed: Match[] = []
|
||||
if (controller.signal.aborted) return;
|
||||
const parsed: Match[] = [];
|
||||
for (const line of lines) {
|
||||
const m = parseRipgrepLine(line)
|
||||
if (!m) continue
|
||||
const rel = relativePath(cwd, m.file)
|
||||
parsed.push({ ...m, file: rel.startsWith('..') ? m.file : rel })
|
||||
const m = parseRipgrepLine(line);
|
||||
if (!m) continue;
|
||||
const rel = relativePath(cwd, m.file);
|
||||
parsed.push({ ...m, file: rel.startsWith('..') ? m.file : rel });
|
||||
}
|
||||
if (!parsed.length) return
|
||||
collected += parsed.length
|
||||
if (!parsed.length) return;
|
||||
collected += parsed.length;
|
||||
setMatches(prev => {
|
||||
// Append+dedupe instead of replace: prev may hold client-
|
||||
// filtered results that are valid matches for this query.
|
||||
// Replacing would drop the count to this chunk's size then
|
||||
// grow it back — visible as a flicker.
|
||||
const seen = new Set(prev.map(matchKey))
|
||||
const fresh = parsed.filter(p => !seen.has(matchKey(p)))
|
||||
if (!fresh.length) return prev
|
||||
const next = prev.concat(fresh)
|
||||
return next.length > MAX_TOTAL_MATCHES
|
||||
? next.slice(0, MAX_TOTAL_MATCHES)
|
||||
: next
|
||||
})
|
||||
const seen = new Set(prev.map(matchKey));
|
||||
const fresh = parsed.filter(p => !seen.has(matchKey(p)));
|
||||
if (!fresh.length) return prev;
|
||||
const next = prev.concat(fresh);
|
||||
return next.length > MAX_TOTAL_MATCHES ? next.slice(0, MAX_TOTAL_MATCHES) : next;
|
||||
});
|
||||
if (collected >= MAX_TOTAL_MATCHES) {
|
||||
controller.abort()
|
||||
setTruncated(true)
|
||||
setIsSearching(false)
|
||||
controller.abort();
|
||||
setTruncated(true);
|
||||
setIsSearching(false);
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -195,10 +173,10 @@ export function GlobalSearchDialog({
|
||||
// Stream closed with zero chunks — clear stale results so
|
||||
// "No matches" renders instead of the previous query's list.
|
||||
.finally(() => {
|
||||
if (controller.signal.aborted) return
|
||||
if (collected === 0) setMatches(m => (m.length ? [] : m))
|
||||
setIsSearching(false)
|
||||
})
|
||||
if (controller.signal.aborted) return;
|
||||
if (collected === 0) setMatches(m => (m.length ? [] : m));
|
||||
setIsSearching(false);
|
||||
});
|
||||
},
|
||||
DEBOUNCE_MS,
|
||||
q,
|
||||
@@ -206,45 +184,36 @@ export function GlobalSearchDialog({
|
||||
setMatches,
|
||||
setTruncated,
|
||||
setIsSearching,
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const listWidth = previewOnRight
|
||||
? Math.floor((columns - 10) * 0.5)
|
||||
: columns - 8
|
||||
const maxPathWidth = Math.max(20, Math.floor(listWidth * 0.4))
|
||||
const maxTextWidth = Math.max(20, listWidth - maxPathWidth - 4)
|
||||
const previewWidth = previewOnRight
|
||||
? Math.max(40, columns - listWidth - 14)
|
||||
: columns - 6
|
||||
const listWidth = previewOnRight ? Math.floor((columns - 10) * 0.5) : columns - 8;
|
||||
const maxPathWidth = Math.max(20, Math.floor(listWidth * 0.4));
|
||||
const maxTextWidth = Math.max(20, listWidth - maxPathWidth - 4);
|
||||
const previewWidth = previewOnRight ? Math.max(40, columns - listWidth - 14) : columns - 6;
|
||||
|
||||
const handleOpen = (m: Match) => {
|
||||
const opened = openFileInExternalEditor(
|
||||
resolvePath(getCwd(), m.file),
|
||||
m.line,
|
||||
)
|
||||
const opened = openFileInExternalEditor(resolvePath(getCwd(), m.file), m.line);
|
||||
logEvent('tengu_global_search_select', {
|
||||
result_count: matches.length,
|
||||
opened_editor: opened,
|
||||
})
|
||||
onDone()
|
||||
}
|
||||
});
|
||||
onDone();
|
||||
};
|
||||
|
||||
const handleInsert = (m: Match, mention: boolean) => {
|
||||
onInsert(mention ? `@${m.file}#L${m.line} ` : `${m.file}:${m.line} `)
|
||||
onInsert(mention ? `@${m.file}#L${m.line} ` : `${m.file}:${m.line} `);
|
||||
logEvent('tengu_global_search_insert', {
|
||||
result_count: matches.length,
|
||||
mention,
|
||||
})
|
||||
onDone()
|
||||
}
|
||||
});
|
||||
onDone();
|
||||
};
|
||||
|
||||
// Always pass a non-empty string so the line is reserved — prevents the
|
||||
// searchBox from bouncing when the count appears/disappears.
|
||||
const matchLabel =
|
||||
matches.length > 0
|
||||
? `${matches.length}${truncated ? '+' : ''} matches${isSearching ? '…' : ''}`
|
||||
: ' '
|
||||
matches.length > 0 ? `${matches.length}${truncated ? '+' : ''} matches${isSearching ? '…' : ''}` : ' ';
|
||||
|
||||
return (
|
||||
<FuzzyPicker
|
||||
@@ -264,9 +233,7 @@ export function GlobalSearchDialog({
|
||||
handler: m => handleInsert(m, false),
|
||||
}}
|
||||
onCancel={onDone}
|
||||
emptyMessage={q =>
|
||||
isSearching ? 'Searching…' : q ? 'No matches' : 'Type to search…'
|
||||
}
|
||||
emptyMessage={q => (isSearching ? 'Searching…' : q ? 'No matches' : 'Type to search…')}
|
||||
matchLabel={matchLabel}
|
||||
selectAction="open in editor"
|
||||
renderItem={(m, isFocused) => (
|
||||
@@ -274,10 +241,7 @@ export function GlobalSearchDialog({
|
||||
<Text dimColor>
|
||||
{truncatePathMiddle(m.file, maxPathWidth)}:{m.line}
|
||||
</Text>{' '}
|
||||
{highlightMatch(
|
||||
truncateToWidth(m.text.trimStart(), maxTextWidth),
|
||||
query,
|
||||
)}
|
||||
{highlightMatch(truncateToWidth(m.text.trimStart(), maxTextWidth), query)}
|
||||
</Text>
|
||||
)}
|
||||
renderPreview={m =>
|
||||
@@ -287,9 +251,7 @@ export function GlobalSearchDialog({
|
||||
{truncatePathMiddle(m.file, previewWidth)}:{m.line}
|
||||
</Text>
|
||||
{preview.content.split('\n').map((line, i) => (
|
||||
<Text key={i}>
|
||||
{highlightMatch(truncateToWidth(line, previewWidth), query)}
|
||||
</Text>
|
||||
<Text key={i}>{highlightMatch(truncateToWidth(line, previewWidth), query)}</Text>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
@@ -297,11 +259,11 @@ export function GlobalSearchDialog({
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function matchKey(m: Match): string {
|
||||
return `${m.file}:${m.line}`
|
||||
return `${m.file}:${m.line}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,10 +274,10 @@ function matchKey(m: Match): string {
|
||||
* @internal exported for testing
|
||||
*/
|
||||
export function parseRipgrepLine(line: string): Match | null {
|
||||
const m = /^(.*?):(\d+):(.*)$/.exec(line)
|
||||
if (!m) return null
|
||||
const [, file, lineStr, text] = m
|
||||
const lineNum = Number(lineStr)
|
||||
if (!file || !Number.isFinite(lineNum)) return null
|
||||
return { file, line: lineNum, text: text ?? '' }
|
||||
const m = /^(.*?):(\d+):(.*)$/.exec(line);
|
||||
if (!m) return null;
|
||||
const [, file, lineStr, text] = m;
|
||||
const lineNum = Number(lineStr);
|
||||
if (!file || !Number.isFinite(lineNum)) return null;
|
||||
return { file, line: lineNum, text: text ?? '' };
|
||||
}
|
||||
|
||||
@@ -1,48 +1,41 @@
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { type Command, formatDescriptionWithSource } from '../../commands.js'
|
||||
import { truncate } from '../../utils/truncate.js'
|
||||
import { Box, Text, useTabHeaderFocus } from '@anthropic/ink'
|
||||
import { Select } from '../CustomSelect/select.js'
|
||||
import * as React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { type Command, formatDescriptionWithSource } from '../../commands.js';
|
||||
import { truncate } from '../../utils/truncate.js';
|
||||
import { Box, Text, useTabHeaderFocus } from '@anthropic/ink';
|
||||
import { Select } from '../CustomSelect/select.js';
|
||||
|
||||
type Props = {
|
||||
commands: Command[]
|
||||
maxHeight: number
|
||||
columns: number
|
||||
title: string
|
||||
onCancel: () => void
|
||||
emptyMessage?: string
|
||||
}
|
||||
commands: Command[];
|
||||
maxHeight: number;
|
||||
columns: number;
|
||||
title: string;
|
||||
onCancel: () => void;
|
||||
emptyMessage?: string;
|
||||
};
|
||||
|
||||
export function Commands({
|
||||
commands,
|
||||
maxHeight,
|
||||
columns,
|
||||
title,
|
||||
onCancel,
|
||||
emptyMessage,
|
||||
}: Props): React.ReactNode {
|
||||
const { headerFocused, focusHeader } = useTabHeaderFocus()
|
||||
const maxWidth = Math.max(1, columns - 10)
|
||||
const visibleCount = Math.max(1, Math.floor((maxHeight - 10) / 2))
|
||||
export function Commands({ commands, maxHeight, columns, title, onCancel, emptyMessage }: Props): React.ReactNode {
|
||||
const { headerFocused, focusHeader } = useTabHeaderFocus();
|
||||
const maxWidth = Math.max(1, columns - 10);
|
||||
const visibleCount = Math.max(1, Math.floor((maxHeight - 10) / 2));
|
||||
|
||||
const options = useMemo(() => {
|
||||
// Custom commands can appear more than once (e.g. same name at user and
|
||||
// project scope). Dedupe by name to avoid React key collisions in Select.
|
||||
const seen = new Set<string>()
|
||||
const seen = new Set<string>();
|
||||
return commands
|
||||
.filter(cmd => {
|
||||
if (seen.has(cmd.name)) return false
|
||||
seen.add(cmd.name)
|
||||
return true
|
||||
if (seen.has(cmd.name)) return false;
|
||||
seen.add(cmd.name);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(cmd => ({
|
||||
label: `/${cmd.name}`,
|
||||
value: cmd.name,
|
||||
description: truncate(formatDescriptionWithSource(cmd), maxWidth, true),
|
||||
}))
|
||||
}, [commands, maxWidth])
|
||||
}));
|
||||
}, [commands, maxWidth]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingY={1}>
|
||||
@@ -66,5 +59,5 @@ export function Commands({
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import * as React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { PromptInputHelpMenu } from '../PromptInput/PromptInputHelpMenu.js'
|
||||
import * as React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { PromptInputHelpMenu } from '../PromptInput/PromptInputHelpMenu.js';
|
||||
|
||||
export function General(): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column" paddingY={1} gap={1}>
|
||||
<Box>
|
||||
<Text>
|
||||
Claude understands your codebase, makes edits with your permission,
|
||||
and executes commands — right from your terminal.
|
||||
Claude understands your codebase, makes edits with your permission, and executes commands — right from your
|
||||
terminal.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
@@ -18,5 +18,5 @@ export function General(): React.ReactNode {
|
||||
<PromptInputHelpMenu gap={2} fixedWidth={true} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,66 +1,55 @@
|
||||
import * as React from 'react'
|
||||
import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js'
|
||||
import { useShortcutDisplay } from 'src/keybindings/useShortcutDisplay.js'
|
||||
import * as React from 'react';
|
||||
import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js';
|
||||
import { useShortcutDisplay } from 'src/keybindings/useShortcutDisplay.js';
|
||||
import {
|
||||
builtInCommandNames,
|
||||
type Command,
|
||||
type CommandResultDisplay,
|
||||
INTERNAL_ONLY_COMMANDS,
|
||||
} from '../../commands.js'
|
||||
import { useIsInsideModal } from '../../context/modalContext.js'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { Box, Link, Text, Tab, Tabs, Pane } from '@anthropic/ink'
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js'
|
||||
import { Commands } from './Commands.js'
|
||||
import { General } from './General.js'
|
||||
} from '../../commands.js';
|
||||
import { useIsInsideModal } from '../../context/modalContext.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, Link, Text, Tab, Tabs, Pane } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
import { Commands } from './Commands.js';
|
||||
import { General } from './General.js';
|
||||
|
||||
type Props = {
|
||||
onClose: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
commands: Command[]
|
||||
}
|
||||
onClose: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
commands: Command[];
|
||||
};
|
||||
|
||||
export function HelpV2({ onClose, commands }: Props): React.ReactNode {
|
||||
const { rows, columns } = useTerminalSize()
|
||||
const maxHeight = Math.floor(rows / 2)
|
||||
const { rows, columns } = useTerminalSize();
|
||||
const maxHeight = Math.floor(rows / 2);
|
||||
// Inside the modal slot, FullscreenLayout already caps height and Pane/Tabs
|
||||
// use flexShrink=0 (see #23592) — our own height= constraint would clip the
|
||||
// footer since Tabs won't shrink to fit. Let the modal slot handle sizing.
|
||||
const insideModal = useIsInsideModal()
|
||||
const insideModal = useIsInsideModal();
|
||||
|
||||
const close = () => onClose('Help dialog dismissed', { display: 'system' })
|
||||
useKeybinding('help:dismiss', close, { context: 'Help' })
|
||||
const exitState = useExitOnCtrlCDWithKeybindings(close)
|
||||
const dismissShortcut = useShortcutDisplay('help:dismiss', 'Help', 'esc')
|
||||
const close = () => onClose('Help dialog dismissed', { display: 'system' });
|
||||
useKeybinding('help:dismiss', close, { context: 'Help' });
|
||||
const exitState = useExitOnCtrlCDWithKeybindings(close);
|
||||
const dismissShortcut = useShortcutDisplay('help:dismiss', 'Help', 'esc');
|
||||
|
||||
const builtinNames = builtInCommandNames()
|
||||
let builtinCommands = commands.filter(
|
||||
cmd => builtinNames.has(cmd.name) && !cmd.isHidden,
|
||||
)
|
||||
let antOnlyCommands: Command[] = []
|
||||
const builtinNames = builtInCommandNames();
|
||||
let builtinCommands = commands.filter(cmd => builtinNames.has(cmd.name) && !cmd.isHidden);
|
||||
let antOnlyCommands: Command[] = [];
|
||||
|
||||
// We have to do this in an `if` to help treeshaking
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
const internalOnlyNames = new Set(INTERNAL_ONLY_COMMANDS.map(_ => _.name))
|
||||
builtinCommands = builtinCommands.filter(
|
||||
cmd => !internalOnlyNames.has(cmd.name),
|
||||
)
|
||||
antOnlyCommands = commands.filter(
|
||||
cmd => internalOnlyNames.has(cmd.name) && !cmd.isHidden,
|
||||
)
|
||||
const internalOnlyNames = new Set(INTERNAL_ONLY_COMMANDS.map(_ => _.name));
|
||||
builtinCommands = builtinCommands.filter(cmd => !internalOnlyNames.has(cmd.name));
|
||||
antOnlyCommands = commands.filter(cmd => internalOnlyNames.has(cmd.name) && !cmd.isHidden);
|
||||
}
|
||||
|
||||
const customCommands = commands.filter(
|
||||
cmd => !builtinNames.has(cmd.name) && !cmd.isHidden,
|
||||
)
|
||||
const customCommands = commands.filter(cmd => !builtinNames.has(cmd.name) && !cmd.isHidden);
|
||||
|
||||
const tabs = [
|
||||
<Tab key="general" title="general">
|
||||
<General />
|
||||
</Tab>,
|
||||
]
|
||||
];
|
||||
|
||||
tabs.push(
|
||||
<Tab key="commands" title="commands">
|
||||
@@ -72,7 +61,7 @@ export function HelpV2({ onClose, commands }: Props): React.ReactNode {
|
||||
onCancel={close}
|
||||
/>
|
||||
</Tab>,
|
||||
)
|
||||
);
|
||||
|
||||
tabs.push(
|
||||
<Tab key="custom" title="custom-commands">
|
||||
@@ -85,7 +74,7 @@ export function HelpV2({ onClose, commands }: Props): React.ReactNode {
|
||||
onCancel={close}
|
||||
/>
|
||||
</Tab>,
|
||||
)
|
||||
);
|
||||
|
||||
if (process.env.USER_TYPE === 'ant' && antOnlyCommands.length > 0) {
|
||||
tabs.push(
|
||||
@@ -98,18 +87,14 @@ export function HelpV2({ onClose, commands }: Props): React.ReactNode {
|
||||
onCancel={close}
|
||||
/>
|
||||
</Tab>,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height={insideModal ? undefined : maxHeight}>
|
||||
<Pane color="professionalBlue">
|
||||
<Tabs
|
||||
title={
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? '/help'
|
||||
: `Claude Code v${MACRO.VERSION}`
|
||||
}
|
||||
title={process.env.USER_TYPE === 'ant' ? '/help' : `Claude Code v${MACRO.VERSION}`}
|
||||
color="professionalBlue"
|
||||
defaultTab="general"
|
||||
>
|
||||
@@ -117,8 +102,7 @@ export function HelpV2({ onClose, commands }: Props): React.ReactNode {
|
||||
</Tabs>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
For more help:{' '}
|
||||
<Link url="https://code.claude.com/docs/en/overview" />
|
||||
For more help: <Link url="https://code.claude.com/docs/en/overview" />
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
@@ -132,5 +116,5 @@ export function HelpV2({ onClose, commands }: Props): React.ReactNode {
|
||||
</Box>
|
||||
</Pane>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import * as React from 'react'
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSettings } from '../hooks/useSettings.js'
|
||||
import { Ansi, Box, type DOMElement, measureElement, NoSelect, Text, useTheme } from '@anthropic/ink'
|
||||
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'
|
||||
import sliceAnsi from '../utils/sliceAnsi.js'
|
||||
import { countCharInString } from '../utils/stringUtils.js'
|
||||
import { HighlightedCodeFallback } from './HighlightedCode/Fallback.js'
|
||||
import { expectColorFile } from './StructuredDiff/colorDiff.js'
|
||||
import * as React from 'react';
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSettings } from '../hooks/useSettings.js';
|
||||
import { Ansi, Box, type DOMElement, measureElement, NoSelect, Text, useTheme } from '@anthropic/ink';
|
||||
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js';
|
||||
import sliceAnsi from '../utils/sliceAnsi.js';
|
||||
import { countCharInString } from '../utils/stringUtils.js';
|
||||
import { HighlightedCodeFallback } from './HighlightedCode/Fallback.js';
|
||||
import { expectColorFile } from './StructuredDiff/colorDiff.js';
|
||||
|
||||
type Props = {
|
||||
code: string
|
||||
filePath: string
|
||||
width?: number
|
||||
dim?: boolean
|
||||
}
|
||||
code: string;
|
||||
filePath: string;
|
||||
width?: number;
|
||||
dim?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_WIDTH = 80
|
||||
const DEFAULT_WIDTH = 80;
|
||||
|
||||
export const HighlightedCode = memo(function HighlightedCode({
|
||||
code,
|
||||
@@ -23,39 +23,38 @@ export const HighlightedCode = memo(function HighlightedCode({
|
||||
width,
|
||||
dim = false,
|
||||
}: Props): React.ReactElement {
|
||||
const ref = useRef<DOMElement>(null)
|
||||
const [measuredWidth, setMeasuredWidth] = useState(width || DEFAULT_WIDTH)
|
||||
const [theme] = useTheme()
|
||||
const settings = useSettings()
|
||||
const syntaxHighlightingDisabled =
|
||||
settings.syntaxHighlightingDisabled ?? false
|
||||
const ref = useRef<DOMElement>(null);
|
||||
const [measuredWidth, setMeasuredWidth] = useState(width || DEFAULT_WIDTH);
|
||||
const [theme] = useTheme();
|
||||
const settings = useSettings();
|
||||
const syntaxHighlightingDisabled = settings.syntaxHighlightingDisabled ?? false;
|
||||
|
||||
const colorFile = useMemo(() => {
|
||||
if (syntaxHighlightingDisabled) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
const ColorFile = expectColorFile()
|
||||
const ColorFile = expectColorFile();
|
||||
if (!ColorFile) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
return new ColorFile(code, filePath)
|
||||
}, [code, filePath, syntaxHighlightingDisabled])
|
||||
return new ColorFile(code, filePath);
|
||||
}, [code, filePath, syntaxHighlightingDisabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!width && ref.current) {
|
||||
const { width: elementWidth } = measureElement(ref.current)
|
||||
const { width: elementWidth } = measureElement(ref.current);
|
||||
if (elementWidth > 0) {
|
||||
setMeasuredWidth(elementWidth - 2)
|
||||
setMeasuredWidth(elementWidth - 2);
|
||||
}
|
||||
}
|
||||
}, [width])
|
||||
}, [width]);
|
||||
|
||||
const lines = useMemo(() => {
|
||||
if (colorFile === null) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
return colorFile.render(theme, measuredWidth, dim)
|
||||
}, [colorFile, theme, measuredWidth, dim])
|
||||
return colorFile.render(theme, measuredWidth, dim);
|
||||
}, [colorFile, theme, measuredWidth, dim]);
|
||||
|
||||
// Gutter width matches ColorFile's layout in lib.rs: space + right-aligned
|
||||
// line number (max_digits = lineCount.toString().length) + space. No marker
|
||||
@@ -64,10 +63,10 @@ export const HighlightedCode = memo(function HighlightedCode({
|
||||
// (~4× DOM nodes + sliceAnsi cost); non-fullscreen uses terminal-native
|
||||
// selection where noSelect is meaningless.
|
||||
const gutterWidth = useMemo(() => {
|
||||
if (!isFullscreenEnvEnabled()) return 0
|
||||
const lineCount = countCharInString(code, '\n') + 1
|
||||
return lineCount.toString().length + 2
|
||||
}, [code])
|
||||
if (!isFullscreenEnvEnabled()) return 0;
|
||||
const lineCount = countCharInString(code, '\n') + 1;
|
||||
return lineCount.toString().length + 2;
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<Box ref={ref}>
|
||||
@@ -84,26 +83,15 @@ export const HighlightedCode = memo(function HighlightedCode({
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<HighlightedCodeFallback
|
||||
code={code}
|
||||
filePath={filePath}
|
||||
dim={dim}
|
||||
skipColoring={syntaxHighlightingDisabled}
|
||||
/>
|
||||
<HighlightedCodeFallback code={code} filePath={filePath} dim={dim} skipColoring={syntaxHighlightingDisabled} />
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
function CodeLine({
|
||||
line,
|
||||
gutterWidth,
|
||||
}: {
|
||||
line: string
|
||||
gutterWidth: number
|
||||
}): React.ReactNode {
|
||||
const gutter = sliceAnsi(line, 0, gutterWidth)
|
||||
const content = sliceAnsi(line, gutterWidth)
|
||||
function CodeLine({ line, gutterWidth }: { line: string; gutterWidth: number }): React.ReactNode {
|
||||
const gutter = sliceAnsi(line, 0, gutterWidth);
|
||||
const content = sliceAnsi(line, gutterWidth);
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<NoSelect fromLeftEdge>
|
||||
@@ -115,5 +103,5 @@ function CodeLine({
|
||||
<Ansi>{content}</Ansi>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import { extname } from 'path'
|
||||
import React, { Suspense, use, useMemo } from 'react'
|
||||
import { Ansi, Text } from '@anthropic/ink'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { getCliHighlightPromise } from '../../utils/cliHighlight.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { convertLeadingTabsToSpaces } from '../../utils/file.js'
|
||||
import { hashPair } from '../../utils/hash.js'
|
||||
import { extname } from 'path';
|
||||
import React, { Suspense, use, useMemo } from 'react';
|
||||
import { Ansi, Text } from '@anthropic/ink';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { getCliHighlightPromise } from '../../utils/cliHighlight.js';
|
||||
import { logForDebugging } from '../../utils/debug.js';
|
||||
import { convertLeadingTabsToSpaces } from '../../utils/file.js';
|
||||
import { hashPair } from '../../utils/hash.js';
|
||||
|
||||
type Props = {
|
||||
code: string
|
||||
filePath: string
|
||||
dim?: boolean
|
||||
skipColoring?: boolean
|
||||
}
|
||||
code: string;
|
||||
filePath: string;
|
||||
dim?: boolean;
|
||||
skipColoring?: boolean;
|
||||
};
|
||||
|
||||
// Module-level highlight cache — hl.highlight() is the hot cost on virtual-
|
||||
// scroll remounts. useMemo doesn't survive unmount→remount. Keyed by hash
|
||||
// of code+language to avoid retaining full source strings (#24180 RSS fix).
|
||||
const hlCache = new LRUCache<string, string>({ max: 500 })
|
||||
const hlCache = new LRUCache<string, string>({ max: 500 });
|
||||
function cachedHighlight(
|
||||
hl: NonNullable<Awaited<ReturnType<typeof getCliHighlightPromise>>>,
|
||||
code: string,
|
||||
language: string,
|
||||
): string {
|
||||
const key = hashPair(language, code)
|
||||
const hit = hlCache.get(key)
|
||||
if (hit !== undefined) return hit
|
||||
const out = hl.highlight(code, { language })
|
||||
hlCache.set(key, out)
|
||||
return out
|
||||
const key = hashPair(language, code);
|
||||
const hit = hlCache.get(key);
|
||||
if (hit !== undefined) return hit;
|
||||
const out = hl.highlight(code, { language });
|
||||
hlCache.set(key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function HighlightedCodeFallback({
|
||||
@@ -37,55 +37,45 @@ export function HighlightedCodeFallback({
|
||||
dim = false,
|
||||
skipColoring = false,
|
||||
}: Props): React.ReactElement {
|
||||
const codeWithSpaces = convertLeadingTabsToSpaces(code)
|
||||
const codeWithSpaces = convertLeadingTabsToSpaces(code);
|
||||
if (skipColoring) {
|
||||
return (
|
||||
<Text dimColor={dim}>
|
||||
<Ansi>{codeWithSpaces}</Ansi>
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
const language = extname(filePath).slice(1)
|
||||
const language = extname(filePath).slice(1);
|
||||
return (
|
||||
<Text dimColor={dim}>
|
||||
<Suspense fallback={<Ansi>{codeWithSpaces}</Ansi>}>
|
||||
<Highlighted codeWithSpaces={codeWithSpaces} language={language} />
|
||||
</Suspense>
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Highlighted({
|
||||
codeWithSpaces,
|
||||
language,
|
||||
}: {
|
||||
codeWithSpaces: string
|
||||
language: string
|
||||
}): React.ReactElement {
|
||||
const hl = use(getCliHighlightPromise())
|
||||
function Highlighted({ codeWithSpaces, language }: { codeWithSpaces: string; language: string }): React.ReactElement {
|
||||
const hl = use(getCliHighlightPromise());
|
||||
const out = useMemo(() => {
|
||||
if (!hl) return codeWithSpaces
|
||||
let highlightLang = 'markdown'
|
||||
if (!hl) return codeWithSpaces;
|
||||
let highlightLang = 'markdown';
|
||||
if (language) {
|
||||
if (hl.supportsLanguage(language)) {
|
||||
highlightLang = language
|
||||
highlightLang = language;
|
||||
} else {
|
||||
logForDebugging(
|
||||
`Language not supported while highlighting code, falling back to markdown: ${language}`,
|
||||
)
|
||||
logForDebugging(`Language not supported while highlighting code, falling back to markdown: ${language}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return cachedHighlight(hl, codeWithSpaces, highlightLang)
|
||||
return cachedHighlight(hl, codeWithSpaces, highlightLang);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('Unknown language')) {
|
||||
logForDebugging(
|
||||
`Language not supported while highlighting code, falling back to markdown: ${e}`,
|
||||
)
|
||||
return cachedHighlight(hl, codeWithSpaces, 'markdown')
|
||||
logForDebugging(`Language not supported while highlighting code, falling back to markdown: ${e}`);
|
||||
return cachedHighlight(hl, codeWithSpaces, 'markdown');
|
||||
}
|
||||
return codeWithSpaces
|
||||
return codeWithSpaces;
|
||||
}
|
||||
}, [codeWithSpaces, language, hl])
|
||||
return <Ansi>{out}</Ansi>
|
||||
}, [codeWithSpaces, language, hl]);
|
||||
return <Ansi>{out}</Ansi>;
|
||||
}
|
||||
|
||||
@@ -1,97 +1,86 @@
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRegisterOverlay } from '../context/overlayContext.js'
|
||||
import {
|
||||
getTimestampedHistory,
|
||||
type TimestampedHistoryEntry,
|
||||
} from '../history.js'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { Box, Text, stringWidth, wrapAnsi } from '@anthropic/ink'
|
||||
import { logEvent } from '../services/analytics/index.js'
|
||||
import type { HistoryEntry } from '../utils/config.js'
|
||||
import { formatRelativeTimeAgo, truncateToWidth } from '../utils/format.js'
|
||||
import { FuzzyPicker } from '@anthropic/ink'
|
||||
import * as React from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRegisterOverlay } from '../context/overlayContext.js';
|
||||
import { getTimestampedHistory, type TimestampedHistoryEntry } from '../history.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { Box, Text, stringWidth, wrapAnsi } from '@anthropic/ink';
|
||||
import { logEvent } from '../services/analytics/index.js';
|
||||
import type { HistoryEntry } from '../utils/config.js';
|
||||
import { formatRelativeTimeAgo, truncateToWidth } from '../utils/format.js';
|
||||
import { FuzzyPicker } from '@anthropic/ink';
|
||||
|
||||
type Props = {
|
||||
initialQuery?: string
|
||||
onSelect: (entry: HistoryEntry) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
initialQuery?: string;
|
||||
onSelect: (entry: HistoryEntry) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const PREVIEW_ROWS = 6
|
||||
const AGE_WIDTH = 8
|
||||
const PREVIEW_ROWS = 6;
|
||||
const AGE_WIDTH = 8;
|
||||
|
||||
type Item = {
|
||||
entry: TimestampedHistoryEntry
|
||||
display: string
|
||||
lower: string
|
||||
firstLine: string
|
||||
age: string
|
||||
}
|
||||
entry: TimestampedHistoryEntry;
|
||||
display: string;
|
||||
lower: string;
|
||||
firstLine: string;
|
||||
age: string;
|
||||
};
|
||||
|
||||
export function HistorySearchDialog({
|
||||
initialQuery,
|
||||
onSelect,
|
||||
onCancel,
|
||||
}: Props): React.ReactNode {
|
||||
useRegisterOverlay('history-search')
|
||||
const { columns } = useTerminalSize()
|
||||
export function HistorySearchDialog({ initialQuery, onSelect, onCancel }: Props): React.ReactNode {
|
||||
useRegisterOverlay('history-search');
|
||||
const { columns } = useTerminalSize();
|
||||
|
||||
const [items, setItems] = useState<Item[] | null>(null)
|
||||
const [query, setQuery] = useState(initialQuery ?? '')
|
||||
const [items, setItems] = useState<Item[] | null>(null);
|
||||
const [query, setQuery] = useState(initialQuery ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const reader = getTimestampedHistory()
|
||||
const loaded: Item[] = []
|
||||
const reader = getTimestampedHistory();
|
||||
const loaded: Item[] = [];
|
||||
for await (const entry of reader) {
|
||||
if (cancelled) {
|
||||
void reader.return(undefined)
|
||||
return
|
||||
void reader.return(undefined);
|
||||
return;
|
||||
}
|
||||
const display = entry.display
|
||||
const nl = display.indexOf('\n')
|
||||
const age = formatRelativeTimeAgo(new Date(entry.timestamp))
|
||||
const display = entry.display;
|
||||
const nl = display.indexOf('\n');
|
||||
const age = formatRelativeTimeAgo(new Date(entry.timestamp));
|
||||
loaded.push({
|
||||
entry,
|
||||
display,
|
||||
lower: display.toLowerCase(),
|
||||
firstLine: nl === -1 ? display : display.slice(0, nl),
|
||||
age: age + ' '.repeat(Math.max(0, AGE_WIDTH - stringWidth(age))),
|
||||
})
|
||||
});
|
||||
}
|
||||
if (!cancelled) setItems(loaded)
|
||||
})()
|
||||
if (!cancelled) setItems(loaded);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!items) return []
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return items
|
||||
const exact: Item[] = []
|
||||
const fuzzy: Item[] = []
|
||||
if (!items) return [];
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
const exact: Item[] = [];
|
||||
const fuzzy: Item[] = [];
|
||||
for (const item of items) {
|
||||
if (item.lower.includes(q)) {
|
||||
exact.push(item)
|
||||
exact.push(item);
|
||||
} else if (isSubsequence(item.lower, q)) {
|
||||
fuzzy.push(item)
|
||||
fuzzy.push(item);
|
||||
}
|
||||
}
|
||||
return exact.concat(fuzzy)
|
||||
}, [items, query])
|
||||
return exact.concat(fuzzy);
|
||||
}, [items, query]);
|
||||
|
||||
const previewOnRight = columns >= 100
|
||||
const listWidth = previewOnRight
|
||||
? Math.floor((columns - 6) * 0.5)
|
||||
: columns - 6
|
||||
const rowWidth = Math.max(20, listWidth - AGE_WIDTH - 1)
|
||||
const previewWidth = previewOnRight
|
||||
? Math.max(20, columns - listWidth - 12)
|
||||
: Math.max(20, columns - 10)
|
||||
const previewOnRight = columns >= 100;
|
||||
const listWidth = previewOnRight ? Math.floor((columns - 6) * 0.5) : columns - 6;
|
||||
const rowWidth = Math.max(20, listWidth - AGE_WIDTH - 1);
|
||||
const previewWidth = previewOnRight ? Math.max(20, columns - listWidth - 12) : Math.max(20, columns - 10);
|
||||
|
||||
return (
|
||||
<FuzzyPicker
|
||||
@@ -105,47 +94,29 @@ export function HistorySearchDialog({
|
||||
logEvent('tengu_history_picker_select', {
|
||||
result_count: filtered.length,
|
||||
query_length: query.length,
|
||||
})
|
||||
void item.entry.resolve().then(onSelect)
|
||||
});
|
||||
void item.entry.resolve().then(onSelect);
|
||||
}}
|
||||
onCancel={onCancel}
|
||||
emptyMessage={q =>
|
||||
items === null
|
||||
? 'Loading…'
|
||||
: q
|
||||
? 'No matching prompts'
|
||||
: 'No history yet'
|
||||
}
|
||||
emptyMessage={q => (items === null ? 'Loading…' : q ? 'No matching prompts' : 'No history yet')}
|
||||
selectAction="use"
|
||||
direction="up"
|
||||
previewPosition={previewOnRight ? 'right' : 'bottom'}
|
||||
renderItem={(item, isFocused) => (
|
||||
<Text>
|
||||
<Text dimColor>{item.age}</Text>
|
||||
<Text color={isFocused ? 'suggestion' : undefined}>
|
||||
{' '}
|
||||
{truncateToWidth(item.firstLine, rowWidth)}
|
||||
</Text>
|
||||
<Text color={isFocused ? 'suggestion' : undefined}> {truncateToWidth(item.firstLine, rowWidth)}</Text>
|
||||
</Text>
|
||||
)}
|
||||
renderPreview={item => {
|
||||
const wrapped = wrapAnsi(item.display, previewWidth, { hard: true })
|
||||
.split('\n')
|
||||
.filter(l => l.trim() !== '')
|
||||
const overflow = wrapped.length > PREVIEW_ROWS
|
||||
const shown = wrapped.slice(
|
||||
0,
|
||||
overflow ? PREVIEW_ROWS - 1 : PREVIEW_ROWS,
|
||||
)
|
||||
const more = wrapped.length - shown.length
|
||||
.filter(l => l.trim() !== '');
|
||||
const overflow = wrapped.length > PREVIEW_ROWS;
|
||||
const shown = wrapped.slice(0, overflow ? PREVIEW_ROWS - 1 : PREVIEW_ROWS);
|
||||
const more = wrapped.length - shown.length;
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderDimColor
|
||||
paddingX={1}
|
||||
height={PREVIEW_ROWS + 2}
|
||||
>
|
||||
<Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} height={PREVIEW_ROWS + 2}>
|
||||
{shown.map((row, i) => (
|
||||
<Text key={i} dimColor>
|
||||
{row}
|
||||
@@ -153,16 +124,16 @@ export function HistorySearchDialog({
|
||||
))}
|
||||
{more > 0 && <Text dimColor>{`… +${more} more lines`}</Text>}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isSubsequence(text: string, query: string): boolean {
|
||||
let j = 0
|
||||
let j = 0;
|
||||
for (let i = 0; i < text.length && j < query.length; i++) {
|
||||
if (text[i] === query[j]) j++
|
||||
if (text[i] === query[j]) j++;
|
||||
}
|
||||
return j === query.length
|
||||
return j === query.length;
|
||||
}
|
||||
|
||||
@@ -1,91 +1,77 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { Text, Dialog } from '@anthropic/ink'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
|
||||
import { isSupportedTerminal } from '../utils/ide.js'
|
||||
import { Select } from './CustomSelect/index.js'
|
||||
import React, { useCallback } from 'react';
|
||||
import { Text, Dialog } from '@anthropic/ink';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js';
|
||||
import { isSupportedTerminal } from '../utils/ide.js';
|
||||
import { Select } from './CustomSelect/index.js';
|
||||
|
||||
type IdeAutoConnectDialogProps = {
|
||||
onComplete: () => void
|
||||
}
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
export function IdeAutoConnectDialog({
|
||||
onComplete,
|
||||
}: IdeAutoConnectDialogProps): React.ReactNode {
|
||||
export function IdeAutoConnectDialog({ onComplete }: IdeAutoConnectDialogProps): React.ReactNode {
|
||||
const handleSelect = useCallback(
|
||||
async (value: string) => {
|
||||
const autoConnect = value === 'yes'
|
||||
const autoConnect = value === 'yes';
|
||||
|
||||
// Save the preference and mark dialog as shown
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
autoConnectIde: autoConnect,
|
||||
hasIdeAutoConnectDialogBeenShown: true,
|
||||
}))
|
||||
}));
|
||||
|
||||
onComplete()
|
||||
onComplete();
|
||||
},
|
||||
[onComplete],
|
||||
)
|
||||
);
|
||||
|
||||
const options = [
|
||||
{ label: 'Yes', value: 'yes' },
|
||||
{ label: 'No', value: 'no' },
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Do you wish to enable auto-connect to IDE?"
|
||||
color="ide"
|
||||
onCancel={onComplete}
|
||||
>
|
||||
<Dialog title="Do you wish to enable auto-connect to IDE?" color="ide" onCancel={onComplete}>
|
||||
<Select options={options} onChange={handleSelect} defaultValue={'yes'} />
|
||||
<Text dimColor>
|
||||
You can also configure this in /config or with the --ide flag
|
||||
</Text>
|
||||
<Text dimColor>You can also configure this in /config or with the --ide flag</Text>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowAutoConnectDialog(): boolean {
|
||||
const config = getGlobalConfig()
|
||||
return (
|
||||
!isSupportedTerminal() &&
|
||||
config.autoConnectIde !== true &&
|
||||
config.hasIdeAutoConnectDialogBeenShown !== true
|
||||
)
|
||||
const config = getGlobalConfig();
|
||||
return !isSupportedTerminal() && config.autoConnectIde !== true && config.hasIdeAutoConnectDialogBeenShown !== true;
|
||||
}
|
||||
|
||||
type IdeDisableAutoConnectDialogProps = {
|
||||
onComplete: (disableAutoConnect: boolean) => void
|
||||
}
|
||||
onComplete: (disableAutoConnect: boolean) => void;
|
||||
};
|
||||
|
||||
export function IdeDisableAutoConnectDialog({
|
||||
onComplete,
|
||||
}: IdeDisableAutoConnectDialogProps): React.ReactNode {
|
||||
export function IdeDisableAutoConnectDialog({ onComplete }: IdeDisableAutoConnectDialogProps): React.ReactNode {
|
||||
const handleSelect = useCallback(
|
||||
(value: string) => {
|
||||
const disableAutoConnect = value === 'yes'
|
||||
const disableAutoConnect = value === 'yes';
|
||||
|
||||
if (disableAutoConnect) {
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
autoConnectIde: false,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
onComplete(disableAutoConnect)
|
||||
onComplete(disableAutoConnect);
|
||||
},
|
||||
[onComplete],
|
||||
)
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onComplete(false)
|
||||
}, [onComplete])
|
||||
onComplete(false);
|
||||
}, [onComplete]);
|
||||
|
||||
const options = [
|
||||
{ label: 'No', value: 'no' },
|
||||
{ label: 'Yes', value: 'yes' },
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -96,10 +82,10 @@ export function IdeDisableAutoConnectDialog({
|
||||
>
|
||||
<Select options={options} onChange={handleSelect} defaultValue={'no'} />
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowDisableAutoConnectDialog(): boolean {
|
||||
const config = getGlobalConfig()
|
||||
return !isSupportedTerminal() && config.autoConnectIde === true
|
||||
const config = getGlobalConfig();
|
||||
return !isSupportedTerminal() && config.autoConnectIde === true;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
import React from 'react'
|
||||
import { envDynamic } from 'src/utils/envDynamic.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useKeybindings } from '../keybindings/useKeybinding.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
|
||||
import { env } from '../utils/env.js'
|
||||
import React from 'react';
|
||||
import { envDynamic } from 'src/utils/envDynamic.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybindings } from '../keybindings/useKeybinding.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js';
|
||||
import { env } from '../utils/env.js';
|
||||
import {
|
||||
getTerminalIdeType,
|
||||
type IDEExtensionInstallationStatus,
|
||||
isJetBrainsIde,
|
||||
toIDEDisplayName,
|
||||
} from '../utils/ide.js'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
} from '../utils/ide.js';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
|
||||
interface Props {
|
||||
onDone: () => void
|
||||
installationStatus: IDEExtensionInstallationStatus | null
|
||||
onDone: () => void;
|
||||
installationStatus: IDEExtensionInstallationStatus | null;
|
||||
}
|
||||
|
||||
export function IdeOnboardingDialog({
|
||||
onDone,
|
||||
installationStatus,
|
||||
}: Props): React.ReactNode {
|
||||
markDialogAsShown()
|
||||
export function IdeOnboardingDialog({ onDone, installationStatus }: Props): React.ReactNode {
|
||||
markDialogAsShown();
|
||||
|
||||
// Handle Enter/Escape to dismiss
|
||||
useKeybindings(
|
||||
@@ -30,16 +27,15 @@ export function IdeOnboardingDialog({
|
||||
'confirm:no': onDone,
|
||||
},
|
||||
{ context: 'Confirmation' },
|
||||
)
|
||||
);
|
||||
|
||||
const ideType = installationStatus?.ideType ?? getTerminalIdeType()
|
||||
const isJetBrains = isJetBrainsIde(ideType)
|
||||
const ideType = installationStatus?.ideType ?? getTerminalIdeType();
|
||||
const isJetBrains = isJetBrainsIde(ideType);
|
||||
|
||||
const ideName = toIDEDisplayName(ideType)
|
||||
const installedVersion = installationStatus?.installedVersion
|
||||
const pluginOrExtension = isJetBrains ? 'plugin' : 'extension'
|
||||
const mentionShortcut =
|
||||
env.platform === 'darwin' ? 'Cmd+Option+K' : 'Ctrl+Alt+K'
|
||||
const ideName = toIDEDisplayName(ideType);
|
||||
const installedVersion = installationStatus?.installedVersion;
|
||||
const pluginOrExtension = isJetBrains ? 'plugin' : 'extension';
|
||||
const mentionShortcut = env.platform === 'darwin' ? 'Cmd+Option+K' : 'Ctrl+Alt+K';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -50,23 +46,18 @@ export function IdeOnboardingDialog({
|
||||
<Text>Welcome to Claude Code for {ideName}</Text>
|
||||
</>
|
||||
}
|
||||
subtitle={
|
||||
installedVersion
|
||||
? `installed ${pluginOrExtension} v${installedVersion}`
|
||||
: undefined
|
||||
}
|
||||
subtitle={installedVersion ? `installed ${pluginOrExtension} v${installedVersion}` : undefined}
|
||||
color="ide"
|
||||
onCancel={onDone}
|
||||
hideInputGuide
|
||||
>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
• Claude has context of <Text color="suggestion">⧉ open files</Text>{' '}
|
||||
and <Text color="suggestion">⧉ selected lines</Text>
|
||||
• Claude has context of <Text color="suggestion">⧉ open files</Text> and{' '}
|
||||
<Text color="suggestion">⧉ selected lines</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
• Review Claude Code's changes{' '}
|
||||
<Text color="diffAddedWord">+11</Text>{' '}
|
||||
• Review Claude Code's changes <Text color="diffAddedWord">+11</Text>{' '}
|
||||
<Text color="diffRemovedWord">-22</Text> in the comfort of your IDE
|
||||
</Text>
|
||||
<Text>
|
||||
@@ -84,25 +75,25 @@ export function IdeOnboardingDialog({
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasIdeOnboardingDialogBeenShown(): boolean {
|
||||
const config = getGlobalConfig()
|
||||
const terminal = envDynamic.terminal || 'unknown'
|
||||
return config.hasIdeOnboardingBeenShown?.[terminal] === true
|
||||
const config = getGlobalConfig();
|
||||
const terminal = envDynamic.terminal || 'unknown';
|
||||
return config.hasIdeOnboardingBeenShown?.[terminal] === true;
|
||||
}
|
||||
|
||||
function markDialogAsShown(): void {
|
||||
if (hasIdeOnboardingDialogBeenShown()) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const terminal = envDynamic.terminal || 'unknown'
|
||||
const terminal = envDynamic.terminal || 'unknown';
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
hasIdeOnboardingBeenShown: {
|
||||
...current.hasIdeOnboardingBeenShown,
|
||||
[terminal]: true,
|
||||
},
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,38 +1,32 @@
|
||||
import { basename } from 'path'
|
||||
import * as React from 'react'
|
||||
import { useIdeConnectionStatus } from '../hooks/useIdeConnectionStatus.js'
|
||||
import type { IDESelection } from '../hooks/useIdeSelection.js'
|
||||
import { Text } from '@anthropic/ink'
|
||||
import type { MCPServerConnection } from '../services/mcp/types.js'
|
||||
import { basename } from 'path';
|
||||
import * as React from 'react';
|
||||
import { useIdeConnectionStatus } from '../hooks/useIdeConnectionStatus.js';
|
||||
import type { IDESelection } from '../hooks/useIdeSelection.js';
|
||||
import { Text } from '@anthropic/ink';
|
||||
import type { MCPServerConnection } from '../services/mcp/types.js';
|
||||
|
||||
type IdeStatusIndicatorProps = {
|
||||
ideSelection: IDESelection | undefined
|
||||
mcpClients?: MCPServerConnection[]
|
||||
}
|
||||
ideSelection: IDESelection | undefined;
|
||||
mcpClients?: MCPServerConnection[];
|
||||
};
|
||||
|
||||
export function IdeStatusIndicator({
|
||||
ideSelection,
|
||||
mcpClients,
|
||||
}: IdeStatusIndicatorProps): React.ReactNode {
|
||||
const { status: ideStatus } = useIdeConnectionStatus(mcpClients)
|
||||
export function IdeStatusIndicator({ ideSelection, mcpClients }: IdeStatusIndicatorProps): React.ReactNode {
|
||||
const { status: ideStatus } = useIdeConnectionStatus(mcpClients);
|
||||
|
||||
// Check if we should show the IDE selection indicator
|
||||
const shouldShowIdeSelection =
|
||||
ideStatus === 'connected' &&
|
||||
(ideSelection?.filePath ||
|
||||
(ideSelection?.text && ideSelection.lineCount > 0))
|
||||
ideStatus === 'connected' && (ideSelection?.filePath || (ideSelection?.text && ideSelection.lineCount > 0));
|
||||
|
||||
if (ideStatus === null || !shouldShowIdeSelection || !ideSelection) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ideSelection.text && ideSelection.lineCount > 0) {
|
||||
return (
|
||||
<Text color="ide" key="selection-indicator" wrap="truncate">
|
||||
⧉ {ideSelection.lineCount}{' '}
|
||||
{ideSelection.lineCount === 1 ? 'line' : 'lines'} selected
|
||||
⧉ {ideSelection.lineCount} {ideSelection.lineCount === 1 ? 'line' : 'lines'} selected
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (ideSelection.filePath) {
|
||||
@@ -40,6 +34,6 @@ export function IdeStatusIndicator({
|
||||
<Text color="ide" key="selection-indicator" wrap="truncate">
|
||||
⧉ In {basename(ideSelection.filePath)}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { formatTokens } from '../utils/format.js'
|
||||
import { Select } from './CustomSelect/index.js'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { formatTokens } from '../utils/format.js';
|
||||
import { Select } from './CustomSelect/index.js';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
|
||||
type IdleReturnAction = 'continue' | 'clear' | 'dismiss' | 'never'
|
||||
type IdleReturnAction = 'continue' | 'clear' | 'dismiss' | 'never';
|
||||
|
||||
type Props = {
|
||||
idleMinutes: number
|
||||
totalInputTokens: number
|
||||
onDone: (action: IdleReturnAction) => void
|
||||
}
|
||||
idleMinutes: number;
|
||||
totalInputTokens: number;
|
||||
onDone: (action: IdleReturnAction) => void;
|
||||
};
|
||||
|
||||
export function IdleReturnDialog({
|
||||
idleMinutes,
|
||||
totalInputTokens,
|
||||
onDone,
|
||||
}: Props): React.ReactNode {
|
||||
const formattedIdle = formatIdleDuration(idleMinutes)
|
||||
const formattedTokens = formatTokens(totalInputTokens)
|
||||
export function IdleReturnDialog({ idleMinutes, totalInputTokens, onDone }: Props): React.ReactNode {
|
||||
const formattedIdle = formatIdleDuration(idleMinutes);
|
||||
const formattedTokens = formatTokens(totalInputTokens);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -26,9 +22,7 @@ export function IdleReturnDialog({
|
||||
onCancel={() => onDone('dismiss')}
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
If this is a new task, clearing context will save usage and be faster.
|
||||
</Text>
|
||||
<Text>If this is a new task, clearing context will save usage and be faster.</Text>
|
||||
</Box>
|
||||
<Select
|
||||
options={[
|
||||
@@ -48,20 +42,20 @@ export function IdleReturnDialog({
|
||||
onChange={(value: IdleReturnAction) => onDone(value)}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function formatIdleDuration(minutes: number): string {
|
||||
if (minutes < 1) {
|
||||
return '< 1m'
|
||||
return '< 1m';
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `${Math.floor(minutes)}m`
|
||||
return `${Math.floor(minutes)}m`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainingMinutes = Math.floor(minutes % 60)
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = Math.floor(minutes % 60);
|
||||
if (remainingMinutes === 0) {
|
||||
return `${hours}h`
|
||||
return `${hours}h`;
|
||||
}
|
||||
return `${hours}h ${remainingMinutes}m`
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react'
|
||||
import { Text } from '@anthropic/ink'
|
||||
import * as React from 'react';
|
||||
import { Text } from '@anthropic/ink';
|
||||
|
||||
export function InterruptedByUser(): React.ReactNode {
|
||||
return (
|
||||
@@ -11,5 +11,5 @@ export function InterruptedByUser(): React.ReactNode {
|
||||
<Text dimColor>· What should Claude do instead?</Text>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
import React from 'react'
|
||||
import { Box, Dialog, wrappedRender as render, Text } from '@anthropic/ink'
|
||||
import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'
|
||||
import { AppStateProvider } from '../state/AppState.js'
|
||||
import type { ConfigParseError } from '../utils/errors.js'
|
||||
import { getBaseRenderOptions } from '../utils/renderOptions.js'
|
||||
import {
|
||||
jsonStringify,
|
||||
writeFileSync_DEPRECATED,
|
||||
} from '../utils/slowOperations.js'
|
||||
import type { ThemeName } from '../utils/theme.js'
|
||||
import { Select } from './CustomSelect/index.js'
|
||||
import React from 'react';
|
||||
import { Box, Dialog, wrappedRender as render, Text } from '@anthropic/ink';
|
||||
import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js';
|
||||
import { AppStateProvider } from '../state/AppState.js';
|
||||
import type { ConfigParseError } from '../utils/errors.js';
|
||||
import { getBaseRenderOptions } from '../utils/renderOptions.js';
|
||||
import { jsonStringify, writeFileSync_DEPRECATED } from '../utils/slowOperations.js';
|
||||
import type { ThemeName } from '../utils/theme.js';
|
||||
import { Select } from './CustomSelect/index.js';
|
||||
|
||||
interface InvalidConfigHandlerProps {
|
||||
error: ConfigParseError
|
||||
error: ConfigParseError;
|
||||
}
|
||||
|
||||
interface InvalidConfigDialogProps {
|
||||
filePath: string
|
||||
errorDescription: string
|
||||
onExit: () => void
|
||||
onReset: () => void
|
||||
filePath: string;
|
||||
errorDescription: string;
|
||||
onExit: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,18 +31,17 @@ function InvalidConfigDialog({
|
||||
// Handler for Select onChange
|
||||
const handleSelect = (value: string) => {
|
||||
if (value === 'exit') {
|
||||
onExit()
|
||||
onExit();
|
||||
} else {
|
||||
onReset()
|
||||
onReset();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog title="Configuration Error" color="error" onCancel={onExit}>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
The configuration file at <Text bold>{filePath}</Text> contains
|
||||
invalid JSON.
|
||||
The configuration file at <Text bold>{filePath}</Text> contains invalid JSON.
|
||||
</Text>
|
||||
<Text>{errorDescription}</Text>
|
||||
</Box>
|
||||
@@ -61,27 +57,25 @@ function InvalidConfigDialog({
|
||||
/>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe fallback theme name for error dialogs to avoid circular dependency.
|
||||
* Uses a hardcoded dark theme that doesn't require reading from config.
|
||||
*/
|
||||
const SAFE_ERROR_THEME_NAME: ThemeName = 'dark'
|
||||
const SAFE_ERROR_THEME_NAME: ThemeName = 'dark';
|
||||
|
||||
export async function showInvalidConfigDialog({
|
||||
error,
|
||||
}: InvalidConfigHandlerProps): Promise<void> {
|
||||
export async function showInvalidConfigDialog({ error }: InvalidConfigHandlerProps): Promise<void> {
|
||||
// Extend RenderOptions with theme property for this specific usage
|
||||
type SafeRenderOptions = Parameters<typeof render>[1] & { theme?: ThemeName }
|
||||
type SafeRenderOptions = Parameters<typeof render>[1] & { theme?: ThemeName };
|
||||
|
||||
const renderOptions: SafeRenderOptions = {
|
||||
...getBaseRenderOptions(false),
|
||||
// IMPORTANT: Use hardcoded theme name to avoid circular dependency with getGlobalConfig()
|
||||
// This allows the error dialog to show even when config file has JSON syntax errors
|
||||
theme: SAFE_ERROR_THEME_NAME,
|
||||
}
|
||||
};
|
||||
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: render must be awaited inside executor
|
||||
await new Promise<void>(async resolve => {
|
||||
@@ -92,24 +86,23 @@ export async function showInvalidConfigDialog({
|
||||
filePath={error.filePath}
|
||||
errorDescription={error.message}
|
||||
onExit={() => {
|
||||
unmount()
|
||||
void resolve()
|
||||
process.exit(1)
|
||||
unmount();
|
||||
void resolve();
|
||||
process.exit(1);
|
||||
}}
|
||||
onReset={() => {
|
||||
writeFileSync_DEPRECATED(
|
||||
error.filePath,
|
||||
jsonStringify(error.defaultConfig, null, 2),
|
||||
{ flush: false, encoding: 'utf8' },
|
||||
)
|
||||
unmount()
|
||||
void resolve()
|
||||
process.exit(0)
|
||||
writeFileSync_DEPRECATED(error.filePath, jsonStringify(error.defaultConfig, null, 2), {
|
||||
flush: false,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
unmount();
|
||||
void resolve();
|
||||
process.exit(0);
|
||||
}}
|
||||
/>
|
||||
</KeybindingSetup>
|
||||
</AppStateProvider>,
|
||||
renderOptions,
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,38 +1,32 @@
|
||||
import React from 'react'
|
||||
import { Text, Dialog } from '@anthropic/ink'
|
||||
import type { ValidationError } from '../utils/settings/validation.js'
|
||||
import { Select } from './CustomSelect/index.js'
|
||||
import { ValidationErrorsList } from './ValidationErrorsList.js'
|
||||
import React from 'react';
|
||||
import { Text, Dialog } from '@anthropic/ink';
|
||||
import type { ValidationError } from '../utils/settings/validation.js';
|
||||
import { Select } from './CustomSelect/index.js';
|
||||
import { ValidationErrorsList } from './ValidationErrorsList.js';
|
||||
|
||||
type Props = {
|
||||
settingsErrors: ValidationError[]
|
||||
onContinue: () => void
|
||||
onExit: () => void
|
||||
}
|
||||
settingsErrors: ValidationError[];
|
||||
onContinue: () => void;
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dialog shown when settings files have validation errors.
|
||||
* User must choose to continue (skipping invalid files) or exit to fix them.
|
||||
*/
|
||||
export function InvalidSettingsDialog({
|
||||
settingsErrors,
|
||||
onContinue,
|
||||
onExit,
|
||||
}: Props): React.ReactNode {
|
||||
export function InvalidSettingsDialog({ settingsErrors, onContinue, onExit }: Props): React.ReactNode {
|
||||
function handleSelect(value: string): void {
|
||||
if (value === 'exit') {
|
||||
onExit()
|
||||
onExit();
|
||||
} else {
|
||||
onContinue()
|
||||
onContinue();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title="Settings Error" onCancel={onExit} color="warning">
|
||||
<ValidationErrorsList errors={settingsErrors} />
|
||||
<Text dimColor>
|
||||
Files with errors are skipped entirely, not just the invalid settings.
|
||||
</Text>
|
||||
<Text dimColor>Files with errors are skipped entirely, not just the invalid settings.</Text>
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'Exit and fix manually', value: 'exit' },
|
||||
@@ -44,5 +38,5 @@ export function InvalidSettingsDialog({
|
||||
onChange={handleSelect}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import {
|
||||
getCachedKeybindingWarnings,
|
||||
getKeybindingsPath,
|
||||
isKeybindingCustomizationEnabled,
|
||||
} from '../keybindings/loadUserBindings.js'
|
||||
} from '../keybindings/loadUserBindings.js';
|
||||
|
||||
/**
|
||||
* Displays keybinding validation warnings in the UI.
|
||||
@@ -16,17 +16,17 @@ import {
|
||||
export function KeybindingWarnings(): React.ReactNode {
|
||||
// Only show warnings when keybinding customization is enabled
|
||||
if (!isKeybindingCustomizationEnabled()) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const warnings = getCachedKeybindingWarnings()
|
||||
const warnings = getCachedKeybindingWarnings();
|
||||
|
||||
if (warnings.length === 0) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const errors = warnings.filter(w => w.severity === 'error')
|
||||
const warns = warnings.filter(w => w.severity === 'warning')
|
||||
const errors = warnings.filter(w => w.severity === 'error');
|
||||
const warns = warnings.filter(w => w.severity === 'warning');
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1} marginBottom={1}>
|
||||
@@ -68,5 +68,5 @@ export function KeybindingWarnings(): React.ReactNode {
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,26 @@
|
||||
import figures from 'figures'
|
||||
import React, { useState } from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
||||
import TextInput from './TextInput.js'
|
||||
import figures from 'figures';
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js';
|
||||
import TextInput from './TextInput.js';
|
||||
|
||||
type Props = {
|
||||
initialLanguage: string | undefined
|
||||
onComplete: (language: string | undefined) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
initialLanguage: string | undefined;
|
||||
onComplete: (language: string | undefined) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function LanguagePicker({
|
||||
initialLanguage,
|
||||
onComplete,
|
||||
onCancel,
|
||||
}: Props): React.ReactNode {
|
||||
const [language, setLanguage] = useState(initialLanguage)
|
||||
const [cursorOffset, setCursorOffset] = useState(
|
||||
(initialLanguage ?? '').length,
|
||||
)
|
||||
export function LanguagePicker({ initialLanguage, onComplete, onCancel }: Props): React.ReactNode {
|
||||
const [language, setLanguage] = useState(initialLanguage);
|
||||
const [cursorOffset, setCursorOffset] = useState((initialLanguage ?? '').length);
|
||||
|
||||
// Use configurable keybinding for ESC to cancel
|
||||
// Use Settings context so 'n' key doesn't trigger cancel (allows typing 'n' in input)
|
||||
useKeybinding('confirm:no', onCancel, { context: 'Settings' })
|
||||
useKeybinding('confirm:no', onCancel, { context: 'Settings' });
|
||||
|
||||
function handleSubmit(): void {
|
||||
const trimmed = language?.trim()
|
||||
onComplete(trimmed || undefined)
|
||||
const trimmed = language?.trim();
|
||||
onComplete(trimmed || undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -48,5 +42,5 @@ export function LanguagePicker({
|
||||
</Box>
|
||||
<Text dimColor>Leave empty for default (English)</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,57 +1,51 @@
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { TEARDROP_ASTERISK } from '../../constants/figures.js'
|
||||
import { Box, Text, useAnimationFrame } from '@anthropic/ink'
|
||||
import { getInitialSettings } from '../../utils/settings/settings.js'
|
||||
import { hueToRgb, toRGBColor } from '../Spinner/utils.js'
|
||||
import * as React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { TEARDROP_ASTERISK } from '../../constants/figures.js';
|
||||
import { Box, Text, useAnimationFrame } from '@anthropic/ink';
|
||||
import { getInitialSettings } from '../../utils/settings/settings.js';
|
||||
import { hueToRgb, toRGBColor } from '../Spinner/utils.js';
|
||||
|
||||
const SWEEP_DURATION_MS = 1500
|
||||
const SWEEP_COUNT = 2
|
||||
const TOTAL_ANIMATION_MS = SWEEP_DURATION_MS * SWEEP_COUNT
|
||||
const SETTLED_GREY = toRGBColor({ r: 153, g: 153, b: 153 })
|
||||
const SWEEP_DURATION_MS = 1500;
|
||||
const SWEEP_COUNT = 2;
|
||||
const TOTAL_ANIMATION_MS = SWEEP_DURATION_MS * SWEEP_COUNT;
|
||||
const SETTLED_GREY = toRGBColor({ r: 153, g: 153, b: 153 });
|
||||
|
||||
export function AnimatedAsterisk({
|
||||
char = TEARDROP_ASTERISK,
|
||||
}: {
|
||||
char?: string
|
||||
}): React.ReactNode {
|
||||
export function AnimatedAsterisk({ char = TEARDROP_ASTERISK }: { char?: string }): React.ReactNode {
|
||||
// Read prefersReducedMotion once at mount — no useSettings() subscription,
|
||||
// since that would re-render whenever settings change.
|
||||
const [reducedMotion] = useState(
|
||||
() => getInitialSettings().prefersReducedMotion ?? false,
|
||||
)
|
||||
const [done, setDone] = useState(reducedMotion)
|
||||
const [reducedMotion] = useState(() => getInitialSettings().prefersReducedMotion ?? false);
|
||||
const [done, setDone] = useState(reducedMotion);
|
||||
// useAnimationFrame's clock is shared — capture our start offset so the
|
||||
// sweep always begins at hue 0 regardless of when we mount.
|
||||
const startTimeRef = useRef<number | null>(null)
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
// Wire the ref so useAnimationFrame's viewport-pause kicks in: if the
|
||||
// user submits a message before the sweep finishes, the clock stops
|
||||
// automatically once this row enters scrollback (prevents flicker).
|
||||
const [ref, time] = useAnimationFrame(done ? null : 50)
|
||||
const [ref, time] = useAnimationFrame(done ? null : 50);
|
||||
|
||||
useEffect(() => {
|
||||
if (done) return
|
||||
const t = setTimeout(setDone, TOTAL_ANIMATION_MS, true)
|
||||
return () => clearTimeout(t)
|
||||
}, [done])
|
||||
if (done) return;
|
||||
const t = setTimeout(setDone, TOTAL_ANIMATION_MS, true);
|
||||
return () => clearTimeout(t);
|
||||
}, [done]);
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<Box ref={ref}>
|
||||
<Text color={SETTLED_GREY}>{char}</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (startTimeRef.current === null) {
|
||||
startTimeRef.current = time
|
||||
startTimeRef.current = time;
|
||||
}
|
||||
const elapsed = time - startTimeRef.current
|
||||
const hue = ((elapsed / SWEEP_DURATION_MS) * 360) % 360
|
||||
const elapsed = time - startTimeRef.current;
|
||||
const hue = ((elapsed / SWEEP_DURATION_MS) * 360) % 360;
|
||||
|
||||
return (
|
||||
<Box ref={ref}>
|
||||
<Text color={toRGBColor(hueToRgb(hue))}>{char}</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Box } from '@anthropic/ink'
|
||||
import { getInitialSettings } from '../../utils/settings/settings.js'
|
||||
import { Clawd, type ClawdPose } from './Clawd.js'
|
||||
import * as React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Box } from '@anthropic/ink';
|
||||
import { getInitialSettings } from '../../utils/settings/settings.js';
|
||||
import { Clawd, type ClawdPose } from './Clawd.js';
|
||||
|
||||
type Frame = { pose: ClawdPose; offset: number }
|
||||
type Frame = { pose: ClawdPose; offset: number };
|
||||
|
||||
/** Hold a pose for n frames (60ms each). */
|
||||
function hold(pose: ClawdPose, offset: number, frames: number): Frame[] {
|
||||
return Array.from({ length: frames }, () => ({ pose, offset }))
|
||||
return Array.from({ length: frames }, () => ({ pose, offset }));
|
||||
}
|
||||
|
||||
// Offset semantics: marginTop in a fixed-height-3 container. 0 = normal,
|
||||
@@ -24,21 +24,21 @@ const JUMP_WAVE: readonly Frame[] = [
|
||||
...hold('default', 1, 2), // crouch again
|
||||
...hold('arms-up', 0, 3), // spring!
|
||||
...hold('default', 0, 1),
|
||||
]
|
||||
];
|
||||
|
||||
// Click animation: glance right, then left, then back.
|
||||
const LOOK_AROUND: readonly Frame[] = [
|
||||
...hold('look-right', 0, 5),
|
||||
...hold('look-left', 0, 5),
|
||||
...hold('default', 0, 1),
|
||||
]
|
||||
];
|
||||
|
||||
const CLICK_ANIMATIONS: readonly (readonly Frame[])[] = [JUMP_WAVE, LOOK_AROUND]
|
||||
const CLICK_ANIMATIONS: readonly (readonly Frame[])[] = [JUMP_WAVE, LOOK_AROUND];
|
||||
|
||||
const IDLE: Frame = { pose: 'default', offset: 0 }
|
||||
const FRAME_MS = 60
|
||||
const incrementFrame = (i: number) => i + 1
|
||||
const CLAWD_HEIGHT = 3
|
||||
const IDLE: Frame = { pose: 'default', offset: 0 };
|
||||
const FRAME_MS = 60;
|
||||
const incrementFrame = (i: number) => i + 1;
|
||||
const CLAWD_HEIGHT = 3;
|
||||
|
||||
/**
|
||||
* Clawd with click-triggered animations (crouch-jump with arms up, or
|
||||
@@ -49,48 +49,44 @@ const CLAWD_HEIGHT = 3
|
||||
* elsewhere this renders and behaves identically to plain `<Clawd />`.
|
||||
*/
|
||||
export function AnimatedClawd(): React.ReactNode {
|
||||
const { pose, bounceOffset, onClick } = useClawdAnimation()
|
||||
const { pose, bounceOffset, onClick } = useClawdAnimation();
|
||||
return (
|
||||
<Box height={CLAWD_HEIGHT} flexDirection="column" onClick={onClick}>
|
||||
<Box marginTop={bounceOffset} flexShrink={0}>
|
||||
<Clawd pose={pose} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function useClawdAnimation(): {
|
||||
pose: ClawdPose
|
||||
bounceOffset: number
|
||||
onClick: () => void
|
||||
pose: ClawdPose;
|
||||
bounceOffset: number;
|
||||
onClick: () => void;
|
||||
} {
|
||||
// Read once at mount — no useSettings() subscription, since that would
|
||||
// re-render on any settings change.
|
||||
const [reducedMotion] = useState(
|
||||
() => getInitialSettings().prefersReducedMotion ?? false,
|
||||
)
|
||||
const [frameIndex, setFrameIndex] = useState(-1)
|
||||
const sequenceRef = useRef<readonly Frame[]>(JUMP_WAVE)
|
||||
const [reducedMotion] = useState(() => getInitialSettings().prefersReducedMotion ?? false);
|
||||
const [frameIndex, setFrameIndex] = useState(-1);
|
||||
const sequenceRef = useRef<readonly Frame[]>(JUMP_WAVE);
|
||||
|
||||
const onClick = () => {
|
||||
if (reducedMotion || frameIndex !== -1) return
|
||||
sequenceRef.current =
|
||||
CLICK_ANIMATIONS[Math.floor(Math.random() * CLICK_ANIMATIONS.length)]!
|
||||
setFrameIndex(0)
|
||||
}
|
||||
if (reducedMotion || frameIndex !== -1) return;
|
||||
sequenceRef.current = CLICK_ANIMATIONS[Math.floor(Math.random() * CLICK_ANIMATIONS.length)]!;
|
||||
setFrameIndex(0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (frameIndex === -1) return
|
||||
if (frameIndex === -1) return;
|
||||
if (frameIndex >= sequenceRef.current.length) {
|
||||
setFrameIndex(-1)
|
||||
return
|
||||
setFrameIndex(-1);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(setFrameIndex, FRAME_MS, incrementFrame)
|
||||
return () => clearTimeout(timer)
|
||||
}, [frameIndex])
|
||||
const timer = setTimeout(setFrameIndex, FRAME_MS, incrementFrame);
|
||||
return () => clearTimeout(timer);
|
||||
}, [frameIndex]);
|
||||
|
||||
const seq = sequenceRef.current
|
||||
const current =
|
||||
frameIndex >= 0 && frameIndex < seq.length ? seq[frameIndex]! : IDLE
|
||||
return { pose: current.pose, bounceOffset: current.offset, onClick }
|
||||
const seq = sequenceRef.current;
|
||||
const current = frameIndex >= 0 && frameIndex < seq.length ? seq[frameIndex]! : IDLE;
|
||||
return { pose: current.pose, bounceOffset: current.offset, onClick };
|
||||
}
|
||||
|
||||
@@ -4,49 +4,44 @@
|
||||
// docs/feature-gating.md). Do NOT import this module statically from
|
||||
// unguarded code.
|
||||
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
type ChannelEntry,
|
||||
getAllowedChannels,
|
||||
getHasDevChannels,
|
||||
} from '../../bootstrap/state.js'
|
||||
import { getBuiltinPlugins } from '../../plugins/builtinPlugins.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { getMcpConfigsByScope } from '../../services/mcp/config.js'
|
||||
import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { type ChannelEntry, getAllowedChannels, getHasDevChannels } from '../../bootstrap/state.js';
|
||||
import { getBuiltinPlugins } from '../../plugins/builtinPlugins.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { getMcpConfigsByScope } from '../../services/mcp/config.js';
|
||||
import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js';
|
||||
|
||||
export function ChannelsNotice(): React.ReactNode {
|
||||
// Snapshot all reads at mount. This notice enters scrollback immediately
|
||||
// after the logo; any re-render past that point forces a full terminal
|
||||
// reset.
|
||||
const [{ channels, list, unmatched }] =
|
||||
useState(() => {
|
||||
const ch = getAllowedChannels()
|
||||
if (ch.length === 0)
|
||||
return {
|
||||
channels: ch,
|
||||
list: '',
|
||||
unmatched: [] as Unmatched[],
|
||||
}
|
||||
const l = ch.map(formatEntry).join(', ')
|
||||
const [{ channels, list, unmatched }] = useState(() => {
|
||||
const ch = getAllowedChannels();
|
||||
if (ch.length === 0)
|
||||
return {
|
||||
channels: ch,
|
||||
list: l,
|
||||
unmatched: findUnmatched(ch),
|
||||
}
|
||||
})
|
||||
if (channels.length === 0) return null
|
||||
list: '',
|
||||
unmatched: [] as Unmatched[],
|
||||
};
|
||||
const l = ch.map(formatEntry).join(', ');
|
||||
return {
|
||||
channels: ch,
|
||||
list: l,
|
||||
unmatched: findUnmatched(ch),
|
||||
};
|
||||
});
|
||||
if (channels.length === 0) return null;
|
||||
|
||||
// When both flags are passed, the list mixes entries and a single flag
|
||||
// name would be wrong for half of it. entry.dev distinguishes origin.
|
||||
const hasNonDev = channels.some(c => !c.dev)
|
||||
const hasNonDev = channels.some(c => !c.dev);
|
||||
const flag =
|
||||
getHasDevChannels() && hasNonDev
|
||||
? 'Channels'
|
||||
: getHasDevChannels()
|
||||
? '--dangerously-load-development-channels'
|
||||
: '--channels'
|
||||
: '--channels';
|
||||
|
||||
// "Listening for" not "active" — at this point we only know the allowlist
|
||||
// was set. Server connection, capability declaration, and whether the name
|
||||
@@ -55,9 +50,8 @@ export function ChannelsNotice(): React.ReactNode {
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
<Text color="error">Listening for channel messages from: {list}</Text>
|
||||
<Text dimColor>
|
||||
Experimental · inbound messages will be pushed into this session, this
|
||||
carries prompt injection risks. Restart Claude Code without {flag} to
|
||||
disable.
|
||||
Experimental · inbound messages will be pushed into this session, this carries prompt injection risks. Restart
|
||||
Claude Code without {flag} to disable.
|
||||
</Text>
|
||||
{unmatched.map(u => (
|
||||
<Text key={`${formatEntry(u.entry)}:${u.why}`} color="warning">
|
||||
@@ -65,62 +59,61 @@ export function ChannelsNotice(): React.ReactNode {
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function formatEntry(c: ChannelEntry): string {
|
||||
return c.kind === 'plugin'
|
||||
? `plugin:${c.name}@${c.marketplace}`
|
||||
: `server:${c.name}`
|
||||
return c.kind === 'plugin' ? `plugin:${c.name}@${c.marketplace}` : `server:${c.name}`;
|
||||
}
|
||||
|
||||
type Unmatched = { entry: ChannelEntry; why: string }
|
||||
type Unmatched = { entry: ChannelEntry; why: string };
|
||||
|
||||
type FindUnmatchedDeps = {
|
||||
configuredServerNames?: ReadonlySet<string>
|
||||
installedPluginIds?: ReadonlySet<string>
|
||||
}
|
||||
configuredServerNames?: ReadonlySet<string>;
|
||||
installedPluginIds?: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export function findUnmatched(
|
||||
entries: readonly ChannelEntry[],
|
||||
deps?: FindUnmatchedDeps,
|
||||
): Unmatched[] {
|
||||
export function findUnmatched(entries: readonly ChannelEntry[], deps?: FindUnmatchedDeps): Unmatched[] {
|
||||
// Server-kind: build one Set from all scopes up front. getMcpConfigsByScope
|
||||
// is not cached (project scope walks the dir tree); getMcpConfigByName would
|
||||
// redo that walk per entry.
|
||||
const configured = deps?.configuredServerNames ?? (() => {
|
||||
const scopes = ['enterprise', 'user', 'project', 'local'] as const
|
||||
const names = new Set<string>()
|
||||
for (const scope of scopes) {
|
||||
for (const name of Object.keys(getMcpConfigsByScope(scope).servers)) {
|
||||
names.add(name)
|
||||
const configured =
|
||||
deps?.configuredServerNames ??
|
||||
(() => {
|
||||
const scopes = ['enterprise', 'user', 'project', 'local'] as const;
|
||||
const names = new Set<string>();
|
||||
for (const scope of scopes) {
|
||||
for (const name of Object.keys(getMcpConfigsByScope(scope).servers)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
})()
|
||||
return names;
|
||||
})();
|
||||
|
||||
// Plugin-kind installed check: installed_plugins.json keys are
|
||||
// `name@marketplace`. loadInstalledPluginsV2 is cached.
|
||||
const installedPluginIds = deps?.installedPluginIds ?? (() => {
|
||||
const ids = new Set(Object.keys(loadInstalledPluginsV2().plugins))
|
||||
const builtinPlugins = getBuiltinPlugins()
|
||||
for (const plugin of [...builtinPlugins.enabled, ...builtinPlugins.disabled]) {
|
||||
ids.add(plugin.source)
|
||||
}
|
||||
return ids
|
||||
})()
|
||||
const installedPluginIds =
|
||||
deps?.installedPluginIds ??
|
||||
(() => {
|
||||
const ids = new Set(Object.keys(loadInstalledPluginsV2().plugins));
|
||||
const builtinPlugins = getBuiltinPlugins();
|
||||
for (const plugin of [...builtinPlugins.enabled, ...builtinPlugins.disabled]) {
|
||||
ids.add(plugin.source);
|
||||
}
|
||||
return ids;
|
||||
})();
|
||||
|
||||
const out: Unmatched[] = []
|
||||
const out: Unmatched[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'server') {
|
||||
if (!configured.has(entry.name)) {
|
||||
out.push({ entry, why: 'no MCP server configured with that name' })
|
||||
out.push({ entry, why: 'no MCP server configured with that name' });
|
||||
}
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
if (!installedPluginIds.has(`${entry.name}@${entry.marketplace}`)) {
|
||||
out.push({ entry, why: 'plugin not installed' })
|
||||
out.push({ entry, why: 'plugin not installed' });
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import * as React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { env } from '../../utils/env.js'
|
||||
import * as React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { env } from '../../utils/env.js';
|
||||
|
||||
export type ClawdPose =
|
||||
| 'default'
|
||||
| 'arms-up' // both arms raised (used during jump)
|
||||
| 'look-left' // both pupils shifted left
|
||||
| 'look-right' // both pupils shifted right
|
||||
| 'look-right'; // both pupils shifted right
|
||||
|
||||
type Props = {
|
||||
pose?: ClawdPose
|
||||
}
|
||||
pose?: ClawdPose;
|
||||
};
|
||||
|
||||
// Standard-terminal pose fragments. Each row is split into segments so we can
|
||||
// vary only the parts that change (eyes, arms) while keeping the body/bg spans
|
||||
@@ -23,23 +23,23 @@ type Props = {
|
||||
// default (▛/▜, bottom pupils) — otherwise only one eye would appear to move.
|
||||
type Segments = {
|
||||
/** row 1 left (no bg): optional raised arm + side */
|
||||
r1L: string
|
||||
r1L: string;
|
||||
/** row 1 eyes (with bg): left-eye, forehead, right-eye */
|
||||
r1E: string
|
||||
r1E: string;
|
||||
/** row 1 right (no bg): side + optional raised arm */
|
||||
r1R: string
|
||||
r1R: string;
|
||||
/** row 2 left (no bg): arm + body curve */
|
||||
r2L: string
|
||||
r2L: string;
|
||||
/** row 2 right (no bg): body curve + arm */
|
||||
r2R: string
|
||||
}
|
||||
r2R: string;
|
||||
};
|
||||
|
||||
const POSES: Record<ClawdPose, Segments> = {
|
||||
default: { r1L: ' ▐', r1E: '▛███▜', r1R: '▌', r2L: '▝▜', r2R: '▛▘' },
|
||||
'look-left': { r1L: ' ▐', r1E: '▟███▟', r1R: '▌', r2L: '▝▜', r2R: '▛▘' },
|
||||
'look-right': { r1L: ' ▐', r1E: '▙███▙', r1R: '▌', r2L: '▝▜', r2R: '▛▘' },
|
||||
'arms-up': { r1L: '▗▟', r1E: '▛███▜', r1R: '▙▖', r2L: ' ▜', r2R: '▛ ' },
|
||||
}
|
||||
};
|
||||
|
||||
// Apple Terminal uses a bg-fill trick (see below), so only eye poses make
|
||||
// sense. Arm poses fall back to default.
|
||||
@@ -48,13 +48,13 @@ const APPLE_EYES: Record<ClawdPose, string> = {
|
||||
'look-left': ' ▘ ▘ ',
|
||||
'look-right': ' ▝ ▝ ',
|
||||
'arms-up': ' ▗ ▖ ',
|
||||
}
|
||||
};
|
||||
|
||||
export function Clawd({ pose = 'default' }: Props = {}): React.ReactNode {
|
||||
if (env.terminal === 'Apple_Terminal') {
|
||||
return <AppleTerminalClawd pose={pose} />
|
||||
return <AppleTerminalClawd pose={pose} />;
|
||||
}
|
||||
const p = POSES[pose]
|
||||
const p = POSES[pose];
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
@@ -75,7 +75,7 @@ export function Clawd({ pose = 'default' }: Props = {}): React.ReactNode {
|
||||
{' '}▘▘ ▝▝{' '}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AppleTerminalClawd({ pose }: { pose: ClawdPose }): React.ReactNode {
|
||||
@@ -94,5 +94,5 @@ function AppleTerminalClawd({ pose }: { pose: ClawdPose }): React.ReactNode {
|
||||
<Text backgroundColor="clawd_body">{' '.repeat(7)}</Text>
|
||||
<Text color="clawd_body">▘▘ ▝▝</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,83 +1,71 @@
|
||||
import * as React from 'react'
|
||||
import { type ReactNode, useEffect } from 'react'
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink'
|
||||
import { useAppState } from '../../state/AppState.js'
|
||||
import { getEffortSuffix } from '../../utils/effort.js'
|
||||
import { truncate } from '../../utils/format.js'
|
||||
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'
|
||||
import {
|
||||
formatModelAndBilling,
|
||||
getLogoDisplayData,
|
||||
truncatePath,
|
||||
} from '../../utils/logoV2Utils.js'
|
||||
import { renderModelSetting } from '../../utils/model/model.js'
|
||||
import { OffscreenFreeze } from '../OffscreenFreeze.js'
|
||||
import { AnimatedClawd } from './AnimatedClawd.js'
|
||||
import { Clawd } from './Clawd.js'
|
||||
import {
|
||||
GuestPassesUpsell,
|
||||
incrementGuestPassesSeenCount,
|
||||
useShowGuestPassesUpsell,
|
||||
} from './GuestPassesUpsell.js'
|
||||
import * as React from 'react';
|
||||
import { type ReactNode, useEffect } from 'react';
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink';
|
||||
import { useAppState } from '../../state/AppState.js';
|
||||
import { getEffortSuffix } from '../../utils/effort.js';
|
||||
import { truncate } from '../../utils/format.js';
|
||||
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js';
|
||||
import { formatModelAndBilling, getLogoDisplayData, truncatePath } from '../../utils/logoV2Utils.js';
|
||||
import { renderModelSetting } from '../../utils/model/model.js';
|
||||
import { OffscreenFreeze } from '../OffscreenFreeze.js';
|
||||
import { AnimatedClawd } from './AnimatedClawd.js';
|
||||
import { Clawd } from './Clawd.js';
|
||||
import { GuestPassesUpsell, incrementGuestPassesSeenCount, useShowGuestPassesUpsell } from './GuestPassesUpsell.js';
|
||||
import {
|
||||
incrementOverageCreditUpsellSeenCount,
|
||||
OverageCreditUpsell,
|
||||
useShowOverageCreditUpsell,
|
||||
} from './OverageCreditUpsell.js'
|
||||
} from './OverageCreditUpsell.js';
|
||||
|
||||
export function CondensedLogo(): ReactNode {
|
||||
const { columns } = useTerminalSize()
|
||||
const agent = useAppState(s => s.agent)
|
||||
const effortValue = useAppState(s => s.effortValue)
|
||||
const model = useMainLoopModel()
|
||||
const modelDisplayName = renderModelSetting(model)
|
||||
const { version, cwd, billingType, agentName: agentNameFromSettings } = getLogoDisplayData()
|
||||
const { columns } = useTerminalSize();
|
||||
const agent = useAppState(s => s.agent);
|
||||
const effortValue = useAppState(s => s.effortValue);
|
||||
const model = useMainLoopModel();
|
||||
const modelDisplayName = renderModelSetting(model);
|
||||
const { version, cwd, billingType, agentName: agentNameFromSettings } = getLogoDisplayData();
|
||||
|
||||
// Prefer AppState.agent (set from --agent CLI flag) over settings
|
||||
const agentName = agent ?? agentNameFromSettings
|
||||
const showGuestPassesUpsell = useShowGuestPassesUpsell()
|
||||
const showOverageCreditUpsell = useShowOverageCreditUpsell()
|
||||
const agentName = agent ?? agentNameFromSettings;
|
||||
const showGuestPassesUpsell = useShowGuestPassesUpsell();
|
||||
const showOverageCreditUpsell = useShowOverageCreditUpsell();
|
||||
|
||||
useEffect(() => {
|
||||
if (showGuestPassesUpsell) {
|
||||
incrementGuestPassesSeenCount()
|
||||
incrementGuestPassesSeenCount();
|
||||
}
|
||||
}, [showGuestPassesUpsell])
|
||||
}, [showGuestPassesUpsell]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showOverageCreditUpsell && !showGuestPassesUpsell) {
|
||||
incrementOverageCreditUpsellSeenCount()
|
||||
incrementOverageCreditUpsellSeenCount();
|
||||
}
|
||||
}, [showOverageCreditUpsell, showGuestPassesUpsell])
|
||||
}, [showOverageCreditUpsell, showGuestPassesUpsell]);
|
||||
|
||||
// Calculate available width for text content
|
||||
// Account for: condensed clawd width (11 chars) + gap (2) + padding (2) = 15 chars
|
||||
const textWidth = Math.max(columns - 15, 20)
|
||||
const textWidth = Math.max(columns - 15, 20);
|
||||
|
||||
// Truncate version to fit within available width, accounting for "Claude Code v" prefix
|
||||
const versionPrefix = 'Claude Code v'
|
||||
const truncatedVersion = truncate(
|
||||
version,
|
||||
Math.max(textWidth - versionPrefix.length, 6),
|
||||
)
|
||||
const versionPrefix = 'Claude Code v';
|
||||
const truncatedVersion = truncate(version, Math.max(textWidth - versionPrefix.length, 6));
|
||||
|
||||
const effortSuffix = getEffortSuffix(model, effortValue)
|
||||
const { shouldSplit, truncatedModel, truncatedBilling } =
|
||||
formatModelAndBilling(
|
||||
modelDisplayName + effortSuffix,
|
||||
billingType,
|
||||
textWidth,
|
||||
)
|
||||
const effortSuffix = getEffortSuffix(model, effortValue);
|
||||
const { shouldSplit, truncatedModel, truncatedBilling } = formatModelAndBilling(
|
||||
modelDisplayName + effortSuffix,
|
||||
billingType,
|
||||
textWidth,
|
||||
);
|
||||
|
||||
// Truncate path, accounting for agent name if present
|
||||
const separator = ' · '
|
||||
const atPrefix = '@'
|
||||
const separator = ' · ';
|
||||
const atPrefix = '@';
|
||||
const cwdAvailableWidth = agentName
|
||||
? textWidth - atPrefix.length - stringWidth(agentName) - separator.length
|
||||
: textWidth
|
||||
const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10))
|
||||
: textWidth;
|
||||
const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10));
|
||||
|
||||
// OffscreenFreeze: the logo sits at the top of the message list and is the
|
||||
// first thing to enter scrollback. useMainLoopModel() subscribes to model
|
||||
@@ -86,33 +74,28 @@ export function CondensedLogo(): ReactNode {
|
||||
return (
|
||||
<OffscreenFreeze>
|
||||
<Box flexDirection="row" gap={2} alignItems="center">
|
||||
{isFullscreenEnvEnabled() ? <AnimatedClawd /> : <Clawd />}
|
||||
{isFullscreenEnvEnabled() ? <AnimatedClawd /> : <Clawd />}
|
||||
|
||||
{/* Info */}
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
<Text bold>Claude Code</Text>{' '}
|
||||
<Text dimColor>v{truncatedVersion}</Text>
|
||||
</Text>
|
||||
{shouldSplit ? (
|
||||
<>
|
||||
<Text dimColor>{truncatedModel}</Text>
|
||||
<Text dimColor>{truncatedBilling}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text dimColor>
|
||||
{truncatedModel} · {truncatedBilling}
|
||||
{/* Info */}
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
<Text bold>Claude Code</Text> <Text dimColor>v{truncatedVersion}</Text>
|
||||
</Text>
|
||||
)}
|
||||
<Text dimColor>
|
||||
{agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd}
|
||||
</Text>
|
||||
{showGuestPassesUpsell && <GuestPassesUpsell />}
|
||||
{!showGuestPassesUpsell && showOverageCreditUpsell && (
|
||||
<OverageCreditUpsell maxWidth={textWidth} twoLine />
|
||||
)}
|
||||
</Box>
|
||||
{shouldSplit ? (
|
||||
<>
|
||||
<Text dimColor>{truncatedModel}</Text>
|
||||
<Text dimColor>{truncatedBilling}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text dimColor>
|
||||
{truncatedModel} · {truncatedBilling}
|
||||
</Text>
|
||||
)}
|
||||
<Text dimColor>{agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd}</Text>
|
||||
{showGuestPassesUpsell && <GuestPassesUpsell />}
|
||||
{!showGuestPassesUpsell && showOverageCreditUpsell && <OverageCreditUpsell maxWidth={textWidth} twoLine />}
|
||||
</Box>
|
||||
</Box>
|
||||
</OffscreenFreeze>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,31 @@
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { getDynamicConfig_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js'
|
||||
import * as React from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { getDynamicConfig_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js';
|
||||
|
||||
const CONFIG_NAME = 'tengu-top-of-feed-tip'
|
||||
const CONFIG_NAME = 'tengu-top-of-feed-tip';
|
||||
|
||||
export function EmergencyTip(): React.ReactNode {
|
||||
const tip = useMemo(getTipOfFeed, [])
|
||||
const tip = useMemo(getTipOfFeed, []);
|
||||
// Memoize to prevent re-reads after we save - we want the value at mount time
|
||||
const lastShownTip = useMemo(
|
||||
() => getGlobalConfig().lastShownEmergencyTip,
|
||||
[],
|
||||
)
|
||||
const lastShownTip = useMemo(() => getGlobalConfig().lastShownEmergencyTip, []);
|
||||
|
||||
// Only show if this is a new/different tip
|
||||
const shouldShow = tip.tip && tip.tip !== lastShownTip
|
||||
const shouldShow = tip.tip && tip.tip !== lastShownTip;
|
||||
|
||||
// Save the tip we're showing so we don't show it again
|
||||
useEffect(() => {
|
||||
if (shouldShow) {
|
||||
saveGlobalConfig(current => {
|
||||
if (current.lastShownEmergencyTip === tip.tip) return current
|
||||
return { ...current, lastShownEmergencyTip: tip.tip }
|
||||
})
|
||||
if (current.lastShownEmergencyTip === tip.tip) return current;
|
||||
return { ...current, lastShownEmergencyTip: tip.tip };
|
||||
});
|
||||
}
|
||||
}, [shouldShow, tip.tip])
|
||||
}, [shouldShow, tip.tip]);
|
||||
|
||||
if (!shouldShow) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -43,23 +40,20 @@ export function EmergencyTip(): React.ReactNode {
|
||||
{tip.tip}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
type TipOfFeed = {
|
||||
tip: string
|
||||
color?: 'dim' | 'warning' | 'error'
|
||||
}
|
||||
tip: string;
|
||||
color?: 'dim' | 'warning' | 'error';
|
||||
};
|
||||
|
||||
const DEFAULT_TIP: TipOfFeed = { tip: '', color: 'dim' }
|
||||
const DEFAULT_TIP: TipOfFeed = { tip: '', color: 'dim' };
|
||||
|
||||
/**
|
||||
* Get the tip of the feed from dynamic config with caching
|
||||
* Returns cached value immediately, updates in background
|
||||
*/
|
||||
function getTipOfFeed(): TipOfFeed {
|
||||
return getDynamicConfig_CACHED_MAY_BE_STALE<TipOfFeed>(
|
||||
CONFIG_NAME,
|
||||
DEFAULT_TIP,
|
||||
)
|
||||
return getDynamicConfig_CACHED_MAY_BE_STALE<TipOfFeed>(CONFIG_NAME, DEFAULT_TIP);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from 'react'
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* Internal-only component. Shows experiment enrollment status for internal
|
||||
* users. Stubbed — returns null in non-internal builds.
|
||||
*/
|
||||
export function ExperimentEnrollmentNotice(): React.ReactNode {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,65 +1,57 @@
|
||||
import * as React from 'react'
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink'
|
||||
import { truncate } from '../../utils/format.js'
|
||||
import * as React from 'react';
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink';
|
||||
import { truncate } from '../../utils/format.js';
|
||||
|
||||
export type FeedLine = {
|
||||
text: string
|
||||
timestamp?: string
|
||||
}
|
||||
text: string;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
export type FeedConfig = {
|
||||
title: string
|
||||
lines: FeedLine[]
|
||||
footer?: string
|
||||
emptyMessage?: string
|
||||
customContent?: { content: React.ReactNode; width: number }
|
||||
}
|
||||
title: string;
|
||||
lines: FeedLine[];
|
||||
footer?: string;
|
||||
emptyMessage?: string;
|
||||
customContent?: { content: React.ReactNode; width: number };
|
||||
};
|
||||
|
||||
type FeedProps = {
|
||||
config: FeedConfig
|
||||
actualWidth: number
|
||||
}
|
||||
config: FeedConfig;
|
||||
actualWidth: number;
|
||||
};
|
||||
|
||||
export function calculateFeedWidth(config: FeedConfig): number {
|
||||
const { title, lines, footer, emptyMessage, customContent } = config
|
||||
const { title, lines, footer, emptyMessage, customContent } = config;
|
||||
|
||||
let maxWidth = stringWidth(title)
|
||||
let maxWidth = stringWidth(title);
|
||||
|
||||
if (customContent !== undefined) {
|
||||
maxWidth = Math.max(maxWidth, customContent.width)
|
||||
maxWidth = Math.max(maxWidth, customContent.width);
|
||||
} else if (lines.length === 0 && emptyMessage) {
|
||||
maxWidth = Math.max(maxWidth, stringWidth(emptyMessage))
|
||||
maxWidth = Math.max(maxWidth, stringWidth(emptyMessage));
|
||||
} else {
|
||||
const gap = ' '
|
||||
const maxTimestampWidth = Math.max(
|
||||
0,
|
||||
...lines.map(line => (line.timestamp ? stringWidth(line.timestamp) : 0)),
|
||||
)
|
||||
const gap = ' ';
|
||||
const maxTimestampWidth = Math.max(0, ...lines.map(line => (line.timestamp ? stringWidth(line.timestamp) : 0)));
|
||||
|
||||
for (const line of lines) {
|
||||
const timestampWidth = maxTimestampWidth > 0 ? maxTimestampWidth : 0
|
||||
const lineWidth =
|
||||
stringWidth(line.text) +
|
||||
(timestampWidth > 0 ? timestampWidth + gap.length : 0)
|
||||
maxWidth = Math.max(maxWidth, lineWidth)
|
||||
const timestampWidth = maxTimestampWidth > 0 ? maxTimestampWidth : 0;
|
||||
const lineWidth = stringWidth(line.text) + (timestampWidth > 0 ? timestampWidth + gap.length : 0);
|
||||
maxWidth = Math.max(maxWidth, lineWidth);
|
||||
}
|
||||
}
|
||||
|
||||
if (footer) {
|
||||
maxWidth = Math.max(maxWidth, stringWidth(footer))
|
||||
maxWidth = Math.max(maxWidth, stringWidth(footer));
|
||||
}
|
||||
|
||||
return maxWidth
|
||||
return maxWidth;
|
||||
}
|
||||
|
||||
export function Feed({ config, actualWidth }: FeedProps): React.ReactNode {
|
||||
const { title, lines, footer, emptyMessage, customContent } = config
|
||||
const { title, lines, footer, emptyMessage, customContent } = config;
|
||||
|
||||
const gap = ' '
|
||||
const maxTimestampWidth = Math.max(
|
||||
0,
|
||||
...lines.map(line => (line.timestamp ? stringWidth(line.timestamp) : 0)),
|
||||
)
|
||||
const gap = ' ';
|
||||
const maxTimestampWidth = Math.max(0, ...lines.map(line => (line.timestamp ? stringWidth(line.timestamp) : 0)));
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={actualWidth}>
|
||||
@@ -80,25 +72,19 @@ export function Feed({ config, actualWidth }: FeedProps): React.ReactNode {
|
||||
) : (
|
||||
<>
|
||||
{lines.map((line, index) => {
|
||||
const textWidth = Math.max(
|
||||
10,
|
||||
actualWidth -
|
||||
(maxTimestampWidth > 0 ? maxTimestampWidth + gap.length : 0),
|
||||
)
|
||||
const textWidth = Math.max(10, actualWidth - (maxTimestampWidth > 0 ? maxTimestampWidth + gap.length : 0));
|
||||
|
||||
return (
|
||||
<Text key={index}>
|
||||
{maxTimestampWidth > 0 && (
|
||||
<>
|
||||
<Text dimColor>
|
||||
{(line.timestamp || '').padEnd(maxTimestampWidth)}
|
||||
</Text>
|
||||
<Text dimColor>{(line.timestamp || '').padEnd(maxTimestampWidth)}</Text>
|
||||
{gap}
|
||||
</>
|
||||
)}
|
||||
<Text>{truncate(line.text, textWidth)}</Text>
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
})}
|
||||
{footer && (
|
||||
<Text dimColor italic>
|
||||
@@ -108,5 +94,5 @@ export function Feed({ config, actualWidth }: FeedProps): React.ReactNode {
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,27 @@
|
||||
import * as React from 'react'
|
||||
import { Box } from '@anthropic/ink'
|
||||
import { Divider } from '@anthropic/ink'
|
||||
import type { FeedConfig } from './Feed.js'
|
||||
import { calculateFeedWidth, Feed } from './Feed.js'
|
||||
import * as React from 'react';
|
||||
import { Box } from '@anthropic/ink';
|
||||
import { Divider } from '@anthropic/ink';
|
||||
import type { FeedConfig } from './Feed.js';
|
||||
import { calculateFeedWidth, Feed } from './Feed.js';
|
||||
|
||||
type FeedColumnProps = {
|
||||
feeds: FeedConfig[]
|
||||
maxWidth: number
|
||||
}
|
||||
feeds: FeedConfig[];
|
||||
maxWidth: number;
|
||||
};
|
||||
|
||||
export function FeedColumn({
|
||||
feeds,
|
||||
maxWidth,
|
||||
}: FeedColumnProps): React.ReactNode {
|
||||
const feedWidths = feeds.map(feed => calculateFeedWidth(feed))
|
||||
const maxOfAllFeeds = Math.max(...feedWidths)
|
||||
const actualWidth = Math.min(maxOfAllFeeds, maxWidth)
|
||||
export function FeedColumn({ feeds, maxWidth }: FeedColumnProps): React.ReactNode {
|
||||
const feedWidths = feeds.map(feed => calculateFeedWidth(feed));
|
||||
const maxOfAllFeeds = Math.max(...feedWidths);
|
||||
const actualWidth = Math.min(maxOfAllFeeds, maxWidth);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{feeds.map((feed, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Feed config={feed} actualWidth={actualWidth} />
|
||||
{index < feeds.length - 1 && (
|
||||
<Divider color="claude" width={actualWidth} />
|
||||
)}
|
||||
{index < feeds.length - 1 && <Divider color="claude" width={actualWidth} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from 'react'
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* Internal-only component. Displays a warning when feature-gate overrides
|
||||
@@ -6,5 +6,5 @@ import * as React from 'react'
|
||||
* non-internal builds.
|
||||
*/
|
||||
export function GateOverridesWarning(): React.ReactNode {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,73 +1,72 @@
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Text } from '@anthropic/ink'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Text } from '@anthropic/ink';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import {
|
||||
checkCachedPassesEligibility,
|
||||
formatCreditAmount,
|
||||
getCachedReferrerReward,
|
||||
getCachedRemainingPasses,
|
||||
} from '../../services/api/referral.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
} from '../../services/api/referral.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
|
||||
function resetIfPassesRefreshed(): void {
|
||||
const remaining = getCachedRemainingPasses()
|
||||
if (remaining == null || remaining <= 0) return
|
||||
const config = getGlobalConfig()
|
||||
const lastSeen = config.passesLastSeenRemaining ?? 0
|
||||
const remaining = getCachedRemainingPasses();
|
||||
if (remaining == null || remaining <= 0) return;
|
||||
const config = getGlobalConfig();
|
||||
const lastSeen = config.passesLastSeenRemaining ?? 0;
|
||||
if (remaining > lastSeen) {
|
||||
saveGlobalConfig(prev => ({
|
||||
...prev,
|
||||
passesUpsellSeenCount: 0,
|
||||
hasVisitedPasses: false,
|
||||
passesLastSeenRemaining: remaining,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowGuestPassesUpsell(): boolean {
|
||||
const { eligible, hasCache } = checkCachedPassesEligibility()
|
||||
const { eligible, hasCache } = checkCachedPassesEligibility();
|
||||
// Only show if eligible and cache exists (don't block on fetch)
|
||||
if (!eligible || !hasCache) return false
|
||||
if (!eligible || !hasCache) return false;
|
||||
// Reset upsell counters if passes were refreshed (covers both campaign change and pass refresh)
|
||||
resetIfPassesRefreshed()
|
||||
resetIfPassesRefreshed();
|
||||
|
||||
const config = getGlobalConfig()
|
||||
if ((config.passesUpsellSeenCount ?? 0) >= 3) return false
|
||||
if (config.hasVisitedPasses) return false
|
||||
const config = getGlobalConfig();
|
||||
if ((config.passesUpsellSeenCount ?? 0) >= 3) return false;
|
||||
if (config.hasVisitedPasses) return false;
|
||||
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useShowGuestPassesUpsell(): boolean {
|
||||
const [show] = useState(() => shouldShowGuestPassesUpsell())
|
||||
return show
|
||||
const [show] = useState(() => shouldShowGuestPassesUpsell());
|
||||
return show;
|
||||
}
|
||||
|
||||
export function incrementGuestPassesSeenCount(): void {
|
||||
let newCount = 0
|
||||
let newCount = 0;
|
||||
saveGlobalConfig(prev => {
|
||||
newCount = (prev.passesUpsellSeenCount ?? 0) + 1
|
||||
newCount = (prev.passesUpsellSeenCount ?? 0) + 1;
|
||||
return {
|
||||
...prev,
|
||||
passesUpsellSeenCount: newCount,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
logEvent('tengu_guest_passes_upsell_shown', {
|
||||
seen_count: newCount,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Condensed layout for mini welcome screen
|
||||
export function GuestPassesUpsell(): React.ReactNode {
|
||||
const reward = getCachedReferrerReward()
|
||||
const reward = getCachedReferrerReward();
|
||||
return (
|
||||
<Text dimColor>
|
||||
<Text color="claude">[✻]</Text> <Text color="claude">[✻]</Text>{' '}
|
||||
<Text color="claude">[✻]</Text> ·{' '}
|
||||
<Text color="claude">[✻]</Text> <Text color="claude">[✻]</Text> <Text color="claude">[✻]</Text> ·{' '}
|
||||
{reward
|
||||
? `Share Claude Code and earn ${formatCreditAmount(reward)} of extra usage · /passes`
|
||||
: '3 guest passes at /passes'}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||
import * as React from 'react'
|
||||
import { Box, Text, color, stringWidth } from '@anthropic/ink'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import * as React from 'react';
|
||||
import { Box, Text, color, stringWidth } from '@anthropic/ink';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import {
|
||||
getLayoutMode,
|
||||
calculateLayoutDimensions,
|
||||
@@ -11,46 +11,39 @@ import {
|
||||
getRecentActivitySync,
|
||||
getRecentReleaseNotesSync,
|
||||
getLogoDisplayData,
|
||||
} from '../../utils/logoV2Utils.js'
|
||||
import { truncate } from '../../utils/format.js'
|
||||
import { getDisplayPath } from '../../utils/file.js'
|
||||
import { Clawd } from './Clawd.js'
|
||||
import { FeedColumn } from './FeedColumn.js'
|
||||
} from '../../utils/logoV2Utils.js';
|
||||
import { truncate } from '../../utils/format.js';
|
||||
import { getDisplayPath } from '../../utils/file.js';
|
||||
import { Clawd } from './Clawd.js';
|
||||
import { FeedColumn } from './FeedColumn.js';
|
||||
import {
|
||||
createRecentActivityFeed,
|
||||
createWhatsNewFeed,
|
||||
createProjectOnboardingFeed,
|
||||
createGuestPassesFeed,
|
||||
} from './feedConfigs.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js'
|
||||
import { resolveThemeSetting } from 'src/utils/systemTheme.js'
|
||||
import { getInitialSettings } from 'src/utils/settings/settings.js'
|
||||
import {
|
||||
isDebugMode,
|
||||
isDebugToStdErr,
|
||||
getDebugLogPath,
|
||||
} from 'src/utils/debug.js'
|
||||
import { useEffect, useState } from 'react'
|
||||
} from './feedConfigs.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js';
|
||||
import { resolveThemeSetting } from 'src/utils/systemTheme.js';
|
||||
import { getInitialSettings } from 'src/utils/settings/settings.js';
|
||||
import { isDebugMode, isDebugToStdErr, getDebugLogPath } from 'src/utils/debug.js';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
getSteps,
|
||||
shouldShowProjectOnboarding,
|
||||
incrementProjectOnboardingSeenCount,
|
||||
} from '../../projectOnboardingState.js'
|
||||
import { CondensedLogo } from './CondensedLogo.js'
|
||||
import { OffscreenFreeze } from '../OffscreenFreeze.js'
|
||||
import { checkForReleaseNotesSync } from '../../utils/releaseNotes.js'
|
||||
import { getDumpPromptsPath } from 'src/services/api/dumpPrompts.js'
|
||||
import { isEnvTruthy } from 'src/utils/envUtils.js'
|
||||
import {
|
||||
getStartupPerfLogPath,
|
||||
isDetailedProfilingEnabled,
|
||||
} from 'src/utils/startupProfiler.js'
|
||||
import { EmergencyTip } from './EmergencyTip.js'
|
||||
import { VoiceModeNotice } from './VoiceModeNotice.js'
|
||||
import { Opus1mMergeNotice } from './Opus1mMergeNotice.js'
|
||||
import { GateOverridesWarning } from './GateOverridesWarning.js'
|
||||
import { ExperimentEnrollmentNotice } from './ExperimentEnrollmentNotice.js'
|
||||
import { feature } from 'bun:bundle'
|
||||
} from '../../projectOnboardingState.js';
|
||||
import { CondensedLogo } from './CondensedLogo.js';
|
||||
import { OffscreenFreeze } from '../OffscreenFreeze.js';
|
||||
import { checkForReleaseNotesSync } from '../../utils/releaseNotes.js';
|
||||
import { getDumpPromptsPath } from 'src/services/api/dumpPrompts.js';
|
||||
import { isEnvTruthy } from 'src/utils/envUtils.js';
|
||||
import { getStartupPerfLogPath, isDetailedProfilingEnabled } from 'src/utils/startupProfiler.js';
|
||||
import { EmergencyTip } from './EmergencyTip.js';
|
||||
import { VoiceModeNotice } from './VoiceModeNotice.js';
|
||||
import { Opus1mMergeNotice } from './Opus1mMergeNotice.js';
|
||||
import { GateOverridesWarning } from './GateOverridesWarning.js';
|
||||
import { ExperimentEnrollmentNotice } from './ExperimentEnrollmentNotice.js';
|
||||
import { feature } from 'bun:bundle';
|
||||
|
||||
// Conditional require so ChannelsNotice.tsx tree-shakes when both flags are
|
||||
// false. A module-scope helper component inside a feature() ternary does NOT
|
||||
@@ -61,128 +54,98 @@ import { feature } from 'bun:bundle'
|
||||
const ChannelsNoticeModule =
|
||||
feature('KAIROS') || feature('KAIROS_CHANNELS')
|
||||
? (require('./ChannelsNotice.js') as typeof import('./ChannelsNotice.js'))
|
||||
: null
|
||||
: null;
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'
|
||||
import {
|
||||
useShowGuestPassesUpsell,
|
||||
incrementGuestPassesSeenCount,
|
||||
} from './GuestPassesUpsell.js'
|
||||
import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js';
|
||||
import { useShowGuestPassesUpsell, incrementGuestPassesSeenCount } from './GuestPassesUpsell.js';
|
||||
import {
|
||||
useShowOverageCreditUpsell,
|
||||
incrementOverageCreditUpsellSeenCount,
|
||||
createOverageCreditFeed,
|
||||
} from './OverageCreditUpsell.js'
|
||||
import { plural } from '../../utils/stringUtils.js'
|
||||
import { useAppState } from '../../state/AppState.js'
|
||||
import { getEffortSuffix } from '../../utils/effort.js'
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'
|
||||
import { renderModelSetting } from '../../utils/model/model.js'
|
||||
} from './OverageCreditUpsell.js';
|
||||
import { plural } from '../../utils/stringUtils.js';
|
||||
import { useAppState } from '../../state/AppState.js';
|
||||
import { getEffortSuffix } from '../../utils/effort.js';
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
|
||||
import { renderModelSetting } from '../../utils/model/model.js';
|
||||
|
||||
const LEFT_PANEL_MAX_WIDTH = 50
|
||||
const LEFT_PANEL_MAX_WIDTH = 50;
|
||||
|
||||
export function LogoV2(): React.ReactNode {
|
||||
const activities = getRecentActivitySync()
|
||||
const username = getGlobalConfig().oauthAccount?.displayName ?? ''
|
||||
const activities = getRecentActivitySync();
|
||||
const username = getGlobalConfig().oauthAccount?.displayName ?? '';
|
||||
|
||||
const { columns } = useTerminalSize()
|
||||
const showOnboarding = shouldShowProjectOnboarding()
|
||||
const showSandboxStatus = SandboxManager.isSandboxingEnabled()
|
||||
const showGuestPassesUpsell = useShowGuestPassesUpsell()
|
||||
const showOverageCreditUpsell = useShowOverageCreditUpsell()
|
||||
const agent = useAppState(s => s.agent)
|
||||
const effortValue = useAppState(s => s.effortValue)
|
||||
const { columns } = useTerminalSize();
|
||||
const showOnboarding = shouldShowProjectOnboarding();
|
||||
const showSandboxStatus = SandboxManager.isSandboxingEnabled();
|
||||
const showGuestPassesUpsell = useShowGuestPassesUpsell();
|
||||
const showOverageCreditUpsell = useShowOverageCreditUpsell();
|
||||
const agent = useAppState(s => s.agent);
|
||||
const effortValue = useAppState(s => s.effortValue);
|
||||
|
||||
const config = getGlobalConfig()
|
||||
const config = getGlobalConfig();
|
||||
|
||||
let changelog: string[]
|
||||
let changelog: string[];
|
||||
try {
|
||||
changelog = getRecentReleaseNotesSync(3)
|
||||
changelog = getRecentReleaseNotesSync(3);
|
||||
} catch {
|
||||
changelog = []
|
||||
changelog = [];
|
||||
}
|
||||
|
||||
// Get company announcements and select one:
|
||||
// - First startup (numStartups === 1): show first announcement
|
||||
// - All other startups: randomly select from announcements
|
||||
const [announcement] = useState(() => {
|
||||
const announcements = getInitialSettings().companyAnnouncements
|
||||
if (!announcements || announcements.length === 0) return undefined
|
||||
const announcements = getInitialSettings().companyAnnouncements;
|
||||
if (!announcements || announcements.length === 0) return undefined;
|
||||
return config.numStartups === 1
|
||||
? announcements[0]
|
||||
: announcements[Math.floor(Math.random() * announcements.length)]
|
||||
})
|
||||
const { hasReleaseNotes } = checkForReleaseNotesSync(
|
||||
config.lastReleaseNotesSeen,
|
||||
)
|
||||
: announcements[Math.floor(Math.random() * announcements.length)];
|
||||
});
|
||||
const { hasReleaseNotes } = checkForReleaseNotesSync(config.lastReleaseNotesSeen);
|
||||
|
||||
useEffect(() => {
|
||||
const currentConfig = getGlobalConfig()
|
||||
const currentConfig = getGlobalConfig();
|
||||
if (currentConfig.lastReleaseNotesSeen === MACRO.VERSION) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
saveGlobalConfig(current => {
|
||||
if (current.lastReleaseNotesSeen === MACRO.VERSION) return current
|
||||
return { ...current, lastReleaseNotesSeen: MACRO.VERSION }
|
||||
})
|
||||
if (current.lastReleaseNotesSeen === MACRO.VERSION) return current;
|
||||
return { ...current, lastReleaseNotesSeen: MACRO.VERSION };
|
||||
});
|
||||
if (showOnboarding) {
|
||||
incrementProjectOnboardingSeenCount()
|
||||
incrementProjectOnboardingSeenCount();
|
||||
}
|
||||
}, [config, showOnboarding])
|
||||
}, [config, showOnboarding]);
|
||||
|
||||
// In condensed mode (early-return below renders <CondensedLogo/>),
|
||||
// CondensedLogo's own useEffect handles the impression count. Skipping
|
||||
// here avoids double-counting since hooks fire before the early return.
|
||||
const isCondensedMode =
|
||||
!hasReleaseNotes &&
|
||||
!showOnboarding &&
|
||||
!isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO)
|
||||
const isCondensedMode = !hasReleaseNotes && !showOnboarding && !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO);
|
||||
|
||||
useEffect(() => {
|
||||
if (showGuestPassesUpsell && !showOnboarding && !isCondensedMode) {
|
||||
incrementGuestPassesSeenCount()
|
||||
incrementGuestPassesSeenCount();
|
||||
}
|
||||
}, [showGuestPassesUpsell, showOnboarding, isCondensedMode])
|
||||
}, [showGuestPassesUpsell, showOnboarding, isCondensedMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
showOverageCreditUpsell &&
|
||||
!showOnboarding &&
|
||||
!showGuestPassesUpsell &&
|
||||
!isCondensedMode
|
||||
) {
|
||||
incrementOverageCreditUpsellSeenCount()
|
||||
if (showOverageCreditUpsell && !showOnboarding && !showGuestPassesUpsell && !isCondensedMode) {
|
||||
incrementOverageCreditUpsellSeenCount();
|
||||
}
|
||||
}, [
|
||||
showOverageCreditUpsell,
|
||||
showOnboarding,
|
||||
showGuestPassesUpsell,
|
||||
isCondensedMode,
|
||||
])
|
||||
}, [showOverageCreditUpsell, showOnboarding, showGuestPassesUpsell, isCondensedMode]);
|
||||
|
||||
const model = useMainLoopModel()
|
||||
const fullModelDisplayName = renderModelSetting(model)
|
||||
const {
|
||||
version,
|
||||
cwd,
|
||||
billingType,
|
||||
agentName: agentNameFromSettings,
|
||||
} = getLogoDisplayData()
|
||||
const model = useMainLoopModel();
|
||||
const fullModelDisplayName = renderModelSetting(model);
|
||||
const { version, cwd, billingType, agentName: agentNameFromSettings } = getLogoDisplayData();
|
||||
// Prefer AppState.agent (set from --agent CLI flag) over settings
|
||||
const agentName = agent ?? agentNameFromSettings
|
||||
const agentName = agent ?? agentNameFromSettings;
|
||||
// -20 to account for the max length of subscription name " · Claude Enterprise".
|
||||
const effortSuffix = getEffortSuffix(model, effortValue)
|
||||
const modelDisplayName = truncate(
|
||||
fullModelDisplayName + effortSuffix,
|
||||
LEFT_PANEL_MAX_WIDTH - 20,
|
||||
)
|
||||
const effortSuffix = getEffortSuffix(model, effortValue);
|
||||
const modelDisplayName = truncate(fullModelDisplayName + effortSuffix, LEFT_PANEL_MAX_WIDTH - 20);
|
||||
|
||||
// Show condensed logo if no new changelog and not showing onboarding and not forcing full logo
|
||||
if (
|
||||
!hasReleaseNotes &&
|
||||
!showOnboarding &&
|
||||
!isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO)
|
||||
) {
|
||||
if (!hasReleaseNotes && !showOnboarding && !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO)) {
|
||||
return (
|
||||
<>
|
||||
<CondensedLogo />
|
||||
@@ -192,17 +155,13 @@ export function LogoV2(): React.ReactNode {
|
||||
{isDebugMode() && (
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
<Text color="warning">Debug mode enabled</Text>
|
||||
<Text dimColor>
|
||||
Logging to: {isDebugToStdErr() ? 'stderr' : getDebugLogPath()}
|
||||
</Text>
|
||||
<Text dimColor>Logging to: {isDebugToStdErr() ? 'stderr' : getDebugLogPath()}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<EmergencyTip />
|
||||
{process.env.CLAUDE_CODE_TMUX_SESSION && (
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
<Text dimColor>
|
||||
tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION}
|
||||
</Text>
|
||||
<Text dimColor>tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION}</Text>
|
||||
<Text dimColor>
|
||||
{process.env.CLAUDE_CODE_TMUX_PREFIX_CONFLICTS
|
||||
? `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} ${process.env.CLAUDE_CODE_TMUX_PREFIX} d (press prefix twice - Claude uses ${process.env.CLAUDE_CODE_TMUX_PREFIX})`
|
||||
@@ -213,9 +172,7 @@ export function LogoV2(): React.ReactNode {
|
||||
{announcement && (
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
{!process.env.IS_DEMO && config.oauthAccount?.organizationName && (
|
||||
<Text dimColor>
|
||||
Message from {config.oauthAccount.organizationName}:
|
||||
</Text>
|
||||
<Text dimColor>Message from {config.oauthAccount.organizationName}:</Text>
|
||||
)}
|
||||
<Text>{announcement}</Text>
|
||||
</Box>
|
||||
@@ -228,51 +185,41 @@ export function LogoV2(): React.ReactNode {
|
||||
{process.env.USER_TYPE === 'ant' && !process.env.DEMO_VERSION && (
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
<Text color="warning">[ANT-ONLY] Logs:</Text>
|
||||
<Text dimColor>
|
||||
API calls: {getDisplayPath(getDumpPromptsPath())}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Debug logs: {getDisplayPath(getDebugLogPath())}
|
||||
</Text>
|
||||
<Text dimColor>API calls: {getDisplayPath(getDumpPromptsPath())}</Text>
|
||||
<Text dimColor>Debug logs: {getDisplayPath(getDebugLogPath())}</Text>
|
||||
{isDetailedProfilingEnabled() && (
|
||||
<Text dimColor>
|
||||
Startup Perf: {getDisplayPath(getStartupPerfLogPath())}
|
||||
</Text>
|
||||
<Text dimColor>Startup Perf: {getDisplayPath(getStartupPerfLogPath())}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{process.env.USER_TYPE === 'ant' && <GateOverridesWarning />}
|
||||
{process.env.USER_TYPE === 'ant' && <ExperimentEnrollmentNotice />}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate layout and display values
|
||||
const layoutMode = getLayoutMode(columns)
|
||||
const layoutMode = getLayoutMode(columns);
|
||||
|
||||
const userTheme = resolveThemeSetting(getGlobalConfig().theme)
|
||||
const borderTitle = ` ${color('claude', userTheme)('Claude Code')} ${color('inactive', userTheme)(`v${version}`)} `
|
||||
const compactBorderTitle = color('claude', userTheme)(' Claude Code ')
|
||||
const userTheme = resolveThemeSetting(getGlobalConfig().theme);
|
||||
const borderTitle = ` ${color('claude', userTheme)('Claude Code')} ${color('inactive', userTheme)(`v${version}`)} `;
|
||||
const compactBorderTitle = color('claude', userTheme)(' Claude Code ');
|
||||
|
||||
// Early return for compact mode
|
||||
if (layoutMode === 'compact') {
|
||||
const layoutWidth = 4 // border + padding
|
||||
let welcomeMessage = formatWelcomeMessage(username)
|
||||
const layoutWidth = 4; // border + padding
|
||||
let welcomeMessage = formatWelcomeMessage(username);
|
||||
if (stringWidth(welcomeMessage) > columns - layoutWidth) {
|
||||
welcomeMessage = formatWelcomeMessage(null)
|
||||
welcomeMessage = formatWelcomeMessage(null);
|
||||
}
|
||||
|
||||
// Calculate cwd width accounting for agent name if present
|
||||
const separator = ' · '
|
||||
const atPrefix = '@'
|
||||
const separator = ' · ';
|
||||
const atPrefix = '@';
|
||||
const cwdAvailableWidth = agentName
|
||||
? columns -
|
||||
layoutWidth -
|
||||
atPrefix.length -
|
||||
stringWidth(agentName) -
|
||||
separator.length
|
||||
: columns - layoutWidth
|
||||
const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10))
|
||||
? columns - layoutWidth - atPrefix.length - stringWidth(agentName) - separator.length
|
||||
: columns - layoutWidth;
|
||||
const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10));
|
||||
// OffscreenFreeze: logo is the first thing to enter scrollback; useMainLoopModel()
|
||||
// subscribes to model changes and getLogoDisplayData() reads cwd/subscription —
|
||||
// any change while in scrollback forces a full reset.
|
||||
@@ -300,9 +247,7 @@ export function LogoV2(): React.ReactNode {
|
||||
</Box>
|
||||
<Text dimColor>{modelDisplayName}</Text>
|
||||
<Text dimColor>{billingType}</Text>
|
||||
<Text dimColor>
|
||||
{agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd}
|
||||
</Text>
|
||||
<Text dimColor>{agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd}</Text>
|
||||
</Box>
|
||||
</OffscreenFreeze>
|
||||
<VoiceModeNotice />
|
||||
@@ -310,45 +255,32 @@ export function LogoV2(): React.ReactNode {
|
||||
{ChannelsNoticeModule && <ChannelsNoticeModule.ChannelsNotice />}
|
||||
{showSandboxStatus && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color="warning">
|
||||
Your bash commands will be sandboxed. Disable with /sandbox.
|
||||
</Text>
|
||||
<Text color="warning">Your bash commands will be sandboxed. Disable with /sandbox.</Text>
|
||||
</Box>
|
||||
)}
|
||||
{process.env.USER_TYPE === 'ant' && <GateOverridesWarning />}
|
||||
{process.env.USER_TYPE === 'ant' && <ExperimentEnrollmentNotice />}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const welcomeMessage = formatWelcomeMessage(username)
|
||||
const welcomeMessage = formatWelcomeMessage(username);
|
||||
const modelLine =
|
||||
!process.env.IS_DEMO && config.oauthAccount?.organizationName
|
||||
? `${modelDisplayName} · ${billingType} · ${config.oauthAccount.organizationName}`
|
||||
: `${modelDisplayName} · ${billingType}`
|
||||
: `${modelDisplayName} · ${billingType}`;
|
||||
// Calculate cwd width accounting for agent name if present
|
||||
const cwdSeparator = ' · '
|
||||
const cwdAtPrefix = '@'
|
||||
const cwdSeparator = ' · ';
|
||||
const cwdAtPrefix = '@';
|
||||
const cwdAvailableWidth = agentName
|
||||
? LEFT_PANEL_MAX_WIDTH -
|
||||
cwdAtPrefix.length -
|
||||
stringWidth(agentName) -
|
||||
cwdSeparator.length
|
||||
: LEFT_PANEL_MAX_WIDTH
|
||||
const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10))
|
||||
const cwdLine = agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd
|
||||
const optimalLeftWidth = calculateOptimalLeftWidth(
|
||||
welcomeMessage,
|
||||
cwdLine,
|
||||
modelLine,
|
||||
)
|
||||
? LEFT_PANEL_MAX_WIDTH - cwdAtPrefix.length - stringWidth(agentName) - cwdSeparator.length
|
||||
: LEFT_PANEL_MAX_WIDTH;
|
||||
const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10));
|
||||
const cwdLine = agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd;
|
||||
const optimalLeftWidth = calculateOptimalLeftWidth(welcomeMessage, cwdLine, modelLine);
|
||||
|
||||
// Calculate layout dimensions
|
||||
const { leftWidth, rightWidth } = calculateLayoutDimensions(
|
||||
columns,
|
||||
layoutMode,
|
||||
optimalLeftWidth,
|
||||
)
|
||||
const { leftWidth, rightWidth } = calculateLayoutDimensions(columns, layoutMode, optimalLeftWidth);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -365,11 +297,7 @@ export function LogoV2(): React.ReactNode {
|
||||
}}
|
||||
>
|
||||
{/* Main content */}
|
||||
<Box
|
||||
flexDirection={layoutMode === 'horizontal' ? 'row' : 'column'}
|
||||
paddingX={1}
|
||||
gap={1}
|
||||
>
|
||||
<Box flexDirection={layoutMode === 'horizontal' ? 'row' : 'column'} paddingX={1} gap={1}>
|
||||
{/* Left Panel */}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
@@ -408,24 +336,12 @@ export function LogoV2(): React.ReactNode {
|
||||
<FeedColumn
|
||||
feeds={
|
||||
showOnboarding
|
||||
? [
|
||||
createProjectOnboardingFeed(getSteps()),
|
||||
createRecentActivityFeed(activities),
|
||||
]
|
||||
? [createProjectOnboardingFeed(getSteps()), createRecentActivityFeed(activities)]
|
||||
: showGuestPassesUpsell
|
||||
? [
|
||||
createRecentActivityFeed(activities),
|
||||
createGuestPassesFeed(),
|
||||
]
|
||||
? [createRecentActivityFeed(activities), createGuestPassesFeed()]
|
||||
: showOverageCreditUpsell
|
||||
? [
|
||||
createRecentActivityFeed(activities),
|
||||
createOverageCreditFeed(),
|
||||
]
|
||||
: [
|
||||
createRecentActivityFeed(activities),
|
||||
createWhatsNewFeed(changelog),
|
||||
]
|
||||
? [createRecentActivityFeed(activities), createOverageCreditFeed()]
|
||||
: [createRecentActivityFeed(activities), createWhatsNewFeed(changelog)]
|
||||
}
|
||||
maxWidth={rightWidth}
|
||||
/>
|
||||
@@ -439,17 +355,13 @@ export function LogoV2(): React.ReactNode {
|
||||
{isDebugMode() && (
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
<Text color="warning">Debug mode enabled</Text>
|
||||
<Text dimColor>
|
||||
Logging to: {isDebugToStdErr() ? 'stderr' : getDebugLogPath()}
|
||||
</Text>
|
||||
<Text dimColor>Logging to: {isDebugToStdErr() ? 'stderr' : getDebugLogPath()}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<EmergencyTip />
|
||||
{process.env.CLAUDE_CODE_TMUX_SESSION && (
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
<Text dimColor>
|
||||
tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION}
|
||||
</Text>
|
||||
<Text dimColor>tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION}</Text>
|
||||
<Text dimColor>
|
||||
{process.env.CLAUDE_CODE_TMUX_PREFIX_CONFLICTS
|
||||
? `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} ${process.env.CLAUDE_CODE_TMUX_PREFIX} d (press prefix twice - Claude uses ${process.env.CLAUDE_CODE_TMUX_PREFIX})`
|
||||
@@ -460,18 +372,14 @@ export function LogoV2(): React.ReactNode {
|
||||
{announcement && (
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
{!process.env.IS_DEMO && config.oauthAccount?.organizationName && (
|
||||
<Text dimColor>
|
||||
Message from {config.oauthAccount.organizationName}:
|
||||
</Text>
|
||||
<Text dimColor>Message from {config.oauthAccount.organizationName}:</Text>
|
||||
)}
|
||||
<Text>{announcement}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{showSandboxStatus && (
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
<Text color="warning">
|
||||
Your bash commands will be sandboxed. Disable with /sandbox.
|
||||
</Text>
|
||||
<Text color="warning">Your bash commands will be sandboxed. Disable with /sandbox.</Text>
|
||||
</Box>
|
||||
)}
|
||||
{process.env.USER_TYPE === 'ant' && !process.env.DEMO_VERSION && (
|
||||
@@ -482,20 +390,15 @@ export function LogoV2(): React.ReactNode {
|
||||
{process.env.USER_TYPE === 'ant' && !process.env.DEMO_VERSION && (
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
<Text color="warning">[ANT-ONLY] Logs:</Text>
|
||||
<Text dimColor>
|
||||
API calls: {getDisplayPath(getDumpPromptsPath())}
|
||||
</Text>
|
||||
<Text dimColor>API calls: {getDisplayPath(getDumpPromptsPath())}</Text>
|
||||
<Text dimColor>Debug logs: {getDisplayPath(getDebugLogPath())}</Text>
|
||||
{isDetailedProfilingEnabled() && (
|
||||
<Text dimColor>
|
||||
Startup Perf: {getDisplayPath(getStartupPerfLogPath())}
|
||||
</Text>
|
||||
<Text dimColor>Startup Perf: {getDisplayPath(getStartupPerfLogPath())}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{process.env.USER_TYPE === 'ant' && <GateOverridesWarning />}
|
||||
{process.env.USER_TYPE === 'ant' && <ExperimentEnrollmentNotice />}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +1,35 @@
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { UP_ARROW } from '../../constants/figures.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { isOpus1mMergeEnabled } from '../../utils/model/model.js'
|
||||
import { AnimatedAsterisk } from './AnimatedAsterisk.js'
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { UP_ARROW } from '../../constants/figures.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
import { isOpus1mMergeEnabled } from '../../utils/model/model.js';
|
||||
import { AnimatedAsterisk } from './AnimatedAsterisk.js';
|
||||
|
||||
const MAX_SHOW_COUNT = 6
|
||||
const MAX_SHOW_COUNT = 6;
|
||||
|
||||
export function shouldShowOpus1mMergeNotice(): boolean {
|
||||
return (
|
||||
isOpus1mMergeEnabled() &&
|
||||
(getGlobalConfig().opus1mMergeNoticeSeenCount ?? 0) < MAX_SHOW_COUNT
|
||||
)
|
||||
return isOpus1mMergeEnabled() && (getGlobalConfig().opus1mMergeNoticeSeenCount ?? 0) < MAX_SHOW_COUNT;
|
||||
}
|
||||
|
||||
export function Opus1mMergeNotice(): React.ReactNode {
|
||||
const [show] = useState(shouldShowOpus1mMergeNotice)
|
||||
const [show] = useState(shouldShowOpus1mMergeNotice);
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) return
|
||||
const newCount = (getGlobalConfig().opus1mMergeNoticeSeenCount ?? 0) + 1
|
||||
if (!show) return;
|
||||
const newCount = (getGlobalConfig().opus1mMergeNoticeSeenCount ?? 0) + 1;
|
||||
saveGlobalConfig(prev => {
|
||||
if ((prev.opus1mMergeNoticeSeenCount ?? 0) >= newCount) return prev
|
||||
return { ...prev, opus1mMergeNoticeSeenCount: newCount }
|
||||
})
|
||||
}, [show])
|
||||
if ((prev.opus1mMergeNoticeSeenCount ?? 0) >= newCount) return prev;
|
||||
return { ...prev, opus1mMergeNoticeSeenCount: newCount };
|
||||
});
|
||||
}, [show]);
|
||||
|
||||
if (!show) return null
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<Box paddingLeft={2}>
|
||||
<AnimatedAsterisk char={UP_ARROW} />
|
||||
<Text dimColor>
|
||||
{' '}
|
||||
Opus now defaults to 1M context · 5x more room, same pricing
|
||||
</Text>
|
||||
<Text dimColor> Opus now defaults to 1M context · 5x more room, same pricing</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Text } from '@anthropic/ink'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Text } from '@anthropic/ink';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import {
|
||||
formatGrantAmount,
|
||||
getCachedOverageCreditGrant,
|
||||
refreshOverageCreditGrantCache,
|
||||
} from '../../services/api/overageCreditGrant.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { truncate } from '../../utils/format.js'
|
||||
import type { FeedConfig } from './Feed.js'
|
||||
} from '../../services/api/overageCreditGrant.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
import { truncate } from '../../utils/format.js';
|
||||
import type { FeedConfig } from './Feed.js';
|
||||
|
||||
const MAX_IMPRESSIONS = 3
|
||||
const MAX_IMPRESSIONS = 3;
|
||||
|
||||
/**
|
||||
* Whether to show the overage credit upsell on any surface.
|
||||
@@ -29,20 +29,19 @@ const MAX_IMPRESSIONS = 3
|
||||
* (welcome feed, tips).
|
||||
*/
|
||||
export function isEligibleForOverageCreditGrant(): boolean {
|
||||
const info = getCachedOverageCreditGrant()
|
||||
if (!info || !info.available || info.granted) return false
|
||||
return formatGrantAmount(info) !== null
|
||||
const info = getCachedOverageCreditGrant();
|
||||
if (!info || !info.available || info.granted) return false;
|
||||
return formatGrantAmount(info) !== null;
|
||||
}
|
||||
|
||||
export function shouldShowOverageCreditUpsell(): boolean {
|
||||
if (!isEligibleForOverageCreditGrant()) return false
|
||||
if (!isEligibleForOverageCreditGrant()) return false;
|
||||
|
||||
const config = getGlobalConfig()
|
||||
if (config.hasVisitedExtraUsage) return false
|
||||
if ((config.overageCreditUpsellSeenCount ?? 0) >= MAX_IMPRESSIONS)
|
||||
return false
|
||||
const config = getGlobalConfig();
|
||||
if (config.hasVisitedExtraUsage) return false;
|
||||
if ((config.overageCreditUpsellSeenCount ?? 0) >= MAX_IMPRESSIONS) return false;
|
||||
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,78 +49,71 @@ export function shouldShowOverageCreditUpsell(): boolean {
|
||||
* unconditionally on mount — it no-ops if cache is fresh.
|
||||
*/
|
||||
export function maybeRefreshOverageCreditCache(): void {
|
||||
if (getCachedOverageCreditGrant() !== null) return
|
||||
void refreshOverageCreditGrantCache()
|
||||
if (getCachedOverageCreditGrant() !== null) return;
|
||||
void refreshOverageCreditGrantCache();
|
||||
}
|
||||
|
||||
export function useShowOverageCreditUpsell(): boolean {
|
||||
const [show] = useState(() => {
|
||||
maybeRefreshOverageCreditCache()
|
||||
return shouldShowOverageCreditUpsell()
|
||||
})
|
||||
return show
|
||||
maybeRefreshOverageCreditCache();
|
||||
return shouldShowOverageCreditUpsell();
|
||||
});
|
||||
return show;
|
||||
}
|
||||
|
||||
export function incrementOverageCreditUpsellSeenCount(): void {
|
||||
let newCount = 0
|
||||
let newCount = 0;
|
||||
saveGlobalConfig(prev => {
|
||||
newCount = (prev.overageCreditUpsellSeenCount ?? 0) + 1
|
||||
newCount = (prev.overageCreditUpsellSeenCount ?? 0) + 1;
|
||||
return {
|
||||
...prev,
|
||||
overageCreditUpsellSeenCount: newCount,
|
||||
}
|
||||
})
|
||||
logEvent('tengu_overage_credit_upsell_shown', { seen_count: newCount })
|
||||
};
|
||||
});
|
||||
logEvent('tengu_overage_credit_upsell_shown', { seen_count: newCount });
|
||||
}
|
||||
|
||||
// Copy from "OC & Bulk Overages copy" doc (#6 — CLI /usage)
|
||||
function getUsageText(amount: string): string {
|
||||
return `${amount} in extra usage for third-party apps · /extra-usage`
|
||||
return `${amount} in extra usage for third-party apps · /extra-usage`;
|
||||
}
|
||||
|
||||
// Copy from "OC & Bulk Overages copy" doc (#4 — CLI Welcome screen).
|
||||
// Char budgets: title ≤19, subtitle ≤48.
|
||||
const FEED_SUBTITLE = 'On us. Works on third-party apps · /extra-usage'
|
||||
const FEED_SUBTITLE = 'On us. Works on third-party apps · /extra-usage';
|
||||
|
||||
function getFeedTitle(amount: string): string {
|
||||
return `${amount} in extra usage`
|
||||
return `${amount} in extra usage`;
|
||||
}
|
||||
|
||||
type Props = { maxWidth?: number; twoLine?: boolean }
|
||||
type Props = { maxWidth?: number; twoLine?: boolean };
|
||||
|
||||
export function OverageCreditUpsell({
|
||||
maxWidth,
|
||||
twoLine,
|
||||
}: Props): React.ReactNode {
|
||||
const info = getCachedOverageCreditGrant()
|
||||
if (!info) return null
|
||||
const amount = formatGrantAmount(info)
|
||||
if (!amount) return null
|
||||
export function OverageCreditUpsell({ maxWidth, twoLine }: Props): React.ReactNode {
|
||||
const info = getCachedOverageCreditGrant();
|
||||
if (!info) return null;
|
||||
const amount = formatGrantAmount(info);
|
||||
if (!amount) return null;
|
||||
|
||||
if (twoLine) {
|
||||
const title = getFeedTitle(amount)
|
||||
const title = getFeedTitle(amount);
|
||||
return (
|
||||
<>
|
||||
<Text color="claude">
|
||||
{maxWidth ? truncate(title, maxWidth) : title}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{maxWidth ? truncate(FEED_SUBTITLE, maxWidth) : FEED_SUBTITLE}
|
||||
</Text>
|
||||
<Text color="claude">{maxWidth ? truncate(title, maxWidth) : title}</Text>
|
||||
<Text dimColor>{maxWidth ? truncate(FEED_SUBTITLE, maxWidth) : FEED_SUBTITLE}</Text>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const text = getUsageText(amount)
|
||||
const display = maxWidth ? truncate(text, maxWidth) : text
|
||||
const highlightLen = Math.min(getFeedTitle(amount).length, display.length)
|
||||
const text = getUsageText(amount);
|
||||
const display = maxWidth ? truncate(text, maxWidth) : text;
|
||||
const highlightLen = Math.min(getFeedTitle(amount).length, display.length);
|
||||
|
||||
return (
|
||||
<Text dimColor>
|
||||
<Text color="claude">{display.slice(0, highlightLen)}</Text>
|
||||
{display.slice(highlightLen)}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,9 +124,9 @@ export function OverageCreditUpsell({
|
||||
* Char budgets: title ≤19, subtitle ≤48.
|
||||
*/
|
||||
export function createOverageCreditFeed(): FeedConfig {
|
||||
const info = getCachedOverageCreditGrant()
|
||||
const amount = info ? formatGrantAmount(info) : null
|
||||
const title = amount ? getFeedTitle(amount) : 'extra usage credit'
|
||||
const info = getCachedOverageCreditGrant();
|
||||
const amount = info ? formatGrantAmount(info) : null;
|
||||
const title = amount ? getFeedTitle(amount) : 'extra usage credit';
|
||||
return {
|
||||
title,
|
||||
lines: [],
|
||||
@@ -142,5 +134,5 @@ export function createOverageCreditFeed(): FeedConfig {
|
||||
content: <Text dimColor>{FEED_SUBTITLE}</Text>,
|
||||
width: Math.max(title.length, FEED_SUBTITLE.length),
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { getInitialSettings } from '../../utils/settings/settings.js'
|
||||
import { isVoiceModeEnabled } from '../../voice/voiceModeEnabled.js'
|
||||
import { AnimatedAsterisk } from './AnimatedAsterisk.js'
|
||||
import { shouldShowOpus1mMergeNotice } from './Opus1mMergeNotice.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
import { getInitialSettings } from '../../utils/settings/settings.js';
|
||||
import { isVoiceModeEnabled } from '../../voice/voiceModeEnabled.js';
|
||||
import { AnimatedAsterisk } from './AnimatedAsterisk.js';
|
||||
import { shouldShowOpus1mMergeNotice } from './Opus1mMergeNotice.js';
|
||||
|
||||
const MAX_SHOW_COUNT = 3
|
||||
const MAX_SHOW_COUNT = 3;
|
||||
|
||||
export function VoiceModeNotice(): React.ReactNode {
|
||||
// Positive ternary pattern — see docs/feature-gating.md.
|
||||
// All strings must be inside the guarded branch for dead-code elimination.
|
||||
return feature('VOICE_MODE') ? <VoiceModeNoticeInner /> : null
|
||||
return feature('VOICE_MODE') ? <VoiceModeNoticeInner /> : null;
|
||||
}
|
||||
|
||||
function VoiceModeNoticeInner(): React.ReactNode {
|
||||
@@ -28,24 +28,24 @@ function VoiceModeNoticeInner(): React.ReactNode {
|
||||
getInitialSettings().voiceEnabled !== true &&
|
||||
(getGlobalConfig().voiceNoticeSeenCount ?? 0) < MAX_SHOW_COUNT &&
|
||||
!shouldShowOpus1mMergeNotice(),
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) return
|
||||
if (!show) return;
|
||||
// Capture outside the updater so StrictMode's second invocation is a no-op.
|
||||
const newCount = (getGlobalConfig().voiceNoticeSeenCount ?? 0) + 1
|
||||
const newCount = (getGlobalConfig().voiceNoticeSeenCount ?? 0) + 1;
|
||||
saveGlobalConfig(prev => {
|
||||
if ((prev.voiceNoticeSeenCount ?? 0) >= newCount) return prev
|
||||
return { ...prev, voiceNoticeSeenCount: newCount }
|
||||
})
|
||||
}, [show])
|
||||
if ((prev.voiceNoticeSeenCount ?? 0) >= newCount) return prev;
|
||||
return { ...prev, voiceNoticeSeenCount: newCount };
|
||||
});
|
||||
}, [show]);
|
||||
|
||||
if (!show) return null
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<Box paddingLeft={2}>
|
||||
<AnimatedAsterisk />
|
||||
<Text dimColor> Voice mode is now available · /voice to enable</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import React from 'react'
|
||||
import { Box, Text, useTheme } from '@anthropic/ink'
|
||||
import { env } from '../../utils/env.js'
|
||||
import React from 'react';
|
||||
import { Box, Text, useTheme } from '@anthropic/ink';
|
||||
import { env } from '../../utils/env.js';
|
||||
|
||||
const WELCOME_V2_WIDTH = 58
|
||||
const WELCOME_V2_WIDTH = 58;
|
||||
|
||||
export function WelcomeV2(): React.ReactNode {
|
||||
const [theme] = useTheme()
|
||||
const welcomeMessage = 'Welcome to Claude Code'
|
||||
const [theme] = useTheme();
|
||||
const welcomeMessage = 'Welcome to Claude Code';
|
||||
|
||||
if (env.terminal === 'Apple_Terminal') {
|
||||
return (
|
||||
<AppleTerminalWelcomeV2 theme={theme} welcomeMessage={welcomeMessage} />
|
||||
)
|
||||
return <AppleTerminalWelcomeV2 theme={theme} welcomeMessage={welcomeMessage} />;
|
||||
}
|
||||
|
||||
if (['light', 'light-daltonized', 'light-ansi'].includes(theme)) {
|
||||
@@ -22,30 +20,14 @@ export function WelcomeV2(): React.ReactNode {
|
||||
<Text color="claude">{welcomeMessage} </Text>
|
||||
<Text dimColor>v{MACRO.VERSION} </Text>
|
||||
</Text>
|
||||
<Text>
|
||||
{'…………………………………………………………………………………………………………………………………………………………'}
|
||||
</Text>
|
||||
<Text>
|
||||
{' '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' ░░░░░░ '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' ░░░ ░░░░░░░░░░ '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' ░░░░░░░░░░░░░░░░░░░ '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' '}
|
||||
</Text>
|
||||
<Text>{'…………………………………………………………………………………………………………………………………………………………'}</Text>
|
||||
<Text>{' '}</Text>
|
||||
<Text>{' '}</Text>
|
||||
<Text>{' '}</Text>
|
||||
<Text>{' ░░░░░░ '}</Text>
|
||||
<Text>{' ░░░ ░░░░░░░░░░ '}</Text>
|
||||
<Text>{' ░░░░░░░░░░░░░░░░░░░ '}</Text>
|
||||
<Text>{' '}</Text>
|
||||
<Text>
|
||||
<Text dimColor>{' ░░░░'}</Text>
|
||||
<Text>{' ██ '}</Text>
|
||||
@@ -54,9 +36,7 @@ export function WelcomeV2(): React.ReactNode {
|
||||
<Text dimColor>{' ░░░░░░░░░░'}</Text>
|
||||
<Text>{' ██▒▒██ '}</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
{' ▒▒ ██ ▒'}
|
||||
</Text>
|
||||
<Text>{' ▒▒ ██ ▒'}</Text>
|
||||
<Text>
|
||||
{' '}
|
||||
<Text color="clawd_body"> █████████ </Text>
|
||||
@@ -81,7 +61,7 @@ export function WelcomeV2(): React.ReactNode {
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -91,41 +71,21 @@ export function WelcomeV2(): React.ReactNode {
|
||||
<Text color="claude">{welcomeMessage} </Text>
|
||||
<Text dimColor>v{MACRO.VERSION} </Text>
|
||||
</Text>
|
||||
<Text>
|
||||
{'…………………………………………………………………………………………………………………………………………………………'}
|
||||
</Text>
|
||||
<Text>
|
||||
{' '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' * █████▓▓░ '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' * ███▓░ ░░ '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' ░░░░░░ ███▓░ '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' ░░░ ░░░░░░░░░░ ███▓░ '}
|
||||
</Text>
|
||||
<Text>{'…………………………………………………………………………………………………………………………………………………………'}</Text>
|
||||
<Text>{' '}</Text>
|
||||
<Text>{' * █████▓▓░ '}</Text>
|
||||
<Text>{' * ███▓░ ░░ '}</Text>
|
||||
<Text>{' ░░░░░░ ███▓░ '}</Text>
|
||||
<Text>{' ░░░ ░░░░░░░░░░ ███▓░ '}</Text>
|
||||
<Text>
|
||||
<Text>{' ░░░░░░░░░░░░░░░░░░░ '}</Text>
|
||||
<Text bold>*</Text>
|
||||
<Text>{' ██▓░░ ▓ '}</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
{' ░▓▓███▓▓░ '}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{' * ░░░░ '}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{' ░░░░░░░░ '}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{' ░░░░░░░░░░░░░░░░ '}
|
||||
</Text>
|
||||
<Text>{' ░▓▓███▓▓░ '}</Text>
|
||||
<Text dimColor>{' * ░░░░ '}</Text>
|
||||
<Text dimColor>{' ░░░░░░░░ '}</Text>
|
||||
<Text dimColor>{' ░░░░░░░░░░░░░░░░ '}</Text>
|
||||
<Text>
|
||||
{' '}
|
||||
<Text color="clawd_body"> █████████ </Text>
|
||||
@@ -152,21 +112,16 @@ export function WelcomeV2(): React.ReactNode {
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
type AppleTerminalWelcomeV2Props = {
|
||||
theme: string
|
||||
welcomeMessage: string
|
||||
}
|
||||
theme: string;
|
||||
welcomeMessage: string;
|
||||
};
|
||||
|
||||
function AppleTerminalWelcomeV2({
|
||||
theme,
|
||||
welcomeMessage,
|
||||
}: AppleTerminalWelcomeV2Props): React.ReactNode {
|
||||
const isLightTheme = ['light', 'light-daltonized', 'light-ansi'].includes(
|
||||
theme,
|
||||
)
|
||||
function AppleTerminalWelcomeV2({ theme, welcomeMessage }: AppleTerminalWelcomeV2Props): React.ReactNode {
|
||||
const isLightTheme = ['light', 'light-daltonized', 'light-ansi'].includes(theme);
|
||||
|
||||
if (isLightTheme) {
|
||||
return (
|
||||
@@ -176,30 +131,14 @@ function AppleTerminalWelcomeV2({
|
||||
<Text color="claude">{welcomeMessage} </Text>
|
||||
<Text dimColor>v{MACRO.VERSION} </Text>
|
||||
</Text>
|
||||
<Text>
|
||||
{'…………………………………………………………………………………………………………………………………………………………'}
|
||||
</Text>
|
||||
<Text>
|
||||
{' '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' ░░░░░░ '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' ░░░ ░░░░░░░░░░ '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' ░░░░░░░░░░░░░░░░░░░ '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' '}
|
||||
</Text>
|
||||
<Text>{'…………………………………………………………………………………………………………………………………………………………'}</Text>
|
||||
<Text>{' '}</Text>
|
||||
<Text>{' '}</Text>
|
||||
<Text>{' '}</Text>
|
||||
<Text>{' ░░░░░░ '}</Text>
|
||||
<Text>{' ░░░ ░░░░░░░░░░ '}</Text>
|
||||
<Text>{' ░░░░░░░░░░░░░░░░░░░ '}</Text>
|
||||
<Text>{' '}</Text>
|
||||
<Text>
|
||||
<Text dimColor>{' ░░░░'}</Text>
|
||||
<Text>{' ██ '}</Text>
|
||||
@@ -208,12 +147,8 @@ function AppleTerminalWelcomeV2({
|
||||
<Text dimColor>{' ░░░░░░░░░░'}</Text>
|
||||
<Text>{' ██▒▒██ '}</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
{' ▒▒ ██ ▒'}
|
||||
</Text>
|
||||
<Text>
|
||||
{' ▒▒░░▒▒ ▒ ▒▒'}
|
||||
</Text>
|
||||
<Text>{' ▒▒ ██ ▒'}</Text>
|
||||
<Text>{' ▒▒░░▒▒ ▒ ▒▒'}</Text>
|
||||
<Text>
|
||||
{' '}
|
||||
<Text color="clawd_body">▗</Text>
|
||||
@@ -242,7 +177,7 @@ function AppleTerminalWelcomeV2({
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -252,41 +187,21 @@ function AppleTerminalWelcomeV2({
|
||||
<Text color="claude">{welcomeMessage} </Text>
|
||||
<Text dimColor>v{MACRO.VERSION} </Text>
|
||||
</Text>
|
||||
<Text>
|
||||
{'…………………………………………………………………………………………………………………………………………………………'}
|
||||
</Text>
|
||||
<Text>
|
||||
{' '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' * █████▓▓░ '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' * ███▓░ ░░ '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' ░░░░░░ ███▓░ '}
|
||||
</Text>
|
||||
<Text>
|
||||
{' ░░░ ░░░░░░░░░░ ███▓░ '}
|
||||
</Text>
|
||||
<Text>{'…………………………………………………………………………………………………………………………………………………………'}</Text>
|
||||
<Text>{' '}</Text>
|
||||
<Text>{' * █████▓▓░ '}</Text>
|
||||
<Text>{' * ███▓░ ░░ '}</Text>
|
||||
<Text>{' ░░░░░░ ███▓░ '}</Text>
|
||||
<Text>{' ░░░ ░░░░░░░░░░ ███▓░ '}</Text>
|
||||
<Text>
|
||||
<Text>{' ░░░░░░░░░░░░░░░░░░░ '}</Text>
|
||||
<Text bold>*</Text>
|
||||
<Text>{' ██▓░░ ▓ '}</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
{' ░▓▓███▓▓░ '}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{' * ░░░░ '}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{' ░░░░░░░░ '}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{' ░░░░░░░░░░░░░░░░ '}
|
||||
</Text>
|
||||
<Text>{' ░▓▓███▓▓░ '}</Text>
|
||||
<Text dimColor>{' * ░░░░ '}</Text>
|
||||
<Text dimColor>{' ░░░░░░░░ '}</Text>
|
||||
<Text dimColor>{' ░░░░░░░░░░░░░░░░ '}</Text>
|
||||
<Text>
|
||||
{' '}
|
||||
<Text dimColor>*</Text>
|
||||
@@ -322,5 +237,5 @@ function AppleTerminalWelcomeV2({
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,103 +1,96 @@
|
||||
import figures from 'figures'
|
||||
import { homedir } from 'os'
|
||||
import * as React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { Step } from '../../projectOnboardingState.js'
|
||||
import {
|
||||
formatCreditAmount,
|
||||
getCachedReferrerReward,
|
||||
} from '../../services/api/referral.js'
|
||||
import type { LogOption } from '../../types/logs.js'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { formatRelativeTimeAgo } from '../../utils/format.js'
|
||||
import type { FeedConfig, FeedLine } from './Feed.js'
|
||||
import figures from 'figures';
|
||||
import { homedir } from 'os';
|
||||
import * as React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { Step } from '../../projectOnboardingState.js';
|
||||
import { formatCreditAmount, getCachedReferrerReward } from '../../services/api/referral.js';
|
||||
import type { LogOption } from '../../types/logs.js';
|
||||
import { getCwd } from '../../utils/cwd.js';
|
||||
import { formatRelativeTimeAgo } from '../../utils/format.js';
|
||||
import type { FeedConfig, FeedLine } from './Feed.js';
|
||||
|
||||
export function createRecentActivityFeed(activities: LogOption[]): FeedConfig {
|
||||
const lines: FeedLine[] = activities.map(log => {
|
||||
const time = formatRelativeTimeAgo(log.modified)
|
||||
const description =
|
||||
log.summary && log.summary !== 'No prompt' ? log.summary : log.firstPrompt
|
||||
const time = formatRelativeTimeAgo(log.modified);
|
||||
const description = log.summary && log.summary !== 'No prompt' ? log.summary : log.firstPrompt;
|
||||
|
||||
return {
|
||||
text: description || '',
|
||||
timestamp: time,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
title: 'Recent activity',
|
||||
lines,
|
||||
footer: lines.length > 0 ? '/resume for more' : undefined,
|
||||
emptyMessage: 'No recent activity',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createWhatsNewFeed(releaseNotes: string[]): FeedConfig {
|
||||
const lines: FeedLine[] = releaseNotes.map(note => {
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
const match = note.match(/^(\d+\s+\w+\s+ago)\s+(.+)$/)
|
||||
const match = note.match(/^(\d+\s+\w+\s+ago)\s+(.+)$/);
|
||||
if (match) {
|
||||
return {
|
||||
timestamp: match[1],
|
||||
text: match[2] || '',
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
text: note,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
const emptyMessage =
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? 'Unable to fetch latest claude-cli-internal commits'
|
||||
: 'Check the Claude Code changelog for updates'
|
||||
: 'Check the Claude Code changelog for updates';
|
||||
|
||||
return {
|
||||
title:
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? "What's new [ANT-ONLY: Latest CC commits]"
|
||||
: "What's new",
|
||||
title: process.env.USER_TYPE === 'ant' ? "What's new [ANT-ONLY: Latest CC commits]" : "What's new",
|
||||
lines,
|
||||
footer: lines.length > 0 ? '/release-notes for more' : undefined,
|
||||
emptyMessage,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectOnboardingFeed(steps: Step[]): FeedConfig {
|
||||
const enabledSteps = steps
|
||||
.filter(({ isEnabled }) => isEnabled)
|
||||
.sort((a, b) => Number(a.isComplete) - Number(b.isComplete))
|
||||
.sort((a, b) => Number(a.isComplete) - Number(b.isComplete));
|
||||
|
||||
const lines: FeedLine[] = enabledSteps.map(({ text, isComplete }) => {
|
||||
const checkmark = isComplete ? `${figures.tick} ` : ''
|
||||
const checkmark = isComplete ? `${figures.tick} ` : '';
|
||||
return {
|
||||
text: `${checkmark}${text}`,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
const warningText =
|
||||
getCwd() === homedir()
|
||||
? 'Note: You have launched claude in your home directory. For the best experience, launch it in a project directory instead.'
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
if (warningText) {
|
||||
lines.push({
|
||||
text: warningText,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Tips for getting started',
|
||||
lines,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createGuestPassesFeed(): FeedConfig {
|
||||
const reward = getCachedReferrerReward()
|
||||
const reward = getCachedReferrerReward();
|
||||
const subtitle = reward
|
||||
? `Share Claude Code and earn ${formatCreditAmount(reward)} of extra usage`
|
||||
: 'Share Claude Code with friends'
|
||||
: 'Share Claude Code with friends';
|
||||
return {
|
||||
title: '3 guest passes',
|
||||
lines: [],
|
||||
@@ -113,5 +106,5 @@ export function createGuestPassesFeed(): FeedConfig {
|
||||
width: 48,
|
||||
},
|
||||
footer: '/passes',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import * as React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { Select } from '../CustomSelect/select.js'
|
||||
import { PermissionDialog } from '../permissions/PermissionDialog.js'
|
||||
import * as React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { Select } from '../CustomSelect/select.js';
|
||||
import { PermissionDialog } from '../permissions/PermissionDialog.js';
|
||||
|
||||
type Props = {
|
||||
pluginName: string
|
||||
pluginDescription?: string
|
||||
fileExtension: string
|
||||
onResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void
|
||||
}
|
||||
pluginName: string;
|
||||
pluginDescription?: string;
|
||||
fileExtension: string;
|
||||
onResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void;
|
||||
};
|
||||
|
||||
const AUTO_DISMISS_MS = 30_000
|
||||
const AUTO_DISMISS_MS = 30_000;
|
||||
|
||||
export function LspRecommendationMenu({
|
||||
pluginName,
|
||||
@@ -19,33 +19,29 @@ export function LspRecommendationMenu({
|
||||
onResponse,
|
||||
}: Props): React.ReactNode {
|
||||
// Use ref to avoid timer reset when onResponse changes
|
||||
const onResponseRef = React.useRef(onResponse)
|
||||
onResponseRef.current = onResponse
|
||||
const onResponseRef = React.useRef(onResponse);
|
||||
onResponseRef.current = onResponse;
|
||||
|
||||
// 30-second auto-dismiss timer - counts as ignored (no)
|
||||
React.useEffect(() => {
|
||||
const timeoutId = setTimeout(
|
||||
ref => ref.current('no'),
|
||||
AUTO_DISMISS_MS,
|
||||
onResponseRef,
|
||||
)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [])
|
||||
const timeoutId = setTimeout(ref => ref.current('no'), AUTO_DISMISS_MS, onResponseRef);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
function onSelect(value: string): void {
|
||||
switch (value) {
|
||||
case 'yes':
|
||||
onResponse('yes')
|
||||
break
|
||||
onResponse('yes');
|
||||
break;
|
||||
case 'no':
|
||||
onResponse('no')
|
||||
break
|
||||
onResponse('no');
|
||||
break;
|
||||
case 'never':
|
||||
onResponse('never')
|
||||
break
|
||||
onResponse('never');
|
||||
break;
|
||||
case 'disable':
|
||||
onResponse('disable')
|
||||
break
|
||||
onResponse('disable');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,16 +70,13 @@ export function LspRecommendationMenu({
|
||||
label: 'Disable all LSP recommendations',
|
||||
value: 'disable',
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<PermissionDialog title="LSP Plugin Recommendation">
|
||||
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
LSP provides code intelligence like go-to-definition and error
|
||||
checking
|
||||
</Text>
|
||||
<Text dimColor>LSP provides code intelligence like go-to-definition and error checking</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>Plugin:</Text>
|
||||
@@ -102,13 +95,9 @@ export function LspRecommendationMenu({
|
||||
<Text>Would you like to install this LSP plugin?</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={onSelect}
|
||||
onCancel={() => onResponse('no')}
|
||||
/>
|
||||
<Select options={options} onChange={onSelect} onCancel={() => onResponse('no')} />
|
||||
</Box>
|
||||
</Box>
|
||||
</PermissionDialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,76 +1,65 @@
|
||||
import React from 'react'
|
||||
import React from 'react';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import {
|
||||
getSettings_DEPRECATED,
|
||||
updateSettingsForSource,
|
||||
} from '../utils/settings/settings.js'
|
||||
import { Select } from './CustomSelect/index.js'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
import { MCPServerDialogCopy } from './MCPServerDialogCopy.js'
|
||||
} from 'src/services/analytics/index.js';
|
||||
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js';
|
||||
import { Select } from './CustomSelect/index.js';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
import { MCPServerDialogCopy } from './MCPServerDialogCopy.js';
|
||||
|
||||
type Props = {
|
||||
serverName: string
|
||||
onDone(): void
|
||||
}
|
||||
serverName: string;
|
||||
onDone(): void;
|
||||
};
|
||||
|
||||
export function MCPServerApprovalDialog({
|
||||
serverName,
|
||||
onDone,
|
||||
}: Props): React.ReactNode {
|
||||
export function MCPServerApprovalDialog({ serverName, onDone }: Props): React.ReactNode {
|
||||
function onChange(value: 'yes' | 'yes_all' | 'no') {
|
||||
logEvent('tengu_mcp_dialog_choice', {
|
||||
choice:
|
||||
value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
choice: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
|
||||
switch (value) {
|
||||
case 'yes':
|
||||
case 'yes_all': {
|
||||
// Get current enabled servers from settings
|
||||
const currentSettings = getSettings_DEPRECATED() || {}
|
||||
const enabledServers = currentSettings.enabledMcpjsonServers || []
|
||||
const currentSettings = getSettings_DEPRECATED() || {};
|
||||
const enabledServers = currentSettings.enabledMcpjsonServers || [];
|
||||
|
||||
// Add server if not already enabled
|
||||
if (!enabledServers.includes(serverName)) {
|
||||
updateSettingsForSource('localSettings', {
|
||||
enabledMcpjsonServers: [...enabledServers, serverName],
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (value === 'yes_all') {
|
||||
updateSettingsForSource('localSettings', {
|
||||
enableAllProjectMcpServers: true,
|
||||
})
|
||||
});
|
||||
}
|
||||
onDone()
|
||||
break
|
||||
onDone();
|
||||
break;
|
||||
}
|
||||
case 'no': {
|
||||
// Get current disabled servers from settings
|
||||
const currentSettings = getSettings_DEPRECATED() || {}
|
||||
const disabledServers = currentSettings.disabledMcpjsonServers || []
|
||||
const currentSettings = getSettings_DEPRECATED() || {};
|
||||
const disabledServers = currentSettings.disabledMcpjsonServers || [];
|
||||
|
||||
// Add server if not already disabled
|
||||
if (!disabledServers.includes(serverName)) {
|
||||
updateSettingsForSource('localSettings', {
|
||||
disabledMcpjsonServers: [...disabledServers, serverName],
|
||||
})
|
||||
});
|
||||
}
|
||||
onDone()
|
||||
break
|
||||
onDone();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={`New MCP server found in .mcp.json: ${serverName}`}
|
||||
color="warning"
|
||||
onCancel={() => onChange('no')}
|
||||
>
|
||||
<Dialog title={`New MCP server found in .mcp.json: ${serverName}`} color="warning" onCancel={() => onChange('no')}>
|
||||
<MCPServerDialogCopy />
|
||||
|
||||
<Select
|
||||
@@ -86,5 +75,5 @@ export function MCPServerApprovalDialog({
|
||||
onCancel={() => onChange('no')}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,66 +1,54 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { gracefulShutdown } from 'src/utils/gracefulShutdown.js'
|
||||
import { writeToStdout } from 'src/utils/process.js'
|
||||
import { Box, color, Text, useTheme, Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'
|
||||
import { addMcpConfig, getAllMcpConfigs } from '../services/mcp/config.js'
|
||||
import type {
|
||||
ConfigScope,
|
||||
McpServerConfig,
|
||||
ScopedMcpServerConfig,
|
||||
} from '../services/mcp/types.js'
|
||||
import { plural } from '../utils/stringUtils.js'
|
||||
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'
|
||||
import { SelectMulti } from './CustomSelect/SelectMulti.js'
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { gracefulShutdown } from 'src/utils/gracefulShutdown.js';
|
||||
import { writeToStdout } from 'src/utils/process.js';
|
||||
import { Box, color, Text, useTheme, Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink';
|
||||
import { addMcpConfig, getAllMcpConfigs } from '../services/mcp/config.js';
|
||||
import type { ConfigScope, McpServerConfig, ScopedMcpServerConfig } from '../services/mcp/types.js';
|
||||
import { plural } from '../utils/stringUtils.js';
|
||||
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js';
|
||||
import { SelectMulti } from './CustomSelect/SelectMulti.js';
|
||||
|
||||
type Props = {
|
||||
servers: Record<string, McpServerConfig>
|
||||
scope: ConfigScope
|
||||
onDone(): void
|
||||
}
|
||||
servers: Record<string, McpServerConfig>;
|
||||
scope: ConfigScope;
|
||||
onDone(): void;
|
||||
};
|
||||
|
||||
export function MCPServerDesktopImportDialog({
|
||||
servers,
|
||||
scope,
|
||||
onDone,
|
||||
}: Props): React.ReactNode {
|
||||
const serverNames = Object.keys(servers)
|
||||
const [existingServers, setExistingServers] = useState<
|
||||
Record<string, ScopedMcpServerConfig>
|
||||
>({})
|
||||
export function MCPServerDesktopImportDialog({ servers, scope, onDone }: Props): React.ReactNode {
|
||||
const serverNames = Object.keys(servers);
|
||||
const [existingServers, setExistingServers] = useState<Record<string, ScopedMcpServerConfig>>({});
|
||||
|
||||
useEffect(() => {
|
||||
void getAllMcpConfigs().then(({ servers }) => setExistingServers(servers))
|
||||
}, [])
|
||||
void getAllMcpConfigs().then(({ servers }) => setExistingServers(servers));
|
||||
}, []);
|
||||
|
||||
const collisions = serverNames.filter(
|
||||
name => existingServers[name] !== undefined,
|
||||
)
|
||||
const collisions = serverNames.filter(name => existingServers[name] !== undefined);
|
||||
|
||||
async function onSubmit(selectedServers: string[]) {
|
||||
let importedCount = 0
|
||||
let importedCount = 0;
|
||||
|
||||
for (const serverName of selectedServers) {
|
||||
const serverConfig = servers[serverName]
|
||||
const serverConfig = servers[serverName];
|
||||
if (serverConfig) {
|
||||
// If the server name already exists, find a new name with _1, _2, etc.
|
||||
let finalName = serverName
|
||||
let finalName = serverName;
|
||||
if (existingServers[finalName] !== undefined) {
|
||||
let counter = 1
|
||||
let counter = 1;
|
||||
while (existingServers[`${serverName}_${counter}`] !== undefined) {
|
||||
counter++
|
||||
counter++;
|
||||
}
|
||||
finalName = `${serverName}_${counter}`
|
||||
finalName = `${serverName}_${counter}`;
|
||||
}
|
||||
|
||||
await addMcpConfig(finalName, serverConfig, scope)
|
||||
importedCount++
|
||||
await addMcpConfig(finalName, serverConfig, scope);
|
||||
importedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
done(importedCount)
|
||||
done(importedCount);
|
||||
}
|
||||
|
||||
const [theme] = useTheme()
|
||||
const [theme] = useTheme();
|
||||
|
||||
// Define done before using in useCallback
|
||||
const done = useCallback(
|
||||
@@ -68,21 +56,21 @@ export function MCPServerDesktopImportDialog({
|
||||
if (importedCount > 0) {
|
||||
writeToStdout(
|
||||
`\n${color('success', theme)(`Successfully imported ${importedCount} MCP ${plural(importedCount, 'server')} to ${scope} config.`)}\n`,
|
||||
)
|
||||
);
|
||||
} else {
|
||||
writeToStdout('\nNo servers were imported.')
|
||||
writeToStdout('\nNo servers were imported.');
|
||||
}
|
||||
onDone()
|
||||
onDone();
|
||||
|
||||
void gracefulShutdown()
|
||||
void gracefulShutdown();
|
||||
},
|
||||
[theme, scope, onDone],
|
||||
)
|
||||
);
|
||||
|
||||
// Handle ESC to cancel (import 0 servers)
|
||||
const handleEscCancel = useCallback(() => {
|
||||
done(0)
|
||||
}, [done])
|
||||
done(0);
|
||||
}, [done]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -95,8 +83,8 @@ export function MCPServerDesktopImportDialog({
|
||||
>
|
||||
{collisions.length > 0 && (
|
||||
<Text color="warning">
|
||||
Note: Some servers already exist with the same name. If selected,
|
||||
they will be imported with a numbered suffix.
|
||||
Note: Some servers already exist with the same name. If selected, they will be imported with a numbered
|
||||
suffix.
|
||||
</Text>
|
||||
)}
|
||||
<Text>Please select the servers you want to import:</Text>
|
||||
@@ -117,15 +105,10 @@ export function MCPServerDesktopImportDialog({
|
||||
<Byline>
|
||||
<KeyboardShortcutHint shortcut="Space" action="select" />
|
||||
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Confirmation"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
|
||||
</Byline>
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React from 'react'
|
||||
import { Link, Text } from '@anthropic/ink'
|
||||
import React from 'react';
|
||||
import { Link, Text } from '@anthropic/ink';
|
||||
|
||||
export function MCPServerDialogCopy(): React.ReactNode {
|
||||
return (
|
||||
<Text>
|
||||
MCP servers may execute code or access system resources. All tool calls
|
||||
require approval. Learn more in the{' '}
|
||||
MCP servers may execute code or access system resources. All tool calls require approval. Learn more in the{' '}
|
||||
<Link url="https://code.claude.com/docs/en/mcp">MCP documentation</Link>.
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,78 +1,64 @@
|
||||
import partition from 'lodash-es/partition.js'
|
||||
import React, { useCallback } from 'react'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import {
|
||||
getSettings_DEPRECATED,
|
||||
updateSettingsForSource,
|
||||
} from '../utils/settings/settings.js'
|
||||
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'
|
||||
import { SelectMulti } from './CustomSelect/SelectMulti.js'
|
||||
import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'
|
||||
import { MCPServerDialogCopy } from './MCPServerDialogCopy.js'
|
||||
import partition from 'lodash-es/partition.js';
|
||||
import React, { useCallback } from 'react';
|
||||
import { logEvent } from 'src/services/analytics/index.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js';
|
||||
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js';
|
||||
import { SelectMulti } from './CustomSelect/SelectMulti.js';
|
||||
import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink';
|
||||
import { MCPServerDialogCopy } from './MCPServerDialogCopy.js';
|
||||
|
||||
type Props = {
|
||||
serverNames: string[]
|
||||
onDone(): void
|
||||
}
|
||||
serverNames: string[];
|
||||
onDone(): void;
|
||||
};
|
||||
|
||||
export function MCPServerMultiselectDialog({
|
||||
serverNames,
|
||||
onDone,
|
||||
}: Props): React.ReactNode {
|
||||
export function MCPServerMultiselectDialog({ serverNames, onDone }: Props): React.ReactNode {
|
||||
function onSubmit(selectedServers: string[]) {
|
||||
const currentSettings = getSettings_DEPRECATED() || {}
|
||||
const enabledServers = currentSettings.enabledMcpjsonServers || []
|
||||
const disabledServers = currentSettings.disabledMcpjsonServers || []
|
||||
const currentSettings = getSettings_DEPRECATED() || {};
|
||||
const enabledServers = currentSettings.enabledMcpjsonServers || [];
|
||||
const disabledServers = currentSettings.disabledMcpjsonServers || [];
|
||||
|
||||
// Use partition to separate approved and rejected servers
|
||||
const [approvedServers, rejectedServers] = partition(serverNames, server =>
|
||||
selectedServers.includes(server),
|
||||
)
|
||||
const [approvedServers, rejectedServers] = partition(serverNames, server => selectedServers.includes(server));
|
||||
|
||||
logEvent('tengu_mcp_multidialog_choice', {
|
||||
approved: approvedServers.length,
|
||||
rejected: rejectedServers.length,
|
||||
})
|
||||
});
|
||||
|
||||
// Update settings with approved servers
|
||||
if (approvedServers.length > 0) {
|
||||
const newEnabledServers = [
|
||||
...new Set([...enabledServers, ...approvedServers]),
|
||||
]
|
||||
const newEnabledServers = [...new Set([...enabledServers, ...approvedServers])];
|
||||
updateSettingsForSource('localSettings', {
|
||||
enabledMcpjsonServers: newEnabledServers,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Update settings with rejected servers
|
||||
if (rejectedServers.length > 0) {
|
||||
const newDisabledServers = [
|
||||
...new Set([...disabledServers, ...rejectedServers]),
|
||||
]
|
||||
const newDisabledServers = [...new Set([...disabledServers, ...rejectedServers])];
|
||||
updateSettingsForSource('localSettings', {
|
||||
disabledMcpjsonServers: newDisabledServers,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
onDone()
|
||||
onDone();
|
||||
}
|
||||
|
||||
// Handle ESC to reject all servers
|
||||
const handleEscRejectAll = useCallback(() => {
|
||||
const currentSettings = getSettings_DEPRECATED() || {}
|
||||
const disabledServers = currentSettings.disabledMcpjsonServers || []
|
||||
const currentSettings = getSettings_DEPRECATED() || {};
|
||||
const disabledServers = currentSettings.disabledMcpjsonServers || [];
|
||||
|
||||
const newDisabledServers = [
|
||||
...new Set([...disabledServers, ...serverNames]),
|
||||
]
|
||||
const newDisabledServers = [...new Set([...disabledServers, ...serverNames])];
|
||||
|
||||
updateSettingsForSource('localSettings', {
|
||||
disabledMcpjsonServers: newDisabledServers,
|
||||
})
|
||||
});
|
||||
|
||||
onDone()
|
||||
}, [serverNames, onDone])
|
||||
onDone();
|
||||
}, [serverNames, onDone]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -111,5 +97,5 @@ export function MCPServerMultiselectDialog({
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,52 +1,40 @@
|
||||
import React from 'react'
|
||||
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js'
|
||||
import type { SettingsJson } from '../../utils/settings/types.js'
|
||||
import { Select } from '../CustomSelect/index.js'
|
||||
import { PermissionDialog } from '../permissions/PermissionDialog.js'
|
||||
import {
|
||||
extractDangerousSettings,
|
||||
formatDangerousSettingsList,
|
||||
} from './utils.js'
|
||||
import React from 'react';
|
||||
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
import type { SettingsJson } from '../../utils/settings/types.js';
|
||||
import { Select } from '../CustomSelect/index.js';
|
||||
import { PermissionDialog } from '../permissions/PermissionDialog.js';
|
||||
import { extractDangerousSettings, formatDangerousSettingsList } from './utils.js';
|
||||
|
||||
type Props = {
|
||||
settings: SettingsJson
|
||||
onAccept: () => void
|
||||
onReject: () => void
|
||||
}
|
||||
settings: SettingsJson;
|
||||
onAccept: () => void;
|
||||
onReject: () => void;
|
||||
};
|
||||
|
||||
export function ManagedSettingsSecurityDialog({
|
||||
settings,
|
||||
onAccept,
|
||||
onReject,
|
||||
}: Props): React.ReactNode {
|
||||
const dangerous = extractDangerousSettings(settings)
|
||||
const settingsList = formatDangerousSettingsList(dangerous)
|
||||
export function ManagedSettingsSecurityDialog({ settings, onAccept, onReject }: Props): React.ReactNode {
|
||||
const dangerous = extractDangerousSettings(settings);
|
||||
const settingsList = formatDangerousSettingsList(dangerous);
|
||||
|
||||
const exitState = useExitOnCtrlCDWithKeybindings()
|
||||
const exitState = useExitOnCtrlCDWithKeybindings();
|
||||
|
||||
useKeybinding('confirm:no', onReject, { context: 'Confirmation' })
|
||||
useKeybinding('confirm:no', onReject, { context: 'Confirmation' });
|
||||
|
||||
function onChange(value: 'accept' | 'exit'): void {
|
||||
if (value === 'exit') {
|
||||
onReject()
|
||||
return
|
||||
onReject();
|
||||
return;
|
||||
}
|
||||
onAccept()
|
||||
onAccept();
|
||||
}
|
||||
|
||||
return (
|
||||
<PermissionDialog
|
||||
color="warning"
|
||||
titleColor="warning"
|
||||
title="Managed settings require approval"
|
||||
>
|
||||
<PermissionDialog color="warning" titleColor="warning" title="Managed settings require approval">
|
||||
<Box flexDirection="column" gap={1} paddingTop={1}>
|
||||
<Text>
|
||||
Your organization has configured managed settings that could allow
|
||||
execution of arbitrary code or interception of your prompts and
|
||||
responses.
|
||||
Your organization has configured managed settings that could allow execution of arbitrary code or interception
|
||||
of your prompts and responses.
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column">
|
||||
@@ -62,8 +50,8 @@ export function ManagedSettingsSecurityDialog({
|
||||
</Box>
|
||||
|
||||
<Text>
|
||||
Only accept if you trust your organization's IT administration
|
||||
and expect these settings to be configured.
|
||||
Only accept if you trust your organization's IT administration and expect these settings to be
|
||||
configured.
|
||||
</Text>
|
||||
|
||||
<Select
|
||||
@@ -76,13 +64,9 @@ export function ManagedSettingsSecurityDialog({
|
||||
/>
|
||||
|
||||
<Text dimColor>
|
||||
{exitState.pending ? (
|
||||
<>Press {exitState.keyName} again to exit</>
|
||||
) : (
|
||||
<>Enter to confirm · Esc to exit</>
|
||||
)}
|
||||
{exitState.pending ? <>Press {exitState.keyName} again to exit</> : <>Enter to confirm · Esc to exit</>}
|
||||
</Text>
|
||||
</Box>
|
||||
</PermissionDialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
import { marked, type Token, type Tokens } from 'marked'
|
||||
import React, { Suspense, use, useMemo, useRef } from 'react'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { useSettings } from '../hooks/useSettings.js'
|
||||
import { Ansi, Box, useTheme } from '@anthropic/ink'
|
||||
import {
|
||||
type CliHighlight,
|
||||
getCliHighlightPromise,
|
||||
} from '../utils/cliHighlight.js'
|
||||
import { hashContent } from '../utils/hash.js'
|
||||
import { configureMarked, formatToken } from '../utils/markdown.js'
|
||||
import { stripPromptXMLTags } from '../utils/messages.js'
|
||||
import { MarkdownTable } from './MarkdownTable.js'
|
||||
import { marked, type Token, type Tokens } from 'marked';
|
||||
import React, { Suspense, use, useMemo, useRef } from 'react';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { useSettings } from '../hooks/useSettings.js';
|
||||
import { Ansi, Box, useTheme } from '@anthropic/ink';
|
||||
import { type CliHighlight, getCliHighlightPromise } from '../utils/cliHighlight.js';
|
||||
import { hashContent } from '../utils/hash.js';
|
||||
import { configureMarked, formatToken } from '../utils/markdown.js';
|
||||
import { stripPromptXMLTags } from '../utils/messages.js';
|
||||
import { MarkdownTable } from './MarkdownTable.js';
|
||||
|
||||
type Props = {
|
||||
children: string
|
||||
children: string;
|
||||
/** When true, render all text content as dim */
|
||||
dimColor?: boolean
|
||||
}
|
||||
dimColor?: boolean;
|
||||
};
|
||||
|
||||
// Module-level token cache — marked.lexer is the hot cost on virtual-scroll
|
||||
// remounts (~3ms per message). useMemo doesn't survive unmount→remount, so
|
||||
// scrolling back to a previously-visible message re-parses. Messages are
|
||||
// immutable in history; same content → same tokens. Keyed by hash to avoid
|
||||
// retaining full content strings (turn50→turn99 RSS regression, #24180).
|
||||
const tokenCache = new LRUCache<string, Token[]>({ max: 500 })
|
||||
const tokenCache = new LRUCache<string, Token[]>({ max: 500 });
|
||||
|
||||
// Characters that indicate markdown syntax. If none are present, skip the
|
||||
// ~3ms marked.lexer call entirely — render as a single paragraph. Covers
|
||||
@@ -31,11 +28,11 @@ const tokenCache = new LRUCache<string, Token[]>({ max: 500 })
|
||||
// plain sentences. Checked via indexOf (not regex) for speed.
|
||||
// Single regex: matches any MD marker or ordered-list start (N. at line start).
|
||||
// One pass instead of 10× includes scans.
|
||||
const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. /
|
||||
const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. /;
|
||||
function hasMarkdownSyntax(s: string): boolean {
|
||||
// Sample first 500 chars — if markdown exists it's usually early (headers,
|
||||
// code fence, list). Long tool outputs are mostly plain text tails.
|
||||
return MD_SYNTAX_RE.test(s.length > 500 ? s.slice(0, 500) : s)
|
||||
return MD_SYNTAX_RE.test(s.length > 500 ? s.slice(0, 500) : s);
|
||||
}
|
||||
|
||||
function cachedLexer(content: string): Token[] {
|
||||
@@ -51,14 +48,14 @@ function cachedLexer(content: string): Token[] {
|
||||
text: content,
|
||||
tokens: [{ type: 'text', raw: content, text: content }],
|
||||
} as Token,
|
||||
]
|
||||
];
|
||||
}
|
||||
const key = hashContent(content)
|
||||
const hit = tokenCache.get(key)
|
||||
if (hit) return hit
|
||||
const tokens = marked.lexer(content)
|
||||
tokenCache.set(key, tokens)
|
||||
return tokens
|
||||
const key = hashContent(content);
|
||||
const hit = tokenCache.get(key);
|
||||
if (hit) return hit;
|
||||
const tokens = marked.lexer(content);
|
||||
tokenCache.set(key, tokens);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,9 +64,9 @@ function cachedLexer(content: string): Token[] {
|
||||
* - Other content is rendered as ANSI strings via formatToken
|
||||
*/
|
||||
export function Markdown(props: Props): React.ReactNode {
|
||||
const settings = useSettings()
|
||||
const settings = useSettings();
|
||||
if (settings.syntaxHighlightingDisabled) {
|
||||
return <MarkdownBody {...props} highlight={null} />
|
||||
return <MarkdownBody {...props} highlight={null} />;
|
||||
}
|
||||
// Suspense fallback renders with highlight=null — plain markdown shows
|
||||
// for ~50ms on first ever render while cli-highlight loads.
|
||||
@@ -77,26 +74,22 @@ export function Markdown(props: Props): React.ReactNode {
|
||||
<Suspense fallback={<MarkdownBody {...props} highlight={null} />}>
|
||||
<MarkdownWithHighlight {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownWithHighlight(props: Props): React.ReactNode {
|
||||
const highlight = use(getCliHighlightPromise())
|
||||
return <MarkdownBody {...props} highlight={highlight} />
|
||||
const highlight = use(getCliHighlightPromise());
|
||||
return <MarkdownBody {...props} highlight={highlight} />;
|
||||
}
|
||||
|
||||
function MarkdownBody({
|
||||
children,
|
||||
dimColor,
|
||||
highlight,
|
||||
}: Props & { highlight: CliHighlight | null }): React.ReactNode {
|
||||
const [theme] = useTheme()
|
||||
configureMarked()
|
||||
function MarkdownBody({ children, dimColor, highlight }: Props & { highlight: CliHighlight | null }): React.ReactNode {
|
||||
const [theme] = useTheme();
|
||||
configureMarked();
|
||||
|
||||
const elements = useMemo(() => {
|
||||
const tokens = cachedLexer(stripPromptXMLTags(children))
|
||||
const elements: React.ReactNode[] = []
|
||||
let nonTableContent = ''
|
||||
const tokens = cachedLexer(stripPromptXMLTags(children));
|
||||
const elements: React.ReactNode[] = [];
|
||||
let nonTableContent = '';
|
||||
|
||||
function flushNonTableContent(): void {
|
||||
if (nonTableContent) {
|
||||
@@ -104,40 +97,34 @@ function MarkdownBody({
|
||||
<Ansi key={elements.length} dimColor={dimColor}>
|
||||
{nonTableContent.trim()}
|
||||
</Ansi>,
|
||||
)
|
||||
nonTableContent = ''
|
||||
);
|
||||
nonTableContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.type === 'table') {
|
||||
flushNonTableContent()
|
||||
elements.push(
|
||||
<MarkdownTable
|
||||
key={elements.length}
|
||||
token={token as Tokens.Table}
|
||||
highlight={highlight}
|
||||
/>,
|
||||
)
|
||||
flushNonTableContent();
|
||||
elements.push(<MarkdownTable key={elements.length} token={token as Tokens.Table} highlight={highlight} />);
|
||||
} else {
|
||||
nonTableContent += formatToken(token, theme, 0, null, null, highlight)
|
||||
nonTableContent += formatToken(token, theme, 0, null, null, highlight);
|
||||
}
|
||||
}
|
||||
|
||||
flushNonTableContent()
|
||||
return elements
|
||||
}, [children, dimColor, highlight, theme])
|
||||
flushNonTableContent();
|
||||
return elements;
|
||||
}, [children, dimColor, highlight, theme]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{elements}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
type StreamingProps = {
|
||||
children: string
|
||||
}
|
||||
children: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders markdown during streaming by splitting at the last top-level block
|
||||
@@ -149,49 +136,47 @@ type StreamingProps = {
|
||||
* is idempotent and safe under StrictMode double-rendering. Component unmounts
|
||||
* between turns (streamingText → null), resetting the ref.
|
||||
*/
|
||||
export function StreamingMarkdown({
|
||||
children,
|
||||
}: StreamingProps): React.ReactNode {
|
||||
export function StreamingMarkdown({ children }: StreamingProps): React.ReactNode {
|
||||
// React Compiler: this component reads and writes stablePrefixRef.current
|
||||
// during render by design. The boundary only advances (monotonic), so
|
||||
// the ref mutation is idempotent under StrictMode double-render — but the
|
||||
// compiler can't prove that, and memoizing around the ref reads would
|
||||
// break the algorithm (stale boundary). Opt out.
|
||||
'use no memo'
|
||||
configureMarked()
|
||||
'use no memo';
|
||||
configureMarked();
|
||||
|
||||
// Strip before boundary tracking so it matches <Markdown>'s stripping
|
||||
// (line 29). When a closing tag arrives, stripped(N+1) is not a prefix
|
||||
// of stripped(N), but the startsWith reset below handles that with a
|
||||
// one-time re-lex on the smaller stripped string.
|
||||
const stripped = stripPromptXMLTags(children)
|
||||
const stripped = stripPromptXMLTags(children);
|
||||
|
||||
const stablePrefixRef = useRef('')
|
||||
const stablePrefixRef = useRef('');
|
||||
|
||||
// Reset if text was replaced (defensive; normally unmount handles this)
|
||||
if (!stripped.startsWith(stablePrefixRef.current)) {
|
||||
stablePrefixRef.current = ''
|
||||
stablePrefixRef.current = '';
|
||||
}
|
||||
|
||||
// Lex only from current boundary — O(unstable length), not O(full text)
|
||||
const boundary = stablePrefixRef.current.length
|
||||
const tokens = marked.lexer(stripped.substring(boundary))
|
||||
const boundary = stablePrefixRef.current.length;
|
||||
const tokens = marked.lexer(stripped.substring(boundary));
|
||||
|
||||
// Last non-space token is the growing block; everything before is final
|
||||
let lastContentIdx = tokens.length - 1
|
||||
let lastContentIdx = tokens.length - 1;
|
||||
while (lastContentIdx >= 0 && tokens[lastContentIdx]!.type === 'space') {
|
||||
lastContentIdx--
|
||||
lastContentIdx--;
|
||||
}
|
||||
let advance = 0
|
||||
let advance = 0;
|
||||
for (let i = 0; i < lastContentIdx; i++) {
|
||||
advance += tokens[i]!.raw.length
|
||||
advance += tokens[i]!.raw.length;
|
||||
}
|
||||
if (advance > 0) {
|
||||
stablePrefixRef.current = stripped.substring(0, boundary + advance)
|
||||
stablePrefixRef.current = stripped.substring(0, boundary + advance);
|
||||
}
|
||||
|
||||
const stablePrefix = stablePrefixRef.current
|
||||
const unstableSuffix = stripped.substring(stablePrefix.length)
|
||||
const stablePrefix = stablePrefixRef.current;
|
||||
const unstableSuffix = stripped.substring(stablePrefix.length);
|
||||
|
||||
// stablePrefix is memoized inside <Markdown> via useMemo([children, ...])
|
||||
// so it never re-parses as the unstable suffix grows
|
||||
@@ -200,5 +185,5 @@ export function StreamingMarkdown({
|
||||
{stablePrefix && <Markdown>{stablePrefix}</Markdown>}
|
||||
{unstableSuffix && <Markdown>{unstableSuffix}</Markdown>}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import type { Token, Tokens } from 'marked'
|
||||
import React from 'react'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { Ansi, stringWidth, useTheme, wrapAnsi } from '@anthropic/ink'
|
||||
import type { CliHighlight } from '../utils/cliHighlight.js'
|
||||
import { formatToken, padAligned } from '../utils/markdown.js'
|
||||
import type { Token, Tokens } from 'marked';
|
||||
import React from 'react';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { Ansi, stringWidth, useTheme, wrapAnsi } from '@anthropic/ink';
|
||||
import type { CliHighlight } from '../utils/cliHighlight.js';
|
||||
import { formatToken, padAligned } from '../utils/markdown.js';
|
||||
|
||||
/** Accounts for parent indentation (e.g. message dot prefix) and terminal
|
||||
* resize races. Without enough margin the table overflows its layout box
|
||||
* and Ink's clip truncates differently on alternating frames, causing an
|
||||
* infinite flicker loop in scrollback. */
|
||||
const SAFETY_MARGIN = 4
|
||||
const SAFETY_MARGIN = 4;
|
||||
|
||||
/** Minimum column width to prevent degenerate layouts */
|
||||
const MIN_COLUMN_WIDTH = 3
|
||||
const MIN_COLUMN_WIDTH = 3;
|
||||
|
||||
/**
|
||||
* Maximum number of lines per row before switching to vertical format.
|
||||
* When wrapping would make rows taller than this, vertical (key-value)
|
||||
* format provides better readability.
|
||||
*/
|
||||
const MAX_ROW_LINES = 4
|
||||
const MAX_ROW_LINES = 4;
|
||||
|
||||
/** ANSI escape codes for text formatting */
|
||||
const ANSI_BOLD_START = '\x1b[1m'
|
||||
const ANSI_BOLD_END = '\x1b[22m'
|
||||
const ANSI_BOLD_START = '\x1b[1m';
|
||||
const ANSI_BOLD_END = '\x1b[22m';
|
||||
|
||||
type Props = {
|
||||
token: Tokens.Table
|
||||
highlight: CliHighlight | null
|
||||
token: Tokens.Table;
|
||||
highlight: CliHighlight | null;
|
||||
/** Override terminal width (useful for testing) */
|
||||
forceWidth?: number
|
||||
}
|
||||
forceWidth?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap text to fit within a given width, returning array of lines.
|
||||
@@ -40,26 +40,22 @@ type Props = {
|
||||
* @param hard - If true, break words that exceed width (needed when columns
|
||||
* are narrower than the longest word). Default false.
|
||||
*/
|
||||
function wrapText(
|
||||
text: string,
|
||||
width: number,
|
||||
options?: { hard?: boolean },
|
||||
): string[] {
|
||||
if (width <= 0) return [text]
|
||||
function wrapText(text: string, width: number, options?: { hard?: boolean }): string[] {
|
||||
if (width <= 0) return [text];
|
||||
// Strip trailing whitespace/newlines before wrapping.
|
||||
// formatToken() adds EOL to paragraphs and other token types,
|
||||
// which would otherwise create extra blank lines in table cells.
|
||||
const trimmedText = text.trimEnd()
|
||||
const trimmedText = text.trimEnd();
|
||||
const wrapped = wrapAnsi(trimmedText, width, {
|
||||
hard: options?.hard ?? false,
|
||||
trim: false,
|
||||
wordWrap: true,
|
||||
})
|
||||
});
|
||||
// Filter out empty lines that result from trailing newlines or
|
||||
// multiple consecutive newlines in the source content.
|
||||
const lines = wrapped.split('\n').filter(line => line.length > 0)
|
||||
const lines = wrapped.split('\n').filter(line => line.length > 0);
|
||||
// Ensure we always return at least one line (empty string for empty cells)
|
||||
return lines.length > 0 ? lines : ['']
|
||||
return lines.length > 0 ? lines : [''];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,174 +66,152 @@ function wrapText(
|
||||
* 3. Wrapping text within cells (no truncation)
|
||||
* 4. Properly aligning multi-line rows with borders
|
||||
*/
|
||||
export function MarkdownTable({
|
||||
token,
|
||||
highlight,
|
||||
forceWidth,
|
||||
}: Props): React.ReactNode {
|
||||
const [theme] = useTheme()
|
||||
const { columns: actualTerminalWidth } = useTerminalSize()
|
||||
const terminalWidth = forceWidth ?? actualTerminalWidth
|
||||
export function MarkdownTable({ token, highlight, forceWidth }: Props): React.ReactNode {
|
||||
const [theme] = useTheme();
|
||||
const { columns: actualTerminalWidth } = useTerminalSize();
|
||||
const terminalWidth = forceWidth ?? actualTerminalWidth;
|
||||
|
||||
// Format cell content to ANSI string
|
||||
function formatCell(tokens: Token[] | undefined): string {
|
||||
return (
|
||||
tokens
|
||||
?.map(_ => formatToken(_, theme, 0, null, null, highlight))
|
||||
.join('') ?? ''
|
||||
)
|
||||
return tokens?.map(_ => formatToken(_, theme, 0, null, null, highlight)).join('') ?? '';
|
||||
}
|
||||
|
||||
// Get plain text (stripped of ANSI codes)
|
||||
function getPlainText(tokens: Token[] | undefined): string {
|
||||
return stripAnsi(formatCell(tokens))
|
||||
return stripAnsi(formatCell(tokens));
|
||||
}
|
||||
|
||||
// Get the longest word width in a cell (minimum width to avoid breaking words)
|
||||
function getMinWidth(tokens: Token[] | undefined): number {
|
||||
const text = getPlainText(tokens)
|
||||
const words = text.split(/\s+/).filter(w => w.length > 0)
|
||||
if (words.length === 0) return MIN_COLUMN_WIDTH
|
||||
return Math.max(...words.map(w => stringWidth(w)), MIN_COLUMN_WIDTH)
|
||||
const text = getPlainText(tokens);
|
||||
const words = text.split(/\s+/).filter(w => w.length > 0);
|
||||
if (words.length === 0) return MIN_COLUMN_WIDTH;
|
||||
return Math.max(...words.map(w => stringWidth(w)), MIN_COLUMN_WIDTH);
|
||||
}
|
||||
|
||||
// Get ideal width (full content without wrapping)
|
||||
function getIdealWidth(tokens: Token[] | undefined): number {
|
||||
return Math.max(stringWidth(getPlainText(tokens)), MIN_COLUMN_WIDTH)
|
||||
return Math.max(stringWidth(getPlainText(tokens)), MIN_COLUMN_WIDTH);
|
||||
}
|
||||
|
||||
// Calculate column widths
|
||||
// Step 1: Get minimum (longest word) and ideal (full content) widths
|
||||
const minWidths = token.header.map((header, colIndex) => {
|
||||
let maxMinWidth = getMinWidth(header.tokens)
|
||||
let maxMinWidth = getMinWidth(header.tokens);
|
||||
for (const row of token.rows) {
|
||||
maxMinWidth = Math.max(maxMinWidth, getMinWidth(row[colIndex]?.tokens))
|
||||
maxMinWidth = Math.max(maxMinWidth, getMinWidth(row[colIndex]?.tokens));
|
||||
}
|
||||
return maxMinWidth
|
||||
})
|
||||
return maxMinWidth;
|
||||
});
|
||||
|
||||
const idealWidths = token.header.map((header, colIndex) => {
|
||||
let maxIdeal = getIdealWidth(header.tokens)
|
||||
let maxIdeal = getIdealWidth(header.tokens);
|
||||
for (const row of token.rows) {
|
||||
maxIdeal = Math.max(maxIdeal, getIdealWidth(row[colIndex]?.tokens))
|
||||
maxIdeal = Math.max(maxIdeal, getIdealWidth(row[colIndex]?.tokens));
|
||||
}
|
||||
return maxIdeal
|
||||
})
|
||||
return maxIdeal;
|
||||
});
|
||||
|
||||
// Step 2: Calculate available space
|
||||
// Border overhead: │ content │ content │ = 1 + (width + 3) per column
|
||||
const numCols = token.header.length
|
||||
const borderOverhead = 1 + numCols * 3 // │ + (2 padding + 1 border) per col
|
||||
const numCols = token.header.length;
|
||||
const borderOverhead = 1 + numCols * 3; // │ + (2 padding + 1 border) per col
|
||||
// Account for SAFETY_MARGIN to avoid triggering the fallback safety check
|
||||
const availableWidth = Math.max(
|
||||
terminalWidth - borderOverhead - SAFETY_MARGIN,
|
||||
numCols * MIN_COLUMN_WIDTH,
|
||||
)
|
||||
const availableWidth = Math.max(terminalWidth - borderOverhead - SAFETY_MARGIN, numCols * MIN_COLUMN_WIDTH);
|
||||
|
||||
// Step 3: Calculate column widths that fit available space
|
||||
const totalMin = minWidths.reduce((sum, w) => sum + w, 0)
|
||||
const totalIdeal = idealWidths.reduce((sum, w) => sum + w, 0)
|
||||
const totalMin = minWidths.reduce((sum, w) => sum + w, 0);
|
||||
const totalIdeal = idealWidths.reduce((sum, w) => sum + w, 0);
|
||||
|
||||
// Track whether columns are narrower than longest words (needs hard wrap)
|
||||
let needsHardWrap = false
|
||||
let needsHardWrap = false;
|
||||
|
||||
let columnWidths: number[]
|
||||
let columnWidths: number[];
|
||||
if (totalIdeal <= availableWidth) {
|
||||
// Everything fits - use ideal widths
|
||||
columnWidths = idealWidths
|
||||
columnWidths = idealWidths;
|
||||
} else if (totalMin <= availableWidth) {
|
||||
// Need to shrink - give each column its min, distribute remaining space
|
||||
const extraSpace = availableWidth - totalMin
|
||||
const overflows = idealWidths.map((ideal, i) => ideal - minWidths[i]!)
|
||||
const totalOverflow = overflows.reduce((sum, o) => sum + o, 0)
|
||||
const extraSpace = availableWidth - totalMin;
|
||||
const overflows = idealWidths.map((ideal, i) => ideal - minWidths[i]!);
|
||||
const totalOverflow = overflows.reduce((sum, o) => sum + o, 0);
|
||||
|
||||
columnWidths = minWidths.map((min, i) => {
|
||||
if (totalOverflow === 0) return min
|
||||
const extra = Math.floor((overflows[i]! / totalOverflow) * extraSpace)
|
||||
return min + extra
|
||||
})
|
||||
if (totalOverflow === 0) return min;
|
||||
const extra = Math.floor((overflows[i]! / totalOverflow) * extraSpace);
|
||||
return min + extra;
|
||||
});
|
||||
} else {
|
||||
// Table wider than terminal at minimum widths
|
||||
// Shrink columns proportionally to fit, allowing word breaks
|
||||
needsHardWrap = true
|
||||
const scaleFactor = availableWidth / totalMin
|
||||
columnWidths = minWidths.map(w =>
|
||||
Math.max(Math.floor(w * scaleFactor), MIN_COLUMN_WIDTH),
|
||||
)
|
||||
needsHardWrap = true;
|
||||
const scaleFactor = availableWidth / totalMin;
|
||||
columnWidths = minWidths.map(w => Math.max(Math.floor(w * scaleFactor), MIN_COLUMN_WIDTH));
|
||||
}
|
||||
|
||||
// Step 4: Calculate max row lines to determine if vertical format is needed
|
||||
function calculateMaxRowLines(): number {
|
||||
let maxLines = 1
|
||||
let maxLines = 1;
|
||||
// Check header
|
||||
for (let i = 0; i < token.header.length; i++) {
|
||||
const content = formatCell(token.header[i]!.tokens)
|
||||
const content = formatCell(token.header[i]!.tokens);
|
||||
const wrapped = wrapText(content, columnWidths[i]!, {
|
||||
hard: needsHardWrap,
|
||||
})
|
||||
maxLines = Math.max(maxLines, wrapped.length)
|
||||
});
|
||||
maxLines = Math.max(maxLines, wrapped.length);
|
||||
}
|
||||
// Check rows
|
||||
for (const row of token.rows) {
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const content = formatCell(row[i]?.tokens)
|
||||
const content = formatCell(row[i]?.tokens);
|
||||
const wrapped = wrapText(content, columnWidths[i]!, {
|
||||
hard: needsHardWrap,
|
||||
})
|
||||
maxLines = Math.max(maxLines, wrapped.length)
|
||||
});
|
||||
maxLines = Math.max(maxLines, wrapped.length);
|
||||
}
|
||||
}
|
||||
return maxLines
|
||||
return maxLines;
|
||||
}
|
||||
|
||||
// Use vertical format if wrapping would make rows too tall
|
||||
const maxRowLines = calculateMaxRowLines()
|
||||
const useVerticalFormat = maxRowLines > MAX_ROW_LINES
|
||||
const maxRowLines = calculateMaxRowLines();
|
||||
const useVerticalFormat = maxRowLines > MAX_ROW_LINES;
|
||||
|
||||
// Render a single row with potential multi-line cells
|
||||
// Returns an array of strings, one per line of the row
|
||||
function renderRowLines(
|
||||
cells: Array<{ tokens?: Token[] }>,
|
||||
isHeader: boolean,
|
||||
): string[] {
|
||||
function renderRowLines(cells: Array<{ tokens?: Token[] }>, isHeader: boolean): string[] {
|
||||
// Get wrapped lines for each cell (preserving ANSI formatting)
|
||||
const cellLines = cells.map((cell, colIndex) => {
|
||||
const formattedText = formatCell(cell.tokens)
|
||||
const width = columnWidths[colIndex]!
|
||||
return wrapText(formattedText, width, { hard: needsHardWrap })
|
||||
})
|
||||
const formattedText = formatCell(cell.tokens);
|
||||
const width = columnWidths[colIndex]!;
|
||||
return wrapText(formattedText, width, { hard: needsHardWrap });
|
||||
});
|
||||
|
||||
// Find max number of lines in this row
|
||||
const maxLines = Math.max(...cellLines.map(lines => lines.length), 1)
|
||||
const maxLines = Math.max(...cellLines.map(lines => lines.length), 1);
|
||||
|
||||
// Calculate vertical offset for each cell (to center vertically)
|
||||
const verticalOffsets = cellLines.map(lines =>
|
||||
Math.floor((maxLines - lines.length) / 2),
|
||||
)
|
||||
const verticalOffsets = cellLines.map(lines => Math.floor((maxLines - lines.length) / 2));
|
||||
|
||||
// Build each line of the row as a single string
|
||||
const result: string[] = []
|
||||
const result: string[] = [];
|
||||
for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) {
|
||||
let line = '│'
|
||||
let line = '│';
|
||||
for (let colIndex = 0; colIndex < cells.length; colIndex++) {
|
||||
const lines = cellLines[colIndex]!
|
||||
const offset = verticalOffsets[colIndex]!
|
||||
const contentLineIdx = lineIdx - offset
|
||||
const lineText =
|
||||
contentLineIdx >= 0 && contentLineIdx < lines.length
|
||||
? lines[contentLineIdx]!
|
||||
: ''
|
||||
const width = columnWidths[colIndex]!
|
||||
const lines = cellLines[colIndex]!;
|
||||
const offset = verticalOffsets[colIndex]!;
|
||||
const contentLineIdx = lineIdx - offset;
|
||||
const lineText = contentLineIdx >= 0 && contentLineIdx < lines.length ? lines[contentLineIdx]! : '';
|
||||
const width = columnWidths[colIndex]!;
|
||||
// Headers always centered; data uses table alignment
|
||||
const align = isHeader ? 'center' : (token.align?.[colIndex] ?? 'left')
|
||||
const align = isHeader ? 'center' : (token.align?.[colIndex] ?? 'left');
|
||||
|
||||
line +=
|
||||
' ' + padAligned(lineText, stringWidth(lineText), width, align) + ' │'
|
||||
line += ' ' + padAligned(lineText, stringWidth(lineText), width, align) + ' │';
|
||||
}
|
||||
result.push(line)
|
||||
result.push(line);
|
||||
}
|
||||
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
|
||||
// Render horizontal border as a single string
|
||||
@@ -246,109 +220,102 @@ export function MarkdownTable({
|
||||
top: ['┌', '─', '┬', '┐'],
|
||||
middle: ['├', '─', '┼', '┤'],
|
||||
bottom: ['└', '─', '┴', '┘'],
|
||||
}[type] as [string, string, string, string]
|
||||
}[type] as [string, string, string, string];
|
||||
|
||||
let line = left
|
||||
let line = left;
|
||||
columnWidths.forEach((width, colIndex) => {
|
||||
line += mid.repeat(width + 2)
|
||||
line += colIndex < columnWidths.length - 1 ? cross : right
|
||||
})
|
||||
return line
|
||||
line += mid.repeat(width + 2);
|
||||
line += colIndex < columnWidths.length - 1 ? cross : right;
|
||||
});
|
||||
return line;
|
||||
}
|
||||
|
||||
// Render vertical format (key-value pairs) for extra-narrow terminals
|
||||
function renderVerticalFormat(): string {
|
||||
const lines: string[] = []
|
||||
const headers = token.header.map(h => getPlainText(h.tokens))
|
||||
const separatorWidth = Math.min(terminalWidth - 1, 40)
|
||||
const separator = '─'.repeat(separatorWidth)
|
||||
const lines: string[] = [];
|
||||
const headers = token.header.map(h => getPlainText(h.tokens));
|
||||
const separatorWidth = Math.min(terminalWidth - 1, 40);
|
||||
const separator = '─'.repeat(separatorWidth);
|
||||
// Small indent for wrapped lines (just 2 spaces)
|
||||
const wrapIndent = ' '
|
||||
const wrapIndent = ' ';
|
||||
|
||||
token.rows.forEach((row, rowIndex) => {
|
||||
if (rowIndex > 0) {
|
||||
lines.push(separator)
|
||||
lines.push(separator);
|
||||
}
|
||||
|
||||
row.forEach((cell, colIndex) => {
|
||||
const label = headers[colIndex] || `Column ${colIndex + 1}`
|
||||
const label = headers[colIndex] || `Column ${colIndex + 1}`;
|
||||
// Clean value: trim, remove extra internal whitespace/newlines
|
||||
const rawValue = formatCell(cell.tokens).trimEnd()
|
||||
const value = rawValue.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
const rawValue = formatCell(cell.tokens).trimEnd();
|
||||
const value = rawValue.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Wrap value to fit terminal, accounting for label on first line
|
||||
const firstLineWidth = terminalWidth - stringWidth(label) - 3
|
||||
const subsequentLineWidth = terminalWidth - wrapIndent.length - 1
|
||||
const firstLineWidth = terminalWidth - stringWidth(label) - 3;
|
||||
const subsequentLineWidth = terminalWidth - wrapIndent.length - 1;
|
||||
|
||||
// Two-pass wrap: first line is narrower (label takes space),
|
||||
// continuation lines get the full width minus indent.
|
||||
const firstPassLines = wrapText(value, Math.max(firstLineWidth, 10))
|
||||
const firstLine = firstPassLines[0] || ''
|
||||
const firstPassLines = wrapText(value, Math.max(firstLineWidth, 10));
|
||||
const firstLine = firstPassLines[0] || '';
|
||||
|
||||
let wrappedValue: string[]
|
||||
if (
|
||||
firstPassLines.length <= 1 ||
|
||||
subsequentLineWidth <= firstLineWidth
|
||||
) {
|
||||
wrappedValue = firstPassLines
|
||||
let wrappedValue: string[];
|
||||
if (firstPassLines.length <= 1 || subsequentLineWidth <= firstLineWidth) {
|
||||
wrappedValue = firstPassLines;
|
||||
} else {
|
||||
// Re-join remaining text and re-wrap to the wider continuation width
|
||||
const remainingText = firstPassLines
|
||||
.slice(1)
|
||||
.map(l => l.trim())
|
||||
.join(' ')
|
||||
const rewrapped = wrapText(remainingText, subsequentLineWidth)
|
||||
wrappedValue = [firstLine, ...rewrapped]
|
||||
.join(' ');
|
||||
const rewrapped = wrapText(remainingText, subsequentLineWidth);
|
||||
wrappedValue = [firstLine, ...rewrapped];
|
||||
}
|
||||
|
||||
// First line: bold label + value
|
||||
lines.push(
|
||||
`${ANSI_BOLD_START}${label}:${ANSI_BOLD_END} ${wrappedValue[0] || ''}`,
|
||||
)
|
||||
lines.push(`${ANSI_BOLD_START}${label}:${ANSI_BOLD_END} ${wrappedValue[0] || ''}`);
|
||||
|
||||
// Subsequent lines with small indent (skip empty lines)
|
||||
for (let i = 1; i < wrappedValue.length; i++) {
|
||||
const line = wrappedValue[i]!
|
||||
if (!line.trim()) continue
|
||||
lines.push(`${wrapIndent}${line}`)
|
||||
const line = wrappedValue[i]!;
|
||||
if (!line.trim()) continue;
|
||||
lines.push(`${wrapIndent}${line}`);
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
return lines.join('\n')
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Choose format based on available width
|
||||
if (useVerticalFormat) {
|
||||
return <Ansi>{renderVerticalFormat()}</Ansi>
|
||||
return <Ansi>{renderVerticalFormat()}</Ansi>;
|
||||
}
|
||||
|
||||
// Build the complete horizontal table as an array of strings
|
||||
const tableLines: string[] = []
|
||||
tableLines.push(renderBorderLine('top'))
|
||||
tableLines.push(...renderRowLines(token.header, true))
|
||||
tableLines.push(renderBorderLine('middle'))
|
||||
const tableLines: string[] = [];
|
||||
tableLines.push(renderBorderLine('top'));
|
||||
tableLines.push(...renderRowLines(token.header, true));
|
||||
tableLines.push(renderBorderLine('middle'));
|
||||
token.rows.forEach((row, rowIndex) => {
|
||||
tableLines.push(...renderRowLines(row, false))
|
||||
tableLines.push(...renderRowLines(row, false));
|
||||
if (rowIndex < token.rows.length - 1) {
|
||||
tableLines.push(renderBorderLine('middle'))
|
||||
tableLines.push(renderBorderLine('middle'));
|
||||
}
|
||||
})
|
||||
tableLines.push(renderBorderLine('bottom'))
|
||||
});
|
||||
tableLines.push(renderBorderLine('bottom'));
|
||||
|
||||
// Safety check: verify no line exceeds terminal width.
|
||||
// This catches edge cases during terminal resize where calculations
|
||||
// were based on a different width than the current render target.
|
||||
const maxLineWidth = Math.max(
|
||||
...tableLines.map(line => stringWidth(stripAnsi(line))),
|
||||
)
|
||||
const maxLineWidth = Math.max(...tableLines.map(line => stringWidth(stripAnsi(line))));
|
||||
|
||||
// If we're within SAFETY_MARGIN characters of the edge, use vertical format
|
||||
// to account for terminal resize race conditions.
|
||||
if (maxLineWidth > terminalWidth - SAFETY_MARGIN) {
|
||||
return <Ansi>{renderVerticalFormat()}</Ansi>
|
||||
return <Ansi>{renderVerticalFormat()}</Ansi>;
|
||||
}
|
||||
|
||||
// Render as a single Ansi block to prevent Ink from wrapping mid-row
|
||||
return <Ansi>{tableLines.join('\n')}</Ansi>
|
||||
return <Ansi>{tableLines.join('\n')}</Ansi>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import { useMemoryUsage } from '../hooks/useMemoryUsage.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { formatFileSize } from '../utils/format.js'
|
||||
import * as React from 'react';
|
||||
import { useMemoryUsage } from '../hooks/useMemoryUsage.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { formatFileSize } from '../utils/format.js';
|
||||
|
||||
export function MemoryUsageIndicator(): React.ReactNode {
|
||||
// Ant-only: the /heapdump link is an internal debugging aid. Gating before
|
||||
@@ -9,25 +9,25 @@ export function MemoryUsageIndicator(): React.ReactNode {
|
||||
// USER_TYPE is a build-time constant, so the hook call below is either always
|
||||
// reached or dead-code-eliminated — never conditional at runtime.
|
||||
if (process.env.USER_TYPE !== 'ant') {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const memoryUsage = useMemoryUsage()
|
||||
const memoryUsage = useMemoryUsage();
|
||||
|
||||
if (!memoryUsage) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const { heapUsed, status } = memoryUsage
|
||||
const { heapUsed, status } = memoryUsage;
|
||||
|
||||
// Only show indicator when memory usage is high or critical
|
||||
if (status === 'normal') {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const formattedSize = formatFileSize(heapUsed)
|
||||
const color = status === 'critical' ? 'error' : 'warning'
|
||||
const formattedSize = formatFileSize(heapUsed);
|
||||
const color = status === 'critical' ? 'error' : 'warning';
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -35,5 +35,5 @@ export function MemoryUsageIndicator(): React.ReactNode {
|
||||
High memory usage ({formattedSize}) · /heapdump
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { BetaContentBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import { feature } from 'bun:bundle';
|
||||
import type { BetaContentBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs';
|
||||
import type {
|
||||
ImageBlockParam,
|
||||
TextBlockParam,
|
||||
ThinkingBlockParam,
|
||||
ToolResultBlockParam,
|
||||
ToolUseBlockParam,
|
||||
} from '@anthropic-ai/sdk/resources/index.mjs'
|
||||
import * as React from 'react'
|
||||
import type { Command } from '../commands.js'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { Box } from '@anthropic/ink'
|
||||
import type { Tools } from '../Tool.js'
|
||||
import {
|
||||
type ConnectorTextBlock,
|
||||
isConnectorTextBlock,
|
||||
} from '../types/connectorText.js'
|
||||
} from '@anthropic-ai/sdk/resources/index.mjs';
|
||||
import * as React from 'react';
|
||||
import type { Command } from '../commands.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { Box } from '@anthropic/ink';
|
||||
import type { Tools } from '../Tool.js';
|
||||
import { type ConnectorTextBlock, isConnectorTextBlock } from '../types/connectorText.js';
|
||||
import type {
|
||||
AssistantMessage,
|
||||
AttachmentMessage as AttachmentMessageType,
|
||||
@@ -24,27 +21,27 @@ import type {
|
||||
NormalizedUserMessage,
|
||||
ProgressMessage,
|
||||
SystemMessage,
|
||||
} from '../types/message.js'
|
||||
import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js'
|
||||
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'
|
||||
import { logError } from '../utils/log.js'
|
||||
import type { buildMessageLookups } from '../utils/messages.js'
|
||||
import { CompactSummary } from './CompactSummary.js'
|
||||
import { AdvisorMessage } from './messages/AdvisorMessage.js'
|
||||
import { AssistantRedactedThinkingMessage } from './messages/AssistantRedactedThinkingMessage.js'
|
||||
import { AssistantTextMessage } from './messages/AssistantTextMessage.js'
|
||||
import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js'
|
||||
import { AssistantToolUseMessage } from './messages/AssistantToolUseMessage.js'
|
||||
import { AttachmentMessage } from './messages/AttachmentMessage.js'
|
||||
import { CollapsedReadSearchContent } from './messages/CollapsedReadSearchContent.js'
|
||||
import { CompactBoundaryMessage } from './messages/CompactBoundaryMessage.js'
|
||||
import { GroupedToolUseContent } from './messages/GroupedToolUseContent.js'
|
||||
import { SystemTextMessage } from './messages/SystemTextMessage.js'
|
||||
import { UserImageMessage } from './messages/UserImageMessage.js'
|
||||
import { UserTextMessage } from './messages/UserTextMessage.js'
|
||||
import { UserToolResultMessage } from './messages/UserToolResultMessage/UserToolResultMessage.js'
|
||||
import { OffscreenFreeze } from './OffscreenFreeze.js'
|
||||
import { ExpandShellOutputProvider } from './shell/ExpandShellOutputContext.js'
|
||||
} from '../types/message.js';
|
||||
import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js';
|
||||
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js';
|
||||
import { logError } from '../utils/log.js';
|
||||
import type { buildMessageLookups } from '../utils/messages.js';
|
||||
import { CompactSummary } from './CompactSummary.js';
|
||||
import { AdvisorMessage } from './messages/AdvisorMessage.js';
|
||||
import { AssistantRedactedThinkingMessage } from './messages/AssistantRedactedThinkingMessage.js';
|
||||
import { AssistantTextMessage } from './messages/AssistantTextMessage.js';
|
||||
import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js';
|
||||
import { AssistantToolUseMessage } from './messages/AssistantToolUseMessage.js';
|
||||
import { AttachmentMessage } from './messages/AttachmentMessage.js';
|
||||
import { CollapsedReadSearchContent } from './messages/CollapsedReadSearchContent.js';
|
||||
import { CompactBoundaryMessage } from './messages/CompactBoundaryMessage.js';
|
||||
import { GroupedToolUseContent } from './messages/GroupedToolUseContent.js';
|
||||
import { SystemTextMessage } from './messages/SystemTextMessage.js';
|
||||
import { UserImageMessage } from './messages/UserImageMessage.js';
|
||||
import { UserTextMessage } from './messages/UserTextMessage.js';
|
||||
import { UserToolResultMessage } from './messages/UserToolResultMessage/UserToolResultMessage.js';
|
||||
import { OffscreenFreeze } from './OffscreenFreeze.js';
|
||||
import { ExpandShellOutputProvider } from './shell/ExpandShellOutputContext.js';
|
||||
|
||||
export type Props = {
|
||||
message:
|
||||
@@ -53,33 +50,33 @@ export type Props = {
|
||||
| AttachmentMessageType
|
||||
| SystemMessage
|
||||
| GroupedToolUseMessageType
|
||||
| CollapsedReadSearchGroupType
|
||||
lookups: ReturnType<typeof buildMessageLookups>
|
||||
| CollapsedReadSearchGroupType;
|
||||
lookups: ReturnType<typeof buildMessageLookups>;
|
||||
// TODO: Find a way to remove this, and leave spacing to the consumer
|
||||
/** Absolute width for the container Box. When provided, eliminates a wrapper Box in the caller. */
|
||||
containerWidth?: number
|
||||
addMargin: boolean
|
||||
tools: Tools
|
||||
commands: Command[]
|
||||
verbose: boolean
|
||||
inProgressToolUseIDs: Set<string>
|
||||
progressMessagesForMessage: ProgressMessage[]
|
||||
shouldAnimate: boolean
|
||||
shouldShowDot: boolean
|
||||
style?: 'condensed'
|
||||
width?: number | string
|
||||
isTranscriptMode: boolean
|
||||
isStatic: boolean
|
||||
onOpenRateLimitOptions?: () => void
|
||||
isActiveCollapsedGroup?: boolean
|
||||
isUserContinuation?: boolean
|
||||
containerWidth?: number;
|
||||
addMargin: boolean;
|
||||
tools: Tools;
|
||||
commands: Command[];
|
||||
verbose: boolean;
|
||||
inProgressToolUseIDs: Set<string>;
|
||||
progressMessagesForMessage: ProgressMessage[];
|
||||
shouldAnimate: boolean;
|
||||
shouldShowDot: boolean;
|
||||
style?: 'condensed';
|
||||
width?: number | string;
|
||||
isTranscriptMode: boolean;
|
||||
isStatic: boolean;
|
||||
onOpenRateLimitOptions?: () => void;
|
||||
isActiveCollapsedGroup?: boolean;
|
||||
isUserContinuation?: boolean;
|
||||
/** ID of the last thinking block (uuid:index) to show, used for hiding past thinking in transcript mode */
|
||||
lastThinkingBlockId?: string | null
|
||||
lastThinkingBlockId?: string | null;
|
||||
/** UUID of the latest user bash output message (for auto-expanding) */
|
||||
latestBashOutputUUID?: string | null
|
||||
latestBashOutputUUID?: string | null;
|
||||
/** Whether to collapse diff display for this message */
|
||||
shouldCollapseDiffs?: boolean
|
||||
}
|
||||
shouldCollapseDiffs?: boolean;
|
||||
};
|
||||
|
||||
function MessageImpl({
|
||||
message,
|
||||
@@ -112,7 +109,7 @@ function MessageImpl({
|
||||
verbose={verbose}
|
||||
isTranscriptMode={isTranscriptMode}
|
||||
/>
|
||||
)
|
||||
);
|
||||
case 'assistant':
|
||||
return (
|
||||
<Box flexDirection="column" width={containerWidth ?? '100%'}>
|
||||
@@ -139,38 +136,37 @@ function MessageImpl({
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
case 'user': {
|
||||
if (message.isCompactSummary) {
|
||||
return (
|
||||
<CompactSummary
|
||||
message={message}
|
||||
screen={isTranscriptMode ? 'transcript' : 'prompt'}
|
||||
/>
|
||||
)
|
||||
return <CompactSummary message={message} screen={isTranscriptMode ? 'transcript' : 'prompt'} />;
|
||||
}
|
||||
// Precompute the imageIndex prop for each content block. The previous
|
||||
// version incremented a counter inside the .map() callback, which
|
||||
// React Compiler bails on ("UpdateExpression to variables captured
|
||||
// within lambdas"). A plain for loop keeps the mutation out of a
|
||||
// closure so the compiler can memoize MessageImpl.
|
||||
const imageIndices: number[] = []
|
||||
let imagePosition = 0
|
||||
const imageIndices: number[] = [];
|
||||
let imagePosition = 0;
|
||||
for (const param of message.message.content as Array<{ type: string }>) {
|
||||
if (param.type === 'image') {
|
||||
const id = message.imagePasteIds?.[imagePosition]
|
||||
imagePosition++
|
||||
imageIndices.push(id ?? imagePosition)
|
||||
const id = message.imagePasteIds?.[imagePosition];
|
||||
imagePosition++;
|
||||
imageIndices.push(id ?? imagePosition);
|
||||
} else {
|
||||
imageIndices.push(imagePosition)
|
||||
imageIndices.push(imagePosition);
|
||||
}
|
||||
}
|
||||
// Check if this message is the latest bash output - if so, wrap content
|
||||
// with provider so OutputLine can show full output via context
|
||||
const isLatestBashOutput = latestBashOutputUUID === message.uuid
|
||||
const isLatestBashOutput = latestBashOutputUUID === message.uuid;
|
||||
const content = (
|
||||
<Box flexDirection="column" width={containerWidth ?? '100%'}>
|
||||
{(message.message.content as Array<TextBlockParam | ImageBlockParam | ToolUseBlockParam | ToolResultBlockParam>).map((param, index) => (
|
||||
{(
|
||||
message.message.content as Array<
|
||||
TextBlockParam | ImageBlockParam | ToolUseBlockParam | ToolResultBlockParam
|
||||
>
|
||||
).map((param, index) => (
|
||||
<UserMessage
|
||||
key={index}
|
||||
message={message}
|
||||
@@ -188,12 +184,8 @@ function MessageImpl({
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
return isLatestBashOutput ? (
|
||||
<ExpandShellOutputProvider>{content}</ExpandShellOutputProvider>
|
||||
) : (
|
||||
content
|
||||
)
|
||||
);
|
||||
return isLatestBashOutput ? <ExpandShellOutputProvider>{content}</ExpandShellOutputProvider> : content;
|
||||
}
|
||||
case 'system':
|
||||
if (message.subtype === 'compact_boundary') {
|
||||
@@ -201,32 +193,32 @@ function MessageImpl({
|
||||
// appends instead of resetting, Messages.tsx skips the boundary
|
||||
// filter) — scroll up for history, no need for the ctrl+o hint.
|
||||
if (isFullscreenEnvEnabled()) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
return <CompactBoundaryMessage />
|
||||
return <CompactBoundaryMessage />;
|
||||
}
|
||||
if (message.subtype === 'microcompact_boundary') {
|
||||
// Logged at creation time in createMicrocompactBoundaryMessage
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
if (feature('HISTORY_SNIP')) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { isSnipBoundaryMessage } =
|
||||
require('../services/compact/snipProjection.js') as typeof import('../services/compact/snipProjection.js')
|
||||
require('../services/compact/snipProjection.js') as typeof import('../services/compact/snipProjection.js');
|
||||
const { isSnipMarkerMessage } =
|
||||
require('../services/compact/snipCompact.js') as typeof import('../services/compact/snipCompact.js')
|
||||
require('../services/compact/snipCompact.js') as typeof import('../services/compact/snipCompact.js');
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
if (isSnipBoundaryMessage(message)) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { SnipBoundaryMessage } =
|
||||
require('./messages/SnipBoundaryMessage.js') as typeof import('./messages/SnipBoundaryMessage.js')
|
||||
require('./messages/SnipBoundaryMessage.js') as typeof import('./messages/SnipBoundaryMessage.js');
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
return <SnipBoundaryMessage message={message} />
|
||||
return <SnipBoundaryMessage message={message} />;
|
||||
}
|
||||
if (isSnipMarkerMessage(message)) {
|
||||
// Internal registration marker — not user-facing. The boundary
|
||||
// message (above) is what shows when snips actually execute.
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (message.subtype === 'local_command') {
|
||||
@@ -237,7 +229,7 @@ function MessageImpl({
|
||||
verbose={verbose}
|
||||
isTranscriptMode={isTranscriptMode}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SystemTextMessage
|
||||
@@ -246,7 +238,7 @@ function MessageImpl({
|
||||
verbose={verbose}
|
||||
isTranscriptMode={isTranscriptMode}
|
||||
/>
|
||||
)
|
||||
);
|
||||
case 'grouped_tool_use':
|
||||
return (
|
||||
<GroupedToolUseContent
|
||||
@@ -256,7 +248,7 @@ function MessageImpl({
|
||||
inProgressToolUseIDs={inProgressToolUseIDs}
|
||||
shouldAnimate={shouldAnimate}
|
||||
/>
|
||||
)
|
||||
);
|
||||
case 'collapsed_read_search':
|
||||
// OffscreenFreeze: the verb flips "Reading…"→"Read" when tools complete.
|
||||
// If the group has scrolled into scrollback by then, the update triggers
|
||||
@@ -281,7 +273,7 @@ function MessageImpl({
|
||||
isActiveGroup={isActiveCollapsedGroup}
|
||||
/>
|
||||
</OffscreenFreeze>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,24 +291,20 @@ function UserMessage({
|
||||
isTranscriptMode,
|
||||
shouldCollapseDiffs,
|
||||
}: {
|
||||
message: NormalizedUserMessage
|
||||
addMargin: boolean
|
||||
tools: Tools
|
||||
progressMessagesForMessage: ProgressMessage[]
|
||||
param:
|
||||
| TextBlockParam
|
||||
| ImageBlockParam
|
||||
| ToolUseBlockParam
|
||||
| ToolResultBlockParam
|
||||
style?: 'condensed'
|
||||
verbose: boolean
|
||||
imageIndex?: number
|
||||
isUserContinuation: boolean
|
||||
lookups: ReturnType<typeof buildMessageLookups>
|
||||
isTranscriptMode: boolean
|
||||
shouldCollapseDiffs?: boolean
|
||||
message: NormalizedUserMessage;
|
||||
addMargin: boolean;
|
||||
tools: Tools;
|
||||
progressMessagesForMessage: ProgressMessage[];
|
||||
param: TextBlockParam | ImageBlockParam | ToolUseBlockParam | ToolResultBlockParam;
|
||||
style?: 'condensed';
|
||||
verbose: boolean;
|
||||
imageIndex?: number;
|
||||
isUserContinuation: boolean;
|
||||
lookups: ReturnType<typeof buildMessageLookups>;
|
||||
isTranscriptMode: boolean;
|
||||
shouldCollapseDiffs?: boolean;
|
||||
}): React.ReactNode {
|
||||
const { columns } = useTerminalSize()
|
||||
const { columns } = useTerminalSize();
|
||||
switch (param.type) {
|
||||
case 'text':
|
||||
return (
|
||||
@@ -328,16 +316,11 @@ function UserMessage({
|
||||
isTranscriptMode={isTranscriptMode}
|
||||
timestamp={message.timestamp as string | undefined}
|
||||
/>
|
||||
)
|
||||
);
|
||||
case 'image':
|
||||
// If previous message is user (text or image), this is a continuation - use connector
|
||||
// Otherwise this image starts a new user turn - use margin
|
||||
return (
|
||||
<UserImageMessage
|
||||
imageId={imageIndex}
|
||||
addMargin={addMargin && !isUserContinuation}
|
||||
/>
|
||||
)
|
||||
return <UserImageMessage imageId={imageIndex} addMargin={addMargin && !isUserContinuation} />;
|
||||
case 'tool_result':
|
||||
return (
|
||||
<UserToolResultMessage
|
||||
@@ -352,9 +335,9 @@ function UserMessage({
|
||||
isTranscriptMode={isTranscriptMode}
|
||||
shouldCollapseDiffs={shouldCollapseDiffs}
|
||||
/>
|
||||
)
|
||||
);
|
||||
default:
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,25 +368,25 @@ function AssistantMessageBlock({
|
||||
| ImageBlockParam
|
||||
| ThinkingBlockParam
|
||||
| ToolUseBlockParam
|
||||
| ToolResultBlockParam
|
||||
addMargin: boolean
|
||||
tools: Tools
|
||||
commands: Command[]
|
||||
verbose: boolean
|
||||
inProgressToolUseIDs: Set<string>
|
||||
progressMessagesForMessage: ProgressMessage[]
|
||||
shouldAnimate: boolean
|
||||
shouldShowDot: boolean
|
||||
width?: number | string
|
||||
inProgressToolCallCount?: number
|
||||
isTranscriptMode: boolean
|
||||
lookups: ReturnType<typeof buildMessageLookups>
|
||||
onOpenRateLimitOptions?: () => void
|
||||
| ToolResultBlockParam;
|
||||
addMargin: boolean;
|
||||
tools: Tools;
|
||||
commands: Command[];
|
||||
verbose: boolean;
|
||||
inProgressToolUseIDs: Set<string>;
|
||||
progressMessagesForMessage: ProgressMessage[];
|
||||
shouldAnimate: boolean;
|
||||
shouldShowDot: boolean;
|
||||
width?: number | string;
|
||||
inProgressToolCallCount?: number;
|
||||
isTranscriptMode: boolean;
|
||||
lookups: ReturnType<typeof buildMessageLookups>;
|
||||
onOpenRateLimitOptions?: () => void;
|
||||
/** ID of this content block's message:index for thinking block comparison */
|
||||
thinkingBlockId: string
|
||||
thinkingBlockId: string;
|
||||
/** ID of the last thinking block to show, null means show all */
|
||||
lastThinkingBlockId?: string | null
|
||||
advisorModel?: string
|
||||
lastThinkingBlockId?: string | null;
|
||||
advisorModel?: string;
|
||||
}): React.ReactNode {
|
||||
if (feature('CONNECTOR_TEXT')) {
|
||||
if (isConnectorTextBlock(param)) {
|
||||
@@ -416,7 +399,7 @@ function AssistantMessageBlock({
|
||||
width={width}
|
||||
onOpenRateLimitOptions={onOpenRateLimitOptions}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
switch (param.type) {
|
||||
@@ -436,7 +419,7 @@ function AssistantMessageBlock({
|
||||
lookups={lookups}
|
||||
isTranscriptMode={isTranscriptMode}
|
||||
/>
|
||||
)
|
||||
);
|
||||
case 'text':
|
||||
return (
|
||||
<AssistantTextMessage
|
||||
@@ -447,19 +430,18 @@ function AssistantMessageBlock({
|
||||
width={width}
|
||||
onOpenRateLimitOptions={onOpenRateLimitOptions}
|
||||
/>
|
||||
)
|
||||
);
|
||||
case 'redacted_thinking':
|
||||
if (!isTranscriptMode && !verbose) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
return <AssistantRedactedThinkingMessage addMargin={addMargin} />
|
||||
return <AssistantRedactedThinkingMessage addMargin={addMargin} />;
|
||||
case 'thinking': {
|
||||
if (!isTranscriptMode && !verbose) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
// In transcript mode with hidePastThinking, only show the last thinking block
|
||||
const isLastThinking =
|
||||
!lastThinkingBlockId || thinkingBlockId === lastThinkingBlockId
|
||||
const isLastThinking = !lastThinkingBlockId || thinkingBlockId === lastThinkingBlockId;
|
||||
return (
|
||||
<AssistantThinkingMessage
|
||||
addMargin={addMargin}
|
||||
@@ -468,7 +450,7 @@ function AssistantMessageBlock({
|
||||
verbose={verbose}
|
||||
hideInTranscript={isTranscriptMode && !isLastThinking}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
case 'server_tool_use':
|
||||
case 'advisor_tool_result':
|
||||
@@ -483,29 +465,24 @@ function AssistantMessageBlock({
|
||||
verbose={verbose || isTranscriptMode}
|
||||
advisorModel={advisorModel}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
logError(new Error(`Unable to render server tool block: ${param.type}`))
|
||||
return null
|
||||
logError(new Error(`Unable to render server tool block: ${param.type}`));
|
||||
return null;
|
||||
default:
|
||||
logError(new Error(`Unable to render message type: ${param.type}`))
|
||||
return null
|
||||
logError(new Error(`Unable to render message type: ${param.type}`));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasThinkingContent(m: {
|
||||
type: string
|
||||
message?: { content: Array<{ type: string }> }
|
||||
}): boolean {
|
||||
if (m.type !== 'assistant' || !m.message) return false
|
||||
return m.message.content.some(
|
||||
b => b.type === 'thinking' || b.type === 'redacted_thinking',
|
||||
)
|
||||
export function hasThinkingContent(m: { type: string; message?: { content: Array<{ type: string }> } }): boolean {
|
||||
if (m.type !== 'assistant' || !m.message) return false;
|
||||
return m.message.content.some(b => b.type === 'thinking' || b.type === 'redacted_thinking');
|
||||
}
|
||||
|
||||
/** Exported for testing */
|
||||
export function areMessagePropsEqual(prev: Props, next: Props): boolean {
|
||||
if (prev.message.uuid !== next.message.uuid) return false
|
||||
if (prev.message.uuid !== next.message.uuid) return false;
|
||||
// Only re-render on lastThinkingBlockId change if this message actually
|
||||
// has thinking content — otherwise every message in scrollback re-renders
|
||||
// whenever streaming thinking starts/stops (CC-941).
|
||||
@@ -513,21 +490,21 @@ export function areMessagePropsEqual(prev: Props, next: Props): boolean {
|
||||
prev.lastThinkingBlockId !== next.lastThinkingBlockId &&
|
||||
hasThinkingContent(next.message as Parameters<typeof hasThinkingContent>[0])
|
||||
) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
// Verbose toggle changes thinking block visibility/expansion
|
||||
if (prev.verbose !== next.verbose) return false
|
||||
if (prev.verbose !== next.verbose) return false;
|
||||
// Only re-render if this message's "is latest bash output" status changed,
|
||||
// not when the global latestBashOutputUUID changes to a different message
|
||||
const prevIsLatest = prev.latestBashOutputUUID === prev.message.uuid
|
||||
const nextIsLatest = next.latestBashOutputUUID === next.message.uuid
|
||||
if (prevIsLatest !== nextIsLatest) return false
|
||||
if (prev.isTranscriptMode !== next.isTranscriptMode) return false
|
||||
const prevIsLatest = prev.latestBashOutputUUID === prev.message.uuid;
|
||||
const nextIsLatest = next.latestBashOutputUUID === next.message.uuid;
|
||||
if (prevIsLatest !== nextIsLatest) return false;
|
||||
if (prev.isTranscriptMode !== next.isTranscriptMode) return false;
|
||||
// containerWidth is an absolute number in the no-metadata path (wrapper
|
||||
// Box is skipped). Static messages must re-render on terminal resize.
|
||||
if (prev.containerWidth !== next.containerWidth) return false
|
||||
if (prev.isStatic && next.isStatic) return true
|
||||
return false
|
||||
if (prev.containerWidth !== next.containerWidth) return false;
|
||||
if (prev.isStatic && next.isStatic) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export const Message = React.memo(MessageImpl, areMessagePropsEqual)
|
||||
export const Message = React.memo(MessageImpl, areMessagePropsEqual);
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
import React from 'react'
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink'
|
||||
import type { NormalizedMessage } from '../types/message.js'
|
||||
import React from 'react';
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink';
|
||||
import type { NormalizedMessage } from '../types/message.js';
|
||||
|
||||
type Props = {
|
||||
message: NormalizedMessage
|
||||
isTranscriptMode: boolean
|
||||
}
|
||||
message: NormalizedMessage;
|
||||
isTranscriptMode: boolean;
|
||||
};
|
||||
|
||||
export function MessageModel({
|
||||
message,
|
||||
isTranscriptMode,
|
||||
}: Props): React.ReactNode {
|
||||
const content = message.message?.content
|
||||
const contentArray = Array.isArray(content) ? content : []
|
||||
export function MessageModel({ message, isTranscriptMode }: Props): React.ReactNode {
|
||||
const content = message.message?.content;
|
||||
const contentArray = Array.isArray(content) ? content : [];
|
||||
const shouldShowModel =
|
||||
isTranscriptMode &&
|
||||
message.type === 'assistant' &&
|
||||
message.message?.model &&
|
||||
contentArray.some((c: any) => c?.type === 'text')
|
||||
contentArray.some((c: any) => c?.type === 'text');
|
||||
|
||||
if (!shouldShowModel) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const model = message.message!.model as string
|
||||
const model = message.message!.model as string;
|
||||
|
||||
return (
|
||||
<Box minWidth={stringWidth(model) + 8}>
|
||||
<Text dimColor>{model}</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import * as React from 'react'
|
||||
import { useContext } from 'react'
|
||||
import { Box, NoSelect, Text, Ratchet } from '@anthropic/ink'
|
||||
import * as React from 'react';
|
||||
import { useContext } from 'react';
|
||||
import { Box, NoSelect, Text, Ratchet } from '@anthropic/ink';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
height?: number
|
||||
}
|
||||
children: React.ReactNode;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export function MessageResponse({ children, height }: Props): React.ReactNode {
|
||||
const isMessageResponse = useContext(MessageResponseContext)
|
||||
const isMessageResponse = useContext(MessageResponseContext);
|
||||
if (isMessageResponse) {
|
||||
return children
|
||||
return children;
|
||||
}
|
||||
const content = (
|
||||
<MessageResponseProvider>
|
||||
@@ -23,26 +23,18 @@ export function MessageResponse({ children, height }: Props): React.ReactNode {
|
||||
</Box>
|
||||
</Box>
|
||||
</MessageResponseProvider>
|
||||
)
|
||||
);
|
||||
if (height !== undefined) {
|
||||
return content
|
||||
return content;
|
||||
}
|
||||
return <Ratchet lock="offscreen">{content}</Ratchet>
|
||||
return <Ratchet lock="offscreen">{content}</Ratchet>;
|
||||
}
|
||||
|
||||
// This is a context that is used to determine if the message response
|
||||
// is rendered as a descendant of another MessageResponse. We use it
|
||||
// to avoid rendering nested ⎿ characters.
|
||||
const MessageResponseContext = React.createContext(false)
|
||||
const MessageResponseContext = React.createContext(false);
|
||||
|
||||
function MessageResponseProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<MessageResponseContext.Provider value={true}>
|
||||
{children}
|
||||
</MessageResponseContext.Provider>
|
||||
)
|
||||
function MessageResponseProvider({ children }: { children: React.ReactNode }): React.ReactNode {
|
||||
return <MessageResponseContext.Provider value={true}>{children}</MessageResponseContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -1,62 +1,69 @@
|
||||
import * as React from 'react'
|
||||
import type { Command } from '../commands.js'
|
||||
import { Box } from '@anthropic/ink'
|
||||
import type { Screen } from '../screens/REPL.js'
|
||||
import type { Tools } from '../Tool.js'
|
||||
import type { RenderableMessage } from '../types/message.js'
|
||||
import * as React from 'react';
|
||||
import type { Command } from '../commands.js';
|
||||
import { Box } from '@anthropic/ink';
|
||||
import type { Screen } from '../screens/REPL.js';
|
||||
import type { Tools } from '../Tool.js';
|
||||
import type { RenderableMessage } from '../types/message.js';
|
||||
import {
|
||||
getDisplayMessageFromCollapsed,
|
||||
getToolSearchOrReadInfo,
|
||||
getToolUseIdsFromCollapsedGroup,
|
||||
hasAnyToolInProgress,
|
||||
} from '../utils/collapseReadSearch.js'
|
||||
} from '../utils/collapseReadSearch.js';
|
||||
import {
|
||||
type buildMessageLookups,
|
||||
EMPTY_STRING_SET,
|
||||
getProgressMessagesFromLookup,
|
||||
getSiblingToolUseIDsFromLookup,
|
||||
getToolUseID,
|
||||
} from '../utils/messages.js'
|
||||
import { hasThinkingContent, Message } from './Message.js'
|
||||
} from '../utils/messages.js';
|
||||
import { hasThinkingContent, Message } from './Message.js';
|
||||
|
||||
// Narrow the first element of MessageContent to a block with known shape.
|
||||
type ContentBlock = { type: string; name?: string; input?: unknown; id?: string; text?: string; [key: string]: unknown }
|
||||
type ContentBlock = {
|
||||
type: string;
|
||||
name?: string;
|
||||
input?: unknown;
|
||||
id?: string;
|
||||
text?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
const firstBlock = (content: unknown): ContentBlock | undefined => {
|
||||
if (!Array.isArray(content)) return undefined
|
||||
const b = content[0]
|
||||
if (b == null || typeof b === 'string') return undefined
|
||||
return b as ContentBlock
|
||||
}
|
||||
import { MessageModel } from './MessageModel.js'
|
||||
import { shouldRenderStatically } from './Messages.js'
|
||||
import { MessageTimestamp } from './MessageTimestamp.js'
|
||||
import { OffscreenFreeze } from './OffscreenFreeze.js'
|
||||
if (!Array.isArray(content)) return undefined;
|
||||
const b = content[0];
|
||||
if (b == null || typeof b === 'string') return undefined;
|
||||
return b as ContentBlock;
|
||||
};
|
||||
import { MessageModel } from './MessageModel.js';
|
||||
import { shouldRenderStatically } from './Messages.js';
|
||||
import { MessageTimestamp } from './MessageTimestamp.js';
|
||||
import { OffscreenFreeze } from './OffscreenFreeze.js';
|
||||
|
||||
export type Props = {
|
||||
message: RenderableMessage
|
||||
message: RenderableMessage;
|
||||
/** Whether the previous message in renderableMessages is also a user message. */
|
||||
isUserContinuation: boolean
|
||||
isUserContinuation: boolean;
|
||||
/**
|
||||
* Whether there is non-skippable content after this message in renderableMessages.
|
||||
* Only needs to be accurate for `collapsed_read_search` messages — used to decide
|
||||
* if the collapsed group spinner should stay active. Pass `false` otherwise.
|
||||
*/
|
||||
hasContentAfter: boolean
|
||||
tools: Tools
|
||||
commands: Command[]
|
||||
verbose: boolean
|
||||
inProgressToolUseIDs: Set<string>
|
||||
streamingToolUseIDs: Set<string>
|
||||
screen: Screen
|
||||
canAnimate: boolean
|
||||
onOpenRateLimitOptions?: () => void
|
||||
lastThinkingBlockId: string | null
|
||||
latestBashOutputUUID: string | null
|
||||
columns: number
|
||||
isLoading: boolean
|
||||
lookups: ReturnType<typeof buildMessageLookups>
|
||||
shouldCollapseDiffs?: boolean
|
||||
}
|
||||
hasContentAfter: boolean;
|
||||
tools: Tools;
|
||||
commands: Command[];
|
||||
verbose: boolean;
|
||||
inProgressToolUseIDs: Set<string>;
|
||||
streamingToolUseIDs: Set<string>;
|
||||
screen: Screen;
|
||||
canAnimate: boolean;
|
||||
onOpenRateLimitOptions?: () => void;
|
||||
lastThinkingBlockId: string | null;
|
||||
latestBashOutputUUID: string | null;
|
||||
columns: number;
|
||||
isLoading: boolean;
|
||||
lookups: ReturnType<typeof buildMessageLookups>;
|
||||
shouldCollapseDiffs?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scans forward from `index+1` to check if any "real" content follows. Used to
|
||||
@@ -75,54 +82,46 @@ export function hasContentAfterIndex(
|
||||
streamingToolUseIDs: Set<string>,
|
||||
): boolean {
|
||||
for (let i = index + 1; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
const msg = messages[i];
|
||||
if (msg?.type === 'assistant') {
|
||||
const content = firstBlock(msg.message.content)
|
||||
if (
|
||||
content?.type === 'thinking' ||
|
||||
content?.type === 'redacted_thinking'
|
||||
) {
|
||||
continue
|
||||
const content = firstBlock(msg.message.content);
|
||||
if (content?.type === 'thinking' || content?.type === 'redacted_thinking') {
|
||||
continue;
|
||||
}
|
||||
if (content?.type === 'tool_use') {
|
||||
if (
|
||||
getToolSearchOrReadInfo(content.name!, content.input, tools)
|
||||
.isCollapsible
|
||||
) {
|
||||
continue
|
||||
if (getToolSearchOrReadInfo(content.name!, content.input, tools).isCollapsible) {
|
||||
continue;
|
||||
}
|
||||
// Non-collapsible tool uses appear in syntheticStreamingToolUseMessages
|
||||
// before their ID is added to inProgressToolUseIDs. Skip while streaming
|
||||
// to avoid briefly finalizing the read group.
|
||||
if (streamingToolUseIDs.has(content.id!)) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
if (msg?.type === 'system' || msg?.type === 'attachment') {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
// Tool results arrive while the collapsed group is still being built
|
||||
if (msg?.type === 'user') {
|
||||
const content = firstBlock(msg.message.content)
|
||||
const content = firstBlock(msg.message.content);
|
||||
if (content?.type === 'tool_result') {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Collapsible grouped_tool_use messages arrive transiently before being
|
||||
// merged into the current collapsed group on the next render cycle
|
||||
if (msg?.type === 'grouped_tool_use') {
|
||||
const firstInput = firstBlock(msg.messages[0]?.message.content)?.input
|
||||
if (
|
||||
getToolSearchOrReadInfo(msg.toolName, firstInput, tools).isCollapsible
|
||||
) {
|
||||
continue
|
||||
const firstInput = firstBlock(msg.messages[0]?.message.content)?.input;
|
||||
if (getToolSearchOrReadInfo(msg.toolName, firstInput, tools).isCollapsible) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
function MessageRowImpl({
|
||||
@@ -144,32 +143,22 @@ function MessageRowImpl({
|
||||
lookups,
|
||||
shouldCollapseDiffs,
|
||||
}: Props): React.ReactNode {
|
||||
const isTranscriptMode = screen === 'transcript'
|
||||
const isGrouped = msg.type === 'grouped_tool_use'
|
||||
const isCollapsed = msg.type === 'collapsed_read_search'
|
||||
const isTranscriptMode = screen === 'transcript';
|
||||
const isGrouped = msg.type === 'grouped_tool_use';
|
||||
const isCollapsed = msg.type === 'collapsed_read_search';
|
||||
|
||||
// A collapsed group is "active" (grey dot, present tense "Reading…") when its tools
|
||||
// are still executing OR when the overall query is still running with nothing after it.
|
||||
// hasAnyToolInProgress takes priority: if tools are running, always show active regardless
|
||||
// of what else is in the message list (avoids false finalization during parallel execution).
|
||||
const isActiveCollapsedGroup =
|
||||
isCollapsed &&
|
||||
(hasAnyToolInProgress(msg, inProgressToolUseIDs) ||
|
||||
(isLoading && !hasContentAfter))
|
||||
isCollapsed && (hasAnyToolInProgress(msg, inProgressToolUseIDs) || (isLoading && !hasContentAfter));
|
||||
|
||||
const displayMsg = isGrouped
|
||||
? msg.displayMessage
|
||||
: isCollapsed
|
||||
? getDisplayMessageFromCollapsed(msg)
|
||||
: msg
|
||||
const displayMsg = isGrouped ? msg.displayMessage : isCollapsed ? getDisplayMessageFromCollapsed(msg) : msg;
|
||||
|
||||
const progressMessagesForMessage =
|
||||
isGrouped || isCollapsed ? [] : getProgressMessagesFromLookup(msg, lookups)
|
||||
const progressMessagesForMessage = isGrouped || isCollapsed ? [] : getProgressMessagesFromLookup(msg, lookups);
|
||||
|
||||
const siblingToolUseIDs =
|
||||
isGrouped || isCollapsed
|
||||
? EMPTY_STRING_SET
|
||||
: getSiblingToolUseIDsFromLookup(msg, lookups)
|
||||
const siblingToolUseIDs = isGrouped || isCollapsed ? EMPTY_STRING_SET : getSiblingToolUseIDsFromLookup(msg, lookups);
|
||||
|
||||
const isStatic = shouldRenderStatically(
|
||||
msg,
|
||||
@@ -178,30 +167,29 @@ function MessageRowImpl({
|
||||
siblingToolUseIDs,
|
||||
screen,
|
||||
lookups,
|
||||
)
|
||||
);
|
||||
|
||||
let shouldAnimate = false
|
||||
let shouldAnimate = false;
|
||||
if (canAnimate) {
|
||||
if (isGrouped) {
|
||||
shouldAnimate = msg.messages.some(m => {
|
||||
const content = firstBlock(m.message.content)
|
||||
return (
|
||||
content?.type === 'tool_use' && inProgressToolUseIDs.has(content.id!)
|
||||
)
|
||||
})
|
||||
const content = firstBlock(m.message.content);
|
||||
return content?.type === 'tool_use' && inProgressToolUseIDs.has(content.id!);
|
||||
});
|
||||
} else if (isCollapsed) {
|
||||
shouldAnimate = hasAnyToolInProgress(msg, inProgressToolUseIDs)
|
||||
shouldAnimate = hasAnyToolInProgress(msg, inProgressToolUseIDs);
|
||||
} else {
|
||||
const toolUseID = getToolUseID(msg)
|
||||
shouldAnimate = !toolUseID || inProgressToolUseIDs.has(toolUseID)
|
||||
const toolUseID = getToolUseID(msg);
|
||||
shouldAnimate = !toolUseID || inProgressToolUseIDs.has(toolUseID);
|
||||
}
|
||||
}
|
||||
|
||||
const hasMetadata =
|
||||
isTranscriptMode &&
|
||||
displayMsg.type === 'assistant' &&
|
||||
(Array.isArray(displayMsg.message.content) && (displayMsg.message.content as Array<{ type: string }>).some(c => c.type === 'text')) &&
|
||||
(displayMsg.timestamp || displayMsg.message.model)
|
||||
Array.isArray(displayMsg.message.content) &&
|
||||
(displayMsg.message.content as Array<{ type: string }>).some(c => c.type === 'text') &&
|
||||
(displayMsg.timestamp || displayMsg.message.model);
|
||||
|
||||
const messageEl = (
|
||||
<Message
|
||||
@@ -225,7 +213,7 @@ function MessageRowImpl({
|
||||
latestBashOutputUUID={latestBashOutputUUID}
|
||||
shouldCollapseDiffs={shouldCollapseDiffs}
|
||||
/>
|
||||
)
|
||||
);
|
||||
// OffscreenFreeze: the outer React.memo already bails for static messages,
|
||||
// so this only wraps rows that DO re-render — in-progress tools, collapsed
|
||||
// read/search spinners, bash elapsed timers. When those rows have scrolled
|
||||
@@ -233,81 +221,64 @@ function MessageRowImpl({
|
||||
// change forces log-update.ts into a full terminal reset per tick. Freezing
|
||||
// returns the cached element ref so React bails and produces zero diff.
|
||||
if (!hasMetadata) {
|
||||
return <OffscreenFreeze>{messageEl}</OffscreenFreeze>
|
||||
return <OffscreenFreeze>{messageEl}</OffscreenFreeze>;
|
||||
}
|
||||
// Margin on children, not here — else null items (hook_success etc.) get phantom 1-row spacing.
|
||||
return (
|
||||
<OffscreenFreeze>
|
||||
<Box width={columns} flexDirection="column">
|
||||
<Box
|
||||
flexDirection="row"
|
||||
justifyContent="flex-end"
|
||||
gap={1}
|
||||
marginTop={1}
|
||||
>
|
||||
<MessageTimestamp
|
||||
message={displayMsg}
|
||||
isTranscriptMode={isTranscriptMode}
|
||||
/>
|
||||
<MessageModel
|
||||
message={displayMsg}
|
||||
isTranscriptMode={isTranscriptMode}
|
||||
/>
|
||||
<Box flexDirection="row" justifyContent="flex-end" gap={1} marginTop={1}>
|
||||
<MessageTimestamp message={displayMsg} isTranscriptMode={isTranscriptMode} />
|
||||
<MessageModel message={displayMsg} isTranscriptMode={isTranscriptMode} />
|
||||
</Box>
|
||||
{messageEl}
|
||||
</Box>
|
||||
</OffscreenFreeze>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a message is "streaming" - i.e., its content may still be changing.
|
||||
* Exported for testing.
|
||||
*/
|
||||
export function isMessageStreaming(
|
||||
msg: RenderableMessage,
|
||||
streamingToolUseIDs: Set<string>,
|
||||
): boolean {
|
||||
export function isMessageStreaming(msg: RenderableMessage, streamingToolUseIDs: Set<string>): boolean {
|
||||
if (msg.type === 'grouped_tool_use') {
|
||||
return msg.messages.some(m => {
|
||||
const content = firstBlock(m.message.content)
|
||||
return content?.type === 'tool_use' && streamingToolUseIDs.has(content.id!)
|
||||
})
|
||||
const content = firstBlock(m.message.content);
|
||||
return content?.type === 'tool_use' && streamingToolUseIDs.has(content.id!);
|
||||
});
|
||||
}
|
||||
if (msg.type === 'collapsed_read_search') {
|
||||
const toolIds = getToolUseIdsFromCollapsedGroup(msg)
|
||||
return toolIds.some(id => streamingToolUseIDs.has(id))
|
||||
const toolIds = getToolUseIdsFromCollapsedGroup(msg);
|
||||
return toolIds.some(id => streamingToolUseIDs.has(id));
|
||||
}
|
||||
const toolUseID = getToolUseID(msg)
|
||||
return !!toolUseID && streamingToolUseIDs.has(toolUseID)
|
||||
const toolUseID = getToolUseID(msg);
|
||||
return !!toolUseID && streamingToolUseIDs.has(toolUseID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if all tools in a message are resolved.
|
||||
* Exported for testing.
|
||||
*/
|
||||
export function allToolsResolved(
|
||||
msg: RenderableMessage,
|
||||
resolvedToolUseIDs: Set<string>,
|
||||
): boolean {
|
||||
export function allToolsResolved(msg: RenderableMessage, resolvedToolUseIDs: Set<string>): boolean {
|
||||
if (msg.type === 'grouped_tool_use') {
|
||||
return msg.messages.every(m => {
|
||||
const content = firstBlock(m.message.content)
|
||||
return content?.type === 'tool_use' && resolvedToolUseIDs.has(content.id!)
|
||||
})
|
||||
const content = firstBlock(m.message.content);
|
||||
return content?.type === 'tool_use' && resolvedToolUseIDs.has(content.id!);
|
||||
});
|
||||
}
|
||||
if (msg.type === 'collapsed_read_search') {
|
||||
const toolIds = getToolUseIdsFromCollapsedGroup(msg)
|
||||
return toolIds.every(id => resolvedToolUseIDs.has(id))
|
||||
const toolIds = getToolUseIdsFromCollapsedGroup(msg);
|
||||
return toolIds.every(id => resolvedToolUseIDs.has(id));
|
||||
}
|
||||
if (msg.type === 'assistant') {
|
||||
const block = firstBlock(msg.message.content)
|
||||
const block = firstBlock(msg.message.content);
|
||||
if (block?.type === 'server_tool_use') {
|
||||
return resolvedToolUseIDs.has(block.id!)
|
||||
return resolvedToolUseIDs.has(block.id!);
|
||||
}
|
||||
}
|
||||
const toolUseID = getToolUseID(msg)
|
||||
return !toolUseID || resolvedToolUseIDs.has(toolUseID)
|
||||
const toolUseID = getToolUseID(msg);
|
||||
return !toolUseID || resolvedToolUseIDs.has(toolUseID);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -318,29 +289,26 @@ export function allToolsResolved(
|
||||
*/
|
||||
export function areMessageRowPropsEqual(prev: Props, next: Props): boolean {
|
||||
// Different message reference = content may have changed, must re-render
|
||||
if (prev.message !== next.message) return false
|
||||
if (prev.message !== next.message) return false;
|
||||
|
||||
// Screen mode change = re-render
|
||||
if (prev.screen !== next.screen) return false
|
||||
if (prev.screen !== next.screen) return false;
|
||||
|
||||
// Verbose toggle changes thinking block visibility
|
||||
if (prev.verbose !== next.verbose) return false
|
||||
if (prev.verbose !== next.verbose) return false;
|
||||
|
||||
// collapsed_read_search is never static in prompt mode (matches shouldRenderStatically)
|
||||
if (
|
||||
prev.message.type === 'collapsed_read_search' &&
|
||||
next.screen !== 'transcript'
|
||||
) {
|
||||
return false
|
||||
if (prev.message.type === 'collapsed_read_search' && next.screen !== 'transcript') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Width change affects Box layout
|
||||
if (prev.columns !== next.columns) return false
|
||||
if (prev.columns !== next.columns) return false;
|
||||
|
||||
// latestBashOutputUUID affects rendering (full vs truncated output)
|
||||
const prevIsLatestBash = prev.latestBashOutputUUID === prev.message.uuid
|
||||
const nextIsLatestBash = next.latestBashOutputUUID === next.message.uuid
|
||||
if (prevIsLatestBash !== nextIsLatestBash) return false
|
||||
const prevIsLatestBash = prev.latestBashOutputUUID === prev.message.uuid;
|
||||
const nextIsLatestBash = next.latestBashOutputUUID === next.message.uuid;
|
||||
if (prevIsLatestBash !== nextIsLatestBash) return false;
|
||||
|
||||
// lastThinkingBlockId affects thinking block visibility — but only for
|
||||
// messages that HAVE thinking content. Checking unconditionally busts the
|
||||
@@ -349,21 +317,18 @@ export function areMessageRowPropsEqual(prev: Props, next: Props): boolean {
|
||||
prev.lastThinkingBlockId !== next.lastThinkingBlockId &&
|
||||
hasThinkingContent(next.message as Parameters<typeof hasThinkingContent>[0])
|
||||
) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this message is still "in flight"
|
||||
const isStreaming = isMessageStreaming(prev.message, prev.streamingToolUseIDs)
|
||||
const isResolved = allToolsResolved(
|
||||
prev.message,
|
||||
prev.lookups.resolvedToolUseIDs,
|
||||
)
|
||||
const isStreaming = isMessageStreaming(prev.message, prev.streamingToolUseIDs);
|
||||
const isResolved = allToolsResolved(prev.message, prev.lookups.resolvedToolUseIDs);
|
||||
|
||||
// Only bail out for truly static messages
|
||||
if (isStreaming || !isResolved) return false
|
||||
if (isStreaming || !isResolved) return false;
|
||||
|
||||
// Static message - safe to skip re-render
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
export const MessageRow = React.memo(MessageRowImpl, areMessageRowPropsEqual)
|
||||
export const MessageRow = React.memo(MessageRowImpl, areMessageRowPropsEqual);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user