/ services / tips / tipRegistry.ts
tipRegistry.ts
  1  import chalk from 'chalk'
  2  import { logForDebugging } from 'src/utils/debug.js'
  3  import { fileHistoryEnabled } from 'src/utils/fileHistory.js'
  4  import {
  5    getInitialSettings,
  6    getSettings_DEPRECATED,
  7    getSettingsForSource,
  8  } from 'src/utils/settings/settings.js'
  9  import { shouldOfferTerminalSetup } from '../../commands/terminalSetup/terminalSetup.js'
 10  import { getDesktopUpsellConfig } from '../../components/DesktopUpsell/DesktopUpsellStartup.js'
 11  import { color } from '../../components/design-system/color.js'
 12  import { shouldShowOverageCreditUpsell } from '../../components/LogoV2/OverageCreditUpsell.js'
 13  import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js'
 14  import { isKairosCronEnabled } from '../../tools/ScheduleCronTool/prompt.js'
 15  import { is1PApiCustomer } from '../../utils/auth.js'
 16  import { countConcurrentSessions } from '../../utils/concurrentSessions.js'
 17  import { getGlobalConfig } from '../../utils/config.js'
 18  import {
 19    getEffortEnvOverride,
 20    modelSupportsEffort,
 21  } from '../../utils/effort.js'
 22  import { env } from '../../utils/env.js'
 23  import { cacheKeys } from '../../utils/fileStateCache.js'
 24  import { getWorktreeCount } from '../../utils/git.js'
 25  import {
 26    detectRunningIDEsCached,
 27    getSortedIdeLockfiles,
 28    isCursorInstalled,
 29    isSupportedTerminal,
 30    isSupportedVSCodeTerminal,
 31    isVSCodeInstalled,
 32    isWindsurfInstalled,
 33  } from '../../utils/ide.js'
 34  import {
 35    getMainLoopModel,
 36    getUserSpecifiedModelSetting,
 37  } from '../../utils/model/model.js'
 38  import { getPlatform } from '../../utils/platform.js'
 39  import { isPluginInstalled } from '../../utils/plugins/installedPluginsManager.js'
 40  import { loadKnownMarketplacesConfigSafe } from '../../utils/plugins/marketplaceManager.js'
 41  import { OFFICIAL_MARKETPLACE_NAME } from '../../utils/plugins/officialMarketplace.js'
 42  import {
 43    getCurrentSessionAgentColor,
 44    isCustomTitleEnabled,
 45  } from '../../utils/sessionStorage.js'
 46  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js'
 47  import {
 48    formatGrantAmount,
 49    getCachedOverageCreditGrant,
 50  } from '../api/overageCreditGrant.js'
 51  import {
 52    checkCachedPassesEligibility,
 53    formatCreditAmount,
 54    getCachedReferrerReward,
 55  } from '../api/referral.js'
 56  import { getSessionsSinceLastShown } from './tipHistory.js'
 57  import type { Tip, TipContext } from './types.js'
 58  
 59  let _isOfficialMarketplaceInstalledCache: boolean | undefined
 60  async function isOfficialMarketplaceInstalled(): Promise<boolean> {
 61    if (_isOfficialMarketplaceInstalledCache !== undefined) {
 62      return _isOfficialMarketplaceInstalledCache
 63    }
 64    const config = await loadKnownMarketplacesConfigSafe()
 65    _isOfficialMarketplaceInstalledCache = OFFICIAL_MARKETPLACE_NAME in config
 66    return _isOfficialMarketplaceInstalledCache
 67  }
 68  
 69  async function isMarketplacePluginRelevant(
 70    pluginName: string,
 71    context: TipContext | undefined,
 72    signals: { filePath?: RegExp; cli?: string[] },
 73  ): Promise<boolean> {
 74    if (!(await isOfficialMarketplaceInstalled())) {
 75      return false
 76    }
 77    if (isPluginInstalled(`${pluginName}@${OFFICIAL_MARKETPLACE_NAME}`)) {
 78      return false
 79    }
 80    const { bashTools } = context ?? {}
 81    if (signals.cli && bashTools?.size) {
 82      if (signals.cli.some(cmd => bashTools.has(cmd))) {
 83        return true
 84      }
 85    }
 86    if (signals.filePath && context?.readFileState) {
 87      const readFiles = cacheKeys(context.readFileState)
 88      if (readFiles.some(fp => signals.filePath!.test(fp))) {
 89        return true
 90      }
 91    }
 92    return false
 93  }
 94  
 95  const externalTips: Tip[] = [
 96    {
 97      id: 'new-user-warmup',
 98      content: async () =>
 99        `Start with small features or bug fixes, tell Claude to propose a plan, and verify its suggested edits`,
100      cooldownSessions: 3,
101      async isRelevant() {
102        const config = getGlobalConfig()
103        return config.numStartups < 10
104      },
105    },
106    {
107      id: 'plan-mode-for-complex-tasks',
108      content: async () =>
109        `Use Plan Mode to prepare for a complex request before making changes. Press ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} twice to enable.`,
110      cooldownSessions: 5,
111      isRelevant: async () => {
112        if (process.env.USER_TYPE === 'ant') return false
113        const config = getGlobalConfig()
114        // Show to users who haven't used plan mode recently (7+ days)
115        const daysSinceLastUse = config.lastPlanModeUse
116          ? (Date.now() - config.lastPlanModeUse) / (1000 * 60 * 60 * 24)
117          : Infinity
118        return daysSinceLastUse > 7
119      },
120    },
121    {
122      id: 'default-permission-mode-config',
123      content: async () =>
124        `Use /config to change your default permission mode (including Plan Mode)`,
125      cooldownSessions: 10,
126      isRelevant: async () => {
127        try {
128          const config = getGlobalConfig()
129          const settings = getSettings_DEPRECATED()
130          // Show if they've used plan mode but haven't set a default
131          const hasUsedPlanMode = Boolean(config.lastPlanModeUse)
132          const hasDefaultMode = Boolean(settings?.permissions?.defaultMode)
133          return hasUsedPlanMode && !hasDefaultMode
134        } catch (error) {
135          logForDebugging(
136            `Failed to check default-permission-mode-config tip relevance: ${error}`,
137            { level: 'warn' },
138          )
139          return false
140        }
141      },
142    },
143    {
144      id: 'git-worktrees',
145      content: async () =>
146        'Use git worktrees to run multiple Claude sessions in parallel.',
147      cooldownSessions: 10,
148      isRelevant: async () => {
149        try {
150          const config = getGlobalConfig()
151          const worktreeCount = await getWorktreeCount()
152          return worktreeCount <= 1 && config.numStartups > 50
153        } catch (_) {
154          return false
155        }
156      },
157    },
158    {
159      id: 'color-when-multi-clauding',
160      content: async () =>
161        'Running multiple Claude sessions? Use /color and /rename to tell them apart at a glance.',
162      cooldownSessions: 10,
163      isRelevant: async () => {
164        if (getCurrentSessionAgentColor()) return false
165        const count = await countConcurrentSessions()
166        return count >= 2
167      },
168    },
169    {
170      id: 'terminal-setup',
171      content: async () =>
172        env.terminal === 'Apple_Terminal'
173          ? 'Run /terminal-setup to enable convenient terminal integration like Option + Enter for new line and more'
174          : 'Run /terminal-setup to enable convenient terminal integration like Shift + Enter for new line and more',
175      cooldownSessions: 10,
176      async isRelevant() {
177        const config = getGlobalConfig()
178        if (env.terminal === 'Apple_Terminal') {
179          return !config.optionAsMetaKeyInstalled
180        }
181        return !config.shiftEnterKeyBindingInstalled
182      },
183    },
184    {
185      id: 'shift-enter',
186      content: async () =>
187        env.terminal === 'Apple_Terminal'
188          ? 'Press Option+Enter to send a multi-line message'
189          : 'Press Shift+Enter to send a multi-line message',
190      cooldownSessions: 10,
191      async isRelevant() {
192        const config = getGlobalConfig()
193        return Boolean(
194          (env.terminal === 'Apple_Terminal'
195            ? config.optionAsMetaKeyInstalled
196            : config.shiftEnterKeyBindingInstalled) && config.numStartups > 3,
197        )
198      },
199    },
200    {
201      id: 'shift-enter-setup',
202      content: async () =>
203        env.terminal === 'Apple_Terminal'
204          ? 'Run /terminal-setup to enable Option+Enter for new lines'
205          : 'Run /terminal-setup to enable Shift+Enter for new lines',
206      cooldownSessions: 10,
207      async isRelevant() {
208        if (!shouldOfferTerminalSetup()) {
209          return false
210        }
211        const config = getGlobalConfig()
212        return !(env.terminal === 'Apple_Terminal'
213          ? config.optionAsMetaKeyInstalled
214          : config.shiftEnterKeyBindingInstalled)
215      },
216    },
217    {
218      id: 'memory-command',
219      content: async () => 'Use /memory to view and manage Claude memory',
220      cooldownSessions: 15,
221      async isRelevant() {
222        const config = getGlobalConfig()
223        return config.memoryUsageCount <= 0
224      },
225    },
226    {
227      id: 'theme-command',
228      content: async () => 'Use /theme to change the color theme',
229      cooldownSessions: 20,
230      isRelevant: async () => true,
231    },
232    {
233      id: 'colorterm-truecolor',
234      content: async () =>
235        'Try setting environment variable COLORTERM=truecolor for richer colors',
236      cooldownSessions: 30,
237      isRelevant: async () => !process.env.COLORTERM && chalk.level < 3,
238    },
239    {
240      id: 'powershell-tool-env',
241      content: async () =>
242        'Set CLAUDE_CODE_USE_POWERSHELL_TOOL=1 to enable the PowerShell tool (preview)',
243      cooldownSessions: 10,
244      isRelevant: async () =>
245        getPlatform() === 'windows' &&
246        process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL === undefined,
247    },
248    {
249      id: 'status-line',
250      content: async () =>
251        'Use /statusline to set up a custom status line that will display beneath the input box',
252      cooldownSessions: 25,
253      isRelevant: async () => getSettings_DEPRECATED().statusLine === undefined,
254    },
255    {
256      id: 'prompt-queue',
257      content: async () =>
258        'Hit Enter to queue up additional messages while Claude is working.',
259      cooldownSessions: 5,
260      async isRelevant() {
261        const config = getGlobalConfig()
262        return config.promptQueueUseCount <= 3
263      },
264    },
265    {
266      id: 'enter-to-steer-in-relatime',
267      content: async () =>
268        'Send messages to Claude while it works to steer Claude in real-time',
269      cooldownSessions: 20,
270      isRelevant: async () => true,
271    },
272    {
273      id: 'todo-list',
274      content: async () =>
275        'Ask Claude to create a todo list when working on complex tasks to track progress and remain on track',
276      cooldownSessions: 20,
277      isRelevant: async () => true,
278    },
279    {
280      id: 'vscode-command-install',
281      content: async () =>
282        `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`,
283      cooldownSessions: 0,
284      async isRelevant() {
285        // Only show this tip if we're in a VS Code-style terminal
286        if (!isSupportedVSCodeTerminal()) {
287          return false
288        }
289        if (getPlatform() !== 'macos') {
290          return false
291        }
292  
293        // Check if the relevant command is available
294        switch (env.terminal) {
295          case 'vscode':
296            return !(await isVSCodeInstalled())
297          case 'cursor':
298            return !(await isCursorInstalled())
299          case 'windsurf':
300            return !(await isWindsurfInstalled())
301          default:
302            return false
303        }
304      },
305    },
306    {
307      id: 'ide-upsell-external-terminal',
308      content: async () => 'Connect Claude to your IDE · /ide',
309      cooldownSessions: 4,
310      async isRelevant() {
311        if (isSupportedTerminal()) {
312          return false
313        }
314  
315        // Use lockfiles as a (quicker) signal for running IDEs
316        const lockfiles = await getSortedIdeLockfiles()
317        if (lockfiles.length !== 0) {
318          return false
319        }
320  
321        const runningIDEs = await detectRunningIDEsCached()
322        return runningIDEs.length > 0
323      },
324    },
325    {
326      id: 'install-github-app',
327      content: async () =>
328        'Run /install-github-app to tag @claude right from your Github issues and PRs',
329      cooldownSessions: 10,
330      isRelevant: async () => !getGlobalConfig().githubActionSetupCount,
331    },
332    {
333      id: 'install-slack-app',
334      content: async () => 'Run /install-slack-app to use Claude in Slack',
335      cooldownSessions: 10,
336      isRelevant: async () => !getGlobalConfig().slackAppInstallCount,
337    },
338    {
339      id: 'permissions',
340      content: async () =>
341        'Use /permissions to pre-approve and pre-deny bash, edit, and MCP tools',
342      cooldownSessions: 10,
343      async isRelevant() {
344        const config = getGlobalConfig()
345        return config.numStartups > 10
346      },
347    },
348    {
349      id: 'drag-and-drop-images',
350      content: async () =>
351        'Did you know you can drag and drop image files into your terminal?',
352      cooldownSessions: 10,
353      isRelevant: async () => !env.isSSH(),
354    },
355    {
356      id: 'paste-images-mac',
357      content: async () =>
358        'Paste images into Claude Code using control+v (not cmd+v!)',
359      cooldownSessions: 10,
360      isRelevant: async () => getPlatform() === 'macos',
361    },
362    {
363      id: 'double-esc',
364      content: async () =>
365        'Double-tap esc to rewind the conversation to a previous point in time',
366      cooldownSessions: 10,
367      isRelevant: async () => !fileHistoryEnabled(),
368    },
369    {
370      id: 'double-esc-code-restore',
371      content: async () =>
372        'Double-tap esc to rewind the code and/or conversation to a previous point in time',
373      cooldownSessions: 10,
374      isRelevant: async () => fileHistoryEnabled(),
375    },
376    {
377      id: 'continue',
378      content: async () =>
379        'Run claude --continue or claude --resume to resume a conversation',
380      cooldownSessions: 10,
381      isRelevant: async () => true,
382    },
383    {
384      id: 'rename-conversation',
385      content: async () =>
386        'Name your conversations with /rename to find them easily in /resume later',
387      cooldownSessions: 15,
388      isRelevant: async () =>
389        isCustomTitleEnabled() && getGlobalConfig().numStartups > 10,
390    },
391    {
392      id: 'custom-commands',
393      content: async () =>
394        'Create skills by adding .md files to .claude/skills/ in your project or ~/.claude/skills/ for skills that work in any project',
395      cooldownSessions: 15,
396      async isRelevant() {
397        const config = getGlobalConfig()
398        return config.numStartups > 10
399      },
400    },
401    {
402      id: 'shift-tab',
403      content: async () =>
404        process.env.USER_TYPE === 'ant'
405          ? `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode and auto mode`
406          : `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode, auto-accept edit mode, and plan mode`,
407      cooldownSessions: 10,
408      isRelevant: async () => true,
409    },
410    {
411      id: 'image-paste',
412      content: async () =>
413        `Use ${getShortcutDisplay('chat:imagePaste', 'Chat', 'ctrl+v')} to paste images from your clipboard`,
414      cooldownSessions: 20,
415      isRelevant: async () => true,
416    },
417    {
418      id: 'custom-agents',
419      content: async () =>
420        'Use /agents to optimize specific tasks. Eg. Software Architect, Code Writer, Code Reviewer',
421      cooldownSessions: 15,
422      async isRelevant() {
423        const config = getGlobalConfig()
424        return config.numStartups > 5
425      },
426    },
427    {
428      id: 'agent-flag',
429      content: async () =>
430        'Use --agent <agent_name> to directly start a conversation with a subagent',
431      cooldownSessions: 15,
432      async isRelevant() {
433        const config = getGlobalConfig()
434        return config.numStartups > 5
435      },
436    },
437    {
438      id: 'desktop-app',
439      content: async () =>
440        'Run Claude Code locally or remotely using the Claude desktop app: clau.de/desktop',
441      cooldownSessions: 15,
442      isRelevant: async () => getPlatform() !== 'linux',
443    },
444    {
445      id: 'desktop-shortcut',
446      content: async ctx => {
447        const blue = color('suggestion', ctx.theme)
448        return `Continue your session in Claude Code Desktop with ${blue('/desktop')}`
449      },
450      cooldownSessions: 15,
451      isRelevant: async () => {
452        if (!getDesktopUpsellConfig().enable_shortcut_tip) return false
453        return (
454          process.platform === 'darwin' ||
455          (process.platform === 'win32' && process.arch === 'x64')
456        )
457      },
458    },
459    {
460      id: 'web-app',
461      content: async () =>
462        'Run tasks in the cloud while you keep coding locally · clau.de/web',
463      cooldownSessions: 15,
464      isRelevant: async () => true,
465    },
466    {
467      id: 'mobile-app',
468      content: async () =>
469        '/mobile to use Claude Code from the Claude app on your phone',
470      cooldownSessions: 15,
471      isRelevant: async () => true,
472    },
473    {
474      id: 'opusplan-mode-reminder',
475      content: async () =>
476        `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.`,
477      cooldownSessions: 2,
478      async isRelevant() {
479        if (process.env.USER_TYPE === 'ant') return false
480        const config = getGlobalConfig()
481        const modelSetting = getUserSpecifiedModelSetting()
482        const hasOpusPlanMode = modelSetting === 'opusplan'
483        // Show reminder if they have Opus Plan Mode and haven't used plan mode recently (3+ days)
484        const daysSinceLastUse = config.lastPlanModeUse
485          ? (Date.now() - config.lastPlanModeUse) / (1000 * 60 * 60 * 24)
486          : Infinity
487        return hasOpusPlanMode && daysSinceLastUse > 3
488      },
489    },
490    {
491      id: 'frontend-design-plugin',
492      content: async ctx => {
493        const blue = color('suggestion', ctx.theme)
494        return `Working with HTML/CSS? Install the frontend-design plugin:\n${blue(`/plugin install frontend-design@${OFFICIAL_MARKETPLACE_NAME}`)}`
495      },
496      cooldownSessions: 3,
497      isRelevant: async context =>
498        isMarketplacePluginRelevant('frontend-design', context, {
499          filePath: /\.(html|css|htm)$/i,
500        }),
501    },
502    {
503      id: 'vercel-plugin',
504      content: async ctx => {
505        const blue = color('suggestion', ctx.theme)
506        return `Working with Vercel? Install the vercel plugin:\n${blue(`/plugin install vercel@${OFFICIAL_MARKETPLACE_NAME}`)}`
507      },
508      cooldownSessions: 3,
509      isRelevant: async context =>
510        isMarketplacePluginRelevant('vercel', context, {
511          filePath: /(?:^|[/\\])vercel\.json$/i,
512          cli: ['vercel'],
513        }),
514    },
515    {
516      id: 'effort-high-nudge',
517      content: async ctx => {
518        const blue = color('suggestion', ctx.theme)
519        const cmd = blue('/effort high')
520        const variant = getFeatureValue_CACHED_MAY_BE_STALE<
521          'off' | 'copy_a' | 'copy_b'
522        >('tengu_tide_elm', 'off')
523        return variant === 'copy_b'
524          ? `Use ${cmd} for better one-shot answers. Claude thinks it through first.`
525          : `Working on something tricky? ${cmd} gives better first answers`
526      },
527      cooldownSessions: 3,
528      isRelevant: async () => {
529        if (!is1PApiCustomer()) return false
530        if (!modelSupportsEffort(getMainLoopModel())) return false
531        if (getSettingsForSource('policySettings')?.effortLevel !== undefined) {
532          return false
533        }
534        if (getEffortEnvOverride() !== undefined) return false
535        const persisted = getInitialSettings().effortLevel
536        if (persisted === 'high' || persisted === 'max') return false
537        return (
538          getFeatureValue_CACHED_MAY_BE_STALE<'off' | 'copy_a' | 'copy_b'>(
539            'tengu_tide_elm',
540            'off',
541          ) !== 'off'
542        )
543      },
544    },
545    {
546      id: 'subagent-fanout-nudge',
547      content: async ctx => {
548        const blue = color('suggestion', ctx.theme)
549        const variant = getFeatureValue_CACHED_MAY_BE_STALE<
550          'off' | 'copy_a' | 'copy_b'
551        >('tengu_tern_alloy', 'off')
552        return variant === 'copy_b'
553          ? `For big tasks, tell Claude to ${blue('use subagents')}. They work in parallel and keep your main thread clean.`
554          : `Say ${blue('"fan out subagents"')} and Claude sends a team. Each one digs deep so nothing gets missed.`
555      },
556      cooldownSessions: 3,
557      isRelevant: async () => {
558        if (!is1PApiCustomer()) return false
559        return (
560          getFeatureValue_CACHED_MAY_BE_STALE<'off' | 'copy_a' | 'copy_b'>(
561            'tengu_tern_alloy',
562            'off',
563          ) !== 'off'
564        )
565      },
566    },
567    {
568      id: 'loop-command-nudge',
569      content: async ctx => {
570        const blue = color('suggestion', ctx.theme)
571        const variant = getFeatureValue_CACHED_MAY_BE_STALE<
572          'off' | 'copy_a' | 'copy_b'
573        >('tengu_timber_lark', 'off')
574        return variant === 'copy_b'
575          ? `Use ${blue('/loop 5m check the deploy')} to run any prompt on a schedule. Set it and forget it.`
576          : `${blue('/loop')} runs any prompt on a recurring schedule. Great for monitoring deploys, babysitting PRs, or polling status.`
577      },
578      cooldownSessions: 3,
579      isRelevant: async () => {
580        if (!is1PApiCustomer()) return false
581        if (!isKairosCronEnabled()) return false
582        return (
583          getFeatureValue_CACHED_MAY_BE_STALE<'off' | 'copy_a' | 'copy_b'>(
584            'tengu_timber_lark',
585            'off',
586          ) !== 'off'
587        )
588      },
589    },
590    {
591      id: 'guest-passes',
592      content: async ctx => {
593        const claude = color('claude', ctx.theme)
594        const reward = getCachedReferrerReward()
595        return reward
596          ? `Share Claude Code and earn ${claude(formatCreditAmount(reward))} of extra usage · ${claude('/passes')}`
597          : `You have free guest passes to share · ${claude('/passes')}`
598      },
599      cooldownSessions: 3,
600      isRelevant: async () => {
601        const config = getGlobalConfig()
602        if (config.hasVisitedPasses) {
603          return false
604        }
605        const { eligible } = checkCachedPassesEligibility()
606        return eligible
607      },
608    },
609    {
610      id: 'overage-credit',
611      content: async ctx => {
612        const claude = color('claude', ctx.theme)
613        const info = getCachedOverageCreditGrant()
614        const amount = info ? formatGrantAmount(info) : null
615        if (!amount) return ''
616        // Copy from "OC & Bulk Overages copy" doc (#5 — CLI Rotating tip)
617        return `${claude(`${amount} in extra usage, on us`)} · third-party apps · ${claude('/extra-usage')}`
618      },
619      cooldownSessions: 3,
620      isRelevant: async () => shouldShowOverageCreditUpsell(),
621    },
622    {
623      id: 'feedback-command',
624      content: async () => 'Use /feedback to help us improve!',
625      cooldownSessions: 15,
626      async isRelevant() {
627        if (process.env.USER_TYPE === 'ant') {
628          return false
629        }
630        const config = getGlobalConfig()
631        return config.numStartups > 5
632      },
633    },
634  ]
635  const internalOnlyTips: Tip[] =
636    process.env.USER_TYPE === 'ant'
637      ? [
638          {
639            id: 'important-claudemd',
640            content: async () =>
641              '[ANT-ONLY] Use "IMPORTANT:" prefix for must-follow CLAUDE.md rules',
642            cooldownSessions: 30,
643            isRelevant: async () => true,
644          },
645          {
646            id: 'skillify',
647            content: async () =>
648              '[ANT-ONLY] Use /skillify at the end of a workflow to turn it into a reusable skill',
649            cooldownSessions: 15,
650            isRelevant: async () => true,
651          },
652        ]
653      : []
654  
655  function getCustomTips(): Tip[] {
656    const settings = getInitialSettings()
657    const override = settings.spinnerTipsOverride
658    if (!override?.tips?.length) return []
659  
660    return override.tips.map((content, i) => ({
661      id: `custom-tip-${i}`,
662      content: async () => content,
663      cooldownSessions: 0,
664      isRelevant: async () => true,
665    }))
666  }
667  
668  export async function getRelevantTips(context?: TipContext): Promise<Tip[]> {
669    const settings = getInitialSettings()
670    const override = settings.spinnerTipsOverride
671    const customTips = getCustomTips()
672  
673    // If excludeDefault is true and there are custom tips, skip built-in tips entirely
674    if (override?.excludeDefault && customTips.length > 0) {
675      return customTips
676    }
677  
678    // Otherwise, filter built-in tips as before and combine with custom
679    const tips = [...externalTips, ...internalOnlyTips]
680    const isRelevant = await Promise.all(tips.map(_ => _.isRelevant(context)))
681    const filtered = tips
682      .filter((_, index) => isRelevant[index])
683      .filter(_ => getSessionsSinceLastShown(_.id) >= _.cooldownSessions)
684  
685    return [...filtered, ...customTips]
686  }