style: 完成所有文件的lint

This commit is contained in:
claude-code-best
2026-05-01 21:39:30 +08:00
parent d136872cc9
commit 6182015005
1333 changed files with 68255 additions and 77882 deletions

View File

@@ -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.`);
}}
/>
)
);
}

View File

@@ -1 +1,5 @@
export default { name: 'agents-platform', type: 'local', isEnabled: () => false }
export default {
name: 'agents-platform',
type: 'local',
isEnabled: () => false,
}

View File

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

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

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

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -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}
/>
)
}
);
};

View File

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

View File

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

View File

@@ -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" />;
};

View File

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

View File

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

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

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

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

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

View File

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

View File

@@ -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} />);
};

View File

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

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

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

View File

@@ -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);
}}
/>
)
);
}

View File

@@ -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');
}}
/>
)
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
</>
)
);
}

View File

@@ -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>
</>
)
);
}

View File

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

View File

@@ -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>
</>
)
);
}

View File

@@ -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>
</>
)
);
}

View File

@@ -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>
</>
)
);
}

View File

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

View File

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

View File

@@ -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&apos;t open? Use the url below to sign in{' '}
</Text>
<Text dimColor>Browser didn&apos;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>
)
);
}

View File

@@ -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&apos;t already
</Text>
<Text>1. Install the Claude GitHub App if you haven&apos;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&apos;t already
</Text>
<Text>2. Install the Claude GitHub App if you haven&apos;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>
</>
)
);
}

View File

@@ -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>
</>
)
);
}

View File

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

View File

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

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

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

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

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

View File

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

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&apos;t change. See each plugin&apos;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&apos;t change. See each plugin&apos;s homepage for more information.
{customMessage ? ` ${customMessage}` : ''}
</Text>
</Box>
)
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,2 +1,2 @@
// Auto-generated stub — replace with real implementation
export type UnifiedInstalledItem = any;
export type UnifiedInstalledItem = any

View File

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

View File

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

View File

@@ -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'} />;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)} />;
};

View File

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

View File

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

View File

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