mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
style: 完成所有文件的lint
This commit is contained in:
@@ -1,42 +1,33 @@
|
||||
import chalk from 'chalk'
|
||||
import figures from 'figures'
|
||||
import React, { useEffect } from 'react'
|
||||
import {
|
||||
getAdditionalDirectoriesForClaudeMd,
|
||||
setAdditionalDirectoriesForClaudeMd,
|
||||
} from '../../bootstrap/state.js'
|
||||
import type { LocalJSXCommandContext } from '../../commands.js'
|
||||
import { MessageResponse } from '../../components/MessageResponse.js'
|
||||
import { AddWorkspaceDirectory } from '../../components/permissions/rules/AddWorkspaceDirectory.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import {
|
||||
applyPermissionUpdate,
|
||||
persistPermissionUpdate,
|
||||
} from '../../utils/permissions/PermissionUpdate.js'
|
||||
import type { PermissionUpdateDestination } from '../../utils/permissions/PermissionUpdateSchema.js'
|
||||
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
|
||||
import {
|
||||
addDirHelpMessage,
|
||||
validateDirectoryForWorkspace,
|
||||
} from './validation.js'
|
||||
import chalk from 'chalk';
|
||||
import figures from 'figures';
|
||||
import React, { useEffect } from 'react';
|
||||
import { getAdditionalDirectoriesForClaudeMd, setAdditionalDirectoriesForClaudeMd } from '../../bootstrap/state.js';
|
||||
import type { LocalJSXCommandContext } from '../../commands.js';
|
||||
import { MessageResponse } from '../../components/MessageResponse.js';
|
||||
import { AddWorkspaceDirectory } from '../../components/permissions/rules/AddWorkspaceDirectory.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { applyPermissionUpdate, persistPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js';
|
||||
import type { PermissionUpdateDestination } from '../../utils/permissions/PermissionUpdateSchema.js';
|
||||
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js';
|
||||
import { addDirHelpMessage, validateDirectoryForWorkspace } from './validation.js';
|
||||
|
||||
function AddDirError({
|
||||
message,
|
||||
args,
|
||||
onDone,
|
||||
}: {
|
||||
message: string
|
||||
args: string
|
||||
onDone: () => void
|
||||
message: string;
|
||||
args: string;
|
||||
onDone: () => void;
|
||||
}): React.ReactNode {
|
||||
useEffect(() => {
|
||||
// We need to defer calling onDone to avoid the "return null" bug where
|
||||
// the component unmounts before React can render the error message.
|
||||
// Using setTimeout ensures the error displays before the command exits.
|
||||
const timer = setTimeout(onDone, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [onDone])
|
||||
const timer = setTimeout(onDone, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onDone]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -47,7 +38,7 @@ function AddDirError({
|
||||
<Text>{message}</Text>
|
||||
</MessageResponse>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function call(
|
||||
@@ -55,58 +46,53 @@ export async function call(
|
||||
context: LocalJSXCommandContext,
|
||||
args?: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const directoryPath = (args ?? '').trim()
|
||||
const appState = context.getAppState()
|
||||
const directoryPath = (args ?? '').trim();
|
||||
const appState = context.getAppState();
|
||||
|
||||
// Helper to handle adding a directory (shared by both with-path and no-path cases)
|
||||
const handleAddDirectory = async (path: string, remember = false) => {
|
||||
const destination: PermissionUpdateDestination = remember
|
||||
? 'localSettings'
|
||||
: 'session'
|
||||
const destination: PermissionUpdateDestination = remember ? 'localSettings' : 'session';
|
||||
|
||||
const permissionUpdate = {
|
||||
type: 'addDirectories' as const,
|
||||
directories: [path],
|
||||
destination,
|
||||
}
|
||||
};
|
||||
|
||||
// Apply to session context
|
||||
const latestAppState = context.getAppState()
|
||||
const updatedContext = applyPermissionUpdate(
|
||||
latestAppState.toolPermissionContext,
|
||||
permissionUpdate,
|
||||
)
|
||||
const latestAppState = context.getAppState();
|
||||
const updatedContext = applyPermissionUpdate(latestAppState.toolPermissionContext, permissionUpdate);
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
toolPermissionContext: updatedContext,
|
||||
}))
|
||||
}));
|
||||
|
||||
// Update sandbox config so Bash commands can access the new directory.
|
||||
// Bootstrap state is the source of truth for session-only dirs; persisted
|
||||
// dirs are picked up via the settings subscription, but we refresh
|
||||
// eagerly here to avoid a race when the user acts immediately.
|
||||
const currentDirs = getAdditionalDirectoriesForClaudeMd()
|
||||
const currentDirs = getAdditionalDirectoriesForClaudeMd();
|
||||
if (!currentDirs.includes(path)) {
|
||||
setAdditionalDirectoriesForClaudeMd([...currentDirs, path])
|
||||
setAdditionalDirectoriesForClaudeMd([...currentDirs, path]);
|
||||
}
|
||||
SandboxManager.refreshConfig()
|
||||
SandboxManager.refreshConfig();
|
||||
|
||||
let message: string
|
||||
let message: string;
|
||||
|
||||
if (remember) {
|
||||
try {
|
||||
persistPermissionUpdate(permissionUpdate)
|
||||
message = `Added ${chalk.bold(path)} as a working directory and saved to local settings`
|
||||
persistPermissionUpdate(permissionUpdate);
|
||||
message = `Added ${chalk.bold(path)} as a working directory and saved to local settings`;
|
||||
} catch (error) {
|
||||
message = `Added ${chalk.bold(path)} as a working directory. Failed to save to local settings: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
message = `Added ${chalk.bold(path)} as a working directory. Failed to save to local settings: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
}
|
||||
} else {
|
||||
message = `Added ${chalk.bold(path)} as a working directory for this session`
|
||||
message = `Added ${chalk.bold(path)} as a working directory for this session`;
|
||||
}
|
||||
|
||||
const messageWithHint = `${message} ${chalk.dim('· /permissions to manage')}`
|
||||
onDone(messageWithHint)
|
||||
}
|
||||
const messageWithHint = `${message} ${chalk.dim('· /permissions to manage')}`;
|
||||
onDone(messageWithHint);
|
||||
};
|
||||
|
||||
// When no path is provided, show AddWorkspaceDirectory input form directly
|
||||
// and return to REPL after confirmation
|
||||
@@ -116,27 +102,18 @@ export async function call(
|
||||
permissionContext={appState.toolPermissionContext}
|
||||
onAddDirectory={handleAddDirectory}
|
||||
onCancel={() => {
|
||||
onDone('Did not add a working directory.')
|
||||
onDone('Did not add a working directory.');
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const result = await validateDirectoryForWorkspace(
|
||||
directoryPath,
|
||||
appState.toolPermissionContext,
|
||||
)
|
||||
const result = await validateDirectoryForWorkspace(directoryPath, appState.toolPermissionContext);
|
||||
|
||||
if (result.resultType !== 'success') {
|
||||
const message = addDirHelpMessage(result)
|
||||
const message = addDirHelpMessage(result);
|
||||
|
||||
return (
|
||||
<AddDirError
|
||||
message={message}
|
||||
args={args ?? ''}
|
||||
onDone={() => onDone(message)}
|
||||
/>
|
||||
)
|
||||
return <AddDirError message={message} args={args ?? ''} onDone={() => onDone(message)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -145,10 +122,8 @@ export async function call(
|
||||
permissionContext={appState.toolPermissionContext}
|
||||
onAddDirectory={handleAddDirectory}
|
||||
onCancel={() => {
|
||||
onDone(
|
||||
`Did not add ${chalk.bold(result.absolutePath)} as a working directory.`,
|
||||
)
|
||||
onDone(`Did not add ${chalk.bold(result.absolutePath)} as a working directory.`);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
export default { name: 'agents-platform', type: 'local', isEnabled: () => false }
|
||||
export default {
|
||||
name: 'agents-platform',
|
||||
type: 'local',
|
||||
isEnabled: () => false,
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import * as React from 'react'
|
||||
import { AgentsMenu } from '../../components/agents/AgentsMenu.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import { getTools } from '../../tools.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import * as React from 'react';
|
||||
import { AgentsMenu } from '../../components/agents/AgentsMenu.js';
|
||||
import type { ToolUseContext } from '../../Tool.js';
|
||||
import { getTools } from '../../tools.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: ToolUseContext,
|
||||
): Promise<React.ReactNode> {
|
||||
const appState = context.getAppState()
|
||||
const permissionContext = appState.toolPermissionContext
|
||||
const tools = getTools(permissionContext)
|
||||
export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext): Promise<React.ReactNode> {
|
||||
const appState = context.getAppState();
|
||||
const permissionContext = appState.toolPermissionContext;
|
||||
const tools = getTools(permissionContext);
|
||||
|
||||
return <AgentsMenu tools={tools} onExit={onDone} />
|
||||
return <AgentsMenu tools={tools} onExit={onDone} />;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -44,8 +44,10 @@ export function deriveFirstPrompt(
|
||||
typeof content === 'string'
|
||||
? content
|
||||
: content.find(
|
||||
(block: { type: string; text?: string }): block is { type: 'text'; text: string } =>
|
||||
block.type === 'text',
|
||||
(block: {
|
||||
type: string
|
||||
text?: string
|
||||
}): block is { type: 'text'; text: string } => block.type === 'text',
|
||||
)?.text
|
||||
if (!raw) return 'Branched conversation'
|
||||
return (
|
||||
@@ -240,7 +242,9 @@ export async function call(
|
||||
// Build LogOption for resume
|
||||
const now = new Date()
|
||||
const firstPrompt = deriveFirstPrompt(
|
||||
serializedMessages.find(m => m.type === 'user') as Extract<SerializedMessage, { type: 'user' }> | undefined,
|
||||
serializedMessages.find(m => m.type === 'user') as
|
||||
| Extract<SerializedMessage, { type: 'user' }>
|
||||
| undefined,
|
||||
)
|
||||
|
||||
// Save custom title - use provided title or firstPrompt as default
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,39 +1,29 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { toString as qrToString } from 'qrcode'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js'
|
||||
import {
|
||||
checkBridgeMinVersion,
|
||||
getBridgeDisabledReason,
|
||||
isEnvLessBridgeEnabled,
|
||||
} from '../../bridge/bridgeEnabled.js'
|
||||
import { checkEnvLessBridgeMinVersion } from '../../bridge/envLessBridgeConfig.js'
|
||||
import {
|
||||
BRIDGE_LOGIN_INSTRUCTION,
|
||||
REMOTE_CONTROL_DISCONNECTED_MSG,
|
||||
} from '../../bridge/types.js'
|
||||
import { Dialog, ListItem } from '@anthropic/ink'
|
||||
import { shouldShowRemoteCallout } from '../../components/RemoteCallout.js'
|
||||
import { useRegisterOverlay } from '../../context/overlayContext.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import { toString as qrToString } from 'qrcode';
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js';
|
||||
import { checkBridgeMinVersion, getBridgeDisabledReason, isEnvLessBridgeEnabled } from '../../bridge/bridgeEnabled.js';
|
||||
import { checkEnvLessBridgeMinVersion } from '../../bridge/envLessBridgeConfig.js';
|
||||
import { BRIDGE_LOGIN_INSTRUCTION, REMOTE_CONTROL_DISCONNECTED_MSG } from '../../bridge/types.js';
|
||||
import { Dialog, ListItem } from '@anthropic/ink';
|
||||
import { shouldShowRemoteCallout } from '../../components/RemoteCallout.js';
|
||||
import { useRegisterOverlay } from '../../context/overlayContext.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import type {
|
||||
LocalJSXCommandContext,
|
||||
LocalJSXCommandOnDone,
|
||||
} from '../../types/command.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
} from '../../services/analytics/index.js';
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js';
|
||||
import type { ToolUseContext } from '../../Tool.js';
|
||||
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { logForDebugging } from '../../utils/debug.js';
|
||||
|
||||
type Props = {
|
||||
onDone: LocalJSXCommandOnDone
|
||||
name?: string
|
||||
}
|
||||
onDone: LocalJSXCommandOnDone;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* /remote-control command — manages the bidirectional bridge connection.
|
||||
@@ -48,34 +38,33 @@ type Props = {
|
||||
* URL and options to disconnect or continue.
|
||||
*/
|
||||
function BridgeToggle({ onDone, name }: Props): React.ReactNode {
|
||||
const setAppState = useSetAppState()
|
||||
const replBridgeConnected = useAppState(s => s.replBridgeConnected)
|
||||
const replBridgeEnabled = useAppState(s => s.replBridgeEnabled)
|
||||
const replBridgeOutboundOnly = useAppState(s => s.replBridgeOutboundOnly)
|
||||
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false)
|
||||
const setAppState = useSetAppState();
|
||||
const replBridgeConnected = useAppState(s => s.replBridgeConnected);
|
||||
const replBridgeEnabled = useAppState(s => s.replBridgeEnabled);
|
||||
const replBridgeOutboundOnly = useAppState(s => s.replBridgeOutboundOnly);
|
||||
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// If already connected or enabled in full bidirectional mode, show
|
||||
// disconnect confirmation. Outbound-only (CCR mirror) doesn't count —
|
||||
// /remote-control upgrades it to full RC instead.
|
||||
if ((replBridgeConnected || replBridgeEnabled) && !replBridgeOutboundOnly) {
|
||||
setShowDisconnectDialog(true)
|
||||
return
|
||||
setShowDisconnectDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
// Pre-flight checks before enabling (awaits GrowthBook init if disk
|
||||
// cache is stale — so Max users don't get a false "not enabled" error)
|
||||
const error = await checkBridgePrerequisites()
|
||||
if (cancelled) return
|
||||
const error = await checkBridgePrerequisites();
|
||||
if (cancelled) return;
|
||||
if (error) {
|
||||
logEvent('tengu_bridge_command', {
|
||||
action:
|
||||
'preflight_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
onDone(error, { display: 'system' })
|
||||
return
|
||||
action: 'preflight_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(error, { display: 'system' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Show first-time remote dialog if not yet seen.
|
||||
@@ -83,48 +72,47 @@ function BridgeToggle({ onDone, name }: Props): React.ReactNode {
|
||||
// enables the bridge (the handler only sets replBridgeEnabled, not the name).
|
||||
if (shouldShowRemoteCallout()) {
|
||||
setAppState(prev => {
|
||||
if (prev.showRemoteCallout) return prev
|
||||
if (prev.showRemoteCallout) return prev;
|
||||
return {
|
||||
...prev,
|
||||
showRemoteCallout: true,
|
||||
replBridgeInitialName: name,
|
||||
}
|
||||
})
|
||||
onDone('', { display: 'system' })
|
||||
return
|
||||
};
|
||||
});
|
||||
onDone('', { display: 'system' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Enable the bridge — useReplBridge in REPL.tsx handles the rest:
|
||||
// registers environment, creates session with conversation, connects WebSocket
|
||||
logEvent('tengu_bridge_command', {
|
||||
action:
|
||||
'connect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
action: 'connect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
setAppState(prev => {
|
||||
if (prev.replBridgeEnabled && !prev.replBridgeOutboundOnly) return prev
|
||||
if (prev.replBridgeEnabled && !prev.replBridgeOutboundOnly) return prev;
|
||||
return {
|
||||
...prev,
|
||||
replBridgeEnabled: true,
|
||||
replBridgeExplicit: true,
|
||||
replBridgeOutboundOnly: false,
|
||||
replBridgeInitialName: name,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
onDone('Remote Control connecting\u2026', {
|
||||
display: 'system',
|
||||
})
|
||||
})()
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount
|
||||
cancelled = true;
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount
|
||||
|
||||
if (showDisconnectDialog) {
|
||||
return <BridgeDisconnectDialog onDone={onDone} />
|
||||
return <BridgeDisconnectDialog onDone={onDone} />;
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,22 +120,22 @@ function BridgeToggle({ onDone, name }: Props): React.ReactNode {
|
||||
* Shows the session URL and lets the user disconnect or continue.
|
||||
*/
|
||||
function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode {
|
||||
useRegisterOverlay('bridge-disconnect-dialog')
|
||||
const setAppState = useSetAppState()
|
||||
const sessionUrl = useAppState(s => s.replBridgeSessionUrl)
|
||||
const connectUrl = useAppState(s => s.replBridgeConnectUrl)
|
||||
const sessionActive = useAppState(s => s.replBridgeSessionActive)
|
||||
const [focusIndex, setFocusIndex] = useState(2)
|
||||
const [showQR, setShowQR] = useState(false)
|
||||
const [qrText, setQrText] = useState('')
|
||||
useRegisterOverlay('bridge-disconnect-dialog');
|
||||
const setAppState = useSetAppState();
|
||||
const sessionUrl = useAppState(s => s.replBridgeSessionUrl);
|
||||
const connectUrl = useAppState(s => s.replBridgeConnectUrl);
|
||||
const sessionActive = useAppState(s => s.replBridgeSessionActive);
|
||||
const [focusIndex, setFocusIndex] = useState(2);
|
||||
const [showQR, setShowQR] = useState(false);
|
||||
const [qrText, setQrText] = useState('');
|
||||
|
||||
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: 'utf8',
|
||||
@@ -155,55 +143,53 @@ function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode {
|
||||
small: true,
|
||||
} as Parameters<typeof qrToString>[1])
|
||||
.then(setQrText)
|
||||
.catch(() => setQrText(''))
|
||||
}, [showQR, displayUrl])
|
||||
.catch(() => setQrText(''));
|
||||
}, [showQR, displayUrl]);
|
||||
|
||||
function handleDisconnect(): void {
|
||||
setAppState(prev => {
|
||||
if (!prev.replBridgeEnabled) return prev
|
||||
if (!prev.replBridgeEnabled) return prev;
|
||||
return {
|
||||
...prev,
|
||||
replBridgeEnabled: false,
|
||||
replBridgeExplicit: false,
|
||||
replBridgeOutboundOnly: false,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
logEvent('tengu_bridge_command', {
|
||||
action:
|
||||
'disconnect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
onDone(REMOTE_CONTROL_DISCONNECTED_MSG, { display: 'system' })
|
||||
action: 'disconnect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(REMOTE_CONTROL_DISCONNECTED_MSG, { display: 'system' });
|
||||
}
|
||||
|
||||
function handleShowQR(): void {
|
||||
setShowQR(prev => !prev)
|
||||
setShowQR(prev => !prev);
|
||||
}
|
||||
|
||||
function handleContinue(): void {
|
||||
onDone(undefined, { display: 'skip' })
|
||||
onDone(undefined, { display: 'skip' });
|
||||
}
|
||||
|
||||
const ITEM_COUNT = 3
|
||||
const ITEM_COUNT = 3;
|
||||
|
||||
useKeybindings(
|
||||
{
|
||||
'select:next': () => setFocusIndex(i => (i + 1) % ITEM_COUNT),
|
||||
'select:previous': () =>
|
||||
setFocusIndex(i => (i - 1 + ITEM_COUNT) % ITEM_COUNT),
|
||||
'select:previous': () => setFocusIndex(i => (i - 1 + ITEM_COUNT) % ITEM_COUNT),
|
||||
'select:accept': () => {
|
||||
if (focusIndex === 0) {
|
||||
handleDisconnect()
|
||||
handleDisconnect();
|
||||
} else if (focusIndex === 1) {
|
||||
handleShowQR()
|
||||
handleShowQR();
|
||||
} else {
|
||||
handleContinue()
|
||||
handleContinue();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ context: 'Select' },
|
||||
)
|
||||
);
|
||||
|
||||
const qrLines = qrText ? qrText.split('\n').filter(l => l.length > 0) : []
|
||||
const qrLines = qrText ? qrText.split('\n').filter(l => l.length > 0) : [];
|
||||
|
||||
return (
|
||||
<Dialog title="Remote Control" onCancel={handleContinue} hideInputGuide>
|
||||
@@ -233,7 +219,7 @@ function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode {
|
||||
<Text dimColor>Enter to select · Esc to continue</Text>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,43 +230,39 @@ function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode {
|
||||
*/
|
||||
async function checkBridgePrerequisites(): Promise<string | null> {
|
||||
// Check organization policy — remote control may be disabled
|
||||
const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import(
|
||||
'../../services/policyLimits/index.js'
|
||||
)
|
||||
await waitForPolicyLimitsToLoad()
|
||||
const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import('../../services/policyLimits/index.js');
|
||||
await waitForPolicyLimitsToLoad();
|
||||
if (!isPolicyAllowed('allow_remote_control')) {
|
||||
return "Remote Control is disabled by your organization's policy."
|
||||
return "Remote Control is disabled by your organization's policy.";
|
||||
}
|
||||
|
||||
const disabledReason = await getBridgeDisabledReason()
|
||||
const disabledReason = await getBridgeDisabledReason();
|
||||
if (disabledReason) {
|
||||
return disabledReason
|
||||
return disabledReason;
|
||||
}
|
||||
|
||||
// Mirror the v1/v2 branching logic in initReplBridge: env-less (v2) is used
|
||||
// only when the flag is on AND the session is not perpetual. In assistant
|
||||
// mode (KAIROS) useReplBridge sets perpetual=true, which forces
|
||||
// initReplBridge onto the v1 path — so the prerequisite check must match.
|
||||
let useV2 = isEnvLessBridgeEnabled()
|
||||
let useV2 = isEnvLessBridgeEnabled();
|
||||
if (feature('KAIROS') && useV2) {
|
||||
const { isAssistantMode } = await import('../../assistant/index.js')
|
||||
const { isAssistantMode } = await import('../../assistant/index.js');
|
||||
if (isAssistantMode()) {
|
||||
useV2 = false
|
||||
useV2 = false;
|
||||
}
|
||||
}
|
||||
const versionError = useV2
|
||||
? await checkEnvLessBridgeMinVersion()
|
||||
: checkBridgeMinVersion()
|
||||
const versionError = useV2 ? await checkEnvLessBridgeMinVersion() : checkBridgeMinVersion();
|
||||
if (versionError) {
|
||||
return versionError
|
||||
return versionError;
|
||||
}
|
||||
|
||||
if (!getBridgeAccessToken()) {
|
||||
return BRIDGE_LOGIN_INSTRUCTION
|
||||
return BRIDGE_LOGIN_INSTRUCTION;
|
||||
}
|
||||
|
||||
logForDebugging('[bridge] Prerequisites passed, enabling bridge')
|
||||
return null
|
||||
logForDebugging('[bridge] Prerequisites passed, enabling bridge');
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function call(
|
||||
@@ -288,6 +270,6 @@ export async function call(
|
||||
_context: ToolUseContext & LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const name = args.trim() || undefined
|
||||
return <BridgeToggle onDone={onDone} name={name} />
|
||||
const name = args.trim() || undefined;
|
||||
return <BridgeToggle onDone={onDone} name={name} />;
|
||||
}
|
||||
|
||||
@@ -1,118 +1,96 @@
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useInterval } from 'usehooks-ts'
|
||||
import type { CommandResultDisplay } from '../../commands.js'
|
||||
import { Markdown } from '../../components/Markdown.js'
|
||||
import { SpinnerGlyph } from '../../components/Spinner/SpinnerGlyph.js'
|
||||
import { DOWN_ARROW, UP_ARROW } from '../../constants/figures.js'
|
||||
import { getSystemPrompt } from '../../constants/prompts.js'
|
||||
import { useModalOrTerminalSize } from '../../context/modalContext.js'
|
||||
import { getSystemContext, getUserContext } from '../../context.js'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { type KeyboardEvent, type ScrollBoxHandle, ScrollBox } from '@anthropic/ink'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import { createAbortController } from '../../utils/abortController.js'
|
||||
import { saveGlobalConfig } from '../../utils/config.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import {
|
||||
type CacheSafeParams,
|
||||
getLastCacheSafeParams,
|
||||
} from '../../utils/forkedAgent.js'
|
||||
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
|
||||
import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'
|
||||
import { runSideQuestion } from '../../utils/sideQuestion.js'
|
||||
import { asSystemPrompt } from '../../utils/systemPromptType.js'
|
||||
import * as React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useInterval } from 'usehooks-ts';
|
||||
import type { CommandResultDisplay } from '../../commands.js';
|
||||
import { Markdown } from '../../components/Markdown.js';
|
||||
import { SpinnerGlyph } from '../../components/Spinner/SpinnerGlyph.js';
|
||||
import { DOWN_ARROW, UP_ARROW } from '../../constants/figures.js';
|
||||
import { getSystemPrompt } from '../../constants/prompts.js';
|
||||
import { useModalOrTerminalSize } from '../../context/modalContext.js';
|
||||
import { getSystemContext, getUserContext } from '../../context.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { type KeyboardEvent, type ScrollBoxHandle, ScrollBox } from '@anthropic/ink';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import type { Message } from '../../types/message.js';
|
||||
import { createAbortController } from '../../utils/abortController.js';
|
||||
import { saveGlobalConfig } from '../../utils/config.js';
|
||||
import { errorMessage } from '../../utils/errors.js';
|
||||
import { type CacheSafeParams, getLastCacheSafeParams } from '../../utils/forkedAgent.js';
|
||||
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js';
|
||||
import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js';
|
||||
import { runSideQuestion } from '../../utils/sideQuestion.js';
|
||||
import { asSystemPrompt } from '../../utils/systemPromptType.js';
|
||||
|
||||
type BtwComponentProps = {
|
||||
question: string
|
||||
context: ProcessUserInputContext
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
}
|
||||
question: string;
|
||||
context: ProcessUserInputContext;
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
};
|
||||
|
||||
const CHROME_ROWS = 5
|
||||
const OUTER_CHROME_ROWS = 6
|
||||
const SCROLL_LINES = 3
|
||||
const CHROME_ROWS = 5;
|
||||
const OUTER_CHROME_ROWS = 6;
|
||||
const SCROLL_LINES = 3;
|
||||
|
||||
function BtwSideQuestion({
|
||||
question,
|
||||
context,
|
||||
onDone,
|
||||
}: BtwComponentProps): React.ReactNode {
|
||||
const [response, setResponse] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [frame, setFrame] = useState(0)
|
||||
const scrollRef = useRef<ScrollBoxHandle>(null)
|
||||
const { rows } = useModalOrTerminalSize(useTerminalSize())
|
||||
function BtwSideQuestion({ question, context, onDone }: BtwComponentProps): React.ReactNode {
|
||||
const [response, setResponse] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [frame, setFrame] = useState(0);
|
||||
const scrollRef = useRef<ScrollBoxHandle>(null);
|
||||
const { rows } = useModalOrTerminalSize(useTerminalSize());
|
||||
|
||||
// Animate spinner while loading
|
||||
useInterval(() => setFrame(f => f + 1), response || error ? null : 80)
|
||||
useInterval(() => setFrame(f => f + 1), response || error ? null : 80);
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
if (
|
||||
e.key === 'escape' ||
|
||||
e.key === 'return' ||
|
||||
e.key === ' ' ||
|
||||
(e.ctrl && (e.key === 'c' || e.key === 'd'))
|
||||
) {
|
||||
e.preventDefault()
|
||||
onDone(undefined, { display: 'skip' })
|
||||
return
|
||||
if (e.key === 'escape' || e.key === 'return' || e.key === ' ' || (e.ctrl && (e.key === 'c' || e.key === 'd'))) {
|
||||
e.preventDefault();
|
||||
onDone(undefined, { display: 'skip' });
|
||||
return;
|
||||
}
|
||||
if (e.key === 'up' || (e.ctrl && e.key === 'p')) {
|
||||
e.preventDefault()
|
||||
scrollRef.current?.scrollBy(-SCROLL_LINES)
|
||||
e.preventDefault();
|
||||
scrollRef.current?.scrollBy(-SCROLL_LINES);
|
||||
}
|
||||
if (e.key === 'down' || (e.ctrl && e.key === 'n')) {
|
||||
e.preventDefault()
|
||||
scrollRef.current?.scrollBy(SCROLL_LINES)
|
||||
e.preventDefault();
|
||||
scrollRef.current?.scrollBy(SCROLL_LINES);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = createAbortController()
|
||||
const abortController = createAbortController();
|
||||
|
||||
async function fetchResponse(): Promise<void> {
|
||||
try {
|
||||
const cacheSafeParams = await buildCacheSafeParams(context)
|
||||
const result = await runSideQuestion({ question, cacheSafeParams })
|
||||
const cacheSafeParams = await buildCacheSafeParams(context);
|
||||
const result = await runSideQuestion({ question, cacheSafeParams });
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
if (result.response) {
|
||||
setResponse(result.response)
|
||||
setResponse(result.response);
|
||||
} else {
|
||||
setError('No response received')
|
||||
setError('No response received');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!abortController.signal.aborted) {
|
||||
setError(errorMessage(err) || 'Failed to get response')
|
||||
setError(errorMessage(err) || 'Failed to get response');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void fetchResponse()
|
||||
void fetchResponse();
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}, [question, context])
|
||||
abortController.abort();
|
||||
};
|
||||
}, [question, context]);
|
||||
|
||||
const maxContentHeight = Math.max(5, rows - CHROME_ROWS - OUTER_CHROME_ROWS)
|
||||
const maxContentHeight = Math.max(5, rows - CHROME_ROWS - OUTER_CHROME_ROWS);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Box flexDirection="column" paddingLeft={2} marginTop={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
|
||||
<Box>
|
||||
<Text color="warning" bold>
|
||||
/btw{' '}
|
||||
@@ -136,13 +114,12 @@ function BtwSideQuestion({
|
||||
{(response || error) && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
{UP_ARROW}/{DOWN_ARROW} to scroll · Space, Enter, or Escape to
|
||||
dismiss
|
||||
{UP_ARROW}/{DOWN_ARROW} to scroll · Space, Enter, or Escape to dismiss
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,20 +138,16 @@ function BtwSideQuestion({
|
||||
* --append-system-prompt, coordinator mode).
|
||||
*/
|
||||
function stripInProgressAssistantMessage(messages: Message[]): Message[] {
|
||||
const last = messages.at(-1)
|
||||
const last = messages.at(-1);
|
||||
if (last?.type === 'assistant' && last.message!.stop_reason === null) {
|
||||
return messages.slice(0, -1)
|
||||
return messages.slice(0, -1);
|
||||
}
|
||||
return messages
|
||||
return messages;
|
||||
}
|
||||
|
||||
async function buildCacheSafeParams(
|
||||
context: ProcessUserInputContext,
|
||||
): Promise<CacheSafeParams> {
|
||||
const forkContextMessages = getMessagesAfterCompactBoundary(
|
||||
stripInProgressAssistantMessage(context.messages),
|
||||
)
|
||||
const saved = getLastCacheSafeParams()
|
||||
async function buildCacheSafeParams(context: ProcessUserInputContext): Promise<CacheSafeParams> {
|
||||
const forkContextMessages = getMessagesAfterCompactBoundary(stripInProgressAssistantMessage(context.messages));
|
||||
const saved = getLastCacheSafeParams();
|
||||
if (saved) {
|
||||
return {
|
||||
systemPrompt: saved.systemPrompt,
|
||||
@@ -182,25 +155,20 @@ async function buildCacheSafeParams(
|
||||
systemContext: saved.systemContext,
|
||||
toolUseContext: context,
|
||||
forkContextMessages,
|
||||
}
|
||||
};
|
||||
}
|
||||
const [rawSystemPrompt, userContext, systemContext] = await Promise.all([
|
||||
getSystemPrompt(
|
||||
context.options.tools,
|
||||
context.options.mainLoopModel,
|
||||
[],
|
||||
context.options.mcpClients,
|
||||
),
|
||||
getSystemPrompt(context.options.tools, context.options.mainLoopModel, [], context.options.mcpClients),
|
||||
getUserContext(),
|
||||
getSystemContext(),
|
||||
])
|
||||
]);
|
||||
return {
|
||||
systemPrompt: asSystemPrompt(rawSystemPrompt),
|
||||
userContext,
|
||||
systemContext,
|
||||
toolUseContext: context,
|
||||
forkContextMessages,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function call(
|
||||
@@ -208,19 +176,17 @@ export async function call(
|
||||
context: ProcessUserInputContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const question = args?.trim()
|
||||
const question = args?.trim();
|
||||
|
||||
if (!question) {
|
||||
onDone('Usage: /btw <your question>', { display: 'system' })
|
||||
return null
|
||||
onDone('Usage: /btw <your question>', { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
btwUseCount: current.btwUseCount + 1,
|
||||
}))
|
||||
}));
|
||||
|
||||
return (
|
||||
<BtwSideQuestion question={question} context={context} onDone={onDone} />
|
||||
)
|
||||
return <BtwSideQuestion question={question} context={context} onDone={onDone} />;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,9 @@ export async function call(
|
||||
return React.createElement(CompanionCard, {
|
||||
companion,
|
||||
lastReaction,
|
||||
onDone: onDone as unknown as Parameters<typeof CompanionCard>[0]['onDone'],
|
||||
onDone: onDone as unknown as Parameters<
|
||||
typeof CompanionCard
|
||||
>[0]['onDone'],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,39 +1,29 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
type OptionWithDescription,
|
||||
Select,
|
||||
} from '../../components/CustomSelect/select.js'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useAppState } from '../../state/AppState.js'
|
||||
import { isClaudeAISubscriber } from '../../utils/auth.js'
|
||||
import { openBrowser } from '../../utils/browser.js'
|
||||
import {
|
||||
CLAUDE_IN_CHROME_MCP_SERVER_NAME,
|
||||
openInChrome,
|
||||
} from '../../utils/claudeInChrome/common.js'
|
||||
import { isChromeExtensionInstalled } from '../../utils/claudeInChrome/setup.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { env } from '../../utils/env.js'
|
||||
import { isRunningOnHomespace } from '../../utils/envUtils.js'
|
||||
import React, { useState } from 'react';
|
||||
import { type OptionWithDescription, Select } from '../../components/CustomSelect/select.js';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useAppState } from '../../state/AppState.js';
|
||||
import { isClaudeAISubscriber } from '../../utils/auth.js';
|
||||
import { openBrowser } from '../../utils/browser.js';
|
||||
import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, openInChrome } from '../../utils/claudeInChrome/common.js';
|
||||
import { isChromeExtensionInstalled } from '../../utils/claudeInChrome/setup.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
import { env } from '../../utils/env.js';
|
||||
import { isRunningOnHomespace } from '../../utils/envUtils.js';
|
||||
|
||||
const CHROME_EXTENSION_URL = 'https://claude.ai/chrome'
|
||||
const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions'
|
||||
const CHROME_RECONNECT_URL = 'https://clau.de/chrome/reconnect'
|
||||
const CHROME_EXTENSION_URL = 'https://claude.ai/chrome';
|
||||
const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions';
|
||||
const CHROME_RECONNECT_URL = 'https://clau.de/chrome/reconnect';
|
||||
|
||||
type MenuAction =
|
||||
| 'install-extension'
|
||||
| 'reconnect'
|
||||
| 'manage-permissions'
|
||||
| 'toggle-default'
|
||||
type MenuAction = 'install-extension' | 'reconnect' | 'manage-permissions' | 'toggle-default';
|
||||
|
||||
type Props = {
|
||||
onDone: (result?: string) => void
|
||||
isExtensionInstalled: boolean
|
||||
configEnabled: boolean | undefined
|
||||
isClaudeAISubscriber: boolean
|
||||
isWSL: boolean
|
||||
}
|
||||
onDone: (result?: string) => void;
|
||||
isExtensionInstalled: boolean;
|
||||
configEnabled: boolean | undefined;
|
||||
isClaudeAISubscriber: boolean;
|
||||
isWSL: boolean;
|
||||
};
|
||||
|
||||
function ClaudeInChromeMenu({
|
||||
onDone,
|
||||
@@ -42,72 +32,66 @@ function ClaudeInChromeMenu({
|
||||
isClaudeAISubscriber,
|
||||
isWSL,
|
||||
}: Props): React.ReactNode {
|
||||
const mcpClients = useAppState(s => s.mcp.clients)
|
||||
const [selectKey, setSelectKey] = useState(0)
|
||||
const [enabledByDefault, setEnabledByDefault] = useState(
|
||||
configEnabled ?? false,
|
||||
)
|
||||
const [showInstallHint, setShowInstallHint] = useState(false)
|
||||
const [isExtensionInstalled, setIsExtensionInstalled] = useState(installed)
|
||||
const mcpClients = useAppState(s => s.mcp.clients);
|
||||
const [selectKey, setSelectKey] = useState(0);
|
||||
const [enabledByDefault, setEnabledByDefault] = useState(configEnabled ?? false);
|
||||
const [showInstallHint, setShowInstallHint] = useState(false);
|
||||
const [isExtensionInstalled, setIsExtensionInstalled] = useState(installed);
|
||||
|
||||
const isHomespace = process.env.USER_TYPE === 'ant' && isRunningOnHomespace()
|
||||
const isHomespace = process.env.USER_TYPE === 'ant' && isRunningOnHomespace();
|
||||
|
||||
const chromeClient = mcpClients.find(
|
||||
c => c.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME,
|
||||
)
|
||||
const isConnected = chromeClient?.type === 'connected'
|
||||
const chromeClient = mcpClients.find(c => c.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME);
|
||||
const isConnected = chromeClient?.type === 'connected';
|
||||
|
||||
function openUrl(url: string): void {
|
||||
if (isHomespace) {
|
||||
void openBrowser(url)
|
||||
void openBrowser(url);
|
||||
} else {
|
||||
void openInChrome(url)
|
||||
void openInChrome(url);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAction(action: MenuAction): void {
|
||||
switch (action) {
|
||||
case 'install-extension':
|
||||
setSelectKey(k => k + 1)
|
||||
setShowInstallHint(true)
|
||||
openUrl(CHROME_EXTENSION_URL)
|
||||
break
|
||||
setSelectKey(k => k + 1);
|
||||
setShowInstallHint(true);
|
||||
openUrl(CHROME_EXTENSION_URL);
|
||||
break;
|
||||
case 'reconnect':
|
||||
setSelectKey(k => k + 1)
|
||||
setSelectKey(k => k + 1);
|
||||
void isChromeExtensionInstalled().then(installed => {
|
||||
setIsExtensionInstalled(installed)
|
||||
setIsExtensionInstalled(installed);
|
||||
if (installed) {
|
||||
setShowInstallHint(false)
|
||||
setShowInstallHint(false);
|
||||
}
|
||||
})
|
||||
openUrl(CHROME_RECONNECT_URL)
|
||||
break
|
||||
});
|
||||
openUrl(CHROME_RECONNECT_URL);
|
||||
break;
|
||||
case 'manage-permissions':
|
||||
setSelectKey(k => k + 1)
|
||||
openUrl(CHROME_PERMISSIONS_URL)
|
||||
break
|
||||
setSelectKey(k => k + 1);
|
||||
openUrl(CHROME_PERMISSIONS_URL);
|
||||
break;
|
||||
case 'toggle-default': {
|
||||
const newValue = !enabledByDefault
|
||||
const newValue = !enabledByDefault;
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
claudeInChromeDefaultEnabled: newValue,
|
||||
}))
|
||||
setEnabledByDefault(newValue)
|
||||
break
|
||||
}));
|
||||
setEnabledByDefault(newValue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const options: OptionWithDescription<MenuAction>[] = []
|
||||
const requiresExtensionSuffix = isExtensionInstalled
|
||||
? ''
|
||||
: ' (requires extension)'
|
||||
const options: OptionWithDescription<MenuAction>[] = [];
|
||||
const requiresExtensionSuffix = isExtensionInstalled ? '' : ' (requires extension)';
|
||||
|
||||
if (!isExtensionInstalled && !isHomespace) {
|
||||
options.push({
|
||||
label: 'Install Chrome extension',
|
||||
value: 'install-extension',
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
options.push(
|
||||
@@ -133,36 +117,23 @@ function ClaudeInChromeMenu({
|
||||
label: `Enabled by default: ${enabledByDefault ? 'Yes' : 'No'}`,
|
||||
value: 'toggle-default',
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
const isDisabled =
|
||||
isWSL || ((process.env.USER_TYPE as string) !== 'ant' && !isClaudeAISubscriber)
|
||||
const isDisabled = isWSL || ((process.env.USER_TYPE as string) !== 'ant' && !isClaudeAISubscriber);
|
||||
|
||||
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. 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.
|
||||
Navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and network
|
||||
requests.
|
||||
</Text>
|
||||
|
||||
{isWSL && (
|
||||
<Text color="error">
|
||||
Claude in Chrome is not supported in WSL at this time.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isWSL && <Text color="error">Claude in Chrome is not supported in WSL at this time.</Text>}
|
||||
|
||||
{(process.env.USER_TYPE as string) !== 'ant' && !isClaudeAISubscriber && (
|
||||
<Text color="error">
|
||||
Claude in Chrome requires a claude.ai subscription.
|
||||
</Text>
|
||||
<Text color="error">Claude in Chrome requires a claude.ai subscription.</Text>
|
||||
)}
|
||||
|
||||
{!isDisabled && (
|
||||
@@ -170,12 +141,7 @@ function ClaudeInChromeMenu({
|
||||
{!isHomespace && (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
Status:{' '}
|
||||
{isConnected ? (
|
||||
<Text color="success">Enabled</Text>
|
||||
) : (
|
||||
<Text color="inactive">Disabled</Text>
|
||||
)}
|
||||
Status: {isConnected ? <Text color="success">Enabled</Text> : <Text color="inactive">Disabled</Text>}
|
||||
</Text>
|
||||
<Text>
|
||||
Extension:{' '}
|
||||
@@ -187,17 +153,10 @@ function ClaudeInChromeMenu({
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Select
|
||||
key={selectKey}
|
||||
options={options}
|
||||
onChange={handleAction}
|
||||
hideIndexes
|
||||
/>
|
||||
<Select key={selectKey} options={options} onChange={handleAction} hideIndexes />
|
||||
|
||||
{showInstallHint && (
|
||||
<Text color="warning">
|
||||
Once installed, select {'"Reconnect extension"'} to connect.
|
||||
</Text>
|
||||
<Text color="warning">Once installed, select {'"Reconnect extension"'} to connect.</Text>
|
||||
)}
|
||||
|
||||
<Text>
|
||||
@@ -208,25 +167,22 @@ function ClaudeInChromeMenu({
|
||||
</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.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Text dimColor>Learn more: https://code.claude.com/docs/en/chrome</Text>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const call = async function (
|
||||
onDone: (result?: string) => void,
|
||||
): Promise<React.ReactNode> {
|
||||
const isExtensionInstalled = await isChromeExtensionInstalled()
|
||||
const config = getGlobalConfig()
|
||||
const isSubscriber = isClaudeAISubscriber()
|
||||
const isWSL = env.isWslEnvironment()
|
||||
export const call = async function (onDone: (result?: string) => void): Promise<React.ReactNode> {
|
||||
const isExtensionInstalled = await isChromeExtensionInstalled();
|
||||
const config = getGlobalConfig();
|
||||
const isSubscriber = isClaudeAISubscriber();
|
||||
const isWSL = env.isWslEnvironment();
|
||||
|
||||
return (
|
||||
<ClaudeInChromeMenu
|
||||
@@ -236,5 +192,5 @@ export const call = async function (
|
||||
isClaudeAISubscriber={isSubscriber}
|
||||
isWSL={isWSL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -93,12 +93,12 @@ export function clearSessionCaches(
|
||||
|
||||
// Clear tungsten session usage tracking
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
void import('@claude-code-best/builtin-tools/tools/TungstenTool/TungstenTool.js').then(
|
||||
({ clearSessionsWithTungstenUsage, resetInitializationState }) => {
|
||||
clearSessionsWithTungstenUsage()
|
||||
resetInitializationState()
|
||||
},
|
||||
)
|
||||
void import(
|
||||
'@claude-code-best/builtin-tools/tools/TungstenTool/TungstenTool.js'
|
||||
).then(({ clearSessionsWithTungstenUsage, resetInitializationState }) => {
|
||||
clearSessionsWithTungstenUsage()
|
||||
resetInitializationState()
|
||||
})
|
||||
}
|
||||
// Clear attribution caches (file content cache, pending bash states)
|
||||
// Dynamic import to preserve dead code elimination for COMMIT_ATTRIBUTION feature flag
|
||||
@@ -126,19 +126,21 @@ export function clearSessionCaches(
|
||||
// Clear session environment variables
|
||||
clearSessionEnvVars()
|
||||
// Clear WebFetch URL cache (up to 50MB of cached page content)
|
||||
void import('@claude-code-best/builtin-tools/tools/WebFetchTool/utils.js').then(
|
||||
({ clearWebFetchCache }) => clearWebFetchCache(),
|
||||
)
|
||||
void import(
|
||||
'@claude-code-best/builtin-tools/tools/WebFetchTool/utils.js'
|
||||
).then(({ clearWebFetchCache }) => clearWebFetchCache())
|
||||
// Clear ToolSearch description cache (full tool prompts, ~500KB for 50 MCP tools)
|
||||
void import('@claude-code-best/builtin-tools/tools/ToolSearchTool/ToolSearchTool.js').then(
|
||||
({ clearToolSearchDescriptionCache }) => clearToolSearchDescriptionCache(),
|
||||
void import(
|
||||
'@claude-code-best/builtin-tools/tools/ToolSearchTool/ToolSearchTool.js'
|
||||
).then(({ clearToolSearchDescriptionCache }) =>
|
||||
clearToolSearchDescriptionCache(),
|
||||
)
|
||||
// Clear agent definitions cache (accumulates per-cwd via EnterWorktreeTool)
|
||||
void import('@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js').then(
|
||||
({ clearAgentDefinitionsCache }) => clearAgentDefinitionsCache(),
|
||||
)
|
||||
void import(
|
||||
'@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||
).then(({ clearAgentDefinitionsCache }) => clearAgentDefinitionsCache())
|
||||
// Clear SkillTool prompt cache (accumulates per project root)
|
||||
void import('@claude-code-best/builtin-tools/tools/SkillTool/prompt.js').then(({ clearPromptCache }) =>
|
||||
clearPromptCache(),
|
||||
void import('@claude-code-best/builtin-tools/tools/SkillTool/prompt.js').then(
|
||||
({ clearPromptCache }) => clearPromptCache(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ async function compactViaReactive(
|
||||
context.setStreamMode?.('requesting')
|
||||
context.setResponseLength?.(() => 0)
|
||||
context.onCompactProgress?.({ type: 'compact_end' })
|
||||
context.setSDKStatus?.("" as SDKStatus)
|
||||
context.setSDKStatus?.('' as SDKStatus)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import { Settings } from '../../components/Settings/Settings.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import * as React from 'react';
|
||||
import { Settings } from '../../components/Settings/Settings.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
return <Settings onClose={onDone} context={context} defaultTab="Config" />
|
||||
}
|
||||
return <Settings onClose={onDone} context={context} defaultTab="Config" />;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import * as React from 'react'
|
||||
import type { LocalJSXCommandContext } from '../../commands.js'
|
||||
import { ContextVisualization } from '../../components/ContextVisualization.js'
|
||||
import { microcompactMessages } from '../../services/compact/microCompact.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import { analyzeContextUsage } from '../../utils/analyzeContext.js'
|
||||
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
|
||||
import { renderToAnsiString } from '../../utils/staticRender.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import * as React from 'react';
|
||||
import type { LocalJSXCommandContext } from '../../commands.js';
|
||||
import { ContextVisualization } from '../../components/ContextVisualization.js';
|
||||
import { microcompactMessages } from '../../services/compact/microCompact.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import type { Message } from '../../types/message.js';
|
||||
import { analyzeContextUsage } from '../../utils/analyzeContext.js';
|
||||
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js';
|
||||
import { renderToAnsiString } from '../../utils/staticRender.js';
|
||||
|
||||
/**
|
||||
* Apply the same context transforms query.ts does before the API call, so
|
||||
@@ -16,36 +16,33 @@ import { renderToAnsiString } from '../../utils/staticRender.js'
|
||||
* was collapsed — user sees "180k, 3 spans collapsed" when the API sees 120k.
|
||||
*/
|
||||
function toApiView(messages: Message[]): Message[] {
|
||||
let view = getMessagesAfterCompactBoundary(messages)
|
||||
let view = getMessagesAfterCompactBoundary(messages);
|
||||
if (feature('CONTEXT_COLLAPSE')) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { projectView } =
|
||||
require('../../services/contextCollapse/operations.js') as typeof import('../../services/contextCollapse/operations.js')
|
||||
require('../../services/contextCollapse/operations.js') as typeof import('../../services/contextCollapse/operations.js');
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
view = projectView(view)
|
||||
view = projectView(view);
|
||||
}
|
||||
return view
|
||||
return view;
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: LocalJSXCommandContext,
|
||||
): Promise<React.ReactNode> {
|
||||
export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode> {
|
||||
const {
|
||||
messages,
|
||||
getAppState,
|
||||
options: { mainLoopModel, tools },
|
||||
} = context
|
||||
} = context;
|
||||
|
||||
const apiView = toApiView(messages)
|
||||
const apiView = toApiView(messages);
|
||||
|
||||
// Apply microcompact to get accurate representation of messages sent to API
|
||||
const { messages: compactedMessages } = await microcompactMessages(apiView)
|
||||
const { messages: compactedMessages } = await microcompactMessages(apiView);
|
||||
|
||||
// Get terminal width for responsive sizing
|
||||
const terminalWidth = process.stdout.columns || 80
|
||||
const terminalWidth = process.stdout.columns || 80;
|
||||
|
||||
const appState = getAppState()
|
||||
const appState = getAppState();
|
||||
|
||||
// Analyze context with compacted messages
|
||||
// Pass original messages as last parameter for accurate API usage extraction
|
||||
@@ -59,10 +56,10 @@ export async function call(
|
||||
context, // Pass full context for system prompt calculation
|
||||
undefined, // mainThreadAgentDefinition
|
||||
apiView, // Original messages for API usage extraction
|
||||
)
|
||||
);
|
||||
|
||||
// Render to ANSI string to preserve colors and pass to onDone like local commands do
|
||||
const output = await renderToAnsiString(<ContextVisualization data={data} />)
|
||||
onDone(output)
|
||||
return null
|
||||
const output = await renderToAnsiString(<ContextVisualization data={data} />);
|
||||
onDone(output);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { marked, type Tokens } from 'marked'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import React, { useRef } from 'react'
|
||||
import type { CommandResultDisplay } from '../../commands.js'
|
||||
import type { OptionWithDescription } from '../../components/CustomSelect/select.js'
|
||||
import { Select } from '../../components/CustomSelect/select.js'
|
||||
import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink'
|
||||
import { Box, setClipboard, Text, stringWidth, type KeyboardEvent } from '@anthropic/ink'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import type { AssistantMessage, Message } from '../../types/message.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { extractTextContent, stripPromptXMLTags } from '../../utils/messages.js'
|
||||
import { countCharInString } from '../../utils/stringUtils.js'
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { marked, type Tokens } from 'marked';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import React, { useRef } from 'react';
|
||||
import type { CommandResultDisplay } from '../../commands.js';
|
||||
import type { OptionWithDescription } from '../../components/CustomSelect/select.js';
|
||||
import { Select } from '../../components/CustomSelect/select.js';
|
||||
import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink';
|
||||
import { Box, setClipboard, Text, stringWidth, type KeyboardEvent } from '@anthropic/ink';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
import type { AssistantMessage, Message } from '../../types/message.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
import { extractTextContent, stripPromptXMLTags } from '../../utils/messages.js';
|
||||
import { countCharInString } from '../../utils/stringUtils.js';
|
||||
|
||||
const COPY_DIR = join(tmpdir(), 'claude')
|
||||
const RESPONSE_FILENAME = 'response.md'
|
||||
const MAX_LOOKBACK = 20
|
||||
const COPY_DIR = join(tmpdir(), 'claude');
|
||||
const RESPONSE_FILENAME = 'response.md';
|
||||
const MAX_LOOKBACK = 20;
|
||||
|
||||
type CodeBlock = {
|
||||
code: string
|
||||
lang: string | undefined
|
||||
}
|
||||
code: string;
|
||||
lang: string | undefined;
|
||||
};
|
||||
|
||||
function extractCodeBlocks(markdown: string): CodeBlock[] {
|
||||
const tokens = marked.lexer(stripPromptXMLTags(markdown))
|
||||
const blocks: CodeBlock[] = []
|
||||
const tokens = marked.lexer(stripPromptXMLTags(markdown));
|
||||
const blocks: CodeBlock[] = [];
|
||||
for (const token of tokens) {
|
||||
if (token.type === 'code') {
|
||||
const codeToken = token as Tokens.Code
|
||||
blocks.push({ code: codeToken.text, lang: codeToken.lang })
|
||||
const codeToken = token as Tokens.Code;
|
||||
blocks.push({ code: codeToken.text, lang: codeToken.lang });
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,95 +42,80 @@ function extractCodeBlocks(markdown: string): CodeBlock[] {
|
||||
* Index 0 = latest, 1 = second-to-latest, etc. Caps at MAX_LOOKBACK.
|
||||
*/
|
||||
export function collectRecentAssistantTexts(messages: Message[]): string[] {
|
||||
const texts: string[] = []
|
||||
for (
|
||||
let i = messages.length - 1;
|
||||
i >= 0 && texts.length < MAX_LOOKBACK;
|
||||
i--
|
||||
) {
|
||||
const msg = messages[i]
|
||||
if (msg?.type !== 'assistant' || msg.isApiErrorMessage) continue
|
||||
const content = (msg as AssistantMessage).message.content
|
||||
if (!Array.isArray(content)) continue
|
||||
const text = extractTextContent(content, '\n\n')
|
||||
if (text) texts.push(text)
|
||||
const texts: string[] = [];
|
||||
for (let i = messages.length - 1; i >= 0 && texts.length < MAX_LOOKBACK; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg?.type !== 'assistant' || msg.isApiErrorMessage) continue;
|
||||
const content = (msg as AssistantMessage).message.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
const text = extractTextContent(content, '\n\n');
|
||||
if (text) texts.push(text);
|
||||
}
|
||||
return texts
|
||||
return texts;
|
||||
}
|
||||
|
||||
export function fileExtension(lang: string | undefined): string {
|
||||
if (lang) {
|
||||
// Sanitize to prevent path traversal (e.g. ```../../etc/passwd)
|
||||
// Language identifiers are alphanumeric: python, tsx, jsonc, etc.
|
||||
const sanitized = lang.replace(/[^a-zA-Z0-9]/g, '')
|
||||
const sanitized = lang.replace(/[^a-zA-Z0-9]/g, '');
|
||||
if (sanitized && sanitized !== 'plaintext') {
|
||||
return `.${sanitized}`
|
||||
return `.${sanitized}`;
|
||||
}
|
||||
}
|
||||
return '.txt'
|
||||
return '.txt';
|
||||
}
|
||||
|
||||
async function writeToFile(text: string, filename: string): Promise<string> {
|
||||
const filePath = join(COPY_DIR, filename)
|
||||
await mkdir(COPY_DIR, { recursive: true })
|
||||
await writeFile(filePath, text, 'utf-8')
|
||||
return filePath
|
||||
const filePath = join(COPY_DIR, filename);
|
||||
await mkdir(COPY_DIR, { recursive: true });
|
||||
await writeFile(filePath, text, 'utf-8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function copyOrWriteToFile(
|
||||
text: string,
|
||||
filename: string,
|
||||
): Promise<string> {
|
||||
const raw = await setClipboard(text)
|
||||
if (raw) process.stdout.write(raw)
|
||||
const lineCount = countCharInString(text, '\n') + 1
|
||||
const charCount = text.length
|
||||
async function copyOrWriteToFile(text: string, filename: string): Promise<string> {
|
||||
const raw = await setClipboard(text);
|
||||
if (raw) process.stdout.write(raw);
|
||||
const lineCount = countCharInString(text, '\n') + 1;
|
||||
const charCount = text.length;
|
||||
// Also write to a temp file — clipboard paths are best-effort (OSC 52 needs
|
||||
// terminal support), so the file provides a reliable fallback.
|
||||
try {
|
||||
const filePath = await writeToFile(text, filename)
|
||||
return `Copied to clipboard (${charCount} characters, ${lineCount} lines)\nAlso written to ${filePath}`
|
||||
const filePath = await writeToFile(text, filename);
|
||||
return `Copied to clipboard (${charCount} characters, ${lineCount} lines)\nAlso written to ${filePath}`;
|
||||
} catch {
|
||||
return `Copied to clipboard (${charCount} characters, ${lineCount} lines)`
|
||||
return `Copied to clipboard (${charCount} characters, ${lineCount} lines)`;
|
||||
}
|
||||
}
|
||||
|
||||
function truncateLine(text: string, maxLen: number): string {
|
||||
const firstLine = text.split('\n')[0] ?? ''
|
||||
const firstLine = text.split('\n')[0] ?? '';
|
||||
if (stringWidth(firstLine) <= maxLen) {
|
||||
return firstLine
|
||||
return firstLine;
|
||||
}
|
||||
let result = ''
|
||||
let width = 0
|
||||
const targetWidth = maxLen - 1
|
||||
let result = '';
|
||||
let width = 0;
|
||||
const targetWidth = maxLen - 1;
|
||||
for (const char of firstLine) {
|
||||
const charWidth = stringWidth(char)
|
||||
if (width + charWidth > targetWidth) break
|
||||
result += char
|
||||
width += charWidth
|
||||
const charWidth = stringWidth(char);
|
||||
if (width + charWidth > targetWidth) break;
|
||||
result += char;
|
||||
width += charWidth;
|
||||
}
|
||||
return result + '\u2026'
|
||||
return result + '\u2026';
|
||||
}
|
||||
|
||||
type PickerProps = {
|
||||
fullText: string
|
||||
codeBlocks: CodeBlock[]
|
||||
messageAge: number
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
}
|
||||
fullText: string;
|
||||
codeBlocks: CodeBlock[];
|
||||
messageAge: number;
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
};
|
||||
|
||||
type PickerSelection = number | 'full' | 'always'
|
||||
type PickerSelection = number | 'full' | 'always';
|
||||
|
||||
function CopyPicker({
|
||||
fullText,
|
||||
codeBlocks,
|
||||
messageAge,
|
||||
onDone,
|
||||
}: PickerProps): React.ReactNode {
|
||||
const focusedRef = useRef<PickerSelection>('full')
|
||||
function CopyPicker({ fullText, codeBlocks, messageAge, onDone }: PickerProps): React.ReactNode {
|
||||
const focusedRef = useRef<PickerSelection>('full');
|
||||
|
||||
const options: OptionWithDescription<PickerSelection>[] = [
|
||||
{
|
||||
@@ -139,109 +124,99 @@ function CopyPicker({
|
||||
description: `${fullText.length} chars, ${countCharInString(fullText, '\n') + 1} lines`,
|
||||
},
|
||||
...codeBlocks.map((block, index) => {
|
||||
const blockLines = countCharInString(block.code, '\n') + 1
|
||||
const blockLines = countCharInString(block.code, '\n') + 1;
|
||||
return {
|
||||
label: truncateLine(block.code, 60),
|
||||
value: index,
|
||||
description:
|
||||
[block.lang, blockLines > 1 ? `${blockLines} lines` : undefined]
|
||||
.filter(Boolean)
|
||||
.join(', ') || undefined,
|
||||
}
|
||||
[block.lang, blockLines > 1 ? `${blockLines} lines` : undefined].filter(Boolean).join(', ') || undefined,
|
||||
};
|
||||
}),
|
||||
{
|
||||
label: 'Always copy full response',
|
||||
value: 'always' as const,
|
||||
description: 'Skip this picker in the future (revert via /config)',
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
function getSelectionContent(selected: PickerSelection): {
|
||||
text: string
|
||||
filename: string
|
||||
blockIndex?: number
|
||||
text: string;
|
||||
filename: string;
|
||||
blockIndex?: number;
|
||||
} {
|
||||
if (selected === 'full' || selected === 'always') {
|
||||
return { text: fullText, filename: RESPONSE_FILENAME }
|
||||
return { text: fullText, filename: RESPONSE_FILENAME };
|
||||
}
|
||||
const block = codeBlocks[selected]!
|
||||
const block = codeBlocks[selected]!;
|
||||
return {
|
||||
text: block.code,
|
||||
filename: `copy${fileExtension(block.lang)}`,
|
||||
blockIndex: selected,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSelect(selected: PickerSelection): Promise<void> {
|
||||
const content = getSelectionContent(selected)
|
||||
const content = getSelectionContent(selected);
|
||||
if (selected === 'always') {
|
||||
if (!getGlobalConfig().copyFullResponse) {
|
||||
saveGlobalConfig(c => ({ ...c, copyFullResponse: true }))
|
||||
saveGlobalConfig(c => ({ ...c, copyFullResponse: true }));
|
||||
}
|
||||
logEvent('tengu_copy', {
|
||||
block_count: codeBlocks.length,
|
||||
always: true,
|
||||
message_age: messageAge,
|
||||
})
|
||||
const result = await copyOrWriteToFile(content.text, content.filename)
|
||||
onDone(
|
||||
`${result}\nPreference saved. Use /config to change copyFullResponse`,
|
||||
)
|
||||
return
|
||||
});
|
||||
const result = await copyOrWriteToFile(content.text, content.filename);
|
||||
onDone(`${result}\nPreference saved. Use /config to change copyFullResponse`);
|
||||
return;
|
||||
}
|
||||
logEvent('tengu_copy', {
|
||||
selected_block: content.blockIndex,
|
||||
block_count: codeBlocks.length,
|
||||
message_age: messageAge,
|
||||
})
|
||||
const result = await copyOrWriteToFile(content.text, content.filename)
|
||||
onDone(result)
|
||||
});
|
||||
const result = await copyOrWriteToFile(content.text, content.filename);
|
||||
onDone(result);
|
||||
}
|
||||
|
||||
async function handleWrite(selected: PickerSelection): Promise<void> {
|
||||
const content = getSelectionContent(selected)
|
||||
const content = getSelectionContent(selected);
|
||||
logEvent('tengu_copy', {
|
||||
selected_block: content.blockIndex,
|
||||
block_count: codeBlocks.length,
|
||||
message_age: messageAge,
|
||||
write_shortcut: true,
|
||||
})
|
||||
});
|
||||
try {
|
||||
const filePath = await writeToFile(content.text, content.filename)
|
||||
onDone(`Written to ${filePath}`)
|
||||
const filePath = await writeToFile(content.text, content.filename);
|
||||
onDone(`Written to ${filePath}`);
|
||||
} catch (e) {
|
||||
onDone(`Failed to write file: ${e instanceof Error ? e.message : e}`)
|
||||
onDone(`Failed to write file: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
if (e.key === 'w') {
|
||||
e.preventDefault()
|
||||
void handleWrite(focusedRef.current)
|
||||
e.preventDefault();
|
||||
void handleWrite(focusedRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Pane>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Box flexDirection="column" gap={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
|
||||
<Text dimColor>Select content to copy:</Text>
|
||||
<Select<PickerSelection>
|
||||
options={options}
|
||||
hideIndexes={false}
|
||||
onFocus={value => {
|
||||
focusedRef.current = value
|
||||
focusedRef.current = value;
|
||||
}}
|
||||
onChange={selected => {
|
||||
void handleSelect(selected)
|
||||
void handleSelect(selected);
|
||||
}}
|
||||
onCancel={() => {
|
||||
onDone('Copy cancelled', { display: 'system' })
|
||||
onDone('Copy cancelled', { display: 'system' });
|
||||
}}
|
||||
/>
|
||||
<Text dimColor>
|
||||
@@ -253,56 +228,47 @@ function CopyPicker({
|
||||
</Text>
|
||||
</Box>
|
||||
</Pane>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, context, args) => {
|
||||
const texts = collectRecentAssistantTexts(context.messages)
|
||||
const texts = collectRecentAssistantTexts(context.messages);
|
||||
|
||||
if (texts.length === 0) {
|
||||
onDone('No assistant message to copy')
|
||||
return null
|
||||
onDone('No assistant message to copy');
|
||||
return null;
|
||||
}
|
||||
|
||||
// /copy N reaches back N-1 messages (1 = latest, 2 = second-to-latest, ...)
|
||||
let age = 0
|
||||
const arg = args?.trim()
|
||||
let age = 0;
|
||||
const arg = args?.trim();
|
||||
if (arg) {
|
||||
const n = Number(arg)
|
||||
const n = Number(arg);
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
onDone(`Usage: /copy [N] where N is 1 (latest), 2, 3, \u2026 Got: ${arg}`)
|
||||
return null
|
||||
onDone(`Usage: /copy [N] where N is 1 (latest), 2, 3, \u2026 Got: ${arg}`);
|
||||
return null;
|
||||
}
|
||||
if (n > texts.length) {
|
||||
onDone(
|
||||
`Only ${texts.length} assistant ${texts.length === 1 ? 'message' : 'messages'} available to copy`,
|
||||
)
|
||||
return null
|
||||
onDone(`Only ${texts.length} assistant ${texts.length === 1 ? 'message' : 'messages'} available to copy`);
|
||||
return null;
|
||||
}
|
||||
age = n - 1
|
||||
age = n - 1;
|
||||
}
|
||||
|
||||
const text = texts[age]!
|
||||
const codeBlocks = extractCodeBlocks(text)
|
||||
const config = getGlobalConfig()
|
||||
const text = texts[age]!;
|
||||
const codeBlocks = extractCodeBlocks(text);
|
||||
const config = getGlobalConfig();
|
||||
|
||||
if (codeBlocks.length === 0 || config.copyFullResponse) {
|
||||
logEvent('tengu_copy', {
|
||||
always: config.copyFullResponse,
|
||||
block_count: codeBlocks.length,
|
||||
message_age: age,
|
||||
})
|
||||
const result = await copyOrWriteToFile(text, RESPONSE_FILENAME)
|
||||
onDone(result)
|
||||
return null
|
||||
});
|
||||
const result = await copyOrWriteToFile(text, RESPONSE_FILENAME);
|
||||
onDone(result);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyPicker
|
||||
fullText={text}
|
||||
codeBlocks={codeBlocks}
|
||||
messageAge={age}
|
||||
onDone={onDone}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <CopyPicker fullText={text} codeBlocks={codeBlocks} messageAge={age} onDone={onDone} />;
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
LocalJSXCommandOnDone,
|
||||
LocalJSXCommandContext,
|
||||
} from '../../types/command.js'
|
||||
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js';
|
||||
|
||||
/**
|
||||
* /daemon slash command — manages daemon and background sessions from the REPL.
|
||||
@@ -14,44 +11,41 @@ export async function call(
|
||||
_context: LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const parts = args ? args.trim().split(/\s+/) : []
|
||||
const sub = parts[0] || 'status'
|
||||
const parts = args ? args.trim().split(/\s+/) : [];
|
||||
const sub = parts[0] || 'status';
|
||||
|
||||
// attach is interactive/blocking — not available inside the REPL
|
||||
if (sub === 'attach') {
|
||||
onDone(
|
||||
'Use `claude daemon attach` from the CLI. Attach is not available inside the REPL.',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
onDone('Use `claude daemon attach` from the CLI. Attach is not available inside the REPL.', { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
// For all other subcommands, capture console output and return via onDone
|
||||
const lines = await captureConsole(async () => {
|
||||
if (sub === 'bg') {
|
||||
const bg = await import('../../cli/bg.js')
|
||||
await bg.handleBgStart(parts.slice(1))
|
||||
const bg = await import('../../cli/bg.js');
|
||||
await bg.handleBgStart(parts.slice(1));
|
||||
} else {
|
||||
const { daemonMain } = await import('../../daemon/main.js')
|
||||
await daemonMain([sub, ...parts.slice(1)])
|
||||
const { daemonMain } = await import('../../daemon/main.js');
|
||||
await daemonMain([sub, ...parts.slice(1)]);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onDone(lines.join('\n') || 'Done.', { display: 'system' })
|
||||
return null
|
||||
onDone(lines.join('\n') || 'Done.', { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
async function captureConsole(fn: () => Promise<void>): Promise<string[]> {
|
||||
const lines: string[] = []
|
||||
const origLog = console.log
|
||||
const origError = console.error
|
||||
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '))
|
||||
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '))
|
||||
const lines: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origError = console.error;
|
||||
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '));
|
||||
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '));
|
||||
try {
|
||||
await fn()
|
||||
await fn();
|
||||
} finally {
|
||||
console.log = origLog
|
||||
console.error = origError
|
||||
console.log = origLog;
|
||||
console.error = origError;
|
||||
}
|
||||
return lines
|
||||
return lines;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import React from 'react'
|
||||
import type { CommandResultDisplay } from '../../commands.js'
|
||||
import { DesktopHandoff } from '../../components/DesktopHandoff.js'
|
||||
import React from 'react';
|
||||
import type { CommandResultDisplay } from '../../commands.js';
|
||||
import { DesktopHandoff } from '../../components/DesktopHandoff.js';
|
||||
|
||||
export async function call(
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void,
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void,
|
||||
): Promise<React.ReactNode> {
|
||||
return <DesktopHandoff onDone={onDone} />
|
||||
return <DesktopHandoff onDone={onDone} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import * as React from 'react';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
const { DiffDialog } = await import('../../components/diff/DiffDialog.js')
|
||||
return <DiffDialog messages={context.messages} onDone={onDone} />
|
||||
}
|
||||
const { DiffDialog } = await import('../../components/diff/DiffDialog.js');
|
||||
return <DiffDialog messages={context.messages} onDone={onDone} />;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Doctor } from '../../screens/Doctor.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import React from 'react';
|
||||
import { Doctor } from '../../screens/Doctor.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
export const call: LocalJSXCommandCall = (onDone, _context, _args) => {
|
||||
return Promise.resolve(<Doctor onDone={onDone} />)
|
||||
}
|
||||
return Promise.resolve(<Doctor onDone={onDone} />);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from 'react'
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'
|
||||
import * as React from 'react';
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
} from '../../services/analytics/index.js';
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import {
|
||||
type EffortValue,
|
||||
getDisplayedEffortLevel,
|
||||
@@ -13,171 +13,157 @@ import {
|
||||
getEffortValueDescription,
|
||||
isEffortLevel,
|
||||
toPersistableEffort,
|
||||
} from '../../utils/effort.js'
|
||||
import { updateSettingsForSource } from '../../utils/settings/settings.js'
|
||||
} from '../../utils/effort.js';
|
||||
import { updateSettingsForSource } from '../../utils/settings/settings.js';
|
||||
|
||||
const COMMON_HELP_ARGS = ['help', '-h', '--help']
|
||||
const COMMON_HELP_ARGS = ['help', '-h', '--help'];
|
||||
|
||||
type EffortCommandResult = {
|
||||
message: string
|
||||
effortUpdate?: { value: EffortValue | undefined }
|
||||
}
|
||||
message: string;
|
||||
effortUpdate?: { value: EffortValue | undefined };
|
||||
};
|
||||
|
||||
function setEffortValue(effortValue: EffortValue): EffortCommandResult {
|
||||
const persistable = toPersistableEffort(effortValue)
|
||||
const persistable = toPersistableEffort(effortValue);
|
||||
if (persistable !== undefined) {
|
||||
const result = updateSettingsForSource('userSettings', {
|
||||
effortLevel: persistable,
|
||||
})
|
||||
});
|
||||
if (result.error) {
|
||||
return {
|
||||
message: `Failed to set effort level: ${result.error.message}`,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
logEvent('tengu_effort_command', {
|
||||
effort:
|
||||
effortValue as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
effort: effortValue as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
|
||||
// Env var wins at resolveAppliedEffort time. Only flag it when it actually
|
||||
// conflicts — if env matches what the user just asked for, the outcome is
|
||||
// the same, so "Set effort to X" is true and the note is noise.
|
||||
const envOverride = getEffortEnvOverride()
|
||||
const envOverride = getEffortEnvOverride();
|
||||
if (envOverride !== undefined && envOverride !== effortValue) {
|
||||
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL
|
||||
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL;
|
||||
if (persistable === undefined) {
|
||||
return {
|
||||
message: `Not applied: CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides effort this session, and ${effortValue} is session-only (nothing saved)`,
|
||||
effortUpdate: { value: effortValue },
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: `CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides this session — clear it and ${effortValue} takes over`,
|
||||
effortUpdate: { value: effortValue },
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const description = getEffortValueDescription(effortValue)
|
||||
const suffix = persistable !== undefined ? '' : ' (this session only)'
|
||||
const description = getEffortValueDescription(effortValue);
|
||||
const suffix = persistable !== undefined ? '' : ' (this session only)';
|
||||
return {
|
||||
message: `Set effort level to ${effortValue}${suffix}: ${description}`,
|
||||
effortUpdate: { value: effortValue },
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function showCurrentEffort(
|
||||
appStateEffort: EffortValue | undefined,
|
||||
model: string,
|
||||
): EffortCommandResult {
|
||||
const envOverride = getEffortEnvOverride()
|
||||
const effectiveValue =
|
||||
envOverride === null ? undefined : (envOverride ?? appStateEffort)
|
||||
export function showCurrentEffort(appStateEffort: EffortValue | undefined, model: string): EffortCommandResult {
|
||||
const envOverride = getEffortEnvOverride();
|
||||
const effectiveValue = envOverride === null ? undefined : (envOverride ?? appStateEffort);
|
||||
if (effectiveValue === undefined) {
|
||||
const level = getDisplayedEffortLevel(model, appStateEffort)
|
||||
return { message: `Effort level: auto (currently ${level})` }
|
||||
const level = getDisplayedEffortLevel(model, appStateEffort);
|
||||
return { message: `Effort level: auto (currently ${level})` };
|
||||
}
|
||||
const description = getEffortValueDescription(effectiveValue)
|
||||
const description = getEffortValueDescription(effectiveValue);
|
||||
return {
|
||||
message: `Current effort level: ${effectiveValue} (${description})`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function unsetEffortLevel(): EffortCommandResult {
|
||||
const result = updateSettingsForSource('userSettings', {
|
||||
effortLevel: undefined,
|
||||
})
|
||||
});
|
||||
if (result.error) {
|
||||
return {
|
||||
message: `Failed to set effort level: ${result.error.message}`,
|
||||
}
|
||||
};
|
||||
}
|
||||
logEvent('tengu_effort_command', {
|
||||
effort:
|
||||
'auto' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
effort: 'auto' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
// env=auto/unset (null) matches what /effort auto asks for, so only warn
|
||||
// when env is pinning a specific level that will keep overriding.
|
||||
const envOverride = getEffortEnvOverride()
|
||||
const envOverride = getEffortEnvOverride();
|
||||
if (envOverride !== undefined && envOverride !== null) {
|
||||
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL
|
||||
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL;
|
||||
return {
|
||||
message: `Cleared effort from settings, but CLAUDE_CODE_EFFORT_LEVEL=${envRaw} still controls this session`,
|
||||
effortUpdate: { value: undefined },
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: 'Effort level set to auto',
|
||||
effortUpdate: { value: undefined },
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function executeEffort(args: string): EffortCommandResult {
|
||||
const normalized = args.toLowerCase()
|
||||
const normalized = args.toLowerCase();
|
||||
if (normalized === 'auto' || normalized === 'unset') {
|
||||
return unsetEffortLevel()
|
||||
return unsetEffortLevel();
|
||||
}
|
||||
|
||||
if (!isEffortLevel(normalized)) {
|
||||
return {
|
||||
message: `Invalid argument: ${args}. Valid options are: low, medium, high, max, auto`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return setEffortValue(normalized)
|
||||
return setEffortValue(normalized);
|
||||
}
|
||||
|
||||
function ShowCurrentEffort({
|
||||
onDone,
|
||||
}: {
|
||||
onDone: (result: string) => void
|
||||
}): React.ReactNode {
|
||||
const effortValue = useAppState(s => s.effortValue)
|
||||
const model = useMainLoopModel()
|
||||
const { message } = showCurrentEffort(effortValue, model)
|
||||
onDone(message)
|
||||
return null
|
||||
function ShowCurrentEffort({ onDone }: { onDone: (result: string) => void }): React.ReactNode {
|
||||
const effortValue = useAppState(s => s.effortValue);
|
||||
const model = useMainLoopModel();
|
||||
const { message } = showCurrentEffort(effortValue, model);
|
||||
onDone(message);
|
||||
return null;
|
||||
}
|
||||
|
||||
function ApplyEffortAndClose({
|
||||
result,
|
||||
onDone,
|
||||
}: {
|
||||
result: EffortCommandResult
|
||||
onDone: (result: string) => void
|
||||
result: EffortCommandResult;
|
||||
onDone: (result: string) => void;
|
||||
}): React.ReactNode {
|
||||
const setAppState = useSetAppState()
|
||||
const { effortUpdate, message } = result
|
||||
const setAppState = useSetAppState();
|
||||
const { effortUpdate, message } = result;
|
||||
React.useEffect(() => {
|
||||
if (effortUpdate) {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
effortValue: effortUpdate.value,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
onDone(message)
|
||||
}, [setAppState, effortUpdate, message, onDone])
|
||||
return null
|
||||
onDone(message);
|
||||
}, [setAppState, effortUpdate, message, onDone]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
_context: unknown,
|
||||
args?: string,
|
||||
): Promise<React.ReactNode> {
|
||||
args = args?.trim() || ''
|
||||
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
|
||||
args = args?.trim() || '';
|
||||
|
||||
if (COMMON_HELP_ARGS.includes(args)) {
|
||||
onDone(
|
||||
'Usage: /effort [low|medium|high|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6/4.7, DeepSeek V4 Pro)\n- auto: Use the default effort level for your model',
|
||||
)
|
||||
return
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args || args === 'current' || args === 'status') {
|
||||
return <ShowCurrentEffort onDone={onDone} />
|
||||
return <ShowCurrentEffort onDone={onDone} />;
|
||||
}
|
||||
|
||||
const result = executeEffort(args)
|
||||
return <ApplyEffortAndClose result={result} onDone={onDone} />
|
||||
const result = executeEffort(args);
|
||||
return <ApplyEffortAndClose result={result} onDone={onDone} />;
|
||||
}
|
||||
|
||||
2
src/commands/env/index.js
vendored
2
src/commands/env/index.js
vendored
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,44 +1,36 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { spawnSync } from 'child_process'
|
||||
import sample from 'lodash-es/sample.js'
|
||||
import * as React from 'react'
|
||||
import { ExitFlow } from '../../components/ExitFlow.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { isBgSession } from '../../utils/concurrentSessions.js'
|
||||
import { gracefulShutdown } from '../../utils/gracefulShutdown.js'
|
||||
import { getCurrentWorktreeSession } from '../../utils/worktree.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import { spawnSync } from 'child_process';
|
||||
import sample from 'lodash-es/sample.js';
|
||||
import * as React from 'react';
|
||||
import { ExitFlow } from '../../components/ExitFlow.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { isBgSession } from '../../utils/concurrentSessions.js';
|
||||
import { gracefulShutdown } from '../../utils/gracefulShutdown.js';
|
||||
import { getCurrentWorktreeSession } from '../../utils/worktree.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!';
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
): Promise<React.ReactNode> {
|
||||
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
|
||||
// Inside a `claude --bg` tmux session: detach instead of kill. The REPL
|
||||
// keeps running; `claude attach` can reconnect. Covers /exit, /quit,
|
||||
// ctrl+c, ctrl+d — all funnel through here via REPL's handleExit.
|
||||
if (feature('BG_SESSIONS') && isBgSession()) {
|
||||
onDone()
|
||||
spawnSync('tmux', ['detach-client'], { stdio: 'ignore' })
|
||||
return null
|
||||
onDone();
|
||||
spawnSync('tmux', ['detach-client'], { stdio: 'ignore' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const showWorktree = getCurrentWorktreeSession() !== null
|
||||
const showWorktree = getCurrentWorktreeSession() !== null;
|
||||
|
||||
if (showWorktree) {
|
||||
return (
|
||||
<ExitFlow
|
||||
showWorktree={showWorktree}
|
||||
onDone={onDone}
|
||||
onCancel={() => onDone()}
|
||||
/>
|
||||
)
|
||||
return <ExitFlow showWorktree={showWorktree} onDone={onDone} onCancel={() => onDone()} />;
|
||||
}
|
||||
|
||||
onDone(getRandomGoodbyeMessage())
|
||||
await gracefulShutdown(0, 'prompt_input_exit')
|
||||
return null
|
||||
onDone(getRandomGoodbyeMessage());
|
||||
await gracefulShutdown(0, 'prompt_input_exit');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
import { join } from 'path'
|
||||
import React from 'react'
|
||||
import { ExportDialog } from '../../components/ExportDialog.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { renderMessagesToPlainText } from '../../utils/exportRenderer.js'
|
||||
import { writeFileSync_DEPRECATED } from '../../utils/slowOperations.js'
|
||||
import { join } from 'path';
|
||||
import React from 'react';
|
||||
import { ExportDialog } from '../../components/ExportDialog.js';
|
||||
import type { ToolUseContext } from '../../Tool.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import type { Message } from '../../types/message.js';
|
||||
import { getCwd } from '../../utils/cwd.js';
|
||||
import { renderMessagesToPlainText } from '../../utils/exportRenderer.js';
|
||||
import { writeFileSync_DEPRECATED } from '../../utils/slowOperations.js';
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}-${hours}${minutes}${seconds}`
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}-${hours}${minutes}${seconds}`;
|
||||
}
|
||||
|
||||
export function extractFirstPrompt(messages: Message[]): string {
|
||||
const firstUserMessage = messages.find(msg => msg.type === 'user')
|
||||
const firstUserMessage = messages.find(msg => msg.type === 'user');
|
||||
|
||||
if (!firstUserMessage || firstUserMessage.type !== 'user') {
|
||||
return ''
|
||||
return '';
|
||||
}
|
||||
|
||||
const content = firstUserMessage.message?.content
|
||||
let result = ''
|
||||
const content = firstUserMessage.message?.content;
|
||||
let result = '';
|
||||
|
||||
if (typeof content === 'string') {
|
||||
result = content.trim()
|
||||
result = content.trim();
|
||||
} else if (Array.isArray(content)) {
|
||||
const textContent = content.find(item => item.type === 'text')
|
||||
const textContent = content.find(item => item.type === 'text');
|
||||
if (textContent && 'text' in textContent) {
|
||||
result = textContent.text.trim()
|
||||
result = textContent.text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Take first line only and limit length
|
||||
result = result.split('\n')[0] || ''
|
||||
result = result.split('\n')[0] || '';
|
||||
if (result.length > 50) {
|
||||
result = result.substring(0, 49) + '…'
|
||||
result = result.substring(0, 49) + '…';
|
||||
}
|
||||
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sanitizeFilename(text: string): string {
|
||||
@@ -53,14 +53,12 @@ export function sanitizeFilename(text: string): string {
|
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special chars
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Replace multiple hyphens with single
|
||||
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
||||
}
|
||||
|
||||
async function exportWithReactRenderer(
|
||||
context: ToolUseContext,
|
||||
): Promise<string> {
|
||||
const tools = context.options.tools || []
|
||||
return renderMessagesToPlainText(context.messages, tools)
|
||||
async function exportWithReactRenderer(context: ToolUseContext): Promise<string> {
|
||||
const tools = context.options.tools || [];
|
||||
return renderMessagesToPlainText(context.messages, tools);
|
||||
}
|
||||
|
||||
export async function call(
|
||||
@@ -69,43 +67,37 @@ export async function call(
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
// Render the conversation content
|
||||
const content = await exportWithReactRenderer(context)
|
||||
const content = await exportWithReactRenderer(context);
|
||||
|
||||
// If args are provided, write directly to file and skip dialog
|
||||
const filename = args.trim()
|
||||
const filename = args.trim();
|
||||
if (filename) {
|
||||
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(`Conversation exported to: ${filepath}`)
|
||||
return null
|
||||
});
|
||||
onDone(`Conversation exported to: ${filepath}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
onDone(
|
||||
`Failed to export conversation: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
)
|
||||
return null
|
||||
onDone(`Failed to export conversation: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate default filename from first prompt or timestamp
|
||||
const firstPrompt = extractFirstPrompt(context.messages)
|
||||
const timestamp = formatTimestamp(new Date())
|
||||
const firstPrompt = extractFirstPrompt(context.messages);
|
||||
const timestamp = formatTimestamp(new Date());
|
||||
|
||||
let defaultFilename: string
|
||||
let defaultFilename: string;
|
||||
if (firstPrompt) {
|
||||
const sanitized = sanitizeFilename(firstPrompt)
|
||||
defaultFilename = sanitized
|
||||
? `${timestamp}-${sanitized}.txt`
|
||||
: `conversation-${timestamp}.txt`
|
||||
const sanitized = sanitizeFilename(firstPrompt);
|
||||
defaultFilename = sanitized ? `${timestamp}-${sanitized}.txt` : `conversation-${timestamp}.txt`;
|
||||
} else {
|
||||
defaultFilename = `conversation-${timestamp}.txt`
|
||||
defaultFilename = `conversation-${timestamp}.txt`;
|
||||
}
|
||||
|
||||
// Return the dialog component when no args provided
|
||||
@@ -114,8 +106,8 @@ export async function call(
|
||||
content={content}
|
||||
defaultFilename={defaultFilename}
|
||||
onDone={result => {
|
||||
onDone(result.message)
|
||||
onDone(result.message);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
import React from 'react'
|
||||
import type { LocalJSXCommandContext } from '../../commands.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { Login } from '../login/login.js'
|
||||
import { runExtraUsage } from './extra-usage-core.js'
|
||||
import React from 'react';
|
||||
import type { LocalJSXCommandContext } from '../../commands.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { Login } from '../login/login.js';
|
||||
import { runExtraUsage } from './extra-usage-core.js';
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: LocalJSXCommandContext,
|
||||
): Promise<React.ReactNode | null> {
|
||||
const result = await runExtraUsage()
|
||||
const result = await runExtraUsage();
|
||||
|
||||
if (result.type === 'message') {
|
||||
onDone(result.value)
|
||||
return null
|
||||
onDone(result.value);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Login
|
||||
startingMessage={
|
||||
'Starting new login following /extra-usage. Exit with Ctrl-C to use existing account.'
|
||||
}
|
||||
startingMessage={'Starting new login following /extra-usage. Exit with Ctrl-C to use existing account.'}
|
||||
onDone={success => {
|
||||
context.onChangeAPIKey()
|
||||
onDone(success ? 'Login successful' : 'Login interrupted')
|
||||
context.onChangeAPIKey();
|
||||
onDone(success ? 'Login successful' : 'Login interrupted');
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import type {
|
||||
CommandResultDisplay,
|
||||
LocalJSXCommandContext,
|
||||
} from '../../commands.js'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
import { FastIcon, getFastIconString } from '../../components/FastIcon.js'
|
||||
import { Box, Link, Text } from '@anthropic/ink'
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
import { FastIcon, getFastIconString } from '../../components/FastIcon.js';
|
||||
import { Box, Link, Text } from '@anthropic/ink';
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import {
|
||||
type AppState,
|
||||
useAppState,
|
||||
useSetAppState,
|
||||
} from '../../state/AppState.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
} from '../../services/analytics/index.js';
|
||||
import { type AppState, useAppState, useSetAppState } from '../../state/AppState.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import {
|
||||
clearFastModeCooldown,
|
||||
FAST_MODE_MODEL_DISPLAY,
|
||||
@@ -27,33 +20,28 @@ import {
|
||||
isFastModeEnabled,
|
||||
isFastModeSupportedByModel,
|
||||
prefetchFastModeStatus,
|
||||
} from '../../utils/fastMode.js'
|
||||
import { formatDuration } from '../../utils/format.js'
|
||||
import { formatModelPricing, getOpus46CostTier } from '../../utils/modelCost.js'
|
||||
import { updateSettingsForSource } from '../../utils/settings/settings.js'
|
||||
} from '../../utils/fastMode.js';
|
||||
import { formatDuration } from '../../utils/format.js';
|
||||
import { formatModelPricing, getOpus46CostTier } from '../../utils/modelCost.js';
|
||||
import { updateSettingsForSource } from '../../utils/settings/settings.js';
|
||||
|
||||
function applyFastMode(
|
||||
enable: boolean,
|
||||
setAppState: (f: (prev: AppState) => AppState) => void,
|
||||
): void {
|
||||
clearFastModeCooldown()
|
||||
function applyFastMode(enable: boolean, setAppState: (f: (prev: AppState) => AppState) => void): void {
|
||||
clearFastModeCooldown();
|
||||
updateSettingsForSource('userSettings', {
|
||||
fastMode: enable ? true : undefined,
|
||||
})
|
||||
});
|
||||
if (enable) {
|
||||
setAppState(prev => {
|
||||
// Only switch model if current model doesn't support fast mode
|
||||
const needsModelSwitch = !isFastModeSupportedByModel(prev.mainLoopModel)
|
||||
const needsModelSwitch = !isFastModeSupportedByModel(prev.mainLoopModel);
|
||||
return {
|
||||
...prev,
|
||||
...(needsModelSwitch
|
||||
? { mainLoopModel: getFastModeModel(), mainLoopModelForSession: null }
|
||||
: {}),
|
||||
...(needsModelSwitch ? { mainLoopModel: getFastModeModel(), mainLoopModelForSession: null } : {}),
|
||||
fastMode: true,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setAppState(prev => ({ ...prev, fastMode: false }))
|
||||
setAppState(prev => ({ ...prev, fastMode: false }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,38 +49,32 @@ export function FastModePicker({
|
||||
onDone,
|
||||
unavailableReason,
|
||||
}: {
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
unavailableReason: string | null
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
unavailableReason: string | null;
|
||||
}): React.ReactNode {
|
||||
const model = useAppState(s => s.mainLoopModel)
|
||||
const initialFastMode = useAppState(s => s.fastMode)
|
||||
const setAppState = useSetAppState()
|
||||
const [enableFastMode, setEnableFastMode] = useState(initialFastMode ?? false)
|
||||
const runtimeState = getFastModeRuntimeState()
|
||||
const isCooldown = runtimeState.status === 'cooldown'
|
||||
const isUnavailable = unavailableReason !== null
|
||||
const pricing = formatModelPricing(getOpus46CostTier(true))
|
||||
const model = useAppState(s => s.mainLoopModel);
|
||||
const initialFastMode = useAppState(s => s.fastMode);
|
||||
const setAppState = useSetAppState();
|
||||
const [enableFastMode, setEnableFastMode] = useState(initialFastMode ?? false);
|
||||
const runtimeState = getFastModeRuntimeState();
|
||||
const isCooldown = runtimeState.status === 'cooldown';
|
||||
const isUnavailable = unavailableReason !== null;
|
||||
const pricing = formatModelPricing(getOpus46CostTier(true));
|
||||
|
||||
function handleConfirm(): void {
|
||||
if (isUnavailable) return
|
||||
applyFastMode(enableFastMode, setAppState)
|
||||
if (isUnavailable) return;
|
||||
applyFastMode(enableFastMode, setAppState);
|
||||
logEvent('tengu_fast_mode_toggled', {
|
||||
enabled: enableFastMode,
|
||||
source:
|
||||
'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
source: 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
if (enableFastMode) {
|
||||
const fastIcon = getFastIconString(enableFastMode)
|
||||
const modelUpdated = !isFastModeSupportedByModel(model)
|
||||
? ` · model set to ${FAST_MODE_MODEL_DISPLAY}`
|
||||
: ''
|
||||
onDone(`${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`)
|
||||
const fastIcon = getFastIconString(enableFastMode);
|
||||
const modelUpdated = !isFastModeSupportedByModel(model) ? ` · model set to ${FAST_MODE_MODEL_DISPLAY}` : '';
|
||||
onDone(`${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`);
|
||||
} else {
|
||||
setAppState(prev => ({ ...prev, fastMode: false }))
|
||||
onDone(`Fast mode OFF`)
|
||||
setAppState(prev => ({ ...prev, fastMode: false }));
|
||||
onDone(`Fast mode OFF`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,20 +82,18 @@ export function FastModePicker({
|
||||
if (isUnavailable) {
|
||||
// Ensure fast mode is off if the org has disabled it
|
||||
if (initialFastMode) {
|
||||
applyFastMode(false, setAppState)
|
||||
applyFastMode(false, setAppState);
|
||||
}
|
||||
onDone('Fast mode OFF', { display: 'system' })
|
||||
return
|
||||
onDone('Fast mode OFF', { display: 'system' });
|
||||
return;
|
||||
}
|
||||
const message = initialFastMode
|
||||
? `${getFastIconString()} Kept Fast mode ON`
|
||||
: `Kept Fast mode OFF`
|
||||
onDone(message, { display: 'system' })
|
||||
const message = initialFastMode ? `${getFastIconString()} Kept Fast mode ON` : `Kept Fast mode OFF`;
|
||||
onDone(message, { display: 'system' });
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
if (isUnavailable) return
|
||||
setEnableFastMode(prev => !prev)
|
||||
if (isUnavailable) return;
|
||||
setEnableFastMode(prev => !prev);
|
||||
}
|
||||
|
||||
useKeybindings(
|
||||
@@ -126,13 +106,13 @@ export function FastModePicker({
|
||||
'confirm:toggle': handleToggle,
|
||||
},
|
||||
{ context: 'Confirmation' },
|
||||
)
|
||||
);
|
||||
|
||||
const title = (
|
||||
<Text>
|
||||
<FastIcon cooldown={isCooldown} /> Fast mode (research preview)
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -159,10 +139,7 @@ export function FastModePicker({
|
||||
<Box flexDirection="column" gap={0} marginLeft={2}>
|
||||
<Box flexDirection="row" gap={2}>
|
||||
<Text bold>Fast mode</Text>
|
||||
<Text
|
||||
color={enableFastMode ? 'fastMode' : undefined}
|
||||
bold={enableFastMode}
|
||||
>
|
||||
<Text color={enableFastMode ? 'fastMode' : undefined} bold={enableFastMode}>
|
||||
{enableFastMode ? 'ON ' : 'OFF'}
|
||||
</Text>
|
||||
<Text dimColor>{pricing}</Text>
|
||||
@@ -186,12 +163,10 @@ export function FastModePicker({
|
||||
)}
|
||||
<Text dimColor>
|
||||
Learn more:{' '}
|
||||
<Link url="https://code.claude.com/docs/en/fast-mode">
|
||||
https://code.claude.com/docs/en/fast-mode
|
||||
</Link>
|
||||
<Link url="https://code.claude.com/docs/en/fast-mode">https://code.claude.com/docs/en/fast-mode</Link>
|
||||
</Text>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleFastModeShortcut(
|
||||
@@ -199,28 +174,25 @@ async function handleFastModeShortcut(
|
||||
getAppState: () => AppState,
|
||||
setAppState: (f: (prev: AppState) => AppState) => void,
|
||||
): Promise<string> {
|
||||
const unavailableReason = getFastModeUnavailableReason()
|
||||
const unavailableReason = getFastModeUnavailableReason();
|
||||
if (unavailableReason) {
|
||||
return `Fast mode unavailable: ${unavailableReason}`
|
||||
return `Fast mode unavailable: ${unavailableReason}`;
|
||||
}
|
||||
|
||||
const { mainLoopModel } = getAppState()
|
||||
applyFastMode(enable, setAppState)
|
||||
const { mainLoopModel } = getAppState();
|
||||
applyFastMode(enable, setAppState);
|
||||
logEvent('tengu_fast_mode_toggled', {
|
||||
enabled: enable,
|
||||
source:
|
||||
'shortcut' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
source: 'shortcut' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
|
||||
if (enable) {
|
||||
const fastIcon = getFastIconString(true)
|
||||
const modelUpdated = !isFastModeSupportedByModel(mainLoopModel)
|
||||
? ` · model set to ${FAST_MODE_MODEL_DISPLAY}`
|
||||
: ''
|
||||
const pricing = formatModelPricing(getOpus46CostTier(true))
|
||||
return `${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`
|
||||
const fastIcon = getFastIconString(true);
|
||||
const modelUpdated = !isFastModeSupportedByModel(mainLoopModel) ? ` · model set to ${FAST_MODE_MODEL_DISPLAY}` : '';
|
||||
const pricing = formatModelPricing(getOpus46CostTier(true));
|
||||
return `${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`;
|
||||
} else {
|
||||
return `Fast mode OFF`
|
||||
return `Fast mode OFF`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,31 +202,24 @@ export async function call(
|
||||
args?: string,
|
||||
): Promise<React.ReactNode | null> {
|
||||
if (!isFastModeEnabled()) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch org fast mode status before showing the picker. We must know
|
||||
// whether the org has disabled fast mode before allowing any toggle.
|
||||
// If a startup prefetch is already in flight, this awaits it.
|
||||
await prefetchFastModeStatus()
|
||||
await prefetchFastModeStatus();
|
||||
|
||||
const arg = args?.trim().toLowerCase()
|
||||
const arg = args?.trim().toLowerCase();
|
||||
if (arg === 'on' || arg === 'off') {
|
||||
const result = await handleFastModeShortcut(
|
||||
arg === 'on',
|
||||
context.getAppState,
|
||||
context.setAppState,
|
||||
)
|
||||
onDone(result)
|
||||
return null
|
||||
const result = await handleFastModeShortcut(arg === 'on', context.getAppState, context.setAppState);
|
||||
onDone(result);
|
||||
return null;
|
||||
}
|
||||
|
||||
const unavailableReason = getFastModeUnavailableReason()
|
||||
const unavailableReason = getFastModeUnavailableReason();
|
||||
logEvent('tengu_fast_mode_picker_shown', {
|
||||
unavailable_reason: (unavailableReason ??
|
||||
'') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
return (
|
||||
<FastModePicker onDone={onDone} unavailableReason={unavailableReason} />
|
||||
)
|
||||
unavailable_reason: (unavailableReason ?? '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
return <FastModePicker onDone={onDone} unavailableReason={unavailableReason} />;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import * as React from 'react'
|
||||
import type {
|
||||
CommandResultDisplay,
|
||||
LocalJSXCommandContext,
|
||||
} from '../../commands.js'
|
||||
import { Feedback } from '../../components/Feedback.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import * as React from 'react';
|
||||
import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js';
|
||||
import { Feedback } from '../../components/Feedback.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import type { Message } from '../../types/message.js';
|
||||
|
||||
// Shared function to render the Feedback component
|
||||
export function renderFeedbackComponent(
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void,
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void,
|
||||
abortSignal: AbortSignal,
|
||||
messages: Message[],
|
||||
initialDescription: string = '',
|
||||
backgroundTasks: {
|
||||
[taskId: string]: {
|
||||
type: string
|
||||
identity?: { agentId: string }
|
||||
messages?: Message[]
|
||||
}
|
||||
type: string;
|
||||
identity?: { agentId: string };
|
||||
messages?: Message[];
|
||||
};
|
||||
} = {},
|
||||
): React.ReactNode {
|
||||
return (
|
||||
@@ -32,7 +26,7 @@ export function renderFeedbackComponent(
|
||||
onDone={onDone}
|
||||
backgroundTasks={backgroundTasks}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function call(
|
||||
@@ -40,11 +34,6 @@ export async function call(
|
||||
context: LocalJSXCommandContext,
|
||||
args?: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const initialDescription = args || ''
|
||||
return renderFeedbackComponent(
|
||||
onDone,
|
||||
context.abortController.signal,
|
||||
context.messages,
|
||||
initialDescription,
|
||||
)
|
||||
const initialDescription = args || '';
|
||||
return renderFeedbackComponent(onDone, context.abortController.signal, context.messages, initialDescription);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const call: LocalCommandCall = async (_args, context) => {
|
||||
// Collect UUIDs of every message that will be snipped (everything currently
|
||||
// in the conversation). The next call to `snipCompactIfNeeded` will honour
|
||||
// the boundary and strip these from the model-facing view.
|
||||
const removedUuids = messages.map((m) => m.uuid)
|
||||
const removedUuids = messages.map(m => m.uuid)
|
||||
|
||||
const boundaryMessage: Message = {
|
||||
type: 'system',
|
||||
@@ -39,7 +39,7 @@ const call: LocalCommandCall = async (_args, context) => {
|
||||
},
|
||||
} as Message // subtype is feature-gated; cast through Message
|
||||
|
||||
setMessages((prev) => [...prev, boundaryMessage])
|
||||
setMessages(prev => [...prev, boundaryMessage])
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import React from 'react'
|
||||
import { AgentTool } from '@claude-code-best/builtin-tools/tools/AgentTool/AgentTool.js'
|
||||
import { isInForkChild } from '@claude-code-best/builtin-tools/tools/AgentTool/forkSubagent.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import React from 'react';
|
||||
import { AgentTool } from '@claude-code-best/builtin-tools/tools/AgentTool/AgentTool.js';
|
||||
import { isInForkChild } from '@claude-code-best/builtin-tools/tools/AgentTool/forkSubagent.js';
|
||||
import { logForDebugging } from '../../utils/debug.js';
|
||||
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js';
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
@@ -12,30 +12,30 @@ export async function call(
|
||||
): Promise<React.ReactNode> {
|
||||
// Check feature flag
|
||||
if (!feature('FORK_SUBAGENT')) {
|
||||
onDone('Fork subagent feature is not enabled. Set FEATURE_FORK_SUBAGENT=1 to enable.', { display: 'system' })
|
||||
return null
|
||||
onDone('Fork subagent feature is not enabled. Set FEATURE_FORK_SUBAGENT=1 to enable.', { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Recursive fork guard
|
||||
if (isInForkChild(context.messages)) {
|
||||
onDone('Fork is not available inside a forked worker. Complete your task directly using your tools.', { display: 'system' })
|
||||
return null
|
||||
onDone('Fork is not available inside a forked worker. Complete your task directly using your tools.', {
|
||||
display: 'system',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const directive = args.trim()
|
||||
const directive = args.trim();
|
||||
if (!directive) {
|
||||
onDone('Usage: /fork <directive>\nExample: /fork Fix the null check in validate.ts', { display: 'system' })
|
||||
return null
|
||||
onDone('Usage: /fork <directive>\nExample: /fork Fix the null check in validate.ts', { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the last assistant message to fork from
|
||||
const lastAssistantMessage = [...context.messages].reverse().find(
|
||||
m => m.type === 'assistant'
|
||||
) as any // Type assertion to avoid complex type import
|
||||
const lastAssistantMessage = [...context.messages].reverse().find(m => m.type === 'assistant') as any; // Type assertion to avoid complex type import
|
||||
|
||||
if (!lastAssistantMessage) {
|
||||
onDone('Cannot fork: no assistant response in conversation history.', { display: 'system' })
|
||||
return null
|
||||
onDone('Cannot fork: no assistant response in conversation history.', { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -45,29 +45,24 @@ export async function call(
|
||||
prompt: directive,
|
||||
run_in_background: true, // fork always runs async
|
||||
description: `Fork: ${directive.slice(0, 30)}${directive.length > 30 ? '...' : ''}`,
|
||||
}
|
||||
};
|
||||
|
||||
// Call AgentTool with proper parameters:
|
||||
// - input: the agent parameters (no subagent_type => fork path)
|
||||
// - toolUseContext: the current context (ToolUseContext)
|
||||
// - canUseTool: permission-check function from context
|
||||
// - assistantMessage: the last assistant message to fork from
|
||||
AgentTool.call(
|
||||
input,
|
||||
context,
|
||||
context.canUseTool!,
|
||||
lastAssistantMessage
|
||||
).catch(error => {
|
||||
logForDebugging(`Fork subagent async error: ${error}`, { level: 'error' })
|
||||
})
|
||||
AgentTool.call(input, context, context.canUseTool!, lastAssistantMessage).catch(error => {
|
||||
logForDebugging(`Fork subagent async error: ${error}`, { level: 'error' });
|
||||
});
|
||||
|
||||
// Notify user that fork has been started
|
||||
onDone(`Forked subagent started with directive: "${directive}"`, { display: 'system' })
|
||||
return null
|
||||
onDone(`Forked subagent started with directive: "${directive}"`, { display: 'system' });
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Catches synchronous setup errors only
|
||||
logForDebugging(`Fork command setup error: ${error}`, { level: 'error' })
|
||||
onDone(`Fork failed: ${error instanceof Error ? error.message : String(error)}`, { display: 'system' })
|
||||
return null
|
||||
logForDebugging(`Fork command setup error: ${error}`, { level: 'error' });
|
||||
onDone(`Fork failed: ${error instanceof Error ? error.message : String(error)}`, { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import { HelpV2 } from '../../components/HelpV2/HelpV2.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import * as React from 'react';
|
||||
import { HelpV2 } from '../../components/HelpV2/HelpV2.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
export const call: LocalJSXCommandCall = async (
|
||||
onDone,
|
||||
{ options: { commands } },
|
||||
) => {
|
||||
return <HelpV2 commands={commands} onClose={onDone} />
|
||||
}
|
||||
export const call: LocalJSXCommandCall = async (onDone, { options: { commands } }) => {
|
||||
return <HelpV2 commands={commands} onClose={onDone} />;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from 'react'
|
||||
import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import { getTools } from '../../tools.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import * as React from 'react';
|
||||
import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import { getTools } from '../../tools.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
logEvent('tengu_hooks_command', {})
|
||||
const appState = context.getAppState()
|
||||
const permissionContext = appState.toolPermissionContext
|
||||
const toolNames = getTools(permissionContext).map(tool => tool.name)
|
||||
return <HooksConfigMenu toolNames={toolNames} onExit={onDone} />
|
||||
}
|
||||
logEvent('tengu_hooks_command', {});
|
||||
const appState = context.getAppState();
|
||||
const permissionContext = appState.toolPermissionContext;
|
||||
const toolNames = getTools(permissionContext).map(tool => tool.name);
|
||||
return <HooksConfigMenu toolNames={toolNames} onExit={onDone} />;
|
||||
};
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
import chalk from 'chalk'
|
||||
import * as path from 'path'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import type {
|
||||
CommandResultDisplay,
|
||||
LocalJSXCommandContext,
|
||||
} from '../../commands.js'
|
||||
import { Select } from '../../components/CustomSelect/index.js'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
import chalk from 'chalk';
|
||||
import * as path from 'path';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { logEvent } from 'src/services/analytics/index.js';
|
||||
import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js';
|
||||
import { Select } from '../../components/CustomSelect/index.js';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
import {
|
||||
IdeAutoConnectDialog,
|
||||
IdeDisableAutoConnectDialog,
|
||||
shouldShowAutoConnectDialog,
|
||||
shouldShowDisableAutoConnectDialog,
|
||||
} from '../../components/IdeAutoConnectDialog.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { clearServerCache } from '../../services/mcp/client.js'
|
||||
import type { ScopedMcpServerConfig } from '../../services/mcp/types.js'
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
|
||||
} from '../../components/IdeAutoConnectDialog.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { clearServerCache } from '../../services/mcp/client.js';
|
||||
import type { ScopedMcpServerConfig } from '../../services/mcp/types.js';
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js';
|
||||
import { getCwd } from '../../utils/cwd.js';
|
||||
import { execFileNoThrow } from '../../utils/execFileNoThrow.js';
|
||||
import {
|
||||
type DetectedIDEInfo,
|
||||
detectIDEs,
|
||||
@@ -29,16 +26,16 @@ import {
|
||||
isSupportedJetBrainsTerminal,
|
||||
isSupportedTerminal,
|
||||
toIDEDisplayName,
|
||||
} from '../../utils/ide.js'
|
||||
import { getCurrentWorktreeSession } from '../../utils/worktree.js'
|
||||
} from '../../utils/ide.js';
|
||||
import { getCurrentWorktreeSession } from '../../utils/worktree.js';
|
||||
|
||||
type IDEScreenProps = {
|
||||
availableIDEs: DetectedIDEInfo[]
|
||||
unavailableIDEs: DetectedIDEInfo[]
|
||||
selectedIDE?: DetectedIDEInfo | null
|
||||
onClose: () => void
|
||||
onSelect: (ide?: DetectedIDEInfo) => void
|
||||
}
|
||||
availableIDEs: DetectedIDEInfo[];
|
||||
unavailableIDEs: DetectedIDEInfo[];
|
||||
selectedIDE?: DetectedIDEInfo | null;
|
||||
onClose: () => void;
|
||||
onSelect: (ide?: DetectedIDEInfo) => void;
|
||||
};
|
||||
|
||||
function IDEScreen({
|
||||
availableIDEs,
|
||||
@@ -47,51 +44,43 @@ function IDEScreen({
|
||||
onClose,
|
||||
onSelect,
|
||||
}: IDEScreenProps): React.ReactNode {
|
||||
const [selectedValue, setSelectedValue] = useState(
|
||||
selectedIDE?.port?.toString() ?? 'None',
|
||||
)
|
||||
const [showAutoConnectDialog, setShowAutoConnectDialog] = useState(false)
|
||||
const [showDisableAutoConnectDialog, setShowDisableAutoConnectDialog] =
|
||||
useState(false)
|
||||
const [selectedValue, setSelectedValue] = useState(selectedIDE?.port?.toString() ?? 'None');
|
||||
const [showAutoConnectDialog, setShowAutoConnectDialog] = useState(false);
|
||||
const [showDisableAutoConnectDialog, setShowDisableAutoConnectDialog] = useState(false);
|
||||
|
||||
const handleSelectIDE = useCallback(
|
||||
(value: string) => {
|
||||
if (value !== 'None' && shouldShowAutoConnectDialog()) {
|
||||
setShowAutoConnectDialog(true)
|
||||
setShowAutoConnectDialog(true);
|
||||
} else if (value === 'None' && shouldShowDisableAutoConnectDialog()) {
|
||||
setShowDisableAutoConnectDialog(true)
|
||||
setShowDisableAutoConnectDialog(true);
|
||||
} else {
|
||||
onSelect(availableIDEs.find(ide => ide.port === parseInt(value, 10)))
|
||||
onSelect(availableIDEs.find(ide => ide.port === parseInt(value, 10)));
|
||||
}
|
||||
},
|
||||
[availableIDEs, onSelect],
|
||||
)
|
||||
);
|
||||
|
||||
const ideCounts = availableIDEs.reduce<Record<string, number>>((acc, ide) => {
|
||||
acc[ide.name] = (acc[ide.name] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
acc[ide.name] = (acc[ide.name] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const options = availableIDEs
|
||||
.map(ide => {
|
||||
const hasMultipleInstances = (ideCounts[ide.name] || 0) > 1
|
||||
const showWorkspace =
|
||||
hasMultipleInstances && ide.workspaceFolders.length > 0
|
||||
const hasMultipleInstances = (ideCounts[ide.name] || 0) > 1;
|
||||
const showWorkspace = hasMultipleInstances && ide.workspaceFolders.length > 0;
|
||||
|
||||
return {
|
||||
label: ide.name,
|
||||
value: ide.port.toString(),
|
||||
description: showWorkspace
|
||||
? formatWorkspaceFolders(ide.workspaceFolders)
|
||||
: undefined,
|
||||
}
|
||||
description: showWorkspace ? formatWorkspaceFolders(ide.workspaceFolders) : undefined,
|
||||
};
|
||||
})
|
||||
.concat([{ label: 'None', value: 'None', description: undefined }])
|
||||
.concat([{ label: 'None', value: 'None', description: undefined }]);
|
||||
|
||||
if (showAutoConnectDialog) {
|
||||
return (
|
||||
<IdeAutoConnectDialog onComplete={() => handleSelectIDE(selectedValue)} />
|
||||
)
|
||||
return <IdeAutoConnectDialog onComplete={() => handleSelectIDE(selectedValue)} />;
|
||||
}
|
||||
|
||||
if (showDisableAutoConnectDialog) {
|
||||
@@ -100,10 +89,10 @@ function IDEScreen({
|
||||
onComplete={() => {
|
||||
// Always disconnect when user selects "None", regardless of their
|
||||
// choice about disabling auto-connect
|
||||
onSelect(undefined)
|
||||
onSelect(undefined);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -129,36 +118,28 @@ function IDEScreen({
|
||||
defaultFocusValue={selectedValue}
|
||||
options={options}
|
||||
onChange={value => {
|
||||
setSelectedValue(value)
|
||||
handleSelectIDE(value)
|
||||
setSelectedValue(value);
|
||||
handleSelectIDE(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{availableIDEs.length !== 0 &&
|
||||
availableIDEs.some(
|
||||
ide => ide.name === 'VS Code' || ide.name === 'Visual Studio Code',
|
||||
) && (
|
||||
availableIDEs.some(ide => ide.name === 'VS Code' || ide.name === 'Visual Studio Code') && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="warning">
|
||||
Note: Only one Claude Code instance can be connected to VS Code
|
||||
at a time.
|
||||
</Text>
|
||||
<Text color="warning">Note: Only one Claude Code instance can be connected to VS Code at a time.</Text>
|
||||
</Box>
|
||||
)}
|
||||
{availableIDEs.length !== 0 && !isSupportedTerminal() && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Tip: You can enable auto-connect to IDE in /config or with the
|
||||
--ide flag
|
||||
</Text>
|
||||
<Text dimColor>Tip: You can enable auto-connect to IDE in /config or with the --ide flag</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{unavailableIDEs.length > 0 && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text dimColor>
|
||||
Found {unavailableIDEs.length} other running IDE(s). However,
|
||||
their workspace/project directories do not match the current cwd.
|
||||
Found {unavailableIDEs.length} other running IDE(s). However, their workspace/project directories do not
|
||||
match the current cwd.
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{unavailableIDEs.map((ide, index) => (
|
||||
@@ -173,82 +154,64 @@ function IDEScreen({
|
||||
)}
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function findCurrentIDE(
|
||||
availableIDEs: DetectedIDEInfo[],
|
||||
dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>,
|
||||
): Promise<DetectedIDEInfo | null> {
|
||||
const currentConfig = dynamicMcpConfig?.ide
|
||||
if (
|
||||
!currentConfig ||
|
||||
(currentConfig.type !== 'sse-ide' && currentConfig.type !== 'ws-ide')
|
||||
) {
|
||||
return null
|
||||
const currentConfig = dynamicMcpConfig?.ide;
|
||||
if (!currentConfig || (currentConfig.type !== 'sse-ide' && currentConfig.type !== 'ws-ide')) {
|
||||
return null;
|
||||
}
|
||||
for (const ide of availableIDEs) {
|
||||
if (ide.url === currentConfig.url) {
|
||||
return ide
|
||||
return ide;
|
||||
}
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
type IDEOpenSelectionProps = {
|
||||
availableIDEs: DetectedIDEInfo[]
|
||||
onSelectIDE: (ide?: DetectedIDEInfo) => void
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
}
|
||||
availableIDEs: DetectedIDEInfo[];
|
||||
onSelectIDE: (ide?: DetectedIDEInfo) => void;
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
};
|
||||
|
||||
function IDEOpenSelection({
|
||||
availableIDEs,
|
||||
onSelectIDE,
|
||||
onDone,
|
||||
}: IDEOpenSelectionProps): React.ReactNode {
|
||||
const [selectedValue, setSelectedValue] = useState(
|
||||
availableIDEs[0]?.port?.toString() ?? '',
|
||||
)
|
||||
function IDEOpenSelection({ availableIDEs, onSelectIDE, onDone }: IDEOpenSelectionProps): React.ReactNode {
|
||||
const [selectedValue, setSelectedValue] = useState(availableIDEs[0]?.port?.toString() ?? '');
|
||||
|
||||
const handleSelectIDE = useCallback(
|
||||
(value: string) => {
|
||||
const selectedIDE = availableIDEs.find(
|
||||
ide => ide.port === parseInt(value, 10),
|
||||
)
|
||||
onSelectIDE(selectedIDE)
|
||||
const selectedIDE = availableIDEs.find(ide => ide.port === parseInt(value, 10));
|
||||
onSelectIDE(selectedIDE);
|
||||
},
|
||||
[availableIDEs, onSelectIDE],
|
||||
)
|
||||
);
|
||||
|
||||
const options = availableIDEs.map(ide => ({
|
||||
label: ide.name,
|
||||
value: ide.port.toString(),
|
||||
}))
|
||||
}));
|
||||
|
||||
function handleCancel(): void {
|
||||
onDone('IDE selection cancelled', { display: 'system' })
|
||||
onDone('IDE selection cancelled', { display: 'system' });
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Select an IDE to open the project"
|
||||
onCancel={handleCancel}
|
||||
color="ide"
|
||||
>
|
||||
<Dialog title="Select an IDE to open the project" onCancel={handleCancel} color="ide">
|
||||
<Select
|
||||
defaultValue={selectedValue}
|
||||
defaultFocusValue={selectedValue}
|
||||
options={options}
|
||||
onChange={value => {
|
||||
setSelectedValue(value)
|
||||
handleSelectIDE(value)
|
||||
setSelectedValue(value);
|
||||
handleSelectIDE(value);
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function RunningIDESelector({
|
||||
@@ -256,88 +219,72 @@ function RunningIDESelector({
|
||||
onSelectIDE,
|
||||
onDone,
|
||||
}: {
|
||||
runningIDEs: IdeType[]
|
||||
onSelectIDE: (ide: IdeType) => void
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
runningIDEs: IdeType[];
|
||||
onSelectIDE: (ide: IdeType) => void;
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
}): React.ReactNode {
|
||||
const [selectedValue, setSelectedValue] = useState(runningIDEs[0] ?? '')
|
||||
const [selectedValue, setSelectedValue] = useState(runningIDEs[0] ?? '');
|
||||
|
||||
const handleSelectIDE = useCallback(
|
||||
(value: string) => {
|
||||
onSelectIDE(value as IdeType)
|
||||
onSelectIDE(value as IdeType);
|
||||
},
|
||||
[onSelectIDE],
|
||||
)
|
||||
);
|
||||
|
||||
const options = runningIDEs.map(ide => ({
|
||||
label: toIDEDisplayName(ide),
|
||||
value: ide,
|
||||
}))
|
||||
}));
|
||||
|
||||
function handleCancel(): void {
|
||||
onDone('IDE selection cancelled', { display: 'system' })
|
||||
onDone('IDE selection cancelled', { display: 'system' });
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Select IDE to install extension"
|
||||
onCancel={handleCancel}
|
||||
color="ide"
|
||||
>
|
||||
<Dialog title="Select IDE to install extension" onCancel={handleCancel} color="ide">
|
||||
<Select
|
||||
defaultFocusValue={selectedValue}
|
||||
options={options}
|
||||
onChange={value => {
|
||||
setSelectedValue(value)
|
||||
handleSelectIDE(value)
|
||||
setSelectedValue(value);
|
||||
handleSelectIDE(value);
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InstallOnMount({
|
||||
ide,
|
||||
onInstall,
|
||||
}: {
|
||||
ide: IdeType
|
||||
onInstall: (ide: IdeType) => void
|
||||
}): React.ReactNode {
|
||||
function InstallOnMount({ ide, onInstall }: { ide: IdeType; onInstall: (ide: IdeType) => void }): React.ReactNode {
|
||||
useEffect(() => {
|
||||
onInstall(ide)
|
||||
}, [ide, onInstall])
|
||||
return null
|
||||
onInstall(ide);
|
||||
}, [ide, onInstall]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void,
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void,
|
||||
context: LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode | null> {
|
||||
logEvent('tengu_ext_ide_command', {})
|
||||
logEvent('tengu_ext_ide_command', {});
|
||||
const {
|
||||
options: { dynamicMcpConfig },
|
||||
onChangeDynamicMcpConfig,
|
||||
} = context
|
||||
} = context;
|
||||
|
||||
// Handle 'open' argument
|
||||
if (args?.trim() === 'open') {
|
||||
const worktreeSession = getCurrentWorktreeSession()
|
||||
const targetPath = worktreeSession ? worktreeSession.worktreePath : getCwd()
|
||||
const worktreeSession = getCurrentWorktreeSession();
|
||||
const targetPath = worktreeSession ? worktreeSession.worktreePath : getCwd();
|
||||
|
||||
// Detect available IDEs
|
||||
const detectedIDEs = await detectIDEs(true)
|
||||
const availableIDEs = detectedIDEs.filter(ide => ide.isValid)
|
||||
const detectedIDEs = await detectIDEs(true);
|
||||
const availableIDEs = detectedIDEs.filter(ide => ide.isValid);
|
||||
|
||||
if (availableIDEs.length === 0) {
|
||||
onDone('No IDEs with Claude Code extension detected.')
|
||||
return null
|
||||
onDone('No IDEs with Claude Code extension detected.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return IDE selection component
|
||||
@@ -346,8 +293,8 @@ export async function call(
|
||||
availableIDEs={availableIDEs}
|
||||
onSelectIDE={async (selectedIDE?: DetectedIDEInfo) => {
|
||||
if (!selectedIDE) {
|
||||
onDone('No IDE selected.')
|
||||
return
|
||||
onDone('No IDE selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to open the project in the selected IDE
|
||||
@@ -357,58 +304,50 @@ export async function call(
|
||||
selectedIDE.name.toLowerCase().includes('windsurf')
|
||||
) {
|
||||
// VS Code-based IDEs
|
||||
const { code } = await execFileNoThrow('code', [targetPath])
|
||||
const { code } = await execFileNoThrow('code', [targetPath]);
|
||||
if (code === 0) {
|
||||
onDone(
|
||||
`Opened ${worktreeSession ? 'worktree' : 'project'} in ${chalk.bold(selectedIDE.name)}`,
|
||||
)
|
||||
onDone(`Opened ${worktreeSession ? 'worktree' : 'project'} in ${chalk.bold(selectedIDE.name)}`);
|
||||
} else {
|
||||
onDone(
|
||||
`Failed to open in ${selectedIDE.name}. Try opening manually: ${targetPath}`,
|
||||
)
|
||||
onDone(`Failed to open in ${selectedIDE.name}. Try opening manually: ${targetPath}`);
|
||||
}
|
||||
} else if (isSupportedJetBrainsTerminal()) {
|
||||
// JetBrains IDEs - they usually open via their CLI tools
|
||||
onDone(
|
||||
`Please open the ${worktreeSession ? 'worktree' : 'project'} manually in ${chalk.bold(selectedIDE.name)}: ${targetPath}`,
|
||||
)
|
||||
);
|
||||
} else {
|
||||
onDone(
|
||||
`Please open the ${worktreeSession ? 'worktree' : 'project'} manually in ${chalk.bold(selectedIDE.name)}: ${targetPath}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
onDone={() => {
|
||||
onDone('Exited without opening IDE', { display: 'system' })
|
||||
onDone('Exited without opening IDE', { display: 'system' });
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const detectedIDEs = await detectIDEs(true)
|
||||
const detectedIDEs = await detectIDEs(true);
|
||||
|
||||
// If no IDEs with extensions detected, check for running IDEs and offer to install
|
||||
if (
|
||||
detectedIDEs.length === 0 &&
|
||||
context.onInstallIDEExtension &&
|
||||
!isSupportedTerminal()
|
||||
) {
|
||||
const runningIDEs = await detectRunningIDEs()
|
||||
if (detectedIDEs.length === 0 && context.onInstallIDEExtension && !isSupportedTerminal()) {
|
||||
const runningIDEs = await detectRunningIDEs();
|
||||
|
||||
const onInstall = (ide: IdeType) => {
|
||||
if (context.onInstallIDEExtension) {
|
||||
context.onInstallIDEExtension(ide)
|
||||
context.onInstallIDEExtension(ide);
|
||||
// The completion message will be shown after installation
|
||||
if (isJetBrainsIde(ide)) {
|
||||
onDone(
|
||||
`Installed plugin to ${chalk.bold(toIDEDisplayName(ide))}\n` +
|
||||
`Please ${chalk.bold('restart your IDE')} completely for it to take effect`,
|
||||
)
|
||||
);
|
||||
} else {
|
||||
onDone(`Installed extension to ${chalk.bold(toIDEDisplayName(ide))}`)
|
||||
onDone(`Installed extension to ${chalk.bold(toIDEDisplayName(ide))}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (runningIDEs.length > 1) {
|
||||
// Show selector when multiple IDEs are running
|
||||
@@ -417,19 +356,19 @@ export async function call(
|
||||
runningIDEs={runningIDEs}
|
||||
onSelectIDE={onInstall}
|
||||
onDone={() => {
|
||||
onDone('No IDE selected.', { display: 'system' })
|
||||
onDone('No IDE selected.', { display: 'system' });
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
} else if (runningIDEs.length === 1) {
|
||||
return <InstallOnMount ide={runningIDEs[0]!} onInstall={onInstall} />
|
||||
return <InstallOnMount ide={runningIDEs[0]!} onInstall={onInstall} />;
|
||||
}
|
||||
}
|
||||
|
||||
const availableIDEs = detectedIDEs.filter(ide => ide.isValid)
|
||||
const unavailableIDEs = detectedIDEs.filter(ide => !ide.isValid)
|
||||
const availableIDEs = detectedIDEs.filter(ide => ide.isValid);
|
||||
const unavailableIDEs = detectedIDEs.filter(ide => !ide.isValid);
|
||||
|
||||
const currentIDE = await findCurrentIDE(availableIDEs, dynamicMcpConfig)
|
||||
const currentIDE = await findCurrentIDE(availableIDEs, dynamicMcpConfig);
|
||||
|
||||
return (
|
||||
<IDECommandFlow
|
||||
@@ -440,25 +379,20 @@ export async function call(
|
||||
onChangeDynamicMcpConfig={onChangeDynamicMcpConfig}
|
||||
onDone={onDone}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Connection timeout slightly longer than the 30s MCP connection timeout
|
||||
const IDE_CONNECTION_TIMEOUT_MS = 35000
|
||||
const IDE_CONNECTION_TIMEOUT_MS = 35000;
|
||||
|
||||
type IDECommandFlowProps = {
|
||||
availableIDEs: DetectedIDEInfo[]
|
||||
unavailableIDEs: DetectedIDEInfo[]
|
||||
currentIDE: DetectedIDEInfo | null
|
||||
dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>
|
||||
onChangeDynamicMcpConfig?: (
|
||||
config: Record<string, ScopedMcpServerConfig>,
|
||||
) => void
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
}
|
||||
availableIDEs: DetectedIDEInfo[];
|
||||
unavailableIDEs: DetectedIDEInfo[];
|
||||
currentIDE: DetectedIDEInfo | null;
|
||||
dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>;
|
||||
onChangeDynamicMcpConfig?: (config: Record<string, ScopedMcpServerConfig>) => void;
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
};
|
||||
|
||||
function IDECommandFlow({
|
||||
availableIDEs,
|
||||
@@ -468,80 +402,66 @@ function IDECommandFlow({
|
||||
onChangeDynamicMcpConfig,
|
||||
onDone,
|
||||
}: IDECommandFlowProps): React.ReactNode {
|
||||
const [connectingIDE, setConnectingIDE] = useState<DetectedIDEInfo | null>(
|
||||
null,
|
||||
)
|
||||
const ideClient = useAppState(s => s.mcp.clients.find(c => c.name === 'ide'))
|
||||
const setAppState = useSetAppState()
|
||||
const isFirstCheckRef = useRef(true)
|
||||
const [connectingIDE, setConnectingIDE] = useState<DetectedIDEInfo | null>(null);
|
||||
const ideClient = useAppState(s => s.mcp.clients.find(c => c.name === 'ide'));
|
||||
const setAppState = useSetAppState();
|
||||
const isFirstCheckRef = useRef(true);
|
||||
|
||||
// Watch for connection result
|
||||
useEffect(() => {
|
||||
if (!connectingIDE) return
|
||||
if (!connectingIDE) return;
|
||||
// Skip the first check — it reflects stale state from before the
|
||||
// config change was dispatched
|
||||
if (isFirstCheckRef.current) {
|
||||
isFirstCheckRef.current = false
|
||||
return
|
||||
isFirstCheckRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!ideClient || ideClient.type === 'pending') return
|
||||
if (!ideClient || ideClient.type === 'pending') return;
|
||||
if (ideClient.type === 'connected') {
|
||||
onDone(`Connected to ${connectingIDE.name}.`)
|
||||
onDone(`Connected to ${connectingIDE.name}.`);
|
||||
} else if (ideClient.type === 'failed') {
|
||||
onDone(`Failed to connect to ${connectingIDE.name}.`)
|
||||
onDone(`Failed to connect to ${connectingIDE.name}.`);
|
||||
}
|
||||
}, [ideClient, connectingIDE, onDone])
|
||||
}, [ideClient, connectingIDE, onDone]);
|
||||
|
||||
// Timeout fallback
|
||||
useEffect(() => {
|
||||
if (!connectingIDE) return
|
||||
const timer = setTimeout(
|
||||
onDone,
|
||||
IDE_CONNECTION_TIMEOUT_MS,
|
||||
`Connection to ${connectingIDE.name} timed out.`,
|
||||
)
|
||||
return () => clearTimeout(timer)
|
||||
}, [connectingIDE, onDone])
|
||||
if (!connectingIDE) return;
|
||||
const timer = setTimeout(onDone, IDE_CONNECTION_TIMEOUT_MS, `Connection to ${connectingIDE.name} timed out.`);
|
||||
return () => clearTimeout(timer);
|
||||
}, [connectingIDE, onDone]);
|
||||
|
||||
const handleSelectIDE = useCallback(
|
||||
(selectedIDE?: DetectedIDEInfo) => {
|
||||
if (!onChangeDynamicMcpConfig) {
|
||||
onDone('Error connecting to IDE.')
|
||||
return
|
||||
onDone('Error connecting to IDE.');
|
||||
return;
|
||||
}
|
||||
const newConfig = { ...(dynamicMcpConfig || {}) }
|
||||
const newConfig = { ...(dynamicMcpConfig || {}) };
|
||||
if (currentIDE) {
|
||||
delete newConfig.ide
|
||||
delete newConfig.ide;
|
||||
}
|
||||
if (!selectedIDE) {
|
||||
// Close the MCP transport and remove the client from state
|
||||
if (ideClient && ideClient.type === 'connected' && currentIDE) {
|
||||
// Null out onclose to prevent auto-reconnection
|
||||
ideClient.client.onclose = () => {}
|
||||
void clearServerCache('ide', ideClient.config)
|
||||
ideClient.client.onclose = () => {};
|
||||
void clearServerCache('ide', ideClient.config);
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
mcp: {
|
||||
...prev.mcp,
|
||||
clients: prev.mcp.clients.filter(c => c.name !== 'ide'),
|
||||
tools: prev.mcp.tools.filter(
|
||||
t => !t.name?.startsWith('mcp__ide__'),
|
||||
),
|
||||
commands: prev.mcp.commands.filter(
|
||||
c => !c.name?.startsWith('mcp__ide__'),
|
||||
),
|
||||
tools: prev.mcp.tools.filter(t => !t.name?.startsWith('mcp__ide__')),
|
||||
commands: prev.mcp.commands.filter(c => !c.name?.startsWith('mcp__ide__')),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
}
|
||||
onChangeDynamicMcpConfig(newConfig)
|
||||
onDone(
|
||||
currentIDE
|
||||
? `Disconnected from ${currentIDE.name}.`
|
||||
: 'No IDE selected.',
|
||||
)
|
||||
return
|
||||
onChangeDynamicMcpConfig(newConfig);
|
||||
onDone(currentIDE ? `Disconnected from ${currentIDE.name}.` : 'No IDE selected.');
|
||||
return;
|
||||
}
|
||||
const url = selectedIDE.url
|
||||
const url = selectedIDE.url;
|
||||
newConfig.ide = {
|
||||
type: url.startsWith('ws:') ? 'ws-ide' : 'sse-ide',
|
||||
url: url,
|
||||
@@ -549,23 +469,16 @@ function IDECommandFlow({
|
||||
authToken: selectedIDE.authToken,
|
||||
ideRunningInWindows: selectedIDE.ideRunningInWindows,
|
||||
scope: 'dynamic' as const,
|
||||
} as ScopedMcpServerConfig
|
||||
isFirstCheckRef.current = true
|
||||
setConnectingIDE(selectedIDE)
|
||||
onChangeDynamicMcpConfig(newConfig)
|
||||
} as ScopedMcpServerConfig;
|
||||
isFirstCheckRef.current = true;
|
||||
setConnectingIDE(selectedIDE);
|
||||
onChangeDynamicMcpConfig(newConfig);
|
||||
},
|
||||
[
|
||||
dynamicMcpConfig,
|
||||
currentIDE,
|
||||
ideClient,
|
||||
setAppState,
|
||||
onChangeDynamicMcpConfig,
|
||||
onDone,
|
||||
],
|
||||
)
|
||||
[dynamicMcpConfig, currentIDE, ideClient, setAppState, onChangeDynamicMcpConfig, onDone],
|
||||
);
|
||||
|
||||
if (connectingIDE) {
|
||||
return <Text dimColor>Connecting to {connectingIDE.name}…</Text>
|
||||
return <Text dimColor>Connecting to {connectingIDE.name}…</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -576,7 +489,7 @@ function IDECommandFlow({
|
||||
onClose={() => onDone('IDE selection cancelled', { display: 'system' })}
|
||||
onSelect={handleSelectIDE}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -585,46 +498,43 @@ function IDECommandFlow({
|
||||
* @param maxLength Maximum total length of the formatted string
|
||||
* @returns Formatted string with folder paths
|
||||
*/
|
||||
export function formatWorkspaceFolders(
|
||||
folders: string[],
|
||||
maxLength: number = 100,
|
||||
): string {
|
||||
if (folders.length === 0) return ''
|
||||
export function formatWorkspaceFolders(folders: string[], maxLength: number = 100): string {
|
||||
if (folders.length === 0) return '';
|
||||
|
||||
const cwd = getCwd()
|
||||
const cwd = getCwd();
|
||||
|
||||
// Only show first 2 workspaces
|
||||
const foldersToShow = folders.slice(0, 2)
|
||||
const hasMore = folders.length > 2
|
||||
const foldersToShow = folders.slice(0, 2);
|
||||
const hasMore = folders.length > 2;
|
||||
|
||||
// Account for ", …" if there are more folders
|
||||
const ellipsisOverhead = hasMore ? 3 : 0 // ", …"
|
||||
const ellipsisOverhead = hasMore ? 3 : 0; // ", …"
|
||||
|
||||
// Account for commas and spaces between paths (", " = 2 chars per separator)
|
||||
const separatorOverhead = (foldersToShow.length - 1) * 2
|
||||
const availableLength = maxLength - separatorOverhead - ellipsisOverhead
|
||||
const separatorOverhead = (foldersToShow.length - 1) * 2;
|
||||
const availableLength = maxLength - separatorOverhead - ellipsisOverhead;
|
||||
|
||||
const maxLengthPerPath = Math.floor(availableLength / foldersToShow.length)
|
||||
const maxLengthPerPath = Math.floor(availableLength / foldersToShow.length);
|
||||
|
||||
const cwdNFC = cwd.normalize('NFC')
|
||||
const cwdNFC = cwd.normalize('NFC');
|
||||
const formattedFolders = foldersToShow.map(folder => {
|
||||
// Strip cwd from the beginning if present
|
||||
// Normalize both to NFC for consistent comparison (macOS uses NFD paths)
|
||||
const folderNFC = folder.normalize('NFC')
|
||||
const folderNFC = folder.normalize('NFC');
|
||||
if (folderNFC.startsWith(cwdNFC + path.sep)) {
|
||||
folder = folderNFC.slice(cwdNFC.length + 1)
|
||||
folder = folderNFC.slice(cwdNFC.length + 1);
|
||||
}
|
||||
|
||||
if (folder.length <= maxLengthPerPath) {
|
||||
return folder
|
||||
return folder;
|
||||
}
|
||||
return '…' + folder.slice(-(maxLengthPerPath - 1))
|
||||
})
|
||||
return '…' + folder.slice(-(maxLengthPerPath - 1));
|
||||
});
|
||||
|
||||
let result = formattedFolders.join(', ')
|
||||
let result = formattedFolders.join(', ');
|
||||
if (hasMore) {
|
||||
result += ', …'
|
||||
result += ', …';
|
||||
}
|
||||
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -895,7 +895,9 @@ async function summarizeTranscriptChunk(chunk: string): Promise<string> {
|
||||
},
|
||||
})
|
||||
|
||||
const text = extractTextContent(result.message.content as readonly { readonly type: string }[])
|
||||
const text = extractTextContent(
|
||||
result.message.content as readonly { readonly type: string }[],
|
||||
)
|
||||
return text || chunk.slice(0, 2000)
|
||||
} catch {
|
||||
// On error, just return truncated chunk
|
||||
@@ -1038,7 +1040,9 @@ RESPOND WITH ONLY A VALID JSON OBJECT matching this schema:
|
||||
},
|
||||
})
|
||||
|
||||
const text = extractTextContent(result.message.content as readonly { readonly type: string }[])
|
||||
const text = extractTextContent(
|
||||
result.message.content as readonly { readonly type: string }[],
|
||||
)
|
||||
|
||||
// Parse JSON from response
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/)
|
||||
@@ -1589,7 +1593,9 @@ async function generateSectionInsight(
|
||||
},
|
||||
})
|
||||
|
||||
const text = extractTextContent(result.message.content as readonly { readonly type: string }[])
|
||||
const text = extractTextContent(
|
||||
result.message.content as readonly { readonly type: string }[],
|
||||
)
|
||||
|
||||
if (text) {
|
||||
// Parse JSON from response
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import TextInput from '../../components/TextInput.js'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { Box, color, Text, useTheme } from '@anthropic/ink'
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, color, Text, useTheme } from '@anthropic/ink';
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
|
||||
interface ApiKeyStepProps {
|
||||
existingApiKey: string | null
|
||||
useExistingKey: boolean
|
||||
apiKeyOrOAuthToken: string
|
||||
onApiKeyChange: (value: string) => void
|
||||
onToggleUseExistingKey: (useExisting: boolean) => void
|
||||
onSubmit: () => void
|
||||
onCreateOAuthToken?: () => void
|
||||
selectedOption?: 'existing' | 'new' | 'oauth'
|
||||
onSelectOption?: (option: 'existing' | 'new' | 'oauth') => void
|
||||
existingApiKey: string | null;
|
||||
useExistingKey: boolean;
|
||||
apiKeyOrOAuthToken: string;
|
||||
onApiKeyChange: (value: string) => void;
|
||||
onToggleUseExistingKey: (useExisting: boolean) => void;
|
||||
onSubmit: () => void;
|
||||
onCreateOAuthToken?: () => void;
|
||||
selectedOption?: 'existing' | 'new' | 'oauth';
|
||||
onSelectOption?: (option: 'existing' | 'new' | 'oauth') => void;
|
||||
}
|
||||
|
||||
export function ApiKeyStep({
|
||||
@@ -23,62 +23,47 @@ export function ApiKeyStep({
|
||||
onSubmit,
|
||||
onToggleUseExistingKey,
|
||||
onCreateOAuthToken,
|
||||
selectedOption = existingApiKey
|
||||
? 'existing'
|
||||
: onCreateOAuthToken
|
||||
? 'oauth'
|
||||
: 'new',
|
||||
selectedOption = existingApiKey ? 'existing' : onCreateOAuthToken ? 'oauth' : 'new',
|
||||
onSelectOption,
|
||||
}: ApiKeyStepProps) {
|
||||
const [cursorOffset, setCursorOffset] = useState(0)
|
||||
const terminalSize = useTerminalSize()
|
||||
const [theme] = useTheme()
|
||||
const [cursorOffset, setCursorOffset] = useState(0);
|
||||
const terminalSize = useTerminalSize();
|
||||
const [theme] = useTheme();
|
||||
|
||||
const handlePrevious = useCallback(() => {
|
||||
if (selectedOption === 'new' && onCreateOAuthToken) {
|
||||
// From 'new' go up to 'oauth'
|
||||
onSelectOption?.('oauth')
|
||||
onSelectOption?.('oauth');
|
||||
} else if (selectedOption === 'oauth' && existingApiKey) {
|
||||
// From 'oauth' go up to 'existing' (only if it exists)
|
||||
onSelectOption?.('existing')
|
||||
onToggleUseExistingKey(true)
|
||||
onSelectOption?.('existing');
|
||||
onToggleUseExistingKey(true);
|
||||
}
|
||||
}, [
|
||||
selectedOption,
|
||||
onCreateOAuthToken,
|
||||
existingApiKey,
|
||||
onSelectOption,
|
||||
onToggleUseExistingKey,
|
||||
])
|
||||
}, [selectedOption, onCreateOAuthToken, existingApiKey, onSelectOption, onToggleUseExistingKey]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (selectedOption === 'existing') {
|
||||
// From 'existing' go down to 'oauth' (if available) or 'new'
|
||||
onSelectOption?.(onCreateOAuthToken ? 'oauth' : 'new')
|
||||
onToggleUseExistingKey(false)
|
||||
onSelectOption?.(onCreateOAuthToken ? 'oauth' : 'new');
|
||||
onToggleUseExistingKey(false);
|
||||
} else if (selectedOption === 'oauth') {
|
||||
// From 'oauth' go down to 'new'
|
||||
onSelectOption?.('new')
|
||||
onSelectOption?.('new');
|
||||
}
|
||||
}, [
|
||||
selectedOption,
|
||||
onCreateOAuthToken,
|
||||
onSelectOption,
|
||||
onToggleUseExistingKey,
|
||||
])
|
||||
}, [selectedOption, onCreateOAuthToken, onSelectOption, onToggleUseExistingKey]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (selectedOption === 'oauth' && onCreateOAuthToken) {
|
||||
onCreateOAuthToken()
|
||||
onCreateOAuthToken();
|
||||
} else {
|
||||
onSubmit()
|
||||
onSubmit();
|
||||
}
|
||||
}, [selectedOption, onCreateOAuthToken, onSubmit])
|
||||
}, [selectedOption, onCreateOAuthToken, onSubmit]);
|
||||
|
||||
// When the text input is visible, omit confirm:yes so bare 'y' passes
|
||||
// through to the input instead of submitting. TextInput's onSubmit handles
|
||||
// Enter. Keep the Confirmation context (not Settings) to avoid j/k bindings.
|
||||
const isTextInputVisible = selectedOption === 'new'
|
||||
const isTextInputVisible = selectedOption === 'new';
|
||||
useKeybindings(
|
||||
{
|
||||
'confirm:previous': handlePrevious,
|
||||
@@ -86,14 +71,14 @@ export function ApiKeyStep({
|
||||
'confirm:yes': handleConfirm,
|
||||
},
|
||||
{ context: 'Confirmation', isActive: !isTextInputVisible },
|
||||
)
|
||||
);
|
||||
useKeybindings(
|
||||
{
|
||||
'confirm:previous': handlePrevious,
|
||||
'confirm:next': handleNext,
|
||||
},
|
||||
{ context: 'Confirmation', isActive: isTextInputVisible },
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -105,9 +90,7 @@ export function ApiKeyStep({
|
||||
{existingApiKey && (
|
||||
<Box marginBottom={1}>
|
||||
<Text>
|
||||
{selectedOption === 'existing'
|
||||
? color('success', theme)('> ')
|
||||
: ' '}
|
||||
{selectedOption === 'existing' ? color('success', theme)('> ') : ' '}
|
||||
Use your existing Claude Code API key
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -115,9 +98,7 @@ export function ApiKeyStep({
|
||||
{onCreateOAuthToken && (
|
||||
<Box marginBottom={1}>
|
||||
<Text>
|
||||
{selectedOption === 'oauth'
|
||||
? color('success', theme)('> ')
|
||||
: ' '}
|
||||
{selectedOption === 'oauth' ? color('success', theme)('> ') : ' '}
|
||||
Create a long-lived token with your Claude subscription
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -148,5 +129,5 @@ export function ApiKeyStep({
|
||||
<Text dimColor>↑/↓ to select · Enter to continue</Text>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import TextInput from '../../components/TextInput.js'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { Box, color, Text, useTheme } from '@anthropic/ink'
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, color, Text, useTheme } from '@anthropic/ink';
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
|
||||
interface CheckExistingSecretStepProps {
|
||||
useExistingSecret: boolean
|
||||
secretName: string
|
||||
onToggleUseExistingSecret: (useExisting: boolean) => void
|
||||
onSecretNameChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
useExistingSecret: boolean;
|
||||
secretName: string;
|
||||
onToggleUseExistingSecret: (useExisting: boolean) => void;
|
||||
onSecretNameChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export function CheckExistingSecretStep({
|
||||
@@ -19,21 +19,15 @@ export function CheckExistingSecretStep({
|
||||
onSecretNameChange,
|
||||
onSubmit,
|
||||
}: CheckExistingSecretStepProps) {
|
||||
const [cursorOffset, setCursorOffset] = useState(0)
|
||||
const terminalSize = useTerminalSize()
|
||||
const [theme] = useTheme()
|
||||
const [cursorOffset, setCursorOffset] = useState(0);
|
||||
const terminalSize = useTerminalSize();
|
||||
const [theme] = useTheme();
|
||||
|
||||
// When the text input is visible, omit confirm:yes so bare 'y' passes
|
||||
// through to the input instead of submitting. TextInput's onSubmit handles
|
||||
// Enter. Keep the Confirmation context (not Settings) to avoid j/k bindings.
|
||||
const handlePrevious = useCallback(
|
||||
() => onToggleUseExistingSecret(true),
|
||||
[onToggleUseExistingSecret],
|
||||
)
|
||||
const handleNext = useCallback(
|
||||
() => onToggleUseExistingSecret(false),
|
||||
[onToggleUseExistingSecret],
|
||||
)
|
||||
const handlePrevious = useCallback(() => onToggleUseExistingSecret(true), [onToggleUseExistingSecret]);
|
||||
const handleNext = useCallback(() => onToggleUseExistingSecret(false), [onToggleUseExistingSecret]);
|
||||
useKeybindings(
|
||||
{
|
||||
'confirm:previous': handlePrevious,
|
||||
@@ -41,14 +35,14 @@ export function CheckExistingSecretStep({
|
||||
'confirm:yes': onSubmit,
|
||||
},
|
||||
{ context: 'Confirmation', isActive: useExistingSecret },
|
||||
)
|
||||
);
|
||||
useKeybindings(
|
||||
{
|
||||
'confirm:previous': handlePrevious,
|
||||
'confirm:next': handleNext,
|
||||
},
|
||||
{ context: 'Confirmation', isActive: !useExistingSecret },
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -58,9 +52,7 @@ export function CheckExistingSecretStep({
|
||||
<Text dimColor>Setup API key secret</Text>
|
||||
</Box>
|
||||
<Box marginBottom={1}>
|
||||
<Text color="warning">
|
||||
ANTHROPIC_API_KEY already exists in repository secrets!
|
||||
</Text>
|
||||
<Text color="warning">ANTHROPIC_API_KEY already exists in repository secrets!</Text>
|
||||
</Box>
|
||||
<Box marginBottom={1}>
|
||||
<Text>Would you like to:</Text>
|
||||
@@ -80,9 +72,7 @@ export function CheckExistingSecretStep({
|
||||
{!useExistingSecret && (
|
||||
<>
|
||||
<Box marginBottom={1}>
|
||||
<Text>
|
||||
Enter new secret name (alphanumeric with underscores):
|
||||
</Text>
|
||||
<Text>Enter new secret name (alphanumeric with underscores):</Text>
|
||||
</Box>
|
||||
<TextInput
|
||||
value={secretName}
|
||||
@@ -102,5 +92,5 @@ export function CheckExistingSecretStep({
|
||||
<Text dimColor>↑/↓ to select · Enter to continue</Text>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { Text } from '@anthropic/ink'
|
||||
import React from 'react';
|
||||
import { Text } from '@anthropic/ink';
|
||||
|
||||
export function CheckGitHubStep() {
|
||||
return <Text>Checking GitHub CLI installation…</Text>
|
||||
return <Text>Checking GitHub CLI installation…</Text>;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import TextInput from '../../components/TextInput.js'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
|
||||
interface ChooseRepoStepProps {
|
||||
currentRepo: string | null
|
||||
useCurrentRepo: boolean
|
||||
repoUrl: string
|
||||
onRepoUrlChange: (value: string) => void
|
||||
onToggleUseCurrentRepo: (useCurrentRepo: boolean) => void
|
||||
onSubmit: () => void
|
||||
currentRepo: string | null;
|
||||
useCurrentRepo: boolean;
|
||||
repoUrl: string;
|
||||
onRepoUrlChange: (value: string) => void;
|
||||
onToggleUseCurrentRepo: (useCurrentRepo: boolean) => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export function ChooseRepoStep({
|
||||
@@ -21,32 +21,32 @@ export function ChooseRepoStep({
|
||||
onSubmit,
|
||||
onToggleUseCurrentRepo,
|
||||
}: ChooseRepoStepProps) {
|
||||
const [cursorOffset, setCursorOffset] = useState(0)
|
||||
const [showEmptyError, setShowEmptyError] = useState(false)
|
||||
const terminalSize = useTerminalSize()
|
||||
const textInputColumns = terminalSize.columns
|
||||
const [cursorOffset, setCursorOffset] = useState(0);
|
||||
const [showEmptyError, setShowEmptyError] = useState(false);
|
||||
const terminalSize = useTerminalSize();
|
||||
const textInputColumns = terminalSize.columns;
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const repoName = useCurrentRepo ? currentRepo : repoUrl
|
||||
const repoName = useCurrentRepo ? currentRepo : repoUrl;
|
||||
if (!repoName?.trim()) {
|
||||
setShowEmptyError(true)
|
||||
return
|
||||
setShowEmptyError(true);
|
||||
return;
|
||||
}
|
||||
onSubmit()
|
||||
}, [useCurrentRepo, currentRepo, repoUrl, onSubmit])
|
||||
onSubmit();
|
||||
}, [useCurrentRepo, currentRepo, repoUrl, onSubmit]);
|
||||
|
||||
// When the text input is visible, omit confirm:yes so bare 'y' passes
|
||||
// through to the input instead of submitting. TextInput's onSubmit handles
|
||||
// Enter. Keep the Confirmation context (not Settings) to avoid j/k bindings.
|
||||
const isTextInputVisible = !useCurrentRepo || !currentRepo
|
||||
const isTextInputVisible = !useCurrentRepo || !currentRepo;
|
||||
const handlePrevious = useCallback(() => {
|
||||
onToggleUseCurrentRepo(true)
|
||||
setShowEmptyError(false)
|
||||
}, [onToggleUseCurrentRepo])
|
||||
onToggleUseCurrentRepo(true);
|
||||
setShowEmptyError(false);
|
||||
}, [onToggleUseCurrentRepo]);
|
||||
const handleNext = useCallback(() => {
|
||||
onToggleUseCurrentRepo(false)
|
||||
setShowEmptyError(false)
|
||||
}, [onToggleUseCurrentRepo])
|
||||
onToggleUseCurrentRepo(false);
|
||||
setShowEmptyError(false);
|
||||
}, [onToggleUseCurrentRepo]);
|
||||
|
||||
useKeybindings(
|
||||
{
|
||||
@@ -55,14 +55,14 @@ export function ChooseRepoStep({
|
||||
'confirm:yes': handleSubmit,
|
||||
},
|
||||
{ context: 'Confirmation', isActive: !isTextInputVisible },
|
||||
)
|
||||
);
|
||||
useKeybindings(
|
||||
{
|
||||
'confirm:previous': handlePrevious,
|
||||
'confirm:next': handleNext,
|
||||
},
|
||||
{ context: 'Confirmation', isActive: isTextInputVisible },
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -73,10 +73,7 @@ export function ChooseRepoStep({
|
||||
</Box>
|
||||
{currentRepo && (
|
||||
<Box marginBottom={1}>
|
||||
<Text
|
||||
bold={useCurrentRepo}
|
||||
color={useCurrentRepo ? 'permission' : undefined}
|
||||
>
|
||||
<Text bold={useCurrentRepo} color={useCurrentRepo ? 'permission' : undefined}>
|
||||
{useCurrentRepo ? '> ' : ' '}
|
||||
Use current repository: {currentRepo}
|
||||
</Text>
|
||||
@@ -96,8 +93,8 @@ export function ChooseRepoStep({
|
||||
<TextInput
|
||||
value={repoUrl}
|
||||
onChange={value => {
|
||||
onRepoUrlChange(value)
|
||||
setShowEmptyError(false)
|
||||
onRepoUrlChange(value);
|
||||
setShowEmptyError(false);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
focus={true}
|
||||
@@ -116,10 +113,8 @@ export function ChooseRepoStep({
|
||||
</Box>
|
||||
)}
|
||||
<Box marginLeft={3}>
|
||||
<Text dimColor>
|
||||
{currentRepo ? '↑/↓ to select · ' : ''}Enter to continue
|
||||
</Text>
|
||||
<Text dimColor>{currentRepo ? '↑/↓ to select · ' : ''}Enter to continue</Text>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { Workflow } from './types.js'
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { Workflow } from './types.js';
|
||||
|
||||
interface CreatingStepProps {
|
||||
currentWorkflowInstallStep: number
|
||||
secretExists: boolean
|
||||
useExistingSecret: boolean
|
||||
secretName: string
|
||||
skipWorkflow?: boolean
|
||||
selectedWorkflows: Workflow[]
|
||||
currentWorkflowInstallStep: number;
|
||||
secretExists: boolean;
|
||||
useExistingSecret: boolean;
|
||||
secretName: string;
|
||||
skipWorkflow?: boolean;
|
||||
selectedWorkflows: Workflow[];
|
||||
}
|
||||
|
||||
export function CreatingStep({
|
||||
@@ -22,21 +22,15 @@ export function CreatingStep({
|
||||
const progressSteps = skipWorkflow
|
||||
? [
|
||||
'Getting repository information',
|
||||
secretExists && useExistingSecret
|
||||
? 'Using existing API key secret'
|
||||
: `Setting up ${secretName} secret`,
|
||||
secretExists && useExistingSecret ? 'Using existing API key secret' : `Setting up ${secretName} secret`,
|
||||
]
|
||||
: [
|
||||
'Getting repository information',
|
||||
'Creating branch',
|
||||
selectedWorkflows.length > 1
|
||||
? 'Creating workflow files'
|
||||
: 'Creating workflow file',
|
||||
secretExists && useExistingSecret
|
||||
? 'Using existing API key secret'
|
||||
: `Setting up ${secretName} secret`,
|
||||
selectedWorkflows.length > 1 ? 'Creating workflow files' : 'Creating workflow file',
|
||||
secretExists && useExistingSecret ? 'Using existing API key secret' : `Setting up ${secretName} secret`,
|
||||
'Opening pull request page',
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -46,33 +40,25 @@ export function CreatingStep({
|
||||
<Text dimColor>Create GitHub Actions workflow</Text>
|
||||
</Box>
|
||||
{progressSteps.map((stepText, index) => {
|
||||
let status: 'completed' | 'in-progress' | 'pending' = 'pending'
|
||||
let status: 'completed' | 'in-progress' | 'pending' = 'pending';
|
||||
|
||||
if (index < currentWorkflowInstallStep) {
|
||||
status = 'completed'
|
||||
status = 'completed';
|
||||
} else if (index === currentWorkflowInstallStep) {
|
||||
status = 'in-progress'
|
||||
status = 'in-progress';
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={index}>
|
||||
<Text
|
||||
color={
|
||||
status === 'completed'
|
||||
? 'success'
|
||||
: status === 'in-progress'
|
||||
? 'warning'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Text color={status === 'completed' ? 'success' : status === 'in-progress' ? 'warning' : undefined}>
|
||||
{status === 'completed' ? '✓ ' : ''}
|
||||
{stepText}
|
||||
{status === 'in-progress' ? '…' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import React from 'react'
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import React from 'react';
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
|
||||
interface ErrorStepProps {
|
||||
error: string | undefined
|
||||
errorReason?: string
|
||||
errorInstructions?: string[]
|
||||
error: string | undefined;
|
||||
errorReason?: string;
|
||||
errorInstructions?: string[];
|
||||
}
|
||||
|
||||
export function ErrorStep({
|
||||
error,
|
||||
errorReason,
|
||||
errorInstructions,
|
||||
}: ErrorStepProps) {
|
||||
export function ErrorStep({ error, errorReason, errorInstructions }: ErrorStepProps) {
|
||||
return (
|
||||
<>
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||
@@ -38,8 +34,7 @@ export function ErrorStep({
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
For manual setup instructions, see:{' '}
|
||||
<Text color="claude">{GITHUB_ACTION_SETUP_DOCS_URL}</Text>
|
||||
For manual setup instructions, see: <Text color="claude">{GITHUB_ACTION_SETUP_DOCS_URL}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -47,5 +42,5 @@ export function ErrorStep({
|
||||
<Text dimColor>Press any key to exit</Text>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import React from 'react'
|
||||
import { Select } from 'src/components/CustomSelect/index.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import React from 'react';
|
||||
import { Select } from 'src/components/CustomSelect/index.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
|
||||
interface ExistingWorkflowStepProps {
|
||||
repoName: string
|
||||
onSelectAction: (action: 'update' | 'skip' | 'exit') => void
|
||||
repoName: string;
|
||||
onSelectAction: (action: 'update' | 'skip' | 'exit') => void;
|
||||
}
|
||||
|
||||
export function ExistingWorkflowStep({
|
||||
repoName,
|
||||
onSelectAction,
|
||||
}: ExistingWorkflowStepProps) {
|
||||
export function ExistingWorkflowStep({ repoName, onSelectAction }: ExistingWorkflowStepProps) {
|
||||
const options = [
|
||||
{
|
||||
label: 'Update workflow file with latest version',
|
||||
@@ -24,15 +21,15 @@ export function ExistingWorkflowStep({
|
||||
label: 'Exit without making changes',
|
||||
value: 'exit',
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
onSelectAction(value as 'update' | 'skip' | 'exit')
|
||||
}
|
||||
onSelectAction(value as 'update' | 'skip' | 'exit');
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onSelectAction('exit')
|
||||
}
|
||||
onSelectAction('exit');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
|
||||
@@ -43,28 +40,21 @@ export function ExistingWorkflowStep({
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text>
|
||||
A Claude workflow file already exists at{' '}
|
||||
<Text color="claude">.github/workflows/claude.yml</Text>
|
||||
A Claude workflow file already exists at <Text color="claude">.github/workflows/claude.yml</Text>
|
||||
</Text>
|
||||
<Text dimColor>What would you like to do?</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Select
|
||||
options={options}
|
||||
onChange={handleSelect}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
<Select options={options} onChange={handleSelect} onCancel={handleCancel} />
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
View the latest workflow template at:{' '}
|
||||
<Text color="claude">
|
||||
https://github.com/anthropics/claude-code-action/blob/main/examples/claude.yml
|
||||
</Text>
|
||||
<Text color="claude">https://github.com/anthropics/claude-code-action/blob/main/examples/claude.yml</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import figures from 'figures'
|
||||
import React from 'react'
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js'
|
||||
import figures from 'figures';
|
||||
import React from 'react';
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
|
||||
interface InstallAppStepProps {
|
||||
repoUrl: string
|
||||
onSubmit: () => void
|
||||
repoUrl: string;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export function InstallAppStep({ repoUrl, onSubmit }: InstallAppStepProps) {
|
||||
// Enter to submit
|
||||
useKeybinding('confirm:yes', onSubmit, { context: 'Confirmation' })
|
||||
useKeybinding('confirm:yes', onSubmit, { context: 'Confirmation' });
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
|
||||
@@ -33,9 +33,7 @@ export function InstallAppStep({ repoUrl, onSubmit }: InstallAppStepProps) {
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
Important: Make sure to grant access to this specific repository
|
||||
</Text>
|
||||
<Text dimColor>Important: Make sure to grant access to this specific repository</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text bold color="permission">
|
||||
@@ -44,10 +42,9 @@ export function InstallAppStep({ repoUrl, onSubmit }: InstallAppStepProps) {
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Having trouble? See manual setup instructions at:{' '}
|
||||
<Text color="claude">{GITHUB_ACTION_SETUP_DOCS_URL}</Text>
|
||||
Having trouble? See manual setup instructions at: <Text color="claude">{GITHUB_ACTION_SETUP_DOCS_URL}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { KeyboardShortcutHint } from '@anthropic/ink'
|
||||
import { Spinner } from '../../components/Spinner.js'
|
||||
import TextInput from '../../components/TextInput.js'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { type KeyboardEvent, setClipboard, Box, Link, Text } from '@anthropic/ink'
|
||||
import { OAuthService } from '../../services/oauth/index.js'
|
||||
import { saveOAuthTokensIfNeeded } from '../../utils/auth.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
} from 'src/services/analytics/index.js';
|
||||
import { KeyboardShortcutHint } from '@anthropic/ink';
|
||||
import { Spinner } from '../../components/Spinner.js';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { type KeyboardEvent, setClipboard, Box, Link, Text } from '@anthropic/ink';
|
||||
import { OAuthService } from '../../services/oauth/index.js';
|
||||
import { saveOAuthTokensIfNeeded } from '../../utils/auth.js';
|
||||
import { logError } from '../../utils/log.js';
|
||||
|
||||
interface OAuthFlowStepProps {
|
||||
onSuccess: (token: string) => void
|
||||
onCancel: () => void
|
||||
onSuccess: (token: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type OAuthStatus =
|
||||
@@ -23,139 +23,132 @@ type OAuthStatus =
|
||||
| { state: 'processing' }
|
||||
| { state: 'success'; token: string }
|
||||
| { state: 'error'; message: string; toRetry?: OAuthStatus }
|
||||
| { state: 'about_to_retry'; nextState: OAuthStatus }
|
||||
| { state: 'about_to_retry'; nextState: OAuthStatus };
|
||||
|
||||
const PASTE_HERE_MSG = 'Paste code here if prompted > '
|
||||
const PASTE_HERE_MSG = 'Paste code here if prompted > ';
|
||||
|
||||
export function OAuthFlowStep({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: OAuthFlowStepProps): React.ReactNode {
|
||||
export function OAuthFlowStep({ onSuccess, onCancel }: OAuthFlowStepProps): React.ReactNode {
|
||||
const [oauthStatus, setOAuthStatus] = useState<OAuthStatus>({
|
||||
state: 'starting',
|
||||
})
|
||||
const [oauthService] = useState(() => new OAuthService())
|
||||
const [pastedCode, setPastedCode] = useState('')
|
||||
const [cursorOffset, setCursorOffset] = useState(0)
|
||||
const [showPastePrompt, setShowPastePrompt] = useState(false)
|
||||
const [urlCopied, setUrlCopied] = useState(false)
|
||||
const timersRef = useRef<Set<NodeJS.Timeout>>(new Set())
|
||||
});
|
||||
const [oauthService] = useState(() => new OAuthService());
|
||||
const [pastedCode, setPastedCode] = useState('');
|
||||
const [cursorOffset, setCursorOffset] = useState(0);
|
||||
const [showPastePrompt, setShowPastePrompt] = useState(false);
|
||||
const [urlCopied, setUrlCopied] = useState(false);
|
||||
const timersRef = useRef<Set<NodeJS.Timeout>>(new Set());
|
||||
// Separate ref so startOAuth's timer clear doesn't cancel the urlCopied reset
|
||||
const urlCopiedTimerRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
||||
const urlCopiedTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
const terminalSize = useTerminalSize()
|
||||
const textInputColumns = Math.max(
|
||||
50,
|
||||
terminalSize.columns - PASTE_HERE_MSG.length - 4,
|
||||
)
|
||||
const terminalSize = useTerminalSize();
|
||||
const textInputColumns = Math.max(50, terminalSize.columns - PASTE_HERE_MSG.length - 4);
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
if (oauthStatus.state !== 'error') return
|
||||
e.preventDefault()
|
||||
if (oauthStatus.state !== 'error') return;
|
||||
e.preventDefault();
|
||||
if (e.key === 'return' && oauthStatus.toRetry) {
|
||||
setPastedCode('')
|
||||
setCursorOffset(0)
|
||||
setPastedCode('');
|
||||
setCursorOffset(0);
|
||||
setOAuthStatus({
|
||||
state: 'about_to_retry',
|
||||
nextState: oauthStatus.toRetry,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
onCancel()
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitCode(value: string, url: string) {
|
||||
try {
|
||||
// Expecting format "authorizationCode#state" from the authorization callback URL
|
||||
const [authorizationCode, state] = value.split('#')
|
||||
const [authorizationCode, state] = value.split('#');
|
||||
|
||||
if (!authorizationCode || !state) {
|
||||
setOAuthStatus({
|
||||
state: 'error',
|
||||
message: 'Invalid code. Please make sure the full code was copied',
|
||||
toRetry: { state: 'waiting_for_login', url },
|
||||
})
|
||||
return
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Track which path the user is taking (manual code entry)
|
||||
logEvent('tengu_oauth_manual_entry', {})
|
||||
logEvent('tengu_oauth_manual_entry', {});
|
||||
oauthService.handleManualAuthCodeInput({
|
||||
authorizationCode,
|
||||
state,
|
||||
})
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
logError(err)
|
||||
logError(err);
|
||||
setOAuthStatus({
|
||||
state: 'error',
|
||||
message: (err as Error).message,
|
||||
toRetry: { state: 'waiting_for_login', url },
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const startOAuth = useCallback(async () => {
|
||||
// Clear any existing timers when starting new OAuth flow
|
||||
timersRef.current.forEach(timer => clearTimeout(timer))
|
||||
timersRef.current.clear()
|
||||
timersRef.current.forEach(timer => clearTimeout(timer));
|
||||
timersRef.current.clear();
|
||||
|
||||
try {
|
||||
const result = await oauthService.startOAuthFlow(
|
||||
async url => {
|
||||
setOAuthStatus({ state: 'waiting_for_login', url })
|
||||
const timer = setTimeout(setShowPastePrompt, 3000, true)
|
||||
timersRef.current.add(timer)
|
||||
setOAuthStatus({ state: 'waiting_for_login', url });
|
||||
const timer = setTimeout(setShowPastePrompt, 3000, true);
|
||||
timersRef.current.add(timer);
|
||||
},
|
||||
{
|
||||
loginWithClaudeAi: true, // Always use Claude AI for subscription tokens
|
||||
inferenceOnly: true,
|
||||
expiresIn: 365 * 24 * 60 * 60, // 1 year
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
// Show processing state
|
||||
setOAuthStatus({ state: 'processing' })
|
||||
setOAuthStatus({ state: 'processing' });
|
||||
|
||||
// OAuthFlowStep creates inference-only tokens for GitHub Actions, not a
|
||||
// replacement login. Use saveOAuthTokensIfNeeded directly to avoid
|
||||
// performLogout which would destroy the user's existing auth session.
|
||||
saveOAuthTokensIfNeeded(result)
|
||||
saveOAuthTokensIfNeeded(result);
|
||||
|
||||
// For OAuth flow, the access token can be used as an API key
|
||||
const timer1 = setTimeout(
|
||||
(setOAuthStatus, accessToken, onSuccess, timersRef) => {
|
||||
setOAuthStatus({ state: 'success', token: accessToken })
|
||||
setOAuthStatus({ state: 'success', token: accessToken });
|
||||
// Auto-continue after brief delay to show success
|
||||
const timer2 = setTimeout(onSuccess, 1000, accessToken)
|
||||
timersRef.current.add(timer2 as unknown as NodeJS.Timeout)
|
||||
const timer2 = setTimeout(onSuccess, 1000, accessToken);
|
||||
timersRef.current.add(timer2 as unknown as NodeJS.Timeout);
|
||||
},
|
||||
100,
|
||||
setOAuthStatus,
|
||||
result.accessToken,
|
||||
onSuccess,
|
||||
timersRef,
|
||||
)
|
||||
timersRef.current.add(timer1)
|
||||
);
|
||||
timersRef.current.add(timer1);
|
||||
} catch (err) {
|
||||
const errorMessage = (err as Error).message
|
||||
const errorMessage = (err as Error).message;
|
||||
setOAuthStatus({
|
||||
state: 'error',
|
||||
message: errorMessage,
|
||||
toRetry: { state: 'starting' }, // Allow retry by starting fresh OAuth flow
|
||||
})
|
||||
logError(err)
|
||||
});
|
||||
logError(err);
|
||||
logEvent('tengu_oauth_error', {
|
||||
error:
|
||||
errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
error: errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
}
|
||||
}, [oauthService, onSuccess])
|
||||
}, [oauthService, onSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (oauthStatus.state === 'starting') {
|
||||
void startOAuth()
|
||||
void startOAuth();
|
||||
}
|
||||
}, [oauthStatus.state, startOAuth])
|
||||
}, [oauthStatus.state, startOAuth]);
|
||||
|
||||
// Retry logic
|
||||
useEffect(() => {
|
||||
@@ -163,46 +156,41 @@ export function OAuthFlowStep({
|
||||
const timer = setTimeout(
|
||||
(nextState, setShowPastePrompt, setOAuthStatus) => {
|
||||
// Only show paste prompt when retrying to waiting_for_login
|
||||
setShowPastePrompt(nextState.state === 'waiting_for_login')
|
||||
setOAuthStatus(nextState)
|
||||
setShowPastePrompt(nextState.state === 'waiting_for_login');
|
||||
setOAuthStatus(nextState);
|
||||
},
|
||||
500,
|
||||
oauthStatus.nextState,
|
||||
setShowPastePrompt,
|
||||
setOAuthStatus,
|
||||
)
|
||||
timersRef.current.add(timer)
|
||||
);
|
||||
timersRef.current.add(timer);
|
||||
}
|
||||
}, [oauthStatus])
|
||||
}, [oauthStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
pastedCode === 'c' &&
|
||||
oauthStatus.state === 'waiting_for_login' &&
|
||||
showPastePrompt &&
|
||||
!urlCopied
|
||||
) {
|
||||
if (pastedCode === 'c' && oauthStatus.state === 'waiting_for_login' && showPastePrompt && !urlCopied) {
|
||||
void setClipboard(oauthStatus.url).then(raw => {
|
||||
if (raw) process.stdout.write(raw)
|
||||
setUrlCopied(true)
|
||||
clearTimeout(urlCopiedTimerRef.current)
|
||||
urlCopiedTimerRef.current = setTimeout(setUrlCopied, 2000, false)
|
||||
})
|
||||
setPastedCode('')
|
||||
if (raw) process.stdout.write(raw);
|
||||
setUrlCopied(true);
|
||||
clearTimeout(urlCopiedTimerRef.current);
|
||||
urlCopiedTimerRef.current = setTimeout(setUrlCopied, 2000, false);
|
||||
});
|
||||
setPastedCode('');
|
||||
}
|
||||
}, [pastedCode, oauthStatus, showPastePrompt, urlCopied])
|
||||
}, [pastedCode, oauthStatus, showPastePrompt, urlCopied]);
|
||||
|
||||
// Cleanup OAuth service and timers when component unmounts
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current
|
||||
const timers = timersRef.current;
|
||||
return () => {
|
||||
oauthService.cleanup()
|
||||
oauthService.cleanup();
|
||||
// Clear all timers
|
||||
timers.forEach(timer => clearTimeout(timer))
|
||||
timers.clear()
|
||||
clearTimeout(urlCopiedTimerRef.current)
|
||||
}
|
||||
}, [oauthService])
|
||||
timers.forEach(timer => clearTimeout(timer));
|
||||
timers.clear();
|
||||
clearTimeout(urlCopiedTimerRef.current);
|
||||
};
|
||||
}, [oauthService]);
|
||||
|
||||
// Helper function to render the appropriate status message
|
||||
function renderStatusMessage(): React.ReactNode {
|
||||
@@ -213,7 +201,7 @@ export function OAuthFlowStep({
|
||||
<Spinner />
|
||||
<Text>Starting authentication…</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
case 'waiting_for_login':
|
||||
return (
|
||||
@@ -221,9 +209,7 @@ export function OAuthFlowStep({
|
||||
{!showPastePrompt && (
|
||||
<Box>
|
||||
<Spinner />
|
||||
<Text>
|
||||
Opening browser to sign in with your Claude account…
|
||||
</Text>
|
||||
<Text>Opening browser to sign in with your Claude account…</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -233,9 +219,7 @@ export function OAuthFlowStep({
|
||||
<TextInput
|
||||
value={pastedCode}
|
||||
onChange={setPastedCode}
|
||||
onSubmit={(value: string) =>
|
||||
handleSubmitCode(value, oauthStatus.url)
|
||||
}
|
||||
onSubmit={(value: string) => handleSubmitCode(value, oauthStatus.url)}
|
||||
cursorOffset={cursorOffset}
|
||||
onChangeCursorOffset={setCursorOffset}
|
||||
columns={textInputColumns}
|
||||
@@ -243,7 +227,7 @@ export function OAuthFlowStep({
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
case 'processing':
|
||||
return (
|
||||
@@ -251,52 +235,42 @@ export function OAuthFlowStep({
|
||||
<Spinner />
|
||||
<Text>Processing authentication…</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
case 'success':
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="success">
|
||||
✓ Authentication token created successfully!
|
||||
</Text>
|
||||
<Text color="success">✓ Authentication token created successfully!</Text>
|
||||
<Text dimColor>Using token for GitHub Actions setup…</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="error">OAuth error: {oauthStatus.message}</Text>
|
||||
{oauthStatus.toRetry ? (
|
||||
<Text dimColor>
|
||||
Press Enter to try again, or any other key to cancel
|
||||
</Text>
|
||||
<Text dimColor>Press Enter to try again, or any other key to cancel</Text>
|
||||
) : (
|
||||
<Text dimColor>Press any key to return to API key selection</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
case 'about_to_retry':
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="permission">Retrying…</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
default:
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Box flexDirection="column" gap={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
|
||||
{/* Show header inline only for initial starting state */}
|
||||
{oauthStatus.state === 'starting' && (
|
||||
<Box flexDirection="column" gap={1} paddingBottom={1}>
|
||||
@@ -305,21 +279,17 @@ export function OAuthFlowStep({
|
||||
</Box>
|
||||
)}
|
||||
{/* Show header for non-starting states (to avoid duplicate with inline header)*/}
|
||||
{oauthStatus.state !== 'success' &&
|
||||
oauthStatus.state !== 'starting' &&
|
||||
oauthStatus.state !== 'processing' && (
|
||||
<Box key="header" flexDirection="column" gap={1} paddingBottom={1}>
|
||||
<Text bold>Create Authentication Token</Text>
|
||||
<Text dimColor>Creating a long-lived token for GitHub Actions</Text>
|
||||
</Box>
|
||||
)}
|
||||
{oauthStatus.state !== 'success' && oauthStatus.state !== 'starting' && oauthStatus.state !== 'processing' && (
|
||||
<Box key="header" flexDirection="column" gap={1} paddingBottom={1}>
|
||||
<Text bold>Create Authentication Token</Text>
|
||||
<Text dimColor>Creating a long-lived token for GitHub Actions</Text>
|
||||
</Box>
|
||||
)}
|
||||
{/* Show URL when paste prompt is visible */}
|
||||
{oauthStatus.state === 'waiting_for_login' && showPastePrompt && (
|
||||
<Box flexDirection="column" key="urlToCopy" gap={1} paddingBottom={1}>
|
||||
<Box paddingX={1}>
|
||||
<Text dimColor>
|
||||
Browser didn't open? Use the url below to sign in{' '}
|
||||
</Text>
|
||||
<Text dimColor>Browser didn't open? Use the url below to sign in </Text>
|
||||
{urlCopied ? (
|
||||
<Text color="success">(Copied!)</Text>
|
||||
) : (
|
||||
@@ -337,5 +307,5 @@ export function OAuthFlowStep({
|
||||
{renderStatusMessage()}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
|
||||
type SuccessStepProps = {
|
||||
secretExists: boolean
|
||||
useExistingSecret: boolean
|
||||
secretName: string
|
||||
skipWorkflow?: boolean
|
||||
}
|
||||
secretExists: boolean;
|
||||
useExistingSecret: boolean;
|
||||
secretName: string;
|
||||
skipWorkflow?: boolean;
|
||||
};
|
||||
|
||||
export function SuccessStep({
|
||||
secretExists,
|
||||
@@ -21,14 +21,10 @@ export function SuccessStep({
|
||||
<Text bold>Install GitHub App</Text>
|
||||
<Text dimColor>Success</Text>
|
||||
</Box>
|
||||
{!skipWorkflow && (
|
||||
<Text color="success">✓ GitHub Actions workflow created!</Text>
|
||||
)}
|
||||
{!skipWorkflow && <Text color="success">✓ GitHub Actions workflow created!</Text>}
|
||||
{secretExists && useExistingSecret && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="success">
|
||||
✓ Using existing ANTHROPIC_API_KEY secret
|
||||
</Text>
|
||||
<Text color="success">✓ Using existing ANTHROPIC_API_KEY secret</Text>
|
||||
</Box>
|
||||
)}
|
||||
{(!secretExists || !useExistingSecret) && (
|
||||
@@ -41,18 +37,14 @@ export function SuccessStep({
|
||||
</Box>
|
||||
{skipWorkflow ? (
|
||||
<>
|
||||
<Text>
|
||||
1. Install the Claude GitHub App if you haven't already
|
||||
</Text>
|
||||
<Text>1. Install the Claude GitHub App if you haven't already</Text>
|
||||
<Text>2. Your workflow file was kept unchanged</Text>
|
||||
<Text>3. API key is configured and ready to use</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text>1. A pre-filled PR page has been created</Text>
|
||||
<Text>
|
||||
2. Install the Claude GitHub App if you haven't already
|
||||
</Text>
|
||||
<Text>2. Install the Claude GitHub App if you haven't already</Text>
|
||||
<Text>3. Merge the PR to enable Claude PR assistance</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -61,5 +53,5 @@ export function SuccessStep({
|
||||
<Text dimColor>Press any key to exit</Text>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
import figures from 'figures'
|
||||
import React from 'react'
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js'
|
||||
import type { Warning } from './types.js'
|
||||
import figures from 'figures';
|
||||
import React from 'react';
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
import type { Warning } from './types.js';
|
||||
|
||||
interface WarningsStepProps {
|
||||
warnings: Warning[]
|
||||
onContinue: () => void
|
||||
warnings: Warning[];
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function WarningsStep({ warnings, onContinue }: WarningsStepProps) {
|
||||
// Enter to continue
|
||||
useKeybinding('confirm:yes', onContinue, { context: 'Confirmation' })
|
||||
useKeybinding('confirm:yes', onContinue, { context: 'Confirmation' });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold>{figures.warning} Setup Warnings</Text>
|
||||
<Text dimColor>
|
||||
We found some potential issues, but you can continue anyway
|
||||
</Text>
|
||||
<Text dimColor>We found some potential issues, but you can continue anyway</Text>
|
||||
</Box>
|
||||
|
||||
{warnings.map((warning, index) => (
|
||||
@@ -55,5 +53,5 @@ export function WarningsStep({ warnings, onContinue }: WarningsStepProps) {
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { execa } from 'execa'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { execa } from 'execa';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { WorkflowMultiselectDialog } from '../../components/WorkflowMultiselectDialog.js'
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'
|
||||
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'
|
||||
import { type KeyboardEvent, Box } from '@anthropic/ink'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { getAnthropicApiKey, isAnthropicAuthEnabled } from '../../utils/auth.js'
|
||||
import { openBrowser } from '../../utils/browser.js'
|
||||
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
|
||||
import { getGithubRepo } from '../../utils/git.js'
|
||||
import { plural } from '../../utils/stringUtils.js'
|
||||
import { ApiKeyStep } from './ApiKeyStep.js'
|
||||
import { CheckExistingSecretStep } from './CheckExistingSecretStep.js'
|
||||
import { CheckGitHubStep } from './CheckGitHubStep.js'
|
||||
import { ChooseRepoStep } from './ChooseRepoStep.js'
|
||||
import { CreatingStep } from './CreatingStep.js'
|
||||
import { ErrorStep } from './ErrorStep.js'
|
||||
import { ExistingWorkflowStep } from './ExistingWorkflowStep.js'
|
||||
import { InstallAppStep } from './InstallAppStep.js'
|
||||
import { OAuthFlowStep } from './OAuthFlowStep.js'
|
||||
import { SuccessStep } from './SuccessStep.js'
|
||||
import { setupGitHubActions } from './setupGitHubActions.js'
|
||||
import type { State, Warning, Workflow } from './types.js'
|
||||
import { WarningsStep } from './WarningsStep.js'
|
||||
} from 'src/services/analytics/index.js';
|
||||
import { WorkflowMultiselectDialog } from '../../components/WorkflowMultiselectDialog.js';
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
|
||||
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
|
||||
import { type KeyboardEvent, Box } from '@anthropic/ink';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { getAnthropicApiKey, isAnthropicAuthEnabled } from '../../utils/auth.js';
|
||||
import { openBrowser } from '../../utils/browser.js';
|
||||
import { execFileNoThrow } from '../../utils/execFileNoThrow.js';
|
||||
import { getGithubRepo } from '../../utils/git.js';
|
||||
import { plural } from '../../utils/stringUtils.js';
|
||||
import { ApiKeyStep } from './ApiKeyStep.js';
|
||||
import { CheckExistingSecretStep } from './CheckExistingSecretStep.js';
|
||||
import { CheckGitHubStep } from './CheckGitHubStep.js';
|
||||
import { ChooseRepoStep } from './ChooseRepoStep.js';
|
||||
import { CreatingStep } from './CreatingStep.js';
|
||||
import { ErrorStep } from './ErrorStep.js';
|
||||
import { ExistingWorkflowStep } from './ExistingWorkflowStep.js';
|
||||
import { InstallAppStep } from './InstallAppStep.js';
|
||||
import { OAuthFlowStep } from './OAuthFlowStep.js';
|
||||
import { SuccessStep } from './SuccessStep.js';
|
||||
import { setupGitHubActions } from './setupGitHubActions.js';
|
||||
import type { State, Warning, Workflow } from './types.js';
|
||||
import { WarningsStep } from './WarningsStep.js';
|
||||
|
||||
const INITIAL_STATE: State = {
|
||||
step: 'check-gh',
|
||||
@@ -44,54 +44,50 @@ const INITIAL_STATE: State = {
|
||||
selectedWorkflows: ['claude', 'claude-review'] as Workflow[],
|
||||
selectedApiKeyOption: 'new' as 'existing' | 'new' | 'oauth',
|
||||
authType: 'api_key',
|
||||
}
|
||||
};
|
||||
|
||||
function InstallGitHubApp(props: {
|
||||
onDone: (message: string) => void
|
||||
}): React.ReactNode {
|
||||
const [existingApiKey] = useState(() => getAnthropicApiKey())
|
||||
function InstallGitHubApp(props: { onDone: (message: string) => void }): React.ReactNode {
|
||||
const [existingApiKey] = useState(() => getAnthropicApiKey());
|
||||
const [state, setState] = useState({
|
||||
...INITIAL_STATE,
|
||||
useExistingKey: !!existingApiKey,
|
||||
selectedApiKeyOption: (existingApiKey
|
||||
? 'existing'
|
||||
: isAnthropicAuthEnabled()
|
||||
? 'oauth'
|
||||
: 'new') as 'existing' | 'new' | 'oauth',
|
||||
})
|
||||
useExitOnCtrlCDWithKeybindings()
|
||||
selectedApiKeyOption: (existingApiKey ? 'existing' : isAnthropicAuthEnabled() ? 'oauth' : 'new') as
|
||||
| 'existing'
|
||||
| 'new'
|
||||
| 'oauth',
|
||||
});
|
||||
useExitOnCtrlCDWithKeybindings();
|
||||
|
||||
React.useEffect(() => {
|
||||
logEvent('tengu_install_github_app_started', {})
|
||||
}, [])
|
||||
logEvent('tengu_install_github_app_started', {});
|
||||
}, []);
|
||||
|
||||
const checkGitHubCLI = useCallback(async () => {
|
||||
const warnings: Warning[] = []
|
||||
const warnings: Warning[] = [];
|
||||
|
||||
// Check if gh is installed
|
||||
const ghVersionResult = await execa('gh --version', {
|
||||
shell: true,
|
||||
reject: false,
|
||||
})
|
||||
});
|
||||
if (ghVersionResult.exitCode !== 0) {
|
||||
warnings.push({
|
||||
title: 'GitHub CLI not found',
|
||||
message:
|
||||
'GitHub CLI (gh) does not appear to be installed or accessible.',
|
||||
message: 'GitHub CLI (gh) does not appear to be installed or accessible.',
|
||||
instructions: [
|
||||
'Install GitHub CLI from https://cli.github.com/',
|
||||
'macOS: brew install gh',
|
||||
'Windows: winget install --id GitHub.cli',
|
||||
'Linux: See installation instructions at https://github.com/cli/cli#installation',
|
||||
],
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Check auth status
|
||||
const authResult = await execa('gh auth status -a', {
|
||||
shell: true,
|
||||
reject: false,
|
||||
})
|
||||
});
|
||||
if (authResult.exitCode !== 0) {
|
||||
warnings.push({
|
||||
title: 'GitHub CLI not authenticated',
|
||||
@@ -101,19 +97,19 @@ function InstallGitHubApp(props: {
|
||||
'Follow the prompts to authenticate with GitHub',
|
||||
'Or set up authentication using environment variables or other methods',
|
||||
],
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// Check if required scopes are present in the Token scopes line
|
||||
const tokenScopesMatch = authResult.stdout.match(/Token scopes:.*$/m)
|
||||
const tokenScopesMatch = authResult.stdout.match(/Token scopes:.*$/m);
|
||||
if (tokenScopesMatch) {
|
||||
const scopes = tokenScopesMatch[0]
|
||||
const missingScopes: string[] = []
|
||||
const scopes = tokenScopesMatch[0];
|
||||
const missingScopes: string[] = [];
|
||||
|
||||
if (!scopes.includes('repo')) {
|
||||
missingScopes.push('repo')
|
||||
missingScopes.push('repo');
|
||||
}
|
||||
if (!scopes.includes('workflow')) {
|
||||
missingScopes.push('workflow')
|
||||
missingScopes.push('workflow');
|
||||
}
|
||||
|
||||
if (missingScopes.length > 0) {
|
||||
@@ -131,18 +127,18 @@ function InstallGitHubApp(props: {
|
||||
'',
|
||||
'This will add the necessary permissions to manage workflows and secrets.',
|
||||
],
|
||||
}))
|
||||
return
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if in a git repo and get remote URL
|
||||
const currentRepo = (await getGithubRepo()) ?? ''
|
||||
const currentRepo = (await getGithubRepo()) ?? '';
|
||||
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'check-gh' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
@@ -151,14 +147,14 @@ function InstallGitHubApp(props: {
|
||||
selectedRepoName: currentRepo,
|
||||
useCurrentRepo: !!currentRepo, // Set to false if no repo detected
|
||||
step: warnings.length > 0 ? 'warnings' : 'choose-repo',
|
||||
}))
|
||||
}, [])
|
||||
}));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state.step === 'check-gh') {
|
||||
void checkGitHubCLI()
|
||||
void checkGitHubCLI();
|
||||
}
|
||||
}, [state.step, checkGitHubCLI])
|
||||
}, [state.step, checkGitHubCLI]);
|
||||
|
||||
const runSetupGitHubActions = useCallback(
|
||||
async (apiKeyOrOAuthToken: string | null, secretName: string) => {
|
||||
@@ -166,7 +162,7 @@ function InstallGitHubApp(props: {
|
||||
...prev,
|
||||
step: 'creating',
|
||||
currentWorkflowInstallStep: 0,
|
||||
}))
|
||||
}));
|
||||
|
||||
try {
|
||||
await setupGitHubActions(
|
||||
@@ -177,7 +173,7 @@ function InstallGitHubApp(props: {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentWorkflowInstallStep: prev.currentWorkflowInstallStep + 1,
|
||||
}))
|
||||
}));
|
||||
},
|
||||
state.workflowAction === 'skip',
|
||||
state.selectedWorkflows,
|
||||
@@ -187,22 +183,18 @@ function InstallGitHubApp(props: {
|
||||
workflowExists: state.workflowExists,
|
||||
secretExists: state.secretExists,
|
||||
},
|
||||
)
|
||||
);
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'creating' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
setState(prev => ({ ...prev, step: 'success' }))
|
||||
});
|
||||
setState(prev => ({ ...prev, step: 'success' }));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to set up GitHub Actions'
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to set up GitHub Actions';
|
||||
|
||||
if (errorMessage.includes('workflow file already exists')) {
|
||||
logEvent('tengu_install_github_app_error', {
|
||||
reason:
|
||||
'workflow_file_exists' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
reason: 'workflow_file_exists' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
step: 'error',
|
||||
@@ -215,12 +207,11 @@ function InstallGitHubApp(props: {
|
||||
' 2. Update the existing file manually using the template from:',
|
||||
` ${GITHUB_ACTION_SETUP_DOCS_URL}`,
|
||||
],
|
||||
}))
|
||||
}));
|
||||
} else {
|
||||
logEvent('tengu_install_github_app_error', {
|
||||
reason:
|
||||
'setup_github_actions_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
reason: 'setup_github_actions_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
@@ -228,7 +219,7 @@ function InstallGitHubApp(props: {
|
||||
error: errorMessage,
|
||||
errorReason: 'GitHub Actions setup failed',
|
||||
errorInstructions: [],
|
||||
}))
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -241,42 +232,32 @@ function InstallGitHubApp(props: {
|
||||
state.secretExists,
|
||||
state.authType,
|
||||
],
|
||||
)
|
||||
);
|
||||
|
||||
async function openGitHubAppInstallation() {
|
||||
const installUrl = 'https://github.com/apps/claude'
|
||||
await openBrowser(installUrl)
|
||||
const installUrl = 'https://github.com/apps/claude';
|
||||
await openBrowser(installUrl);
|
||||
}
|
||||
|
||||
async function checkRepositoryPermissions(
|
||||
repoName: string,
|
||||
): Promise<{ hasAccess: boolean; error?: string }> {
|
||||
async function checkRepositoryPermissions(repoName: string): Promise<{ hasAccess: boolean; error?: string }> {
|
||||
try {
|
||||
const result = await execFileNoThrow('gh', [
|
||||
'api',
|
||||
`repos/${repoName}`,
|
||||
'--jq',
|
||||
'.permissions.admin',
|
||||
])
|
||||
const result = await execFileNoThrow('gh', ['api', `repos/${repoName}`, '--jq', '.permissions.admin']);
|
||||
|
||||
if (result.code === 0) {
|
||||
const hasAdmin = result.stdout.trim() === 'true'
|
||||
return { hasAccess: hasAdmin }
|
||||
const hasAdmin = result.stdout.trim() === 'true';
|
||||
return { hasAccess: hasAdmin };
|
||||
}
|
||||
|
||||
if (
|
||||
result.stderr.includes('404') ||
|
||||
result.stderr.includes('Not Found')
|
||||
) {
|
||||
if (result.stderr.includes('404') || result.stderr.includes('Not Found')) {
|
||||
return {
|
||||
hasAccess: false,
|
||||
error: 'repository_not_found',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { hasAccess: false }
|
||||
return { hasAccess: false };
|
||||
} catch {
|
||||
return { hasAccess: false }
|
||||
return { hasAccess: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,9 +267,9 @@ function InstallGitHubApp(props: {
|
||||
`repos/${repoName}/contents/.github/workflows/claude.yml`,
|
||||
'--jq',
|
||||
'.sha',
|
||||
])
|
||||
]);
|
||||
|
||||
return checkFileResult.code === 0
|
||||
return checkFileResult.code === 0;
|
||||
}
|
||||
|
||||
async function checkExistingSecret() {
|
||||
@@ -299,20 +280,20 @@ function InstallGitHubApp(props: {
|
||||
'actions',
|
||||
'--repo',
|
||||
state.selectedRepoName,
|
||||
])
|
||||
]);
|
||||
|
||||
if (checkSecretsResult.code === 0) {
|
||||
const lines = checkSecretsResult.stdout.split('\n')
|
||||
const lines = checkSecretsResult.stdout.split('\n');
|
||||
const hasAnthropicKey = lines.some((line: string) => {
|
||||
return /^ANTHROPIC_API_KEY\s+/.test(line)
|
||||
})
|
||||
return /^ANTHROPIC_API_KEY\s+/.test(line);
|
||||
});
|
||||
|
||||
if (hasAnthropicKey) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
secretExists: true,
|
||||
step: 'check-existing-secret',
|
||||
}))
|
||||
}));
|
||||
} else {
|
||||
// No existing secret found
|
||||
if (existingApiKey) {
|
||||
@@ -321,11 +302,11 @@ function InstallGitHubApp(props: {
|
||||
...prev,
|
||||
apiKeyOrOAuthToken: existingApiKey,
|
||||
useExistingKey: true,
|
||||
}))
|
||||
await runSetupGitHubActions(existingApiKey, state.secretName)
|
||||
}));
|
||||
await runSetupGitHubActions(existingApiKey, state.secretName);
|
||||
} else {
|
||||
// No local key, go to API key step
|
||||
setState(prev => ({ ...prev, step: 'api-key' }))
|
||||
setState(prev => ({ ...prev, step: 'api-key' }));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -336,11 +317,11 @@ function InstallGitHubApp(props: {
|
||||
...prev,
|
||||
apiKeyOrOAuthToken: existingApiKey,
|
||||
useExistingKey: true,
|
||||
}))
|
||||
await runSetupGitHubActions(existingApiKey, state.secretName)
|
||||
}));
|
||||
await runSetupGitHubActions(existingApiKey, state.secretName);
|
||||
} else {
|
||||
// No local key, go to API key step
|
||||
setState(prev => ({ ...prev, step: 'api-key' }))
|
||||
setState(prev => ({ ...prev, step: 'api-key' }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,33 +330,28 @@ function InstallGitHubApp(props: {
|
||||
if (state.step === 'warnings') {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'warnings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
setState(prev => ({ ...prev, step: 'install-app' }))
|
||||
setTimeout(openGitHubAppInstallation, 0)
|
||||
});
|
||||
setState(prev => ({ ...prev, step: 'install-app' }));
|
||||
setTimeout(openGitHubAppInstallation, 0);
|
||||
} else if (state.step === 'choose-repo') {
|
||||
let repoName = state.useCurrentRepo
|
||||
? state.currentRepo
|
||||
: state.selectedRepoName
|
||||
let repoName = state.useCurrentRepo ? state.currentRepo : state.selectedRepoName;
|
||||
|
||||
if (!repoName.trim()) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const repoWarnings: Warning[] = []
|
||||
const repoWarnings: Warning[] = [];
|
||||
|
||||
if (repoName.includes('github.com')) {
|
||||
const match = repoName.match(/github\.com[:/]([^/]+\/[^/]+)(\.git)?$/)
|
||||
const match = repoName.match(/github\.com[:/]([^/]+\/[^/]+)(\.git)?$/);
|
||||
if (!match) {
|
||||
repoWarnings.push({
|
||||
title: 'Invalid GitHub URL format',
|
||||
message: 'The repository URL format appears to be invalid.',
|
||||
instructions: [
|
||||
'Use format: owner/repo or https://github.com/owner/repo',
|
||||
'Example: anthropics/claude-cli',
|
||||
],
|
||||
})
|
||||
instructions: ['Use format: owner/repo or https://github.com/owner/repo', 'Example: anthropics/claude-cli'],
|
||||
});
|
||||
} else {
|
||||
repoName = match[1]?.replace(/\.git$/, '') || ''
|
||||
repoName = match[1]?.replace(/\.git$/, '') || '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,14 +359,11 @@ function InstallGitHubApp(props: {
|
||||
repoWarnings.push({
|
||||
title: 'Repository format warning',
|
||||
message: 'Repository should be in format "owner/repo"',
|
||||
instructions: [
|
||||
'Use format: owner/repo',
|
||||
'Example: anthropics/claude-cli',
|
||||
],
|
||||
})
|
||||
instructions: ['Use format: owner/repo', 'Example: anthropics/claude-cli'],
|
||||
});
|
||||
}
|
||||
|
||||
const permissionCheck = await checkRepositoryPermissions(repoName)
|
||||
const permissionCheck = await checkRepositoryPermissions(repoName);
|
||||
|
||||
if (permissionCheck.error === 'repository_not_found') {
|
||||
repoWarnings.push({
|
||||
@@ -402,7 +375,7 @@ function InstallGitHubApp(props: {
|
||||
'For private repositories, make sure your GitHub token has the "repo" scope',
|
||||
'You can add the repo scope with: gh auth refresh -h github.com -s repo,workflow',
|
||||
],
|
||||
})
|
||||
});
|
||||
} else if (!permissionCheck.hasAccess) {
|
||||
repoWarnings.push({
|
||||
title: 'Admin permissions required',
|
||||
@@ -412,81 +385,77 @@ function InstallGitHubApp(props: {
|
||||
'Ask a repository admin to run this command if setup fails',
|
||||
'Alternatively, you can use the manual setup instructions',
|
||||
],
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const workflowExists = await checkExistingWorkflowFile(repoName)
|
||||
const workflowExists = await checkExistingWorkflowFile(repoName);
|
||||
|
||||
if (repoWarnings.length > 0) {
|
||||
const allWarnings = [...state.warnings, ...repoWarnings]
|
||||
const allWarnings = [...state.warnings, ...repoWarnings];
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
selectedRepoName: repoName,
|
||||
workflowExists,
|
||||
warnings: allWarnings,
|
||||
step: 'warnings',
|
||||
}))
|
||||
}));
|
||||
} else {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'choose-repo' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
selectedRepoName: repoName,
|
||||
workflowExists,
|
||||
step: 'install-app',
|
||||
}))
|
||||
setTimeout(openGitHubAppInstallation, 0)
|
||||
}));
|
||||
setTimeout(openGitHubAppInstallation, 0);
|
||||
}
|
||||
} else if (state.step === 'install-app') {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'install-app' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
if (state.workflowExists) {
|
||||
setState(prev => ({ ...prev, step: 'check-existing-workflow' }))
|
||||
setState(prev => ({ ...prev, step: 'check-existing-workflow' }));
|
||||
} else {
|
||||
setState(prev => ({ ...prev, step: 'select-workflows' }))
|
||||
setState(prev => ({ ...prev, step: 'select-workflows' }));
|
||||
}
|
||||
} else if (state.step === 'check-existing-workflow') {
|
||||
return
|
||||
return;
|
||||
} else if (state.step === 'select-workflows') {
|
||||
// Handled by the WorkflowMultiselectDialog component
|
||||
return
|
||||
return;
|
||||
} else if (state.step === 'check-existing-secret') {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'check-existing-secret' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
if (state.useExistingSecret) {
|
||||
await runSetupGitHubActions(null, state.secretName)
|
||||
await runSetupGitHubActions(null, state.secretName);
|
||||
} else {
|
||||
// User wants to use a new secret name with their API key
|
||||
await runSetupGitHubActions(state.apiKeyOrOAuthToken, state.secretName)
|
||||
await runSetupGitHubActions(state.apiKeyOrOAuthToken, state.secretName);
|
||||
}
|
||||
} else if (state.step === 'api-key') {
|
||||
// In the new flow, api-key step only appears when user has no existing key
|
||||
// They either entered a new key or will create OAuth token
|
||||
if (state.selectedApiKeyOption === 'oauth') {
|
||||
// OAuth flow already handled by handleCreateOAuthToken
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// If user selected 'existing' option, use the existing API key
|
||||
const apiKeyToUse =
|
||||
state.selectedApiKeyOption === 'existing'
|
||||
? existingApiKey
|
||||
: state.apiKeyOrOAuthToken
|
||||
const apiKeyToUse = state.selectedApiKeyOption === 'existing' ? existingApiKey : state.apiKeyOrOAuthToken;
|
||||
|
||||
if (!apiKeyToUse) {
|
||||
logEvent('tengu_install_github_app_error', {
|
||||
reason:
|
||||
'api_key_missing' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
reason: 'api_key_missing' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
step: 'error',
|
||||
error: 'API key is required',
|
||||
}))
|
||||
return
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the API key being used (either existing or newly entered)
|
||||
@@ -494,7 +463,7 @@ function InstallGitHubApp(props: {
|
||||
...prev,
|
||||
apiKeyOrOAuthToken: apiKeyToUse,
|
||||
useExistingKey: state.selectedApiKeyOption === 'existing',
|
||||
}))
|
||||
}));
|
||||
|
||||
// Check if ANTHROPIC_API_KEY secret already exists
|
||||
const checkSecretsResult = await execFileNoThrow('gh', [
|
||||
@@ -504,132 +473,132 @@ function InstallGitHubApp(props: {
|
||||
'actions',
|
||||
'--repo',
|
||||
state.selectedRepoName,
|
||||
])
|
||||
]);
|
||||
|
||||
if (checkSecretsResult.code === 0) {
|
||||
const lines = checkSecretsResult.stdout.split('\n')
|
||||
const lines = checkSecretsResult.stdout.split('\n');
|
||||
const hasAnthropicKey = lines.some((line: string) => {
|
||||
return /^ANTHROPIC_API_KEY\s+/.test(line)
|
||||
})
|
||||
return /^ANTHROPIC_API_KEY\s+/.test(line);
|
||||
});
|
||||
|
||||
if (hasAnthropicKey) {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
secretExists: true,
|
||||
step: 'check-existing-secret',
|
||||
}))
|
||||
}));
|
||||
} else {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
// No existing secret, proceed to creating
|
||||
await runSetupGitHubActions(apiKeyToUse, state.secretName)
|
||||
await runSetupGitHubActions(apiKeyToUse, state.secretName);
|
||||
}
|
||||
} else {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
// Error checking secrets, proceed anyway
|
||||
await runSetupGitHubActions(apiKeyToUse, state.secretName)
|
||||
await runSetupGitHubActions(apiKeyToUse, state.secretName);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRepoUrlChange = (value: string) => {
|
||||
setState(prev => ({ ...prev, selectedRepoName: value }))
|
||||
}
|
||||
setState(prev => ({ ...prev, selectedRepoName: value }));
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
setState(prev => ({ ...prev, apiKeyOrOAuthToken: value }))
|
||||
}
|
||||
setState(prev => ({ ...prev, apiKeyOrOAuthToken: value }));
|
||||
};
|
||||
|
||||
const handleApiKeyOptionChange = (option: 'existing' | 'new' | 'oauth') => {
|
||||
setState(prev => ({ ...prev, selectedApiKeyOption: option }))
|
||||
}
|
||||
setState(prev => ({ ...prev, selectedApiKeyOption: option }));
|
||||
};
|
||||
|
||||
const handleCreateOAuthToken = useCallback(() => {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
setState(prev => ({ ...prev, step: 'oauth-flow' }))
|
||||
}, [])
|
||||
});
|
||||
setState(prev => ({ ...prev, step: 'oauth-flow' }));
|
||||
}, []);
|
||||
|
||||
const handleOAuthSuccess = useCallback(
|
||||
(token: string) => {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'oauth-flow' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
apiKeyOrOAuthToken: token,
|
||||
useExistingKey: false,
|
||||
secretName: 'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
authType: 'oauth_token',
|
||||
}))
|
||||
void runSetupGitHubActions(token, 'CLAUDE_CODE_OAUTH_TOKEN')
|
||||
}));
|
||||
void runSetupGitHubActions(token, 'CLAUDE_CODE_OAUTH_TOKEN');
|
||||
},
|
||||
[runSetupGitHubActions],
|
||||
)
|
||||
);
|
||||
|
||||
const handleOAuthCancel = useCallback(() => {
|
||||
setState(prev => ({ ...prev, step: 'api-key' }))
|
||||
}, [])
|
||||
setState(prev => ({ ...prev, step: 'api-key' }));
|
||||
}, []);
|
||||
|
||||
const handleSecretNameChange = (value: string) => {
|
||||
if (value && !/^[a-zA-Z0-9_]+$/.test(value)) return
|
||||
setState(prev => ({ ...prev, secretName: value }))
|
||||
}
|
||||
if (value && !/^[a-zA-Z0-9_]+$/.test(value)) return;
|
||||
setState(prev => ({ ...prev, secretName: value }));
|
||||
};
|
||||
|
||||
const handleToggleUseCurrentRepo = (useCurrentRepo: boolean) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
useCurrentRepo,
|
||||
selectedRepoName: useCurrentRepo ? prev.currentRepo : '',
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleToggleUseExistingKey = (useExistingKey: boolean) => {
|
||||
setState(prev => ({ ...prev, useExistingKey }))
|
||||
}
|
||||
setState(prev => ({ ...prev, useExistingKey }));
|
||||
};
|
||||
|
||||
const handleToggleUseExistingSecret = (useExistingSecret: boolean) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
useExistingSecret,
|
||||
secretName: useExistingSecret ? 'ANTHROPIC_API_KEY' : '',
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleWorkflowAction = async (action: 'update' | 'skip' | 'exit') => {
|
||||
if (action === 'exit') {
|
||||
props.onDone('Installation cancelled by user')
|
||||
return
|
||||
props.onDone('Installation cancelled by user');
|
||||
return;
|
||||
}
|
||||
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'check-existing-workflow' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
|
||||
setState(prev => ({ ...prev, workflowAction: action }))
|
||||
setState(prev => ({ ...prev, workflowAction: action }));
|
||||
|
||||
if (action === 'skip' || action === 'update') {
|
||||
// Check if user has existing local API key
|
||||
if (existingApiKey) {
|
||||
await checkExistingSecret()
|
||||
await checkExistingSecret();
|
||||
} else {
|
||||
// No local key, go straight to API key step
|
||||
setState(prev => ({ ...prev, step: 'api-key' }))
|
||||
setState(prev => ({ ...prev, step: 'api-key' }));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function handleDismissKeyDown(e: KeyboardEvent): void {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
if (state.step === 'success') {
|
||||
logEvent('tengu_install_github_app_completed', {})
|
||||
logEvent('tengu_install_github_app_completed', {});
|
||||
}
|
||||
props.onDone(
|
||||
state.step === 'success'
|
||||
@@ -637,16 +606,14 @@ function InstallGitHubApp(props: {
|
||||
: state.error
|
||||
? `Couldn't install GitHub App: ${state.error}\nFor manual setup instructions, see: ${GITHUB_ACTION_SETUP_DOCS_URL}`
|
||||
: `GitHub App installation failed\nFor manual setup instructions, see: ${GITHUB_ACTION_SETUP_DOCS_URL}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
switch (state.step) {
|
||||
case 'check-gh':
|
||||
return <CheckGitHubStep />
|
||||
return <CheckGitHubStep />;
|
||||
case 'warnings':
|
||||
return (
|
||||
<WarningsStep warnings={state.warnings} onContinue={handleSubmit} />
|
||||
)
|
||||
return <WarningsStep warnings={state.warnings} onContinue={handleSubmit} />;
|
||||
case 'choose-repo':
|
||||
return (
|
||||
<ChooseRepoStep
|
||||
@@ -657,21 +624,11 @@ function InstallGitHubApp(props: {
|
||||
onToggleUseCurrentRepo={handleToggleUseCurrentRepo}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)
|
||||
);
|
||||
case 'install-app':
|
||||
return (
|
||||
<InstallAppStep
|
||||
repoUrl={state.selectedRepoName}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)
|
||||
return <InstallAppStep repoUrl={state.selectedRepoName} onSubmit={handleSubmit} />;
|
||||
case 'check-existing-workflow':
|
||||
return (
|
||||
<ExistingWorkflowStep
|
||||
repoName={state.selectedRepoName}
|
||||
onSelectAction={handleWorkflowAction}
|
||||
/>
|
||||
)
|
||||
return <ExistingWorkflowStep repoName={state.selectedRepoName} onSelectAction={handleWorkflowAction} />;
|
||||
case 'check-existing-secret':
|
||||
return (
|
||||
<CheckExistingSecretStep
|
||||
@@ -681,7 +638,7 @@ function InstallGitHubApp(props: {
|
||||
onSecretNameChange={handleSecretNameChange}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)
|
||||
);
|
||||
case 'api-key':
|
||||
return (
|
||||
<ApiKeyStep
|
||||
@@ -691,13 +648,11 @@ function InstallGitHubApp(props: {
|
||||
onApiKeyChange={handleApiKeyChange}
|
||||
onToggleUseExistingKey={handleToggleUseExistingKey}
|
||||
onSubmit={handleSubmit}
|
||||
onCreateOAuthToken={
|
||||
isAnthropicAuthEnabled() ? handleCreateOAuthToken : undefined
|
||||
}
|
||||
onCreateOAuthToken={isAnthropicAuthEnabled() ? handleCreateOAuthToken : undefined}
|
||||
selectedOption={state.selectedApiKeyOption}
|
||||
onSelectOption={handleApiKeyOptionChange}
|
||||
/>
|
||||
)
|
||||
);
|
||||
case 'creating':
|
||||
return (
|
||||
<CreatingStep
|
||||
@@ -708,7 +663,7 @@ function InstallGitHubApp(props: {
|
||||
skipWorkflow={state.workflowAction === 'skip'}
|
||||
selectedWorkflows={state.selectedWorkflows}
|
||||
/>
|
||||
)
|
||||
);
|
||||
case 'success':
|
||||
return (
|
||||
<Box tabIndex={0} autoFocus onKeyDown={handleDismissKeyDown}>
|
||||
@@ -719,17 +674,13 @@ function InstallGitHubApp(props: {
|
||||
skipWorkflow={state.workflowAction === 'skip'}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<Box tabIndex={0} autoFocus onKeyDown={handleDismissKeyDown}>
|
||||
<ErrorStep
|
||||
error={state.error}
|
||||
errorReason={state.errorReason}
|
||||
errorInstructions={state.errorInstructions}
|
||||
/>
|
||||
<ErrorStep error={state.error} errorReason={state.errorReason} errorInstructions={state.errorInstructions} />
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
case 'select-workflows':
|
||||
return (
|
||||
<WorkflowMultiselectDialog
|
||||
@@ -737,33 +688,26 @@ function InstallGitHubApp(props: {
|
||||
onSubmit={selectedWorkflows => {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'select-workflows' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
selectedWorkflows,
|
||||
}))
|
||||
}));
|
||||
// Check if user has existing local API key
|
||||
if (existingApiKey) {
|
||||
void checkExistingSecret()
|
||||
void checkExistingSecret();
|
||||
} else {
|
||||
// No local key, go straight to API key step
|
||||
setState(prev => ({ ...prev, step: 'api-key' }))
|
||||
setState(prev => ({ ...prev, step: 'api-key' }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
case 'oauth-flow':
|
||||
return (
|
||||
<OAuthFlowStep
|
||||
onSuccess={handleOAuthSuccess}
|
||||
onCancel={handleOAuthCancel}
|
||||
/>
|
||||
)
|
||||
return <OAuthFlowStep onSuccess={handleOAuthSuccess} onCancel={handleOAuthCancel} />;
|
||||
}
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
): Promise<React.ReactNode> {
|
||||
return <InstallGitHubApp onDone={onDone} />
|
||||
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
|
||||
return <InstallGitHubApp onDone={onDone} />;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import type { CommandResultDisplay } from 'src/commands.js'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import { StatusIcon } from '@anthropic/ink'
|
||||
import { Box, wrappedRender as render, Text } from '@anthropic/ink'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { env } from '../utils/env.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { CommandResultDisplay } from 'src/commands.js';
|
||||
import { logEvent } from 'src/services/analytics/index.js';
|
||||
import { StatusIcon } from '@anthropic/ink';
|
||||
import { Box, wrappedRender as render, Text } from '@anthropic/ink';
|
||||
import { logForDebugging } from '../utils/debug.js';
|
||||
import { env } from '../utils/env.js';
|
||||
import { errorMessage } from '../utils/errors.js';
|
||||
import {
|
||||
checkInstall,
|
||||
cleanupNpmInstallations,
|
||||
cleanupShellAliases,
|
||||
installLatest,
|
||||
} from '../utils/nativeInstaller/index.js'
|
||||
import {
|
||||
getInitialSettings,
|
||||
updateSettingsForSource,
|
||||
} from '../utils/settings/settings.js'
|
||||
} from '../utils/nativeInstaller/index.js';
|
||||
import { getInitialSettings, updateSettingsForSource } from '../utils/settings/settings.js';
|
||||
|
||||
interface InstallProps {
|
||||
onDone: (result: string, options?: { display?: CommandResultDisplay }) => void
|
||||
force?: boolean
|
||||
target?: string // 'latest', 'stable', or version like '1.0.34'
|
||||
onDone: (result: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
force?: boolean;
|
||||
target?: string; // 'latest', 'stable', or version like '1.0.34'
|
||||
}
|
||||
|
||||
type InstallState =
|
||||
@@ -32,24 +29,24 @@ type InstallState =
|
||||
| { type: 'setting-up' }
|
||||
| { type: 'set-up'; messages: string[] }
|
||||
| { type: 'success'; version: string; setupMessages?: string[] }
|
||||
| { type: 'error'; message: string; warnings?: string[] }
|
||||
| { type: 'error'; message: string; warnings?: string[] };
|
||||
|
||||
function getInstallationPath(): string {
|
||||
const isWindows = env.platform === 'win32'
|
||||
const homeDir = homedir()
|
||||
const isWindows = env.platform === 'win32';
|
||||
const homeDir = homedir();
|
||||
|
||||
if (isWindows) {
|
||||
// Convert to Windows-style path
|
||||
const windowsPath = join(homeDir, '.local', 'bin', 'claude.exe')
|
||||
const windowsPath = join(homeDir, '.local', 'bin', 'claude.exe');
|
||||
// Replace forward slashes with backslashes for Windows display
|
||||
return windowsPath.replace(/\//g, '\\')
|
||||
return windowsPath.replace(/\//g, '\\');
|
||||
}
|
||||
|
||||
return '~/.local/bin/claude'
|
||||
return '~/.local/bin/claude';
|
||||
}
|
||||
|
||||
function SetupNotes({ messages }: { messages: string[] }): React.ReactNode {
|
||||
if (messages.length === 0) return null
|
||||
if (messages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={0} marginBottom={1}>
|
||||
@@ -65,183 +62,151 @@ function SetupNotes({ messages }: { messages: string[] }): React.ReactNode {
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Install({ onDone, force, target }: InstallProps): React.ReactNode {
|
||||
const [state, setState] = useState<InstallState>({ type: 'checking' })
|
||||
const [state, setState] = useState<InstallState>({ type: 'checking' });
|
||||
|
||||
useEffect(() => {
|
||||
async function run() {
|
||||
try {
|
||||
logForDebugging(
|
||||
`Install: Starting installation process (force=${force}, target=${target})`,
|
||||
)
|
||||
logForDebugging(`Install: Starting installation process (force=${force}, target=${target})`);
|
||||
|
||||
// Install native build first
|
||||
const channelOrVersion =
|
||||
target || getInitialSettings()?.autoUpdatesChannel || 'latest'
|
||||
setState({ type: 'installing', version: channelOrVersion })
|
||||
const channelOrVersion = target || getInitialSettings()?.autoUpdatesChannel || 'latest';
|
||||
setState({ type: 'installing', version: channelOrVersion });
|
||||
|
||||
// Pass force flag to trigger reinstall even if up to date
|
||||
logForDebugging(
|
||||
`Install: Calling installLatest(channelOrVersion=${channelOrVersion}, forceReinstall=${force})`,
|
||||
)
|
||||
const result = await installLatest(channelOrVersion, force)
|
||||
);
|
||||
const result = await installLatest(channelOrVersion, force);
|
||||
logForDebugging(
|
||||
`Install: installLatest returned version=${result.latestVersion}, wasUpdated=${result.wasUpdated}, lockFailed=${result.lockFailed}`,
|
||||
)
|
||||
);
|
||||
|
||||
// Check specifically for lock failure
|
||||
if (result.lockFailed) {
|
||||
throw new Error(
|
||||
'Could not install - another process is currently installing Claude. Please try again in a moment.',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// If we couldn't get the version, there might be an issue
|
||||
if (!result.latestVersion) {
|
||||
logForDebugging(
|
||||
'Install: Failed to retrieve version information during install',
|
||||
{ level: 'error' },
|
||||
)
|
||||
logForDebugging('Install: Failed to retrieve version information during install', { level: 'error' });
|
||||
}
|
||||
|
||||
if (!result.wasUpdated) {
|
||||
logForDebugging('Install: Already up to date')
|
||||
logForDebugging('Install: Already up to date');
|
||||
}
|
||||
|
||||
// Set up launcher and shell integration
|
||||
setState({ type: 'setting-up' })
|
||||
const setupMessages = await checkInstall(true)
|
||||
setState({ type: 'setting-up' });
|
||||
const setupMessages = await checkInstall(true);
|
||||
|
||||
logForDebugging(
|
||||
`Install: Setup launcher completed with ${setupMessages.length} messages`,
|
||||
)
|
||||
logForDebugging(`Install: Setup launcher completed with ${setupMessages.length} messages`);
|
||||
if (setupMessages.length > 0) {
|
||||
setupMessages.forEach(msg =>
|
||||
logForDebugging(`Install: Setup message: ${msg.message}`),
|
||||
)
|
||||
setupMessages.forEach(msg => logForDebugging(`Install: Setup message: ${msg.message}`));
|
||||
}
|
||||
|
||||
// Now that native installation succeeded, clean up old npm installations
|
||||
logForDebugging(
|
||||
'Install: Cleaning up npm installations after successful install',
|
||||
)
|
||||
const { removed, errors, warnings } = await cleanupNpmInstallations()
|
||||
logForDebugging('Install: Cleaning up npm installations after successful install');
|
||||
const { removed, errors, warnings } = await cleanupNpmInstallations();
|
||||
|
||||
if (removed > 0) {
|
||||
logForDebugging(`Cleaned up ${removed} npm installation(s)`)
|
||||
logForDebugging(`Cleaned up ${removed} npm installation(s)`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
logForDebugging(`Cleanup errors: ${errors.join(', ')}`)
|
||||
logForDebugging(`Cleanup errors: ${errors.join(', ')}`);
|
||||
// Continue despite cleanup errors - native install already succeeded
|
||||
}
|
||||
|
||||
// Clean up old shell aliases
|
||||
const aliasMessages = await cleanupShellAliases()
|
||||
const aliasMessages = await cleanupShellAliases();
|
||||
if (aliasMessages.length > 0) {
|
||||
logForDebugging(
|
||||
`Shell alias cleanup: ${aliasMessages.map(m => m.message).join('; ')}`,
|
||||
)
|
||||
logForDebugging(`Shell alias cleanup: ${aliasMessages.map(m => m.message).join('; ')}`);
|
||||
}
|
||||
|
||||
// Log success event
|
||||
logEvent('tengu_claude_install_command', {
|
||||
has_version: result.latestVersion ? 1 : 0,
|
||||
forced: force ? 1 : 0,
|
||||
})
|
||||
});
|
||||
|
||||
// If user explicitly specified a channel, save it to settings
|
||||
if (target === 'latest' || target === 'stable') {
|
||||
updateSettingsForSource('userSettings', {
|
||||
autoUpdatesChannel: target,
|
||||
})
|
||||
logForDebugging(
|
||||
`Install: Saved autoUpdatesChannel=${target} to user settings`,
|
||||
)
|
||||
});
|
||||
logForDebugging(`Install: Saved autoUpdatesChannel=${target} to user settings`);
|
||||
}
|
||||
|
||||
// Combine all warning/info messages (convert SetupMessage to string)
|
||||
const allWarnings = [...warnings, ...aliasMessages.map(m => m.message)]
|
||||
const allWarnings = [...warnings, ...aliasMessages.map(m => m.message)];
|
||||
|
||||
// Check if there were any setup errors or notes
|
||||
if (setupMessages.length > 0) {
|
||||
setState({
|
||||
type: 'set-up',
|
||||
messages: setupMessages.map(m => m.message),
|
||||
})
|
||||
});
|
||||
// Still mark as success but show both setup messages and cleanup warnings
|
||||
setTimeout(setState, 2000, {
|
||||
type: 'success' as const,
|
||||
version: result.latestVersion || 'current',
|
||||
setupMessages: [
|
||||
...setupMessages.map(m => m.message),
|
||||
...allWarnings,
|
||||
],
|
||||
})
|
||||
setupMessages: [...setupMessages.map(m => m.message), ...allWarnings],
|
||||
});
|
||||
} else {
|
||||
// No setup messages, go straight to success (but still show cleanup warnings if any)
|
||||
logForDebugging('Install: Shell PATH already configured')
|
||||
logForDebugging('Install: Shell PATH already configured');
|
||||
setState({
|
||||
type: 'success',
|
||||
version: result.latestVersion || 'current',
|
||||
setupMessages: allWarnings.length > 0 ? allWarnings : undefined,
|
||||
})
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logForDebugging(`Install command failed: ${error}`, {
|
||||
level: 'error',
|
||||
})
|
||||
});
|
||||
setState({
|
||||
type: 'error',
|
||||
message: errorMessage(error),
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void run()
|
||||
}, [force, target])
|
||||
void run();
|
||||
}, [force, target]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.type === 'success') {
|
||||
// Give success message time to render before exiting
|
||||
setTimeout(
|
||||
onDone,
|
||||
2000,
|
||||
'Claude Code installation completed successfully',
|
||||
{
|
||||
display: 'system' as const,
|
||||
},
|
||||
)
|
||||
setTimeout(onDone, 2000, 'Claude Code installation completed successfully', {
|
||||
display: 'system' as const,
|
||||
});
|
||||
} else if (state.type === 'error') {
|
||||
// Give error message time to render before exiting
|
||||
setTimeout(onDone, 3000, 'Claude Code installation failed', {
|
||||
display: 'system' as const,
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [state, onDone])
|
||||
}, [state, onDone]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{state.type === 'checking' && (
|
||||
<Text color="claude">Checking installation status...</Text>
|
||||
)}
|
||||
{state.type === 'checking' && <Text color="claude">Checking installation status...</Text>}
|
||||
|
||||
{state.type === 'cleaning-npm' && (
|
||||
<Text color="warning">Cleaning up old npm installations...</Text>
|
||||
)}
|
||||
{state.type === 'cleaning-npm' && <Text color="warning">Cleaning up old npm installations...</Text>}
|
||||
|
||||
{state.type === 'installing' && (
|
||||
<Text color="claude">
|
||||
Installing Claude Code native build {state.version}...
|
||||
</Text>
|
||||
<Text color="claude">Installing Claude Code native build {state.version}...</Text>
|
||||
)}
|
||||
|
||||
{state.type === 'setting-up' && (
|
||||
<Text color="claude">Setting up launcher and shell integration...</Text>
|
||||
)}
|
||||
{state.type === 'setting-up' && <Text color="claude">Setting up launcher and shell integration...</Text>}
|
||||
|
||||
{state.type === 'set-up' && <SetupNotes messages={state.messages} />}
|
||||
|
||||
@@ -291,7 +256,7 @@ function Install({ onDone, force, target }: InstallProps): React.ReactNode {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// This is only used from cli.tsx, not as a slash command
|
||||
@@ -301,27 +266,24 @@ export const install = {
|
||||
description: 'Install Claude Code native build',
|
||||
argumentHint: '[options]',
|
||||
async call(
|
||||
onDone: (
|
||||
result: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void,
|
||||
onDone: (result: string, options?: { display?: CommandResultDisplay }) => void,
|
||||
_context: unknown,
|
||||
args: string[],
|
||||
) {
|
||||
// Parse arguments
|
||||
const force = args.includes('--force')
|
||||
const nonFlagArgs = args.filter(arg => !arg.startsWith('--'))
|
||||
const target = nonFlagArgs[0] // 'latest', 'stable', or version like '1.0.34'
|
||||
const force = args.includes('--force');
|
||||
const nonFlagArgs = args.filter(arg => !arg.startsWith('--'));
|
||||
const target = nonFlagArgs[0]; // 'latest', 'stable', or version like '1.0.34'
|
||||
|
||||
const { unmount } = await render(
|
||||
<Install
|
||||
onDone={(result, options) => {
|
||||
unmount()
|
||||
onDone(result, options)
|
||||
unmount();
|
||||
onDone(result, options);
|
||||
}}
|
||||
force={force}
|
||||
target={target}
|
||||
/>,
|
||||
)
|
||||
);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js'
|
||||
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js';
|
||||
|
||||
/**
|
||||
* /job slash command — manages template jobs from inside the REPL.
|
||||
@@ -11,24 +11,24 @@ export async function call(
|
||||
_context: LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const parts = args ? args.trim().split(/\s+/) : []
|
||||
const sub = parts[0] || 'list'
|
||||
const parts = args ? args.trim().split(/\s+/) : [];
|
||||
const sub = parts[0] || 'list';
|
||||
|
||||
// Capture console output so we can return it as onDone text
|
||||
const lines: string[] = []
|
||||
const origLog = console.log
|
||||
const origError = console.error
|
||||
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '))
|
||||
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '))
|
||||
const lines: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origError = console.error;
|
||||
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '));
|
||||
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '));
|
||||
|
||||
try {
|
||||
const { templatesMain } = await import('../../cli/handlers/templateJobs.js')
|
||||
await templatesMain([sub, ...parts.slice(1)])
|
||||
const { templatesMain } = await import('../../cli/handlers/templateJobs.js');
|
||||
await templatesMain([sub, ...parts.slice(1)]);
|
||||
} finally {
|
||||
console.log = origLog
|
||||
console.error = origError
|
||||
console.log = origLog;
|
||||
console.error = origError;
|
||||
}
|
||||
|
||||
onDone(lines.join('\n') || 'Done.', { display: 'system' })
|
||||
return null
|
||||
onDone(lines.join('\n') || 'Done.', { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,81 +1,71 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import * as React from 'react'
|
||||
import { resetCostState } from '../../bootstrap/state.js'
|
||||
import {
|
||||
clearTrustedDeviceToken,
|
||||
enrollTrustedDevice,
|
||||
} from '../../bridge/trustedDevice.js'
|
||||
import type { LocalJSXCommandContext } from '../../commands.js'
|
||||
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'
|
||||
import { ConsoleOAuthFlow } from '../../components/ConsoleOAuthFlow.js'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'
|
||||
import { Text } from '@anthropic/ink'
|
||||
import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js'
|
||||
import { refreshPolicyLimits } from '../../services/policyLimits/index.js'
|
||||
import { refreshRemoteManagedSettings } from '../../services/remoteManagedSettings/index.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { stripSignatureBlocks } from '../../utils/messages.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import * as React from 'react';
|
||||
import { resetCostState } from '../../bootstrap/state.js';
|
||||
import { clearTrustedDeviceToken, enrollTrustedDevice } from '../../bridge/trustedDevice.js';
|
||||
import type { LocalJSXCommandContext } from '../../commands.js';
|
||||
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js';
|
||||
import { ConsoleOAuthFlow } from '../../components/ConsoleOAuthFlow.js';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
|
||||
import { Text } from '@anthropic/ink';
|
||||
import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js';
|
||||
import { refreshPolicyLimits } from '../../services/policyLimits/index.js';
|
||||
import { refreshRemoteManagedSettings } from '../../services/remoteManagedSettings/index.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { stripSignatureBlocks } from '../../utils/messages.js';
|
||||
import {
|
||||
checkAndDisableAutoModeIfNeeded,
|
||||
resetAutoModeGateCheck,
|
||||
} from '../../utils/permissions/bypassPermissionsKillswitch.js'
|
||||
import { resetUserCache } from '../../utils/user.js'
|
||||
} from '../../utils/permissions/bypassPermissionsKillswitch.js';
|
||||
import { resetUserCache } from '../../utils/user.js';
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: LocalJSXCommandContext,
|
||||
): Promise<React.ReactNode> {
|
||||
export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode> {
|
||||
return (
|
||||
<Login
|
||||
onDone={async success => {
|
||||
context.onChangeAPIKey()
|
||||
context.onChangeAPIKey();
|
||||
// Signature-bearing blocks (thinking, connector_text) are bound to the API key —
|
||||
// strip them so the new key doesn't reject stale signatures.
|
||||
context.setMessages(stripSignatureBlocks)
|
||||
context.setMessages(stripSignatureBlocks);
|
||||
if (success) {
|
||||
// Post-login refresh logic. Keep in sync with onboarding in src/interactiveHelpers.tsx
|
||||
// Reset cost state when switching accounts
|
||||
resetCostState()
|
||||
resetCostState();
|
||||
// Refresh remotely managed settings after login (non-blocking)
|
||||
void refreshRemoteManagedSettings()
|
||||
void refreshRemoteManagedSettings();
|
||||
// Refresh policy limits after login (non-blocking)
|
||||
void refreshPolicyLimits()
|
||||
void refreshPolicyLimits();
|
||||
// Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials
|
||||
resetUserCache()
|
||||
resetUserCache();
|
||||
// Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs)
|
||||
refreshGrowthBookAfterAuthChange()
|
||||
refreshGrowthBookAfterAuthChange();
|
||||
// Clear any stale trusted device token from a previous account before
|
||||
// re-enrolling — prevents sending the old token on bridge calls while
|
||||
// the async enrollTrustedDevice() is in-flight.
|
||||
clearTrustedDeviceToken()
|
||||
clearTrustedDeviceToken();
|
||||
// Enroll as a trusted device for Remote Control (10-min fresh-session window)
|
||||
void enrollTrustedDevice()
|
||||
void enrollTrustedDevice();
|
||||
// Reset killswitch gate checks and re-run with new org
|
||||
resetAutoModeGateCheck()
|
||||
const appState = context.getAppState()
|
||||
void checkAndDisableAutoModeIfNeeded(
|
||||
appState.toolPermissionContext,
|
||||
context.setAppState,
|
||||
appState.fastMode,
|
||||
)
|
||||
resetAutoModeGateCheck();
|
||||
const appState = context.getAppState();
|
||||
void checkAndDisableAutoModeIfNeeded(appState.toolPermissionContext, context.setAppState, appState.fastMode);
|
||||
// Increment authVersion to trigger re-fetching of auth-dependent data in hooks (e.g., MCP servers)
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
authVersion: prev.authVersion + 1,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
onDone(success ? 'Login successful' : 'Login interrupted')
|
||||
onDone(success ? 'Login successful' : 'Login interrupted');
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function Login(props: {
|
||||
onDone: (success: boolean, mainLoopModel: string) => void
|
||||
startingMessage?: string
|
||||
onDone: (success: boolean, mainLoopModel: string) => void;
|
||||
startingMessage?: string;
|
||||
}): React.ReactNode {
|
||||
const mainLoopModel = useMainLoopModel()
|
||||
const mainLoopModel = useMainLoopModel();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -86,19 +76,11 @@ export function Login(props: {
|
||||
exitState.pending ? (
|
||||
<Text>Press {exitState.keyName} again to exit</Text>
|
||||
) : (
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Confirmation"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
|
||||
)
|
||||
}
|
||||
>
|
||||
<ConsoleOAuthFlow
|
||||
onDone={() => props.onDone(true, mainLoopModel)}
|
||||
startingMessage={props.startingMessage}
|
||||
/>
|
||||
<ConsoleOAuthFlow onDone={() => props.onDone(true, mainLoopModel)} startingMessage={props.startingMessage} />
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,89 +1,80 @@
|
||||
import * as React from 'react'
|
||||
import { clearTrustedDeviceTokenCache } from '../../bridge/trustedDevice.js'
|
||||
import { Text } from '@anthropic/ink'
|
||||
import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js'
|
||||
import {
|
||||
getGroveNoticeConfig,
|
||||
getGroveSettings,
|
||||
} from '../../services/api/grove.js'
|
||||
import { clearPolicyLimitsCache } from '../../services/policyLimits/index.js'
|
||||
import * as React from 'react';
|
||||
import { clearTrustedDeviceTokenCache } from '../../bridge/trustedDevice.js';
|
||||
import { Text } from '@anthropic/ink';
|
||||
import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js';
|
||||
import { getGroveNoticeConfig, getGroveSettings } from '../../services/api/grove.js';
|
||||
import { clearPolicyLimitsCache } from '../../services/policyLimits/index.js';
|
||||
// flushTelemetry is loaded lazily to avoid pulling in ~1.1MB of OpenTelemetry at startup
|
||||
import { clearRemoteManagedSettingsCache } from '../../services/remoteManagedSettings/index.js'
|
||||
import { getClaudeAIOAuthTokens, removeApiKey } from '../../utils/auth.js'
|
||||
import { clearBetasCaches } from '../../utils/betas.js'
|
||||
import { saveGlobalConfig } from '../../utils/config.js'
|
||||
import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js'
|
||||
import { getSecureStorage } from '../../utils/secureStorage/index.js'
|
||||
import { clearToolSchemaCache } from '../../utils/toolSchemaCache.js'
|
||||
import { resetUserCache } from '../../utils/user.js'
|
||||
import { clearRemoteManagedSettingsCache } from '../../services/remoteManagedSettings/index.js';
|
||||
import { getClaudeAIOAuthTokens, removeApiKey } from '../../utils/auth.js';
|
||||
import { clearBetasCaches } from '../../utils/betas.js';
|
||||
import { saveGlobalConfig } from '../../utils/config.js';
|
||||
import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js';
|
||||
import { getSecureStorage } from '../../utils/secureStorage/index.js';
|
||||
import { clearToolSchemaCache } from '../../utils/toolSchemaCache.js';
|
||||
import { resetUserCache } from '../../utils/user.js';
|
||||
|
||||
export async function performLogout({
|
||||
clearOnboarding = false,
|
||||
}): Promise<void> {
|
||||
export async function performLogout({ clearOnboarding = false }): Promise<void> {
|
||||
// Flush telemetry BEFORE clearing credentials to prevent org data leakage
|
||||
const { flushTelemetry } = await import(
|
||||
'../../utils/telemetry/instrumentation.js'
|
||||
)
|
||||
await flushTelemetry()
|
||||
const { flushTelemetry } = await import('../../utils/telemetry/instrumentation.js');
|
||||
await flushTelemetry();
|
||||
|
||||
await removeApiKey()
|
||||
await removeApiKey();
|
||||
|
||||
// Wipe all secure storage data on logout
|
||||
const secureStorage = getSecureStorage()
|
||||
secureStorage.delete()
|
||||
const secureStorage = getSecureStorage();
|
||||
secureStorage.delete();
|
||||
|
||||
await clearAuthRelatedCaches()
|
||||
await clearAuthRelatedCaches();
|
||||
saveGlobalConfig(current => {
|
||||
const updated = { ...current }
|
||||
const updated = { ...current };
|
||||
if (clearOnboarding) {
|
||||
updated.hasCompletedOnboarding = false
|
||||
updated.subscriptionNoticeCount = 0
|
||||
updated.hasAvailableSubscription = false
|
||||
updated.hasCompletedOnboarding = false;
|
||||
updated.subscriptionNoticeCount = 0;
|
||||
updated.hasAvailableSubscription = false;
|
||||
if (updated.customApiKeyResponses?.approved) {
|
||||
updated.customApiKeyResponses = {
|
||||
...updated.customApiKeyResponses,
|
||||
approved: [],
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
updated.oauthAccount = undefined
|
||||
return updated
|
||||
})
|
||||
updated.oauthAccount = undefined;
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
// clearing anything memoized that must be invalidated when user/session/auth changes
|
||||
export async function clearAuthRelatedCaches(): Promise<void> {
|
||||
// Clear the OAuth token cache
|
||||
getClaudeAIOAuthTokens.cache?.clear?.()
|
||||
clearTrustedDeviceTokenCache()
|
||||
clearBetasCaches()
|
||||
clearToolSchemaCache()
|
||||
getClaudeAIOAuthTokens.cache?.clear?.();
|
||||
clearTrustedDeviceTokenCache();
|
||||
clearBetasCaches();
|
||||
clearToolSchemaCache();
|
||||
|
||||
// Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials
|
||||
resetUserCache()
|
||||
refreshGrowthBookAfterAuthChange()
|
||||
resetUserCache();
|
||||
refreshGrowthBookAfterAuthChange();
|
||||
|
||||
// Clear Grove config cache
|
||||
getGroveNoticeConfig.cache?.clear?.()
|
||||
getGroveSettings.cache?.clear?.()
|
||||
getGroveNoticeConfig.cache?.clear?.();
|
||||
getGroveSettings.cache?.clear?.();
|
||||
|
||||
// Clear remotely managed settings cache
|
||||
await clearRemoteManagedSettingsCache()
|
||||
await clearRemoteManagedSettingsCache();
|
||||
|
||||
// Clear policy limits cache
|
||||
await clearPolicyLimitsCache()
|
||||
await clearPolicyLimitsCache();
|
||||
}
|
||||
|
||||
export async function call(): Promise<React.ReactNode> {
|
||||
await performLogout({ clearOnboarding: true })
|
||||
await performLogout({ clearOnboarding: true });
|
||||
|
||||
const message = (
|
||||
<Text>Successfully logged out from your Anthropic account.</Text>
|
||||
)
|
||||
const message = <Text>Successfully logged out from your Anthropic account.</Text>;
|
||||
|
||||
setTimeout(() => {
|
||||
gracefulShutdownSync(0, 'logout')
|
||||
}, 200)
|
||||
gracefulShutdownSync(0, 'logout');
|
||||
}, 200);
|
||||
|
||||
return message
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { MCPSettings } from '../../components/mcp/index.js'
|
||||
import { MCPReconnect } from '../../components/mcp/MCPReconnect.js'
|
||||
import { useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js'
|
||||
import { useAppState } from '../../state/AppState.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { PluginSettings } from '../plugin/PluginSettings.js'
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { MCPSettings } from '../../components/mcp/index.js';
|
||||
import { MCPReconnect } from '../../components/mcp/MCPReconnect.js';
|
||||
import { useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js';
|
||||
import { useAppState } from '../../state/AppState.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { PluginSettings } from '../plugin/PluginSettings.js';
|
||||
|
||||
// TODO: This is a hack to get the context value from toggleMcpServer (useContext only works in a component)
|
||||
// Ideally, all MCP state and functions would be in global state.
|
||||
@@ -13,93 +13,72 @@ function MCPToggle({
|
||||
target,
|
||||
onComplete,
|
||||
}: {
|
||||
action: 'enable' | 'disable'
|
||||
target: string
|
||||
onComplete: (result: string) => void
|
||||
action: 'enable' | 'disable';
|
||||
target: string;
|
||||
onComplete: (result: string) => void;
|
||||
}): null {
|
||||
const mcpClients = useAppState(s => s.mcp.clients)
|
||||
const toggleMcpServer = useMcpToggleEnabled()
|
||||
const didRun = useRef(false)
|
||||
const mcpClients = useAppState(s => s.mcp.clients);
|
||||
const toggleMcpServer = useMcpToggleEnabled();
|
||||
const didRun = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (didRun.current) return
|
||||
didRun.current = true
|
||||
if (didRun.current) return;
|
||||
didRun.current = true;
|
||||
|
||||
const isEnabling = action === 'enable'
|
||||
const clients = mcpClients.filter(c => c.name !== 'ide')
|
||||
const isEnabling = action === 'enable';
|
||||
const clients = mcpClients.filter(c => c.name !== 'ide');
|
||||
const toToggle =
|
||||
target === 'all'
|
||||
? clients.filter(c =>
|
||||
isEnabling ? c.type === 'disabled' : c.type !== 'disabled',
|
||||
)
|
||||
: clients.filter(c => c.name === target)
|
||||
? clients.filter(c => (isEnabling ? c.type === 'disabled' : c.type !== 'disabled'))
|
||||
: clients.filter(c => c.name === target);
|
||||
|
||||
if (toToggle.length === 0) {
|
||||
onComplete(
|
||||
target === 'all'
|
||||
? `All MCP servers are already ${isEnabling ? 'enabled' : 'disabled'}`
|
||||
: `MCP server "${target}" not found`,
|
||||
)
|
||||
return
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const s of toToggle) {
|
||||
void toggleMcpServer(s.name)
|
||||
void toggleMcpServer(s.name);
|
||||
}
|
||||
|
||||
onComplete(
|
||||
target === 'all'
|
||||
? `${isEnabling ? 'Enabled' : 'Disabled'} ${toToggle.length} MCP server(s)`
|
||||
: `MCP server "${target}" ${isEnabling ? 'enabled' : 'disabled'}`,
|
||||
)
|
||||
}, [action, target, mcpClients, toggleMcpServer, onComplete])
|
||||
);
|
||||
}, [action, target, mcpClients, toggleMcpServer, onComplete]);
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
_context: unknown,
|
||||
args?: string,
|
||||
): Promise<React.ReactNode> {
|
||||
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
|
||||
if (args) {
|
||||
const parts = args.trim().split(/\s+/)
|
||||
const parts = args.trim().split(/\s+/);
|
||||
|
||||
// Allow /mcp no-redirect to bypass the redirect for testing
|
||||
if (parts[0] === 'no-redirect') {
|
||||
return <MCPSettings onComplete={onDone} />
|
||||
return <MCPSettings onComplete={onDone} />;
|
||||
}
|
||||
|
||||
if (parts[0] === 'reconnect' && parts[1]) {
|
||||
return (
|
||||
<MCPReconnect
|
||||
serverName={parts.slice(1).join(' ')}
|
||||
onComplete={onDone}
|
||||
/>
|
||||
)
|
||||
return <MCPReconnect serverName={parts.slice(1).join(' ')} onComplete={onDone} />;
|
||||
}
|
||||
|
||||
if (parts[0] === 'enable' || parts[0] === 'disable') {
|
||||
return (
|
||||
<MCPToggle
|
||||
action={parts[0]}
|
||||
target={parts.length > 1 ? parts.slice(1).join(' ') : 'all'}
|
||||
onComplete={onDone}
|
||||
/>
|
||||
)
|
||||
<MCPToggle action={parts[0]} target={parts.length > 1 ? parts.slice(1).join(' ') : 'all'} onComplete={onDone} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect base /mcp command to /plugins installed tab for ant users
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
return (
|
||||
<PluginSettings
|
||||
onComplete={onDone}
|
||||
args="manage"
|
||||
showMcpRedirectMessage
|
||||
/>
|
||||
)
|
||||
return <PluginSettings onComplete={onDone} args="manage" showMcpRedirectMessage />;
|
||||
}
|
||||
|
||||
return <MCPSettings onComplete={onDone} />
|
||||
return <MCPSettings onComplete={onDone} />;
|
||||
}
|
||||
|
||||
@@ -1,86 +1,74 @@
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import * as React from 'react'
|
||||
import type { CommandResultDisplay } from '../../commands.js'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
import { MemoryFileSelector } from '../../components/memory/MemoryFileSelector.js'
|
||||
import { getRelativeMemoryPath } from '../../components/memory/MemoryUpdateNotification.js'
|
||||
import { Box, Link, Text } from '@anthropic/ink'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import { clearMemoryFileCaches, getMemoryFiles } from '../../utils/claudemd.js'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import { getErrnoCode } from '../../utils/errors.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { editFileInEditor } from '../../utils/promptEditor.js'
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import * as React from 'react';
|
||||
import type { CommandResultDisplay } from '../../commands.js';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
import { MemoryFileSelector } from '../../components/memory/MemoryFileSelector.js';
|
||||
import { getRelativeMemoryPath } from '../../components/memory/MemoryUpdateNotification.js';
|
||||
import { Box, Link, Text } from '@anthropic/ink';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
import { clearMemoryFileCaches, getMemoryFiles } from '../../utils/claudemd.js';
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js';
|
||||
import { getErrnoCode } from '../../utils/errors.js';
|
||||
import { logError } from '../../utils/log.js';
|
||||
import { editFileInEditor } from '../../utils/promptEditor.js';
|
||||
|
||||
function MemoryCommand({
|
||||
onDone,
|
||||
}: {
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
}): React.ReactNode {
|
||||
const handleSelectMemoryFile = async (memoryPath: string) => {
|
||||
try {
|
||||
// Create claude directory if it doesn't exist (idempotent with recursive)
|
||||
if (memoryPath.includes(getClaudeConfigHomeDir())) {
|
||||
await mkdir(getClaudeConfigHomeDir(), { recursive: true })
|
||||
await mkdir(getClaudeConfigHomeDir(), { recursive: true });
|
||||
}
|
||||
|
||||
// Create file if it doesn't exist (wx flag fails if file exists,
|
||||
// which we catch to preserve existing content)
|
||||
try {
|
||||
await writeFile(memoryPath, '', { encoding: 'utf8', flag: 'wx' })
|
||||
await writeFile(memoryPath, '', { encoding: 'utf8', flag: 'wx' });
|
||||
} catch (e: unknown) {
|
||||
if (getErrnoCode(e) !== 'EEXIST') {
|
||||
throw e
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
await editFileInEditor(memoryPath)
|
||||
await editFileInEditor(memoryPath);
|
||||
|
||||
// Determine which environment variable controls the editor
|
||||
let editorSource = 'default'
|
||||
let editorValue = ''
|
||||
let editorSource = 'default';
|
||||
let editorValue = '';
|
||||
if (process.env.VISUAL) {
|
||||
editorSource = '$VISUAL'
|
||||
editorValue = process.env.VISUAL
|
||||
editorSource = '$VISUAL';
|
||||
editorValue = process.env.VISUAL;
|
||||
} else if (process.env.EDITOR) {
|
||||
editorSource = '$EDITOR'
|
||||
editorValue = process.env.EDITOR
|
||||
editorSource = '$EDITOR';
|
||||
editorValue = process.env.EDITOR;
|
||||
}
|
||||
|
||||
const editorInfo =
|
||||
editorSource !== 'default'
|
||||
? `Using ${editorSource}="${editorValue}".`
|
||||
: ''
|
||||
const editorInfo = editorSource !== 'default' ? `Using ${editorSource}="${editorValue}".` : '';
|
||||
|
||||
const editorHint = editorInfo
|
||||
? `> ${editorInfo} To change editor, set $EDITOR or $VISUAL environment variable.`
|
||||
: `> To use a different editor, set the $EDITOR or $VISUAL environment variable.`
|
||||
: `> To use a different editor, set the $EDITOR or $VISUAL environment variable.`;
|
||||
|
||||
onDone(
|
||||
`Opened memory file at ${getRelativeMemoryPath(memoryPath)}\n\n${editorHint}`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
onDone(`Opened memory file at ${getRelativeMemoryPath(memoryPath)}\n\n${editorHint}`, { display: 'system' });
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
onDone(`Error opening memory file: ${error}`)
|
||||
logError(error);
|
||||
onDone(`Error opening memory file: ${error}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onDone('Cancelled memory editing', { display: 'system' })
|
||||
}
|
||||
onDone('Cancelled memory editing', { display: 'system' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog title="Memory" onCancel={handleCancel} color="remember">
|
||||
<Box flexDirection="column">
|
||||
<React.Suspense fallback={null}>
|
||||
<MemoryFileSelector
|
||||
onSelect={handleSelectMemoryFile}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
<MemoryFileSelector onSelect={handleSelectMemoryFile} onCancel={handleCancel} />
|
||||
</React.Suspense>
|
||||
|
||||
<Box marginTop={1}>
|
||||
@@ -90,13 +78,13 @@ function MemoryCommand({
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const call: LocalJSXCommandCall = async onDone => {
|
||||
// Clear + prime before rendering — Suspense handles the unprimed case,
|
||||
// but awaiting here avoids a fallback flash on initial open.
|
||||
clearMemoryFileCaches()
|
||||
await getMemoryFiles()
|
||||
return <MemoryCommand onDone={onDone} />
|
||||
}
|
||||
clearMemoryFileCaches();
|
||||
await getMemoryFiles();
|
||||
return <MemoryCommand onDone={onDone} />;
|
||||
};
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { toString as qrToString } from 'qrcode'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Pane } from '@anthropic/ink'
|
||||
import { type KeyboardEvent, Box, Text } from '@anthropic/ink'
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { toString as qrToString } from 'qrcode';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Pane } from '@anthropic/ink';
|
||||
import { type KeyboardEvent, Box, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
|
||||
type Platform = 'ios' | 'android'
|
||||
type Platform = 'ios' | 'android';
|
||||
|
||||
type Props = {
|
||||
onDone: () => void
|
||||
}
|
||||
onDone: () => void;
|
||||
};
|
||||
|
||||
const PLATFORMS: Record<Platform, { url: string }> = {
|
||||
ios: {
|
||||
@@ -19,17 +19,17 @@ const PLATFORMS: Record<Platform, { url: string }> = {
|
||||
android: {
|
||||
url: 'https://play.google.com/store/apps/details?id=com.anthropic.claude',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
function MobileQRCode({ onDone }: Props): React.ReactNode {
|
||||
const [platform, setPlatform] = useState<Platform>('ios')
|
||||
const [platform, setPlatform] = useState<Platform>('ios');
|
||||
const [qrCodes, setQrCodes] = useState<Record<Platform, string>>({
|
||||
ios: '',
|
||||
android: '',
|
||||
})
|
||||
});
|
||||
|
||||
const { url } = PLATFORMS[platform]
|
||||
const qrCode = qrCodes[platform]
|
||||
const { url } = PLATFORMS[platform];
|
||||
const qrCode = qrCodes[platform];
|
||||
|
||||
// Generate both QR codes upfront to avoid flicker when switching
|
||||
useEffect(() => {
|
||||
@@ -43,42 +43,37 @@ function MobileQRCode({ onDone }: Props): React.ReactNode {
|
||||
type: 'utf8',
|
||||
errorCorrectionLevel: 'L',
|
||||
}),
|
||||
])
|
||||
setQrCodes({ ios, android })
|
||||
]);
|
||||
setQrCodes({ ios, android });
|
||||
}
|
||||
generateQRCodes().catch(() => {
|
||||
// QR generation failed, leave empty
|
||||
})
|
||||
}, [])
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onDone()
|
||||
}, [onDone])
|
||||
onDone();
|
||||
}, [onDone]);
|
||||
|
||||
useKeybinding('confirm:no', handleClose, { context: 'Confirmation' })
|
||||
useKeybinding('confirm:no', handleClose, { context: 'Confirmation' });
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
if (e.key === 'q' || (e.ctrl && e.key === 'c')) {
|
||||
e.preventDefault()
|
||||
onDone()
|
||||
return
|
||||
e.preventDefault();
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'tab' || e.key === 'left' || e.key === 'right') {
|
||||
e.preventDefault()
|
||||
setPlatform(prev => (prev === 'ios' ? 'android' : 'ios'))
|
||||
e.preventDefault();
|
||||
setPlatform(prev => (prev === 'ios' ? 'android' : 'ios'));
|
||||
}
|
||||
}
|
||||
|
||||
const lines = qrCode.split('\n').filter(line => line.length > 0)
|
||||
const lines = qrCode.split('\n').filter(line => line.length > 0);
|
||||
|
||||
return (
|
||||
<Pane>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Box flexDirection="column" tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
|
||||
<Text> </Text>
|
||||
<Text> </Text>
|
||||
{lines.map((line, i) => (
|
||||
@@ -94,10 +89,7 @@ function MobileQRCode({ onDone }: Props): React.ReactNode {
|
||||
iOS
|
||||
</Text>
|
||||
<Text dimColor>{' / '}</Text>
|
||||
<Text
|
||||
bold={platform === 'android'}
|
||||
underline={platform === 'android'}
|
||||
>
|
||||
<Text bold={platform === 'android'} underline={platform === 'android'}>
|
||||
Android
|
||||
</Text>
|
||||
</Text>
|
||||
@@ -106,11 +98,9 @@ function MobileQRCode({ onDone }: Props): React.ReactNode {
|
||||
<Text dimColor>{url}</Text>
|
||||
</Box>
|
||||
</Pane>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
): Promise<React.ReactNode> {
|
||||
return <MobileQRCode onDone={onDone} />
|
||||
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
|
||||
return <MobileQRCode onDone={onDone} />;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,119 +1,96 @@
|
||||
import chalk from 'chalk'
|
||||
import * as React from 'react'
|
||||
import type { CommandResultDisplay } from '../../commands.js'
|
||||
import { ModelPicker } from '../../components/ModelPicker.js'
|
||||
import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'
|
||||
import chalk from 'chalk';
|
||||
import * as React from 'react';
|
||||
import type { CommandResultDisplay } from '../../commands.js';
|
||||
import { ModelPicker } from '../../components/ModelPicker.js';
|
||||
import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import type { EffortLevel } from '../../utils/effort.js'
|
||||
import { isBilledAsExtraUsage } from '../../utils/extraUsage.js'
|
||||
} from '../../services/analytics/index.js';
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
import type { EffortLevel } from '../../utils/effort.js';
|
||||
import { isBilledAsExtraUsage } from '../../utils/extraUsage.js';
|
||||
import {
|
||||
clearFastModeCooldown,
|
||||
isFastModeAvailable,
|
||||
isFastModeEnabled,
|
||||
isFastModeSupportedByModel,
|
||||
} from '../../utils/fastMode.js'
|
||||
import { MODEL_ALIASES } from '../../utils/model/aliases.js'
|
||||
import {
|
||||
checkOpus1mAccess,
|
||||
checkSonnet1mAccess,
|
||||
} from '../../utils/model/check1mAccess.js'
|
||||
} from '../../utils/fastMode.js';
|
||||
import { MODEL_ALIASES } from '../../utils/model/aliases.js';
|
||||
import { checkOpus1mAccess, checkSonnet1mAccess } from '../../utils/model/check1mAccess.js';
|
||||
import {
|
||||
getDefaultMainLoopModelSetting,
|
||||
isOpus1mMergeEnabled,
|
||||
renderDefaultModelSetting,
|
||||
} from '../../utils/model/model.js'
|
||||
import { isModelAllowed } from '../../utils/model/modelAllowlist.js'
|
||||
import { validateModel } from '../../utils/model/validateModel.js'
|
||||
} from '../../utils/model/model.js';
|
||||
import { isModelAllowed } from '../../utils/model/modelAllowlist.js';
|
||||
import { validateModel } from '../../utils/model/validateModel.js';
|
||||
|
||||
function ModelPickerWrapper({
|
||||
onDone,
|
||||
}: {
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
}): React.ReactNode {
|
||||
const mainLoopModel = useAppState(s => s.mainLoopModel)
|
||||
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession)
|
||||
const isFastMode = useAppState(s => s.fastMode)
|
||||
const setAppState = useSetAppState()
|
||||
const mainLoopModel = useAppState(s => s.mainLoopModel);
|
||||
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession);
|
||||
const isFastMode = useAppState(s => s.fastMode);
|
||||
const setAppState = useSetAppState();
|
||||
|
||||
function handleCancel(): void {
|
||||
logEvent('tengu_model_command_menu', {
|
||||
action:
|
||||
'cancel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
const displayModel = renderModelLabel(mainLoopModel)
|
||||
action: 'cancel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
const displayModel = renderModelLabel(mainLoopModel);
|
||||
onDone(`Kept model as ${chalk.bold(displayModel)}`, {
|
||||
display: 'system',
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function handleSelect(
|
||||
model: string | null,
|
||||
effort: EffortLevel | undefined,
|
||||
): void {
|
||||
function handleSelect(model: string | null, effort: EffortLevel | undefined): void {
|
||||
logEvent('tengu_model_command_menu', {
|
||||
action:
|
||||
model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
from_model:
|
||||
mainLoopModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
to_model:
|
||||
model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
action: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
from_model: mainLoopModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
to_model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
mainLoopModel: model,
|
||||
mainLoopModelForSession: null,
|
||||
}))
|
||||
}));
|
||||
|
||||
let message = `Set model to ${chalk.bold(renderModelLabel(model))}`
|
||||
let message = `Set model to ${chalk.bold(renderModelLabel(model))}`;
|
||||
if (effort !== undefined) {
|
||||
message += ` with ${chalk.bold(effort)} effort`
|
||||
message += ` with ${chalk.bold(effort)} effort`;
|
||||
}
|
||||
|
||||
// Turn off fast mode if switching to unsupported model
|
||||
let wasFastModeToggledOn
|
||||
let wasFastModeToggledOn;
|
||||
if (isFastModeEnabled()) {
|
||||
clearFastModeCooldown()
|
||||
clearFastModeCooldown();
|
||||
if (!isFastModeSupportedByModel(model) && isFastMode) {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
fastMode: false,
|
||||
}))
|
||||
wasFastModeToggledOn = false
|
||||
}));
|
||||
wasFastModeToggledOn = false;
|
||||
// Do not update fast mode in settings since this is an automatic downgrade
|
||||
} else if (
|
||||
isFastModeSupportedByModel(model) &&
|
||||
isFastModeAvailable() &&
|
||||
isFastMode
|
||||
) {
|
||||
message += ` · Fast mode ON`
|
||||
wasFastModeToggledOn = true
|
||||
} else if (isFastModeSupportedByModel(model) && isFastModeAvailable() && isFastMode) {
|
||||
message += ` · Fast mode ON`;
|
||||
wasFastModeToggledOn = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isBilledAsExtraUsage(
|
||||
model,
|
||||
wasFastModeToggledOn === true,
|
||||
isOpus1mMergeEnabled(),
|
||||
)
|
||||
) {
|
||||
message += ` · Billed as extra usage`
|
||||
if (isBilledAsExtraUsage(model, wasFastModeToggledOn === true, isOpus1mMergeEnabled())) {
|
||||
message += ` · Billed as extra usage`;
|
||||
}
|
||||
|
||||
if (wasFastModeToggledOn === false) {
|
||||
// Fast mode was toggled off, show suffix after extra usage billing
|
||||
message += ` · Fast mode OFF`
|
||||
message += ` · Fast mode OFF`;
|
||||
}
|
||||
|
||||
onDone(message)
|
||||
onDone(message);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -124,37 +101,30 @@ function ModelPickerWrapper({
|
||||
onCancel={handleCancel}
|
||||
isStandaloneCommand
|
||||
showFastModeNotice={
|
||||
isFastModeEnabled() &&
|
||||
isFastMode &&
|
||||
isFastModeSupportedByModel(mainLoopModel) &&
|
||||
isFastModeAvailable()
|
||||
isFastModeEnabled() && isFastMode && isFastModeSupportedByModel(mainLoopModel) && isFastModeAvailable()
|
||||
}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SetModelAndClose({
|
||||
args,
|
||||
onDone,
|
||||
}: {
|
||||
args: string
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
args: string;
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
}): React.ReactNode {
|
||||
const isFastMode = useAppState(s => s.fastMode)
|
||||
const setAppState = useSetAppState()
|
||||
const model = args === 'default' ? null : args
|
||||
const isFastMode = useAppState(s => s.fastMode);
|
||||
const setAppState = useSetAppState();
|
||||
const model = args === 'default' ? null : args;
|
||||
|
||||
React.useEffect(() => {
|
||||
async function handleModelChange(): Promise<void> {
|
||||
if (model && !isModelAllowed(model)) {
|
||||
onDone(
|
||||
`Model '${model}' is not available. Your organization restricts model selection.`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return
|
||||
onDone(`Model '${model}' is not available. Your organization restricts model selection.`, {
|
||||
display: 'system',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Update check for 1M access.
|
||||
@@ -162,47 +132,47 @@ function SetModelAndClose({
|
||||
onDone(
|
||||
`Opus 4.7 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (model && isSonnet1mUnavailable(model)) {
|
||||
onDone(
|
||||
`Sonnet 4.6 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip validation for default model
|
||||
if (!model) {
|
||||
setModel(null)
|
||||
return
|
||||
setModel(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip validation for known aliases - they're predefined and should work
|
||||
if (isKnownAlias(model)) {
|
||||
setModel(model)
|
||||
return
|
||||
setModel(model);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate and set custom model
|
||||
try {
|
||||
// Don't use parseUserSpecifiedModel for non-aliases since it lowercases the input
|
||||
// and model names are case-sensitive
|
||||
const { valid, error } = await validateModel(model)
|
||||
const { valid, error } = await validateModel(model);
|
||||
|
||||
if (valid) {
|
||||
setModel(model)
|
||||
setModel(model);
|
||||
} else {
|
||||
onDone(error || `Model '${model}' not found`, {
|
||||
display: 'system',
|
||||
})
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
onDone(`Failed to validate model: ${(error as Error).message}`, {
|
||||
display: 'system',
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,127 +181,103 @@ function SetModelAndClose({
|
||||
...prev,
|
||||
mainLoopModel: modelValue,
|
||||
mainLoopModelForSession: null,
|
||||
}))
|
||||
let message = `Set model to ${chalk.bold(renderModelLabel(modelValue))}`
|
||||
}));
|
||||
let message = `Set model to ${chalk.bold(renderModelLabel(modelValue))}`;
|
||||
|
||||
let wasFastModeToggledOn
|
||||
let wasFastModeToggledOn;
|
||||
if (isFastModeEnabled()) {
|
||||
clearFastModeCooldown()
|
||||
clearFastModeCooldown();
|
||||
if (!isFastModeSupportedByModel(modelValue) && isFastMode) {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
fastMode: false,
|
||||
}))
|
||||
wasFastModeToggledOn = false
|
||||
}));
|
||||
wasFastModeToggledOn = false;
|
||||
// Do not update fast mode in settings since this is an automatic downgrade
|
||||
} else if (isFastModeSupportedByModel(modelValue) && isFastMode) {
|
||||
message += ` · Fast mode ON`
|
||||
wasFastModeToggledOn = true
|
||||
message += ` · Fast mode ON`;
|
||||
wasFastModeToggledOn = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isBilledAsExtraUsage(
|
||||
modelValue,
|
||||
wasFastModeToggledOn === true,
|
||||
isOpus1mMergeEnabled(),
|
||||
)
|
||||
) {
|
||||
message += ` · Billed as extra usage`
|
||||
if (isBilledAsExtraUsage(modelValue, wasFastModeToggledOn === true, isOpus1mMergeEnabled())) {
|
||||
message += ` · Billed as extra usage`;
|
||||
}
|
||||
|
||||
if (wasFastModeToggledOn === false) {
|
||||
// Fast mode was toggled off, show suffix after extra usage billing
|
||||
message += ` · Fast mode OFF`
|
||||
message += ` · Fast mode OFF`;
|
||||
}
|
||||
|
||||
onDone(message)
|
||||
onDone(message);
|
||||
}
|
||||
|
||||
void handleModelChange()
|
||||
}, [model, onDone, setAppState])
|
||||
void handleModelChange();
|
||||
}, [model, onDone, setAppState]);
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
function isKnownAlias(model: string): boolean {
|
||||
return (MODEL_ALIASES as readonly string[]).includes(
|
||||
model.toLowerCase().trim(),
|
||||
)
|
||||
return (MODEL_ALIASES as readonly string[]).includes(model.toLowerCase().trim());
|
||||
}
|
||||
|
||||
function isOpus1mUnavailable(model: string): boolean {
|
||||
const m = model.toLowerCase()
|
||||
return (
|
||||
!checkOpus1mAccess() &&
|
||||
!isOpus1mMergeEnabled() &&
|
||||
m.includes('opus') &&
|
||||
m.includes('[1m]')
|
||||
)
|
||||
const m = model.toLowerCase();
|
||||
return !checkOpus1mAccess() && !isOpus1mMergeEnabled() && m.includes('opus') && m.includes('[1m]');
|
||||
}
|
||||
|
||||
function isSonnet1mUnavailable(model: string): boolean {
|
||||
const m = model.toLowerCase()
|
||||
const m = model.toLowerCase();
|
||||
// Warn about Sonnet and Sonnet 4.6, but not Sonnet 4.5 since that had
|
||||
// a different access criteria.
|
||||
return (
|
||||
!checkSonnet1mAccess() &&
|
||||
(m.includes('sonnet[1m]') || m.includes('sonnet-4-6[1m]'))
|
||||
)
|
||||
return !checkSonnet1mAccess() && (m.includes('sonnet[1m]') || m.includes('sonnet-4-6[1m]'));
|
||||
}
|
||||
|
||||
function ShowModelAndClose({
|
||||
onDone,
|
||||
}: {
|
||||
onDone: (result?: string) => void
|
||||
}): React.ReactNode {
|
||||
const mainLoopModel = useAppState(s => s.mainLoopModel)
|
||||
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession)
|
||||
const effortValue = useAppState(s => s.effortValue)
|
||||
const displayModel = renderModelLabel(mainLoopModel)
|
||||
const effortInfo =
|
||||
effortValue !== undefined ? ` (effort: ${effortValue})` : ''
|
||||
function ShowModelAndClose({ onDone }: { onDone: (result?: string) => void }): React.ReactNode {
|
||||
const mainLoopModel = useAppState(s => s.mainLoopModel);
|
||||
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession);
|
||||
const effortValue = useAppState(s => s.effortValue);
|
||||
const displayModel = renderModelLabel(mainLoopModel);
|
||||
const effortInfo = effortValue !== undefined ? ` (effort: ${effortValue})` : '';
|
||||
|
||||
if (mainLoopModelForSession) {
|
||||
onDone(
|
||||
`Current model: ${chalk.bold(renderModelLabel(mainLoopModelForSession))} (session override from plan mode)\nBase model: ${displayModel}${effortInfo}`,
|
||||
)
|
||||
);
|
||||
} else {
|
||||
onDone(`Current model: ${displayModel}${effortInfo}`)
|
||||
onDone(`Current model: ${displayModel}${effortInfo}`);
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, _context, args) => {
|
||||
args = args?.trim() || ''
|
||||
args = args?.trim() || '';
|
||||
if (COMMON_INFO_ARGS.includes(args)) {
|
||||
logEvent('tengu_model_command_inline_help', {
|
||||
args: args as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
return <ShowModelAndClose onDone={onDone} />
|
||||
});
|
||||
return <ShowModelAndClose onDone={onDone} />;
|
||||
}
|
||||
if (COMMON_HELP_ARGS.includes(args)) {
|
||||
onDone(
|
||||
'Run /model to open the model selection menu, or /model [modelName] to set the model.',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return
|
||||
onDone('Run /model to open the model selection menu, or /model [modelName] to set the model.', {
|
||||
display: 'system',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (args) {
|
||||
logEvent('tengu_model_command_inline', {
|
||||
args: args as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
return <SetModelAndClose args={args} onDone={onDone} />
|
||||
});
|
||||
return <SetModelAndClose args={args} onDone={onDone} />;
|
||||
}
|
||||
|
||||
return <ModelPickerWrapper onDone={onDone} />
|
||||
}
|
||||
return <ModelPickerWrapper onDone={onDone} />;
|
||||
};
|
||||
|
||||
function renderModelLabel(model: string | null): string {
|
||||
const rendered = renderDefaultModelSetting(
|
||||
model ?? getDefaultMainLoopModelSetting(),
|
||||
)
|
||||
return model === null ? `${rendered} (default)` : rendered
|
||||
const rendered = renderDefaultModelSetting(model ?? getDefaultMainLoopModelSetting());
|
||||
return model === null ? `${rendered} (default)` : rendered;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
|
||||
export async function call(onDone: LocalJSXCommandOnDone): Promise<undefined> {
|
||||
onDone(
|
||||
'/output-style has been deprecated. Use /config to change your output style, or set it in your settings file. Changes take effect on the next session.',
|
||||
{ display: 'system' },
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import * as React from 'react'
|
||||
import { Passes } from '../../components/Passes/Passes.js'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import { getCachedRemainingPasses } from '../../services/api/referral.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import * as React from 'react';
|
||||
import { Passes } from '../../components/Passes/Passes.js';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import { getCachedRemainingPasses } from '../../services/api/referral.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
): Promise<React.ReactNode> {
|
||||
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
|
||||
// Mark that user has visited /passes so we stop showing the upsell
|
||||
const config = getGlobalConfig()
|
||||
const isFirstVisit = !config.hasVisitedPasses
|
||||
const config = getGlobalConfig();
|
||||
const isFirstVisit = !config.hasVisitedPasses;
|
||||
if (isFirstVisit) {
|
||||
const remaining = getCachedRemainingPasses()
|
||||
const remaining = getCachedRemainingPasses();
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
hasVisitedPasses: true,
|
||||
passesLastSeenRemaining: remaining ?? current.passesLastSeenRemaining,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
logEvent('tengu_guest_passes_visited', { is_first_visit: isFirstVisit })
|
||||
return <Passes onDone={onDone} />
|
||||
logEvent('tengu_guest_passes_visited', { is_first_visit: isFirstVisit });
|
||||
return <Passes onDone={onDone} />;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import * as React from 'react'
|
||||
import { PermissionRuleList } from '../../components/permissions/rules/PermissionRuleList.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import { createPermissionRetryMessage } from '../../utils/messages.js'
|
||||
import * as React from 'react';
|
||||
import { PermissionRuleList } from '../../components/permissions/rules/PermissionRuleList.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
import { createPermissionRetryMessage } from '../../utils/messages.js';
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
return (
|
||||
<PermissionRuleList
|
||||
onExit={onDone}
|
||||
onRetryDenials={commands => {
|
||||
context.setMessages(prev => [
|
||||
...prev,
|
||||
createPermissionRetryMessage(commands),
|
||||
])
|
||||
context.setMessages(prev => [...prev, createPermissionRetryMessage(commands)]);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,9 @@ import plan from './index.js'
|
||||
describe('plan bridge invocation safety', () => {
|
||||
test('allows headless plan mode operations over Remote Control', () => {
|
||||
expect(plan.getBridgeInvocationError?.('')).toBeUndefined()
|
||||
expect(plan.getBridgeInvocationError?.('write a migration plan')).toBeUndefined()
|
||||
expect(
|
||||
plan.getBridgeInvocationError?.('write a migration plan'),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test('blocks /plan open over Remote Control', () => {
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import * as React from 'react'
|
||||
import { handlePlanModeTransition } from '../../bootstrap/state.js'
|
||||
import type { LocalJSXCommandContext } from '../../commands.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { getExternalEditor } from '../../utils/editor.js'
|
||||
import { toIDEDisplayName } from '../../utils/ide.js'
|
||||
import { applyPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js'
|
||||
import { prepareContextForPlanMode } from '../../utils/permissions/permissionSetup.js'
|
||||
import { getPlan, getPlanFilePath } from '../../utils/plans.js'
|
||||
import { editFileInEditor } from '../../utils/promptEditor.js'
|
||||
import { renderToString } from '../../utils/staticRender.js'
|
||||
import * as React from 'react';
|
||||
import { handlePlanModeTransition } from '../../bootstrap/state.js';
|
||||
import type { LocalJSXCommandContext } from '../../commands.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { getExternalEditor } from '../../utils/editor.js';
|
||||
import { toIDEDisplayName } from '../../utils/ide.js';
|
||||
import { applyPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js';
|
||||
import { prepareContextForPlanMode } from '../../utils/permissions/permissionSetup.js';
|
||||
import { getPlan, getPlanFilePath } from '../../utils/plans.js';
|
||||
import { editFileInEditor } from '../../utils/promptEditor.js';
|
||||
import { renderToString } from '../../utils/staticRender.js';
|
||||
|
||||
function PlanDisplay({
|
||||
planContent,
|
||||
planPath,
|
||||
editorName,
|
||||
}: {
|
||||
planContent: string
|
||||
planPath: string
|
||||
editorName: string | undefined
|
||||
planContent: string;
|
||||
planPath: string;
|
||||
editorName: string | undefined;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -37,7 +37,7 @@ function PlanDisplay({
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function call(
|
||||
@@ -45,63 +45,58 @@ export async function call(
|
||||
context: LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const { getAppState, setAppState } = context
|
||||
const appState = getAppState()
|
||||
const currentMode = appState.toolPermissionContext.mode
|
||||
const { getAppState, setAppState } = context;
|
||||
const appState = getAppState();
|
||||
const currentMode = appState.toolPermissionContext.mode;
|
||||
|
||||
// If not in plan mode, enable it
|
||||
if (currentMode !== 'plan') {
|
||||
handlePlanModeTransition(currentMode, 'plan')
|
||||
handlePlanModeTransition(currentMode, 'plan');
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
toolPermissionContext: applyPermissionUpdate(
|
||||
prepareContextForPlanMode(prev.toolPermissionContext),
|
||||
{ type: 'setMode', mode: 'plan', destination: 'session' },
|
||||
),
|
||||
}))
|
||||
const description = args.trim()
|
||||
toolPermissionContext: applyPermissionUpdate(prepareContextForPlanMode(prev.toolPermissionContext), {
|
||||
type: 'setMode',
|
||||
mode: 'plan',
|
||||
destination: 'session',
|
||||
}),
|
||||
}));
|
||||
const description = args.trim();
|
||||
if (description && description !== 'open') {
|
||||
onDone('Enabled plan mode', { shouldQuery: true })
|
||||
onDone('Enabled plan mode', { shouldQuery: true });
|
||||
} else {
|
||||
onDone('Enabled plan mode')
|
||||
onDone('Enabled plan mode');
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// Already in plan mode - show the current plan
|
||||
const planContent = getPlan()
|
||||
const planPath = getPlanFilePath()
|
||||
const planContent = getPlan();
|
||||
const planPath = getPlanFilePath();
|
||||
|
||||
if (!planContent) {
|
||||
onDone('Already in plan mode. No plan written yet.')
|
||||
return null
|
||||
onDone('Already in plan mode. No plan written yet.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// If user typed "/plan open", open in editor
|
||||
const argList = args.trim().split(/\s+/)
|
||||
const argList = args.trim().split(/\s+/);
|
||||
if (argList[0] === 'open') {
|
||||
const result = await editFileInEditor(planPath)
|
||||
const result = await editFileInEditor(planPath);
|
||||
if (result.error) {
|
||||
onDone(`Failed to open plan in editor: ${result.error}`)
|
||||
onDone(`Failed to open plan in editor: ${result.error}`);
|
||||
} else {
|
||||
onDone(`Opened plan in editor: ${planPath}`)
|
||||
onDone(`Opened plan in editor: ${planPath}`);
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const editor = getExternalEditor()
|
||||
const editorName = editor ? toIDEDisplayName(editor) : undefined
|
||||
const editor = getExternalEditor();
|
||||
const editorName = editor ? toIDEDisplayName(editor) : undefined;
|
||||
|
||||
const display = (
|
||||
<PlanDisplay
|
||||
planContent={planContent}
|
||||
planPath={planPath}
|
||||
editorName={editorName}
|
||||
/>
|
||||
)
|
||||
const display = <PlanDisplay planContent={planContent} planPath={planPath} editorName={editorName} />;
|
||||
|
||||
// Render to string and pass to onDone like local commands do
|
||||
const output = await renderToString(display)
|
||||
onDone(output)
|
||||
return null
|
||||
const output = await renderToString(display);
|
||||
onDone(output);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
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 { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'
|
||||
import { Byline, KeyboardShortcutHint } from '@anthropic/ink'
|
||||
import { Spinner } from '../../components/Spinner.js'
|
||||
import TextInput from '../../components/TextInput.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { toError } from '../../utils/errors.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'
|
||||
import {
|
||||
addMarketplaceSource,
|
||||
saveMarketplaceToSettings,
|
||||
} from '../../utils/plugins/marketplaceManager.js'
|
||||
import { parseMarketplaceInput } from '../../utils/plugins/parseMarketplaceInput.js'
|
||||
import type { ViewState } from './types.js'
|
||||
} from 'src/services/analytics/index.js';
|
||||
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js';
|
||||
import { Byline, KeyboardShortcutHint } from '@anthropic/ink';
|
||||
import { Spinner } from '../../components/Spinner.js';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { toError } from '../../utils/errors.js';
|
||||
import { logError } from '../../utils/log.js';
|
||||
import { clearAllCaches } from '../../utils/plugins/cacheUtils.js';
|
||||
import { addMarketplaceSource, saveMarketplaceToSettings } from '../../utils/plugins/marketplaceManager.js';
|
||||
import { parseMarketplaceInput } from '../../utils/plugins/parseMarketplaceInput.js';
|
||||
import type { ViewState } from './types.js';
|
||||
|
||||
type Props = {
|
||||
inputValue: string
|
||||
setInputValue: (value: string) => void
|
||||
cursorOffset: number
|
||||
setCursorOffset: (offset: number) => void
|
||||
error: string | null
|
||||
setError: (error: string | null) => void
|
||||
result: string | null
|
||||
setResult: (result: string | null) => void
|
||||
setViewState: (state: ViewState) => void
|
||||
onAddComplete?: () => void | Promise<void>
|
||||
cliMode?: boolean
|
||||
}
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
cursorOffset: number;
|
||||
setCursorOffset: (offset: number) => void;
|
||||
error: string | null;
|
||||
setError: (error: string | null) => void;
|
||||
result: string | null;
|
||||
setResult: (result: string | null) => void;
|
||||
setViewState: (state: ViewState) => void;
|
||||
onAddComplete?: () => void | Promise<void>;
|
||||
cliMode?: boolean;
|
||||
};
|
||||
|
||||
export function AddMarketplace({
|
||||
inputValue,
|
||||
@@ -46,94 +43,87 @@ export function AddMarketplace({
|
||||
onAddComplete,
|
||||
cliMode = false,
|
||||
}: Props): React.ReactNode {
|
||||
const hasAttemptedAutoAdd = useRef(false)
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
const [progressMessage, setProgressMessage] = useState<string>('')
|
||||
const hasAttemptedAutoAdd = useRef(false);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [progressMessage, setProgressMessage] = useState<string>('');
|
||||
|
||||
const handleAdd = async () => {
|
||||
const input = inputValue.trim()
|
||||
const input = inputValue.trim();
|
||||
if (!input) {
|
||||
setError('Please enter a marketplace source')
|
||||
return
|
||||
setError('Please enter a marketplace source');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = await parseMarketplaceInput(input)
|
||||
const parsed = await parseMarketplaceInput(input);
|
||||
if (!parsed) {
|
||||
setError(
|
||||
'Invalid marketplace source format. Try: owner/repo, https://..., or ./path',
|
||||
)
|
||||
return
|
||||
setError('Invalid marketplace source format. Try: owner/repo, https://..., or ./path');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if parseMarketplaceInput returned an error
|
||||
if ('error' in parsed) {
|
||||
setError(parsed.error)
|
||||
return
|
||||
setError(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setProgressMessage('')
|
||||
const { name, resolvedSource } = await addMarketplaceSource(
|
||||
parsed,
|
||||
message => {
|
||||
setProgressMessage(message)
|
||||
},
|
||||
)
|
||||
saveMarketplaceToSettings(name, { source: resolvedSource })
|
||||
clearAllCaches()
|
||||
setLoading(true);
|
||||
setProgressMessage('');
|
||||
const { name, resolvedSource } = await addMarketplaceSource(parsed, message => {
|
||||
setProgressMessage(message);
|
||||
});
|
||||
saveMarketplaceToSettings(name, { source: resolvedSource });
|
||||
clearAllCaches();
|
||||
|
||||
let sourceType = parsed.source
|
||||
let sourceType = parsed.source;
|
||||
if (parsed.source === 'github') {
|
||||
sourceType =
|
||||
parsed.repo as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
sourceType = parsed.repo as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS;
|
||||
}
|
||||
|
||||
logEvent('tengu_marketplace_added', {
|
||||
source_type:
|
||||
sourceType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
source_type: sourceType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
|
||||
if (onAddComplete) {
|
||||
await onAddComplete()
|
||||
await onAddComplete();
|
||||
}
|
||||
|
||||
setProgressMessage('')
|
||||
setLoading(false)
|
||||
setProgressMessage('');
|
||||
setLoading(false);
|
||||
|
||||
if (cliMode) {
|
||||
// In CLI mode, set result to trigger completion
|
||||
setResult(`Successfully added marketplace: ${name}`)
|
||||
setResult(`Successfully added marketplace: ${name}`);
|
||||
} else {
|
||||
// In interactive mode, switch to browse view
|
||||
setViewState({ type: 'browse-marketplace', targetMarketplace: name })
|
||||
setViewState({ type: 'browse-marketplace', targetMarketplace: name });
|
||||
}
|
||||
} catch (err) {
|
||||
const error = toError(err)
|
||||
logError(error)
|
||||
setError(error.message)
|
||||
setProgressMessage('')
|
||||
setLoading(false)
|
||||
const error = toError(err);
|
||||
logError(error);
|
||||
setError(error.message);
|
||||
setProgressMessage('');
|
||||
setLoading(false);
|
||||
|
||||
if (cliMode) {
|
||||
// In CLI mode, set result with error to trigger completion
|
||||
setResult(`Error: ${error.message}`)
|
||||
setResult(`Error: ${error.message}`);
|
||||
} else {
|
||||
setResult(null)
|
||||
setResult(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-add if inputValue is provided
|
||||
useEffect(() => {
|
||||
if (inputValue && !hasAttemptedAutoAdd.current && !error && !result) {
|
||||
hasAttemptedAutoAdd.current = true
|
||||
void handleAdd()
|
||||
hasAttemptedAutoAdd.current = true;
|
||||
void handleAdd();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []) // Only run once on mount
|
||||
}, []); // Only run once on mount
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -164,9 +154,7 @@ export function AddMarketplace({
|
||||
{isLoading && (
|
||||
<Box marginTop={1}>
|
||||
<Spinner />
|
||||
<Text>
|
||||
{progressMessage || 'Adding marketplace to configuration…'}
|
||||
</Text>
|
||||
<Text>{progressMessage || 'Adding marketplace to configuration…'}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
@@ -184,15 +172,10 @@ export function AddMarketplace({
|
||||
<Text dimColor italic>
|
||||
<Byline>
|
||||
<KeyboardShortcutHint shortcut="Enter" action="add" />
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Settings"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
<ConfigurableShortcutHint action="confirm:no" context="Settings" fallback="Esc" description="cancel" />
|
||||
</Byline>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,140 +1,139 @@
|
||||
import { getPluginErrorMessage, type PluginError } from '../../types/plugin.js'
|
||||
import { getPluginErrorMessage, type PluginError } from '../../types/plugin.js';
|
||||
|
||||
export function formatErrorMessage(error: PluginError): string {
|
||||
switch (error.type) {
|
||||
case 'path-not-found':
|
||||
return `${error.component} path not found: ${error.path}`
|
||||
return `${error.component} path not found: ${error.path}`;
|
||||
case 'git-auth-failed':
|
||||
return `Git ${error.authType.toUpperCase()} authentication failed for ${error.gitUrl}`
|
||||
return `Git ${error.authType.toUpperCase()} authentication failed for ${error.gitUrl}`;
|
||||
case 'git-timeout':
|
||||
return `Git ${error.operation} timed out for ${error.gitUrl}`
|
||||
return `Git ${error.operation} timed out for ${error.gitUrl}`;
|
||||
case 'network-error':
|
||||
return `Network error accessing ${error.url}${error.details ? `: ${error.details}` : ''}`
|
||||
return `Network error accessing ${error.url}${error.details ? `: ${error.details}` : ''}`;
|
||||
case 'manifest-parse-error':
|
||||
return `Failed to parse manifest at ${error.manifestPath}: ${error.parseError}`
|
||||
return `Failed to parse manifest at ${error.manifestPath}: ${error.parseError}`;
|
||||
case 'manifest-validation-error':
|
||||
return `Invalid manifest at ${error.manifestPath}: ${error.validationErrors.join(', ')}`
|
||||
return `Invalid manifest at ${error.manifestPath}: ${error.validationErrors.join(', ')}`;
|
||||
case 'plugin-not-found':
|
||||
return `Plugin "${error.pluginId}" not found in marketplace "${error.marketplace}"`
|
||||
return `Plugin "${error.pluginId}" not found in marketplace "${error.marketplace}"`;
|
||||
case 'marketplace-not-found':
|
||||
return `Marketplace "${error.marketplace}" not found`
|
||||
return `Marketplace "${error.marketplace}" not found`;
|
||||
case 'marketplace-load-failed':
|
||||
return `Failed to load marketplace "${error.marketplace}": ${error.reason}`
|
||||
return `Failed to load marketplace "${error.marketplace}": ${error.reason}`;
|
||||
case 'mcp-config-invalid':
|
||||
return `Invalid MCP server config for "${error.serverName}": ${error.validationError}`
|
||||
return `Invalid MCP server config for "${error.serverName}": ${error.validationError}`;
|
||||
case 'mcp-server-suppressed-duplicate': {
|
||||
const dup = error.duplicateOf.startsWith('plugin:')
|
||||
? `server provided by plugin "${error.duplicateOf.split(':')[1] ?? '?'}"`
|
||||
: `already-configured "${error.duplicateOf}"`
|
||||
return `MCP server "${error.serverName}" skipped — same command/URL as ${dup}`
|
||||
: `already-configured "${error.duplicateOf}"`;
|
||||
return `MCP server "${error.serverName}" skipped — same command/URL as ${dup}`;
|
||||
}
|
||||
case 'hook-load-failed':
|
||||
return `Failed to load hooks from ${error.hookPath}: ${error.reason}`
|
||||
return `Failed to load hooks from ${error.hookPath}: ${error.reason}`;
|
||||
case 'component-load-failed':
|
||||
return `Failed to load ${error.component} from ${error.path}: ${error.reason}`
|
||||
return `Failed to load ${error.component} from ${error.path}: ${error.reason}`;
|
||||
case 'mcpb-download-failed':
|
||||
return `Failed to download MCPB from ${error.url}: ${error.reason}`
|
||||
return `Failed to download MCPB from ${error.url}: ${error.reason}`;
|
||||
case 'mcpb-extract-failed':
|
||||
return `Failed to extract MCPB ${error.mcpbPath}: ${error.reason}`
|
||||
return `Failed to extract MCPB ${error.mcpbPath}: ${error.reason}`;
|
||||
case 'mcpb-invalid-manifest':
|
||||
return `MCPB manifest invalid at ${error.mcpbPath}: ${error.validationError}`
|
||||
return `MCPB manifest invalid at ${error.mcpbPath}: ${error.validationError}`;
|
||||
case 'marketplace-blocked-by-policy':
|
||||
return error.blockedByBlocklist
|
||||
? `Marketplace "${error.marketplace}" is blocked by enterprise policy`
|
||||
: `Marketplace "${error.marketplace}" is not in the allowed marketplace list`
|
||||
: `Marketplace "${error.marketplace}" is not in the allowed marketplace list`;
|
||||
case 'dependency-unsatisfied':
|
||||
return error.reason === 'not-enabled'
|
||||
? `Dependency "${error.dependency}" is disabled`
|
||||
: `Dependency "${error.dependency}" is not installed`
|
||||
: `Dependency "${error.dependency}" is not installed`;
|
||||
case 'lsp-config-invalid':
|
||||
return `Invalid LSP server config for "${error.serverName}": ${error.validationError}`
|
||||
return `Invalid LSP server config for "${error.serverName}": ${error.validationError}`;
|
||||
case 'lsp-server-start-failed':
|
||||
return `LSP server "${error.serverName}" failed to start: ${error.reason}`
|
||||
return `LSP server "${error.serverName}" failed to start: ${error.reason}`;
|
||||
case 'lsp-server-crashed':
|
||||
return error.signal
|
||||
? `LSP server "${error.serverName}" crashed with signal ${error.signal}`
|
||||
: `LSP server "${error.serverName}" crashed with exit code ${error.exitCode ?? 'unknown'}`
|
||||
: `LSP server "${error.serverName}" crashed with exit code ${error.exitCode ?? 'unknown'}`;
|
||||
case 'lsp-request-timeout':
|
||||
return `LSP server "${error.serverName}" timed out on ${error.method} after ${error.timeoutMs}ms`
|
||||
return `LSP server "${error.serverName}" timed out on ${error.method} after ${error.timeoutMs}ms`;
|
||||
case 'lsp-request-failed':
|
||||
return `LSP server "${error.serverName}" ${error.method} failed: ${error.error}`
|
||||
return `LSP server "${error.serverName}" ${error.method} failed: ${error.error}`;
|
||||
case 'plugin-cache-miss':
|
||||
return `Plugin "${error.plugin}" not cached at ${error.installPath}`
|
||||
return `Plugin "${error.plugin}" not cached at ${error.installPath}`;
|
||||
case 'generic-error':
|
||||
return error.error
|
||||
return error.error;
|
||||
}
|
||||
const _exhaustive: never = error
|
||||
return getPluginErrorMessage(_exhaustive)
|
||||
const _exhaustive: never = error;
|
||||
return getPluginErrorMessage(_exhaustive);
|
||||
}
|
||||
|
||||
export function getErrorGuidance(error: PluginError): string | null {
|
||||
switch (error.type) {
|
||||
case 'path-not-found':
|
||||
return 'Check that the path in your manifest or marketplace config is correct'
|
||||
return 'Check that the path in your manifest or marketplace config is correct';
|
||||
case 'git-auth-failed':
|
||||
return error.authType === 'ssh'
|
||||
? 'Configure SSH keys or use HTTPS URL instead'
|
||||
: 'Configure credentials or use SSH URL instead'
|
||||
: 'Configure credentials or use SSH URL instead';
|
||||
case 'git-timeout':
|
||||
case 'network-error':
|
||||
return 'Check your internet connection and try again'
|
||||
return 'Check your internet connection and try again';
|
||||
case 'manifest-parse-error':
|
||||
return 'Check manifest file syntax in the plugin directory'
|
||||
return 'Check manifest file syntax in the plugin directory';
|
||||
case 'manifest-validation-error':
|
||||
return 'Check manifest file follows the required schema'
|
||||
return 'Check manifest file follows the required schema';
|
||||
case 'plugin-not-found':
|
||||
return `Plugin may not exist in marketplace "${error.marketplace}"`
|
||||
return `Plugin may not exist in marketplace "${error.marketplace}"`;
|
||||
case 'marketplace-not-found':
|
||||
return error.availableMarketplaces.length > 0
|
||||
? `Available marketplaces: ${error.availableMarketplaces.join(', ')}`
|
||||
: 'Add the marketplace first using /plugin marketplace add'
|
||||
: 'Add the marketplace first using /plugin marketplace add';
|
||||
case 'mcp-config-invalid':
|
||||
return 'Check MCP server configuration in .mcp.json or manifest'
|
||||
return 'Check MCP server configuration in .mcp.json or manifest';
|
||||
case 'mcp-server-suppressed-duplicate': {
|
||||
// duplicateOf is "plugin:name:srv" when another plugin won dedup —
|
||||
// users can't remove plugin-provided servers from their MCP config,
|
||||
// so point them at the winning plugin instead.
|
||||
if (error.duplicateOf.startsWith('plugin:')) {
|
||||
const winningPlugin =
|
||||
error.duplicateOf.split(':')[1] ?? 'the other plugin'
|
||||
return `Disable plugin "${winningPlugin}" if you want this plugin's version instead`
|
||||
const winningPlugin = error.duplicateOf.split(':')[1] ?? 'the other plugin';
|
||||
return `Disable plugin "${winningPlugin}" if you want this plugin's version instead`;
|
||||
}
|
||||
return `Remove "${error.duplicateOf}" from your MCP config if you want the plugin's version instead`
|
||||
return `Remove "${error.duplicateOf}" from your MCP config if you want the plugin's version instead`;
|
||||
}
|
||||
case 'hook-load-failed':
|
||||
return 'Check hooks.json file syntax and structure'
|
||||
return 'Check hooks.json file syntax and structure';
|
||||
case 'component-load-failed':
|
||||
return `Check ${error.component} directory structure and file permissions`
|
||||
return `Check ${error.component} directory structure and file permissions`;
|
||||
case 'mcpb-download-failed':
|
||||
return 'Check your internet connection and URL accessibility'
|
||||
return 'Check your internet connection and URL accessibility';
|
||||
case 'mcpb-extract-failed':
|
||||
return 'Verify the MCPB file is valid and not corrupted'
|
||||
return 'Verify the MCPB file is valid and not corrupted';
|
||||
case 'mcpb-invalid-manifest':
|
||||
return 'Contact the plugin author about the invalid manifest'
|
||||
return 'Contact the plugin author about the invalid manifest';
|
||||
case 'marketplace-blocked-by-policy':
|
||||
if (error.blockedByBlocklist) {
|
||||
return 'This marketplace source is explicitly blocked by your administrator'
|
||||
return 'This marketplace source is explicitly blocked by your administrator';
|
||||
}
|
||||
return error.allowedSources.length > 0
|
||||
? `Allowed sources: ${error.allowedSources.join(', ')}`
|
||||
: 'Contact your administrator to configure allowed marketplace sources'
|
||||
: 'Contact your administrator to configure allowed marketplace sources';
|
||||
case 'dependency-unsatisfied':
|
||||
return error.reason === 'not-enabled'
|
||||
? `Enable "${error.dependency}" or uninstall "${error.plugin}"`
|
||||
: `Install "${error.dependency}" or uninstall "${error.plugin}"`
|
||||
: `Install "${error.dependency}" or uninstall "${error.plugin}"`;
|
||||
case 'lsp-config-invalid':
|
||||
return 'Check LSP server configuration in the plugin manifest'
|
||||
return 'Check LSP server configuration in the plugin manifest';
|
||||
case 'lsp-server-start-failed':
|
||||
case 'lsp-server-crashed':
|
||||
case 'lsp-request-timeout':
|
||||
case 'lsp-request-failed':
|
||||
return 'Check LSP server logs with --debug for details'
|
||||
return 'Check LSP server logs with --debug for details';
|
||||
case 'plugin-cache-miss':
|
||||
return 'Run /plugins to refresh the plugin cache'
|
||||
return 'Run /plugins to refresh the plugin cache';
|
||||
case 'marketplace-load-failed':
|
||||
case 'generic-error':
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
const _exhaustive: never = error
|
||||
return null
|
||||
const _exhaustive: never = error;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import figures from 'figures'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
import figures from 'figures';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw text input for config dialog
|
||||
import { Box, Text, useInput, stringWidth } from '@anthropic/ink'
|
||||
import {
|
||||
useKeybinding,
|
||||
useKeybindings,
|
||||
} from '../../keybindings/useKeybinding.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import type {
|
||||
PluginOptionSchema,
|
||||
PluginOptionValues,
|
||||
} from '../../utils/plugins/pluginOptionsStorage.js'
|
||||
import { Box, Text, useInput, stringWidth } from '@anthropic/ink';
|
||||
import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js';
|
||||
import type { PluginOptionSchema, PluginOptionValues } from '../../utils/plugins/pluginOptionsStorage.js';
|
||||
|
||||
/**
|
||||
* Build the onSave payload from collected string inputs.
|
||||
@@ -31,43 +25,39 @@ export function buildFinalValues(
|
||||
configSchema: PluginOptionSchema,
|
||||
initialValues: PluginOptionValues | undefined,
|
||||
): PluginOptionValues {
|
||||
const finalValues: PluginOptionValues = {}
|
||||
const finalValues: PluginOptionValues = {};
|
||||
for (const fieldKey of fields) {
|
||||
const schema = configSchema[fieldKey]
|
||||
const value = collected[fieldKey] ?? ''
|
||||
const schema = configSchema[fieldKey];
|
||||
const value = collected[fieldKey] ?? '';
|
||||
|
||||
if (
|
||||
schema?.sensitive === true &&
|
||||
value === '' &&
|
||||
initialValues?.[fieldKey] !== undefined
|
||||
) {
|
||||
continue
|
||||
if (schema?.sensitive === true && value === '' && initialValues?.[fieldKey] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (schema?.type === 'number') {
|
||||
// Number('') returns 0, not NaN — omit blank number inputs so
|
||||
// validateUserConfig's required check actually catches them.
|
||||
if (value.trim() === '') continue
|
||||
const num = Number(value)
|
||||
finalValues[fieldKey] = Number.isNaN(num) ? value : num
|
||||
if (value.trim() === '') continue;
|
||||
const num = Number(value);
|
||||
finalValues[fieldKey] = Number.isNaN(num) ? value : num;
|
||||
} else if (schema?.type === 'boolean') {
|
||||
finalValues[fieldKey] = isEnvTruthy(value)
|
||||
finalValues[fieldKey] = isEnvTruthy(value);
|
||||
} else {
|
||||
finalValues[fieldKey] = value
|
||||
finalValues[fieldKey] = value;
|
||||
}
|
||||
}
|
||||
return finalValues
|
||||
return finalValues;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
subtitle: string
|
||||
configSchema: PluginOptionSchema
|
||||
title: string;
|
||||
subtitle: string;
|
||||
configSchema: PluginOptionSchema;
|
||||
/** Pre-fill fields when reconfiguring. Sensitive fields are not prepopulated. */
|
||||
initialValues?: PluginOptionValues
|
||||
onSave: (config: PluginOptionValues) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
initialValues?: PluginOptionValues;
|
||||
onSave: (config: PluginOptionValues) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function PluginOptionsDialog({
|
||||
title,
|
||||
@@ -77,68 +67,56 @@ export function PluginOptionsDialog({
|
||||
onSave,
|
||||
onCancel,
|
||||
}: Props): React.ReactNode {
|
||||
const fields = Object.keys(configSchema)
|
||||
const fields = Object.keys(configSchema);
|
||||
|
||||
// Prepopulate from initialValues but skip sensitive fields — we don't
|
||||
// want to echo secrets back into the text buffer.
|
||||
const initialFor = useCallback(
|
||||
(key: string): string => {
|
||||
if (configSchema[key]?.sensitive === true) return ''
|
||||
const v = initialValues?.[key]
|
||||
return v === undefined ? '' : String(v)
|
||||
if (configSchema[key]?.sensitive === true) return '';
|
||||
const v = initialValues?.[key];
|
||||
return v === undefined ? '' : String(v);
|
||||
},
|
||||
[configSchema, initialValues],
|
||||
)
|
||||
);
|
||||
|
||||
const [currentFieldIndex, setCurrentFieldIndex] = useState(0)
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [currentInput, setCurrentInput] = useState(() =>
|
||||
fields[0] ? initialFor(fields[0]) : '',
|
||||
)
|
||||
const [currentFieldIndex, setCurrentFieldIndex] = useState(0);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [currentInput, setCurrentInput] = useState(() => (fields[0] ? initialFor(fields[0]) : ''));
|
||||
|
||||
const currentField = fields[currentFieldIndex]
|
||||
const fieldSchema = currentField ? configSchema[currentField] : null
|
||||
const currentField = fields[currentFieldIndex];
|
||||
const fieldSchema = currentField ? configSchema[currentField] : null;
|
||||
|
||||
// Use Settings context so 'n' key doesn't cancel (allows typing 'n' in input).
|
||||
// isCancelActive={false} on Dialog keeps its own confirm:no out of the way.
|
||||
useKeybinding('confirm:no', onCancel, { context: 'Settings' })
|
||||
useKeybinding('confirm:no', onCancel, { context: 'Settings' });
|
||||
|
||||
// Tab to next field
|
||||
const handleNextField = useCallback(() => {
|
||||
if (currentFieldIndex < fields.length - 1 && currentField) {
|
||||
setValues(prev => ({ ...prev, [currentField]: currentInput }))
|
||||
setCurrentFieldIndex(prev => prev + 1)
|
||||
const nextKey = fields[currentFieldIndex + 1]
|
||||
setCurrentInput(nextKey ? initialFor(nextKey) : '')
|
||||
setValues(prev => ({ ...prev, [currentField]: currentInput }));
|
||||
setCurrentFieldIndex(prev => prev + 1);
|
||||
const nextKey = fields[currentFieldIndex + 1];
|
||||
setCurrentInput(nextKey ? initialFor(nextKey) : '');
|
||||
}
|
||||
}, [currentFieldIndex, fields, currentField, currentInput, initialFor])
|
||||
}, [currentFieldIndex, fields, currentField, currentInput, initialFor]);
|
||||
|
||||
// Enter to save current field and move to next, or save all if last
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!currentField) return
|
||||
if (!currentField) return;
|
||||
|
||||
const newValues = { ...values, [currentField]: currentInput }
|
||||
const newValues = { ...values, [currentField]: currentInput };
|
||||
|
||||
if (currentFieldIndex === fields.length - 1) {
|
||||
onSave(buildFinalValues(fields, newValues, configSchema, initialValues))
|
||||
onSave(buildFinalValues(fields, newValues, configSchema, initialValues));
|
||||
} else {
|
||||
// Move to next field
|
||||
setValues(newValues)
|
||||
setCurrentFieldIndex(prev => prev + 1)
|
||||
const nextKey = fields[currentFieldIndex + 1]
|
||||
setCurrentInput(nextKey ? initialFor(nextKey) : '')
|
||||
setValues(newValues);
|
||||
setCurrentFieldIndex(prev => prev + 1);
|
||||
const nextKey = fields[currentFieldIndex + 1];
|
||||
setCurrentInput(nextKey ? initialFor(nextKey) : '');
|
||||
}
|
||||
}, [
|
||||
currentField,
|
||||
values,
|
||||
currentInput,
|
||||
currentFieldIndex,
|
||||
fields,
|
||||
configSchema,
|
||||
onSave,
|
||||
initialFor,
|
||||
initialValues,
|
||||
])
|
||||
}, [currentField, values, currentInput, currentFieldIndex, fields, configSchema, onSave, initialFor, initialValues]);
|
||||
|
||||
useKeybindings(
|
||||
{
|
||||
@@ -146,47 +124,38 @@ export function PluginOptionsDialog({
|
||||
'confirm:yes': handleConfirm,
|
||||
},
|
||||
{ context: 'Confirmation' },
|
||||
)
|
||||
);
|
||||
|
||||
// Character input handling (backspace, typing)
|
||||
useInput((char, key) => {
|
||||
// Backspace
|
||||
if (key.backspace || key.delete) {
|
||||
setCurrentInput(prev => prev.slice(0, -1))
|
||||
return
|
||||
setCurrentInput(prev => prev.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular character input
|
||||
if (char && !key.ctrl && !key.meta && !key.tab && !key.return) {
|
||||
setCurrentInput(prev => prev + char)
|
||||
setCurrentInput(prev => prev + char);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!fieldSchema || !currentField) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSensitive = fieldSchema.sensitive === true
|
||||
const isRequired = fieldSchema.required === true
|
||||
const displayValue = isSensitive
|
||||
? '*'.repeat(stringWidth(currentInput))
|
||||
: currentInput
|
||||
const isSensitive = fieldSchema.sensitive === true;
|
||||
const isRequired = fieldSchema.required === true;
|
||||
const displayValue = isSensitive ? '*'.repeat(stringWidth(currentInput)) : currentInput;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
onCancel={onCancel}
|
||||
isCancelActive={false}
|
||||
>
|
||||
<Dialog title={title} subtitle={subtitle} onCancel={onCancel} isCancelActive={false}>
|
||||
<Box flexDirection="column">
|
||||
<Text bold={true}>
|
||||
{fieldSchema.title || currentField}
|
||||
{isRequired && <Text color="error"> *</Text>}
|
||||
</Text>
|
||||
{fieldSchema.description && (
|
||||
<Text dimColor={true}>{fieldSchema.description}</Text>
|
||||
)}
|
||||
{fieldSchema.description && <Text dimColor={true}>{fieldSchema.description}</Text>}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>{figures.pointerSmall} </Text>
|
||||
@@ -200,14 +169,10 @@ export function PluginOptionsDialog({
|
||||
Field {currentFieldIndex + 1} of {fields.length}
|
||||
</Text>
|
||||
{currentFieldIndex < fields.length - 1 && (
|
||||
<Text dimColor={true}>
|
||||
Tab: Next field · Enter: Save and continue
|
||||
</Text>
|
||||
)}
|
||||
{currentFieldIndex === fields.length - 1 && (
|
||||
<Text dimColor={true}>Enter: Save configuration</Text>
|
||||
<Text dimColor={true}>Tab: Next field · Enter: Save and continue</Text>
|
||||
)}
|
||||
{currentFieldIndex === fields.length - 1 && <Text dimColor={true}>Enter: Save configuration</Text>}
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,26 +7,20 @@
|
||||
* onDone('skipped') immediately if nothing needs filling.
|
||||
*/
|
||||
|
||||
import * as React from 'react'
|
||||
import type { LoadedPlugin } from '../../types/plugin.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import {
|
||||
loadMcpServerUserConfig,
|
||||
saveMcpServerUserConfig,
|
||||
} from '../../utils/plugins/mcpbHandler.js'
|
||||
import {
|
||||
getUnconfiguredChannels,
|
||||
type UnconfiguredChannel,
|
||||
} from '../../utils/plugins/mcpPluginIntegration.js'
|
||||
import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'
|
||||
import * as React from 'react';
|
||||
import type { LoadedPlugin } from '../../types/plugin.js';
|
||||
import { errorMessage } from '../../utils/errors.js';
|
||||
import { loadMcpServerUserConfig, saveMcpServerUserConfig } from '../../utils/plugins/mcpbHandler.js';
|
||||
import { getUnconfiguredChannels, type UnconfiguredChannel } from '../../utils/plugins/mcpPluginIntegration.js';
|
||||
import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js';
|
||||
import {
|
||||
getUnconfiguredOptions,
|
||||
loadPluginOptions,
|
||||
type PluginOptionSchema,
|
||||
type PluginOptionValues,
|
||||
savePluginOptions,
|
||||
} from '../../utils/plugins/pluginOptionsStorage.js'
|
||||
import { PluginOptionsDialog } from './PluginOptionsDialog.js'
|
||||
} from '../../utils/plugins/pluginOptionsStorage.js';
|
||||
import { PluginOptionsDialog } from './PluginOptionsDialog.js';
|
||||
|
||||
/**
|
||||
* Post-install lookup: return the LoadedPlugin for the just-installed
|
||||
@@ -36,13 +30,9 @@ import { PluginOptionsDialog } from './PluginOptionsDialog.js'
|
||||
*
|
||||
* Install should have cleared caches already; loadAllPlugins reads fresh.
|
||||
*/
|
||||
export async function findPluginOptionsTarget(
|
||||
pluginId: string,
|
||||
): Promise<LoadedPlugin | undefined> {
|
||||
const { enabled, disabled } = await loadAllPlugins()
|
||||
return [...enabled, ...disabled].find(
|
||||
p => p.repository === pluginId || p.source === pluginId,
|
||||
)
|
||||
export async function findPluginOptionsTarget(pluginId: string): Promise<LoadedPlugin | undefined> {
|
||||
const { enabled, disabled } = await loadAllPlugins();
|
||||
return [...enabled, ...disabled].find(p => p.repository === pluginId || p.source === pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,39 +40,35 @@ export async function findPluginOptionsTarget(
|
||||
* collapse to this shape — the only difference is which save function runs.
|
||||
*/
|
||||
type ConfigStep = {
|
||||
key: string
|
||||
title: string
|
||||
subtitle: string
|
||||
schema: PluginOptionSchema
|
||||
key: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
schema: PluginOptionSchema;
|
||||
/** Returns any already-saved values so PluginOptionsDialog can pre-fill and
|
||||
* skip unchanged sensitive fields on reconfigure. */
|
||||
load: () => PluginOptionValues | undefined
|
||||
save: (values: PluginOptionValues) => void
|
||||
}
|
||||
load: () => PluginOptionValues | undefined;
|
||||
save: (values: PluginOptionValues) => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
plugin: LoadedPlugin
|
||||
plugin: LoadedPlugin;
|
||||
/** `name@marketplace` — the savePluginOptions / saveMcpServerUserConfig key. */
|
||||
pluginId: string
|
||||
pluginId: string;
|
||||
/**
|
||||
* `configured` = user filled all fields. `skipped` = nothing needed
|
||||
* configuring, or user hit cancel. `error` = save threw.
|
||||
*/
|
||||
onDone: (outcome: 'configured' | 'skipped' | 'error', detail?: string) => void
|
||||
}
|
||||
onDone: (outcome: 'configured' | 'skipped' | 'error', detail?: string) => void;
|
||||
};
|
||||
|
||||
export function PluginOptionsFlow({
|
||||
plugin,
|
||||
pluginId,
|
||||
onDone,
|
||||
}: Props): React.ReactNode {
|
||||
export function PluginOptionsFlow({ plugin, pluginId, onDone }: Props): React.ReactNode {
|
||||
// Build the step list once at mount. Re-calling after a save would drop the
|
||||
// item we just configured.
|
||||
const [steps] = React.useState<ConfigStep[]>(() => {
|
||||
const result: ConfigStep[] = []
|
||||
const result: ConfigStep[] = [];
|
||||
|
||||
// Top-level manifest.userConfig
|
||||
const unconfigured = getUnconfiguredOptions(plugin)
|
||||
const unconfigured = getUnconfiguredOptions(plugin);
|
||||
if (Object.keys(unconfigured).length > 0) {
|
||||
result.push({
|
||||
key: 'top-level',
|
||||
@@ -90,68 +76,60 @@ export function PluginOptionsFlow({
|
||||
subtitle: 'Plugin options',
|
||||
schema: unconfigured,
|
||||
load: () => loadPluginOptions(pluginId),
|
||||
save: values =>
|
||||
savePluginOptions(pluginId, values, plugin.manifest.userConfig!),
|
||||
})
|
||||
save: values => savePluginOptions(pluginId, values, plugin.manifest.userConfig!),
|
||||
});
|
||||
}
|
||||
|
||||
// Per-channel userConfig (assistant-mode channels)
|
||||
const channels: UnconfiguredChannel[] = getUnconfiguredChannels(plugin)
|
||||
const channels: UnconfiguredChannel[] = getUnconfiguredChannels(plugin);
|
||||
for (const channel of channels) {
|
||||
result.push({
|
||||
key: `channel:${channel.server}`,
|
||||
title: `Configure ${channel.displayName}`,
|
||||
subtitle: `Plugin: ${plugin.name}`,
|
||||
schema: channel.configSchema,
|
||||
load: () =>
|
||||
loadMcpServerUserConfig(pluginId, channel.server) ?? undefined,
|
||||
save: values =>
|
||||
saveMcpServerUserConfig(
|
||||
pluginId,
|
||||
channel.server,
|
||||
values,
|
||||
channel.configSchema,
|
||||
),
|
||||
})
|
||||
load: () => loadMcpServerUserConfig(pluginId, channel.server) ?? undefined,
|
||||
save: values => saveMcpServerUserConfig(pluginId, channel.server, values, channel.configSchema),
|
||||
});
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
return result;
|
||||
});
|
||||
|
||||
const [index, setIndex] = React.useState(0)
|
||||
const [index, setIndex] = React.useState(0);
|
||||
|
||||
// Latest-ref: lets the effect close over the current onDone without
|
||||
// re-running when the parent re-renders.
|
||||
const onDoneRef = React.useRef(onDone)
|
||||
onDoneRef.current = onDone
|
||||
const onDoneRef = React.useRef(onDone);
|
||||
onDoneRef.current = onDone;
|
||||
|
||||
// Nothing to configure → tell the caller and render nothing. Effect,
|
||||
// not inline call: calling setState in the parent during our render
|
||||
// is a React rules-of-hooks violation.
|
||||
React.useEffect(() => {
|
||||
if (steps.length === 0) {
|
||||
onDoneRef.current('skipped')
|
||||
onDoneRef.current('skipped');
|
||||
}
|
||||
}, [steps.length])
|
||||
}, [steps.length]);
|
||||
|
||||
if (steps.length === 0) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = steps[index]!
|
||||
const current = steps[index]!;
|
||||
|
||||
function handleSave(values: PluginOptionValues): void {
|
||||
try {
|
||||
current.save(values)
|
||||
current.save(values);
|
||||
} catch (err) {
|
||||
onDone('error', errorMessage(err))
|
||||
return
|
||||
onDone('error', errorMessage(err));
|
||||
return;
|
||||
}
|
||||
const next = index + 1
|
||||
const next = index + 1;
|
||||
if (next < steps.length) {
|
||||
setIndex(next)
|
||||
setIndex(next);
|
||||
} else {
|
||||
onDone('configured')
|
||||
onDone('configured');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,5 +146,5 @@ export function PluginOptionsFlow({
|
||||
onSave={handleSave}
|
||||
onCancel={() => onDone('skipped')}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,19 @@
|
||||
import figures from 'figures'
|
||||
import * as React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { getPluginTrustMessage } from '../../utils/plugins/marketplaceHelpers.js'
|
||||
import figures from 'figures';
|
||||
import * as React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { getPluginTrustMessage } from '../../utils/plugins/marketplaceHelpers.js';
|
||||
|
||||
export function PluginTrustWarning(): React.ReactNode {
|
||||
const customMessage = getPluginTrustMessage()
|
||||
const customMessage = getPluginTrustMessage();
|
||||
return (
|
||||
<Box marginBottom={1}>
|
||||
<Text color="claude">{figures.warning} </Text>
|
||||
<Text dimColor italic>
|
||||
Make sure you trust a plugin before installing, updating, or using it.
|
||||
Anthropic does not control what MCP servers, files, or other software
|
||||
are included in plugins and cannot verify that they will work as
|
||||
intended or that they won't change. See each plugin's homepage
|
||||
for more information.{customMessage ? ` ${customMessage}` : ''}
|
||||
Make sure you trust a plugin before installing, updating, or using it. Anthropic does not control what MCP
|
||||
servers, files, or other software are included in plugins and cannot verify that they will work as intended or
|
||||
that they won't change. See each plugin's homepage for more information.
|
||||
{customMessage ? ` ${customMessage}` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +1,40 @@
|
||||
import figures from 'figures'
|
||||
import * as React from 'react'
|
||||
import { Box, color, Text, useTheme } from '@anthropic/ink'
|
||||
import { plural } from '../../utils/stringUtils.js'
|
||||
import type { UnifiedInstalledItem } from './unifiedTypes.js'
|
||||
import figures from 'figures';
|
||||
import * as React from 'react';
|
||||
import { Box, color, Text, useTheme } from '@anthropic/ink';
|
||||
import { plural } from '../../utils/stringUtils.js';
|
||||
import type { UnifiedInstalledItem } from './unifiedTypes.js';
|
||||
|
||||
type Props = {
|
||||
item: UnifiedInstalledItem
|
||||
isSelected: boolean
|
||||
}
|
||||
item: UnifiedInstalledItem;
|
||||
isSelected: boolean;
|
||||
};
|
||||
|
||||
export function UnifiedInstalledCell({
|
||||
item,
|
||||
isSelected,
|
||||
}: Props): React.ReactNode {
|
||||
const [theme] = useTheme()
|
||||
export function UnifiedInstalledCell({ item, isSelected }: Props): React.ReactNode {
|
||||
const [theme] = useTheme();
|
||||
|
||||
if (item.type === 'plugin') {
|
||||
// Status icon and text
|
||||
let statusIcon: string
|
||||
let statusText: string
|
||||
let statusIcon: string;
|
||||
let statusText: string;
|
||||
|
||||
// Show pending toggle status if set, otherwise show current status
|
||||
if (item.pendingToggle) {
|
||||
statusIcon = color('suggestion', theme)(figures.arrowRight)
|
||||
statusText =
|
||||
item.pendingToggle === 'will-enable' ? 'will enable' : 'will disable'
|
||||
statusIcon = color('suggestion', theme)(figures.arrowRight);
|
||||
statusText = item.pendingToggle === 'will-enable' ? 'will enable' : 'will disable';
|
||||
} else if (item.errorCount > 0) {
|
||||
statusIcon = color('error', theme)(figures.cross)
|
||||
statusText = `${item.errorCount} ${plural(item.errorCount, 'error')}`
|
||||
statusIcon = color('error', theme)(figures.cross);
|
||||
statusText = `${item.errorCount} ${plural(item.errorCount, 'error')}`;
|
||||
} else if (!item.isEnabled) {
|
||||
statusIcon = color('inactive', theme)(figures.radioOff)
|
||||
statusText = 'disabled'
|
||||
statusIcon = color('inactive', theme)(figures.radioOff);
|
||||
statusText = 'disabled';
|
||||
} else {
|
||||
statusIcon = color('success', theme)(figures.tick)
|
||||
statusText = 'enabled'
|
||||
statusIcon = color('success', theme)(figures.tick);
|
||||
statusText = 'enabled';
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>
|
||||
{isSelected ? `${figures.pointer} ` : ' '}
|
||||
</Text>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>{isSelected ? `${figures.pointer} ` : ' '}</Text>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>{item.name}</Text>
|
||||
<Text dimColor={!isSelected}>
|
||||
{' '}
|
||||
@@ -50,17 +44,15 @@ export function UnifiedInstalledCell({
|
||||
<Text dimColor={!isSelected}> · {statusIcon} </Text>
|
||||
<Text dimColor={!isSelected}>{statusText}</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'flagged-plugin') {
|
||||
const statusIcon = color('warning', theme)(figures.warning)
|
||||
const statusIcon = color('warning', theme)(figures.warning);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>
|
||||
{isSelected ? `${figures.pointer} ` : ' '}
|
||||
</Text>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>{isSelected ? `${figures.pointer} ` : ' '}</Text>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>{item.name}</Text>
|
||||
<Text dimColor={!isSelected}>
|
||||
{' '}
|
||||
@@ -70,18 +62,16 @@ export function UnifiedInstalledCell({
|
||||
<Text dimColor={!isSelected}> · {statusIcon} </Text>
|
||||
<Text dimColor={!isSelected}>removed</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'failed-plugin') {
|
||||
const statusIcon = color('error', theme)(figures.cross)
|
||||
const statusText = `failed to load · ${item.errorCount} ${plural(item.errorCount, 'error')}`
|
||||
const statusIcon = color('error', theme)(figures.cross);
|
||||
const statusText = `failed to load · ${item.errorCount} ${plural(item.errorCount, 'error')}`;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>
|
||||
{isSelected ? `${figures.pointer} ` : ' '}
|
||||
</Text>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>{isSelected ? `${figures.pointer} ` : ' '}</Text>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>{item.name}</Text>
|
||||
<Text dimColor={!isSelected}>
|
||||
{' '}
|
||||
@@ -91,37 +81,35 @@ export function UnifiedInstalledCell({
|
||||
<Text dimColor={!isSelected}> · {statusIcon} </Text>
|
||||
<Text dimColor={!isSelected}>{statusText}</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// MCP server
|
||||
let statusIcon: string
|
||||
let statusText: string
|
||||
let statusIcon: string;
|
||||
let statusText: string;
|
||||
|
||||
if (item.status === 'connected') {
|
||||
statusIcon = color('success', theme)(figures.tick)
|
||||
statusText = 'connected'
|
||||
statusIcon = color('success', theme)(figures.tick);
|
||||
statusText = 'connected';
|
||||
} else if (item.status === 'disabled') {
|
||||
statusIcon = color('inactive', theme)(figures.radioOff)
|
||||
statusText = 'disabled'
|
||||
statusIcon = color('inactive', theme)(figures.radioOff);
|
||||
statusText = 'disabled';
|
||||
} else if (item.status === 'pending') {
|
||||
statusIcon = color('inactive', theme)(figures.radioOff)
|
||||
statusText = 'connecting…'
|
||||
statusIcon = color('inactive', theme)(figures.radioOff);
|
||||
statusText = 'connecting…';
|
||||
} else if (item.status === 'needs-auth') {
|
||||
statusIcon = color('warning', theme)(figures.triangleUpOutline)
|
||||
statusText = 'Enter to auth'
|
||||
statusIcon = color('warning', theme)(figures.triangleUpOutline);
|
||||
statusText = 'Enter to auth';
|
||||
} else {
|
||||
statusIcon = color('error', theme)(figures.cross)
|
||||
statusText = 'failed'
|
||||
statusIcon = color('error', theme)(figures.cross);
|
||||
statusText = 'failed';
|
||||
}
|
||||
|
||||
// Indented MCPs (child of a plugin)
|
||||
if (item.indented) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>
|
||||
{isSelected ? `${figures.pointer} ` : ' '}
|
||||
</Text>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>{isSelected ? `${figures.pointer} ` : ' '}</Text>
|
||||
<Text dimColor={!isSelected}>└ </Text>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>{item.name}</Text>
|
||||
<Text dimColor={!isSelected}>
|
||||
@@ -131,14 +119,12 @@ export function UnifiedInstalledCell({
|
||||
<Text dimColor={!isSelected}> · {statusIcon} </Text>
|
||||
<Text dimColor={!isSelected}>{statusText}</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>
|
||||
{isSelected ? `${figures.pointer} ` : ' '}
|
||||
</Text>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>{isSelected ? `${figures.pointer} ` : ' '}</Text>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>{item.name}</Text>
|
||||
<Text dimColor={!isSelected}>
|
||||
{' '}
|
||||
@@ -147,5 +133,5 @@ export function UnifiedInstalledCell({
|
||||
<Text dimColor={!isSelected}> · {statusIcon} </Text>
|
||||
<Text dimColor={!isSelected}>{statusText}</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import figures from 'figures'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { validateManifest } from '../../utils/plugins/validatePlugin.js'
|
||||
import { plural } from '../../utils/stringUtils.js'
|
||||
import figures from 'figures';
|
||||
import * as React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { errorMessage } from '../../utils/errors.js';
|
||||
import { logError } from '../../utils/log.js';
|
||||
import { validateManifest } from '../../utils/plugins/validatePlugin.js';
|
||||
import { plural } from '../../utils/stringUtils.js';
|
||||
|
||||
type Props = {
|
||||
onComplete: (result?: string) => void
|
||||
path?: string
|
||||
}
|
||||
onComplete: (result?: string) => void;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export function ValidatePlugin({ onComplete, path }: Props): React.ReactNode {
|
||||
useEffect(() => {
|
||||
@@ -28,76 +28,74 @@ export function ValidatePlugin({ onComplete, path }: Props): React.ReactNode {
|
||||
'or .claude-plugin/plugin.json (prefers marketplace if both exist).\n\n' +
|
||||
'Or from the command line:\n' +
|
||||
' claude plugin validate <path>',
|
||||
)
|
||||
return
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await validateManifest(path)
|
||||
const result = await validateManifest(path);
|
||||
|
||||
let output = ''
|
||||
let output = '';
|
||||
|
||||
// Add header
|
||||
output += `Validating ${result.fileType} manifest: ${result.filePath}\n\n`
|
||||
output += `Validating ${result.fileType} manifest: ${result.filePath}\n\n`;
|
||||
|
||||
// Show errors
|
||||
if (result.errors.length > 0) {
|
||||
output += `${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n\n`
|
||||
output += `${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n\n`;
|
||||
|
||||
result.errors.forEach(error => {
|
||||
output += ` ${figures.pointer} ${error.path}: ${error.message}\n`
|
||||
})
|
||||
output += ` ${figures.pointer} ${error.path}: ${error.message}\n`;
|
||||
});
|
||||
|
||||
output += '\n'
|
||||
output += '\n';
|
||||
}
|
||||
|
||||
// Show warnings
|
||||
if (result.warnings.length > 0) {
|
||||
output += `${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n\n`
|
||||
output += `${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n\n`;
|
||||
|
||||
result.warnings.forEach(warning => {
|
||||
output += ` ${figures.pointer} ${warning.path}: ${warning.message}\n`
|
||||
})
|
||||
output += ` ${figures.pointer} ${warning.path}: ${warning.message}\n`;
|
||||
});
|
||||
|
||||
output += '\n'
|
||||
output += '\n';
|
||||
}
|
||||
|
||||
// Show success or failure
|
||||
if (result.success) {
|
||||
if (result.warnings.length > 0) {
|
||||
output += `${figures.tick} Validation passed with warnings\n`
|
||||
output += `${figures.tick} Validation passed with warnings\n`;
|
||||
} else {
|
||||
output += `${figures.tick} Validation passed\n`
|
||||
output += `${figures.tick} Validation passed\n`;
|
||||
}
|
||||
|
||||
// Exit with code 0 (success)
|
||||
process.exitCode = 0
|
||||
process.exitCode = 0;
|
||||
} else {
|
||||
output += `${figures.cross} Validation failed\n`
|
||||
output += `${figures.cross} Validation failed\n`;
|
||||
|
||||
// Exit with code 1 (validation failure)
|
||||
process.exitCode = 1
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
onComplete(output)
|
||||
onComplete(output);
|
||||
} catch (error) {
|
||||
// Exit with code 2 (unexpected error)
|
||||
process.exitCode = 2
|
||||
process.exitCode = 2;
|
||||
|
||||
logError(error)
|
||||
logError(error);
|
||||
|
||||
onComplete(
|
||||
`${figures.cross} Unexpected error during validation: ${errorMessage(error)}`,
|
||||
)
|
||||
onComplete(`${figures.cross} Unexpected error during validation: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
void runValidation()
|
||||
}, [onComplete, path])
|
||||
void runValidation();
|
||||
}, [onComplete, path]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>Running validation...</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,147 +1,149 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parsePluginArgs } from "../parseArgs";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { parsePluginArgs } from '../parseArgs'
|
||||
|
||||
describe("parsePluginArgs", () => {
|
||||
describe('parsePluginArgs', () => {
|
||||
// No args
|
||||
test("returns { type: 'menu' } for undefined", () => {
|
||||
expect(parsePluginArgs(undefined)).toEqual({ type: "menu" });
|
||||
});
|
||||
expect(parsePluginArgs(undefined)).toEqual({ type: 'menu' })
|
||||
})
|
||||
|
||||
test("returns { type: 'menu' } for empty string", () => {
|
||||
expect(parsePluginArgs("")).toEqual({ type: "menu" });
|
||||
});
|
||||
expect(parsePluginArgs('')).toEqual({ type: 'menu' })
|
||||
})
|
||||
|
||||
test("returns { type: 'menu' } for whitespace only", () => {
|
||||
expect(parsePluginArgs(" ")).toEqual({ type: "menu" });
|
||||
});
|
||||
expect(parsePluginArgs(' ')).toEqual({ type: 'menu' })
|
||||
})
|
||||
|
||||
// Help
|
||||
test("returns { type: 'help' } for 'help'", () => {
|
||||
expect(parsePluginArgs("help")).toEqual({ type: "help" });
|
||||
});
|
||||
expect(parsePluginArgs('help')).toEqual({ type: 'help' })
|
||||
})
|
||||
|
||||
test("returns { type: 'help' } for '--help'", () => {
|
||||
expect(parsePluginArgs("--help")).toEqual({ type: "help" });
|
||||
});
|
||||
expect(parsePluginArgs('--help')).toEqual({ type: 'help' })
|
||||
})
|
||||
|
||||
test("returns { type: 'help' } for '-h'", () => {
|
||||
expect(parsePluginArgs("-h")).toEqual({ type: "help" });
|
||||
});
|
||||
expect(parsePluginArgs('-h')).toEqual({ type: 'help' })
|
||||
})
|
||||
|
||||
// Install
|
||||
test("parses 'install my-plugin' -> { type: 'install', plugin: 'my-plugin' }", () => {
|
||||
expect(parsePluginArgs("install my-plugin")).toEqual({
|
||||
type: "install",
|
||||
plugin: "my-plugin",
|
||||
});
|
||||
});
|
||||
expect(parsePluginArgs('install my-plugin')).toEqual({
|
||||
type: 'install',
|
||||
plugin: 'my-plugin',
|
||||
})
|
||||
})
|
||||
|
||||
test("parses 'install my-plugin@github' with marketplace", () => {
|
||||
expect(parsePluginArgs("install my-plugin@github")).toEqual({
|
||||
type: "install",
|
||||
plugin: "my-plugin",
|
||||
marketplace: "github",
|
||||
});
|
||||
});
|
||||
expect(parsePluginArgs('install my-plugin@github')).toEqual({
|
||||
type: 'install',
|
||||
plugin: 'my-plugin',
|
||||
marketplace: 'github',
|
||||
})
|
||||
})
|
||||
|
||||
test("parses 'install https://github.com/...' as URL marketplace", () => {
|
||||
expect(parsePluginArgs("install https://github.com/plugins/my-plugin")).toEqual({
|
||||
type: "install",
|
||||
marketplace: "https://github.com/plugins/my-plugin",
|
||||
});
|
||||
});
|
||||
expect(
|
||||
parsePluginArgs('install https://github.com/plugins/my-plugin'),
|
||||
).toEqual({
|
||||
type: 'install',
|
||||
marketplace: 'https://github.com/plugins/my-plugin',
|
||||
})
|
||||
})
|
||||
|
||||
test("parses 'i plugin' as install shorthand", () => {
|
||||
expect(parsePluginArgs("i plugin")).toEqual({
|
||||
type: "install",
|
||||
plugin: "plugin",
|
||||
});
|
||||
});
|
||||
expect(parsePluginArgs('i plugin')).toEqual({
|
||||
type: 'install',
|
||||
plugin: 'plugin',
|
||||
})
|
||||
})
|
||||
|
||||
test("install without target returns type only", () => {
|
||||
expect(parsePluginArgs("install")).toEqual({ type: "install" });
|
||||
});
|
||||
test('install without target returns type only', () => {
|
||||
expect(parsePluginArgs('install')).toEqual({ type: 'install' })
|
||||
})
|
||||
|
||||
// Uninstall
|
||||
test("returns { type: 'uninstall', plugin: '...' }", () => {
|
||||
expect(parsePluginArgs("uninstall my-plugin")).toEqual({
|
||||
type: "uninstall",
|
||||
plugin: "my-plugin",
|
||||
});
|
||||
});
|
||||
expect(parsePluginArgs('uninstall my-plugin')).toEqual({
|
||||
type: 'uninstall',
|
||||
plugin: 'my-plugin',
|
||||
})
|
||||
})
|
||||
|
||||
// Enable/disable
|
||||
test("returns { type: 'enable', plugin: '...' }", () => {
|
||||
expect(parsePluginArgs("enable my-plugin")).toEqual({
|
||||
type: "enable",
|
||||
plugin: "my-plugin",
|
||||
});
|
||||
});
|
||||
expect(parsePluginArgs('enable my-plugin')).toEqual({
|
||||
type: 'enable',
|
||||
plugin: 'my-plugin',
|
||||
})
|
||||
})
|
||||
|
||||
test("returns { type: 'disable', plugin: '...' }", () => {
|
||||
expect(parsePluginArgs("disable my-plugin")).toEqual({
|
||||
type: "disable",
|
||||
plugin: "my-plugin",
|
||||
});
|
||||
});
|
||||
expect(parsePluginArgs('disable my-plugin')).toEqual({
|
||||
type: 'disable',
|
||||
plugin: 'my-plugin',
|
||||
})
|
||||
})
|
||||
|
||||
// Validate
|
||||
test("returns { type: 'validate', path: '...' }", () => {
|
||||
expect(parsePluginArgs("validate /path/to/plugin")).toEqual({
|
||||
type: "validate",
|
||||
path: "/path/to/plugin",
|
||||
});
|
||||
});
|
||||
expect(parsePluginArgs('validate /path/to/plugin')).toEqual({
|
||||
type: 'validate',
|
||||
path: '/path/to/plugin',
|
||||
})
|
||||
})
|
||||
|
||||
// Manage
|
||||
test("returns { type: 'manage' }", () => {
|
||||
expect(parsePluginArgs("manage")).toEqual({ type: "manage" });
|
||||
});
|
||||
expect(parsePluginArgs('manage')).toEqual({ type: 'manage' })
|
||||
})
|
||||
|
||||
// Marketplace
|
||||
test("parses 'marketplace add ...'", () => {
|
||||
expect(parsePluginArgs("marketplace add https://example.com")).toEqual({
|
||||
type: "marketplace",
|
||||
action: "add",
|
||||
target: "https://example.com",
|
||||
});
|
||||
});
|
||||
expect(parsePluginArgs('marketplace add https://example.com')).toEqual({
|
||||
type: 'marketplace',
|
||||
action: 'add',
|
||||
target: 'https://example.com',
|
||||
})
|
||||
})
|
||||
|
||||
test("parses 'marketplace remove ...'", () => {
|
||||
expect(parsePluginArgs("marketplace remove my-source")).toEqual({
|
||||
type: "marketplace",
|
||||
action: "remove",
|
||||
target: "my-source",
|
||||
});
|
||||
});
|
||||
expect(parsePluginArgs('marketplace remove my-source')).toEqual({
|
||||
type: 'marketplace',
|
||||
action: 'remove',
|
||||
target: 'my-source',
|
||||
})
|
||||
})
|
||||
|
||||
test("parses 'marketplace list'", () => {
|
||||
expect(parsePluginArgs("marketplace list")).toEqual({
|
||||
type: "marketplace",
|
||||
action: "list",
|
||||
});
|
||||
});
|
||||
expect(parsePluginArgs('marketplace list')).toEqual({
|
||||
type: 'marketplace',
|
||||
action: 'list',
|
||||
})
|
||||
})
|
||||
|
||||
test("parses 'market' as alias for 'marketplace'", () => {
|
||||
expect(parsePluginArgs("market list")).toEqual({
|
||||
type: "marketplace",
|
||||
action: "list",
|
||||
});
|
||||
});
|
||||
expect(parsePluginArgs('market list')).toEqual({
|
||||
type: 'marketplace',
|
||||
action: 'list',
|
||||
})
|
||||
})
|
||||
|
||||
// Boundary
|
||||
test("handles extra whitespace", () => {
|
||||
expect(parsePluginArgs(" install my-plugin ")).toEqual({
|
||||
type: "install",
|
||||
plugin: "my-plugin",
|
||||
});
|
||||
});
|
||||
test('handles extra whitespace', () => {
|
||||
expect(parsePluginArgs(' install my-plugin ')).toEqual({
|
||||
type: 'install',
|
||||
plugin: 'my-plugin',
|
||||
})
|
||||
})
|
||||
|
||||
test("handles unknown subcommand gracefully", () => {
|
||||
expect(parsePluginArgs("foobar")).toEqual({ type: "menu" });
|
||||
});
|
||||
test('handles unknown subcommand gracefully', () => {
|
||||
expect(parsePluginArgs('foobar')).toEqual({ type: 'menu' })
|
||||
})
|
||||
|
||||
test("marketplace without action returns type only", () => {
|
||||
expect(parsePluginArgs("marketplace")).toEqual({ type: "marketplace" });
|
||||
});
|
||||
});
|
||||
test('marketplace without action returns type only', () => {
|
||||
expect(parsePluginArgs('marketplace')).toEqual({ type: 'marketplace' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import type { Command } from '../../commands.js';
|
||||
|
||||
const plugin = {
|
||||
type: 'local-jsx',
|
||||
@@ -7,6 +7,6 @@ const plugin = {
|
||||
description: 'Manage Claude Code plugins',
|
||||
immediate: true,
|
||||
load: () => import('./plugin.js'),
|
||||
} satisfies Command
|
||||
} satisfies Command;
|
||||
|
||||
export default plugin
|
||||
export default plugin;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { PluginSettings } from './PluginSettings.js'
|
||||
import * as React from 'react';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { PluginSettings } from './PluginSettings.js';
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
_context: unknown,
|
||||
args?: string,
|
||||
): Promise<React.ReactNode> {
|
||||
return <PluginSettings onComplete={onDone} args={args} />
|
||||
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
|
||||
return <PluginSettings onComplete={onDone} args={args} />;
|
||||
}
|
||||
|
||||
@@ -4,28 +4,28 @@
|
||||
* Used by both DiscoverPlugins and BrowseMarketplace components.
|
||||
*/
|
||||
|
||||
import * as React from 'react'
|
||||
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'
|
||||
import { Box, Byline, Text } from '@anthropic/ink'
|
||||
import type { PluginMarketplaceEntry } from '../../utils/plugins/schemas.js'
|
||||
import * as React from 'react';
|
||||
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js';
|
||||
import { Box, Byline, Text } from '@anthropic/ink';
|
||||
import type { PluginMarketplaceEntry } from '../../utils/plugins/schemas.js';
|
||||
|
||||
/**
|
||||
* Represents a plugin available for installation from a marketplace
|
||||
*/
|
||||
export type InstallablePlugin = {
|
||||
entry: PluginMarketplaceEntry
|
||||
marketplaceName: string
|
||||
pluginId: string
|
||||
isInstalled: boolean
|
||||
}
|
||||
entry: PluginMarketplaceEntry;
|
||||
marketplaceName: string;
|
||||
pluginId: string;
|
||||
isInstalled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Menu option for plugin details view
|
||||
*/
|
||||
export type PluginDetailsMenuOption = {
|
||||
label: string
|
||||
action: string
|
||||
}
|
||||
label: string;
|
||||
action: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract GitHub repo info from a plugin's source
|
||||
@@ -35,17 +35,13 @@ export function extractGitHubRepo(plugin: InstallablePlugin): string | null {
|
||||
plugin.entry.source &&
|
||||
typeof plugin.entry.source === 'object' &&
|
||||
'source' in plugin.entry.source &&
|
||||
plugin.entry.source.source === 'github'
|
||||
plugin.entry.source.source === 'github';
|
||||
|
||||
if (
|
||||
isGitHub &&
|
||||
typeof plugin.entry.source === 'object' &&
|
||||
'repo' in plugin.entry.source
|
||||
) {
|
||||
return plugin.entry.source.repo
|
||||
if (isGitHub && typeof plugin.entry.source === 'object' && 'repo' in plugin.entry.source) {
|
||||
return plugin.entry.source.repo;
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,25 +61,21 @@ export function buildPluginDetailsMenuOptions(
|
||||
label: 'Install for you, in this repo only (local scope)',
|
||||
action: 'install-local',
|
||||
},
|
||||
]
|
||||
];
|
||||
if (hasHomepage) {
|
||||
options.push({ label: 'Open homepage', action: 'homepage' })
|
||||
options.push({ label: 'Open homepage', action: 'homepage' });
|
||||
}
|
||||
if (githubRepo) {
|
||||
options.push({ label: 'View on GitHub', action: 'github' })
|
||||
options.push({ label: 'View on GitHub', action: 'github' });
|
||||
}
|
||||
options.push({ label: 'Back to plugin list', action: 'back' })
|
||||
return options
|
||||
options.push({ label: 'Back to plugin list', action: 'back' });
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Key hint component for plugin selection screens
|
||||
*/
|
||||
export function PluginSelectionKeyHint({
|
||||
hasSelection,
|
||||
}: {
|
||||
hasSelection: boolean
|
||||
}): React.ReactNode {
|
||||
export function PluginSelectionKeyHint({ hasSelection }: { hasSelection: boolean }): React.ReactNode {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor italic>
|
||||
@@ -97,26 +89,11 @@ export function PluginSelectionKeyHint({
|
||||
bold
|
||||
/>
|
||||
)}
|
||||
<ConfigurableShortcutHint
|
||||
action="plugin:toggle"
|
||||
context="Plugin"
|
||||
fallback="Space"
|
||||
description="toggle"
|
||||
/>
|
||||
<ConfigurableShortcutHint
|
||||
action="select:accept"
|
||||
context="Select"
|
||||
fallback="Enter"
|
||||
description="details"
|
||||
/>
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Confirmation"
|
||||
fallback="Esc"
|
||||
description="back"
|
||||
/>
|
||||
<ConfigurableShortcutHint action="plugin:toggle" context="Plugin" fallback="Space" description="toggle" />
|
||||
<ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="details" />
|
||||
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" />
|
||||
</Byline>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type ViewState = any;
|
||||
export type PluginSettingsProps = any;
|
||||
export type ViewState = any
|
||||
export type PluginSettingsProps = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type UnifiedInstalledItem = any;
|
||||
export type UnifiedInstalledItem = any
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { Command } from '../../commands.js'
|
||||
const poor = {
|
||||
type: 'local',
|
||||
name: 'poor',
|
||||
description: 'Toggle poor mode — disable extract_memories and prompt_suggestion to save tokens',
|
||||
description:
|
||||
'Toggle poor mode — disable extract_memories and prompt_suggestion to save tokens',
|
||||
supportsNonInteractive: false,
|
||||
load: () => import('./poor.js'),
|
||||
} satisfies Command
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
* Persisted to settings.json so it survives session restarts.
|
||||
*/
|
||||
|
||||
import { getInitialSettings, updateSettingsForSource } from '../../utils/settings/settings.js'
|
||||
import {
|
||||
getInitialSettings,
|
||||
updateSettingsForSource,
|
||||
} from '../../utils/settings/settings.js'
|
||||
|
||||
let poorModeActive: boolean | null = null
|
||||
|
||||
|
||||
@@ -1,75 +1,56 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
type GroveDecision,
|
||||
GroveDialog,
|
||||
PrivacySettingsDialog,
|
||||
} from '../../components/grove/Grove.js'
|
||||
import * as React from 'react';
|
||||
import { type GroveDecision, GroveDialog, PrivacySettingsDialog } from '../../components/grove/Grove.js';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import {
|
||||
getGroveNoticeConfig,
|
||||
getGroveSettings,
|
||||
isQualifiedForGrove,
|
||||
} from '../../services/api/grove.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
} from '../../services/analytics/index.js';
|
||||
import { getGroveNoticeConfig, getGroveSettings, isQualifiedForGrove } from '../../services/api/grove.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
|
||||
const FALLBACK_MESSAGE =
|
||||
'Review and manage your privacy settings at https://claude.ai/settings/data-privacy-controls'
|
||||
const FALLBACK_MESSAGE = 'Review and manage your privacy settings at https://claude.ai/settings/data-privacy-controls';
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
): Promise<React.ReactNode | null> {
|
||||
const qualified = await isQualifiedForGrove()
|
||||
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode | null> {
|
||||
const qualified = await isQualifiedForGrove();
|
||||
if (!qualified) {
|
||||
onDone(FALLBACK_MESSAGE)
|
||||
return null
|
||||
onDone(FALLBACK_MESSAGE);
|
||||
return null;
|
||||
}
|
||||
|
||||
const [settingsResult, configResult] = await Promise.all([
|
||||
getGroveSettings(),
|
||||
getGroveNoticeConfig(),
|
||||
])
|
||||
const [settingsResult, configResult] = await Promise.all([getGroveSettings(), getGroveNoticeConfig()]);
|
||||
// Hide dialog on API failure (after retry)
|
||||
if (!settingsResult.success) {
|
||||
onDone(FALLBACK_MESSAGE)
|
||||
return null
|
||||
onDone(FALLBACK_MESSAGE);
|
||||
return null;
|
||||
}
|
||||
const settings = settingsResult.data
|
||||
const config = configResult.success ? configResult.data : null
|
||||
const settings = settingsResult.data;
|
||||
const config = configResult.success ? configResult.data : null;
|
||||
|
||||
async function onDoneWithDecision(decision: GroveDecision) {
|
||||
if (decision === 'escape' || decision === 'defer') {
|
||||
onDone('Privacy settings dialog dismissed', {
|
||||
display: 'system',
|
||||
})
|
||||
return
|
||||
});
|
||||
return;
|
||||
}
|
||||
await onDoneWithSettingsCheck()
|
||||
await onDoneWithSettingsCheck();
|
||||
}
|
||||
|
||||
async function onDoneWithSettingsCheck() {
|
||||
const updatedSettingsResult = await getGroveSettings()
|
||||
const updatedSettingsResult = await getGroveSettings();
|
||||
if (!updatedSettingsResult.success) {
|
||||
onDone('Unable to retrieve updated privacy settings', {
|
||||
display: 'system',
|
||||
})
|
||||
return
|
||||
});
|
||||
return;
|
||||
}
|
||||
const updatedSettings = updatedSettingsResult.data
|
||||
const groveStatus = updatedSettings.grove_enabled ? 'true' : 'false'
|
||||
onDone(`"Help improve Claude" set to ${groveStatus}.`)
|
||||
if (
|
||||
settings.grove_enabled !== null &&
|
||||
settings.grove_enabled !== updatedSettings.grove_enabled
|
||||
) {
|
||||
const updatedSettings = updatedSettingsResult.data;
|
||||
const groveStatus = updatedSettings.grove_enabled ? 'true' : 'false';
|
||||
onDone(`"Help improve Claude" set to ${groveStatus}.`);
|
||||
if (settings.grove_enabled !== null && settings.grove_enabled !== updatedSettings.grove_enabled) {
|
||||
logEvent('tengu_grove_policy_toggled', {
|
||||
state:
|
||||
updatedSettings.grove_enabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
location:
|
||||
'settings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
state: updatedSettings.grove_enabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
location: 'settings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,15 +63,9 @@ export async function call(
|
||||
domainExcluded={config?.domain_excluded}
|
||||
onDone={onDoneWithSettingsCheck}
|
||||
></PrivacySettingsDialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Show the GroveDialog for users who haven't accepted terms yet
|
||||
return (
|
||||
<GroveDialog
|
||||
showIfAlreadyViewed={true}
|
||||
onDone={onDoneWithDecision}
|
||||
location={'settings'}
|
||||
/>
|
||||
)
|
||||
return <GroveDialog showIfAlreadyViewed={true} onDone={onDoneWithDecision} location={'settings'} />;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ function getEnvVarForProvider(provider: string): string {
|
||||
function getMergedEnv(): Record<string, string> {
|
||||
const settings = getSettings_DEPRECATED()
|
||||
const merged: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(process.env).filter((e): e is [string, string] => e[1] !== undefined)
|
||||
Object.entries(process.env).filter(
|
||||
(e): e is [string, string] => e[1] !== undefined,
|
||||
),
|
||||
)
|
||||
if (settings?.env) {
|
||||
Object.assign(merged, settings.env)
|
||||
@@ -123,7 +125,12 @@ const call: LocalCommandCall = async (args, context) => {
|
||||
// Handle different provider types
|
||||
// - 'anthropic', 'openai', 'gemini' are stored in settings.json (persistent)
|
||||
// - 'bedrock', 'vertex', 'foundry' are env-only (do NOT touch settings.json)
|
||||
if (arg === 'anthropic' || arg === 'openai' || arg === 'gemini' || arg === 'grok') {
|
||||
if (
|
||||
arg === 'anthropic' ||
|
||||
arg === 'openai' ||
|
||||
arg === 'gemini' ||
|
||||
arg === 'grok'
|
||||
) {
|
||||
// Clear any cloud provider env vars to avoid conflicts
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||
|
||||
@@ -1,71 +1,50 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import type {
|
||||
CommandResultDisplay,
|
||||
LocalJSXCommandContext,
|
||||
} from '../../commands.js'
|
||||
import {
|
||||
type OptionWithDescription,
|
||||
Select,
|
||||
} from '../../components/CustomSelect/select.js'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import { useClaudeAiLimits } from '../../services/claudeAiLimitsHook.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import {
|
||||
getOauthAccountInfo,
|
||||
getRateLimitTier,
|
||||
getSubscriptionType,
|
||||
} from '../../utils/auth.js'
|
||||
import { hasClaudeAiBillingAccess } from '../../utils/billing.js'
|
||||
import { call as extraUsageCall } from '../extra-usage/extra-usage.js'
|
||||
import { extraUsage } from '../extra-usage/index.js'
|
||||
import upgrade from '../upgrade/index.js'
|
||||
import { call as upgradeCall } from '../upgrade/upgrade.js'
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js';
|
||||
import { type OptionWithDescription, Select } from '../../components/CustomSelect/select.js';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import { useClaudeAiLimits } from '../../services/claudeAiLimitsHook.js';
|
||||
import type { ToolUseContext } from '../../Tool.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { getOauthAccountInfo, getRateLimitTier, getSubscriptionType } from '../../utils/auth.js';
|
||||
import { hasClaudeAiBillingAccess } from '../../utils/billing.js';
|
||||
import { call as extraUsageCall } from '../extra-usage/extra-usage.js';
|
||||
import { extraUsage } from '../extra-usage/index.js';
|
||||
import upgrade from '../upgrade/index.js';
|
||||
import { call as upgradeCall } from '../upgrade/upgrade.js';
|
||||
|
||||
type RateLimitOptionsMenuOptionType = 'upgrade' | 'extra-usage' | 'cancel'
|
||||
type RateLimitOptionsMenuOptionType = 'upgrade' | 'extra-usage' | 'cancel';
|
||||
|
||||
type RateLimitOptionsMenuProps = {
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?:
|
||||
| {
|
||||
display?: CommandResultDisplay | undefined
|
||||
display?: CommandResultDisplay | undefined;
|
||||
}
|
||||
| undefined,
|
||||
) => void
|
||||
context: ToolUseContext & LocalJSXCommandContext
|
||||
}
|
||||
) => void;
|
||||
context: ToolUseContext & LocalJSXCommandContext;
|
||||
};
|
||||
|
||||
function RateLimitOptionsMenu({
|
||||
onDone,
|
||||
context,
|
||||
}: RateLimitOptionsMenuProps): React.ReactNode {
|
||||
const [subCommandJSX, setSubCommandJSX] = useState<React.ReactNode>(null)
|
||||
const claudeAiLimits = useClaudeAiLimits()
|
||||
const subscriptionType = getSubscriptionType()
|
||||
const rateLimitTier = getRateLimitTier()
|
||||
const hasExtraUsageEnabled =
|
||||
getOauthAccountInfo()?.hasExtraUsageEnabled === true
|
||||
const isMax = subscriptionType === 'max'
|
||||
const isMax20x = isMax && rateLimitTier === 'default_claude_max_20x'
|
||||
const isTeamOrEnterprise =
|
||||
subscriptionType === 'team' || subscriptionType === 'enterprise'
|
||||
const buyFirst = getFeatureValue_CACHED_MAY_BE_STALE(
|
||||
'tengu_jade_anvil_4',
|
||||
false,
|
||||
)
|
||||
function RateLimitOptionsMenu({ onDone, context }: RateLimitOptionsMenuProps): React.ReactNode {
|
||||
const [subCommandJSX, setSubCommandJSX] = useState<React.ReactNode>(null);
|
||||
const claudeAiLimits = useClaudeAiLimits();
|
||||
const subscriptionType = getSubscriptionType();
|
||||
const rateLimitTier = getRateLimitTier();
|
||||
const hasExtraUsageEnabled = getOauthAccountInfo()?.hasExtraUsageEnabled === true;
|
||||
const isMax = subscriptionType === 'max';
|
||||
const isMax20x = isMax && rateLimitTier === 'default_claude_max_20x';
|
||||
const isTeamOrEnterprise = subscriptionType === 'team' || subscriptionType === 'enterprise';
|
||||
const buyFirst = getFeatureValue_CACHED_MAY_BE_STALE('tengu_jade_anvil_4', false);
|
||||
|
||||
const options = useMemo<
|
||||
OptionWithDescription<RateLimitOptionsMenuOptionType>[]
|
||||
>(() => {
|
||||
const actionOptions: OptionWithDescription<RateLimitOptionsMenuOptionType>[] =
|
||||
[]
|
||||
const options = useMemo<OptionWithDescription<RateLimitOptionsMenuOptionType>[]>(() => {
|
||||
const actionOptions: OptionWithDescription<RateLimitOptionsMenuOptionType>[] = [];
|
||||
|
||||
if (extraUsage.isEnabled()) {
|
||||
const hasBillingAccess = hasClaudeAiBillingAccess()
|
||||
const needsToRequestFromAdmin = isTeamOrEnterprise && !hasBillingAccess
|
||||
const hasBillingAccess = hasClaudeAiBillingAccess();
|
||||
const needsToRequestFromAdmin = isTeamOrEnterprise && !hasBillingAccess;
|
||||
// Org spend cap depleted - non-admins can't request more since there's nothing to allocate
|
||||
// - out_of_credits: wallet empty
|
||||
// - org_level_disabled_until: org spend cap hit for the month
|
||||
@@ -73,29 +52,26 @@ function RateLimitOptionsMenu({
|
||||
const isOrgSpendCapDepleted =
|
||||
claudeAiLimits.overageDisabledReason === 'out_of_credits' ||
|
||||
claudeAiLimits.overageDisabledReason === 'org_level_disabled_until' ||
|
||||
claudeAiLimits.overageDisabledReason === 'org_service_zero_credit_limit'
|
||||
claudeAiLimits.overageDisabledReason === 'org_service_zero_credit_limit';
|
||||
|
||||
// Hide for non-admin Team/Enterprise users when org spend cap is depleted
|
||||
if (needsToRequestFromAdmin && isOrgSpendCapDepleted) {
|
||||
// Don't show extra-usage option
|
||||
} else {
|
||||
const isOverageState =
|
||||
claudeAiLimits.overageStatus === 'rejected' ||
|
||||
claudeAiLimits.overageStatus === 'allowed_warning'
|
||||
claudeAiLimits.overageStatus === 'rejected' || claudeAiLimits.overageStatus === 'allowed_warning';
|
||||
|
||||
let label: string
|
||||
let label: string;
|
||||
if (needsToRequestFromAdmin) {
|
||||
label = isOverageState ? 'Request more' : 'Request extra usage'
|
||||
label = isOverageState ? 'Request more' : 'Request extra usage';
|
||||
} else {
|
||||
label = hasExtraUsageEnabled
|
||||
? 'Add funds to continue with extra usage'
|
||||
: 'Switch to extra usage'
|
||||
label = hasExtraUsageEnabled ? 'Add funds to continue with extra usage' : 'Switch to extra usage';
|
||||
}
|
||||
|
||||
actionOptions.push({
|
||||
label,
|
||||
value: 'extra-usage',
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,19 +79,18 @@ function RateLimitOptionsMenu({
|
||||
actionOptions.push({
|
||||
label: 'Upgrade your plan',
|
||||
value: 'upgrade',
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const cancelOption: OptionWithDescription<RateLimitOptionsMenuOptionType> =
|
||||
{
|
||||
label: 'Stop and wait for limit to reset',
|
||||
value: 'cancel',
|
||||
}
|
||||
const cancelOption: OptionWithDescription<RateLimitOptionsMenuOptionType> = {
|
||||
label: 'Stop and wait for limit to reset',
|
||||
value: 'cancel',
|
||||
};
|
||||
|
||||
if (buyFirst) {
|
||||
return [...actionOptions, cancelOption]
|
||||
return [...actionOptions, cancelOption];
|
||||
}
|
||||
return [cancelOption, ...actionOptions]
|
||||
return [cancelOption, ...actionOptions];
|
||||
}, [
|
||||
buyFirst,
|
||||
isMax20x,
|
||||
@@ -123,55 +98,51 @@ function RateLimitOptionsMenu({
|
||||
hasExtraUsageEnabled,
|
||||
claudeAiLimits.overageStatus,
|
||||
claudeAiLimits.overageDisabledReason,
|
||||
])
|
||||
]);
|
||||
|
||||
function handleCancel(): void {
|
||||
logEvent('tengu_rate_limit_options_menu_cancel', {})
|
||||
onDone(undefined, { display: 'skip' })
|
||||
logEvent('tengu_rate_limit_options_menu_cancel', {});
|
||||
onDone(undefined, { display: 'skip' });
|
||||
}
|
||||
|
||||
function handleSelect(value: RateLimitOptionsMenuOptionType): void {
|
||||
if (value === 'upgrade') {
|
||||
logEvent('tengu_rate_limit_options_menu_select_upgrade', {})
|
||||
logEvent('tengu_rate_limit_options_menu_select_upgrade', {});
|
||||
void upgradeCall(onDone, context).then(jsx => {
|
||||
if (jsx) {
|
||||
setSubCommandJSX(jsx)
|
||||
setSubCommandJSX(jsx);
|
||||
}
|
||||
})
|
||||
});
|
||||
} else if (value === 'extra-usage') {
|
||||
logEvent('tengu_rate_limit_options_menu_select_extra_usage', {})
|
||||
logEvent('tengu_rate_limit_options_menu_select_extra_usage', {});
|
||||
void extraUsageCall(onDone, context).then(jsx => {
|
||||
if (jsx) {
|
||||
setSubCommandJSX(jsx)
|
||||
setSubCommandJSX(jsx);
|
||||
}
|
||||
})
|
||||
});
|
||||
} else if (value === 'cancel') {
|
||||
handleCancel()
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
|
||||
if (subCommandJSX) {
|
||||
return subCommandJSX
|
||||
return subCommandJSX;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="What do you want to do?"
|
||||
onCancel={handleCancel}
|
||||
color="suggestion"
|
||||
>
|
||||
<Dialog title="What do you want to do?" onCancel={handleCancel} color="suggestion">
|
||||
<Select<RateLimitOptionsMenuOptionType>
|
||||
options={options}
|
||||
onChange={handleSelect}
|
||||
visibleOptionCount={options.length}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: ToolUseContext & LocalJSXCommandContext,
|
||||
): Promise<React.ReactNode> {
|
||||
return <RateLimitOptionsMenu onDone={onDone} context={context} />
|
||||
return <RateLimitOptionsMenu onDone={onDone} context={context} />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import { RemoteEnvironmentDialog } from '../../components/RemoteEnvironmentDialog.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import * as React from 'react';
|
||||
import { RemoteEnvironmentDialog } from '../../components/RemoteEnvironmentDialog.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
): Promise<React.ReactNode> {
|
||||
return <RemoteEnvironmentDialog onDone={onDone} />
|
||||
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
|
||||
return <RemoteEnvironmentDialog onDone={onDone} />;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { execa } from 'execa'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Select } from '../../components/CustomSelect/index.js'
|
||||
import { Box, Dialog, LoadingState, Text } from '@anthropic/ink'
|
||||
import { execa } from 'execa';
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Select } from '../../components/CustomSelect/index.js';
|
||||
import { Box, Dialog, LoadingState, Text } from '@anthropic/ink';
|
||||
import {
|
||||
logEvent,
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS as SafeString,
|
||||
} from '../../services/analytics/index.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { openBrowser } from '../../utils/browser.js'
|
||||
import { getGhAuthStatus } from '../../utils/github/ghAuthStatus.js'
|
||||
} from '../../services/analytics/index.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { openBrowser } from '../../utils/browser.js';
|
||||
import { getGhAuthStatus } from '../../utils/github/ghAuthStatus.js';
|
||||
import {
|
||||
createDefaultEnvironment,
|
||||
getCodeWebUrl,
|
||||
@@ -17,25 +17,25 @@ import {
|
||||
importGithubToken,
|
||||
isSignedIn,
|
||||
RedactedGithubToken,
|
||||
} from './api.js'
|
||||
} from './api.js';
|
||||
|
||||
type CheckResult =
|
||||
| { status: 'not_signed_in' }
|
||||
| { status: 'has_gh_token'; token: RedactedGithubToken }
|
||||
| { status: 'gh_not_installed' }
|
||||
| { status: 'gh_not_authenticated' }
|
||||
| { status: 'gh_not_authenticated' };
|
||||
|
||||
async function checkLoginState(): Promise<CheckResult> {
|
||||
if (!(await isSignedIn())) {
|
||||
return { status: 'not_signed_in' }
|
||||
return { status: 'not_signed_in' };
|
||||
}
|
||||
|
||||
const ghStatus = await getGhAuthStatus()
|
||||
const ghStatus = await getGhAuthStatus();
|
||||
if (ghStatus === 'not_installed') {
|
||||
return { status: 'gh_not_installed' }
|
||||
return { status: 'gh_not_installed' };
|
||||
}
|
||||
if (ghStatus === 'not_authenticated') {
|
||||
return { status: 'gh_not_authenticated' }
|
||||
return { status: 'gh_not_authenticated' };
|
||||
}
|
||||
|
||||
// ghStatus === 'authenticated'. getGhAuthStatus spawns with stdout:'ignore'
|
||||
@@ -45,125 +45,113 @@ async function checkLoginState(): Promise<CheckResult> {
|
||||
stderr: 'ignore',
|
||||
timeout: 5000,
|
||||
reject: false,
|
||||
})
|
||||
const trimmed = stdout.trim()
|
||||
});
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
return { status: 'gh_not_authenticated' }
|
||||
return { status: 'gh_not_authenticated' };
|
||||
}
|
||||
return { status: 'has_gh_token', token: new RedactedGithubToken(trimmed) }
|
||||
return { status: 'has_gh_token', token: new RedactedGithubToken(trimmed) };
|
||||
}
|
||||
|
||||
function errorMessage(err: ImportTokenError, codeUrl: string): string {
|
||||
switch (err.kind) {
|
||||
case 'not_signed_in':
|
||||
return `Login failed. Please visit ${codeUrl} and login using the GitHub App`
|
||||
return `Login failed. Please visit ${codeUrl} and login using the GitHub App`;
|
||||
case 'invalid_token':
|
||||
return 'GitHub rejected that token. Run `gh auth login` and try again.'
|
||||
return 'GitHub rejected that token. Run `gh auth login` and try again.';
|
||||
case 'server':
|
||||
return `Server error (${err.status}). Try again in a moment.`
|
||||
return `Server error (${err.status}). Try again in a moment.`;
|
||||
case 'network':
|
||||
return "Couldn't reach the server. Check your connection."
|
||||
return "Couldn't reach the server. Check your connection.";
|
||||
}
|
||||
}
|
||||
|
||||
type Step =
|
||||
| { name: 'checking' }
|
||||
| { name: 'confirm'; token: RedactedGithubToken }
|
||||
| { name: 'uploading' }
|
||||
type Step = { name: 'checking' } | { name: 'confirm'; token: RedactedGithubToken } | { name: 'uploading' };
|
||||
|
||||
function Web({ onDone }: { onDone: LocalJSXCommandOnDone }) {
|
||||
const [step, setStep] = useState<Step>({ name: 'checking' })
|
||||
const [step, setStep] = useState<Step>({ name: 'checking' });
|
||||
|
||||
useEffect(() => {
|
||||
logEvent('tengu_remote_setup_started', {})
|
||||
logEvent('tengu_remote_setup_started', {});
|
||||
void checkLoginState().then(async result => {
|
||||
switch (result.status) {
|
||||
case 'not_signed_in':
|
||||
logEvent('tengu_remote_setup_result', {
|
||||
result: 'not_signed_in' as SafeString,
|
||||
})
|
||||
onDone('Not signed in to Claude. Run /login first.')
|
||||
return
|
||||
});
|
||||
onDone('Not signed in to Claude. Run /login first.');
|
||||
return;
|
||||
case 'gh_not_installed':
|
||||
case 'gh_not_authenticated': {
|
||||
const url = `${getCodeWebUrl()}/onboarding?step=alt-auth`
|
||||
await openBrowser(url)
|
||||
const url = `${getCodeWebUrl()}/onboarding?step=alt-auth`;
|
||||
await openBrowser(url);
|
||||
logEvent('tengu_remote_setup_result', {
|
||||
result: result.status as SafeString,
|
||||
})
|
||||
});
|
||||
onDone(
|
||||
result.status === 'gh_not_installed'
|
||||
? `GitHub CLI not found. Install it via https://cli.github.com/, then run \`gh auth login\`, or connect GitHub on the web: ${url}`
|
||||
: `GitHub CLI not authenticated. Run \`gh auth login\` and try again, or connect GitHub on the web: ${url}`,
|
||||
)
|
||||
return
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'has_gh_token':
|
||||
setStep({ name: 'confirm', token: result.token })
|
||||
setStep({ name: 'confirm', token: result.token });
|
||||
}
|
||||
})
|
||||
});
|
||||
// onDone is stable across renders; intentionally not in deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
const handleCancel = () => {
|
||||
logEvent('tengu_remote_setup_result', {
|
||||
result: 'cancelled' as SafeString,
|
||||
})
|
||||
onDone()
|
||||
}
|
||||
});
|
||||
onDone();
|
||||
};
|
||||
|
||||
const handleConfirm = async (token: RedactedGithubToken) => {
|
||||
setStep({ name: 'uploading' })
|
||||
setStep({ name: 'uploading' });
|
||||
|
||||
const result = await importGithubToken(token)
|
||||
const result = await importGithubToken(token);
|
||||
if (!result.ok) {
|
||||
const err = (result as { ok: false; error: ImportTokenError }).error
|
||||
const err = (result as { ok: false; error: ImportTokenError }).error;
|
||||
logEvent('tengu_remote_setup_result', {
|
||||
result: 'import_failed' as SafeString,
|
||||
error_kind: err.kind as SafeString,
|
||||
})
|
||||
onDone(errorMessage(err, getCodeWebUrl()))
|
||||
return
|
||||
});
|
||||
onDone(errorMessage(err, getCodeWebUrl()));
|
||||
return;
|
||||
}
|
||||
|
||||
// Token import succeeded. Environment creation is best-effort — if it
|
||||
// fails, the web state machine routes to env-setup on landing, which is
|
||||
// one extra click but still better than the OAuth dance.
|
||||
await createDefaultEnvironment()
|
||||
await createDefaultEnvironment();
|
||||
|
||||
const url = getCodeWebUrl()
|
||||
await openBrowser(url)
|
||||
const url = getCodeWebUrl();
|
||||
await openBrowser(url);
|
||||
|
||||
logEvent('tengu_remote_setup_result', {
|
||||
result: 'success' as SafeString,
|
||||
})
|
||||
onDone(`Connected as ${result.result.github_username}. Opened ${url}`)
|
||||
}
|
||||
});
|
||||
onDone(`Connected as ${result.result.github_username}. Opened ${url}`);
|
||||
};
|
||||
|
||||
if (step.name === 'checking') {
|
||||
return <LoadingState message="Checking login status…" />
|
||||
return <LoadingState message="Checking login status…" />;
|
||||
}
|
||||
|
||||
if (step.name === 'uploading') {
|
||||
return <LoadingState message="Connecting GitHub to Claude…" />
|
||||
return <LoadingState message="Connecting GitHub to Claude…" />;
|
||||
}
|
||||
|
||||
const token = step.token
|
||||
const token = step.token;
|
||||
return (
|
||||
<Dialog
|
||||
title="Connect Claude on the web to GitHub?"
|
||||
onCancel={handleCancel}
|
||||
hideInputGuide
|
||||
>
|
||||
<Dialog title="Connect Claude on the web to GitHub?" onCancel={handleCancel} hideInputGuide>
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
Claude on the web requires connecting to your GitHub account to clone
|
||||
and push code on your behalf.
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Your local credentials are used to authenticate with GitHub
|
||||
</Text>
|
||||
<Text>Claude on the web requires connecting to your GitHub account to clone and push code on your behalf.</Text>
|
||||
<Text dimColor>Your local credentials are used to authenticate with GitHub</Text>
|
||||
</Box>
|
||||
<Select
|
||||
options={[
|
||||
@@ -172,19 +160,17 @@ function Web({ onDone }: { onDone: LocalJSXCommandOnDone }) {
|
||||
]}
|
||||
onChange={value => {
|
||||
if (value === 'send') {
|
||||
void handleConfirm(token)
|
||||
void handleConfirm(token);
|
||||
} else {
|
||||
handleCancel()
|
||||
handleCancel();
|
||||
}
|
||||
}}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
): Promise<React.ReactNode> {
|
||||
return <Web onDone={onDone} />
|
||||
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
|
||||
return <Web onDone={onDone} />;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,9 @@ export async function generateSessionName(
|
||||
},
|
||||
})
|
||||
|
||||
const content = Array.isArray(result.message.content) ? extractTextContent(result.message.content) : (result.message.content as string)
|
||||
const content = Array.isArray(result.message.content)
|
||||
? extractTextContent(result.message.content)
|
||||
: (result.message.content as string)
|
||||
|
||||
const response = safeParseJSON(content)
|
||||
if (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const stub = { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default stub;
|
||||
export const resetLimits = stub;
|
||||
export const resetLimitsNonInteractive = stub;
|
||||
const stub = { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
export default stub
|
||||
export const resetLimits = stub
|
||||
export const resetLimitsNonInteractive = stub
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
const stub = { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export const resetLimits = stub;
|
||||
export const resetLimitsNonInteractive = stub;
|
||||
const stub = { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
export const resetLimits = stub
|
||||
export const resetLimitsNonInteractive = stub
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import chalk from 'chalk'
|
||||
import type { UUID } from 'crypto'
|
||||
import figures from 'figures'
|
||||
import * as React from 'react'
|
||||
import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js'
|
||||
import type { CommandResultDisplay, ResumeEntrypoint } from '../../commands.js'
|
||||
import { LogSelector } from '../../components/LogSelector.js'
|
||||
import { MessageResponse } from '../../components/MessageResponse.js'
|
||||
import { Spinner } from '../../components/Spinner.js'
|
||||
import { useIsInsideModal } from '../../context/modalContext.js'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { setClipboard } from '@anthropic/ink'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import type { LogOption } from '../../types/logs.js'
|
||||
import { agenticSessionSearch } from '../../utils/agenticSessionSearch.js'
|
||||
import { checkCrossProjectResume } from '../../utils/crossProjectResume.js'
|
||||
import { getWorktreePaths } from '../../utils/getWorktreePaths.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import chalk from 'chalk';
|
||||
import type { UUID } from 'crypto';
|
||||
import figures from 'figures';
|
||||
import * as React from 'react';
|
||||
import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js';
|
||||
import type { CommandResultDisplay, ResumeEntrypoint } from '../../commands.js';
|
||||
import { LogSelector } from '../../components/LogSelector.js';
|
||||
import { MessageResponse } from '../../components/MessageResponse.js';
|
||||
import { Spinner } from '../../components/Spinner.js';
|
||||
import { useIsInsideModal } from '../../context/modalContext.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { setClipboard } from '@anthropic/ink';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
import type { LogOption } from '../../types/logs.js';
|
||||
import { agenticSessionSearch } from '../../utils/agenticSessionSearch.js';
|
||||
import { checkCrossProjectResume } from '../../utils/crossProjectResume.js';
|
||||
import { getWorktreePaths } from '../../utils/getWorktreePaths.js';
|
||||
import { logError } from '../../utils/log.js';
|
||||
import {
|
||||
getLastSessionLog,
|
||||
getSessionIdFromLog,
|
||||
@@ -26,19 +26,19 @@ import {
|
||||
loadFullLog,
|
||||
loadSameRepoMessageLogs,
|
||||
searchSessionsByCustomTitle,
|
||||
} from '../../utils/sessionStorage.js'
|
||||
import { validateUuid } from '../../utils/uuid.js'
|
||||
} from '../../utils/sessionStorage.js';
|
||||
import { validateUuid } from '../../utils/uuid.js';
|
||||
|
||||
type ResumeResult =
|
||||
| { resultType: 'sessionNotFound'; arg: string }
|
||||
| { resultType: 'multipleMatches'; arg: string; count: number }
|
||||
| { resultType: 'multipleMatches'; arg: string; count: number };
|
||||
|
||||
function resumeHelpMessage(result: ResumeResult): string {
|
||||
switch (result.resultType) {
|
||||
case 'sessionNotFound':
|
||||
return `Session ${chalk.bold(result.arg)} was not found.`
|
||||
return `Session ${chalk.bold(result.arg)} was not found.`;
|
||||
case 'multipleMatches':
|
||||
return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Please use /resume to pick a specific session.`
|
||||
return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Please use /resume to pick a specific session.`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,14 +47,14 @@ function ResumeError({
|
||||
args,
|
||||
onDone,
|
||||
}: {
|
||||
message: string
|
||||
args: string
|
||||
onDone: () => void
|
||||
message: string;
|
||||
args: string;
|
||||
onDone: () => void;
|
||||
}): React.ReactNode {
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(onDone, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [onDone])
|
||||
const timer = setTimeout(onDone, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onDone]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -65,95 +65,82 @@ function ResumeError({
|
||||
<Text>{message}</Text>
|
||||
</MessageResponse>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ResumeCommand({
|
||||
onDone,
|
||||
onResume,
|
||||
}: {
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
onResume: (
|
||||
sessionId: UUID,
|
||||
log: LogOption,
|
||||
entrypoint: ResumeEntrypoint,
|
||||
) => Promise<void>
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
onResume: (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => Promise<void>;
|
||||
}): React.ReactNode {
|
||||
const [logs, setLogs] = React.useState<LogOption[]>([])
|
||||
const [worktreePaths, setWorktreePaths] = React.useState<string[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [resuming, setResuming] = React.useState(false)
|
||||
const [showAllProjects, setShowAllProjects] = React.useState(false)
|
||||
const { rows } = useTerminalSize()
|
||||
const insideModal = useIsInsideModal()
|
||||
const [logs, setLogs] = React.useState<LogOption[]>([]);
|
||||
const [worktreePaths, setWorktreePaths] = React.useState<string[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [resuming, setResuming] = React.useState(false);
|
||||
const [showAllProjects, setShowAllProjects] = React.useState(false);
|
||||
const { rows } = useTerminalSize();
|
||||
const insideModal = useIsInsideModal();
|
||||
|
||||
const loadLogs = React.useCallback(
|
||||
async (allProjects: boolean, paths: string[]) => {
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
try {
|
||||
const allLogs = allProjects
|
||||
? await loadAllProjectsMessageLogs()
|
||||
: await loadSameRepoMessageLogs(paths)
|
||||
const resumable = filterResumableSessions(allLogs, getSessionId())
|
||||
const allLogs = allProjects ? await loadAllProjectsMessageLogs() : await loadSameRepoMessageLogs(paths);
|
||||
const resumable = filterResumableSessions(allLogs, getSessionId());
|
||||
if (resumable.length === 0) {
|
||||
onDone('No conversations found to resume')
|
||||
return
|
||||
onDone('No conversations found to resume');
|
||||
return;
|
||||
}
|
||||
setLogs(resumable)
|
||||
setLogs(resumable);
|
||||
} catch (_err) {
|
||||
onDone('Failed to load conversations')
|
||||
onDone('Failed to load conversations');
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[onDone],
|
||||
)
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function init() {
|
||||
const paths = await getWorktreePaths(getOriginalCwd())
|
||||
setWorktreePaths(paths)
|
||||
void loadLogs(false, paths)
|
||||
const paths = await getWorktreePaths(getOriginalCwd());
|
||||
setWorktreePaths(paths);
|
||||
void loadLogs(false, paths);
|
||||
}
|
||||
void init()
|
||||
}, [loadLogs])
|
||||
void init();
|
||||
}, [loadLogs]);
|
||||
|
||||
const handleToggleAllProjects = React.useCallback(() => {
|
||||
const newValue = !showAllProjects
|
||||
setShowAllProjects(newValue)
|
||||
void loadLogs(newValue, worktreePaths)
|
||||
}, [showAllProjects, loadLogs, worktreePaths])
|
||||
const newValue = !showAllProjects;
|
||||
setShowAllProjects(newValue);
|
||||
void loadLogs(newValue, worktreePaths);
|
||||
}, [showAllProjects, loadLogs, worktreePaths]);
|
||||
|
||||
async function handleSelect(log: LogOption) {
|
||||
const sessionId = validateUuid(getSessionIdFromLog(log))
|
||||
const sessionId = validateUuid(getSessionIdFromLog(log));
|
||||
if (!sessionId) {
|
||||
onDone('Failed to resume conversation')
|
||||
return
|
||||
onDone('Failed to resume conversation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load full messages for lite logs
|
||||
const fullLog = isLiteLog(log) ? await loadFullLog(log) : log
|
||||
const fullLog = isLiteLog(log) ? await loadFullLog(log) : log;
|
||||
|
||||
// Check if this conversation is from a different directory
|
||||
const crossProjectCheck = checkCrossProjectResume(
|
||||
fullLog,
|
||||
showAllProjects,
|
||||
worktreePaths,
|
||||
)
|
||||
const crossProjectCheck = checkCrossProjectResume(fullLog, showAllProjects, worktreePaths);
|
||||
if (crossProjectCheck.isCrossProject) {
|
||||
if (crossProjectCheck.isSameRepoWorktree) {
|
||||
// Same repo worktree - can resume directly
|
||||
setResuming(true)
|
||||
void onResume(sessionId, fullLog, 'slash_command_picker')
|
||||
return
|
||||
setResuming(true);
|
||||
void onResume(sessionId, fullLog, 'slash_command_picker');
|
||||
return;
|
||||
}
|
||||
|
||||
// Different project - show command instead of resuming
|
||||
const raw = await setClipboard((crossProjectCheck as { command: string }).command)
|
||||
if (raw) process.stdout.write(raw)
|
||||
const raw = await setClipboard((crossProjectCheck as { command: string }).command);
|
||||
if (raw) process.stdout.write(raw);
|
||||
|
||||
// Format the output message
|
||||
const message = [
|
||||
@@ -165,19 +152,19 @@ function ResumeCommand({
|
||||
'',
|
||||
'(Command copied to clipboard)',
|
||||
'',
|
||||
].join('\n')
|
||||
].join('\n');
|
||||
|
||||
onDone(message, { display: 'user' })
|
||||
return
|
||||
onDone(message, { display: 'user' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Same directory - proceed with resume
|
||||
setResuming(true)
|
||||
void onResume(sessionId, fullLog, 'slash_command_picker')
|
||||
setResuming(true);
|
||||
void onResume(sessionId, fullLog, 'slash_command_picker');
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
onDone('Resume cancelled', { display: 'system' })
|
||||
onDone('Resume cancelled', { display: 'system' });
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@@ -186,7 +173,7 @@ function ResumeCommand({
|
||||
<Spinner />
|
||||
<Text> Loading conversations…</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (resuming) {
|
||||
@@ -195,7 +182,7 @@ function ResumeCommand({
|
||||
<Spinner />
|
||||
<Text> Resuming conversation…</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -209,77 +196,60 @@ function ResumeCommand({
|
||||
onToggleAllProjects={handleToggleAllProjects}
|
||||
onAgenticSearch={agenticSessionSearch}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function filterResumableSessions(
|
||||
logs: LogOption[],
|
||||
currentSessionId: string,
|
||||
): LogOption[] {
|
||||
return logs.filter(
|
||||
l => !l.isSidechain && getSessionIdFromLog(l) !== currentSessionId,
|
||||
)
|
||||
export function filterResumableSessions(logs: LogOption[], currentSessionId: string): LogOption[] {
|
||||
return logs.filter(l => !l.isSidechain && getSessionIdFromLog(l) !== currentSessionId);
|
||||
}
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, context, args) => {
|
||||
const onResume = async (
|
||||
sessionId: UUID,
|
||||
log: LogOption,
|
||||
entrypoint: ResumeEntrypoint,
|
||||
) => {
|
||||
const onResume = async (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => {
|
||||
try {
|
||||
await context.resume?.(sessionId, log, entrypoint)
|
||||
onDone(undefined, { display: 'skip' })
|
||||
await context.resume?.(sessionId, log, entrypoint);
|
||||
onDone(undefined, { display: 'skip' });
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
onDone(`Failed to resume: ${(error as Error).message}`)
|
||||
logError(error as Error);
|
||||
onDone(`Failed to resume: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const arg = args?.trim()
|
||||
const arg = args?.trim();
|
||||
|
||||
// No argument provided - show picker
|
||||
if (!arg) {
|
||||
return (
|
||||
<ResumeCommand key={Date.now()} onDone={onDone} onResume={onResume} />
|
||||
)
|
||||
return <ResumeCommand key={Date.now()} onDone={onDone} onResume={onResume} />;
|
||||
}
|
||||
|
||||
// Load logs to search (includes same-repo worktrees)
|
||||
const worktreePaths = await getWorktreePaths(getOriginalCwd())
|
||||
const logs = await loadSameRepoMessageLogs(worktreePaths)
|
||||
const worktreePaths = await getWorktreePaths(getOriginalCwd());
|
||||
const logs = await loadSameRepoMessageLogs(worktreePaths);
|
||||
if (logs.length === 0) {
|
||||
const message = 'No conversations found to resume.'
|
||||
return (
|
||||
<ResumeError
|
||||
message={message}
|
||||
args={arg}
|
||||
onDone={() => onDone(message)}
|
||||
/>
|
||||
)
|
||||
const message = 'No conversations found to resume.';
|
||||
return <ResumeError message={message} args={arg} onDone={() => onDone(message)} />;
|
||||
}
|
||||
|
||||
// First, check if arg is a valid UUID
|
||||
const maybeSessionId = validateUuid(arg)
|
||||
const maybeSessionId = validateUuid(arg);
|
||||
if (maybeSessionId) {
|
||||
const matchingLogs = logs
|
||||
.filter(l => getSessionIdFromLog(l) === maybeSessionId)
|
||||
.sort((a, b) => b.modified.getTime() - a.modified.getTime())
|
||||
.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
||||
|
||||
if (matchingLogs.length > 0) {
|
||||
const log = matchingLogs[0]!
|
||||
const fullLog = isLiteLog(log) ? await loadFullLog(log) : log
|
||||
void onResume(maybeSessionId, fullLog, 'slash_command_session_id')
|
||||
return null
|
||||
const log = matchingLogs[0]!;
|
||||
const fullLog = isLiteLog(log) ? await loadFullLog(log) : log;
|
||||
void onResume(maybeSessionId, fullLog, 'slash_command_session_id');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Enriched logs didn't find it — try direct file lookup. This handles
|
||||
// sessions filtered out by enrichLogs (e.g., first message >16KB makes
|
||||
// firstPrompt extraction fail, causing the session to be dropped).
|
||||
const directLog = await getLastSessionLog(maybeSessionId)
|
||||
const directLog = await getLastSessionLog(maybeSessionId);
|
||||
if (directLog) {
|
||||
void onResume(maybeSessionId, directLog, 'slash_command_session_id')
|
||||
return null
|
||||
void onResume(maybeSessionId, directLog, 'slash_command_session_id');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,14 +257,14 @@ export const call: LocalJSXCommandCall = async (onDone, context, args) => {
|
||||
if (isCustomTitleEnabled()) {
|
||||
const titleMatches = await searchSessionsByCustomTitle(arg, {
|
||||
exact: true,
|
||||
})
|
||||
});
|
||||
if (titleMatches.length === 1) {
|
||||
const log = titleMatches[0]!
|
||||
const sessionId = getSessionIdFromLog(log)
|
||||
const log = titleMatches[0]!;
|
||||
const sessionId = getSessionIdFromLog(log);
|
||||
if (sessionId) {
|
||||
const fullLog = isLiteLog(log) ? await loadFullLog(log) : log
|
||||
void onResume(sessionId, fullLog, 'slash_command_title')
|
||||
return null
|
||||
const fullLog = isLiteLog(log) ? await loadFullLog(log) : log;
|
||||
void onResume(sessionId, fullLog, 'slash_command_title');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,20 +274,12 @@ export const call: LocalJSXCommandCall = async (onDone, context, args) => {
|
||||
resultType: 'multipleMatches',
|
||||
arg,
|
||||
count: titleMatches.length,
|
||||
})
|
||||
return (
|
||||
<ResumeError
|
||||
message={message}
|
||||
args={arg}
|
||||
onDone={() => onDone(message)}
|
||||
/>
|
||||
)
|
||||
});
|
||||
return <ResumeError message={message} args={arg} onDone={() => onDone(message)} />;
|
||||
}
|
||||
}
|
||||
|
||||
// No match found - show error
|
||||
const message = resumeHelpMessage({ resultType: 'sessionNotFound', arg })
|
||||
return (
|
||||
<ResumeError message={message} args={arg} onDone={() => onDone(message)} />
|
||||
)
|
||||
}
|
||||
const message = resumeHelpMessage({ resultType: 'sessionNotFound', arg });
|
||||
return <ResumeError message={message} args={arg} onDone={() => onDone(message)} />;
|
||||
};
|
||||
|
||||
@@ -1,70 +1,56 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { Select } from '../../components/CustomSelect/select.js'
|
||||
import { Box, Dialog, Text } from '@anthropic/ink'
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Select } from '../../components/CustomSelect/select.js';
|
||||
import { Box, Dialog, Text } from '@anthropic/ink';
|
||||
|
||||
type Props = {
|
||||
onProceed: (signal: AbortSignal) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
onProceed: (signal: AbortSignal) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function UltrareviewOverageDialog({
|
||||
onProceed,
|
||||
onCancel,
|
||||
}: Props): React.ReactNode {
|
||||
const [isLaunching, setIsLaunching] = useState(false)
|
||||
const abortControllerRef = useRef(new AbortController())
|
||||
export function UltrareviewOverageDialog({ onProceed, onCancel }: Props): React.ReactNode {
|
||||
const [isLaunching, setIsLaunching] = useState(false);
|
||||
const abortControllerRef = useRef(new AbortController());
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === 'proceed') {
|
||||
setIsLaunching(true)
|
||||
setIsLaunching(true);
|
||||
// If onProceed rejects (e.g. launchRemoteReview throws), onDone is
|
||||
// never called and the dialog stays mounted — restore the Select so
|
||||
// the user can retry or cancel instead of staring at "Launching…".
|
||||
void onProceed(abortControllerRef.current.signal).catch(() =>
|
||||
setIsLaunching(false),
|
||||
)
|
||||
void onProceed(abortControllerRef.current.signal).catch(() => setIsLaunching(false));
|
||||
} else {
|
||||
onCancel()
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[onProceed, onCancel],
|
||||
)
|
||||
);
|
||||
|
||||
// Escape during launch aborts the in-flight onProceed via signal so the
|
||||
// caller can skip side effects (confirmOverage, onDone) — otherwise a
|
||||
// fire-and-forget launch would keep running and bill despite "cancelled".
|
||||
const handleCancel = useCallback(() => {
|
||||
abortControllerRef.current.abort()
|
||||
onCancel()
|
||||
}, [onCancel])
|
||||
abortControllerRef.current.abort();
|
||||
onCancel();
|
||||
}, [onCancel]);
|
||||
|
||||
const options = [
|
||||
{ label: 'Proceed with Extra Usage billing', value: 'proceed' },
|
||||
{ label: 'Cancel', value: 'cancel' },
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Ultrareview billing"
|
||||
onCancel={handleCancel}
|
||||
color="background"
|
||||
>
|
||||
<Dialog title="Ultrareview billing" onCancel={handleCancel} color="background">
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
Your free ultrareviews for this organization are used. Further reviews
|
||||
bill as Extra Usage (pay-per-use).
|
||||
Your free ultrareviews for this organization are used. Further reviews bill as Extra Usage (pay-per-use).
|
||||
</Text>
|
||||
{isLaunching ? (
|
||||
<Text color="background">Launching…</Text>
|
||||
) : (
|
||||
<Select
|
||||
options={options}
|
||||
onChange={handleSelect}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
<Select options={options} onChange={handleSelect} onCancel={handleCancel} />
|
||||
)}
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,9 +137,9 @@ export async function launchRemoteReview(
|
||||
// consume at session creation routes billing: first N zero-rate, then
|
||||
// anthropic:cccr org-service-key (overage-only).
|
||||
if (!eligibility.eligible) {
|
||||
const blockers = (eligibility as { eligible: false; errors: Array<{ type: string }> }).errors.filter(
|
||||
e => e.type !== 'no_remote_environment',
|
||||
)
|
||||
const blockers = (
|
||||
eligibility as { eligible: false; errors: Array<{ type: string }> }
|
||||
).errors.filter(e => e.type !== 'no_remote_environment')
|
||||
if (blockers.length > 0) {
|
||||
logEvent('tengu_review_remote_precondition_failed', {
|
||||
precondition_errors: blockers
|
||||
@@ -148,7 +148,9 @@ export async function launchRemoteReview(
|
||||
',',
|
||||
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
const reasons = (blockers as BackgroundRemoteSessionPrecondition[]).map(formatPreconditionError).join('\n')
|
||||
const reasons = (blockers as BackgroundRemoteSessionPrecondition[])
|
||||
.map(formatPreconditionError)
|
||||
.join('\n')
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js'
|
||||
import React from 'react'
|
||||
import type {
|
||||
LocalJSXCommandCall,
|
||||
LocalJSXCommandOnDone,
|
||||
} from '../../types/command.js'
|
||||
import {
|
||||
checkOverageGate,
|
||||
confirmOverage,
|
||||
launchRemoteReview,
|
||||
} from './reviewRemote.js'
|
||||
import { UltrareviewOverageDialog } from './UltrareviewOverageDialog.js'
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js';
|
||||
import React from 'react';
|
||||
import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { checkOverageGate, confirmOverage, launchRemoteReview } from './reviewRemote.js';
|
||||
import { UltrareviewOverageDialog } from './UltrareviewOverageDialog.js';
|
||||
|
||||
function contentBlocksToString(blocks: ContentBlockParam[]): string {
|
||||
return blocks
|
||||
.map(b => (b.type === 'text' ? b.text : ''))
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function launchAndDone(
|
||||
@@ -25,65 +18,57 @@ async function launchAndDone(
|
||||
billingNote: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const result = await launchRemoteReview(args, context, billingNote)
|
||||
const result = await launchRemoteReview(args, context, billingNote);
|
||||
// User hit Escape during the ~5s launch — the dialog already showed
|
||||
// "cancelled" and unmounted, so skip onDone (would write to a dead
|
||||
// transcript slot) and let the caller skip confirmOverage.
|
||||
if (signal?.aborted) return
|
||||
if (signal?.aborted) return;
|
||||
if (result) {
|
||||
onDone(contentBlocksToString(result), { shouldQuery: true })
|
||||
onDone(contentBlocksToString(result), { shouldQuery: true });
|
||||
} else {
|
||||
// Precondition failures now return specific ContentBlockParam[] above.
|
||||
// null only reaches here on teleport failure (PR mode) or non-github
|
||||
// repo — both are CCR/repo connectivity issues.
|
||||
onDone(
|
||||
'Ultrareview failed to launch the remote session. Check that this is a GitHub repo and try again.',
|
||||
{ display: 'system' },
|
||||
)
|
||||
onDone('Ultrareview failed to launch the remote session. Check that this is a GitHub repo and try again.', {
|
||||
display: 'system',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, context, args) => {
|
||||
const gate = await checkOverageGate()
|
||||
const gate = await checkOverageGate();
|
||||
|
||||
if (gate.kind === 'not-enabled') {
|
||||
onDone(
|
||||
'Free ultrareviews used. Enable Extra Usage at https://claude.ai/settings/billing to continue.',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
onDone('Free ultrareviews used. Enable Extra Usage at https://claude.ai/settings/billing to continue.', {
|
||||
display: 'system',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (gate.kind === 'low-balance') {
|
||||
onDone(
|
||||
`Balance too low to launch ultrareview ($${gate.available.toFixed(2)} available, $10 minimum). Top up at https://claude.ai/settings/billing`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (gate.kind === 'needs-confirm') {
|
||||
return (
|
||||
<UltrareviewOverageDialog
|
||||
onProceed={async signal => {
|
||||
await launchAndDone(
|
||||
args,
|
||||
context,
|
||||
onDone,
|
||||
' This review bills as Extra Usage.',
|
||||
signal,
|
||||
)
|
||||
await launchAndDone(args, context, onDone, ' This review bills as Extra Usage.', signal);
|
||||
// Only persist the confirmation flag after a non-aborted launch —
|
||||
// otherwise Escape-during-launch would leave the flag set and
|
||||
// skip this dialog on the next attempt.
|
||||
if (!signal.aborted) confirmOverage()
|
||||
if (!signal.aborted) confirmOverage();
|
||||
}}
|
||||
onCancel={() => onDone('Ultrareview cancelled.', { display: 'system' })}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// gate.kind === 'proceed'
|
||||
await launchAndDone(args, context, onDone, gate.billingNote)
|
||||
return null
|
||||
}
|
||||
await launchAndDone(args, context, onDone, gate.billingNote);
|
||||
return null;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user