import chalk from 'chalk' import { logForDebugging } from 'src/utils/debug.js' import { fileHistoryEnabled } from 'src/utils/fileHistory.js' import { getInitialSettings, getSettings_DEPRECATED, getSettingsForSource, } from 'src/utils/settings/settings.js' import { shouldOfferTerminalSetup } from '../../commands/terminalSetup/terminalSetup.js' import { getDesktopUpsellConfig } from '../../components/DesktopUpsell/DesktopUpsellStartup.js' import { color } from '../../components/design-system/color.js' import { shouldShowOverageCreditUpsell } from '../../components/LogoV2/OverageCreditUpsell.js' import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js' import { isKairosCronEnabled } from '../../tools/ScheduleCronTool/prompt.js' import { is1PApiCustomer } from '../../utils/auth.js' import { countConcurrentSessions } from '../../utils/concurrentSessions.js' import { getGlobalConfig } from '../../utils/config.js' import { getEffortEnvOverride, modelSupportsEffort, } from '../../utils/effort.js' import { env } from '../../utils/env.js' import { cacheKeys } from '../../utils/fileStateCache.js' import { getWorktreeCount } from '../../utils/git.js' import { detectRunningIDEsCached, getSortedIdeLockfiles, isCursorInstalled, isSupportedTerminal, isSupportedVSCodeTerminal, isVSCodeInstalled, isWindsurfInstalled, } from '../../utils/ide.js' import { getMainLoopModel, getUserSpecifiedModelSetting, } from '../../utils/model/model.js' import { getPlatform } from '../../utils/platform.js' import { isPluginInstalled } from '../../utils/plugins/installedPluginsManager.js' import { loadKnownMarketplacesConfigSafe } from '../../utils/plugins/marketplaceManager.js' import { OFFICIAL_MARKETPLACE_NAME } from '../../utils/plugins/officialMarketplace.js' import { getCurrentSessionAgentColor, isCustomTitleEnabled, } from '../../utils/sessionStorage.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' import { formatGrantAmount, getCachedOverageCreditGrant, } from '../api/overageCreditGrant.js' import { checkCachedPassesEligibility, formatCreditAmount, getCachedReferrerReward, } from '../api/referral.js' import { getSessionsSinceLastShown } from './tipHistory.js' import type { Tip, TipContext } from './types.js' let _isOfficialMarketplaceInstalledCache: boolean | undefined async function isOfficialMarketplaceInstalled(): Promise { if (_isOfficialMarketplaceInstalledCache !== undefined) { return _isOfficialMarketplaceInstalledCache } const config = await loadKnownMarketplacesConfigSafe() _isOfficialMarketplaceInstalledCache = OFFICIAL_MARKETPLACE_NAME in config return _isOfficialMarketplaceInstalledCache } async function isMarketplacePluginRelevant( pluginName: string, context: TipContext | undefined, signals: { filePath?: RegExp; cli?: string[] }, ): Promise { if (!(await isOfficialMarketplaceInstalled())) { return false } if (isPluginInstalled(`${pluginName}@${OFFICIAL_MARKETPLACE_NAME}`)) { return false } const { bashTools } = context ?? {} if (signals.cli && bashTools?.size) { if (signals.cli.some(cmd => bashTools.has(cmd))) { return true } } if (signals.filePath && context?.readFileState) { const readFiles = cacheKeys(context.readFileState) if (readFiles.some(fp => signals.filePath!.test(fp))) { return true } } return false } const externalTips: Tip[] = [ { id: 'new-user-warmup', content: async () => `Start with small features or bug fixes, tell Claude to propose a plan, and verify its suggested edits`, cooldownSessions: 3, async isRelevant() { const config = getGlobalConfig() return config.numStartups < 10 }, }, { id: 'plan-mode-for-complex-tasks', content: async () => `Use Plan Mode to prepare for a complex request before making changes. Press ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} twice to enable.`, cooldownSessions: 5, isRelevant: async () => { if (process.env.USER_TYPE === 'ant') return false const config = getGlobalConfig() // Show to users who haven't used plan mode recently (7+ days) const daysSinceLastUse = config.lastPlanModeUse ? (Date.now() - config.lastPlanModeUse) / (1000 * 60 * 60 * 24) : Infinity return daysSinceLastUse > 7 }, }, { id: 'default-permission-mode-config', content: async () => `Use /config to change your default permission mode (including Plan Mode)`, cooldownSessions: 10, isRelevant: async () => { try { const config = getGlobalConfig() const settings = getSettings_DEPRECATED() // Show if they've used plan mode but haven't set a default const hasUsedPlanMode = Boolean(config.lastPlanModeUse) const hasDefaultMode = Boolean(settings?.permissions?.defaultMode) return hasUsedPlanMode && !hasDefaultMode } catch (error) { logForDebugging( `Failed to check default-permission-mode-config tip relevance: ${error}`, { level: 'warn' }, ) return false } }, }, { id: 'git-worktrees', content: async () => 'Use git worktrees to run multiple Claude sessions in parallel.', cooldownSessions: 10, isRelevant: async () => { try { const config = getGlobalConfig() const worktreeCount = await getWorktreeCount() return worktreeCount <= 1 && config.numStartups > 50 } catch (_) { return false } }, }, { id: 'color-when-multi-clauding', content: async () => 'Running multiple Claude sessions? Use /color and /rename to tell them apart at a glance.', cooldownSessions: 10, isRelevant: async () => { if (getCurrentSessionAgentColor()) return false const count = await countConcurrentSessions() return count >= 2 }, }, { id: 'terminal-setup', content: async () => env.terminal === 'Apple_Terminal' ? 'Run /terminal-setup to enable convenient terminal integration like Option + Enter for new line and more' : 'Run /terminal-setup to enable convenient terminal integration like Shift + Enter for new line and more', cooldownSessions: 10, async isRelevant() { const config = getGlobalConfig() if (env.terminal === 'Apple_Terminal') { return !config.optionAsMetaKeyInstalled } return !config.shiftEnterKeyBindingInstalled }, }, { id: 'shift-enter', content: async () => env.terminal === 'Apple_Terminal' ? 'Press Option+Enter to send a multi-line message' : 'Press Shift+Enter to send a multi-line message', cooldownSessions: 10, async isRelevant() { const config = getGlobalConfig() return Boolean( (env.terminal === 'Apple_Terminal' ? config.optionAsMetaKeyInstalled : config.shiftEnterKeyBindingInstalled) && config.numStartups > 3, ) }, }, { id: 'shift-enter-setup', content: async () => env.terminal === 'Apple_Terminal' ? 'Run /terminal-setup to enable Option+Enter for new lines' : 'Run /terminal-setup to enable Shift+Enter for new lines', cooldownSessions: 10, async isRelevant() { if (!shouldOfferTerminalSetup()) { return false } const config = getGlobalConfig() return !(env.terminal === 'Apple_Terminal' ? config.optionAsMetaKeyInstalled : config.shiftEnterKeyBindingInstalled) }, }, { id: 'memory-command', content: async () => 'Use /memory to view and manage Claude memory', cooldownSessions: 15, async isRelevant() { const config = getGlobalConfig() return config.memoryUsageCount <= 0 }, }, { id: 'theme-command', content: async () => 'Use /theme to change the color theme', cooldownSessions: 20, isRelevant: async () => true, }, { id: 'colorterm-truecolor', content: async () => 'Try setting environment variable COLORTERM=truecolor for richer colors', cooldownSessions: 30, isRelevant: async () => !process.env.COLORTERM && chalk.level < 3, }, { id: 'powershell-tool-env', content: async () => 'Set CLAUDE_CODE_USE_POWERSHELL_TOOL=1 to enable the PowerShell tool (preview)', cooldownSessions: 10, isRelevant: async () => getPlatform() === 'windows' && process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL === undefined, }, { id: 'status-line', content: async () => 'Use /statusline to set up a custom status line that will display beneath the input box', cooldownSessions: 25, isRelevant: async () => getSettings_DEPRECATED().statusLine === undefined, }, { id: 'prompt-queue', content: async () => 'Hit Enter to queue up additional messages while Claude is working.', cooldownSessions: 5, async isRelevant() { const config = getGlobalConfig() return config.promptQueueUseCount <= 3 }, }, { id: 'enter-to-steer-in-relatime', content: async () => 'Send messages to Claude while it works to steer Claude in real-time', cooldownSessions: 20, isRelevant: async () => true, }, { id: 'todo-list', content: async () => 'Ask Claude to create a todo list when working on complex tasks to track progress and remain on track', cooldownSessions: 20, isRelevant: async () => true, }, { id: 'vscode-command-install', content: async () => `Open the Command Palette (Cmd+Shift+P) and run "Shell Command: Install '${env.terminal === 'vscode' ? 'code' : env.terminal}' command in PATH" to enable IDE integration`, cooldownSessions: 0, async isRelevant() { // Only show this tip if we're in a VS Code-style terminal if (!isSupportedVSCodeTerminal()) { return false } if (getPlatform() !== 'macos') { return false } // Check if the relevant command is available switch (env.terminal) { case 'vscode': return !(await isVSCodeInstalled()) case 'cursor': return !(await isCursorInstalled()) case 'windsurf': return !(await isWindsurfInstalled()) default: return false } }, }, { id: 'ide-upsell-external-terminal', content: async () => 'Connect Claude to your IDE · /ide', cooldownSessions: 4, async isRelevant() { if (isSupportedTerminal()) { return false } // Use lockfiles as a (quicker) signal for running IDEs const lockfiles = await getSortedIdeLockfiles() if (lockfiles.length !== 0) { return false } const runningIDEs = await detectRunningIDEsCached() return runningIDEs.length > 0 }, }, { id: 'install-github-app', content: async () => 'Run /install-github-app to tag @claude right from your Github issues and PRs', cooldownSessions: 10, isRelevant: async () => !getGlobalConfig().githubActionSetupCount, }, { id: 'install-slack-app', content: async () => 'Run /install-slack-app to use Claude in Slack', cooldownSessions: 10, isRelevant: async () => !getGlobalConfig().slackAppInstallCount, }, { id: 'permissions', content: async () => 'Use /permissions to pre-approve and pre-deny bash, edit, and MCP tools', cooldownSessions: 10, async isRelevant() { const config = getGlobalConfig() return config.numStartups > 10 }, }, { id: 'drag-and-drop-images', content: async () => 'Did you know you can drag and drop image files into your terminal?', cooldownSessions: 10, isRelevant: async () => !env.isSSH(), }, { id: 'paste-images-mac', content: async () => 'Paste images into Claude Code using control+v (not cmd+v!)', cooldownSessions: 10, isRelevant: async () => getPlatform() === 'macos', }, { id: 'double-esc', content: async () => 'Double-tap esc to rewind the conversation to a previous point in time', cooldownSessions: 10, isRelevant: async () => !fileHistoryEnabled(), }, { id: 'double-esc-code-restore', content: async () => 'Double-tap esc to rewind the code and/or conversation to a previous point in time', cooldownSessions: 10, isRelevant: async () => fileHistoryEnabled(), }, { id: 'continue', content: async () => 'Run claude --continue or claude --resume to resume a conversation', cooldownSessions: 10, isRelevant: async () => true, }, { id: 'rename-conversation', content: async () => 'Name your conversations with /rename to find them easily in /resume later', cooldownSessions: 15, isRelevant: async () => isCustomTitleEnabled() && getGlobalConfig().numStartups > 10, }, { id: 'custom-commands', content: async () => 'Create skills by adding .md files to .claude/skills/ in your project or ~/.claude/skills/ for skills that work in any project', cooldownSessions: 15, async isRelevant() { const config = getGlobalConfig() return config.numStartups > 10 }, }, { id: 'shift-tab', content: async () => process.env.USER_TYPE === 'ant' ? `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode and auto mode` : `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode, auto-accept edit mode, and plan mode`, cooldownSessions: 10, isRelevant: async () => true, }, { id: 'image-paste', content: async () => `Use ${getShortcutDisplay('chat:imagePaste', 'Chat', 'ctrl+v')} to paste images from your clipboard`, cooldownSessions: 20, isRelevant: async () => true, }, { id: 'custom-agents', content: async () => 'Use /agents to optimize specific tasks. Eg. Software Architect, Code Writer, Code Reviewer', cooldownSessions: 15, async isRelevant() { const config = getGlobalConfig() return config.numStartups > 5 }, }, { id: 'agent-flag', content: async () => 'Use --agent to directly start a conversation with a subagent', cooldownSessions: 15, async isRelevant() { const config = getGlobalConfig() return config.numStartups > 5 }, }, { id: 'desktop-app', content: async () => 'Run Claude Code locally or remotely using the Claude desktop app: clau.de/desktop', cooldownSessions: 15, isRelevant: async () => getPlatform() !== 'linux', }, { id: 'desktop-shortcut', content: async ctx => { const blue = color('suggestion', ctx.theme) return `Continue your session in Claude Code Desktop with ${blue('/desktop')}` }, cooldownSessions: 15, isRelevant: async () => { if (!getDesktopUpsellConfig().enable_shortcut_tip) return false return ( process.platform === 'darwin' || (process.platform === 'win32' && process.arch === 'x64') ) }, }, { id: 'web-app', content: async () => 'Run tasks in the cloud while you keep coding locally · clau.de/web', cooldownSessions: 15, isRelevant: async () => true, }, { id: 'mobile-app', content: async () => '/mobile to use Claude Code from the Claude app on your phone', cooldownSessions: 15, isRelevant: async () => true, }, { id: 'opusplan-mode-reminder', content: async () => `Your default model setting is Opus Plan Mode. Press ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} twice to activate Plan Mode and plan with Claude Opus.`, cooldownSessions: 2, async isRelevant() { if (process.env.USER_TYPE === 'ant') return false const config = getGlobalConfig() const modelSetting = getUserSpecifiedModelSetting() const hasOpusPlanMode = modelSetting === 'opusplan' // Show reminder if they have Opus Plan Mode and haven't used plan mode recently (3+ days) const daysSinceLastUse = config.lastPlanModeUse ? (Date.now() - config.lastPlanModeUse) / (1000 * 60 * 60 * 24) : Infinity return hasOpusPlanMode && daysSinceLastUse > 3 }, }, { id: 'frontend-design-plugin', content: async ctx => { const blue = color('suggestion', ctx.theme) return `Working with HTML/CSS? Install the frontend-design plugin:\n${blue(`/plugin install frontend-design@${OFFICIAL_MARKETPLACE_NAME}`)}` }, cooldownSessions: 3, isRelevant: async context => isMarketplacePluginRelevant('frontend-design', context, { filePath: /\.(html|css|htm)$/i, }), }, { id: 'vercel-plugin', content: async ctx => { const blue = color('suggestion', ctx.theme) return `Working with Vercel? Install the vercel plugin:\n${blue(`/plugin install vercel@${OFFICIAL_MARKETPLACE_NAME}`)}` }, cooldownSessions: 3, isRelevant: async context => isMarketplacePluginRelevant('vercel', context, { filePath: /(?:^|[/\\])vercel\.json$/i, cli: ['vercel'], }), }, { id: 'effort-high-nudge', content: async ctx => { const blue = color('suggestion', ctx.theme) const cmd = blue('/effort high') const variant = getFeatureValue_CACHED_MAY_BE_STALE< 'off' | 'copy_a' | 'copy_b' >('tengu_tide_elm', 'off') return variant === 'copy_b' ? `Use ${cmd} for better one-shot answers. Claude thinks it through first.` : `Working on something tricky? ${cmd} gives better first answers` }, cooldownSessions: 3, isRelevant: async () => { if (!is1PApiCustomer()) return false if (!modelSupportsEffort(getMainLoopModel())) return false if (getSettingsForSource('policySettings')?.effortLevel !== undefined) { return false } if (getEffortEnvOverride() !== undefined) return false const persisted = getInitialSettings().effortLevel if (persisted === 'high' || persisted === 'max') return false return ( getFeatureValue_CACHED_MAY_BE_STALE<'off' | 'copy_a' | 'copy_b'>( 'tengu_tide_elm', 'off', ) !== 'off' ) }, }, { id: 'subagent-fanout-nudge', content: async ctx => { const blue = color('suggestion', ctx.theme) const variant = getFeatureValue_CACHED_MAY_BE_STALE< 'off' | 'copy_a' | 'copy_b' >('tengu_tern_alloy', 'off') return variant === 'copy_b' ? `For big tasks, tell Claude to ${blue('use subagents')}. They work in parallel and keep your main thread clean.` : `Say ${blue('"fan out subagents"')} and Claude sends a team. Each one digs deep so nothing gets missed.` }, cooldownSessions: 3, isRelevant: async () => { if (!is1PApiCustomer()) return false return ( getFeatureValue_CACHED_MAY_BE_STALE<'off' | 'copy_a' | 'copy_b'>( 'tengu_tern_alloy', 'off', ) !== 'off' ) }, }, { id: 'loop-command-nudge', content: async ctx => { const blue = color('suggestion', ctx.theme) const variant = getFeatureValue_CACHED_MAY_BE_STALE< 'off' | 'copy_a' | 'copy_b' >('tengu_timber_lark', 'off') return variant === 'copy_b' ? `Use ${blue('/loop 5m check the deploy')} to run any prompt on a schedule. Set it and forget it.` : `${blue('/loop')} runs any prompt on a recurring schedule. Great for monitoring deploys, babysitting PRs, or polling status.` }, cooldownSessions: 3, isRelevant: async () => { if (!is1PApiCustomer()) return false if (!isKairosCronEnabled()) return false return ( getFeatureValue_CACHED_MAY_BE_STALE<'off' | 'copy_a' | 'copy_b'>( 'tengu_timber_lark', 'off', ) !== 'off' ) }, }, { id: 'guest-passes', content: async ctx => { const claude = color('claude', ctx.theme) const reward = getCachedReferrerReward() return reward ? `Share Claude Code and earn ${claude(formatCreditAmount(reward))} of extra usage · ${claude('/passes')}` : `You have free guest passes to share · ${claude('/passes')}` }, cooldownSessions: 3, isRelevant: async () => { const config = getGlobalConfig() if (config.hasVisitedPasses) { return false } const { eligible } = checkCachedPassesEligibility() return eligible }, }, { id: 'overage-credit', content: async ctx => { const claude = color('claude', ctx.theme) const info = getCachedOverageCreditGrant() const amount = info ? formatGrantAmount(info) : null if (!amount) return '' // Copy from "OC & Bulk Overages copy" doc (#5 — CLI Rotating tip) return `${claude(`${amount} in extra usage, on us`)} · third-party apps · ${claude('/extra-usage')}` }, cooldownSessions: 3, isRelevant: async () => shouldShowOverageCreditUpsell(), }, { id: 'feedback-command', content: async () => 'Use /feedback to help us improve!', cooldownSessions: 15, async isRelevant() { if (process.env.USER_TYPE === 'ant') { return false } const config = getGlobalConfig() return config.numStartups > 5 }, }, ] const internalOnlyTips: Tip[] = process.env.USER_TYPE === 'ant' ? [ { id: 'important-claudemd', content: async () => '[ANT-ONLY] Use "IMPORTANT:" prefix for must-follow CLAUDE.md rules', cooldownSessions: 30, isRelevant: async () => true, }, { id: 'skillify', content: async () => '[ANT-ONLY] Use /skillify at the end of a workflow to turn it into a reusable skill', cooldownSessions: 15, isRelevant: async () => true, }, ] : [] function getCustomTips(): Tip[] { const settings = getInitialSettings() const override = settings.spinnerTipsOverride if (!override?.tips?.length) return [] return override.tips.map((content, i) => ({ id: `custom-tip-${i}`, content: async () => content, cooldownSessions: 0, isRelevant: async () => true, })) } export async function getRelevantTips(context?: TipContext): Promise { const settings = getInitialSettings() const override = settings.spinnerTipsOverride const customTips = getCustomTips() // If excludeDefault is true and there are custom tips, skip built-in tips entirely if (override?.excludeDefault && customTips.length > 0) { return customTips } // Otherwise, filter built-in tips as before and combine with custom const tips = [...externalTips, ...internalOnlyTips] const isRelevant = await Promise.all(tips.map(_ => _.isRelevant(context))) const filtered = tips .filter((_, index) => isRelevant[index]) .filter(_ => getSessionsSinceLastShown(_.id) >= _.cooldownSessions) return [...filtered, ...customTips] }