Files
claude-code/src/commands/thinkback/thinkback.tsx
claude-code-best 5b1a52b8e0 更新大量 tsx 原始文件; 已经迁移 login panel; 部分 (#121)
* style(B1-1): 格式化 ink/buddy/cli/context/screens/tasks/services/keybindings/state (43 files)

纯格式化:移除分号、React Compiler import、import 多行展开。
修复了 Box.tsx 和 ScrollBox.tsx 中无效的 global.d.ts import。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-2): 格式化 commands (79 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-3): 格式化 components/messages,permissions,mcp,sandbox,shell (104 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-4): 格式化 components/PromptInput,FeedbackSurvey,tasks,agents,skills,design-system,wizard (73 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-5): 格式化 components其余 + hooks + tools (232 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-6): 格式化 main/entrypoints/utils/moreright (21 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: 更新 README,新增 Run.ps1/TODO.md,删除 V6.md

- README.md: 大幅重写,更详细版本历史和配置示例
- Run.ps1: 新增 Windows 启动脚本
- TODO.md: 新增包完成清单
- V6.md: 删除(架构重构规划已不适用)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复以前的问题

* fix: 修复 login 面板的问题

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:24:27 +08:00

522 lines
16 KiB
TypeScript

import { execa } from 'execa'
import { readFile } from 'fs/promises'
import { join } from 'path'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import type { CommandResultDisplay } from '../../commands.js'
import { Select } from '../../components/CustomSelect/select.js'
import { Dialog } from '../../components/design-system/Dialog.js'
import { Spinner } from '../../components/Spinner.js'
import instances from '../../ink/instances.js'
import { Box, Text } from '../../ink.js'
import { enablePluginOp } from '../../services/plugins/pluginOperations.js'
import { logForDebugging } from '../../utils/debug.js'
import { isENOENT, toError } from '../../utils/errors.js'
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
import { pathExists } from '../../utils/file.js'
import { logError } from '../../utils/log.js'
import { getPlatform } from '../../utils/platform.js'
import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'
import { isPluginInstalled } from '../../utils/plugins/installedPluginsManager.js'
import {
addMarketplaceSource,
clearMarketplacesCache,
loadKnownMarketplacesConfig,
refreshMarketplace,
} from '../../utils/plugins/marketplaceManager.js'
import { OFFICIAL_MARKETPLACE_NAME } from '../../utils/plugins/officialMarketplace.js'
import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'
import { installSelectedPlugins } from '../../utils/plugins/pluginStartupCheck.js'
// Marketplace and plugin identifiers - varies by user type
const INTERNAL_MARKETPLACE_NAME = 'claude-code-marketplace'
const INTERNAL_MARKETPLACE_REPO = 'anthropics/claude-code-marketplace'
const OFFICIAL_MARKETPLACE_REPO = 'anthropics/claude-plugins-official'
function getMarketplaceName(): string {
return process.env.USER_TYPE === 'ant'
? INTERNAL_MARKETPLACE_NAME
: OFFICIAL_MARKETPLACE_NAME
}
function getMarketplaceRepo(): string {
return process.env.USER_TYPE === 'ant'
? INTERNAL_MARKETPLACE_REPO
: OFFICIAL_MARKETPLACE_REPO
}
function getPluginId(): string {
return `thinkback@${getMarketplaceName()}`
}
const SKILL_NAME = 'thinkback'
/**
* Get the thinkback skill directory from the installed plugin's cache path
*/
async function getThinkbackSkillDir(): Promise<string | null> {
const { enabled } = await loadAllPlugins()
const thinkbackPlugin = enabled.find(
p =>
p.name === 'thinkback' || (p.source && p.source.includes(getPluginId())),
)
if (!thinkbackPlugin) {
return null
}
const skillDir = join(thinkbackPlugin.path, 'skills', SKILL_NAME)
if (await pathExists(skillDir)) {
return skillDir
}
return null
}
export async function playAnimation(skillDir: string): Promise<{
success: boolean
message: string
}> {
const dataPath = join(skillDir, 'year_in_review.js')
const playerPath = join(skillDir, 'player.js')
// Both files are prerequisites for the node subprocess. Read them here
// (not at call sites) so all callers get consistent error messaging. The
// subprocess runs with reject: false, so a missing file would otherwise
// silently return success. Using readFile (not access) per CLAUDE.md.
//
// Non-ENOENT errors (EACCES etc) are logged and returned as failures rather
// than thrown — the old pathExists-based code never threw, and one caller
// (handleSelect) uses `void playAnimation().then(...)` without a .catch().
try {
await readFile(dataPath)
} catch (e: unknown) {
if (isENOENT(e)) {
return {
success: false,
message: 'No animation found. Run /think-back first to generate one.',
}
}
logError(e)
return {
success: false,
message: `Could not access animation data: ${toError(e).message}`,
}
}
try {
await readFile(playerPath)
} catch (e: unknown) {
if (isENOENT(e)) {
return {
success: false,
message:
'Player script not found. The player.js file is missing from the thinkback skill.',
}
}
logError(e)
return {
success: false,
message: `Could not access player script: ${toError(e).message}`,
}
}
// Get ink instance for terminal takeover
const inkInstance = instances.get(process.stdout)
if (!inkInstance) {
return { success: false, message: 'Failed to access terminal instance' }
}
inkInstance.enterAlternateScreen()
try {
await execa('node', [playerPath], {
stdio: 'inherit',
cwd: skillDir,
reject: false,
})
} catch {
// Animation may have been interrupted (e.g., Ctrl+C)
} finally {
inkInstance.exitAlternateScreen()
}
// Open the HTML file in browser for video download
const htmlPath = join(skillDir, 'year_in_review.html')
if (await pathExists(htmlPath)) {
const platform = getPlatform()
const openCmd =
platform === 'macos'
? 'open'
: platform === 'windows'
? 'start'
: 'xdg-open'
void execFileNoThrow(openCmd, [htmlPath])
}
return { success: true, message: 'Year in review animation complete!' }
}
type InstallState =
| { phase: 'checking' }
| { phase: 'installing-marketplace' }
| { phase: 'installing-plugin' }
| { phase: 'enabling-plugin' }
| { phase: 'ready' }
| { phase: 'error'; message: string }
function ThinkbackInstaller({
onReady,
onError,
}: {
onReady: () => void
onError: (message: string) => void
}): React.ReactNode {
const [state, setState] = useState<InstallState>({ phase: 'checking' })
const [progressMessage, setProgressMessage] = useState('')
useEffect(() => {
async function checkAndInstall(): Promise<void> {
try {
// Check if marketplace is installed
const knownMarketplaces = await loadKnownMarketplacesConfig()
const marketplaceName = getMarketplaceName()
const marketplaceRepo = getMarketplaceRepo()
const pluginId = getPluginId()
const marketplaceInstalled = marketplaceName in knownMarketplaces
// Check if plugin is already installed first
const pluginAlreadyInstalled = isPluginInstalled(pluginId)
if (!marketplaceInstalled) {
// Install the marketplace
setState({ phase: 'installing-marketplace' })
logForDebugging(`Installing marketplace ${marketplaceRepo}`)
await addMarketplaceSource(
{ source: 'github', repo: marketplaceRepo },
message => {
setProgressMessage(message)
},
)
clearAllCaches()
logForDebugging(`Marketplace ${marketplaceName} installed`)
} else if (!pluginAlreadyInstalled) {
// Marketplace installed but plugin not installed - refresh to get latest plugins
// Only refresh when needed to avoid potentially destructive git operations
setState({ phase: 'installing-marketplace' })
setProgressMessage('Updating marketplace…')
logForDebugging(`Refreshing marketplace ${marketplaceName}`)
await refreshMarketplace(marketplaceName, message => {
setProgressMessage(message)
})
clearMarketplacesCache()
clearAllCaches()
logForDebugging(`Marketplace ${marketplaceName} refreshed`)
}
if (!pluginAlreadyInstalled) {
// Install the plugin
setState({ phase: 'installing-plugin' })
logForDebugging(`Installing plugin ${pluginId}`)
const result = await installSelectedPlugins([pluginId])
if (result.failed.length > 0) {
const errorMsg = result.failed
.map(f => `${f.name}: ${f.error}`)
.join(', ')
throw new Error(`Failed to install plugin: ${errorMsg}`)
}
clearAllCaches()
logForDebugging(`Plugin ${pluginId} installed`)
} else {
// Plugin is installed, check if it's enabled
const { disabled } = await loadAllPlugins()
const isDisabled = disabled.some(
p => p.name === 'thinkback' || p.source?.includes(pluginId),
)
if (isDisabled) {
// Enable the plugin
setState({ phase: 'enabling-plugin' })
logForDebugging(`Enabling plugin ${pluginId}`)
const enableResult = await enablePluginOp(pluginId)
if (!enableResult.success) {
throw new Error(
`Failed to enable plugin: ${enableResult.message}`,
)
}
clearAllCaches()
logForDebugging(`Plugin ${pluginId} enabled`)
}
}
setState({ phase: 'ready' })
onReady()
} catch (error) {
const err = toError(error)
logError(err)
setState({ phase: 'error', message: err.message })
onError(err.message)
}
}
void checkAndInstall()
}, [onReady, onError])
if (state.phase === 'error') {
return (
<Box flexDirection="column">
<Text color="error">Error: {state.message}</Text>
</Box>
)
}
if (state.phase === 'ready') {
return null
}
const statusMessage =
state.phase === 'checking'
? 'Checking thinkback installation…'
: state.phase === 'installing-marketplace'
? 'Installing marketplace…'
: state.phase === 'enabling-plugin'
? 'Enabling thinkback plugin…'
: 'Installing thinkback plugin…'
return (
<Box flexDirection="column">
<Box>
<Spinner />
<Text>{progressMessage || statusMessage}</Text>
</Box>
</Box>
)
}
type MenuAction = 'play' | 'edit' | 'fix' | 'regenerate'
type GenerativeAction = Exclude<MenuAction, 'play'>
function ThinkbackMenu({
onDone,
onAction,
skillDir,
hasGenerated,
}: {
onDone: (
result?: string,
options?: { display?: CommandResultDisplay; shouldQuery?: boolean },
) => void
onAction: (action: GenerativeAction) => void
skillDir: string
hasGenerated: boolean
}): React.ReactNode {
const [hasSelected, setHasSelected] = useState(false)
const options = hasGenerated
? [
{
label: 'Play animation',
value: 'play' as const,
description: 'Watch your year in review',
},
{
label: 'Edit content',
value: 'edit' as const,
description: 'Modify the animation',
},
{
label: 'Fix errors',
value: 'fix' as const,
description: 'Fix validation or rendering issues',
},
{
label: 'Regenerate',
value: 'regenerate' as const,
description: 'Create a new animation from scratch',
},
]
: [
{
label: "Let's go!",
value: 'regenerate' as const,
description: 'Generate your personalized animation',
},
]
function handleSelect(value: MenuAction): void {
setHasSelected(true)
if (value === 'play') {
// Play runs the terminal-takeover animation, then signal done with skip
void playAnimation(skillDir).then(() => {
onDone(undefined, { display: 'skip' })
})
} else {
onAction(value)
}
}
function handleCancel(): void {
onDone(undefined, { display: 'skip' })
}
if (hasSelected) {
return null
}
return (
<Dialog
title="Think Back on 2025 with Claude Code"
subtitle="Generate your 2025 Claude Code Think Back (takes a few minutes to run)"
onCancel={handleCancel}
color="claude"
>
<Box flexDirection="column" gap={1}>
{/* Description for first-time users */}
{!hasGenerated && (
<Box flexDirection="column">
<Text>Relive your year of coding with Claude.</Text>
<Text dimColor>
{
"We'll create a personalized ASCII animation celebrating your journey."
}
</Text>
</Box>
)}
{/* Menu */}
<Select
options={options}
onChange={handleSelect}
visibleOptionCount={5}
/>
</Box>
</Dialog>
)
}
const EDIT_PROMPT =
'Use the Skill tool to invoke the "thinkback" skill with mode=edit to modify my existing Claude Code year in review animation. Ask me what I want to change. When the animation is ready, tell the user to run /think-back again to play it.'
const FIX_PROMPT =
'Use the Skill tool to invoke the "thinkback" skill with mode=fix to fix validation or rendering errors in my existing Claude Code year in review animation. Run the validator, identify errors, and fix them. When the animation is ready, tell the user to run /think-back again to play it.'
const REGENERATE_PROMPT =
'Use the Skill tool to invoke the "thinkback" skill with mode=regenerate to create a completely new Claude Code year in review animation from scratch. Delete the existing animation and start fresh. When the animation is ready, tell the user to run /think-back again to play it.'
function ThinkbackFlow({
onDone,
}: {
onDone: (
result?: string,
options?: { display?: CommandResultDisplay; shouldQuery?: boolean },
) => void
}): React.ReactNode {
const [installComplete, setInstallComplete] = useState(false)
const [installError, setInstallError] = useState<string | null>(null)
const [skillDir, setSkillDir] = useState<string | null>(null)
const [hasGenerated, setHasGenerated] = useState<boolean | null>(null)
function handleReady(): void {
setInstallComplete(true)
}
const handleError = useCallback(
(message: string): void => {
setInstallError(message)
// Call onDone with the error message so the model can continue
onDone(
`Error with thinkback: ${message}. Try running /plugin to manually install the think-back plugin.`,
{ display: 'system' },
)
},
[onDone],
)
useEffect(() => {
if (installComplete && !skillDir && !installError) {
// Get the skill directory after installation
void getThinkbackSkillDir().then(dir => {
if (dir) {
logForDebugging(`Thinkback skill directory: ${dir}`)
setSkillDir(dir)
} else {
handleError('Could not find thinkback skill directory')
}
})
}
}, [installComplete, skillDir, installError, handleError])
// Check for generated file once we have skillDir
useEffect(() => {
if (!skillDir) {
return
}
const dataPath = join(skillDir, 'year_in_review.js')
void pathExists(dataPath).then(exists => {
logForDebugging(
`Checking for ${dataPath}: ${exists ? 'found' : 'not found'}`,
)
setHasGenerated(exists)
})
}, [skillDir])
function handleAction(action: GenerativeAction): void {
// Send prompt to model based on action
const prompts: Record<GenerativeAction, string> = {
edit: EDIT_PROMPT,
fix: FIX_PROMPT,
regenerate: REGENERATE_PROMPT,
}
onDone(prompts[action], { display: 'user', shouldQuery: true })
}
if (installError) {
return (
<Box flexDirection="column">
<Text color="error">Error: {installError}</Text>
<Text dimColor>
Try running /plugin to manually install the think-back plugin.
</Text>
</Box>
)
}
if (!installComplete) {
return <ThinkbackInstaller onReady={handleReady} onError={handleError} />
}
if (!skillDir || hasGenerated === null) {
return (
<Box>
<Spinner />
<Text>Loading thinkback skill</Text>
</Box>
)
}
return (
<ThinkbackMenu
onDone={onDone}
onAction={handleAction}
skillDir={skillDir}
hasGenerated={hasGenerated}
/>
)
}
export async function call(
onDone: (
result?: string,
options?: { display?: CommandResultDisplay; shouldQuery?: boolean },
) => void,
): Promise<React.ReactNode> {
return <ThinkbackFlow onDone={onDone} />
}