chore: initialize recovered claude workspace
This commit is contained in:
17
src/services/tips/tipHistory.ts
Normal file
17
src/services/tips/tipHistory.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
|
||||
export function recordTipShown(tipId: string): void {
|
||||
const numStartups = getGlobalConfig().numStartups
|
||||
saveGlobalConfig(c => {
|
||||
const history = c.tipsHistory ?? {}
|
||||
if (history[tipId] === numStartups) return c
|
||||
return { ...c, tipsHistory: { ...history, [tipId]: numStartups } }
|
||||
})
|
||||
}
|
||||
|
||||
export function getSessionsSinceLastShown(tipId: string): number {
|
||||
const config = getGlobalConfig()
|
||||
const lastShown = config.tipsHistory?.[tipId]
|
||||
if (!lastShown) return Infinity
|
||||
return config.numStartups - lastShown
|
||||
}
|
||||
686
src/services/tips/tipRegistry.ts
Normal file
686
src/services/tips/tipRegistry.ts
Normal file
@@ -0,0 +1,686 @@
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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 <agent_name> 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<Tip[]> {
|
||||
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]
|
||||
}
|
||||
58
src/services/tips/tipScheduler.ts
Normal file
58
src/services/tips/tipScheduler.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../analytics/index.js'
|
||||
import { getSessionsSinceLastShown, recordTipShown } from './tipHistory.js'
|
||||
import { getRelevantTips } from './tipRegistry.js'
|
||||
import type { Tip, TipContext } from './types.js'
|
||||
|
||||
export function selectTipWithLongestTimeSinceShown(
|
||||
availableTips: Tip[],
|
||||
): Tip | undefined {
|
||||
if (availableTips.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (availableTips.length === 1) {
|
||||
return availableTips[0]
|
||||
}
|
||||
|
||||
// Sort tips by sessions since last shown (descending) and take the first one
|
||||
// This is the tip that hasn't been shown for the longest time
|
||||
const tipsWithSessions = availableTips.map(tip => ({
|
||||
tip,
|
||||
sessions: getSessionsSinceLastShown(tip.id),
|
||||
}))
|
||||
|
||||
tipsWithSessions.sort((a, b) => b.sessions - a.sessions)
|
||||
return tipsWithSessions[0]?.tip
|
||||
}
|
||||
|
||||
export async function getTipToShowOnSpinner(
|
||||
context?: TipContext,
|
||||
): Promise<Tip | undefined> {
|
||||
// Check if tips are disabled (default to true if not set)
|
||||
if (getSettings_DEPRECATED().spinnerTipsEnabled === false) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const tips = await getRelevantTips(context)
|
||||
if (tips.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return selectTipWithLongestTimeSinceShown(tips)
|
||||
}
|
||||
|
||||
export function recordShownTip(tip: Tip): void {
|
||||
// Record in history
|
||||
recordTipShown(tip.id)
|
||||
|
||||
// Log event for analytics
|
||||
logEvent('tengu_tip_shown', {
|
||||
tipIdLength:
|
||||
tip.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
cooldownSessions: tip.cooldownSessions,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user