first commit
This commit is contained in:
811
claude-code源码-中文注释/src/hooks/fileSuggestions.ts
Normal file
811
claude-code源码-中文注释/src/hooks/fileSuggestions.ts
Normal file
@@ -0,0 +1,811 @@
|
||||
import { statSync } from 'fs'
|
||||
import ignore from 'ignore'
|
||||
import * as path from 'path'
|
||||
import {
|
||||
CLAUDE_CONFIG_DIRECTORIES,
|
||||
loadMarkdownFilesForSubdir,
|
||||
} from 'src/utils/markdownConfigLoader.js'
|
||||
import type { SuggestionItem } from '../components/PromptInput/PromptInputFooterSuggestions.js'
|
||||
import {
|
||||
CHUNK_MS,
|
||||
FileIndex,
|
||||
yieldToEventLoop,
|
||||
} from '../native-ts/file-index/index.js'
|
||||
import { logEvent } from '../services/analytics/index.js'
|
||||
import type { FileSuggestionCommandInput } from '../types/fileSuggestion.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { getCwd } from '../utils/cwd.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { execFileNoThrowWithCwd } from '../utils/execFileNoThrow.js'
|
||||
import { getFsImplementation } from '../utils/fsOperations.js'
|
||||
import { findGitRoot, gitExe } from '../utils/git.js'
|
||||
import {
|
||||
createBaseHookInput,
|
||||
executeFileSuggestionCommand,
|
||||
} from '../utils/hooks.js'
|
||||
import { logError } from '../utils/log.js'
|
||||
import { expandPath } from '../utils/path.js'
|
||||
import { ripGrep } from '../utils/ripgrep.js'
|
||||
import { getInitialSettings } from '../utils/settings/settings.js'
|
||||
import { createSignal } from '../utils/signal.js'
|
||||
|
||||
// Lazily constructed singleton
|
||||
let fileIndex: FileIndex | null = null
|
||||
|
||||
function getFileIndex(): FileIndex {
|
||||
if (!fileIndex) {
|
||||
fileIndex = new FileIndex()
|
||||
}
|
||||
return fileIndex
|
||||
}
|
||||
|
||||
let fileListRefreshPromise: Promise<FileIndex> | null = null
|
||||
// Signal fired when an in-progress index build completes. Lets the
|
||||
// typeahead UI re-run its last search so partial results upgrade to full.
|
||||
const indexBuildComplete = createSignal()
|
||||
export const onIndexBuildComplete = indexBuildComplete.subscribe
|
||||
let cacheGeneration = 0
|
||||
|
||||
// Background fetch for untracked files
|
||||
let untrackedFetchPromise: Promise<void> | null = null
|
||||
|
||||
// Store tracked files so we can rebuild index with untracked
|
||||
let cachedTrackedFiles: string[] = []
|
||||
// Store config files so mergeUntrackedIntoNormalizedCache preserves them
|
||||
let cachedConfigFiles: string[] = []
|
||||
// Store tracked directories so mergeUntrackedIntoNormalizedCache doesn't
|
||||
// recompute ~270k path.dirname() calls on each merge
|
||||
let cachedTrackedDirs: string[] = []
|
||||
|
||||
// Cache for .ignore/.rgignore patterns (keyed by repoRoot:cwd)
|
||||
let ignorePatternsCache: ReturnType<typeof ignore> | null = null
|
||||
let ignorePatternsCacheKey: string | null = null
|
||||
|
||||
// Throttle state for background refresh. .git/index mtime triggers an
|
||||
// immediate refresh when tracked files change (add/checkout/commit/rm).
|
||||
// The time floor still refreshes every 5s to pick up untracked files,
|
||||
// which don't bump the index.
|
||||
let lastRefreshMs = 0
|
||||
let lastGitIndexMtime: number | null = null
|
||||
|
||||
// Signatures of the path lists loaded into the Rust index. Two separate
|
||||
// signatures because the two loadFromFileList call sites use differently
|
||||
// structured arrays — a shared signature would ping-pong and never match.
|
||||
// Skips nucleo.restart() when git ls-files returns an unchanged list
|
||||
// (e.g. `git add` of an already-tracked file bumps index mtime but not the list).
|
||||
let loadedTrackedSignature: string | null = null
|
||||
let loadedMergedSignature: string | null = null
|
||||
|
||||
/**
|
||||
* Clear all file suggestion caches.
|
||||
* Call this when resuming a session to ensure fresh file discovery.
|
||||
*/
|
||||
export function clearFileSuggestionCaches(): void {
|
||||
fileIndex = null
|
||||
fileListRefreshPromise = null
|
||||
cacheGeneration++
|
||||
untrackedFetchPromise = null
|
||||
cachedTrackedFiles = []
|
||||
cachedConfigFiles = []
|
||||
cachedTrackedDirs = []
|
||||
indexBuildComplete.clear()
|
||||
ignorePatternsCache = null
|
||||
ignorePatternsCacheKey = null
|
||||
lastRefreshMs = 0
|
||||
lastGitIndexMtime = null
|
||||
loadedTrackedSignature = null
|
||||
loadedMergedSignature = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Content hash of a path list. A length|first|last sample misses renames of
|
||||
* middle files (same length, same endpoints → stale entry stuck in nucleo).
|
||||
*
|
||||
* Samples every Nth path (plus length). On a 346k-path list this hashes ~700
|
||||
* paths instead of 14MB — enough to catch git operations (checkout, rebase,
|
||||
* add/rm) while running in <1ms. A single mid-list rename that happens to
|
||||
* fall between samples will miss the rebuild, but the 5s refresh floor picks
|
||||
* it up on the next cycle.
|
||||
*/
|
||||
export function pathListSignature(paths: string[]): string {
|
||||
const n = paths.length
|
||||
const stride = Math.max(1, Math.floor(n / 500))
|
||||
let h = 0x811c9dc5 | 0
|
||||
for (let i = 0; i < n; i += stride) {
|
||||
const p = paths[i]!
|
||||
for (let j = 0; j < p.length; j++) {
|
||||
h = ((h ^ p.charCodeAt(j)) * 0x01000193) | 0
|
||||
}
|
||||
h = (h * 0x01000193) | 0
|
||||
}
|
||||
// Stride starts at 0 (first path always hashed); explicitly include last
|
||||
// so single-file add/rm at the tail is caught
|
||||
if (n > 0) {
|
||||
const last = paths[n - 1]!
|
||||
for (let j = 0; j < last.length; j++) {
|
||||
h = ((h ^ last.charCodeAt(j)) * 0x01000193) | 0
|
||||
}
|
||||
}
|
||||
return `${n}:${(h >>> 0).toString(16)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Stat .git/index to detect git state changes without spawning git ls-files.
|
||||
* Returns null for worktrees (.git is a file → ENOTDIR), fresh repos with no
|
||||
* index yet (ENOENT), and non-git dirs — caller falls back to time throttle.
|
||||
*/
|
||||
function getGitIndexMtime(): number | null {
|
||||
const repoRoot = findGitRoot(getCwd())
|
||||
if (!repoRoot) return null
|
||||
try {
|
||||
// eslint-disable-next-line custom-rules/no-sync-fs -- mtimeMs is the operation here, not a pre-check. findGitRoot above already stat-walks synchronously; one more stat is marginal vs spawning git ls-files on every keystroke. Async would force startBackgroundCacheRefresh to become async, breaking the synchronous fileListRefreshPromise contract at the cold-start await site.
|
||||
return statSync(path.join(repoRoot, '.git', 'index')).mtimeMs
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize git paths relative to originalCwd
|
||||
*/
|
||||
function normalizeGitPaths(
|
||||
files: string[],
|
||||
repoRoot: string,
|
||||
originalCwd: string,
|
||||
): string[] {
|
||||
if (originalCwd === repoRoot) {
|
||||
return files
|
||||
}
|
||||
return files.map(f => {
|
||||
const absolutePath = path.join(repoRoot, f)
|
||||
return path.relative(originalCwd, absolutePath)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge already-normalized untracked files into the cache
|
||||
*/
|
||||
async function mergeUntrackedIntoNormalizedCache(
|
||||
normalizedUntracked: string[],
|
||||
): Promise<void> {
|
||||
if (normalizedUntracked.length === 0) return
|
||||
if (!fileIndex || cachedTrackedFiles.length === 0) return
|
||||
|
||||
const untrackedDirs = await getDirectoryNamesAsync(normalizedUntracked)
|
||||
const allPaths = [
|
||||
...cachedTrackedFiles,
|
||||
...cachedConfigFiles,
|
||||
...cachedTrackedDirs,
|
||||
...normalizedUntracked,
|
||||
...untrackedDirs,
|
||||
]
|
||||
const sig = pathListSignature(allPaths)
|
||||
if (sig === loadedMergedSignature) {
|
||||
logForDebugging(
|
||||
`[FileIndex] skipped index rebuild — merged paths unchanged`,
|
||||
)
|
||||
return
|
||||
}
|
||||
await fileIndex.loadFromFileListAsync(allPaths).done
|
||||
loadedMergedSignature = sig
|
||||
logForDebugging(
|
||||
`[FileIndex] rebuilt index with ${cachedTrackedFiles.length} tracked + ${normalizedUntracked.length} untracked files`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load ripgrep-specific ignore patterns from .ignore or .rgignore files
|
||||
* Returns an ignore instance if patterns were found, null otherwise
|
||||
* Results are cached per repoRoot:cwd combination
|
||||
*/
|
||||
async function loadRipgrepIgnorePatterns(
|
||||
repoRoot: string,
|
||||
cwd: string,
|
||||
): Promise<ReturnType<typeof ignore> | null> {
|
||||
const cacheKey = `${repoRoot}:${cwd}`
|
||||
|
||||
// Return cached result if available
|
||||
if (ignorePatternsCacheKey === cacheKey) {
|
||||
return ignorePatternsCache
|
||||
}
|
||||
|
||||
const fs = getFsImplementation()
|
||||
const ignoreFiles = ['.ignore', '.rgignore']
|
||||
const directories = [...new Set([repoRoot, cwd])]
|
||||
|
||||
const ig = ignore()
|
||||
let hasPatterns = false
|
||||
|
||||
const paths = directories.flatMap(dir =>
|
||||
ignoreFiles.map(f => path.join(dir, f)),
|
||||
)
|
||||
const contents = await Promise.all(
|
||||
paths.map(p => fs.readFile(p, { encoding: 'utf8' }).catch(() => null)),
|
||||
)
|
||||
for (const [i, content] of contents.entries()) {
|
||||
if (content === null) continue
|
||||
ig.add(content)
|
||||
hasPatterns = true
|
||||
logForDebugging(`[FileIndex] loaded ignore patterns from ${paths[i]}`)
|
||||
}
|
||||
|
||||
const result = hasPatterns ? ig : null
|
||||
ignorePatternsCache = result
|
||||
ignorePatternsCacheKey = cacheKey
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files using git ls-files (much faster than ripgrep for git repos)
|
||||
* Returns tracked files immediately, fetches untracked in background
|
||||
* @param respectGitignore If true, excludes gitignored files from untracked results
|
||||
*
|
||||
* Note: Unlike ripgrep --follow, git ls-files doesn't follow symlinks.
|
||||
* This is intentional as git tracks symlinks as symlinks.
|
||||
*/
|
||||
async function getFilesUsingGit(
|
||||
abortSignal: AbortSignal,
|
||||
respectGitignore: boolean,
|
||||
): Promise<string[] | null> {
|
||||
const startTime = Date.now()
|
||||
logForDebugging(`[FileIndex] getFilesUsingGit called`)
|
||||
|
||||
// Check if we're in a git repo. findGitRoot is LRU-memoized per path.
|
||||
const repoRoot = findGitRoot(getCwd())
|
||||
if (!repoRoot) {
|
||||
logForDebugging(`[FileIndex] not a git repo, returning null`)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const cwd = getCwd()
|
||||
|
||||
// Get tracked files (fast - reads from git index)
|
||||
// Run from repoRoot so paths are relative to repo root, not CWD
|
||||
const lsFilesStart = Date.now()
|
||||
const trackedResult = await execFileNoThrowWithCwd(
|
||||
gitExe(),
|
||||
['-c', 'core.quotepath=false', 'ls-files', '--recurse-submodules'],
|
||||
{ timeout: 5000, abortSignal, cwd: repoRoot },
|
||||
)
|
||||
logForDebugging(
|
||||
`[FileIndex] git ls-files (tracked) took ${Date.now() - lsFilesStart}ms`,
|
||||
)
|
||||
|
||||
if (trackedResult.code !== 0) {
|
||||
logForDebugging(
|
||||
`[FileIndex] git ls-files failed (code=${trackedResult.code}, stderr=${trackedResult.stderr}), falling back to ripgrep`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const trackedFiles = trackedResult.stdout.trim().split('\n').filter(Boolean)
|
||||
|
||||
// Normalize paths relative to the current working directory
|
||||
let normalizedTracked = normalizeGitPaths(trackedFiles, repoRoot, cwd)
|
||||
|
||||
// Apply .ignore/.rgignore patterns if present (faster than falling back to ripgrep)
|
||||
const ignorePatterns = await loadRipgrepIgnorePatterns(repoRoot, cwd)
|
||||
if (ignorePatterns) {
|
||||
const beforeCount = normalizedTracked.length
|
||||
normalizedTracked = ignorePatterns.filter(normalizedTracked)
|
||||
logForDebugging(
|
||||
`[FileIndex] applied ignore patterns: ${beforeCount} -> ${normalizedTracked.length} files`,
|
||||
)
|
||||
}
|
||||
|
||||
// Cache tracked files for later merge with untracked
|
||||
cachedTrackedFiles = normalizedTracked
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
logForDebugging(
|
||||
`[FileIndex] git ls-files: ${normalizedTracked.length} tracked files in ${duration}ms`,
|
||||
)
|
||||
|
||||
logEvent('tengu_file_suggestions_git_ls_files', {
|
||||
file_count: normalizedTracked.length,
|
||||
tracked_count: normalizedTracked.length,
|
||||
untracked_count: 0,
|
||||
duration_ms: duration,
|
||||
})
|
||||
|
||||
// Start background fetch for untracked files (don't await)
|
||||
if (!untrackedFetchPromise) {
|
||||
const untrackedArgs = respectGitignore
|
||||
? [
|
||||
'-c',
|
||||
'core.quotepath=false',
|
||||
'ls-files',
|
||||
'--others',
|
||||
'--exclude-standard',
|
||||
]
|
||||
: ['-c', 'core.quotepath=false', 'ls-files', '--others']
|
||||
|
||||
const generation = cacheGeneration
|
||||
untrackedFetchPromise = execFileNoThrowWithCwd(gitExe(), untrackedArgs, {
|
||||
timeout: 10000,
|
||||
cwd: repoRoot,
|
||||
})
|
||||
.then(async untrackedResult => {
|
||||
if (generation !== cacheGeneration) {
|
||||
return // Cache was cleared; don't merge stale untracked files
|
||||
}
|
||||
if (untrackedResult.code === 0) {
|
||||
const rawUntrackedFiles = untrackedResult.stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
|
||||
// Normalize paths BEFORE applying ignore patterns (consistent with tracked files)
|
||||
let normalizedUntracked = normalizeGitPaths(
|
||||
rawUntrackedFiles,
|
||||
repoRoot,
|
||||
cwd,
|
||||
)
|
||||
|
||||
// Apply .ignore/.rgignore patterns to normalized untracked files
|
||||
const ignorePatterns = await loadRipgrepIgnorePatterns(
|
||||
repoRoot,
|
||||
cwd,
|
||||
)
|
||||
if (ignorePatterns && normalizedUntracked.length > 0) {
|
||||
const beforeCount = normalizedUntracked.length
|
||||
normalizedUntracked = ignorePatterns.filter(normalizedUntracked)
|
||||
logForDebugging(
|
||||
`[FileIndex] applied ignore patterns to untracked: ${beforeCount} -> ${normalizedUntracked.length} files`,
|
||||
)
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[FileIndex] background untracked fetch: ${normalizedUntracked.length} files`,
|
||||
)
|
||||
// Pass already-normalized files directly to merge function
|
||||
void mergeUntrackedIntoNormalizedCache(normalizedUntracked)
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
logForDebugging(
|
||||
`[FileIndex] background untracked fetch failed: ${error}`,
|
||||
)
|
||||
})
|
||||
.finally(() => {
|
||||
untrackedFetchPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return normalizedTracked
|
||||
} catch (error) {
|
||||
logForDebugging(`[FileIndex] git ls-files error: ${errorMessage(error)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function collects all parent directories for each file path
|
||||
* and returns a list of unique directory names with a trailing separator.
|
||||
* For example, if the input is ['src/index.js', 'src/utils/helpers.js'],
|
||||
* the output will be ['src/', 'src/utils/'].
|
||||
* @param files An array of file paths
|
||||
* @returns An array of unique directory names with a trailing separator
|
||||
*/
|
||||
export function getDirectoryNames(files: string[]): string[] {
|
||||
const directoryNames = new Set<string>()
|
||||
collectDirectoryNames(files, 0, files.length, directoryNames)
|
||||
return [...directoryNames].map(d => d + path.sep)
|
||||
}
|
||||
|
||||
/**
|
||||
* Async variant: yields every ~10k files so 270k+ file lists don't block
|
||||
* the main thread for >10ms at a time.
|
||||
*/
|
||||
export async function getDirectoryNamesAsync(
|
||||
files: string[],
|
||||
): Promise<string[]> {
|
||||
const directoryNames = new Set<string>()
|
||||
// Time-based chunking: yield after CHUNK_MS of work so slow machines get
|
||||
// smaller chunks and stay responsive.
|
||||
let chunkStart = performance.now()
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
collectDirectoryNames(files, i, i + 1, directoryNames)
|
||||
if ((i & 0xff) === 0xff && performance.now() - chunkStart > CHUNK_MS) {
|
||||
await yieldToEventLoop()
|
||||
chunkStart = performance.now()
|
||||
}
|
||||
}
|
||||
return [...directoryNames].map(d => d + path.sep)
|
||||
}
|
||||
|
||||
function collectDirectoryNames(
|
||||
files: string[],
|
||||
start: number,
|
||||
end: number,
|
||||
out: Set<string>,
|
||||
): void {
|
||||
for (let i = start; i < end; i++) {
|
||||
let currentDir = path.dirname(files[i]!)
|
||||
// Early exit if we've already processed this directory and all its parents.
|
||||
// Root detection: path.dirname returns its input at the root (fixed point),
|
||||
// so we stop when dirname stops changing. Checking this before add() keeps
|
||||
// the root out of the result set (matching the old path.parse().root guard).
|
||||
// This avoids path.parse() which allocates a 5-field object per file.
|
||||
while (currentDir !== '.' && !out.has(currentDir)) {
|
||||
const parent = path.dirname(currentDir)
|
||||
if (parent === currentDir) break
|
||||
out.add(currentDir)
|
||||
currentDir = parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets additional files from Claude config directories
|
||||
*/
|
||||
async function getClaudeConfigFiles(cwd: string): Promise<string[]> {
|
||||
const markdownFileArrays = await Promise.all(
|
||||
CLAUDE_CONFIG_DIRECTORIES.map(subdir =>
|
||||
loadMarkdownFilesForSubdir(subdir, cwd),
|
||||
),
|
||||
)
|
||||
return markdownFileArrays.flatMap(markdownFiles =>
|
||||
markdownFiles.map(f => f.filePath),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets project files using git ls-files (fast) or ripgrep (fallback)
|
||||
*/
|
||||
async function getProjectFiles(
|
||||
abortSignal: AbortSignal,
|
||||
respectGitignore: boolean,
|
||||
): Promise<string[]> {
|
||||
logForDebugging(
|
||||
`[FileIndex] getProjectFiles called, respectGitignore=${respectGitignore}`,
|
||||
)
|
||||
|
||||
// Try git ls-files first (much faster for git repos)
|
||||
const gitFiles = await getFilesUsingGit(abortSignal, respectGitignore)
|
||||
if (gitFiles !== null) {
|
||||
logForDebugging(
|
||||
`[FileIndex] using git ls-files result (${gitFiles.length} files)`,
|
||||
)
|
||||
return gitFiles
|
||||
}
|
||||
|
||||
// Fall back to ripgrep
|
||||
logForDebugging(
|
||||
`[FileIndex] git ls-files returned null, falling back to ripgrep`,
|
||||
)
|
||||
const startTime = Date.now()
|
||||
const rgArgs = [
|
||||
'--files',
|
||||
'--follow',
|
||||
'--hidden',
|
||||
'--glob',
|
||||
'!.git/',
|
||||
'--glob',
|
||||
'!.svn/',
|
||||
'--glob',
|
||||
'!.hg/',
|
||||
'--glob',
|
||||
'!.bzr/',
|
||||
'--glob',
|
||||
'!.jj/',
|
||||
'--glob',
|
||||
'!.sl/',
|
||||
]
|
||||
if (!respectGitignore) {
|
||||
rgArgs.push('--no-ignore-vcs')
|
||||
}
|
||||
|
||||
const files = await ripGrep(rgArgs, '.', abortSignal)
|
||||
const relativePaths = files.map(f => path.relative(getCwd(), f))
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
logForDebugging(
|
||||
`[FileIndex] ripgrep: ${relativePaths.length} files in ${duration}ms`,
|
||||
)
|
||||
|
||||
logEvent('tengu_file_suggestions_ripgrep', {
|
||||
file_count: relativePaths.length,
|
||||
duration_ms: duration,
|
||||
})
|
||||
|
||||
return relativePaths
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets both files and their directory paths for providing path suggestions
|
||||
* Uses git ls-files for git repos (fast) or ripgrep as fallback
|
||||
* Returns a FileIndex populated for fast fuzzy search
|
||||
*/
|
||||
export async function getPathsForSuggestions(): Promise<FileIndex> {
|
||||
const signal = AbortSignal.timeout(10_000)
|
||||
const index = getFileIndex()
|
||||
|
||||
try {
|
||||
// Check project settings first, then fall back to global config
|
||||
const projectSettings = getInitialSettings()
|
||||
const globalConfig = getGlobalConfig()
|
||||
const respectGitignore =
|
||||
projectSettings.respectGitignore ?? globalConfig.respectGitignore ?? true
|
||||
|
||||
const cwd = getCwd()
|
||||
const [projectFiles, configFiles] = await Promise.all([
|
||||
getProjectFiles(signal, respectGitignore),
|
||||
getClaudeConfigFiles(cwd),
|
||||
])
|
||||
|
||||
// Cache for mergeUntrackedIntoNormalizedCache
|
||||
cachedConfigFiles = configFiles
|
||||
|
||||
const allFiles = [...projectFiles, ...configFiles]
|
||||
const directories = await getDirectoryNamesAsync(allFiles)
|
||||
cachedTrackedDirs = directories
|
||||
const allPathsList = [...directories, ...allFiles]
|
||||
|
||||
// Skip rebuild when the list is unchanged. This is the common case
|
||||
// during a typing session — git ls-files returns the same output.
|
||||
const sig = pathListSignature(allPathsList)
|
||||
if (sig !== loadedTrackedSignature) {
|
||||
// Await the full build so cold-start returns complete results. The
|
||||
// build yields every ~4ms so the UI stays responsive — user can keep
|
||||
// typing during the ~120ms wait without input lag.
|
||||
await index.loadFromFileListAsync(allPathsList).done
|
||||
loadedTrackedSignature = sig
|
||||
// We just replaced the merged index with tracked-only data. Force
|
||||
// the next untracked merge to rebuild even if its own sig matches.
|
||||
loadedMergedSignature = null
|
||||
} else {
|
||||
logForDebugging(
|
||||
`[FileIndex] skipped index rebuild — tracked paths unchanged`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the common prefix between two strings
|
||||
*/
|
||||
function findCommonPrefix(a: string, b: string): string {
|
||||
const minLength = Math.min(a.length, b.length)
|
||||
let i = 0
|
||||
while (i < minLength && a[i] === b[i]) {
|
||||
i++
|
||||
}
|
||||
return a.substring(0, i)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the longest common prefix among an array of suggestion items
|
||||
*/
|
||||
export function findLongestCommonPrefix(suggestions: SuggestionItem[]): string {
|
||||
if (suggestions.length === 0) return ''
|
||||
|
||||
const strings = suggestions.map(item => item.displayText)
|
||||
let prefix = strings[0]!
|
||||
for (let i = 1; i < strings.length; i++) {
|
||||
const currentString = strings[i]!
|
||||
prefix = findCommonPrefix(prefix, currentString)
|
||||
if (prefix === '') return ''
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a file suggestion item
|
||||
*/
|
||||
function createFileSuggestionItem(
|
||||
filePath: string,
|
||||
score?: number,
|
||||
): SuggestionItem {
|
||||
return {
|
||||
id: `file-${filePath}`,
|
||||
displayText: filePath,
|
||||
metadata: score !== undefined ? { score } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find matching files and folders for a given query using the TS file index
|
||||
*/
|
||||
const MAX_SUGGESTIONS = 15
|
||||
function findMatchingFiles(
|
||||
fileIndex: FileIndex,
|
||||
partialPath: string,
|
||||
): SuggestionItem[] {
|
||||
const results = fileIndex.search(partialPath, MAX_SUGGESTIONS)
|
||||
return results.map(result =>
|
||||
createFileSuggestionItem(result.path, result.score),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a background refresh of the file index cache if not already in progress.
|
||||
*
|
||||
* Throttled: when a cache already exists, we skip the refresh unless git state
|
||||
* has actually changed. This prevents every keystroke from spawning git ls-files
|
||||
* and rebuilding the nucleo index.
|
||||
*/
|
||||
const REFRESH_THROTTLE_MS = 5_000
|
||||
export function startBackgroundCacheRefresh(): void {
|
||||
if (fileListRefreshPromise) return
|
||||
|
||||
// Throttle only when a cache exists — cold start must always populate.
|
||||
// Refresh immediately when .git/index mtime changed (tracked files).
|
||||
// Otherwise refresh at most once per 5s — this floor picks up new UNTRACKED
|
||||
// files, which don't bump .git/index. The signature checks downstream skip
|
||||
// the rebuild when the 5s refresh finds nothing actually changed.
|
||||
const indexMtime = getGitIndexMtime()
|
||||
if (fileIndex) {
|
||||
const gitStateChanged =
|
||||
indexMtime !== null && indexMtime !== lastGitIndexMtime
|
||||
if (!gitStateChanged && Date.now() - lastRefreshMs < REFRESH_THROTTLE_MS) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const generation = cacheGeneration
|
||||
const refreshStart = Date.now()
|
||||
// Ensure the FileIndex singleton exists — it's progressively queryable
|
||||
// via readyCount while the build runs. Callers searching early get partial
|
||||
// results; indexBuildComplete fires after .done so they can re-search.
|
||||
getFileIndex()
|
||||
fileListRefreshPromise = getPathsForSuggestions()
|
||||
.then(result => {
|
||||
if (generation !== cacheGeneration) {
|
||||
return result // Cache was cleared; don't overwrite with stale data
|
||||
}
|
||||
fileListRefreshPromise = null
|
||||
indexBuildComplete.emit()
|
||||
// Commit the start-time mtime observation on success. If git state
|
||||
// changed mid-refresh, the next call will see the newer mtime and
|
||||
// correctly refresh again.
|
||||
lastGitIndexMtime = indexMtime
|
||||
lastRefreshMs = Date.now()
|
||||
logForDebugging(
|
||||
`[FileIndex] cache refresh completed in ${Date.now() - refreshStart}ms`,
|
||||
)
|
||||
return result
|
||||
})
|
||||
.catch(error => {
|
||||
logForDebugging(
|
||||
`[FileIndex] Cache refresh failed: ${errorMessage(error)}`,
|
||||
)
|
||||
logError(error)
|
||||
if (generation === cacheGeneration) {
|
||||
fileListRefreshPromise = null // Allow retry on next call
|
||||
}
|
||||
return getFileIndex()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the top-level files and directories in the current working directory
|
||||
* @returns Array of file/directory paths in the current directory
|
||||
*/
|
||||
async function getTopLevelPaths(): Promise<string[]> {
|
||||
const fs = getFsImplementation()
|
||||
const cwd = getCwd()
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(cwd)
|
||||
return entries.map(entry => {
|
||||
const fullPath = path.join(cwd, entry.name)
|
||||
const relativePath = path.relative(cwd, fullPath)
|
||||
// Add trailing separator for directories
|
||||
return entry.isDirectory() ? relativePath + path.sep : relativePath
|
||||
})
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate file suggestions for the current input and cursor position
|
||||
* @param partialPath The partial file path to match
|
||||
* @param showOnEmpty Whether to show suggestions even if partialPath is empty (used for @ symbol)
|
||||
*/
|
||||
export async function generateFileSuggestions(
|
||||
partialPath: string,
|
||||
showOnEmpty = false,
|
||||
): Promise<SuggestionItem[]> {
|
||||
// If input is empty and we don't want to show suggestions on empty, return nothing
|
||||
if (!partialPath && !showOnEmpty) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Use custom command directly if configured. We don't mix in our config files
|
||||
// because the command returns pre-ranked results using its own search logic.
|
||||
if (getInitialSettings().fileSuggestion?.type === 'command') {
|
||||
const input: FileSuggestionCommandInput = {
|
||||
...createBaseHookInput(),
|
||||
query: partialPath,
|
||||
}
|
||||
const results = await executeFileSuggestionCommand(input)
|
||||
return results.slice(0, MAX_SUGGESTIONS).map(createFileSuggestionItem)
|
||||
}
|
||||
|
||||
// If the partial path is empty or just a dot, return current directory suggestions
|
||||
if (partialPath === '' || partialPath === '.' || partialPath === './') {
|
||||
const topLevelPaths = await getTopLevelPaths()
|
||||
startBackgroundCacheRefresh()
|
||||
return topLevelPaths.slice(0, MAX_SUGGESTIONS).map(createFileSuggestionItem)
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// Kick a background refresh. The index is progressively queryable —
|
||||
// searches during build return partial results from ready chunks, and
|
||||
// the typeahead callback (setOnIndexBuildComplete) re-fires the search
|
||||
// when the build finishes to upgrade partial → full.
|
||||
const wasBuilding = fileListRefreshPromise !== null
|
||||
startBackgroundCacheRefresh()
|
||||
|
||||
// Handle both './' and '.\'
|
||||
let normalizedPath = partialPath
|
||||
const currentDirPrefix = '.' + path.sep
|
||||
if (partialPath.startsWith(currentDirPrefix)) {
|
||||
normalizedPath = partialPath.substring(2)
|
||||
}
|
||||
|
||||
// Handle tilde expansion for home directory
|
||||
if (normalizedPath.startsWith('~')) {
|
||||
normalizedPath = expandPath(normalizedPath)
|
||||
}
|
||||
|
||||
const matches = fileIndex
|
||||
? findMatchingFiles(fileIndex, normalizedPath)
|
||||
: []
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
logForDebugging(
|
||||
`[FileIndex] generateFileSuggestions: ${matches.length} results in ${duration}ms (${wasBuilding ? 'partial' : 'full'} index)`,
|
||||
)
|
||||
logEvent('tengu_file_suggestions_query', {
|
||||
duration_ms: duration,
|
||||
cache_hit: !wasBuilding,
|
||||
result_count: matches.length,
|
||||
query_length: partialPath.length,
|
||||
})
|
||||
|
||||
return matches
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a file suggestion to the input
|
||||
*/
|
||||
export function applyFileSuggestion(
|
||||
suggestion: string | SuggestionItem,
|
||||
input: string,
|
||||
partialPath: string,
|
||||
startPos: number,
|
||||
onInputChange: (value: string) => void,
|
||||
setCursorOffset: (offset: number) => void,
|
||||
): void {
|
||||
// Extract suggestion text from string or SuggestionItem
|
||||
const suggestionText =
|
||||
typeof suggestion === 'string' ? suggestion : suggestion.displayText
|
||||
|
||||
// Replace the partial path with the selected file path
|
||||
const newInput =
|
||||
input.substring(0, startPos) +
|
||||
suggestionText +
|
||||
input.substring(startPos + partialPath.length)
|
||||
onInputChange(newInput)
|
||||
|
||||
// Move cursor to end of the file path
|
||||
const newCursorPos = startPos + suggestionText.length
|
||||
setCursorOffset(newCursorPos)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNotifications } from 'src/context/notifications.js'
|
||||
import { getIsRemoteMode } from '../../bootstrap/state.js'
|
||||
import { useAppState } from '../../state/AppState.js'
|
||||
import type { PermissionMode } from '../../utils/permissions/PermissionMode.js'
|
||||
import {
|
||||
getAutoModeUnavailableNotification,
|
||||
getAutoModeUnavailableReason,
|
||||
} from '../../utils/permissions/permissionSetup.js'
|
||||
import { hasAutoModeOptIn } from '../../utils/settings/settings.js'
|
||||
|
||||
/**
|
||||
* 当 shift+tab 轮播环绕经过自动模式所在位置时,
|
||||
* 显示一次性通知。涵盖所有原因(设置、断路器、
|
||||
* org-允许列表)。启动情况(defaultMode: auto 静默降级)
|
||||
* 由 verifyAutoModeGateAccess → checkAndDisableAutoModeIfNeeded 处理。
|
||||
*/
|
||||
export function useAutoModeUnavailableNotification(): void {
|
||||
const { addNotification } = useNotifications()
|
||||
const mode = useAppState(s => s.toolPermissionContext.mode)
|
||||
const isAutoModeAvailable = useAppState(
|
||||
s => s.toolPermissionContext.isAutoModeAvailable,
|
||||
)
|
||||
const shownRef = useRef(false)
|
||||
const prevModeRef = useRef<PermissionMode>(mode)
|
||||
|
||||
useEffect(() => {
|
||||
const prevMode = prevModeRef.current
|
||||
prevModeRef.current = mode
|
||||
|
||||
if (!feature('TRANSCRIPT_CLASSIFIER')) return
|
||||
if (getIsRemoteMode()) return
|
||||
if (shownRef.current) return
|
||||
|
||||
const wrappedPastAutoSlot =
|
||||
mode === 'default' &&
|
||||
prevMode !== 'default' &&
|
||||
prevMode !== 'auto' &&
|
||||
!isAutoModeAvailable &&
|
||||
hasAutoModeOptIn()
|
||||
|
||||
if (!wrappedPastAutoSlot) return
|
||||
|
||||
const reason = getAutoModeUnavailableReason()
|
||||
if (!reason) return
|
||||
|
||||
shownRef.current = true
|
||||
addNotification({
|
||||
key: 'auto-mode-unavailable',
|
||||
text: getAutoModeUnavailableNotification(reason),
|
||||
color: 'warning',
|
||||
priority: 'medium',
|
||||
})
|
||||
}, [mode, isAutoModeAvailable, addNotification])
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 在挂载时触发一次通知。封装了远程模式门控和
|
||||
* 每个会话一次的 ref 防护,这些在 10+ notifs/ hooks 中被手动滚动。
|
||||
*
|
||||
* compute 函数在首次效果时精确运行一次。返回 null 以跳过,
|
||||
* 返回 Notification 以触发一个,或返回数组以触发多个。同步或异步。
|
||||
* 拒绝被路由到 logError。
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { getIsRemoteMode } from '../../bootstrap/state.js'
|
||||
import {
|
||||
type Notification,
|
||||
useNotifications,
|
||||
} from '../../context/notifications.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
|
||||
type Result = Notification | Notification[] | null
|
||||
|
||||
export function useStartupNotification(
|
||||
compute: () => Result | Promise<Result>,
|
||||
): void {
|
||||
const { addNotification } = useNotifications()
|
||||
const hasRunRef = useRef(false)
|
||||
const computeRef = useRef(compute)
|
||||
computeRef.current = compute
|
||||
|
||||
useEffect(() => {
|
||||
if (getIsRemoteMode() || hasRunRef.current) return
|
||||
hasRunRef.current = true
|
||||
|
||||
void Promise.resolve()
|
||||
.then(() => computeRef.current())
|
||||
.then(result => {
|
||||
if (!result) return
|
||||
for (const n of Array.isArray(result) ? result : [result]) {
|
||||
addNotification(n)
|
||||
}
|
||||
})
|
||||
.catch(logError)
|
||||
}, [addNotification])
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { getIsRemoteMode } from '../../bootstrap/state.js'
|
||||
import {
|
||||
type Notification,
|
||||
useNotifications,
|
||||
} from '../../context/notifications.js'
|
||||
import { useAppState } from '../../state/AppState.js'
|
||||
import { isInProcessTeammateTask } from '../../tasks/InProcessTeammateTask/types.js'
|
||||
|
||||
function parseCount(notif: Notification): number {
|
||||
if (!('text' in notif)) {
|
||||
return 1
|
||||
}
|
||||
const match = notif.text.match(/^(\d+)/)
|
||||
return match?.[1] ? parseInt(match[1], 10) : 1
|
||||
}
|
||||
|
||||
function foldSpawn(acc: Notification, _incoming: Notification): Notification {
|
||||
return makeSpawnNotif(parseCount(acc) + 1)
|
||||
}
|
||||
|
||||
function makeSpawnNotif(count: number): Notification {
|
||||
return {
|
||||
key: 'teammate-spawn',
|
||||
text: count === 1 ? '1 agent spawned' : `${count} agents spawned`,
|
||||
priority: 'low',
|
||||
timeoutMs: 5000,
|
||||
fold: foldSpawn,
|
||||
}
|
||||
}
|
||||
|
||||
function foldShutdown(
|
||||
acc: Notification,
|
||||
_incoming: Notification,
|
||||
): Notification {
|
||||
return makeShutdownNotif(parseCount(acc) + 1)
|
||||
}
|
||||
|
||||
function makeShutdownNotif(count: number): Notification {
|
||||
return {
|
||||
key: 'teammate-shutdown',
|
||||
text: count === 1 ? '1 agent shut down' : `${count} agents shut down`,
|
||||
priority: 'low',
|
||||
timeoutMs: 5000,
|
||||
fold: foldShutdown,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在进程内队友生成或关闭时触发批量通知。
|
||||
* 使用 fold() 将重复事件合并为单个通知,
|
||||
* 如"3 个代理已生成"或"2 个代理已关闭"。
|
||||
*/
|
||||
export function useTeammateLifecycleNotification(): void {
|
||||
const tasks = useAppState(s => s.tasks)
|
||||
const { addNotification } = useNotifications()
|
||||
const seenRunningRef = useRef<Set<string>>(new Set())
|
||||
const seenCompletedRef = useRef<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
if (getIsRemoteMode()) return
|
||||
for (const [id, task] of Object.entries(tasks)) {
|
||||
if (!isInProcessTeammateTask(task)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (task.status === 'running' && !seenRunningRef.current.has(id)) {
|
||||
seenRunningRef.current.add(id)
|
||||
addNotification(makeSpawnNotif(1))
|
||||
}
|
||||
|
||||
if (task.status === 'completed' && !seenCompletedRef.current.has(id)) {
|
||||
seenCompletedRef.current.add(id)
|
||||
addNotification(makeShutdownNotif(1))
|
||||
}
|
||||
}
|
||||
}, [tasks, addNotification])
|
||||
}
|
||||
25
claude-code源码-中文注释/src/hooks/renderPlaceholder.ts
Normal file
25
claude-code源码-中文注释/src/hooks/renderPlaceholder.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
type FileHistorySnapshot,
|
||||
type FileHistoryState,
|
||||
fileHistoryEnabled,
|
||||
fileHistoryRestoreStateFromLog,
|
||||
} from '../utils/fileHistory.js'
|
||||
|
||||
export function useFileHistorySnapshotInit(
|
||||
initialFileHistorySnapshots: FileHistorySnapshot[] | undefined,
|
||||
fileHistoryState: FileHistoryState,
|
||||
onUpdateState: (newState: FileHistoryState) => void,
|
||||
): void {
|
||||
const initialized = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileHistoryEnabled() || initialized.current) {
|
||||
return
|
||||
}
|
||||
initialized.current = true
|
||||
if (initialFileHistorySnapshots) {
|
||||
fileHistoryRestoreStateFromLog(initialFileHistorySnapshots, onUpdateState)
|
||||
}
|
||||
}, [fileHistoryState, initialFileHistorySnapshots, onUpdateState])
|
||||
}
|
||||
396
claude-code源码-中文注释/src/hooks/toolPermission/PermissionContext.ts
Normal file
396
claude-code源码-中文注释/src/hooks/toolPermission/PermissionContext.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js'
|
||||
import type { ToolUseConfirm } from '../../components/permissions/PermissionRequest.js'
|
||||
import type {
|
||||
ToolPermissionContext,
|
||||
Tool as ToolType,
|
||||
ToolUseContext,
|
||||
} from '../../Tool.js'
|
||||
import { awaitClassifierAutoApproval } from '../../tools/BashTool/bashPermissions.js'
|
||||
import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
|
||||
import type { AssistantMessage } from '../../types/message.js'
|
||||
import type {
|
||||
PendingClassifierCheck,
|
||||
PermissionAllowDecision,
|
||||
PermissionDecisionReason,
|
||||
PermissionDenyDecision,
|
||||
} from '../../types/permissions.js'
|
||||
import { setClassifierApproval } from '../../utils/classifierApprovals.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { executePermissionRequestHooks } from '../../utils/hooks.js'
|
||||
import {
|
||||
REJECT_MESSAGE,
|
||||
REJECT_MESSAGE_WITH_REASON_PREFIX,
|
||||
SUBAGENT_REJECT_MESSAGE,
|
||||
SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX,
|
||||
withMemoryCorrectionHint,
|
||||
} from '../../utils/messages.js'
|
||||
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
|
||||
import {
|
||||
applyPermissionUpdates,
|
||||
persistPermissionUpdates,
|
||||
supportsPersistence,
|
||||
} from '../../utils/permissions/PermissionUpdate.js'
|
||||
import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'
|
||||
import {
|
||||
logPermissionDecision,
|
||||
type PermissionDecisionArgs,
|
||||
} from './permissionLogging.js'
|
||||
|
||||
type PermissionApprovalSource =
|
||||
| { type: 'hook'; permanent?: boolean }
|
||||
| { type: 'user'; permanent: boolean }
|
||||
| { type: 'classifier' }
|
||||
|
||||
type PermissionRejectionSource =
|
||||
| { type: 'hook' }
|
||||
| { type: 'user_abort' }
|
||||
| { type: 'user_reject'; hasFeedback: boolean }
|
||||
|
||||
// 权限队列操作的通用接口,与 React 解耦。
|
||||
// 在 REPL 中,这些由 React 状态支持。
|
||||
type PermissionQueueOps = {
|
||||
push(item: ToolUseConfirm): void
|
||||
remove(toolUseID: string): void
|
||||
update(toolUseID: string, patch: Partial<ToolUseConfirm>): void
|
||||
}
|
||||
|
||||
type ResolveOnce<T> = {
|
||||
resolve(value: T): void
|
||||
isResolved(): boolean
|
||||
/**
|
||||
* 原子性地检查并标记为已解决。如果这个调用者
|
||||
* 赢得了竞争(还没有其他人解决),返回 true,否则返回 false。
|
||||
* 在 async 回调中的 await 之前使用这个,以关闭
|
||||
* isResolved() 检查和实际 resolve() 调用之间的窗口。
|
||||
*/
|
||||
claim(): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只解决一次的 Promise 包装器。
|
||||
* 用于防止多个回调同时解决同一个 Promise。
|
||||
*/
|
||||
function createResolveOnce<T>(resolve: (value: T) => void): ResolveOnce<T> {
|
||||
let claimed = false
|
||||
let delivered = false
|
||||
return {
|
||||
resolve(value: T) {
|
||||
if (delivered) return
|
||||
delivered = true
|
||||
claimed = true
|
||||
resolve(value)
|
||||
},
|
||||
isResolved() {
|
||||
return claimed
|
||||
},
|
||||
claim() {
|
||||
if (claimed) return false
|
||||
claimed = true
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建权限上下文对象,包含处理权限决策所需的所有逻辑。
|
||||
* 包括 hook 执行、分类器检查、持久化决策等。
|
||||
*/
|
||||
function createPermissionContext(
|
||||
tool: ToolType,
|
||||
input: Record<string, unknown>,
|
||||
toolUseContext: ToolUseContext,
|
||||
assistantMessage: AssistantMessage,
|
||||
toolUseID: string,
|
||||
setToolPermissionContext: (context: ToolPermissionContext) => void,
|
||||
queueOps?: PermissionQueueOps,
|
||||
) {
|
||||
const messageId = assistantMessage.message.id
|
||||
const ctx = {
|
||||
tool,
|
||||
input,
|
||||
toolUseContext,
|
||||
assistantMessage,
|
||||
messageId,
|
||||
toolUseID,
|
||||
logDecision(
|
||||
args: PermissionDecisionArgs,
|
||||
opts?: {
|
||||
input?: Record<string, unknown>
|
||||
permissionPromptStartTimeMs?: number
|
||||
},
|
||||
) {
|
||||
logPermissionDecision(
|
||||
{
|
||||
tool,
|
||||
input: opts?.input ?? input,
|
||||
toolUseContext,
|
||||
messageId,
|
||||
toolUseID,
|
||||
},
|
||||
args,
|
||||
opts?.permissionPromptStartTimeMs,
|
||||
)
|
||||
},
|
||||
logCancelled() {
|
||||
logEvent('tengu_tool_use_cancelled', {
|
||||
messageID:
|
||||
messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
toolName: sanitizeToolNameForAnalytics(tool.name),
|
||||
})
|
||||
},
|
||||
async persistPermissions(updates: PermissionUpdate[]) {
|
||||
if (updates.length === 0) return false
|
||||
persistPermissionUpdates(updates)
|
||||
const appState = toolUseContext.getAppState()
|
||||
setToolPermissionContext(
|
||||
applyPermissionUpdates(appState.toolPermissionContext, updates),
|
||||
)
|
||||
return updates.some(update => supportsPersistence(update.destination))
|
||||
},
|
||||
resolveIfAborted(resolve: (decision: PermissionDecision) => void) {
|
||||
if (!toolUseContext.abortController.signal.aborted) return false
|
||||
this.logCancelled()
|
||||
resolve(this.cancelAndAbort(undefined, true))
|
||||
return true
|
||||
},
|
||||
cancelAndAbort(
|
||||
feedback?: string,
|
||||
isAbort?: boolean,
|
||||
contentBlocks?: ContentBlockParam[],
|
||||
): PermissionDecision {
|
||||
const sub = !!toolUseContext.agentId
|
||||
const baseMessage = feedback
|
||||
? `${sub ? SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX : REJECT_MESSAGE_WITH_REASON_PREFIX}${feedback}`
|
||||
: sub
|
||||
? SUBAGENT_REJECT_MESSAGE
|
||||
: REJECT_MESSAGE
|
||||
const message = sub ? baseMessage : withMemoryCorrectionHint(baseMessage)
|
||||
if (isAbort || (!feedback && !contentBlocks?.length && !sub)) {
|
||||
logForDebugging(
|
||||
`Aborting: tool=${tool.name} isAbort=${isAbort} hasFeedback=${!!feedback} isSubagent=${sub}`,
|
||||
)
|
||||
toolUseContext.abortController.abort()
|
||||
}
|
||||
return { behavior: 'ask', message, contentBlocks }
|
||||
},
|
||||
...(feature('BASH_CLASSIFIER')
|
||||
? {
|
||||
async tryClassifier(
|
||||
pendingClassifierCheck: PendingClassifierCheck | undefined,
|
||||
updatedInput: Record<string, unknown> | undefined,
|
||||
): Promise<PermissionDecision | null> {
|
||||
if (tool.name !== BASH_TOOL_NAME || !pendingClassifierCheck) {
|
||||
return null
|
||||
}
|
||||
const classifierDecision = await awaitClassifierAutoApproval(
|
||||
pendingClassifierCheck,
|
||||
toolUseContext.abortController.signal,
|
||||
toolUseContext.options.isNonInteractiveSession,
|
||||
)
|
||||
if (!classifierDecision) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
feature('TRANSCRIPT_CLASSIFIER') &&
|
||||
classifierDecision.type === 'classifier'
|
||||
) {
|
||||
const matchedRule = classifierDecision.reason.match(
|
||||
/^Allowed by prompt rule: "(.+)"$/,
|
||||
)?.[1]
|
||||
if (matchedRule) {
|
||||
setClassifierApproval(toolUseID, matchedRule)
|
||||
}
|
||||
}
|
||||
logPermissionDecision(
|
||||
{ tool, input, toolUseContext, messageId, toolUseID },
|
||||
{ decision: 'accept', source: { type: 'classifier' } },
|
||||
undefined,
|
||||
)
|
||||
return {
|
||||
behavior: 'allow' as const,
|
||||
updatedInput: updatedInput ?? input,
|
||||
userModified: false,
|
||||
decisionReason: classifierDecision,
|
||||
}
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
async runHooks(
|
||||
permissionMode: string | undefined,
|
||||
suggestions: PermissionUpdate[] | undefined,
|
||||
updatedInput?: Record<string, unknown>,
|
||||
permissionPromptStartTimeMs?: number,
|
||||
): Promise<PermissionDecision | null> {
|
||||
for await (const hookResult of executePermissionRequestHooks(
|
||||
tool.name,
|
||||
toolUseID,
|
||||
input,
|
||||
toolUseContext,
|
||||
permissionMode,
|
||||
suggestions,
|
||||
toolUseContext.abortController.signal,
|
||||
)) {
|
||||
if (hookResult.permissionRequestResult) {
|
||||
const decision = hookResult.permissionRequestResult
|
||||
if (decision.behavior === 'allow') {
|
||||
const finalInput = decision.updatedInput ?? updatedInput ?? input
|
||||
return await this.handleHookAllow(
|
||||
finalInput,
|
||||
decision.updatedPermissions ?? [],
|
||||
permissionPromptStartTimeMs,
|
||||
)
|
||||
} else if (decision.behavior === 'deny') {
|
||||
this.logDecision(
|
||||
{ decision: 'reject', source: { type: 'hook' } },
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
if (decision.interrupt) {
|
||||
logForDebugging(
|
||||
`Hook interrupt: tool=${tool.name} hookMessage=${decision.message}`,
|
||||
)
|
||||
toolUseContext.abortController.abort()
|
||||
}
|
||||
return this.buildDeny(
|
||||
decision.message || 'Permission denied by hook',
|
||||
{
|
||||
type: 'hook',
|
||||
hookName: 'PermissionRequest',
|
||||
reason: decision.message,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
buildAllow(
|
||||
updatedInput: Record<string, unknown>,
|
||||
opts?: {
|
||||
userModified?: boolean
|
||||
decisionReason?: PermissionDecisionReason
|
||||
acceptFeedback?: string
|
||||
contentBlocks?: ContentBlockParam[]
|
||||
},
|
||||
): PermissionAllowDecision {
|
||||
return {
|
||||
behavior: 'allow' as const,
|
||||
updatedInput,
|
||||
userModified: opts?.userModified ?? false,
|
||||
...(opts?.decisionReason && { decisionReason: opts.decisionReason }),
|
||||
...(opts?.acceptFeedback && { acceptFeedback: opts.acceptFeedback }),
|
||||
...(opts?.contentBlocks &&
|
||||
opts.contentBlocks.length > 0 && {
|
||||
contentBlocks: opts.contentBlocks,
|
||||
}),
|
||||
}
|
||||
},
|
||||
buildDeny(
|
||||
message: string,
|
||||
decisionReason: PermissionDecisionReason,
|
||||
): PermissionDenyDecision {
|
||||
return { behavior: 'deny' as const, message, decisionReason }
|
||||
},
|
||||
async handleUserAllow(
|
||||
updatedInput: Record<string, unknown>,
|
||||
permissionUpdates: PermissionUpdate[],
|
||||
feedback?: string,
|
||||
permissionPromptStartTimeMs?: number,
|
||||
contentBlocks?: ContentBlockParam[],
|
||||
decisionReason?: PermissionDecisionReason,
|
||||
): Promise<PermissionAllowDecision> {
|
||||
const acceptedPermanentUpdates =
|
||||
await this.persistPermissions(permissionUpdates)
|
||||
this.logDecision(
|
||||
{
|
||||
decision: 'accept',
|
||||
source: { type: 'user', permanent: acceptedPermanentUpdates },
|
||||
},
|
||||
{ input: updatedInput, permissionPromptStartTimeMs },
|
||||
)
|
||||
const userModified = tool.inputsEquivalent
|
||||
? !tool.inputsEquivalent(input, updatedInput)
|
||||
: false
|
||||
const trimmedFeedback = feedback?.trim()
|
||||
return this.buildAllow(updatedInput, {
|
||||
userModified,
|
||||
decisionReason,
|
||||
acceptFeedback: trimmedFeedback || undefined,
|
||||
contentBlocks,
|
||||
})
|
||||
},
|
||||
async handleHookAllow(
|
||||
finalInput: Record<string, unknown>,
|
||||
permissionUpdates: PermissionUpdate[],
|
||||
permissionPromptStartTimeMs?: number,
|
||||
): Promise<PermissionAllowDecision> {
|
||||
const acceptedPermanentUpdates =
|
||||
await this.persistPermissions(permissionUpdates)
|
||||
this.logDecision(
|
||||
{
|
||||
decision: 'accept',
|
||||
source: { type: 'hook', permanent: acceptedPermanentUpdates },
|
||||
},
|
||||
{ input: finalInput, permissionPromptStartTimeMs },
|
||||
)
|
||||
return this.buildAllow(finalInput, {
|
||||
decisionReason: { type: 'hook', hookName: 'PermissionRequest' },
|
||||
})
|
||||
},
|
||||
pushToQueue(item: ToolUseConfirm) {
|
||||
queueOps?.push(item)
|
||||
},
|
||||
removeFromQueue() {
|
||||
queueOps?.remove(toolUseID)
|
||||
},
|
||||
updateQueueItem(patch: Partial<ToolUseConfirm>) {
|
||||
queueOps?.update(toolUseID, patch)
|
||||
},
|
||||
}
|
||||
return Object.freeze(ctx)
|
||||
}
|
||||
|
||||
type PermissionContext = ReturnType<typeof createPermissionContext>
|
||||
|
||||
/**
|
||||
* 创建由 React state setter 支持的 PermissionQueueOps。
|
||||
* 这是 React 的 setToolUseConfirmQueue 与 PermissionContext 使用的
|
||||
* 通用队列接口之间的桥梁。
|
||||
*/
|
||||
function createPermissionQueueOps(
|
||||
setToolUseConfirmQueue: React.Dispatch<
|
||||
React.SetStateAction<ToolUseConfirm[]>
|
||||
>,
|
||||
): PermissionQueueOps {
|
||||
return {
|
||||
push(item: ToolUseConfirm) {
|
||||
setToolUseConfirmQueue(queue => [...queue, item])
|
||||
},
|
||||
remove(toolUseID: string) {
|
||||
setToolUseConfirmQueue(queue =>
|
||||
queue.filter(item => item.toolUseID !== toolUseID),
|
||||
)
|
||||
},
|
||||
update(toolUseID: string, patch: Partial<ToolUseConfirm>) {
|
||||
setToolUseConfirmQueue(queue =>
|
||||
queue.map(item =>
|
||||
item.toolUseID === toolUseID ? { ...item, ...patch } : item,
|
||||
),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export { createPermissionContext, createPermissionQueueOps, createResolveOnce }
|
||||
export type {
|
||||
PermissionContext,
|
||||
PermissionApprovalSource,
|
||||
PermissionQueueOps,
|
||||
PermissionRejectionSource,
|
||||
ResolveOnce,
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { PendingClassifierCheck } from '../../../types/permissions.js'
|
||||
import { logError } from '../../../utils/log.js'
|
||||
import type { PermissionDecision } from '../../../utils/permissions/PermissionResult.js'
|
||||
import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'
|
||||
import type { PermissionContext } from '../PermissionContext.js'
|
||||
|
||||
type CoordinatorPermissionParams = {
|
||||
ctx: PermissionContext
|
||||
pendingClassifierCheck?: PendingClassifierCheck | undefined
|
||||
updatedInput: Record<string, unknown> | undefined
|
||||
suggestions: PermissionUpdate[] | undefined
|
||||
permissionMode: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理协调器工作线程权限流程。
|
||||
*
|
||||
* 对于协调器工作线程,自动化检查(hooks 和分类器)按顺序
|
||||
* 等待,然后才进入交互式对话框。
|
||||
*
|
||||
* 如果自动化检查解决了权限则返回 PermissionDecision,
|
||||
* 否则返回 null,调用者应进入交互式对话框。
|
||||
*/
|
||||
async function handleCoordinatorPermission(
|
||||
params: CoordinatorPermissionParams,
|
||||
): Promise<PermissionDecision | null> {
|
||||
const { ctx, updatedInput, suggestions, permissionMode } = params
|
||||
|
||||
try {
|
||||
// 1. 首先尝试权限 hooks(快速,本地)
|
||||
const hookResult = await ctx.runHooks(
|
||||
permissionMode,
|
||||
suggestions,
|
||||
updatedInput,
|
||||
)
|
||||
if (hookResult) return hookResult
|
||||
|
||||
// 2. 尝试分类器(慢,推理 — 仅 bash)
|
||||
const classifierResult = feature('BASH_CLASSIFIER')
|
||||
? await ctx.tryClassifier?.(params.pendingClassifierCheck, updatedInput)
|
||||
: null
|
||||
if (classifierResult) {
|
||||
return classifierResult
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果自动化检查意外失败,进入对话框让用户手动决定。
|
||||
// 非 Error 抛出带有上下文前缀,以便日志可追溯 —
|
||||
// 有意不使用 toError(),否则会丢弃前缀。
|
||||
if (error instanceof Error) {
|
||||
logError(error)
|
||||
} else {
|
||||
logError(new Error(`Automated permission check failed: ${String(error)}`))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 两者都没有解决(或检查失败)— 进入下方对话框。
|
||||
// Hooks 已经运行,分类器已经消耗。
|
||||
return null
|
||||
}
|
||||
|
||||
export { handleCoordinatorPermission }
|
||||
export type { CoordinatorPermissionParams }
|
||||
@@ -0,0 +1,530 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import { getAllowedChannels } from '../../../bootstrap/state.js'
|
||||
import type { BridgePermissionCallbacks } from '../../../bridge/bridgePermissionCallbacks.js'
|
||||
import { getTerminalFocused } from '../../../ink/terminal-focus-state.js'
|
||||
import {
|
||||
CHANNEL_PERMISSION_REQUEST_METHOD,
|
||||
type ChannelPermissionRequestParams,
|
||||
findChannelEntry,
|
||||
} from '../../../services/mcp/channelNotification.js'
|
||||
import type { ChannelPermissionCallbacks } from '../../../services/mcp/channelPermissions.js'
|
||||
import {
|
||||
filterPermissionRelayClients,
|
||||
shortRequestId,
|
||||
truncateForPreview,
|
||||
} from '../../../services/mcp/channelPermissions.js'
|
||||
import { executeAsyncClassifierCheck } from '../../../tools/BashTool/bashPermissions.js'
|
||||
import { BASH_TOOL_NAME } from '../../../tools/BashTool/toolName.js'
|
||||
import {
|
||||
clearClassifierChecking,
|
||||
setClassifierApproval,
|
||||
setClassifierChecking,
|
||||
setYoloClassifierApproval,
|
||||
} from '../../../utils/classifierApprovals.js'
|
||||
import { errorMessage } from '../../../utils/errors.js'
|
||||
import type { PermissionDecision } from '../../../utils/permissions/PermissionResult.js'
|
||||
import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'
|
||||
import { hasPermissionsToUseTool } from '../../../utils/permissions/permissions.js'
|
||||
import type { PermissionContext } from '../PermissionContext.js'
|
||||
import { createResolveOnce } from '../PermissionContext.js'
|
||||
|
||||
type InteractivePermissionParams = {
|
||||
ctx: PermissionContext
|
||||
description: string
|
||||
result: PermissionDecision & { behavior: 'ask' }
|
||||
awaitAutomatedChecksBeforeDialog: boolean | undefined
|
||||
bridgeCallbacks?: BridgePermissionCallbacks
|
||||
channelCallbacks?: ChannelPermissionCallbacks
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理交互式(主代理)权限流程。
|
||||
*
|
||||
* 将 ToolUseConfirm 条目推送到确认队列,包含回调:
|
||||
* onAbort、onAllow、onReject、recheckPermission、onUserInteraction。
|
||||
*
|
||||
* 在后台异步运行权限 hook 和 bash 分类器检查,与用户交互竞争。
|
||||
* 使用 resolve-once 防护和 userInteracted 标志防止多次解决。
|
||||
*
|
||||
* 此函数不返回 Promise — 它设置回调,最终调用 resolve() 来解决
|
||||
* 由调用者拥有的外部 Promise。
|
||||
*/
|
||||
function handleInteractivePermission(
|
||||
params: InteractivePermissionParams,
|
||||
resolve: (decision: PermissionDecision) => void,
|
||||
): void {
|
||||
const {
|
||||
ctx,
|
||||
description,
|
||||
result,
|
||||
awaitAutomatedChecksBeforeDialog,
|
||||
bridgeCallbacks,
|
||||
channelCallbacks,
|
||||
} = params
|
||||
|
||||
const { resolve: resolveOnce, isResolved, claim } = createResolveOnce(resolve)
|
||||
let userInteracted = false
|
||||
let checkmarkTransitionTimer: ReturnType<typeof setTimeout> | undefined
|
||||
// 提升以便 onDismissCheckmark(checkmark 窗口期间的 Esc)也可以
|
||||
// 移除 abort 监听器 — 不只是 timer 回调。
|
||||
let checkmarkAbortHandler: (() => void) | undefined
|
||||
const bridgeRequestId = bridgeCallbacks ? randomUUID() : undefined
|
||||
// 提升以便 local/hook/classifier 获胜时可以移除待处理的 channel
|
||||
// 条目。没有"告诉远程取消"的等价物 — 文本留在你的
|
||||
// 手机中,而本地解决后过时的"yes abc123"会通过
|
||||
// tryConsumeReply(条目消失)作为普通聊天入队。
|
||||
let channelUnsubscribe: (() => void) | undefined
|
||||
|
||||
const permissionPromptStartTimeMs = Date.now()
|
||||
const displayInput = result.updatedInput ?? ctx.input
|
||||
|
||||
function clearClassifierIndicator(): void {
|
||||
if (feature('BASH_CLASSIFIER')) {
|
||||
ctx.updateQueueItem({ classifierCheckInProgress: false })
|
||||
}
|
||||
}
|
||||
|
||||
ctx.pushToQueue({
|
||||
assistantMessage: ctx.assistantMessage,
|
||||
tool: ctx.tool,
|
||||
description,
|
||||
input: displayInput,
|
||||
toolUseContext: ctx.toolUseContext,
|
||||
toolUseID: ctx.toolUseID,
|
||||
permissionResult: result,
|
||||
permissionPromptStartTimeMs,
|
||||
...(feature('BASH_CLASSIFIER')
|
||||
? {
|
||||
classifierCheckInProgress:
|
||||
!!result.pendingClassifierCheck &&
|
||||
!awaitAutomatedChecksBeforeDialog,
|
||||
}
|
||||
: {}),
|
||||
onUserInteraction() {
|
||||
// 当用户开始与权限对话框交互时调用
|
||||
//(例如方向键、tab、输入反馈)
|
||||
// 隐藏分类器指示器,因为自动批准不再可能
|
||||
//
|
||||
// 宽限期:忽略前 200ms 内的交互,以防止
|
||||
// 意外按键过早取消分类器
|
||||
const GRACE_PERIOD_MS = 200
|
||||
if (Date.now() - permissionPromptStartTimeMs < GRACE_PERIOD_MS) {
|
||||
return
|
||||
}
|
||||
userInteracted = true
|
||||
clearClassifierChecking(ctx.toolUseID)
|
||||
clearClassifierIndicator()
|
||||
},
|
||||
onDismissCheckmark() {
|
||||
if (checkmarkTransitionTimer) {
|
||||
clearTimeout(checkmarkTransitionTimer)
|
||||
checkmarkTransitionTimer = undefined
|
||||
if (checkmarkAbortHandler) {
|
||||
ctx.toolUseContext.abortController.signal.removeEventListener(
|
||||
'abort',
|
||||
checkmarkAbortHandler,
|
||||
)
|
||||
checkmarkAbortHandler = undefined
|
||||
}
|
||||
ctx.removeFromQueue()
|
||||
}
|
||||
},
|
||||
onAbort() {
|
||||
if (!claim()) return
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
||||
behavior: 'deny',
|
||||
message: 'User aborted',
|
||||
})
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
channelUnsubscribe?.()
|
||||
ctx.logCancelled()
|
||||
ctx.logDecision(
|
||||
{ decision: 'reject', source: { type: 'user_abort' } },
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
resolveOnce(ctx.cancelAndAbort(undefined, true))
|
||||
},
|
||||
async onAllow(
|
||||
updatedInput,
|
||||
permissionUpdates: PermissionUpdate[],
|
||||
feedback?: string,
|
||||
contentBlocks?: ContentBlockParam[],
|
||||
) {
|
||||
if (!claim()) return // 在 await 之前原子性检查和标记
|
||||
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
||||
behavior: 'allow',
|
||||
updatedInput,
|
||||
updatedPermissions: permissionUpdates,
|
||||
})
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
channelUnsubscribe?.()
|
||||
|
||||
resolveOnce(
|
||||
await ctx.handleUserAllow(
|
||||
updatedInput,
|
||||
permissionUpdates,
|
||||
feedback,
|
||||
permissionPromptStartTimeMs,
|
||||
contentBlocks,
|
||||
result.decisionReason,
|
||||
),
|
||||
)
|
||||
},
|
||||
onReject(feedback?: string, contentBlocks?: ContentBlockParam[]) {
|
||||
if (!claim()) return
|
||||
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
||||
behavior: 'deny',
|
||||
message: feedback ?? 'User denied permission',
|
||||
})
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
channelUnsubscribe?.()
|
||||
|
||||
ctx.logDecision(
|
||||
{
|
||||
decision: 'reject',
|
||||
source: { type: 'user_reject', hasFeedback: !!feedback },
|
||||
},
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
resolveOnce(ctx.cancelAndAbort(feedback, undefined, contentBlocks))
|
||||
},
|
||||
async recheckPermission() {
|
||||
if (isResolved()) return
|
||||
const freshResult = await hasPermissionsToUseTool(
|
||||
ctx.tool,
|
||||
ctx.input,
|
||||
ctx.toolUseContext,
|
||||
ctx.assistantMessage,
|
||||
ctx.toolUseID,
|
||||
)
|
||||
if (freshResult.behavior === 'allow') {
|
||||
// claim()(原子性检查和标记),不是 isResolved() — 上面的
|
||||
// async hasPermissionsToUseTool 调用打开了一个窗口,在此期间 CCR
|
||||
// 可能在飞行中已经响应。匹配 onAllow/onReject/hook
|
||||
// 路径。cancelRequest 告诉 CCR 取消其提示 — 没有
|
||||
// 它,web UI 会显示一个已执行工具的陈旧提示(特别是在
|
||||
// CCR 启动的模式切换触发重新检查后可见,这是
|
||||
// useReplBridge 开始调用它之后此回调存在的用例)。
|
||||
if (!claim()) return
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
channelUnsubscribe?.()
|
||||
ctx.removeFromQueue()
|
||||
ctx.logDecision({ decision: 'accept', source: 'config' })
|
||||
resolveOnce(ctx.buildAllow(freshResult.updatedInput ?? ctx.input))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Race 4: 来自 CCR (claude.ai) 的桥接权限响应
|
||||
// 当桥接连接时,发送权限请求到 CCR 并订阅响应。
|
||||
// 无论哪一方(CLI 或 CCR)先响应都通过 claim() 获胜。
|
||||
//
|
||||
// 所有工具都被转发 — CCR 的通用 allow/deny 模态处理任何
|
||||
// 工具,并且可以在有专用渲染器时返回 updatedInput
|
||||
//(例如计划编辑)。其本地对话框注入字段的工具
|
||||
//(ReviewArtifact selected、AskUserQuestion answers)容忍字段缺失,
|
||||
// 因此通用远程批准会优雅降级而不是抛出错误。
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.sendRequest(
|
||||
bridgeRequestId,
|
||||
ctx.tool.name,
|
||||
displayInput,
|
||||
ctx.toolUseID,
|
||||
description,
|
||||
result.suggestions,
|
||||
result.blockedPath,
|
||||
)
|
||||
|
||||
const signal = ctx.toolUseContext.abortController.signal
|
||||
const unsubscribe = bridgeCallbacks.onResponse(
|
||||
bridgeRequestId,
|
||||
response => {
|
||||
if (!claim()) return // 本地用户/hook/分类器已响应
|
||||
signal.removeEventListener('abort', unsubscribe)
|
||||
clearClassifierChecking(ctx.toolUseID)
|
||||
clearClassifierIndicator()
|
||||
ctx.removeFromQueue()
|
||||
channelUnsubscribe?.()
|
||||
|
||||
if (response.behavior === 'allow') {
|
||||
if (response.updatedPermissions?.length) {
|
||||
void ctx.persistPermissions(response.updatedPermissions)
|
||||
}
|
||||
ctx.logDecision(
|
||||
{
|
||||
decision: 'accept',
|
||||
source: {
|
||||
type: 'user',
|
||||
permanent: !!response.updatedPermissions?.length,
|
||||
},
|
||||
},
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
resolveOnce(ctx.buildAllow(response.updatedInput ?? displayInput))
|
||||
} else {
|
||||
ctx.logDecision(
|
||||
{
|
||||
decision: 'reject',
|
||||
source: {
|
||||
type: 'user_reject',
|
||||
hasFeedback: !!response.message,
|
||||
},
|
||||
},
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
resolveOnce(ctx.cancelAndAbort(response.message))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
signal.addEventListener('abort', unsubscribe, { once: true })
|
||||
}
|
||||
|
||||
// 通道权限中继 — 与上面的桥接块竞争。通过
|
||||
// 其 MCP send_message 工具向每个活动通道(Telegram、iMessage 等)
|
||||
// 发送权限提示,然后将回复与 local/bridge/hook/ 竞争。
|
||||
// 入站"yes abc123"在通知处理程序(useManageMCPConnections.ts)中拦截,
|
||||
// 在入队之前,所以它永远不会作为对话轮次到达 Claude。
|
||||
//
|
||||
// 与桥接块不同,这仍然保护 requiresUserInteraction —
|
||||
// 通道回复是纯 yes/no,没有 updatedInput 路径。实际上
|
||||
// 保护是死代码:所有三个 requiresUserInteraction 工具
|
||||
//(ExitPlanMode、AskUserQuestion、ReviewArtifact)在配置通道时
|
||||
// 返回 isEnabled()===false,所以它们永远不会到达此处理程序。
|
||||
//
|
||||
// 即发即忘的发送:如果 callTool 失败(通道关闭、工具缺失),
|
||||
// 订阅永远不会触发,另一个竞争者获胜。优雅降级 —
|
||||
// 本地对话框始终作为底层。
|
||||
if (
|
||||
(feature('KAIROS') || feature('KAIROS_CHANNELS')) &&
|
||||
channelCallbacks &&
|
||||
!ctx.tool.requiresUserInteraction?.()
|
||||
) {
|
||||
const channelRequestId = shortRequestId(ctx.toolUseID)
|
||||
const allowedChannels = getAllowedChannels()
|
||||
const channelClients = filterPermissionRelayClients(
|
||||
ctx.toolUseContext.getAppState().mcp.clients,
|
||||
name => findChannelEntry(name, allowedChannels) !== undefined,
|
||||
)
|
||||
|
||||
if (channelClients.length > 0) {
|
||||
// 出站结构也是(Kenneth 的对称性要求)— 服务器拥有
|
||||
// 其平台(Telegram markdown、iMessage 富文本、Discord embed)
|
||||
// 的消息格式。CC 发送原始部分;服务器组合。
|
||||
// 旧的 callTool('send_message', {text,content,message}) 三键
|
||||
// hack 消失了 — 不再猜测每个插件采用哪个参数名。
|
||||
const params: ChannelPermissionRequestParams = {
|
||||
request_id: channelRequestId,
|
||||
tool_name: ctx.tool.name,
|
||||
description,
|
||||
input_preview: truncateForPreview(displayInput),
|
||||
}
|
||||
|
||||
for (const client of channelClients) {
|
||||
if (client.type !== 'connected') continue // 为 TS 细化
|
||||
void client.client
|
||||
.notification({
|
||||
method: CHANNEL_PERMISSION_REQUEST_METHOD,
|
||||
params,
|
||||
})
|
||||
.catch(e => {
|
||||
logForDebugging(
|
||||
`Channel permission_request failed for ${client.name}: ${errorMessage(e)}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const channelSignal = ctx.toolUseContext.abortController.signal
|
||||
// 包装以便在每个调用点同时发生 Map 删除和 abort-listener 拆卸。
|
||||
// 之前在 local/hook/classifier 获胜后的 6 个 channelUnsubscribe?.() 调用点
|
||||
// 只删除了 Map 条目 — 死闭包保持在会话范围的 abort 信号上
|
||||
// 注册,直到会话结束。不是功能性 bug(Map.delete 是
|
||||
// 幂等的),但它保持闭包活着。
|
||||
const mapUnsub = channelCallbacks.onResponse(
|
||||
channelRequestId,
|
||||
response => {
|
||||
if (!claim()) return // 另一个竞争者获胜
|
||||
channelUnsubscribe?.() // 两者:Map 删除 + 监听器移除
|
||||
clearClassifierChecking(ctx.toolUseID)
|
||||
clearClassifierIndicator()
|
||||
ctx.removeFromQueue()
|
||||
// 桥接是另一个远程 — 告诉它我们完成了。
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
|
||||
if (response.behavior === 'allow') {
|
||||
ctx.logDecision(
|
||||
{
|
||||
decision: 'accept',
|
||||
source: { type: 'user', permanent: false },
|
||||
},
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
resolveOnce(ctx.buildAllow(displayInput))
|
||||
} else {
|
||||
ctx.logDecision(
|
||||
{
|
||||
decision: 'reject',
|
||||
source: { type: 'user_reject', hasFeedback: false },
|
||||
},
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
resolveOnce(
|
||||
ctx.cancelAndAbort(`Denied via channel ${response.fromServer}`),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
channelUnsubscribe = () => {
|
||||
mapUnsub()
|
||||
channelSignal.removeEventListener('abort', channelUnsubscribe!)
|
||||
}
|
||||
|
||||
channelSignal.addEventListener('abort', channelUnsubscribe, {
|
||||
once: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 如果在协调器分支中已经等待,则跳过 hooks
|
||||
if (!awaitAutomatedChecksBeforeDialog) {
|
||||
// 异步执行 PermissionRequest hooks
|
||||
// 如果 hook 在用户响应之前返回决策,应用它
|
||||
void (async () => {
|
||||
if (isResolved()) return
|
||||
const currentAppState = ctx.toolUseContext.getAppState()
|
||||
const hookDecision = await ctx.runHooks(
|
||||
currentAppState.toolPermissionContext.mode,
|
||||
result.suggestions,
|
||||
result.updatedInput,
|
||||
permissionPromptStartTimeMs,
|
||||
)
|
||||
if (!hookDecision || !claim()) return
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
channelUnsubscribe?.()
|
||||
ctx.removeFromQueue()
|
||||
resolveOnce(hookDecision)
|
||||
})()
|
||||
}
|
||||
|
||||
// 异步执行 bash 分类器检查(如果适用)
|
||||
if (
|
||||
feature('BASH_CLASSIFIER') &&
|
||||
result.pendingClassifierCheck &&
|
||||
ctx.tool.name === BASH_TOOL_NAME &&
|
||||
!awaitAutomatedChecksBeforeDialog
|
||||
) {
|
||||
// "分类器运行中"的 UI 指示器 — 在这里设置(不在
|
||||
// toolExecution.ts)以便通过前缀规则自动允许的命令
|
||||
// 不会在允许返回前闪烁指示器片刻。
|
||||
setClassifierChecking(ctx.toolUseID)
|
||||
void executeAsyncClassifierCheck(
|
||||
result.pendingClassifierCheck,
|
||||
ctx.toolUseContext.abortController.signal,
|
||||
ctx.toolUseContext.options.isNonInteractiveSession,
|
||||
{
|
||||
shouldContinue: () => !isResolved() && !userInteracted,
|
||||
onComplete: () => {
|
||||
clearClassifierChecking(ctx.toolUseID)
|
||||
clearClassifierIndicator()
|
||||
},
|
||||
onAllow: decisionReason => {
|
||||
if (!claim()) return
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
channelUnsubscribe?.()
|
||||
clearClassifierChecking(ctx.toolUseID)
|
||||
|
||||
const matchedRule =
|
||||
decisionReason.type === 'classifier'
|
||||
? (decisionReason.reason.match(
|
||||
/^Allowed by prompt rule: "(.+)"$/,
|
||||
)?.[1] ?? decisionReason.reason)
|
||||
: undefined
|
||||
|
||||
// 显示自动批准过渡,带暗淡选项
|
||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||||
ctx.updateQueueItem({
|
||||
classifierCheckInProgress: false,
|
||||
classifierAutoApproved: true,
|
||||
classifierMatchedRule: matchedRule,
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
feature('TRANSCRIPT_CLASSIFIER') &&
|
||||
decisionReason.type === 'classifier'
|
||||
) {
|
||||
if (decisionReason.classifier === 'auto-mode') {
|
||||
setYoloClassifierApproval(ctx.toolUseID, decisionReason.reason)
|
||||
} else if (matchedRule) {
|
||||
setClassifierApproval(ctx.toolUseID, matchedRule)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.logDecision(
|
||||
{ decision: 'accept', source: { type: 'classifier' } },
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
resolveOnce(ctx.buildAllow(ctx.input, { decisionReason }))
|
||||
|
||||
// 保持 checkmark 可见,然后移除对话框。
|
||||
// 如果终端聚焦则为 3s(用户可以看到),否则为 1s。
|
||||
// 用户可以通过 onDismissCheckmark 的 Esc 提前关闭。
|
||||
const signal = ctx.toolUseContext.abortController.signal
|
||||
checkmarkAbortHandler = () => {
|
||||
if (checkmarkTransitionTimer) {
|
||||
clearTimeout(checkmarkTransitionTimer)
|
||||
checkmarkTransitionTimer = undefined
|
||||
// 兄弟 Bash 错误可以触发这个(StreamingToolExecutor
|
||||
// 通过 siblingAbortController 级联)— 必须删除
|
||||
// 美容的 ✓ 对话框,否则它会阻止下一个排队的项目。
|
||||
ctx.removeFromQueue()
|
||||
}
|
||||
}
|
||||
const checkmarkMs = getTerminalFocused() ? 3000 : 1000
|
||||
checkmarkTransitionTimer = setTimeout(() => {
|
||||
checkmarkTransitionTimer = undefined
|
||||
if (checkmarkAbortHandler) {
|
||||
signal.removeEventListener('abort', checkmarkAbortHandler)
|
||||
checkmarkAbortHandler = undefined
|
||||
}
|
||||
ctx.removeFromQueue()
|
||||
}, checkmarkMs)
|
||||
signal.addEventListener('abort', checkmarkAbortHandler, {
|
||||
once: true,
|
||||
})
|
||||
},
|
||||
},
|
||||
).catch(error => {
|
||||
// 记录分类器 API 错误以供调试,但不将它们传播为中断。
|
||||
// 这些错误可能是网络故障、速率限制或模型问题 — 不是用户取消。
|
||||
logForDebugging(`Async classifier check failed: ${errorMessage(error)}`, {
|
||||
level: 'error',
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --
|
||||
|
||||
export { handleInteractivePermission }
|
||||
export type { InteractivePermissionParams }
|
||||
@@ -0,0 +1,158 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
||||
import type { PendingClassifierCheck } from '../../../types/permissions.js'
|
||||
import { isAgentSwarmsEnabled } from '../../../utils/agentSwarmsEnabled.js'
|
||||
import { toError } from '../../../utils/errors.js'
|
||||
import { logError } from '../../../utils/log.js'
|
||||
import type { PermissionDecision } from '../../../utils/permissions/PermissionResult.js'
|
||||
import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'
|
||||
import {
|
||||
createPermissionRequest,
|
||||
isSwarmWorker,
|
||||
sendPermissionRequestViaMailbox,
|
||||
} from '../../../utils/swarm/permissionSync.js'
|
||||
import { registerPermissionCallback } from '../../useSwarmPermissionPoller.js'
|
||||
import type { PermissionContext } from '../PermissionContext.js'
|
||||
import { createResolveOnce } from '../PermissionContext.js'
|
||||
|
||||
type SwarmWorkerPermissionParams = {
|
||||
ctx: PermissionContext
|
||||
description: string
|
||||
pendingClassifierCheck?: PendingClassifierCheck | undefined
|
||||
updatedInput: Record<string, unknown> | undefined
|
||||
suggestions: PermissionUpdate[] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 swarm 工作线程权限流程。
|
||||
*
|
||||
* 当作为 swarm 工作线程运行时:
|
||||
* 1. 尝试 bash 命令的分类器自动批准
|
||||
* 2. 通过邮箱将权限请求转发给领导者
|
||||
* 3. 注册领导者响应时的回调
|
||||
* 4. 等待时设置待处理指示器
|
||||
*
|
||||
* 如果分类器自动批准则返回 PermissionDecision,
|
||||
* 或在领导者响应时解析的 Promise。
|
||||
* 如果未启用 swarm 或不是 swarm 工作线程则返回 null,
|
||||
* 以便调用者可以回退到交互式处理。
|
||||
*/
|
||||
async function handleSwarmWorkerPermission(
|
||||
params: SwarmWorkerPermissionParams,
|
||||
): Promise<PermissionDecision | null> {
|
||||
if (!isAgentSwarmsEnabled() || !isSwarmWorker()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { ctx, description, updatedInput, suggestions } = params
|
||||
|
||||
// 对于 bash 命令,在转发给领导者之前尝试分类器自动批准。
|
||||
// 代理等待分类器结果(而不是像主代理那样将其与用户交互竞争)。
|
||||
const classifierResult = feature('BASH_CLASSIFIER')
|
||||
? await ctx.tryClassifier?.(params.pendingClassifierCheck, updatedInput)
|
||||
: null
|
||||
if (classifierResult) {
|
||||
return classifierResult
|
||||
}
|
||||
|
||||
// 通过邮箱将权限请求转发给领导者
|
||||
try {
|
||||
const clearPendingRequest = (): void =>
|
||||
ctx.toolUseContext.setAppState(prev => ({
|
||||
...prev,
|
||||
pendingWorkerRequest: null,
|
||||
}))
|
||||
|
||||
const decision = await new Promise<PermissionDecision>(resolve => {
|
||||
const { resolve: resolveOnce, claim } = createResolveOnce(resolve)
|
||||
|
||||
// 创建权限请求
|
||||
const request = createPermissionRequest({
|
||||
toolName: ctx.tool.name,
|
||||
toolUseId: ctx.toolUseID,
|
||||
input: ctx.input,
|
||||
description,
|
||||
permissionSuggestions: suggestions,
|
||||
})
|
||||
|
||||
// 在发送请求之前注册回调,以避免领导者
|
||||
// 在注册回调之前响应这种竞争条件
|
||||
registerPermissionCallback({
|
||||
requestId: request.id,
|
||||
toolUseId: ctx.toolUseID,
|
||||
async onAllow(
|
||||
allowedInput: Record<string, unknown> | undefined,
|
||||
permissionUpdates: PermissionUpdate[],
|
||||
feedback?: string,
|
||||
contentBlocks?: ContentBlockParam[],
|
||||
) {
|
||||
if (!claim()) return // 在 await 之前的原子性检查和标记
|
||||
clearPendingRequest()
|
||||
|
||||
// 将更新的输入与原始输入合并
|
||||
const finalInput =
|
||||
allowedInput && Object.keys(allowedInput).length > 0
|
||||
? allowedInput
|
||||
: ctx.input
|
||||
|
||||
resolveOnce(
|
||||
await ctx.handleUserAllow(
|
||||
finalInput,
|
||||
permissionUpdates,
|
||||
feedback,
|
||||
undefined,
|
||||
contentBlocks,
|
||||
),
|
||||
)
|
||||
},
|
||||
onReject(feedback?: string, contentBlocks?: ContentBlockParam[]) {
|
||||
if (!claim()) return
|
||||
clearPendingRequest()
|
||||
|
||||
ctx.logDecision({
|
||||
decision: 'reject',
|
||||
source: { type: 'user_reject', hasFeedback: !!feedback },
|
||||
})
|
||||
|
||||
resolveOnce(ctx.cancelAndAbort(feedback, undefined, contentBlocks))
|
||||
},
|
||||
})
|
||||
|
||||
// 现在回调已注册,发送请求给领导者
|
||||
void sendPermissionRequestViaMailbox(request)
|
||||
|
||||
// 显示我们正在等待领导者批准的视觉指示器
|
||||
ctx.toolUseContext.setAppState(prev => ({
|
||||
...prev,
|
||||
pendingWorkerRequest: {
|
||||
toolName: ctx.tool.name,
|
||||
toolUseId: ctx.toolUseID,
|
||||
description,
|
||||
},
|
||||
}))
|
||||
|
||||
// 如果在等待领导者响应时中止信号触发,
|
||||
// 用取消决策解析 promise 以免挂起。
|
||||
ctx.toolUseContext.abortController.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
if (!claim()) return
|
||||
clearPendingRequest()
|
||||
ctx.logCancelled()
|
||||
resolveOnce(ctx.cancelAndAbort(undefined, true))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
|
||||
return decision
|
||||
} catch (error) {
|
||||
// 如果 swarm 权限提交失败,回退到本地处理
|
||||
logError(toError(error))
|
||||
// 继续到下面的本地 UI 处理
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export { handleSwarmWorkerPermission }
|
||||
export type { SwarmWorkerPermissionParams }
|
||||
238
claude-code源码-中文注释/src/hooks/toolPermission/permissionLogging.ts
Normal file
238
claude-code源码-中文注释/src/hooks/toolPermission/permissionLogging.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
// 集中化的分析/遥测日志记录,用于工具权限决策。
|
||||
// 所有权限批准/拒绝事件都通过 logPermissionDecision() 流动,
|
||||
// 它会分叉到 Statsig 分析、OTel 遥测和代码编辑指标。
|
||||
import { feature } from 'bun:bundle'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js'
|
||||
import { getCodeEditToolDecisionCounter } from '../../bootstrap/state.js'
|
||||
import type { Tool as ToolType, ToolUseContext } from '../../Tool.js'
|
||||
import { getLanguageName } from '../../utils/cliHighlight.js'
|
||||
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
|
||||
import { logOTelEvent } from '../../utils/telemetry/events.js'
|
||||
import type {
|
||||
PermissionApprovalSource,
|
||||
PermissionRejectionSource,
|
||||
} from './PermissionContext.js'
|
||||
|
||||
type PermissionLogContext = {
|
||||
tool: ToolType
|
||||
input: unknown
|
||||
toolUseContext: ToolUseContext
|
||||
messageId: string
|
||||
toolUseID: string
|
||||
}
|
||||
|
||||
// 判别联合:'accept' 与批准来源配对,'reject' 与拒绝来源配对
|
||||
type PermissionDecisionArgs =
|
||||
| { decision: 'accept'; source: PermissionApprovalSource | 'config' }
|
||||
| { decision: 'reject'; source: PermissionRejectionSource | 'config' }
|
||||
|
||||
const CODE_EDITING_TOOLS = ['Edit', 'Write', 'NotebookEdit']
|
||||
|
||||
function isCodeEditingTool(toolName: string): boolean {
|
||||
return CODE_EDITING_TOOLS.includes(toolName)
|
||||
}
|
||||
|
||||
// 为代码编辑工具构建 OTel 计数器属性,并在可以从输入提取工具目标文件路径时
|
||||
// 用语言信息丰富
|
||||
async function buildCodeEditToolAttributes(
|
||||
tool: ToolType,
|
||||
input: unknown,
|
||||
decision: 'accept' | 'reject',
|
||||
source: string,
|
||||
): Promise<Record<string, string>> {
|
||||
// 如果工具暴露了文件路径(Edit, Write),则从文件路径派生语言
|
||||
let language: string | undefined
|
||||
if (tool.getPath && input) {
|
||||
const parseResult = tool.inputSchema.safeParse(input)
|
||||
if (parseResult.success) {
|
||||
const filePath = tool.getPath(parseResult.data)
|
||||
if (filePath) {
|
||||
language = await getLanguageName(filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
decision,
|
||||
source,
|
||||
tool_name: tool.name,
|
||||
...(language && { language }),
|
||||
}
|
||||
}
|
||||
|
||||
// 将结构化来源展平为分析/OTel 事件的字符串标签
|
||||
function sourceToString(
|
||||
source: PermissionApprovalSource | PermissionRejectionSource,
|
||||
): string {
|
||||
if (
|
||||
(feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) &&
|
||||
source.type === 'classifier'
|
||||
) {
|
||||
return 'classifier'
|
||||
}
|
||||
switch (source.type) {
|
||||
case 'hook':
|
||||
return 'hook'
|
||||
case 'user':
|
||||
return source.permanent ? 'user_permanent' : 'user_temporary'
|
||||
case 'user_abort':
|
||||
return 'user_abort'
|
||||
case 'user_reject':
|
||||
return 'user_reject'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
function baseMetadata(
|
||||
messageId: string,
|
||||
toolName: string,
|
||||
waitMs: number | undefined,
|
||||
): { [key: string]: boolean | number | undefined } {
|
||||
return {
|
||||
messageID:
|
||||
messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
toolName: sanitizeToolNameForAnalytics(toolName),
|
||||
sandboxEnabled: SandboxManager.isSandboxingEnabled(),
|
||||
// 只在用户实际被提示时包含等待时间(不是自动批准)
|
||||
...(waitMs !== undefined && { waiting_for_user_permission_ms: waitMs }),
|
||||
}
|
||||
}
|
||||
|
||||
// 为漏斗分析发出每个批准来源的独特分析事件名称
|
||||
function logApprovalEvent(
|
||||
tool: ToolType,
|
||||
messageId: string,
|
||||
source: PermissionApprovalSource | 'config',
|
||||
waitMs: number | undefined,
|
||||
): void {
|
||||
if (source === 'config') {
|
||||
// 通过设置中的允许列表自动批准 - 无用户等待时间
|
||||
logEvent(
|
||||
'tengu_tool_use_granted_in_config',
|
||||
baseMetadata(messageId, tool.name, undefined),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (
|
||||
(feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) &&
|
||||
source.type === 'classifier'
|
||||
) {
|
||||
logEvent(
|
||||
'tengu_tool_use_granted_by_classifier',
|
||||
baseMetadata(messageId, tool.name, waitMs),
|
||||
)
|
||||
return
|
||||
}
|
||||
switch (source.type) {
|
||||
case 'user':
|
||||
logEvent(
|
||||
source.permanent
|
||||
? 'tengu_tool_use_granted_in_prompt_permanent'
|
||||
: 'tengu_tool_use_granted_in_prompt_temporary',
|
||||
baseMetadata(messageId, tool.name, waitMs),
|
||||
)
|
||||
break
|
||||
case 'hook':
|
||||
logEvent('tengu_tool_use_granted_by_permission_hook', {
|
||||
...baseMetadata(messageId, tool.name, waitMs),
|
||||
permanent: source.permanent ?? false,
|
||||
})
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 拒绝共享一个事件名称,通过元数字段区分
|
||||
function logRejectionEvent(
|
||||
tool: ToolType,
|
||||
messageId: string,
|
||||
source: PermissionRejectionSource | 'config',
|
||||
waitMs: number | undefined,
|
||||
): void {
|
||||
if (source === 'config') {
|
||||
// 被设置中的拒绝列表拒绝
|
||||
logEvent(
|
||||
'tengu_tool_use_denied_in_config',
|
||||
baseMetadata(messageId, tool.name, undefined),
|
||||
)
|
||||
return
|
||||
}
|
||||
logEvent('tengu_tool_use_rejected_in_prompt', {
|
||||
...baseMetadata(messageId, tool.name, waitMs),
|
||||
// 通过单独字段区分 hook 拒绝和用户拒绝
|
||||
...(source.type === 'hook'
|
||||
? { isHook: true }
|
||||
: {
|
||||
hasFeedback:
|
||||
source.type === 'user_reject' ? source.hasFeedback : false,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// 所有权限决策日志记录的单一入口点。在每次批准/拒绝后由权限
|
||||
// 处理程序调用。分叉到:分析事件、OTel 遥测、代码编辑 OTel 计数器
|
||||
// 和 toolUseContext 决策存储。
|
||||
function logPermissionDecision(
|
||||
ctx: PermissionLogContext,
|
||||
args: PermissionDecisionArgs,
|
||||
permissionPromptStartTimeMs?: number,
|
||||
): void {
|
||||
const { tool, input, toolUseContext, messageId, toolUseID } = ctx
|
||||
const { decision, source } = args
|
||||
|
||||
const waiting_for_user_permission_ms =
|
||||
permissionPromptStartTimeMs !== undefined
|
||||
? Date.now() - permissionPromptStartTimeMs
|
||||
: undefined
|
||||
|
||||
// 记录分析事件
|
||||
if (args.decision === 'accept') {
|
||||
logApprovalEvent(
|
||||
tool,
|
||||
messageId,
|
||||
args.source,
|
||||
waiting_for_user_permission_ms,
|
||||
)
|
||||
} else {
|
||||
logRejectionEvent(
|
||||
tool,
|
||||
messageId,
|
||||
args.source,
|
||||
waiting_for_user_permission_ms,
|
||||
)
|
||||
}
|
||||
|
||||
const sourceString = source === 'config' ? 'config' : sourceToString(source)
|
||||
|
||||
// 跟踪代码编辑工具指标
|
||||
if (isCodeEditingTool(tool.name)) {
|
||||
void buildCodeEditToolAttributes(tool, input, decision, sourceString).then(
|
||||
attributes => getCodeEditToolDecisionCounter()?.add(1, attributes),
|
||||
)
|
||||
}
|
||||
|
||||
// 在上下文上持久化决策,以便下游代码可以检查发生了什么
|
||||
if (!toolUseContext.toolDecisions) {
|
||||
toolUseContext.toolDecisions = new Map()
|
||||
}
|
||||
toolUseContext.toolDecisions.set(toolUseID, {
|
||||
source: sourceString,
|
||||
decision,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
void logOTelEvent('tool_decision', {
|
||||
decision,
|
||||
source: sourceString,
|
||||
tool_name: sanitizeToolNameForAnalytics(tool.name),
|
||||
})
|
||||
}
|
||||
|
||||
export { isCodeEditingTool, buildCodeEditToolAttributes, logPermissionDecision }
|
||||
export type { PermissionLogContext, PermissionDecisionArgs }
|
||||
202
claude-code源码-中文注释/src/hooks/unifiedSuggestions.ts
Normal file
202
claude-code源码-中文注释/src/hooks/unifiedSuggestions.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import Fuse from 'fuse.js'
|
||||
import { basename } from 'path'
|
||||
import type { SuggestionItem } from 'src/components/PromptInput/PromptInputFooterSuggestions.js'
|
||||
import { generateFileSuggestions } from 'src/hooks/fileSuggestions.js'
|
||||
import type { ServerResource } from 'src/services/mcp/types.js'
|
||||
import { getAgentColor } from 'src/tools/AgentTool/agentColorManager.js'
|
||||
import type { AgentDefinition } from 'src/tools/AgentTool/loadAgentsDir.js'
|
||||
import { truncateToWidth } from 'src/utils/format.js'
|
||||
import { logError } from 'src/utils/log.js'
|
||||
import type { Theme } from 'src/utils/theme.js'
|
||||
|
||||
type FileSuggestionSource = {
|
||||
type: 'file'
|
||||
displayText: string
|
||||
description?: string
|
||||
path: string
|
||||
filename: string
|
||||
score?: number
|
||||
}
|
||||
|
||||
type McpResourceSuggestionSource = {
|
||||
type: 'mcp_resource'
|
||||
displayText: string
|
||||
description: string
|
||||
server: string
|
||||
uri: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type AgentSuggestionSource = {
|
||||
type: 'agent'
|
||||
displayText: string
|
||||
description: string
|
||||
agentType: string
|
||||
color?: keyof Theme
|
||||
}
|
||||
|
||||
type SuggestionSource =
|
||||
| FileSuggestionSource
|
||||
| McpResourceSuggestionSource
|
||||
| AgentSuggestionSource
|
||||
|
||||
/**
|
||||
* 从源创建统一的建议项目
|
||||
*/
|
||||
function createSuggestionFromSource(source: SuggestionSource): SuggestionItem {
|
||||
switch (source.type) {
|
||||
case 'file':
|
||||
return {
|
||||
id: `file-${source.path}`,
|
||||
displayText: source.displayText,
|
||||
description: source.description,
|
||||
}
|
||||
case 'mcp_resource':
|
||||
return {
|
||||
id: `mcp-resource-${source.server}__${source.uri}`,
|
||||
displayText: source.displayText,
|
||||
description: source.description,
|
||||
}
|
||||
case 'agent':
|
||||
return {
|
||||
id: `agent-${source.agentType}`,
|
||||
displayText: source.displayText,
|
||||
description: source.description,
|
||||
color: source.color,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_UNIFIED_SUGGESTIONS = 15
|
||||
const DESCRIPTION_MAX_LENGTH = 60
|
||||
|
||||
function truncateDescription(description: string): string {
|
||||
return truncateToWidth(description, DESCRIPTION_MAX_LENGTH)
|
||||
}
|
||||
|
||||
function generateAgentSuggestions(
|
||||
agents: AgentDefinition[],
|
||||
query: string,
|
||||
showOnEmpty = false,
|
||||
): AgentSuggestionSource[] {
|
||||
if (!query && !showOnEmpty) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const agentSources: AgentSuggestionSource[] = agents.map(agent => ({
|
||||
type: 'agent' as const,
|
||||
displayText: `${agent.agentType} (agent)`,
|
||||
description: truncateDescription(agent.whenToUse),
|
||||
agentType: agent.agentType,
|
||||
color: getAgentColor(agent.agentType),
|
||||
}))
|
||||
|
||||
if (!query) {
|
||||
return agentSources
|
||||
}
|
||||
|
||||
const queryLower = query.toLowerCase()
|
||||
return agentSources.filter(
|
||||
agent =>
|
||||
agent.agentType.toLowerCase().includes(queryLower) ||
|
||||
agent.displayText.toLowerCase().includes(queryLower),
|
||||
)
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateUnifiedSuggestions(
|
||||
query: string,
|
||||
mcpResources: Record<string, ServerResource[]>,
|
||||
agents: AgentDefinition[],
|
||||
showOnEmpty = false,
|
||||
): Promise<SuggestionItem[]> {
|
||||
if (!query && !showOnEmpty) {
|
||||
return []
|
||||
}
|
||||
|
||||
const [fileSuggestions, agentSources] = await Promise.all([
|
||||
generateFileSuggestions(query, showOnEmpty),
|
||||
Promise.resolve(generateAgentSuggestions(agents, query, showOnEmpty)),
|
||||
])
|
||||
|
||||
const fileSources: FileSuggestionSource[] = fileSuggestions.map(
|
||||
suggestion => ({
|
||||
type: 'file' as const,
|
||||
displayText: suggestion.displayText,
|
||||
description: suggestion.description,
|
||||
path: suggestion.displayText, // Use displayText as path for files
|
||||
filename: basename(suggestion.displayText),
|
||||
score: (suggestion.metadata as { score?: number } | undefined)?.score,
|
||||
}),
|
||||
)
|
||||
|
||||
const mcpSources: McpResourceSuggestionSource[] = Object.values(mcpResources)
|
||||
.flat()
|
||||
.map(resource => ({
|
||||
type: 'mcp_resource' as const,
|
||||
displayText: `${resource.server}:${resource.uri}`,
|
||||
description: truncateDescription(
|
||||
resource.description || resource.name || resource.uri,
|
||||
),
|
||||
server: resource.server,
|
||||
uri: resource.uri,
|
||||
name: resource.name || resource.uri,
|
||||
}))
|
||||
|
||||
if (!query) {
|
||||
const allSources = [...fileSources, ...mcpSources, ...agentSources]
|
||||
return allSources
|
||||
.slice(0, MAX_UNIFIED_SUGGESTIONS)
|
||||
.map(createSuggestionFromSource)
|
||||
}
|
||||
|
||||
const nonFileSources: SuggestionSource[] = [...mcpSources, ...agentSources]
|
||||
|
||||
// 使用 Fuse.js 对非文件源进行评分
|
||||
// 文件源已经由 Rust/nucleo 评分
|
||||
type ScoredSource = { source: SuggestionSource; score: number }
|
||||
const scoredResults: ScoredSource[] = []
|
||||
|
||||
// 添加具有 nucleo 分数的文件源(已经是 0-1,越低越好)
|
||||
for (const fileSource of fileSources) {
|
||||
scoredResults.push({
|
||||
source: fileSource,
|
||||
score: fileSource.score ?? 0.5, // 如果缺失默认为中间分数
|
||||
})
|
||||
}
|
||||
|
||||
// 使用 Fuse.js 对非文件源进行评分并添加
|
||||
if (nonFileSources.length > 0) {
|
||||
const fuse = new Fuse(nonFileSources, {
|
||||
includeScore: true,
|
||||
threshold: 0.6, // 允许更多匹配通过,我们将按分数排序
|
||||
keys: [
|
||||
{ name: 'displayText', weight: 2 },
|
||||
{ name: 'name', weight: 3 },
|
||||
{ name: 'server', weight: 1 },
|
||||
{ name: 'description', weight: 1 },
|
||||
{ name: 'agentType', weight: 3 },
|
||||
],
|
||||
})
|
||||
|
||||
const fuseResults = fuse.search(query, { limit: MAX_UNIFIED_SUGGESTIONS })
|
||||
for (const result of fuseResults) {
|
||||
scoredResults.push({
|
||||
source: result.item,
|
||||
score: result.score ?? 0.5,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 按分数排序所有结果(越低越好)并返回顶部结果
|
||||
scoredResults.sort((a, b) => a.score - b.score)
|
||||
|
||||
return scoredResults
|
||||
.slice(0, MAX_UNIFIED_SUGGESTIONS)
|
||||
.map(r => r.source)
|
||||
.map(createSuggestionFromSource)
|
||||
}
|
||||
22
claude-code源码-中文注释/src/hooks/useAfterFirstRender.ts
Normal file
22
claude-code源码-中文注释/src/hooks/useAfterFirstRender.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react'
|
||||
import { isEnvTruthy } from '../utils/envUtils.js'
|
||||
|
||||
/**
|
||||
* 在首次渲染后执行的 Hook。
|
||||
* 用于 ANT 用户在 CLAUDE_CODE_EXIT_AFTER_FIRST_RENDER 环境变量设置时
|
||||
* 测量启动时间并退出进程。
|
||||
*/
|
||||
export function useAfterFirstRender(): void {
|
||||
useEffect(() => {
|
||||
if (
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_EXIT_AFTER_FIRST_RENDER)
|
||||
) {
|
||||
process.stderr.write(
|
||||
`\nStartup time: ${Math.round(process.uptime() * 1000)}ms\n`,
|
||||
)
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(0)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
87
claude-code源码-中文注释/src/hooks/useApiKeyVerification.ts
Normal file
87
claude-code源码-中文注释/src/hooks/useApiKeyVerification.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { getIsNonInteractiveSession } from '../bootstrap/state.js'
|
||||
import { verifyApiKey } from '../services/api/claude.js'
|
||||
import {
|
||||
getAnthropicApiKeyWithSource,
|
||||
getApiKeyFromApiKeyHelper,
|
||||
isAnthropicAuthEnabled,
|
||||
isClaudeAISubscriber,
|
||||
} from '../utils/auth.js'
|
||||
|
||||
export type VerificationStatus =
|
||||
| 'loading'
|
||||
| 'valid'
|
||||
| 'invalid'
|
||||
| 'missing'
|
||||
| 'error'
|
||||
|
||||
export type ApiKeyVerificationResult = {
|
||||
status: VerificationStatus
|
||||
reverify: () => Promise<void>
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 API Key 状态的 Hook。
|
||||
* 检查 isAnthropicAuthEnabled 和 isClaudeAISubscriber 来确定初始状态。
|
||||
* 使用 skipRetrievingKeyFromApiKeyHelper 避免在信任对话框显示前执行 apiKeyHelper(安全考虑:防止通过 settings.json 实现 RCE)。
|
||||
*/
|
||||
export function useApiKeyVerification(): ApiKeyVerificationResult {
|
||||
const [status, setStatus] = useState<VerificationStatus>(() => {
|
||||
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {
|
||||
return 'valid'
|
||||
}
|
||||
// 使用 skipRetrievingKeyFromApiKeyHelper 避免在信任对话框显示前执行 apiKeyHelper
|
||||
// (安全考虑:防止通过 settings.json 实现 RCE)
|
||||
const { key, source } = getAnthropicApiKeyWithSource({
|
||||
skipRetrievingKeyFromApiKeyHelper: true,
|
||||
})
|
||||
// 如果配置了 apiKeyHelper,即使尚未执行也返回 'loading'
|
||||
if (key || source === 'apiKeyHelper') {
|
||||
return 'loading'
|
||||
}
|
||||
return 'missing'
|
||||
})
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const verify = useCallback(async (): Promise<void> => {
|
||||
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {
|
||||
setStatus('valid')
|
||||
return
|
||||
}
|
||||
// 预热 apiKeyHelper 缓存(如果未配置则无操作),然后从所有来源读取
|
||||
// getAnthropicApiKeyWithSource() 从已预热的缓存中读取
|
||||
await getApiKeyFromApiKeyHelper(getIsNonInteractiveSession())
|
||||
const { key: apiKey, source } = getAnthropicApiKeyWithSource()
|
||||
if (!apiKey) {
|
||||
if (source === 'apiKeyHelper') {
|
||||
setStatus('error')
|
||||
setError(new Error('API key helper did not return a valid key'))
|
||||
return
|
||||
}
|
||||
const newStatus = 'missing'
|
||||
setStatus(newStatus)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const isValid = await verifyApiKey(apiKey, false)
|
||||
const newStatus = isValid ? 'valid' : 'invalid'
|
||||
setStatus(newStatus)
|
||||
return
|
||||
} catch (error) {
|
||||
// 当 API 返回错误响应但不是无效 API key 错误时触发
|
||||
// 此时仍将 API key 标记为无效,但同时记录错误以便向用户显示更多信息
|
||||
setError(error as Error)
|
||||
const newStatus = 'error'
|
||||
setStatus(newStatus)
|
||||
return
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
status,
|
||||
reverify: verify,
|
||||
error,
|
||||
}
|
||||
}
|
||||
249
claude-code源码-中文注释/src/hooks/useAssistantHistory.ts
Normal file
249
claude-code源码-中文注释/src/hooks/useAssistantHistory.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import {
|
||||
createHistoryAuthCtx,
|
||||
fetchLatestEvents,
|
||||
fetchOlderEvents,
|
||||
type HistoryAuthCtx,
|
||||
type HistoryPage,
|
||||
} from '../assistant/sessionHistory.js'
|
||||
import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'
|
||||
import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js'
|
||||
import { convertSDKMessage } from '../remote/sdkMessageAdapter.js'
|
||||
import type { Message, SystemInformationalMessage } from '../types/message.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
|
||||
type Props = {
|
||||
/** Gated on viewerOnly — non-viewer sessions have no remote history to page. */
|
||||
config: RemoteSessionConfig | undefined
|
||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>
|
||||
scrollRef: RefObject<ScrollBoxHandle | null>
|
||||
/** Called after prepend from the layout effect with message count + height
|
||||
* delta. Lets useUnseenDivider shift dividerIndex + dividerYRef. */
|
||||
onPrepend?: (indexDelta: number, heightDelta: number) => void
|
||||
}
|
||||
|
||||
type Result = {
|
||||
/** Trigger for ScrollKeybindingHandler's onScroll composition. */
|
||||
maybeLoadOlder: (handle: ScrollBoxHandle) => void
|
||||
}
|
||||
|
||||
/** Fire loadOlder when scrolled within this many rows of the top. */
|
||||
const PREFETCH_THRESHOLD_ROWS = 40
|
||||
|
||||
/** Max chained page loads to fill the viewport on mount. Bounds the loop if
|
||||
* events convert to zero visible messages (everything filtered). */
|
||||
const MAX_FILL_PAGES = 10
|
||||
|
||||
const SENTINEL_LOADING = 'loading older messages…'
|
||||
const SENTINEL_LOADING_FAILED =
|
||||
'failed to load older messages — scroll up to retry'
|
||||
const SENTINEL_START = 'start of session'
|
||||
|
||||
/** Convert a HistoryPage to REPL Message[] using the same opts as viewer mode. */
|
||||
function pageToMessages(page: HistoryPage): Message[] {
|
||||
const out: Message[] = []
|
||||
for (const ev of page.events) {
|
||||
const c = convertSDKMessage(ev, {
|
||||
convertUserTextMessages: true,
|
||||
convertToolResults: true,
|
||||
})
|
||||
if (c.type === 'message') out.push(c.message)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 在向上滚动时懒加载 `claude assistant` 历史。
|
||||
*
|
||||
* 挂载时:通过 anchor_to_latest 获取最新页面,预置到消息。
|
||||
* 向上滚动到顶部附近时:通过 before_id 获取下一个较旧的页面,预置并滚动锚定(视口保持不动)。
|
||||
*
|
||||
* 除非 config.viewerOnly 否则无操作。REPL 仅在 feature('KAIROS') 门控内调用此 hook,
|
||||
* 因此构建时消除在那里处理。
|
||||
*/
|
||||
export function useAssistantHistory({
|
||||
config,
|
||||
setMessages,
|
||||
scrollRef,
|
||||
onPrepend,
|
||||
}: Props): Result {
|
||||
const enabled = config?.viewerOnly === true
|
||||
|
||||
// Cursor state: ref-only (no re-render on cursor change). `null` = no
|
||||
// older pages. `undefined` = initial page not fetched yet.
|
||||
const cursorRef = useRef<string | null | undefined>(undefined)
|
||||
const ctxRef = useRef<HistoryAuthCtx | null>(null)
|
||||
const inflightRef = useRef(false)
|
||||
|
||||
// Scroll-anchor: snapshot height + prepended count before setMessages;
|
||||
// compensate in useLayoutEffect after React commits. getFreshScrollHeight
|
||||
// reads Yoga directly so the value is correct post-commit.
|
||||
const anchorRef = useRef<{ beforeHeight: number; count: number } | null>(null)
|
||||
|
||||
// Fill-viewport chaining: after the initial page commits, if content doesn't
|
||||
// fill the viewport yet, load another page. Self-chains via the layout effect
|
||||
// until filled or the budget runs out. Budget set once on initial load; user
|
||||
// scroll-ups don't need it (maybeLoadOlder re-fires on next wheel event).
|
||||
const fillBudgetRef = useRef(0)
|
||||
|
||||
// Stable sentinel UUID — reused across swaps so virtual-scroll treats it
|
||||
// as one item (text-only mutation, not remove+insert).
|
||||
const sentinelUuidRef = useRef(randomUUID())
|
||||
|
||||
function mkSentinel(text: string): SystemInformationalMessage {
|
||||
return {
|
||||
type: 'system',
|
||||
subtype: 'informational',
|
||||
content: text,
|
||||
isMeta: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
uuid: sentinelUuidRef.current,
|
||||
level: 'info',
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepend a page at the front, with scroll-anchor snapshot for non-initial.
|
||||
* Replaces the sentinel (always at index 0 when present) in-place. */
|
||||
const prepend = useCallback(
|
||||
(page: HistoryPage, isInitial: boolean) => {
|
||||
const msgs = pageToMessages(page)
|
||||
cursorRef.current = page.hasMore ? page.firstId : null
|
||||
|
||||
if (!isInitial) {
|
||||
const s = scrollRef.current
|
||||
anchorRef.current = s
|
||||
? { beforeHeight: s.getFreshScrollHeight(), count: msgs.length }
|
||||
: null
|
||||
}
|
||||
|
||||
const sentinel = page.hasMore ? null : mkSentinel(SENTINEL_START)
|
||||
setMessages(prev => {
|
||||
// Drop existing sentinel (index 0, known stable UUID — O(1)).
|
||||
const base =
|
||||
prev[0]?.uuid === sentinelUuidRef.current ? prev.slice(1) : prev
|
||||
return sentinel ? [sentinel, ...msgs, ...base] : [...msgs, ...base]
|
||||
})
|
||||
|
||||
logForDebugging(
|
||||
`[useAssistantHistory] ${isInitial ? 'initial' : 'older'} page: ${msgs.length} msgs (raw ${page.events.length}), hasMore=${page.hasMore}`,
|
||||
)
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- scrollRef is a stable ref; mkSentinel reads refs only
|
||||
[setMessages],
|
||||
)
|
||||
|
||||
// Initial fetch on mount — best-effort.
|
||||
useEffect(() => {
|
||||
if (!enabled || !config) return
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
const ctx = await createHistoryAuthCtx(config.sessionId).catch(() => null)
|
||||
if (!ctx || cancelled) return
|
||||
ctxRef.current = ctx
|
||||
const page = await fetchLatestEvents(ctx)
|
||||
if (cancelled || !page) return
|
||||
fillBudgetRef.current = MAX_FILL_PAGES
|
||||
prepend(page, true)
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// config identity is stable (created once in main.tsx, never recreated)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled])
|
||||
|
||||
const loadOlder = useCallback(async () => {
|
||||
if (!enabled || inflightRef.current) return
|
||||
const cursor = cursorRef.current
|
||||
const ctx = ctxRef.current
|
||||
if (!cursor || !ctx) return // null=exhausted, undefined=initial pending
|
||||
inflightRef.current = true
|
||||
// Swap sentinel to "loading…" — O(1) slice since sentinel is at index 0.
|
||||
setMessages(prev => {
|
||||
const base =
|
||||
prev[0]?.uuid === sentinelUuidRef.current ? prev.slice(1) : prev
|
||||
return [mkSentinel(SENTINEL_LOADING), ...base]
|
||||
})
|
||||
try {
|
||||
const page = await fetchOlderEvents(ctx, cursor)
|
||||
if (!page) {
|
||||
// Fetch failed — revert sentinel back to "start" placeholder so the user
|
||||
// can retry on next scroll-up. Cursor is preserved (not nulled out).
|
||||
setMessages(prev => {
|
||||
const base =
|
||||
prev[0]?.uuid === sentinelUuidRef.current ? prev.slice(1) : prev
|
||||
return [mkSentinel(SENTINEL_LOADING_FAILED), ...base]
|
||||
})
|
||||
return
|
||||
}
|
||||
prepend(page, false)
|
||||
} finally {
|
||||
inflightRef.current = false
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mkSentinel reads refs only
|
||||
}, [enabled, prepend, setMessages])
|
||||
|
||||
// Scroll-anchor compensation — after React commits the prepended items,
|
||||
// shift scrollTop by the height delta so the viewport stays put. Also
|
||||
// fire onPrepend here (not in prepend()) so dividerIndex + baseline ref
|
||||
// are shifted with the ACTUAL height delta, not an estimate.
|
||||
// No deps: runs every render; cheap no-op when anchorRef is null.
|
||||
useLayoutEffect(() => {
|
||||
const anchor = anchorRef.current
|
||||
if (anchor === null) return
|
||||
anchorRef.current = null
|
||||
const s = scrollRef.current
|
||||
if (!s || s.isSticky()) return // sticky = pinned bottom; prepend is invisible
|
||||
const delta = s.getFreshScrollHeight() - anchor.beforeHeight
|
||||
if (delta > 0) s.scrollBy(delta)
|
||||
onPrepend?.(anchor.count, delta)
|
||||
})
|
||||
|
||||
// Fill-viewport chain: after paint, if content doesn't exceed the viewport,
|
||||
// load another page. Runs as useEffect (not layout effect) so Ink has
|
||||
// painted and scrollViewportHeight is populated. Self-chains via next
|
||||
// render's effect; budget caps the chain.
|
||||
//
|
||||
// The ScrollBox content wrapper has flexGrow:1 flexShrink:0 — it's clamped
|
||||
// to ≥ viewport. So `content < viewport` is never true; `<=` detects "no
|
||||
// overflow yet" correctly. Stops once there's at least something to scroll.
|
||||
useEffect(() => {
|
||||
if (
|
||||
fillBudgetRef.current <= 0 ||
|
||||
!cursorRef.current ||
|
||||
inflightRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
const s = scrollRef.current
|
||||
if (!s) return
|
||||
const contentH = s.getFreshScrollHeight()
|
||||
const viewH = s.getViewportHeight()
|
||||
logForDebugging(
|
||||
`[useAssistantHistory] fill-check: content=${contentH} viewport=${viewH} budget=${fillBudgetRef.current}`,
|
||||
)
|
||||
if (contentH <= viewH) {
|
||||
fillBudgetRef.current--
|
||||
void loadOlder()
|
||||
} else {
|
||||
fillBudgetRef.current = 0
|
||||
}
|
||||
})
|
||||
|
||||
// Trigger wrapper for onScroll composition in REPL.
|
||||
const maybeLoadOlder = useCallback(
|
||||
(handle: ScrollBoxHandle) => {
|
||||
if (handle.getScrollTop() < PREFETCH_THRESHOLD_ROWS) void loadOlder()
|
||||
},
|
||||
[loadOlder],
|
||||
)
|
||||
|
||||
return { maybeLoadOlder }
|
||||
}
|
||||
125
claude-code源码-中文注释/src/hooks/useAwaySummary.ts
Normal file
125
claude-code源码-中文注释/src/hooks/useAwaySummary.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
getTerminalFocusState,
|
||||
subscribeTerminalFocus,
|
||||
} from '../ink/terminal-focus-state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
||||
import { generateAwaySummary } from '../services/awaySummary.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import { createAwaySummaryMessage } from '../utils/messages.js'
|
||||
|
||||
const BLUR_DELAY_MS = 5 * 60_000
|
||||
|
||||
type SetMessages = (updater: (prev: Message[]) => Message[]) => void
|
||||
|
||||
function hasSummarySinceLastUserTurn(messages: readonly Message[]): boolean {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!
|
||||
if (m.type === 'user' && !m.isMeta && !m.isCompactSummary) return false
|
||||
if (m.type === 'system' && m.subtype === 'away_summary') return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 在终端失焦 5 分钟后追加"离开期间摘要"消息。
|
||||
* 仅在以下条件满足时触发:(a) 失焦后 5 分钟,(b) 当前无进行中的对话,
|
||||
* (c) 自上次用户消息以来没有已有的 away_summary。
|
||||
*
|
||||
* Focus state 为 'unknown'(终端不支持 DECSET 1004)时无操作。
|
||||
*/
|
||||
export function useAwaySummary(
|
||||
messages: readonly Message[],
|
||||
setMessages: SetMessages,
|
||||
isLoading: boolean,
|
||||
): void {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const messagesRef = useRef(messages)
|
||||
const isLoadingRef = useRef(isLoading)
|
||||
const pendingRef = useRef(false)
|
||||
const generateRef = useRef<(() => Promise<void>) | null>(null)
|
||||
|
||||
messagesRef.current = messages
|
||||
isLoadingRef.current = isLoading
|
||||
|
||||
// 3P 默认值: false
|
||||
const gbEnabled = getFeatureValue_CACHED_MAY_BE_STALE(
|
||||
'tengu_sedge_lantern',
|
||||
false,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!feature('AWAY_SUMMARY')) return
|
||||
if (!gbEnabled) return
|
||||
|
||||
function clearTimer(): void {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
function abortInFlight(): void {
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
}
|
||||
|
||||
async function generate(): Promise<void> {
|
||||
pendingRef.current = false
|
||||
if (hasSummarySinceLastUserTurn(messagesRef.current)) return
|
||||
abortInFlight()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const text = await generateAwaySummary(
|
||||
messagesRef.current,
|
||||
controller.signal,
|
||||
)
|
||||
if (controller.signal.aborted || text === null) return
|
||||
setMessages(prev => [...prev, createAwaySummaryMessage(text)])
|
||||
}
|
||||
|
||||
function onBlurTimerFire(): void {
|
||||
timerRef.current = null
|
||||
if (isLoadingRef.current) {
|
||||
pendingRef.current = true
|
||||
return
|
||||
}
|
||||
void generate()
|
||||
}
|
||||
|
||||
function onFocusChange(): void {
|
||||
const state = getTerminalFocusState()
|
||||
if (state === 'blurred') {
|
||||
clearTimer()
|
||||
timerRef.current = setTimeout(onBlurTimerFire, BLUR_DELAY_MS)
|
||||
} else if (state === 'focused') {
|
||||
clearTimer()
|
||||
abortInFlight()
|
||||
pendingRef.current = false
|
||||
}
|
||||
// 'unknown' → 无操作
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeTerminalFocus(onFocusChange)
|
||||
// 处理效果挂载时已经失焦的情况
|
||||
onFocusChange()
|
||||
generateRef.current = generate
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
clearTimer()
|
||||
abortInFlight()
|
||||
generateRef.current = null
|
||||
}
|
||||
}, [gbEnabled, setMessages])
|
||||
|
||||
// 定时器在对话期间触发 → 在对话结束时触发(如果仍然失焦)
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
if (!pendingRef.current) return
|
||||
if (getTerminalFocusState() !== 'blurred') return
|
||||
void generateRef.current?.()
|
||||
}, [isLoading])
|
||||
}
|
||||
51
claude-code源码-中文注释/src/hooks/useBackgroundTaskNavigation.ts
Normal file
51
claude-code源码-中文注释/src/hooks/useBackgroundTaskNavigation.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import chalk from 'chalk'
|
||||
|
||||
type PlaceholderRendererProps = {
|
||||
placeholder?: string
|
||||
value: string
|
||||
showCursor?: boolean
|
||||
focus?: boolean
|
||||
terminalFocus: boolean
|
||||
invert?: (text: string) => string
|
||||
hidePlaceholderText?: boolean
|
||||
}
|
||||
|
||||
export function renderPlaceholder({
|
||||
placeholder,
|
||||
value,
|
||||
showCursor,
|
||||
focus,
|
||||
terminalFocus = true,
|
||||
invert = chalk.inverse,
|
||||
hidePlaceholderText = false,
|
||||
}: PlaceholderRendererProps): {
|
||||
renderedPlaceholder: string | undefined
|
||||
showPlaceholder: boolean
|
||||
} {
|
||||
let renderedPlaceholder: string | undefined = undefined
|
||||
|
||||
if (placeholder) {
|
||||
if (hidePlaceholderText) {
|
||||
// 语音录制:仅显示光标,不显示占位符文本
|
||||
renderedPlaceholder =
|
||||
showCursor && focus && terminalFocus ? invert(' ') : ''
|
||||
} else {
|
||||
renderedPlaceholder = chalk.dim(placeholder)
|
||||
|
||||
// 仅在输入和终端都聚焦时显示反向光标
|
||||
if (showCursor && focus && terminalFocus) {
|
||||
renderedPlaceholder =
|
||||
placeholder.length > 0
|
||||
? invert(placeholder[0]!) + chalk.dim(placeholder.slice(1))
|
||||
: invert(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showPlaceholder = value.length === 0 && Boolean(placeholder)
|
||||
|
||||
return {
|
||||
renderedPlaceholder,
|
||||
showPlaceholder,
|
||||
}
|
||||
}
|
||||
34
claude-code源码-中文注释/src/hooks/useBlink.ts
Normal file
34
claude-code源码-中文注释/src/hooks/useBlink.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { type DOMElement, useAnimationFrame, useTerminalFocus } from '../ink.js'
|
||||
|
||||
const BLINK_INTERVAL_MS = 600
|
||||
|
||||
/**
|
||||
* 用于同步闪烁动画的 Hook,在屏幕外时暂停。
|
||||
*
|
||||
* 返回要附加到动画元素的 ref 和当前闪烁状态。
|
||||
* 所有实例一起闪烁,因为它们从相同的动画时钟派生状态。
|
||||
* 时钟仅在至少一个订阅者可见时运行。
|
||||
* 终端模糊时暂停。
|
||||
*
|
||||
* @param enabled - 闪烁是否激活
|
||||
* @returns [ref, isVisible] - 附加到元素的 ref,在闪烁周期中可见时为 true
|
||||
*
|
||||
* @example
|
||||
* function BlinkingDot({ shouldAnimate }) {
|
||||
* const [ref, isVisible] = useBlink(shouldAnimate)
|
||||
* return <Box ref={ref}>{isVisible ? '●' : ' '}</Box>
|
||||
* }
|
||||
*/
|
||||
export function useBlink(
|
||||
enabled: boolean,
|
||||
intervalMs: number = BLINK_INTERVAL_MS,
|
||||
): [ref: (element: DOMElement | null) => void, isVisible: boolean] {
|
||||
const focused = useTerminalFocus()
|
||||
const [ref, time] = useAnimationFrame(enabled && focused ? intervalMs : null)
|
||||
|
||||
if (!enabled || !focused) return [ref, true]
|
||||
|
||||
// 从时间派生闪烁状态 - 所有实例看到相同的时间所以它们同步
|
||||
const isVisible = Math.floor(time / intervalMs) % 2 === 0
|
||||
return [ref, isVisible]
|
||||
}
|
||||
273
claude-code源码-中文注释/src/hooks/useCancelRequest.ts
Normal file
273
claude-code源码-中文注释/src/hooks/useCancelRequest.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* CancelRequestHandler 组件,用于处理取消/退出键绑定。
|
||||
*
|
||||
* 必须在 KeybindingSetup 内渲染才能访问键绑定上下文。
|
||||
* 此组件不渲染任何内容——只是注册取消键绑定处理程序。
|
||||
*/
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/metadata.js'
|
||||
import {
|
||||
useAppState,
|
||||
useAppStateStore,
|
||||
useSetAppState,
|
||||
} from 'src/state/AppState.js'
|
||||
import { isVimModeEnabled } from '../components/PromptInput/utils.js'
|
||||
import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js'
|
||||
import type { SpinnerMode } from '../components/Spinner/types.js'
|
||||
import { useNotifications } from '../context/notifications.js'
|
||||
import { useIsOverlayActive } from '../context/overlayContext.js'
|
||||
import { useCommandQueue } from '../hooks/useCommandQueue.js'
|
||||
import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
||||
import type { Screen } from '../screens/REPL.js'
|
||||
import { exitTeammateView } from '../state/teammateViewHelpers.js'
|
||||
import {
|
||||
killAllRunningAgentTasks,
|
||||
markAgentsNotified,
|
||||
} from '../tasks/LocalAgentTask/LocalAgentTask.js'
|
||||
import type { PromptInputMode, VimMode } from '../types/textInputTypes.js'
|
||||
import {
|
||||
clearCommandQueue,
|
||||
enqueuePendingNotification,
|
||||
hasCommandsInQueue,
|
||||
} from '../utils/messageQueueManager.js'
|
||||
import { emitTaskTerminatedSdk } from '../utils/sdkEventQueue.js'
|
||||
|
||||
/** 第二次按键终止所有后台代理的时间窗口(毫秒)。 */
|
||||
const KILL_AGENTS_CONFIRM_WINDOW_MS = 3000
|
||||
|
||||
type CancelRequestHandlerProps = {
|
||||
setToolUseConfirmQueue: (
|
||||
f: (toolUseConfirmQueue: ToolUseConfirm[]) => ToolUseConfirm[],
|
||||
) => void
|
||||
onCancel: () => void
|
||||
onAgentsKilled: () => void
|
||||
isMessageSelectorVisible: boolean
|
||||
screen: Screen
|
||||
abortSignal?: AbortSignal
|
||||
popCommandFromQueue?: () => void
|
||||
vimMode?: VimMode
|
||||
isLocalJSXCommand?: boolean
|
||||
isSearchingHistory?: boolean
|
||||
isHelpOpen?: boolean
|
||||
inputMode?: PromptInputMode
|
||||
inputValue?: string
|
||||
streamMode?: SpinnerMode
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过键绑定处理取消请求的组件。
|
||||
* 渲染 null,但注册 'chat:cancel' 键绑定处理程序。
|
||||
*/
|
||||
export function CancelRequestHandler(props: CancelRequestHandlerProps): null {
|
||||
const {
|
||||
setToolUseConfirmQueue,
|
||||
onCancel,
|
||||
onAgentsKilled,
|
||||
isMessageSelectorVisible,
|
||||
screen,
|
||||
abortSignal,
|
||||
popCommandFromQueue,
|
||||
vimMode,
|
||||
isLocalJSXCommand,
|
||||
isSearchingHistory,
|
||||
isHelpOpen,
|
||||
inputMode,
|
||||
inputValue,
|
||||
streamMode,
|
||||
} = props
|
||||
const store = useAppStateStore()
|
||||
const setAppState = useSetAppState()
|
||||
const queuedCommandsLength = useCommandQueue().length
|
||||
const { addNotification, removeNotification } = useNotifications()
|
||||
const lastKillAgentsPressRef = useRef<number>(0)
|
||||
const viewSelectionMode = useAppState(s => s.viewSelectionMode)
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
const cancelProps = {
|
||||
source:
|
||||
'escape' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
streamMode:
|
||||
streamMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
}
|
||||
|
||||
// 优先级 1:如果有活动任务正在运行,首先取消它
|
||||
// 这优先于队列管理,使用户始终可以中断 Claude
|
||||
if (abortSignal !== undefined && !abortSignal.aborted) {
|
||||
logEvent('tengu_cancel', cancelProps)
|
||||
setToolUseConfirmQueue(() => [])
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
|
||||
// 优先级 2:当 Claude 空闲时(没有要取消的运行任务)弹出队列
|
||||
if (hasCommandsInQueue()) {
|
||||
if (popCommandFromQueue) {
|
||||
popCommandFromQueue()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:没有要取消或弹出的内容(如果 isActive 正确则不应到达此处)
|
||||
logEvent('tengu_cancel', cancelProps)
|
||||
setToolUseConfirmQueue(() => [])
|
||||
onCancel()
|
||||
}, [
|
||||
abortSignal,
|
||||
popCommandFromQueue,
|
||||
setToolUseConfirmQueue,
|
||||
onCancel,
|
||||
streamMode,
|
||||
])
|
||||
|
||||
// 确定此处理程序是否应该处于活动状态
|
||||
// 其他上下文(Transcript、HistorySearch、Help)有自己的退出处理程序
|
||||
// 覆盖层(ModelPicker、ThinkingToggle 等)通过 useRegisterOverlay 注册
|
||||
// 本地 JSX 命令(如 /model、/btw)处理自己的输入
|
||||
const isOverlayActive = useIsOverlayActive()
|
||||
const canCancelRunningTask = abortSignal !== undefined && !abortSignal.aborted
|
||||
const hasQueuedCommands = queuedCommandsLength > 0
|
||||
// 当处于 bash/background 模式且输入为空时,Escape 应该退出模式
|
||||
// 而不是取消请求。让 PromptInput 处理模式退出。
|
||||
// 这仅适用于 Escape,Ctrl+C 应始终取消。
|
||||
const isInSpecialModeWithEmptyInput =
|
||||
inputMode !== undefined && inputMode !== 'prompt' && !inputValue
|
||||
// 当查看队友的 transcript 时,让 useBackgroundTaskNavigation 处理 Escape
|
||||
const isViewingTeammate = viewSelectionMode === 'viewing-agent'
|
||||
// 上下文守卫:其他屏幕/覆盖层处理自己的取消
|
||||
const isContextActive =
|
||||
screen !== 'transcript' &&
|
||||
!isSearchingHistory &&
|
||||
!isMessageSelectorVisible &&
|
||||
!isLocalJSXCommand &&
|
||||
!isHelpOpen &&
|
||||
!isOverlayActive &&
|
||||
!(isVimModeEnabled() && vimMode === 'INSERT')
|
||||
|
||||
// Escape (chat:cancel) 在特殊模式且空输入时服从模式退出,
|
||||
// 在查看队友时服从 useBackgroundTaskNavigation
|
||||
const isEscapeActive =
|
||||
isContextActive &&
|
||||
(canCancelRunningTask || hasQueuedCommands) &&
|
||||
!isInSpecialModeWithEmptyInput &&
|
||||
!isViewingTeammate
|
||||
|
||||
// Ctrl+C (app:interrupt): 查看队友时,停止所有操作并返回主线程。
|
||||
// 否则只执行 handleCancel。主线程空闲时一定不能捕获 ctrl+c——
|
||||
// 否则会阻止复制选择处理程序和双击退出看到按键事件。
|
||||
const isCtrlCActive =
|
||||
isContextActive &&
|
||||
(canCancelRunningTask || hasQueuedCommands || isViewingTeammate)
|
||||
|
||||
useKeybinding('chat:cancel', handleCancel, {
|
||||
context: 'Chat',
|
||||
isActive: isEscapeActive,
|
||||
})
|
||||
|
||||
// 共享终止路径:停止所有代理,抑制每个代理的通知,
|
||||
// 发送 SDK 事件,加入单个聚合的面向模型的 notification。
|
||||
// 如果有任何内容被终止则返回 true。
|
||||
const killAllAgentsAndNotify = useCallback((): boolean => {
|
||||
const tasks = store.getState().tasks
|
||||
const running = Object.entries(tasks).filter(
|
||||
([, t]) => t.type === 'local_agent' && t.status === 'running',
|
||||
)
|
||||
if (running.length === 0) return false
|
||||
killAllRunningAgentTasks(tasks, setAppState)
|
||||
const descriptions: string[] = []
|
||||
for (const [taskId, task] of running) {
|
||||
markAgentsNotified(taskId, setAppState)
|
||||
descriptions.push(task.description)
|
||||
emitTaskTerminatedSdk(taskId, 'stopped', {
|
||||
toolUseId: task.toolUseId,
|
||||
summary: task.description,
|
||||
})
|
||||
}
|
||||
const summary =
|
||||
descriptions.length === 1
|
||||
? `Background agent "${descriptions[0]}" was stopped by the user.`
|
||||
: `${descriptions.length} background agents were stopped by the user: ${descriptions.map(d => `"${d}"`).join(', ')}.`
|
||||
enqueuePendingNotification({ value: summary, mode: 'task-notification' })
|
||||
onAgentsKilled()
|
||||
return true
|
||||
}, [store, setAppState, onAgentsKilled])
|
||||
|
||||
// Ctrl+C (app:interrupt)。限定为队友视图:从主提示符终止代理需要
|
||||
// 刻意的手势(chat:killAgents),而不是取消对话的副作用。
|
||||
const handleInterrupt = useCallback(() => {
|
||||
if (isViewingTeammate) {
|
||||
killAllAgentsAndNotify()
|
||||
exitTeammateView(setAppState)
|
||||
}
|
||||
if (canCancelRunningTask || hasQueuedCommands) {
|
||||
handleCancel()
|
||||
}
|
||||
}, [
|
||||
isViewingTeammate,
|
||||
killAllAgentsAndNotify,
|
||||
setAppState,
|
||||
canCancelRunningTask,
|
||||
hasQueuedCommands,
|
||||
handleCancel,
|
||||
])
|
||||
|
||||
useKeybinding('app:interrupt', handleInterrupt, {
|
||||
context: 'Global',
|
||||
isActive: isCtrlCActive,
|
||||
})
|
||||
|
||||
// chat:killAgents 使用双击模式:第一次按下显示确认提示,
|
||||
// 窗口内的第二次按下实际终止所有代理。直接从 store 读取任务以避免闭包过时。
|
||||
const handleKillAgents = useCallback(() => {
|
||||
const tasks = store.getState().tasks
|
||||
const hasRunningAgents = Object.values(tasks).some(
|
||||
t => t.type === 'local_agent' && t.status === 'running',
|
||||
)
|
||||
if (!hasRunningAgents) {
|
||||
addNotification({
|
||||
key: 'kill-agents-none',
|
||||
text: 'No background agents running',
|
||||
priority: 'immediate',
|
||||
timeoutMs: 2000,
|
||||
})
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
const elapsed = now - lastKillAgentsPressRef.current
|
||||
if (elapsed <= KILL_AGENTS_CONFIRM_WINDOW_MS) {
|
||||
// 窗口内的第二次按下——终止所有后台代理
|
||||
lastKillAgentsPressRef.current = 0
|
||||
removeNotification('kill-agents-confirm')
|
||||
logEvent('tengu_cancel', {
|
||||
source:
|
||||
'kill_agents' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
clearCommandQueue()
|
||||
killAllAgentsAndNotify()
|
||||
return
|
||||
}
|
||||
// 第一次按下——在状态栏显示确认提示
|
||||
lastKillAgentsPressRef.current = now
|
||||
const shortcut = getShortcutDisplay(
|
||||
'chat:killAgents',
|
||||
'Chat',
|
||||
'ctrl+x ctrl+k',
|
||||
)
|
||||
addNotification({
|
||||
key: 'kill-agents-confirm',
|
||||
text: `Press ${shortcut} again to stop background agents`,
|
||||
priority: 'immediate',
|
||||
timeoutMs: KILL_AGENTS_CONFIRM_WINDOW_MS,
|
||||
})
|
||||
}, [store, addNotification, removeNotification, killAllAgentsAndNotify])
|
||||
|
||||
// 必须保持始终活跃:ctrl+x 作为和弦前缀被消耗
|
||||
//(因为 ctrl+x ctrl+e 始终活跃),因此非活跃处理程序会泄露 ctrl+k 给 readline kill-line。
|
||||
// 处理程序内部自行控制。
|
||||
useKeybinding('chat:killAgents', handleKillAgents, {
|
||||
context: 'Chat',
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
76
claude-code源码-中文注释/src/hooks/useClipboardImageHint.ts
Normal file
76
claude-code源码-中文注释/src/hooks/useClipboardImageHint.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNotifications } from '../context/notifications.js'
|
||||
import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'
|
||||
import { hasImageInClipboard } from '../utils/imagePaste.js'
|
||||
|
||||
const NOTIFICATION_KEY = 'clipboard-image-hint'
|
||||
// Small debounce to batch rapid focus changes
|
||||
const FOCUS_CHECK_DEBOUNCE_MS = 1000
|
||||
// Don't show the hint more than once per this interval
|
||||
const HINT_COOLDOWN_MS = 30000
|
||||
|
||||
/**
|
||||
* 当终端重新获得焦点且剪贴板包含图像时显示通知的 Hook。
|
||||
*
|
||||
* @param isFocused - 终端当前是否聚焦
|
||||
* @param enabled - 图像粘贴是否启用(onImagePaste 已定义)
|
||||
*/
|
||||
export function useClipboardImageHint(
|
||||
isFocused: boolean,
|
||||
enabled: boolean,
|
||||
): void {
|
||||
const { addNotification } = useNotifications()
|
||||
const lastFocusedRef = useRef(isFocused)
|
||||
const lastHintTimeRef = useRef(0)
|
||||
const checkTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 只在重新获得焦点时触发(之前未聚焦,现在聚焦)
|
||||
const wasFocused = lastFocusedRef.current
|
||||
lastFocusedRef.current = isFocused
|
||||
|
||||
if (!enabled || !isFocused || wasFocused) {
|
||||
return
|
||||
}
|
||||
|
||||
// 清除任何待处理的检查
|
||||
if (checkTimeoutRef.current) {
|
||||
clearTimeout(checkTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Small debounce to batch rapid focus changes
|
||||
checkTimeoutRef.current = setTimeout(
|
||||
async (checkTimeoutRef, lastHintTimeRef, addNotification) => {
|
||||
checkTimeoutRef.current = null
|
||||
|
||||
// Check cooldown to avoid spamming the user
|
||||
const now = Date.now()
|
||||
if (now - lastHintTimeRef.current < HINT_COOLDOWN_MS) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if clipboard has an image (async osascript call)
|
||||
if (await hasImageInClipboard()) {
|
||||
lastHintTimeRef.current = now
|
||||
addNotification({
|
||||
key: NOTIFICATION_KEY,
|
||||
text: `Image in clipboard · ${getShortcutDisplay('chat:imagePaste', 'Chat', 'ctrl+v')} to paste`,
|
||||
priority: 'immediate',
|
||||
timeoutMs: 8000,
|
||||
})
|
||||
}
|
||||
},
|
||||
FOCUS_CHECK_DEBOUNCE_MS,
|
||||
checkTimeoutRef,
|
||||
lastHintTimeRef,
|
||||
addNotification,
|
||||
)
|
||||
|
||||
return () => {
|
||||
if (checkTimeoutRef.current) {
|
||||
clearTimeout(checkTimeoutRef.current)
|
||||
checkTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [isFocused, enabled, addNotification])
|
||||
}
|
||||
15
claude-code源码-中文注释/src/hooks/useCommandQueue.ts
Normal file
15
claude-code源码-中文注释/src/hooks/useCommandQueue.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { QueuedCommand } from '../types/textInputTypes.js'
|
||||
import {
|
||||
getCommandQueueSnapshot,
|
||||
subscribeToCommandQueue,
|
||||
} from '../utils/messageQueueManager.js'
|
||||
|
||||
/**
|
||||
* 订阅统一命令队列的 React Hook。
|
||||
* 返回一个冻结的数组,只在 mutation 时更改引用。
|
||||
* 组件只在队列更改时重新渲染。
|
||||
*/
|
||||
export function useCommandQueue(): readonly QueuedCommand[] {
|
||||
return useSyncExternalStore(subscribeToCommandQueue, getCommandQueueSnapshot)
|
||||
}
|
||||
251
claude-code源码-中文注释/src/hooks/useCopyOnSelect.ts
Normal file
251
claude-code源码-中文注释/src/hooks/useCopyOnSelect.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { KeyboardEvent } from '../ink/events/keyboard-event.js'
|
||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- 向后兼容桥接,直到 REPL 将 handleKeyDown 连接到 <Box onKeyDown>
|
||||
import { useInput } from '../ink.js'
|
||||
import {
|
||||
type AppState,
|
||||
useAppState,
|
||||
useSetAppState,
|
||||
} from '../state/AppState.js'
|
||||
import {
|
||||
enterTeammateView,
|
||||
exitTeammateView,
|
||||
} from '../state/teammateViewHelpers.js'
|
||||
import {
|
||||
getRunningTeammatesSorted,
|
||||
InProcessTeammateTask,
|
||||
} from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'
|
||||
import {
|
||||
type InProcessTeammateTaskState,
|
||||
isInProcessTeammateTask,
|
||||
} from '../tasks/InProcessTeammateTask/types.js'
|
||||
import { isBackgroundTask } from '../tasks/types.js'
|
||||
|
||||
// 按增量逐步选择队友,环绕 leader(-1)..teammates(0..n-1)..hide(n)。
|
||||
// 从折叠树的第一步展开它并停在 leader 上。
|
||||
function stepTeammateSelection(
|
||||
delta: 1 | -1,
|
||||
setAppState: (updater: (prev: AppState) => AppState) => void,
|
||||
): void {
|
||||
setAppState(prev => {
|
||||
const currentCount = getRunningTeammatesSorted(prev.tasks).length
|
||||
if (currentCount === 0) return prev
|
||||
|
||||
if (prev.expandedView !== 'teammates') {
|
||||
return {
|
||||
...prev,
|
||||
expandedView: 'teammates' as const,
|
||||
viewSelectionMode: 'selecting-agent',
|
||||
selectedIPAgentIndex: -1,
|
||||
}
|
||||
}
|
||||
|
||||
const maxIdx = currentCount // hide 行
|
||||
const cur = prev.selectedIPAgentIndex
|
||||
const next =
|
||||
delta === 1
|
||||
? cur >= maxIdx
|
||||
? -1
|
||||
: cur + 1
|
||||
: cur <= -1
|
||||
? maxIdx
|
||||
: cur - 1
|
||||
return {
|
||||
...prev,
|
||||
selectedIPAgentIndex: next,
|
||||
viewSelectionMode: 'selecting-agent',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理后台任务 Shift+Up/Down 键盘导航的自定义 hook。
|
||||
* 当队友(swarm)存在时,在 leader 和队友之间导航。
|
||||
* 当仅存在非队友后台任务时,打开后台任务对话框。
|
||||
* 还处理 Enter 确认选择、'f' 查看转录和 'k' 终止。
|
||||
*/
|
||||
export function useBackgroundTaskNavigation(options?: {
|
||||
onOpenBackgroundTasks?: () => void
|
||||
}): { handleKeyDown: (e: KeyboardEvent) => void } {
|
||||
const tasks = useAppState(s => s.tasks)
|
||||
const viewSelectionMode = useAppState(s => s.viewSelectionMode)
|
||||
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
|
||||
const selectedIPAgentIndex = useAppState(s => s.selectedIPAgentIndex)
|
||||
const setAppState = useSetAppState()
|
||||
|
||||
// 过滤到运行的队友并按字母顺序排序以匹配 TeammateSpinnerTree 显示
|
||||
const teammateTasks = getRunningTeammatesSorted(tasks)
|
||||
const teammateCount = teammateTasks.length
|
||||
|
||||
// 检查非队友后台任务(local_agent、local_bash 等)
|
||||
const hasNonTeammateBackgroundTasks = Object.values(tasks).some(
|
||||
t => isBackgroundTask(t) && t.type !== 'in_process_teammate',
|
||||
)
|
||||
|
||||
// 跟踪之前的队友数量以检测队友何时被移除
|
||||
const prevTeammateCountRef = useRef<number>(teammateCount)
|
||||
|
||||
// 如果队友被移除则夹紧选择索引,或在数量变为 0 时重置
|
||||
useEffect(() => {
|
||||
const prevCount = prevTeammateCountRef.current
|
||||
prevTeammateCountRef.current = teammateCount
|
||||
|
||||
setAppState(prev => {
|
||||
const currentTeammates = getRunningTeammatesSorted(prev.tasks)
|
||||
const currentCount = currentTeammates.length
|
||||
|
||||
// 当队友被移除(数量从 >0 变为 0)时,重置选择
|
||||
// 仅在我们之前有队友时重置(不是初始挂载为 0 时)
|
||||
// 如果正在主动查看队友转录,则不要覆盖 viewSelectionMode —
|
||||
// 用户可能正在查看已完成的队友,需要 escape 退出
|
||||
if (
|
||||
currentCount === 0 &&
|
||||
prevCount > 0 &&
|
||||
prev.selectedIPAgentIndex !== -1
|
||||
) {
|
||||
if (prev.viewSelectionMode === 'viewing-agent') {
|
||||
return {
|
||||
...prev,
|
||||
selectedIPAgentIndex: -1,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
selectedIPAgentIndex: -1,
|
||||
viewSelectionMode: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
// 如果索引超出范围则夹紧
|
||||
// 最大有效索引是当显示 spinner 树时的 currentCount("hide"行)
|
||||
const maxIndex =
|
||||
prev.expandedView === 'teammates' ? currentCount : currentCount - 1
|
||||
if (currentCount > 0 && prev.selectedIPAgentIndex > maxIndex) {
|
||||
return {
|
||||
...prev,
|
||||
selectedIPAgentIndex: maxIndex,
|
||||
}
|
||||
}
|
||||
|
||||
return prev
|
||||
})
|
||||
}, [teammateCount, setAppState])
|
||||
|
||||
// 获取所选择队友的任务信息
|
||||
const getSelectedTeammate = (): {
|
||||
taskId: string
|
||||
task: InProcessTeammateTaskState
|
||||
} | null => {
|
||||
if (teammateCount === 0) return null
|
||||
const selectedIndex = selectedIPAgentIndex
|
||||
const task = teammateTasks[selectedIndex]
|
||||
if (!task) return null
|
||||
|
||||
return { taskId: task.id, task }
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
// 查看模式中的 Escape:
|
||||
// - 如果队友正在运行:仅中止当前工作(停止当前 turn,队友保持活动)
|
||||
// - 如果队友不在运行(完成/终止/失败):退回到 leader 查看
|
||||
if (e.key === 'escape' && viewSelectionMode === 'viewing-agent') {
|
||||
e.preventDefault()
|
||||
const taskId = viewingAgentTaskId
|
||||
if (taskId) {
|
||||
const task = tasks[taskId]
|
||||
if (isInProcessTeammateTask(task) && task.status === 'running') {
|
||||
// 中止 currentWorkAbortController(停止当前 turn)而不是 abortController(终止队友)
|
||||
task.currentWorkAbortController?.abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
// 队友不在运行或任务不存在 — 退出视图
|
||||
exitTeammateView(setAppState)
|
||||
return
|
||||
}
|
||||
|
||||
// 选择模式中的 Escape:退出选择而不中止 leader
|
||||
if (e.key === 'escape' && viewSelectionMode === 'selecting-agent') {
|
||||
e.preventDefault()
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
viewSelectionMode: 'none',
|
||||
selectedIPAgentIndex: -1,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
// Shift+Up/Down 用于队友转录切换(带环绕)
|
||||
// 索引 -1 代表 leader,0+ 是队友
|
||||
// 当 showSpinnerTree 为 true 时,索引 === teammateCount 是 "hide" 行
|
||||
if (e.shift && (e.key === 'up' || e.key === 'down')) {
|
||||
e.preventDefault()
|
||||
if (teammateCount > 0) {
|
||||
stepTeammateSelection(e.key === 'down' ? 1 : -1, setAppState)
|
||||
} else if (hasNonTeammateBackgroundTasks) {
|
||||
options?.onOpenBackgroundTasks?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 'f' 查看所选队友的转录(仅在选择模式)
|
||||
if (
|
||||
e.key === 'f' &&
|
||||
viewSelectionMode === 'selecting-agent' &&
|
||||
teammateCount > 0
|
||||
) {
|
||||
e.preventDefault()
|
||||
const selected = getSelectedTeammate()
|
||||
if (selected) {
|
||||
enterTeammateView(selected.taskId, setAppState)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Enter 确认选择(仅在选择模式时)
|
||||
if (e.key === 'return' && viewSelectionMode === 'selecting-agent') {
|
||||
e.preventDefault()
|
||||
if (selectedIPAgentIndex === -1) {
|
||||
exitTeammateView(setAppState)
|
||||
} else if (selectedIPAgentIndex >= teammateCount) {
|
||||
// 选择了 "Hide" 行 — 折叠 spinner 树
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
expandedView: 'none' as const,
|
||||
viewSelectionMode: 'none',
|
||||
selectedIPAgentIndex: -1,
|
||||
}))
|
||||
} else {
|
||||
const selected = getSelectedTeammate()
|
||||
if (selected) {
|
||||
enterTeammateView(selected.taskId, setAppState)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// k 终止所选队友(仅在选择模式且选择了有效索引时)
|
||||
if (
|
||||
e.key === 'k' &&
|
||||
viewSelectionMode === 'selecting-agent' &&
|
||||
selectedIPAgentIndex >= 0
|
||||
) {
|
||||
e.preventDefault()
|
||||
const selected = getSelectedTeammate()
|
||||
if (selected && selected.task.status === 'running') {
|
||||
void InProcessTeammateTask.kill(selected.taskId, setAppState)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 向后兼容桥接:REPL.tsx 尚未将 handleKeyDown 连接到
|
||||
// <Box onKeyDown>。通过 useInput 订阅并适配 InputEvent →
|
||||
// KeyboardEvent,直到使用者被迁移(单独的 PR)。
|
||||
// TODO(onKeyDown-migration): 一旦 REPL 传递 handleKeyDown 就移除。
|
||||
useInput((_input, _key, event) => {
|
||||
handleKeyDown(new KeyboardEvent(event.keypress))
|
||||
})
|
||||
|
||||
return { handleKeyDown }
|
||||
}
|
||||
46
claude-code源码-中文注释/src/hooks/useDeferredHookMessages.ts
Normal file
46
claude-code源码-中文注释/src/hooks/useDeferredHookMessages.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { HookResultMessage, Message } from '../types/message.js'
|
||||
|
||||
/**
|
||||
* 管理延迟的 SessionStart hook 消息,以便 REPL 可以立即渲染
|
||||
* 而不是阻塞 hook 执行(约 500ms)。
|
||||
*
|
||||
* Hook 消息在 promise 解析时异步注入。
|
||||
* 返回一个回调,onSubmit 应在第一个 API
|
||||
* 请求之前调用以确保模型始终看到 hook 上下文。
|
||||
*/
|
||||
export function useDeferredHookMessages(
|
||||
pendingHookMessages: Promise<HookResultMessage[]> | undefined,
|
||||
setMessages: (action: React.SetStateAction<Message[]>) => void,
|
||||
): () => Promise<void> {
|
||||
const pendingRef = useRef(pendingHookMessages ?? null)
|
||||
const resolvedRef = useRef(!pendingHookMessages)
|
||||
|
||||
useEffect(() => {
|
||||
const promise = pendingRef.current
|
||||
if (!promise) return
|
||||
let cancelled = false
|
||||
promise.then(msgs => {
|
||||
if (cancelled) return
|
||||
resolvedRef.current = true
|
||||
pendingRef.current = null
|
||||
if (msgs.length > 0) {
|
||||
setMessages(prev => [...msgs, ...prev])
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [setMessages])
|
||||
|
||||
return useCallback(async () => {
|
||||
if (resolvedRef.current || !pendingRef.current) return
|
||||
const msgs = await pendingRef.current
|
||||
if (resolvedRef.current) return
|
||||
resolvedRef.current = true
|
||||
pendingRef.current = null
|
||||
if (msgs.length > 0) {
|
||||
setMessages(prev => [...msgs, ...prev])
|
||||
}
|
||||
}, [setMessages])
|
||||
}
|
||||
110
claude-code源码-中文注释/src/hooks/useDiffData.ts
Normal file
110
claude-code源码-中文注释/src/hooks/useDiffData.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { StructuredPatchHunk } from 'diff'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
fetchGitDiff,
|
||||
fetchGitDiffHunks,
|
||||
type GitDiffResult,
|
||||
type GitDiffStats,
|
||||
} from '../utils/gitDiff.js'
|
||||
|
||||
const MAX_LINES_PER_FILE = 400
|
||||
|
||||
export type DiffFile = {
|
||||
path: string
|
||||
linesAdded: number
|
||||
linesRemoved: number
|
||||
isBinary: boolean
|
||||
isLargeFile: boolean
|
||||
isTruncated: boolean
|
||||
isNewFile?: boolean
|
||||
isUntracked?: boolean
|
||||
}
|
||||
|
||||
export type DiffData = {
|
||||
stats: GitDiffStats | null
|
||||
files: DiffFile[]
|
||||
hunks: Map<string, StructuredPatchHunk[]>
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 按需获取当前 git diff 数据的 Hook。
|
||||
* 组件挂载时同时获取统计和 hunks。
|
||||
*/
|
||||
export function useDiffData(): DiffData {
|
||||
const [diffResult, setDiffResult] = useState<GitDiffResult | null>(null)
|
||||
const [hunks, setHunks] = useState<Map<string, StructuredPatchHunk[]>>(
|
||||
new Map(),
|
||||
)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// 挂载时获取 diff 数据
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function loadDiffData() {
|
||||
try {
|
||||
// 同时获取统计和 hunks
|
||||
const [statsResult, hunksResult] = await Promise.all([
|
||||
fetchGitDiff(),
|
||||
fetchGitDiffHunks(),
|
||||
])
|
||||
|
||||
if (!cancelled) {
|
||||
setDiffResult(statsResult)
|
||||
setHunks(hunksResult)
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (_error) {
|
||||
if (!cancelled) {
|
||||
setDiffResult(null)
|
||||
setHunks(new Map())
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadDiffData()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useMemo(() => {
|
||||
if (!diffResult) {
|
||||
return { stats: null, files: [], hunks: new Map(), loading }
|
||||
}
|
||||
|
||||
const { stats, perFileStats } = diffResult
|
||||
const files: DiffFile[] = []
|
||||
|
||||
// 遍历 perFileStats 以获取所有文件,包括 large/skipped 的
|
||||
for (const [path, fileStats] of perFileStats) {
|
||||
const fileHunks = hunks.get(path)
|
||||
const isUntracked = fileStats.isUntracked ?? false
|
||||
|
||||
// 检测大文件(在 perFileStats 中但不在 hunks 中,且不是 binary/untracked)
|
||||
const isLargeFile = !fileStats.isBinary && !isUntracked && !fileHunks
|
||||
|
||||
// 检测截断的文件(total > limit 意味着我们截断了)
|
||||
const totalLines = fileStats.added + fileStats.removed
|
||||
const isTruncated =
|
||||
!isLargeFile && !fileStats.isBinary && totalLines > MAX_LINES_PER_FILE
|
||||
|
||||
files.push({
|
||||
path,
|
||||
linesAdded: fileStats.added,
|
||||
linesRemoved: fileStats.removed,
|
||||
isBinary: fileStats.isBinary,
|
||||
isLargeFile,
|
||||
isTruncated,
|
||||
isUntracked,
|
||||
})
|
||||
}
|
||||
|
||||
files.sort((a, b) => a.path.localeCompare(b.path))
|
||||
|
||||
return { stats, files, hunks, loading: false }
|
||||
}, [diffResult, hunks, loading])
|
||||
}
|
||||
383
claude-code源码-中文注释/src/hooks/useDiffInIDE.ts
Normal file
383
claude-code源码-中文注释/src/hooks/useDiffInIDE.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { basename } from 'path'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import { readFileSync } from 'src/utils/fileRead.js'
|
||||
import { expandPath } from 'src/utils/path.js'
|
||||
import type { PermissionOption } from '../components/permissions/FilePermissionDialog/permissionOptions.js'
|
||||
import type {
|
||||
MCPServerConnection,
|
||||
McpSSEIDEServerConfig,
|
||||
McpWebSocketIDEServerConfig,
|
||||
} from '../services/mcp/types.js'
|
||||
import type { ToolUseContext } from '../Tool.js'
|
||||
import type { FileEdit } from '../tools/FileEditTool/types.js'
|
||||
import {
|
||||
getEditsForPatch,
|
||||
getPatchForEdits,
|
||||
} from '../tools/FileEditTool/utils.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { getPatchFromContents } from '../utils/diff.js'
|
||||
import { isENOENT } from '../utils/errors.js'
|
||||
import {
|
||||
callIdeRpc,
|
||||
getConnectedIdeClient,
|
||||
getConnectedIdeName,
|
||||
hasAccessToIDEExtensionDiffFeature,
|
||||
} from '../utils/ide.js'
|
||||
import { WindowsToWSLConverter } from '../utils/idePathConversion.js'
|
||||
import { logError } from '../utils/log.js'
|
||||
import { getPlatform } from '../utils/platform.js'
|
||||
|
||||
type Props = {
|
||||
onChange(
|
||||
option: PermissionOption,
|
||||
input: {
|
||||
file_path: string
|
||||
edits: FileEdit[]
|
||||
},
|
||||
): void
|
||||
toolUseContext: ToolUseContext
|
||||
filePath: string
|
||||
edits: FileEdit[]
|
||||
editMode: 'single' | 'multiple'
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理在 IDE 中显示文件差异的 Hook。
|
||||
* 打开 IDE diff 视图,让用户审查并接受或拒绝编辑。
|
||||
*/
|
||||
export function useDiffInIDE({
|
||||
onChange,
|
||||
toolUseContext,
|
||||
filePath,
|
||||
edits,
|
||||
editMode,
|
||||
}: Props): {
|
||||
closeTabInIDE: () => void
|
||||
showingDiffInIDE: boolean
|
||||
ideName: string
|
||||
hasError: boolean
|
||||
} {
|
||||
const isUnmounted = useRef(false)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
const sha = useMemo(() => randomUUID().slice(0, 6), [])
|
||||
const tabName = useMemo(
|
||||
() => `✻ [Claude Code] ${basename(filePath)} (${sha}) ⧉`,
|
||||
[filePath, sha],
|
||||
)
|
||||
|
||||
const shouldShowDiffInIDE =
|
||||
hasAccessToIDEExtensionDiffFeature(toolUseContext.options.mcpClients) &&
|
||||
getGlobalConfig().diffTool === 'auto' &&
|
||||
// Diffs should only be for file edits.
|
||||
// File writes may come through here but are not supported for diffs.
|
||||
!filePath.endsWith('.ipynb')
|
||||
|
||||
const ideName =
|
||||
getConnectedIdeName(toolUseContext.options.mcpClients) ?? 'IDE'
|
||||
|
||||
async function showDiff(): Promise<void> {
|
||||
if (!shouldShowDiffInIDE) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
logEvent('tengu_ext_will_show_diff', {})
|
||||
|
||||
const { oldContent, newContent } = await showDiffInIDE(
|
||||
filePath,
|
||||
edits,
|
||||
toolUseContext,
|
||||
tabName,
|
||||
)
|
||||
// Skip if component has been unmounted
|
||||
if (isUnmounted.current) {
|
||||
return
|
||||
}
|
||||
|
||||
logEvent('tengu_ext_diff_accepted', {})
|
||||
|
||||
const newEdits = computeEditsFromContents(
|
||||
filePath,
|
||||
oldContent,
|
||||
newContent,
|
||||
editMode,
|
||||
)
|
||||
|
||||
if (newEdits.length === 0) {
|
||||
// No changes -- edit was rejected (eg. reverted)
|
||||
logEvent('tengu_ext_diff_rejected', {})
|
||||
// We close the tab here because 'no' no longer auto-closes
|
||||
const ideClient = getConnectedIdeClient(
|
||||
toolUseContext.options.mcpClients,
|
||||
)
|
||||
if (ideClient) {
|
||||
// Close the tab in the IDE
|
||||
await closeTabInIDE(tabName, ideClient)
|
||||
}
|
||||
onChange(
|
||||
{ type: 'reject' },
|
||||
{
|
||||
file_path: filePath,
|
||||
edits: edits,
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// File was modified - edit was accepted
|
||||
onChange(
|
||||
{ type: 'accept-once' },
|
||||
{
|
||||
file_path: filePath,
|
||||
edits: newEdits,
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
setHasError(true)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void showDiff()
|
||||
|
||||
// Set flag on unmount
|
||||
return () => {
|
||||
isUnmounted.current = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return {
|
||||
closeTabInIDE() {
|
||||
const ideClient = getConnectedIdeClient(toolUseContext.options.mcpClients)
|
||||
|
||||
if (!ideClient) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return closeTabInIDE(tabName, ideClient)
|
||||
},
|
||||
showingDiffInIDE: shouldShowDiffInIDE && !hasError,
|
||||
ideName: ideName,
|
||||
hasError,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从旧内容和新内容重新计算编辑。这是必要的,
|
||||
* 以便应用用户可能对新内容所做的任何编辑。
|
||||
*/
|
||||
export function computeEditsFromContents(
|
||||
filePath: string,
|
||||
oldContent: string,
|
||||
newContent: string,
|
||||
editMode: 'single' | 'multiple',
|
||||
): FileEdit[] {
|
||||
// Use unformatted patches, otherwise the edits will be formatted.
|
||||
const singleHunk = editMode === 'single'
|
||||
const patch = getPatchFromContents({
|
||||
filePath,
|
||||
oldContent,
|
||||
newContent,
|
||||
singleHunk,
|
||||
})
|
||||
|
||||
if (patch.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// For single edit mode, verify we only got one hunk
|
||||
if (singleHunk && patch.length > 1) {
|
||||
logError(
|
||||
new Error(
|
||||
`Unexpected number of hunks: ${patch.length}. Expected 1 hunk.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Re-compute the edits to match the patch
|
||||
return getEditsForPatch(patch)
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成条件:
|
||||
*
|
||||
* 1. Tab 在 IDE 中关闭
|
||||
* 2. Tab 在 IDE 中保存(我们然后关闭 tab)
|
||||
* 3. 用户在 IDE 中选择了选项
|
||||
* 4. 用户在终端中选择了选项(或按 esc)
|
||||
*
|
||||
* 用新文件内容解决。
|
||||
*
|
||||
* TODO: 5 分钟不活动后超时?
|
||||
* TODO: IDE 退出时更新自动批准 UI
|
||||
* TODO: 批准提示卸载时关闭 IDE tab
|
||||
*/
|
||||
async function showDiffInIDE(
|
||||
file_path: string,
|
||||
edits: FileEdit[],
|
||||
toolUseContext: ToolUseContext,
|
||||
tabName: string,
|
||||
): Promise<{ oldContent: string; newContent: string }> {
|
||||
let isCleanedUp = false
|
||||
|
||||
const oldFilePath = expandPath(file_path)
|
||||
let oldContent = ''
|
||||
try {
|
||||
oldContent = readFileSync(oldFilePath)
|
||||
} catch (e: unknown) {
|
||||
if (!isENOENT(e)) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
// Careful to avoid race conditions, since this
|
||||
// function can be called from multiple places.
|
||||
if (isCleanedUp) {
|
||||
return
|
||||
}
|
||||
isCleanedUp = true
|
||||
|
||||
// Don't fail if this fails
|
||||
try {
|
||||
await closeTabInIDE(tabName, ideClient)
|
||||
} catch (e) {
|
||||
logError(e as Error)
|
||||
}
|
||||
|
||||
process.off('beforeExit', cleanup)
|
||||
toolUseContext.abortController.signal.removeEventListener('abort', cleanup)
|
||||
}
|
||||
|
||||
// Cleanup if the user hits esc to cancel the tool call - or on exit
|
||||
toolUseContext.abortController.signal.addEventListener('abort', cleanup)
|
||||
process.on('beforeExit', cleanup)
|
||||
|
||||
// Open the diff in the IDE
|
||||
const ideClient = getConnectedIdeClient(toolUseContext.options.mcpClients)
|
||||
try {
|
||||
const { updatedFile } = getPatchForEdits({
|
||||
filePath: oldFilePath,
|
||||
fileContents: oldContent,
|
||||
edits,
|
||||
})
|
||||
|
||||
if (!ideClient || ideClient.type !== 'connected') {
|
||||
throw new Error('IDE client not available')
|
||||
}
|
||||
let ideOldPath = oldFilePath
|
||||
|
||||
// Only convert paths if we're in WSL and IDE is on Windows
|
||||
const ideRunningInWindows =
|
||||
(ideClient.config as McpSSEIDEServerConfig | McpWebSocketIDEServerConfig)
|
||||
.ideRunningInWindows === true
|
||||
if (
|
||||
getPlatform() === 'wsl' &&
|
||||
ideRunningInWindows &&
|
||||
process.env.WSL_DISTRO_NAME
|
||||
) {
|
||||
const converter = new WindowsToWSLConverter(process.env.WSL_DISTRO_NAME)
|
||||
ideOldPath = converter.toIDEPath(oldFilePath)
|
||||
}
|
||||
|
||||
const rpcResult = await callIdeRpc(
|
||||
'openDiff',
|
||||
{
|
||||
old_file_path: ideOldPath,
|
||||
new_file_path: ideOldPath,
|
||||
new_file_contents: updatedFile,
|
||||
tab_name: tabName,
|
||||
},
|
||||
ideClient,
|
||||
)
|
||||
|
||||
// Convert the raw RPC result to a ToolCallResponse format
|
||||
const data = Array.isArray(rpcResult) ? rpcResult : [rpcResult]
|
||||
|
||||
// If the user saved the file then take the new contents and resolve with that.
|
||||
if (isSaveMessage(data)) {
|
||||
void cleanup()
|
||||
return {
|
||||
oldContent: oldContent,
|
||||
newContent: data[1].text,
|
||||
}
|
||||
} else if (isClosedMessage(data)) {
|
||||
void cleanup()
|
||||
return {
|
||||
oldContent: oldContent,
|
||||
newContent: updatedFile,
|
||||
}
|
||||
} else if (isRejectedMessage(data)) {
|
||||
void cleanup()
|
||||
return {
|
||||
oldContent: oldContent,
|
||||
newContent: oldContent,
|
||||
}
|
||||
}
|
||||
|
||||
// Indicates that the tool call completed with none of the expected
|
||||
// results. Did the user close the IDE?
|
||||
throw new Error('Not accepted')
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
void cleanup()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function closeTabInIDE(
|
||||
tabName: string,
|
||||
ideClient?: MCPServerConnection | undefined,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!ideClient || ideClient.type !== 'connected') {
|
||||
throw new Error('IDE client not available')
|
||||
}
|
||||
|
||||
// Use direct RPC to close the tab
|
||||
await callIdeRpc('close_tab', { tab_name: tabName }, ideClient)
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
// Don't throw - this is a cleanup operation
|
||||
}
|
||||
}
|
||||
|
||||
function isClosedMessage(data: unknown): data is { text: 'TAB_CLOSED' } {
|
||||
return (
|
||||
Array.isArray(data) &&
|
||||
typeof data[0] === 'object' &&
|
||||
data[0] !== null &&
|
||||
'type' in data[0] &&
|
||||
data[0].type === 'text' &&
|
||||
'text' in data[0] &&
|
||||
data[0].text === 'TAB_CLOSED'
|
||||
)
|
||||
}
|
||||
|
||||
function isRejectedMessage(data: unknown): data is { text: 'DIFF_REJECTED' } {
|
||||
return (
|
||||
Array.isArray(data) &&
|
||||
typeof data[0] === 'object' &&
|
||||
data[0] !== null &&
|
||||
'type' in data[0] &&
|
||||
data[0].type === 'text' &&
|
||||
'text' in data[0] &&
|
||||
data[0].text === 'DIFF_REJECTED'
|
||||
)
|
||||
}
|
||||
|
||||
function isSaveMessage(
|
||||
data: unknown,
|
||||
): data is [{ text: 'FILE_SAVED' }, { text: string }] {
|
||||
return (
|
||||
Array.isArray(data) &&
|
||||
data[0]?.type === 'text' &&
|
||||
data[0].text === 'FILE_SAVED' &&
|
||||
typeof data[1].text === 'string'
|
||||
)
|
||||
}
|
||||
229
claude-code源码-中文注释/src/hooks/useDirectConnect.ts
Normal file
229
claude-code源码-中文注释/src/hooks/useDirectConnect.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js'
|
||||
import type { RemotePermissionResponse } from '../remote/RemoteSessionManager.js'
|
||||
import {
|
||||
createSyntheticAssistantMessage,
|
||||
createToolStub,
|
||||
} from '../remote/remotePermissionBridge.js'
|
||||
import {
|
||||
convertSDKMessage,
|
||||
isSessionEndMessage,
|
||||
} from '../remote/sdkMessageAdapter.js'
|
||||
import {
|
||||
type DirectConnectConfig,
|
||||
DirectConnectSessionManager,
|
||||
} from '../server/directConnectManager.js'
|
||||
import type { Tool } from '../Tool.js'
|
||||
import { findToolByName } from '../Tool.js'
|
||||
import type { Message as MessageType } from '../types/message.js'
|
||||
import type { PermissionAskDecision } from '../types/permissions.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { gracefulShutdown } from '../utils/gracefulShutdown.js'
|
||||
import type { RemoteMessageContent } from '../utils/teleport/api.js'
|
||||
|
||||
type UseDirectConnectResult = {
|
||||
isRemoteMode: boolean
|
||||
sendMessage: (content: RemoteMessageContent) => Promise<boolean>
|
||||
cancelRequest: () => void
|
||||
disconnect: () => void
|
||||
}
|
||||
|
||||
type UseDirectConnectProps = {
|
||||
config: DirectConnectConfig | undefined
|
||||
setMessages: React.Dispatch<React.SetStateAction<MessageType[]>>
|
||||
setIsLoading: (loading: boolean) => void
|
||||
setToolUseConfirmQueue: React.Dispatch<React.SetStateAction<ToolUseConfirm[]>>
|
||||
tools: Tool[]
|
||||
}
|
||||
|
||||
export function useDirectConnect({
|
||||
config,
|
||||
setMessages,
|
||||
setIsLoading,
|
||||
setToolUseConfirmQueue,
|
||||
tools,
|
||||
}: UseDirectConnectProps): UseDirectConnectResult {
|
||||
const isRemoteMode = !!config
|
||||
|
||||
const managerRef = useRef<DirectConnectSessionManager | null>(null)
|
||||
const hasReceivedInitRef = useRef(false)
|
||||
const isConnectedRef = useRef(false)
|
||||
|
||||
// 保持 tools ref 以便 WebSocket 回调不会过时
|
||||
const toolsRef = useRef(tools)
|
||||
useEffect(() => {
|
||||
toolsRef.current = tools
|
||||
}, [tools])
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
hasReceivedInitRef.current = false
|
||||
logForDebugging(`[useDirectConnect] Connecting to ${config.wsUrl}`)
|
||||
|
||||
const manager = new DirectConnectSessionManager(config, {
|
||||
onMessage: sdkMessage => {
|
||||
if (isSessionEndMessage(sdkMessage)) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
// 跳过重复的 init 消息(服务器每次 turn 发送一个)
|
||||
if (sdkMessage.type === 'system' && sdkMessage.subtype === 'init') {
|
||||
if (hasReceivedInitRef.current) {
|
||||
return
|
||||
}
|
||||
hasReceivedInitRef.current = true
|
||||
}
|
||||
|
||||
const converted = convertSDKMessage(sdkMessage, {
|
||||
convertToolResults: true,
|
||||
})
|
||||
if (converted.type === 'message') {
|
||||
setMessages(prev => [...prev, converted.message])
|
||||
}
|
||||
},
|
||||
onPermissionRequest: (request, requestId) => {
|
||||
logForDebugging(
|
||||
`[useDirectConnect] Permission request for tool: ${request.tool_name}`,
|
||||
)
|
||||
|
||||
const tool =
|
||||
findToolByName(toolsRef.current, request.tool_name) ??
|
||||
createToolStub(request.tool_name)
|
||||
|
||||
const syntheticMessage = createSyntheticAssistantMessage(
|
||||
request,
|
||||
requestId,
|
||||
)
|
||||
|
||||
const permissionResult: PermissionAskDecision = {
|
||||
behavior: 'ask',
|
||||
message:
|
||||
request.description ?? `${request.tool_name} requires permission`,
|
||||
suggestions: request.permission_suggestions,
|
||||
blockedPath: request.blocked_path,
|
||||
}
|
||||
|
||||
const toolUseConfirm: ToolUseConfirm = {
|
||||
assistantMessage: syntheticMessage,
|
||||
tool,
|
||||
description:
|
||||
request.description ?? `${request.tool_name} requires permission`,
|
||||
input: request.input,
|
||||
toolUseContext: {} as ToolUseConfirm['toolUseContext'],
|
||||
toolUseID: request.tool_use_id,
|
||||
permissionResult,
|
||||
permissionPromptStartTimeMs: Date.now(),
|
||||
onUserInteraction() {
|
||||
// 远程为空操作
|
||||
},
|
||||
onAbort() {
|
||||
const response: RemotePermissionResponse = {
|
||||
behavior: 'deny',
|
||||
message: 'User aborted',
|
||||
}
|
||||
manager.respondToPermissionRequest(requestId, response)
|
||||
setToolUseConfirmQueue(queue =>
|
||||
queue.filter(item => item.toolUseID !== request.tool_use_id),
|
||||
)
|
||||
},
|
||||
onAllow(updatedInput, _permissionUpdates, _feedback) {
|
||||
const response: RemotePermissionResponse = {
|
||||
behavior: 'allow',
|
||||
updatedInput,
|
||||
}
|
||||
manager.respondToPermissionRequest(requestId, response)
|
||||
setToolUseConfirmQueue(queue =>
|
||||
queue.filter(item => item.toolUseID !== request.tool_use_id),
|
||||
)
|
||||
setIsLoading(true)
|
||||
},
|
||||
onReject(feedback?: string) {
|
||||
const response: RemotePermissionResponse = {
|
||||
behavior: 'deny',
|
||||
message: feedback ?? 'User denied permission',
|
||||
}
|
||||
manager.respondToPermissionRequest(requestId, response)
|
||||
setToolUseConfirmQueue(queue =>
|
||||
queue.filter(item => item.toolUseID !== request.tool_use_id),
|
||||
)
|
||||
},
|
||||
async recheckPermission() {
|
||||
// 远程为空操作
|
||||
},
|
||||
}
|
||||
|
||||
setToolUseConfirmQueue(queue => [...queue, toolUseConfirm])
|
||||
setIsLoading(false)
|
||||
},
|
||||
onConnected: () => {
|
||||
logForDebugging('[useDirectConnect] Connected')
|
||||
isConnectedRef.current = true
|
||||
},
|
||||
onDisconnected: () => {
|
||||
logForDebugging('[useDirectConnect] Disconnected')
|
||||
if (!isConnectedRef.current) {
|
||||
// 从未连接 — 连接失败(例如 auth 拒绝)
|
||||
process.stderr.write(
|
||||
`\nFailed to connect to server at ${config.wsUrl}\n`,
|
||||
)
|
||||
} else {
|
||||
// 曾连接然后丢失 — 服务器进程退出或网络断开
|
||||
process.stderr.write('\nServer disconnected.\n')
|
||||
}
|
||||
isConnectedRef.current = false
|
||||
void gracefulShutdown(1)
|
||||
setIsLoading(false)
|
||||
},
|
||||
onError: error => {
|
||||
logForDebugging(`[useDirectConnect] Error: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
managerRef.current = manager
|
||||
manager.connect()
|
||||
|
||||
return () => {
|
||||
logForDebugging('[useDirectConnect] Cleanup - disconnecting')
|
||||
manager.disconnect()
|
||||
managerRef.current = null
|
||||
}
|
||||
}, [config, setMessages, setIsLoading, setToolUseConfirmQueue])
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: RemoteMessageContent): Promise<boolean> => {
|
||||
const manager = managerRef.current
|
||||
if (!manager) {
|
||||
return false
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
return manager.sendMessage(content)
|
||||
},
|
||||
[setIsLoading],
|
||||
)
|
||||
|
||||
// 取消当前请求
|
||||
const cancelRequest = useCallback(() => {
|
||||
// 向服务器发送中断信号
|
||||
managerRef.current?.sendInterrupt()
|
||||
|
||||
setIsLoading(false)
|
||||
}, [setIsLoading])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
managerRef.current?.disconnect()
|
||||
managerRef.current = null
|
||||
isConnectedRef.current = false
|
||||
}, [])
|
||||
|
||||
// 与 useRemoteSession 相同的稳定性问题 — 记忆化,以便
|
||||
// 依赖于结果对象的消费者每次渲染不会看到新引用。
|
||||
return useMemo(
|
||||
() => ({ isRemoteMode, sendMessage, cancelRequest, disconnect }),
|
||||
[isRemoteMode, sendMessage, cancelRequest, disconnect],
|
||||
)
|
||||
}
|
||||
68
claude-code源码-中文注释/src/hooks/useDoublePress.ts
Normal file
68
claude-code源码-中文注释/src/hooks/useDoublePress.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// 创建一个函数,在第一次调用时执行一个函数,在特定超时内的第二次调用时执行另一个函数
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
export const DOUBLE_PRESS_TIMEOUT_MS = 800
|
||||
|
||||
/**
|
||||
* 检测双击或单按的 Hook。
|
||||
* @param setPending 设置待处理状态的回调
|
||||
* @param onDoublePress 双击时要执行的回调
|
||||
* @param onFirstPress 可选的首次按压回调
|
||||
* @returns 用于处理按压事件的回调函数
|
||||
*/
|
||||
export function useDoublePress(
|
||||
setPending: (pending: boolean) => void,
|
||||
onDoublePress: () => void,
|
||||
onFirstPress?: () => void,
|
||||
): () => void {
|
||||
const lastPressRef = useRef<number>(0)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
||||
|
||||
const clearTimeoutSafe = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 卸载时清理超时
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeoutSafe()
|
||||
}
|
||||
}, [clearTimeoutSafe])
|
||||
|
||||
return useCallback(() => {
|
||||
const now = Date.now()
|
||||
const timeSinceLastPress = now - lastPressRef.current
|
||||
const isDoublePress =
|
||||
timeSinceLastPress <= DOUBLE_PRESS_TIMEOUT_MS &&
|
||||
timeoutRef.current !== undefined
|
||||
|
||||
if (isDoublePress) {
|
||||
// 检测到双击
|
||||
clearTimeoutSafe()
|
||||
setPending(false)
|
||||
onDoublePress()
|
||||
} else {
|
||||
// 首次按压
|
||||
onFirstPress?.()
|
||||
setPending(true)
|
||||
|
||||
// 清除任何现有超时并设置新的
|
||||
clearTimeoutSafe()
|
||||
timeoutRef.current = setTimeout(
|
||||
(setPending, timeoutRef) => {
|
||||
setPending(false)
|
||||
timeoutRef.current = undefined
|
||||
},
|
||||
DOUBLE_PRESS_TIMEOUT_MS,
|
||||
setPending,
|
||||
timeoutRef,
|
||||
)
|
||||
}
|
||||
|
||||
lastPressRef.current = now
|
||||
}, [setPending, onDoublePress, onFirstPress, clearTimeoutSafe])
|
||||
}
|
||||
22
claude-code源码-中文注释/src/hooks/useDynamicConfig.ts
Normal file
22
claude-code源码-中文注释/src/hooks/useDynamicConfig.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import { getDynamicConfig_BLOCKS_ON_INIT } from '../services/analytics/growthbook.js'
|
||||
|
||||
/**
|
||||
* 用于动态配置值的 React Hook。
|
||||
* 最初返回默认值,然后在获取配置时更新。
|
||||
*/
|
||||
export function useDynamicConfig<T>(configName: string, defaultValue: T): T {
|
||||
const [configValue, setConfigValue] = React.useState<T>(defaultValue)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
// 防止在测试中使用此 hook 时测试挂起
|
||||
return
|
||||
}
|
||||
void getDynamicConfig_BLOCKS_ON_INIT<T>(configName, defaultValue).then(
|
||||
setConfigValue,
|
||||
)
|
||||
}, [configName, defaultValue])
|
||||
|
||||
return configValue
|
||||
}
|
||||
37
claude-code源码-中文注释/src/hooks/useElapsedTime.ts
Normal file
37
claude-code源码-中文注释/src/hooks/useElapsedTime.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
import { formatDuration } from '../utils/format.js'
|
||||
|
||||
/**
|
||||
* 返回自 startTime 以来经过的格式化时间的 Hook。
|
||||
* 使用带有基于间隔更新的 useSyncExternalStore 以提高效率。
|
||||
*
|
||||
* @param startTime - Unix 时间戳(毫秒)
|
||||
* @param isRunning - 是否主动更新计时器
|
||||
* @param ms - 我们应该多久触发一次更新?
|
||||
* @param pausedMs - 要减去的总暂停时间
|
||||
* @param endTime - 如果设置,则在此时间戳冻结持续时间(用于
|
||||
* 终端任务)。没有这个,查看完成 30 分钟后的 2 分钟任务
|
||||
* 会显示 "32m"。
|
||||
* @returns 格式化的时间字符串(例如 "1m 23s")
|
||||
*/
|
||||
export function useElapsedTime(
|
||||
startTime: number,
|
||||
isRunning: boolean,
|
||||
ms: number = 1000,
|
||||
pausedMs: number = 0,
|
||||
endTime?: number,
|
||||
): string {
|
||||
const get = () =>
|
||||
formatDuration(Math.max(0, (endTime ?? Date.now()) - startTime - pausedMs))
|
||||
|
||||
const subscribe = useCallback(
|
||||
(notify: () => void) => {
|
||||
if (!isRunning) return () => {}
|
||||
const interval = setInterval(notify, ms)
|
||||
return () => clearInterval(interval)
|
||||
},
|
||||
[isRunning, ms],
|
||||
)
|
||||
|
||||
return useSyncExternalStore(subscribe, get, get)
|
||||
}
|
||||
95
claude-code源码-中文注释/src/hooks/useExitOnCtrlCD.ts
Normal file
95
claude-code源码-中文注释/src/hooks/useExitOnCtrlCD.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import useApp from '../ink/hooks/use-app.js'
|
||||
import type { KeybindingContextName } from '../keybindings/types.js'
|
||||
import { useDoublePress } from './useDoublePress.js'
|
||||
|
||||
export type ExitState = {
|
||||
pending: boolean
|
||||
keyName: 'Ctrl-C' | 'Ctrl-D' | null
|
||||
}
|
||||
|
||||
type KeybindingOptions = {
|
||||
context?: KeybindingContextName
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
type UseKeybindingsHook = (
|
||||
handlers: Record<string, () => void>,
|
||||
options?: KeybindingOptions,
|
||||
) => void
|
||||
|
||||
/**
|
||||
* 处理 ctrl+c 和 ctrl+d 以退出应用。
|
||||
*
|
||||
* 使用基于时间的双按机制:
|
||||
* - 第一次按下:显示"再次按 X 以退出"消息
|
||||
* - 超时内第二次按下:退出应用
|
||||
*
|
||||
* 注意:我们使用基于时间的双按而不是和弦系统,因为
|
||||
* 我们希望第一次 ctrl+c 也触发中断(由其他地方处理)。
|
||||
* 和弦系统会阻止第一次按下触发任何操作。
|
||||
*
|
||||
* 这些键是硬编码的,不能通过 keybindings.json 重新绑定。
|
||||
*
|
||||
* @param useKeybindingsHook - 用于注册处理程序的 useKeybindings hook
|
||||
* (依赖注入以避免导入循环)
|
||||
* @param onInterrupt - 功能处理中断的可选回调(ctrl+c)。
|
||||
* 如果已处理则返回 true,false 回退到双按退出。
|
||||
* @param onExit - 可选的自定义退出处理程序
|
||||
* @param isActive - 键绑定是否活动(默认为 true)。在嵌入式
|
||||
* TextInput 聚焦时设置为 false — TextInput 自己的
|
||||
* ctrl+c/d 处理程序将管理取消/退出,而 Dialog 的
|
||||
* 处理程序否则会双重触发(子 useInput 在父 useKeybindings 之前运行,
|
||||
* 所以两者都看到每个按键)。
|
||||
*/
|
||||
export function useExitOnCtrlCD(
|
||||
useKeybindingsHook: UseKeybindingsHook,
|
||||
onInterrupt?: () => boolean,
|
||||
onExit?: () => void,
|
||||
isActive = true,
|
||||
): ExitState {
|
||||
const { exit } = useApp()
|
||||
const [exitState, setExitState] = useState<ExitState>({
|
||||
pending: false,
|
||||
keyName: null,
|
||||
})
|
||||
|
||||
const exitFn = useMemo(() => onExit ?? exit, [onExit, exit])
|
||||
|
||||
// ctrl+c 的双按处理程序
|
||||
const handleCtrlCDoublePress = useDoublePress(
|
||||
pending => setExitState({ pending, keyName: 'Ctrl-C' }),
|
||||
exitFn,
|
||||
)
|
||||
|
||||
// ctrl+d 的双按处理程序
|
||||
const handleCtrlDDoublePress = useDoublePress(
|
||||
pending => setExitState({ pending, keyName: 'Ctrl-D' }),
|
||||
exitFn,
|
||||
)
|
||||
|
||||
// app:interrupt 的处理程序(默认为 ctrl+c)
|
||||
// 首先让功能通过回调处理中断
|
||||
const handleInterrupt = useCallback(() => {
|
||||
if (onInterrupt?.()) return // 功能已处理
|
||||
handleCtrlCDoublePress()
|
||||
}, [handleCtrlCDoublePress, onInterrupt])
|
||||
|
||||
// app:exit 的处理程序(默认为 ctrl+d)
|
||||
// 这也使用双按来确认退出
|
||||
const handleExit = useCallback(() => {
|
||||
handleCtrlDDoublePress()
|
||||
}, [handleCtrlDDoublePress])
|
||||
|
||||
const handlers = useMemo(
|
||||
() => ({
|
||||
'app:interrupt': handleInterrupt,
|
||||
'app:exit': handleExit,
|
||||
}),
|
||||
[handleInterrupt, handleExit],
|
||||
)
|
||||
|
||||
useKeybindingsHook(handlers, { context: 'Global', isActive })
|
||||
|
||||
return exitState
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useKeybindings } from '../keybindings/useKeybinding.js'
|
||||
import { type ExitState, useExitOnCtrlCD } from './useExitOnCtrlCD.js'
|
||||
|
||||
export type { ExitState }
|
||||
|
||||
/**
|
||||
* 将 useExitOnCtrlCD 与 useKeybindings 连接的便捷 Hook。
|
||||
*
|
||||
* 这是组件中使用 useExitOnCtrlCD 的标准方式。
|
||||
* 分离是为了避免导入循环 — useExitOnCtrlCD.ts
|
||||
* 不直接从 keybindings 模块导入。
|
||||
*
|
||||
* @param onExit - 可选的自定义退出处理程序
|
||||
* @param onInterrupt - 可选的特性处理中断(ctrl+c)的回调。
|
||||
* 返回 true 表示已处理,false 回退到双按退出。
|
||||
* @param isActive - 键绑定是否处于活动状态(默认为 true)。
|
||||
*/
|
||||
export function useExitOnCtrlCDWithKeybindings(
|
||||
onExit?: () => void,
|
||||
onInterrupt?: () => boolean,
|
||||
isActive?: boolean,
|
||||
): ExitState {
|
||||
return useExitOnCtrlCD(useKeybindings, onInterrupt, onExit, isActive)
|
||||
}
|
||||
106
claude-code源码-中文注释/src/hooks/useFileHistorySnapshotInit.ts
Normal file
106
claude-code源码-中文注释/src/hooks/useFileHistorySnapshotInit.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { getLastInteractionTime } from '../bootstrap/state.js'
|
||||
import { fetchPrStatus, type PrReviewState } from '../utils/ghPrStatus.js'
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000
|
||||
const SLOW_GH_THRESHOLD_MS = 4_000
|
||||
const IDLE_STOP_MS = 60 * 60_000 // 空闲 60 分钟后停止轮询
|
||||
|
||||
export type PrStatusState = {
|
||||
number: number | null
|
||||
url: string | null
|
||||
reviewState: PrReviewState | null
|
||||
lastUpdated: number
|
||||
}
|
||||
|
||||
const INITIAL_STATE: PrStatusState = {
|
||||
number: null,
|
||||
url: null,
|
||||
reviewState: null,
|
||||
lastUpdated: 0,
|
||||
}
|
||||
|
||||
/**
|
||||
* 在会话活动时每 60 秒轮询 PR 审核状态。
|
||||
* 当 60 分钟内未检测到交互时,循环停止 — 没有
|
||||
* 计时器保留。React 在 isLoading 更改时重新运行效果
|
||||
*(turn 开始/结束),重新启动循环。效果设置调度
|
||||
* 相对于上次获取时间下次轮询,因此 turn 边界
|
||||
* 不会在每个间隔内产生多次 `gh`。如果获取超过 4 秒,
|
||||
* 则永久禁用。
|
||||
*
|
||||
* 传递 `enabled: false` 完全跳过轮询(hook 仍然必须
|
||||
* 无条件调用以满足 hooks 的规则)。
|
||||
*/
|
||||
export function usePrStatus(isLoading: boolean, enabled = true): PrStatusState {
|
||||
const [prStatus, setPrStatus] = useState<PrStatusState>(INITIAL_STATE)
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const disabledRef = useRef(false)
|
||||
const lastFetchRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
if (disabledRef.current) return
|
||||
|
||||
let cancelled = false
|
||||
let lastSeenInteractionTime = -1
|
||||
let lastActivityTimestamp = Date.now()
|
||||
|
||||
async function poll() {
|
||||
if (cancelled) return
|
||||
|
||||
const currentInteractionTime = getLastInteractionTime()
|
||||
if (lastSeenInteractionTime !== currentInteractionTime) {
|
||||
lastSeenInteractionTime = currentInteractionTime
|
||||
lastActivityTimestamp = Date.now()
|
||||
} else if (Date.now() - lastActivityTimestamp >= IDLE_STOP_MS) {
|
||||
return
|
||||
}
|
||||
|
||||
const start = Date.now()
|
||||
const result = await fetchPrStatus()
|
||||
if (cancelled) return
|
||||
lastFetchRef.current = start
|
||||
|
||||
setPrStatus(prev => {
|
||||
const newNumber = result?.number ?? null
|
||||
const newReviewState = result?.reviewState ?? null
|
||||
if (prev.number === newNumber && prev.reviewState === newReviewState) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
number: newNumber,
|
||||
url: result?.url ?? null,
|
||||
reviewState: newReviewState,
|
||||
lastUpdated: Date.now(),
|
||||
}
|
||||
})
|
||||
|
||||
if (Date.now() - start > SLOW_GH_THRESHOLD_MS) {
|
||||
disabledRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
timeoutRef.current = setTimeout(poll, POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - lastFetchRef.current
|
||||
if (elapsed >= POLL_INTERVAL_MS) {
|
||||
void poll()
|
||||
} else {
|
||||
timeoutRef.current = setTimeout(poll, POLL_INTERVAL_MS - elapsed)
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [isLoading, enabled])
|
||||
|
||||
return prStatus
|
||||
}
|
||||
303
claude-code源码-中文注释/src/hooks/useHistorySearch.ts
Normal file
303
claude-code源码-中文注释/src/hooks/useHistorySearch.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
getModeFromInput,
|
||||
getValueFromInput,
|
||||
} from '../components/PromptInput/inputModes.js'
|
||||
import { makeHistoryReader } from '../history.js'
|
||||
import { KeyboardEvent } from '../ink/events/keyboard-event.js'
|
||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- 向后兼容桥接,直到使用者将 handleKeyDown 连接到 <Box onKeyDown>
|
||||
import { useInput } from '../ink.js'
|
||||
import { useKeybinding, useKeybindings } from '../keybindings/useKeybinding.js'
|
||||
import type { PromptInputMode } from '../types/textInputTypes.js'
|
||||
import type { HistoryEntry } from '../utils/config.js'
|
||||
|
||||
export function useHistorySearch(
|
||||
onAcceptHistory: (entry: HistoryEntry) => void,
|
||||
currentInput: string,
|
||||
onInputChange: (input: string) => void,
|
||||
onCursorChange: (cursorOffset: number) => void,
|
||||
currentCursorOffset: number,
|
||||
onModeChange: (mode: PromptInputMode) => void,
|
||||
currentMode: PromptInputMode,
|
||||
isSearching: boolean,
|
||||
setIsSearching: (isSearching: boolean) => void,
|
||||
setPastedContents: (pastedContents: HistoryEntry['pastedContents']) => void,
|
||||
currentPastedContents: HistoryEntry['pastedContents'],
|
||||
): {
|
||||
historyQuery: string
|
||||
setHistoryQuery: (query: string) => void
|
||||
historyMatch: HistoryEntry | undefined
|
||||
historyFailedMatch: boolean
|
||||
handleKeyDown: (e: KeyboardEvent) => void
|
||||
} {
|
||||
const [historyQuery, setHistoryQuery] = useState('')
|
||||
const [historyFailedMatch, setHistoryFailedMatch] = useState(false)
|
||||
const [originalInput, setOriginalInput] = useState('')
|
||||
const [originalCursorOffset, setOriginalCursorOffset] = useState(0)
|
||||
const [originalMode, setOriginalMode] = useState<PromptInputMode>('prompt')
|
||||
const [originalPastedContents, setOriginalPastedContents] = useState<
|
||||
HistoryEntry['pastedContents']
|
||||
>({})
|
||||
const [historyMatch, setHistoryMatch] = useState<HistoryEntry | undefined>(
|
||||
undefined,
|
||||
)
|
||||
const historyReader = useRef<AsyncGenerator<HistoryEntry> | undefined>(
|
||||
undefined,
|
||||
)
|
||||
const seenPrompts = useRef<Set<string>>(new Set())
|
||||
const searchAbortController = useRef<AbortController | null>(null)
|
||||
|
||||
const closeHistoryReader = useCallback((): void => {
|
||||
if (historyReader.current) {
|
||||
// 必须显式调用 .return() 以触发 readLinesReverse 中的 finally 块,
|
||||
// 这会关闭文件句柄。没有这个,文件描述符会泄漏。
|
||||
void historyReader.current.return(undefined)
|
||||
historyReader.current = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setIsSearching(false)
|
||||
setHistoryQuery('')
|
||||
setHistoryFailedMatch(false)
|
||||
setOriginalInput('')
|
||||
setOriginalCursorOffset(0)
|
||||
setOriginalMode('prompt')
|
||||
setOriginalPastedContents({})
|
||||
setHistoryMatch(undefined)
|
||||
closeHistoryReader()
|
||||
seenPrompts.current.clear()
|
||||
}, [setIsSearching, closeHistoryReader])
|
||||
|
||||
const searchHistory = useCallback(
|
||||
async (resume: boolean, signal?: AbortSignal): Promise<void> => {
|
||||
if (!isSearching) {
|
||||
return
|
||||
}
|
||||
|
||||
if (historyQuery.length === 0) {
|
||||
closeHistoryReader()
|
||||
seenPrompts.current.clear()
|
||||
setHistoryMatch(undefined)
|
||||
setHistoryFailedMatch(false)
|
||||
onInputChange(originalInput)
|
||||
onCursorChange(originalCursorOffset)
|
||||
onModeChange(originalMode)
|
||||
setPastedContents(originalPastedContents)
|
||||
return
|
||||
}
|
||||
|
||||
if (!resume) {
|
||||
closeHistoryReader()
|
||||
historyReader.current = makeHistoryReader()
|
||||
seenPrompts.current.clear()
|
||||
}
|
||||
|
||||
if (!historyReader.current) {
|
||||
return
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
const item = await historyReader.current.next()
|
||||
if (item.done) {
|
||||
// 未找到匹配 — 保留最后匹配但标记为失败
|
||||
setHistoryFailedMatch(true)
|
||||
return
|
||||
}
|
||||
|
||||
const display = item.value.display
|
||||
|
||||
const matchPosition = display.lastIndexOf(historyQuery)
|
||||
if (matchPosition !== -1 && !seenPrompts.current.has(display)) {
|
||||
seenPrompts.current.add(display)
|
||||
setHistoryMatch(item.value)
|
||||
setHistoryFailedMatch(false)
|
||||
const mode = getModeFromInput(display)
|
||||
onModeChange(mode)
|
||||
onInputChange(display)
|
||||
setPastedContents(item.value.pastedContents)
|
||||
|
||||
// 相对于 clean 值而不是 display 定位光标
|
||||
const value = getValueFromInput(display)
|
||||
const cleanMatchPosition = value.lastIndexOf(historyQuery)
|
||||
onCursorChange(
|
||||
cleanMatchPosition !== -1 ? cleanMatchPosition : matchPosition,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
isSearching,
|
||||
historyQuery,
|
||||
closeHistoryReader,
|
||||
onInputChange,
|
||||
onCursorChange,
|
||||
onModeChange,
|
||||
setPastedContents,
|
||||
originalInput,
|
||||
originalCursorOffset,
|
||||
originalMode,
|
||||
originalPastedContents,
|
||||
],
|
||||
)
|
||||
|
||||
// 处理程序:开始历史搜索(当不在搜索时)
|
||||
const handleStartSearch = useCallback(() => {
|
||||
setIsSearching(true)
|
||||
setOriginalInput(currentInput)
|
||||
setOriginalCursorOffset(currentCursorOffset)
|
||||
setOriginalMode(currentMode)
|
||||
setOriginalPastedContents(currentPastedContents)
|
||||
historyReader.current = makeHistoryReader()
|
||||
seenPrompts.current.clear()
|
||||
}, [
|
||||
setIsSearching,
|
||||
currentInput,
|
||||
currentCursorOffset,
|
||||
currentMode,
|
||||
currentPastedContents,
|
||||
])
|
||||
|
||||
// 处理程序:查找下一个匹配(当搜索时)
|
||||
const handleNextMatch = useCallback(() => {
|
||||
void searchHistory(true)
|
||||
}, [searchHistory])
|
||||
|
||||
// 处理程序:接受当前匹配并退出搜索
|
||||
const handleAccept = useCallback(() => {
|
||||
if (historyMatch) {
|
||||
const mode = getModeFromInput(historyMatch.display)
|
||||
const value = getValueFromInput(historyMatch.display)
|
||||
onInputChange(value)
|
||||
onModeChange(mode)
|
||||
setPastedContents(historyMatch.pastedContents)
|
||||
} else {
|
||||
// 无匹配 — 恢复原始粘贴内容
|
||||
setPastedContents(originalPastedContents)
|
||||
}
|
||||
reset()
|
||||
}, [
|
||||
historyMatch,
|
||||
onInputChange,
|
||||
onModeChange,
|
||||
setPastedContents,
|
||||
originalPastedContents,
|
||||
reset,
|
||||
])
|
||||
|
||||
// 处理程序:取消搜索并恢复原始输入
|
||||
const handleCancel = useCallback(() => {
|
||||
onInputChange(originalInput)
|
||||
onCursorChange(originalCursorOffset)
|
||||
setPastedContents(originalPastedContents)
|
||||
reset()
|
||||
}, [
|
||||
onInputChange,
|
||||
onCursorChange,
|
||||
setPastedContents,
|
||||
originalInput,
|
||||
originalCursorOffset,
|
||||
originalPastedContents,
|
||||
reset,
|
||||
])
|
||||
|
||||
// 处理程序:执行(接受并提交)
|
||||
const handleExecute = useCallback(() => {
|
||||
if (historyQuery.length === 0) {
|
||||
onAcceptHistory({
|
||||
display: originalInput,
|
||||
pastedContents: originalPastedContents,
|
||||
})
|
||||
} else if (historyMatch) {
|
||||
const mode = getModeFromInput(historyMatch.display)
|
||||
const value = getValueFromInput(historyMatch.display)
|
||||
onModeChange(mode)
|
||||
onAcceptHistory({
|
||||
display: value,
|
||||
pastedContents: historyMatch.pastedContents,
|
||||
})
|
||||
}
|
||||
reset()
|
||||
}, [
|
||||
historyQuery,
|
||||
historyMatch,
|
||||
onAcceptHistory,
|
||||
onModeChange,
|
||||
originalInput,
|
||||
originalPastedContents,
|
||||
reset,
|
||||
])
|
||||
|
||||
// 在 HISTORY_PICKER 下关闭 — 模态对话框在那里拥有 ctrl+r。
|
||||
useKeybinding('history:search', handleStartSearch, {
|
||||
context: 'Global',
|
||||
isActive: feature('HISTORY_PICKER') ? false : !isSearching,
|
||||
})
|
||||
|
||||
// 历史搜索上下文键绑定(仅在搜索时活动)
|
||||
const historySearchHandlers = useMemo(
|
||||
() => ({
|
||||
'historySearch:next': handleNextMatch,
|
||||
'historySearch:accept': handleAccept,
|
||||
'historySearch:cancel': handleCancel,
|
||||
'historySearch:execute': handleExecute,
|
||||
}),
|
||||
[handleNextMatch, handleAccept, handleCancel, handleExecute],
|
||||
)
|
||||
|
||||
useKeybindings(historySearchHandlers, {
|
||||
context: 'HistorySearch',
|
||||
isActive: isSearching,
|
||||
})
|
||||
|
||||
// 当查询为空时处理 backspace(取消搜索)
|
||||
// 这是不适合键绑定模型的 conditional 行为
|
||||
//(backspace 仅在查询为空时取消)
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (!isSearching) return
|
||||
if (e.key === 'backspace' && historyQuery === '') {
|
||||
e.preventDefault()
|
||||
handleCancel()
|
||||
}
|
||||
}
|
||||
|
||||
// 向后兼容桥接:PromptInput 尚未将 handleKeyDown 连接到
|
||||
// <Box onKeyDown>。通过 useInput 订阅并适配 InputEvent →
|
||||
// KeyboardEvent,直到使用者被迁移(单独的 PR)。
|
||||
// TODO(onKeyDown-migration): 一旦 PromptInput 传递 handleKeyDown 就移除。
|
||||
useInput(
|
||||
(_input, _key, event) => {
|
||||
handleKeyDown(new KeyboardEvent(event.keypress))
|
||||
},
|
||||
{ isActive: isSearching },
|
||||
)
|
||||
|
||||
// 保持对 searchHistory 的引用以避免它成为 useEffect 的依赖
|
||||
const searchHistoryRef = useRef(searchHistory)
|
||||
searchHistoryRef.current = searchHistory
|
||||
|
||||
// 查询更改时重置历史搜索
|
||||
useEffect(() => {
|
||||
searchAbortController.current?.abort()
|
||||
const controller = new AbortController()
|
||||
searchAbortController.current = controller
|
||||
void searchHistoryRef.current(false, controller.signal)
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
}, [historyQuery])
|
||||
|
||||
return {
|
||||
historyQuery,
|
||||
setHistoryQuery,
|
||||
historyMatch,
|
||||
historyFailedMatch,
|
||||
handleKeyDown,
|
||||
}
|
||||
}
|
||||
75
claude-code源码-中文注释/src/hooks/useIdeAtMentioned.ts
Normal file
75
claude-code源码-中文注释/src/hooks/useIdeAtMentioned.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { logError } from 'src/utils/log.js'
|
||||
import { z } from 'zod/v4'
|
||||
import type {
|
||||
ConnectedMCPServer,
|
||||
MCPServerConnection,
|
||||
} from '../services/mcp/types.js'
|
||||
import { getConnectedIdeClient } from '../utils/ide.js'
|
||||
import { lazySchema } from '../utils/lazySchema.js'
|
||||
export type IDEAtMentioned = {
|
||||
filePath: string
|
||||
lineStart?: number
|
||||
lineEnd?: number
|
||||
}
|
||||
|
||||
const NOTIFICATION_METHOD = 'at_mentioned'
|
||||
|
||||
const AtMentionedSchema = lazySchema(() =>
|
||||
z.object({
|
||||
method: z.literal(NOTIFICATION_METHOD),
|
||||
params: z.object({
|
||||
filePath: z.string(),
|
||||
lineStart: z.number().optional(),
|
||||
lineEnd: z.number().optional(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* 通过直接注册 MCP 客户端通知处理程序来跟踪 IDE at-mention 通知的 Hook。
|
||||
*/
|
||||
export function useIdeAtMentioned(
|
||||
mcpClients: MCPServerConnection[],
|
||||
onAtMentioned: (atMentioned: IDEAtMentioned) => void,
|
||||
): void {
|
||||
const ideClientRef = useRef<ConnectedMCPServer | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
// 从 MCP 客户端列表中查找 IDE 客户端
|
||||
const ideClient = getConnectedIdeClient(mcpClients)
|
||||
|
||||
if (ideClientRef.current !== ideClient) {
|
||||
ideClientRef.current = ideClient
|
||||
}
|
||||
|
||||
// 如果找到了连接的 IDE 客户端,注册处理程序
|
||||
if (ideClient) {
|
||||
ideClient.client.setNotificationHandler(
|
||||
AtMentionedSchema(),
|
||||
notification => {
|
||||
if (ideClientRef.current !== ideClient) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = notification.params
|
||||
// 调整行号从 0-based 变为 1-based
|
||||
const lineStart =
|
||||
data.lineStart !== undefined ? data.lineStart + 1 : undefined
|
||||
const lineEnd =
|
||||
data.lineEnd !== undefined ? data.lineEnd + 1 : undefined
|
||||
onAtMentioned({
|
||||
filePath: data.filePath,
|
||||
lineStart: lineStart,
|
||||
lineEnd: lineEnd,
|
||||
})
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 无需清理,因为 MCP 客户端管理自己的生命周期
|
||||
}, [mcpClients, onAtMentioned])
|
||||
}
|
||||
37
claude-code源码-中文注释/src/hooks/useIdeConnectionStatus.ts
Normal file
37
claude-code源码-中文注释/src/hooks/useIdeConnectionStatus.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { MCPServerConnection } from '../services/mcp/types.js'
|
||||
|
||||
export type IdeStatus = 'connected' | 'disconnected' | 'pending' | null
|
||||
|
||||
type IdeConnectionResult = {
|
||||
status: IdeStatus
|
||||
ideName: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 IDE 连接状态的 Hook。
|
||||
* 从 MCP 客户端列表中查找 IDE 客户端并返回其连接状态。
|
||||
*/
|
||||
export function useIdeConnectionStatus(
|
||||
mcpClients?: MCPServerConnection[],
|
||||
): IdeConnectionResult {
|
||||
return useMemo(() => {
|
||||
const ideClient = mcpClients?.find(client => client.name === 'ide')
|
||||
if (!ideClient) {
|
||||
return { status: null, ideName: null }
|
||||
}
|
||||
// 如果可用,从配置中提取 IDE 名称
|
||||
const config = ideClient.config
|
||||
const ideName =
|
||||
config.type === 'sse-ide' || config.type === 'ws-ide'
|
||||
? config.ideName
|
||||
: null
|
||||
if (ideClient.type === 'connected') {
|
||||
return { status: 'connected', ideName }
|
||||
}
|
||||
if (ideClient.type === 'pending') {
|
||||
return { status: 'pending', ideName }
|
||||
}
|
||||
return { status: 'disconnected', ideName }
|
||||
}, [mcpClients])
|
||||
}
|
||||
45
claude-code源码-中文注释/src/hooks/useIdeLogging.ts
Normal file
45
claude-code源码-中文注释/src/hooks/useIdeLogging.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from 'react'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import { z } from 'zod/v4'
|
||||
import type { MCPServerConnection } from '../services/mcp/types.js'
|
||||
import { getConnectedIdeClient } from '../utils/ide.js'
|
||||
import { lazySchema } from '../utils/lazySchema.js'
|
||||
|
||||
const LogEventSchema = lazySchema(() =>
|
||||
z.object({
|
||||
method: z.literal('log_event'),
|
||||
params: z.object({
|
||||
eventName: z.string(),
|
||||
eventData: z.object({}).passthrough(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* 为 MCP 客户端注册 IDE 日志事件处理程序。
|
||||
* 从 IDE 客户端接收 log_event 通知并发送到分析服务。
|
||||
*/
|
||||
export function useIdeLogging(mcpClients: MCPServerConnection[]): void {
|
||||
useEffect(() => {
|
||||
// 如果没有客户端则跳过
|
||||
if (!mcpClients.length) {
|
||||
return
|
||||
}
|
||||
|
||||
// 从 MCP 客户端列表中查找 IDE 客户端
|
||||
const ideClient = getConnectedIdeClient(mcpClients)
|
||||
if (ideClient) {
|
||||
// 注册日志事件处理程序
|
||||
ideClient.client.setNotificationHandler(
|
||||
LogEventSchema(),
|
||||
notification => {
|
||||
const { eventName, eventData } = notification.params
|
||||
logEvent(
|
||||
`tengu_ide_${eventName}`,
|
||||
eventData as { [key: string]: boolean | number | undefined },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}, [mcpClients])
|
||||
}
|
||||
148
claude-code源码-中文注释/src/hooks/useIdeSelection.ts
Normal file
148
claude-code源码-中文注释/src/hooks/useIdeSelection.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { logError } from 'src/utils/log.js'
|
||||
import { z } from 'zod/v4'
|
||||
import type {
|
||||
ConnectedMCPServer,
|
||||
MCPServerConnection,
|
||||
} from '../services/mcp/types.js'
|
||||
import { getConnectedIdeClient } from '../utils/ide.js'
|
||||
import { lazySchema } from '../utils/lazySchema.js'
|
||||
export type SelectionPoint = {
|
||||
line: number
|
||||
character: number
|
||||
}
|
||||
|
||||
export type SelectionData = {
|
||||
selection: {
|
||||
start: SelectionPoint
|
||||
end: SelectionPoint
|
||||
} | null
|
||||
text?: string
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
export type IDESelection = {
|
||||
lineCount: number
|
||||
lineStart?: number
|
||||
text?: string
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
// 定义选择更改通知模式
|
||||
const SelectionChangedSchema = lazySchema(() =>
|
||||
z.object({
|
||||
method: z.literal('selection_changed'),
|
||||
params: z.object({
|
||||
selection: z
|
||||
.object({
|
||||
start: z.object({
|
||||
line: z.number(),
|
||||
character: z.number(),
|
||||
}),
|
||||
end: z.object({
|
||||
line: z.number(),
|
||||
character: z.number(),
|
||||
}),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
text: z.string().optional(),
|
||||
filePath: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* 通过直接注册 MCP 客户端通知处理程序来跟踪 IDE 文本选择信息的 Hook。
|
||||
*/
|
||||
export function useIdeSelection(
|
||||
mcpClients: MCPServerConnection[],
|
||||
onSelect: (selection: IDESelection) => void,
|
||||
): void {
|
||||
const handlersRegistered = useRef(false)
|
||||
const currentIDERef = useRef<ConnectedMCPServer | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 从 MCP 客户端列表中查找 IDE 客户端
|
||||
const ideClient = getConnectedIdeClient(mcpClients)
|
||||
|
||||
// 如果 IDE 客户端更改了,我们需要重新注册处理程序。
|
||||
// 将 undefined 规范化为 null,这样初始 ref 值 (null) 匹配
|
||||
// "未找到 IDE"(undefined),避免每次 MCP 更新时出现虚假重置。
|
||||
if (currentIDERef.current !== (ideClient ?? null)) {
|
||||
handlersRegistered.current = false
|
||||
currentIDERef.current = ideClient || null
|
||||
// 当 IDE 客户端更改时重置选择。
|
||||
onSelect({
|
||||
lineCount: 0,
|
||||
lineStart: undefined,
|
||||
text: undefined,
|
||||
filePath: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// 如果已经为当前 IDE 注册了处理程序或没有 IDE 客户端,则跳过
|
||||
if (handlersRegistered.current || !ideClient) {
|
||||
return
|
||||
}
|
||||
|
||||
// 选择更改的处理函数
|
||||
const selectionChangeHandler = (data: SelectionData) => {
|
||||
if (data.selection?.start && data.selection?.end) {
|
||||
const { start, end } = data.selection
|
||||
let lineCount = end.line - start.line + 1
|
||||
// 如果在行首字符,则不将该行计为已选中。
|
||||
if (end.character === 0) {
|
||||
lineCount--
|
||||
}
|
||||
const selection = {
|
||||
lineCount,
|
||||
lineStart: start.line,
|
||||
text: data.text,
|
||||
filePath: data.filePath,
|
||||
}
|
||||
|
||||
onSelect(selection)
|
||||
}
|
||||
}
|
||||
|
||||
// 为 selection_changed 事件注册通知处理程序
|
||||
ideClient.client.setNotificationHandler(
|
||||
SelectionChangedSchema(),
|
||||
notification => {
|
||||
if (currentIDERef.current !== ideClient) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 从通知参数获取选择数据
|
||||
const selectionData = notification.params
|
||||
|
||||
// 处理选择数据 - 验证它是否有必需的属性
|
||||
if (
|
||||
selectionData.selection &&
|
||||
selectionData.selection.start &&
|
||||
selectionData.selection.end
|
||||
) {
|
||||
// 处理选择更改
|
||||
selectionChangeHandler(selectionData as SelectionData)
|
||||
} else if (selectionData.text !== undefined) {
|
||||
// 处理空选择(当文本是空字符串时)
|
||||
selectionChangeHandler({
|
||||
selection: null,
|
||||
text: selectionData.text,
|
||||
filePath: selectionData.filePath,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 标记我们已注册处理程序
|
||||
handlersRegistered.current = true
|
||||
|
||||
// 无需清理,因为 MCP 客户端管理自己的生命周期
|
||||
}, [mcpClients, onSelect])
|
||||
}
|
||||
969
claude-code源码-中文注释/src/hooks/useInboxPoller.ts
Normal file
969
claude-code源码-中文注释/src/hooks/useInboxPoller.ts
Normal file
@@ -0,0 +1,969 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useInterval } from 'usehooks-ts'
|
||||
import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js'
|
||||
import { TEAMMATE_MESSAGE_TAG } from '../constants/xml.js'
|
||||
import { useTerminalNotification } from '../ink/useTerminalNotification.js'
|
||||
import { sendNotification } from '../services/notifier.js'
|
||||
import {
|
||||
type AppState,
|
||||
useAppState,
|
||||
useAppStateStore,
|
||||
useSetAppState,
|
||||
} from '../state/AppState.js'
|
||||
import { findToolByName } from '../Tool.js'
|
||||
import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js'
|
||||
import { getAllBaseTools } from '../tools.js'
|
||||
import type { PermissionUpdate } from '../types/permissions.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import {
|
||||
findInProcessTeammateTaskId,
|
||||
handlePlanApprovalResponse,
|
||||
} from '../utils/inProcessTeammateHelpers.js'
|
||||
import { createAssistantMessage } from '../utils/messages.js'
|
||||
import {
|
||||
permissionModeFromString,
|
||||
toExternalPermissionMode,
|
||||
} from '../utils/permissions/PermissionMode.js'
|
||||
import { applyPermissionUpdate } from '../utils/permissions/PermissionUpdate.js'
|
||||
import { jsonStringify } from '../utils/slowOperations.js'
|
||||
import { isInsideTmux } from '../utils/swarm/backends/detection.js'
|
||||
import {
|
||||
ensureBackendsRegistered,
|
||||
getBackendByType,
|
||||
} from '../utils/swarm/backends/registry.js'
|
||||
import type { PaneBackendType } from '../utils/swarm/backends/types.js'
|
||||
import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js'
|
||||
import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js'
|
||||
import { sendPermissionResponseViaMailbox } from '../utils/swarm/permissionSync.js'
|
||||
import {
|
||||
removeTeammateFromTeamFile,
|
||||
setMemberMode,
|
||||
} from '../utils/swarm/teamHelpers.js'
|
||||
import { unassignTeammateTasks } from '../utils/tasks.js'
|
||||
import {
|
||||
getAgentName,
|
||||
isPlanModeRequired,
|
||||
isTeamLead,
|
||||
isTeammate,
|
||||
} from '../utils/teammate.js'
|
||||
import { isInProcessTeammate } from '../utils/teammateContext.js'
|
||||
import {
|
||||
isModeSetRequest,
|
||||
isPermissionRequest,
|
||||
isPermissionResponse,
|
||||
isPlanApprovalRequest,
|
||||
isPlanApprovalResponse,
|
||||
isSandboxPermissionRequest,
|
||||
isSandboxPermissionResponse,
|
||||
isShutdownApproved,
|
||||
isShutdownRequest,
|
||||
isTeamPermissionUpdate,
|
||||
markMessagesAsRead,
|
||||
readUnreadMessages,
|
||||
type TeammateMessage,
|
||||
writeToMailbox,
|
||||
} from '../utils/teammateMailbox.js'
|
||||
import {
|
||||
hasPermissionCallback,
|
||||
hasSandboxPermissionCallback,
|
||||
processMailboxPermissionResponse,
|
||||
processSandboxPermissionResponse,
|
||||
} from './useSwarmPermissionPoller.js'
|
||||
|
||||
/**
|
||||
* Get the agent name to poll for messages.
|
||||
* - In-process teammates return undefined (they use waitForNextPromptOrShutdown instead)
|
||||
* - Process-based teammates use their CLAUDE_CODE_AGENT_NAME
|
||||
* - Team leads use their name from teamContext.teammates
|
||||
* - Standalone sessions return undefined
|
||||
*/
|
||||
function getAgentNameToPoll(appState: AppState): string | undefined {
|
||||
// In-process teammates should NOT use useInboxPoller - they have their own
|
||||
// polling mechanism via waitForNextPromptOrShutdown() in inProcessRunner.ts.
|
||||
// Using useInboxPoller would cause message routing issues since in-process
|
||||
// teammates share the same React context and AppState with the leader.
|
||||
//
|
||||
// Note: This can be called when the leader's REPL re-renders while an
|
||||
// in-process teammate's AsyncLocalStorage context is active (due to shared
|
||||
// setAppState). We return undefined to gracefully skip polling rather than
|
||||
// throwing, since this is a normal occurrence during concurrent execution.
|
||||
if (isInProcessTeammate()) {
|
||||
return undefined
|
||||
}
|
||||
if (isTeammate()) {
|
||||
return getAgentName()
|
||||
}
|
||||
// Team lead polls using their agent name (not ID)
|
||||
if (isTeamLead(appState.teamContext)) {
|
||||
const leadAgentId = appState.teamContext!.leadAgentId
|
||||
// Look up the lead's name from teammates map
|
||||
const leadName = appState.teamContext!.teammates[leadAgentId]?.name
|
||||
return leadName || 'team-lead'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const INBOX_POLL_INTERVAL_MS = 1000
|
||||
|
||||
type Props = {
|
||||
enabled: boolean
|
||||
isLoading: boolean
|
||||
focusedInputDialog: string | undefined
|
||||
// Returns true if submission succeeded, false if rejected (e.g., query already running)
|
||||
// Dead code elimination: parameter named onSubmitMessage to avoid "teammate" string in external builds
|
||||
onSubmitMessage: (formatted: string) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询队友收件箱获取新消息并将其作为轮次提交。
|
||||
*
|
||||
* 这个 Hook:
|
||||
* 1. 每 1s 轮询未读消息(队友或团队负责人)
|
||||
* 2. 空闲时:立即将消息作为新轮次提交
|
||||
* 3. 忙碌时:将消息排队到 AppState.inbox 进行 UI 显示,在轮次结束时传递
|
||||
*/
|
||||
export function useInboxPoller({
|
||||
enabled,
|
||||
isLoading,
|
||||
focusedInputDialog,
|
||||
onSubmitMessage,
|
||||
}: Props): void {
|
||||
// 在函数内使用原始名称以便于理解
|
||||
const onSubmitTeammateMessage = onSubmitMessage
|
||||
const store = useAppStateStore()
|
||||
const setAppState = useSetAppState()
|
||||
const inboxMessageCount = useAppState(s => s.inbox.messages.length)
|
||||
const terminal = useTerminalNotification()
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
if (!enabled) return
|
||||
|
||||
// Use ref to avoid dependency on appState object (prevents infinite loop)
|
||||
const currentAppState = store.getState()
|
||||
const agentName = getAgentNameToPoll(currentAppState)
|
||||
if (!agentName) return
|
||||
|
||||
const unread = await readUnreadMessages(
|
||||
agentName,
|
||||
currentAppState.teamContext?.teamName,
|
||||
)
|
||||
|
||||
if (unread.length === 0) return
|
||||
|
||||
logForDebugging(`[InboxPoller] Found ${unread.length} unread message(s)`)
|
||||
|
||||
// Check for plan approval responses and transition out of plan mode if approved
|
||||
// Security: Only accept approval responses from the team lead
|
||||
if (isTeammate() && isPlanModeRequired()) {
|
||||
for (const msg of unread) {
|
||||
const approvalResponse = isPlanApprovalResponse(msg.text)
|
||||
// Verify the message is from the team lead to prevent teammates from forging approvals
|
||||
if (approvalResponse && msg.from === 'team-lead') {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Received plan approval response from team-lead: approved=${approvalResponse.approved}`,
|
||||
)
|
||||
if (approvalResponse.approved) {
|
||||
// Use leader's permission mode if provided, otherwise default
|
||||
const targetMode = approvalResponse.permissionMode ?? 'default'
|
||||
|
||||
// Transition out of plan mode
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
toolPermissionContext: applyPermissionUpdate(
|
||||
prev.toolPermissionContext,
|
||||
{
|
||||
type: 'setMode',
|
||||
mode: toExternalPermissionMode(targetMode),
|
||||
destination: 'session',
|
||||
},
|
||||
),
|
||||
}))
|
||||
logForDebugging(
|
||||
`[InboxPoller] Plan approved by team lead, exited plan mode to ${targetMode}`,
|
||||
)
|
||||
} else {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Plan rejected by team lead: ${approvalResponse.feedback || 'No feedback provided'}`,
|
||||
)
|
||||
}
|
||||
} else if (approvalResponse) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Ignoring plan approval response from non-team-lead: ${msg.from}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to mark messages as read in the inbox file.
|
||||
// Called after messages are successfully delivered or reliably queued.
|
||||
const markRead = () => {
|
||||
void markMessagesAsRead(agentName, currentAppState.teamContext?.teamName)
|
||||
}
|
||||
|
||||
// Separate permission messages from regular teammate messages
|
||||
const permissionRequests: TeammateMessage[] = []
|
||||
const permissionResponses: TeammateMessage[] = []
|
||||
const sandboxPermissionRequests: TeammateMessage[] = []
|
||||
const sandboxPermissionResponses: TeammateMessage[] = []
|
||||
const shutdownRequests: TeammateMessage[] = []
|
||||
const shutdownApprovals: TeammateMessage[] = []
|
||||
const teamPermissionUpdates: TeammateMessage[] = []
|
||||
const modeSetRequests: TeammateMessage[] = []
|
||||
const planApprovalRequests: TeammateMessage[] = []
|
||||
const regularMessages: TeammateMessage[] = []
|
||||
|
||||
for (const m of unread) {
|
||||
const permReq = isPermissionRequest(m.text)
|
||||
const permResp = isPermissionResponse(m.text)
|
||||
const sandboxReq = isSandboxPermissionRequest(m.text)
|
||||
const sandboxResp = isSandboxPermissionResponse(m.text)
|
||||
const shutdownReq = isShutdownRequest(m.text)
|
||||
const shutdownApproval = isShutdownApproved(m.text)
|
||||
const teamPermUpdate = isTeamPermissionUpdate(m.text)
|
||||
const modeSetReq = isModeSetRequest(m.text)
|
||||
const planApprovalReq = isPlanApprovalRequest(m.text)
|
||||
|
||||
if (permReq) {
|
||||
permissionRequests.push(m)
|
||||
} else if (permResp) {
|
||||
permissionResponses.push(m)
|
||||
} else if (sandboxReq) {
|
||||
sandboxPermissionRequests.push(m)
|
||||
} else if (sandboxResp) {
|
||||
sandboxPermissionResponses.push(m)
|
||||
} else if (shutdownReq) {
|
||||
shutdownRequests.push(m)
|
||||
} else if (shutdownApproval) {
|
||||
shutdownApprovals.push(m)
|
||||
} else if (teamPermUpdate) {
|
||||
teamPermissionUpdates.push(m)
|
||||
} else if (modeSetReq) {
|
||||
modeSetRequests.push(m)
|
||||
} else if (planApprovalReq) {
|
||||
planApprovalRequests.push(m)
|
||||
} else {
|
||||
regularMessages.push(m)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle permission requests (leader side) - route to ToolUseConfirmQueue
|
||||
if (
|
||||
permissionRequests.length > 0 &&
|
||||
isTeamLead(currentAppState.teamContext)
|
||||
) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Found ${permissionRequests.length} permission request(s)`,
|
||||
)
|
||||
|
||||
const setToolUseConfirmQueue = getLeaderToolUseConfirmQueue()
|
||||
const teamName = currentAppState.teamContext?.teamName
|
||||
|
||||
for (const m of permissionRequests) {
|
||||
const parsed = isPermissionRequest(m.text)
|
||||
if (!parsed) continue
|
||||
|
||||
if (setToolUseConfirmQueue) {
|
||||
// Route through the standard ToolUseConfirmQueue so tmux workers
|
||||
// get the same tool-specific UI (BashPermissionRequest, FileEditToolDiff, etc.)
|
||||
// as in-process teammates.
|
||||
const tool = findToolByName(getAllBaseTools(), parsed.tool_name)
|
||||
if (!tool) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Unknown tool ${parsed.tool_name}, skipping permission request`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const entry: ToolUseConfirm = {
|
||||
assistantMessage: createAssistantMessage({ content: '' }),
|
||||
tool,
|
||||
description: parsed.description,
|
||||
input: parsed.input,
|
||||
toolUseContext: {} as ToolUseConfirm['toolUseContext'],
|
||||
toolUseID: parsed.tool_use_id,
|
||||
permissionResult: {
|
||||
behavior: 'ask',
|
||||
message: parsed.description,
|
||||
},
|
||||
permissionPromptStartTimeMs: Date.now(),
|
||||
workerBadge: {
|
||||
name: parsed.agent_id,
|
||||
color: 'cyan',
|
||||
},
|
||||
onUserInteraction() {
|
||||
// No-op for tmux workers (no classifier auto-approval)
|
||||
},
|
||||
onAbort() {
|
||||
void sendPermissionResponseViaMailbox(
|
||||
parsed.agent_id,
|
||||
{ decision: 'rejected', resolvedBy: 'leader' },
|
||||
parsed.request_id,
|
||||
teamName,
|
||||
)
|
||||
},
|
||||
onAllow(
|
||||
updatedInput: Record<string, unknown>,
|
||||
permissionUpdates: PermissionUpdate[],
|
||||
) {
|
||||
void sendPermissionResponseViaMailbox(
|
||||
parsed.agent_id,
|
||||
{
|
||||
decision: 'approved',
|
||||
resolvedBy: 'leader',
|
||||
updatedInput,
|
||||
permissionUpdates,
|
||||
},
|
||||
parsed.request_id,
|
||||
teamName,
|
||||
)
|
||||
},
|
||||
onReject(feedback?: string) {
|
||||
void sendPermissionResponseViaMailbox(
|
||||
parsed.agent_id,
|
||||
{
|
||||
decision: 'rejected',
|
||||
resolvedBy: 'leader',
|
||||
feedback,
|
||||
},
|
||||
parsed.request_id,
|
||||
teamName,
|
||||
)
|
||||
},
|
||||
async recheckPermission() {
|
||||
// No-op for tmux workers — permission state is on the worker side
|
||||
},
|
||||
}
|
||||
|
||||
// Deduplicate: if markMessagesAsRead failed on a prior poll,
|
||||
// the same message will be re-read — skip if already queued.
|
||||
setToolUseConfirmQueue(queue => {
|
||||
if (queue.some(q => q.toolUseID === parsed.tool_use_id)) {
|
||||
return queue
|
||||
}
|
||||
return [...queue, entry]
|
||||
})
|
||||
} else {
|
||||
logForDebugging(
|
||||
`[InboxPoller] ToolUseConfirmQueue unavailable, dropping permission request from ${parsed.agent_id}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Send desktop notification for the first request
|
||||
const firstParsed = isPermissionRequest(permissionRequests[0]?.text ?? '')
|
||||
if (firstParsed && !isLoading && !focusedInputDialog) {
|
||||
void sendNotification(
|
||||
{
|
||||
message: `${firstParsed.agent_id} needs permission for ${firstParsed.tool_name}`,
|
||||
notificationType: 'worker_permission_prompt',
|
||||
},
|
||||
terminal,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle permission responses (worker side) - invoke registered callbacks
|
||||
if (permissionResponses.length > 0 && isTeammate()) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Found ${permissionResponses.length} permission response(s)`,
|
||||
)
|
||||
|
||||
for (const m of permissionResponses) {
|
||||
const parsed = isPermissionResponse(m.text)
|
||||
if (!parsed) continue
|
||||
|
||||
if (hasPermissionCallback(parsed.request_id)) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Processing permission response for ${parsed.request_id}: ${parsed.subtype}`,
|
||||
)
|
||||
|
||||
if (parsed.subtype === 'success') {
|
||||
processMailboxPermissionResponse({
|
||||
requestId: parsed.request_id,
|
||||
decision: 'approved',
|
||||
updatedInput: parsed.response?.updated_input,
|
||||
permissionUpdates: parsed.response?.permission_updates,
|
||||
})
|
||||
} else {
|
||||
processMailboxPermissionResponse({
|
||||
requestId: parsed.request_id,
|
||||
decision: 'rejected',
|
||||
feedback: parsed.error,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle sandbox permission requests (leader side) - add to workerSandboxPermissions queue
|
||||
if (
|
||||
sandboxPermissionRequests.length > 0 &&
|
||||
isTeamLead(currentAppState.teamContext)
|
||||
) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Found ${sandboxPermissionRequests.length} sandbox permission request(s)`,
|
||||
)
|
||||
|
||||
const newSandboxRequests: Array<{
|
||||
requestId: string
|
||||
workerId: string
|
||||
workerName: string
|
||||
workerColor?: string
|
||||
host: string
|
||||
createdAt: number
|
||||
}> = []
|
||||
|
||||
for (const m of sandboxPermissionRequests) {
|
||||
const parsed = isSandboxPermissionRequest(m.text)
|
||||
if (!parsed) continue
|
||||
|
||||
// Validate required nested fields to prevent crashes from malformed messages
|
||||
if (!parsed.hostPattern?.host) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Invalid sandbox permission request: missing hostPattern.host`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
newSandboxRequests.push({
|
||||
requestId: parsed.requestId,
|
||||
workerId: parsed.workerId,
|
||||
workerName: parsed.workerName,
|
||||
workerColor: parsed.workerColor,
|
||||
host: parsed.hostPattern.host,
|
||||
createdAt: parsed.createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
if (newSandboxRequests.length > 0) {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
workerSandboxPermissions: {
|
||||
...prev.workerSandboxPermissions,
|
||||
queue: [
|
||||
...prev.workerSandboxPermissions.queue,
|
||||
...newSandboxRequests,
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
// Send desktop notification for the first new request
|
||||
const firstRequest = newSandboxRequests[0]
|
||||
if (firstRequest && !isLoading && !focusedInputDialog) {
|
||||
void sendNotification(
|
||||
{
|
||||
message: `${firstRequest.workerName} needs network access to ${firstRequest.host}`,
|
||||
notificationType: 'worker_permission_prompt',
|
||||
},
|
||||
terminal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle sandbox permission responses (worker side) - invoke registered callbacks
|
||||
if (sandboxPermissionResponses.length > 0 && isTeammate()) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Found ${sandboxPermissionResponses.length} sandbox permission response(s)`,
|
||||
)
|
||||
|
||||
for (const m of sandboxPermissionResponses) {
|
||||
const parsed = isSandboxPermissionResponse(m.text)
|
||||
if (!parsed) continue
|
||||
|
||||
// Check if we have a registered callback for this request
|
||||
if (hasSandboxPermissionCallback(parsed.requestId)) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Processing sandbox permission response for ${parsed.requestId}: allow=${parsed.allow}`,
|
||||
)
|
||||
|
||||
// Process the response using the exported function
|
||||
processSandboxPermissionResponse({
|
||||
requestId: parsed.requestId,
|
||||
host: parsed.host,
|
||||
allow: parsed.allow,
|
||||
})
|
||||
|
||||
// Clear the pending sandbox request indicator
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
pendingSandboxRequest: null,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle team permission updates (teammate side) - apply permission to context
|
||||
if (teamPermissionUpdates.length > 0 && isTeammate()) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Found ${teamPermissionUpdates.length} team permission update(s)`,
|
||||
)
|
||||
|
||||
for (const m of teamPermissionUpdates) {
|
||||
const parsed = isTeamPermissionUpdate(m.text)
|
||||
if (!parsed) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Failed to parse team permission update: ${m.text.substring(0, 100)}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate required nested fields to prevent crashes from malformed messages
|
||||
if (
|
||||
!parsed.permissionUpdate?.rules ||
|
||||
!parsed.permissionUpdate?.behavior
|
||||
) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Invalid team permission update: missing permissionUpdate.rules or permissionUpdate.behavior`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply the permission update to the teammate's context
|
||||
logForDebugging(
|
||||
`[InboxPoller] Applying team permission update: ${parsed.toolName} allowed in ${parsed.directoryPath}`,
|
||||
)
|
||||
logForDebugging(
|
||||
`[InboxPoller] Permission update rules: ${jsonStringify(parsed.permissionUpdate.rules)}`,
|
||||
)
|
||||
|
||||
setAppState(prev => {
|
||||
const updated = applyPermissionUpdate(prev.toolPermissionContext, {
|
||||
type: 'addRules',
|
||||
rules: parsed.permissionUpdate.rules,
|
||||
behavior: parsed.permissionUpdate.behavior,
|
||||
destination: 'session',
|
||||
})
|
||||
logForDebugging(
|
||||
`[InboxPoller] Updated session allow rules: ${jsonStringify(updated.alwaysAllowRules.session)}`,
|
||||
)
|
||||
return {
|
||||
...prev,
|
||||
toolPermissionContext: updated,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle mode set requests (teammate side) - team lead changing teammate's mode
|
||||
if (modeSetRequests.length > 0 && isTeammate()) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Found ${modeSetRequests.length} mode set request(s)`,
|
||||
)
|
||||
|
||||
for (const m of modeSetRequests) {
|
||||
// Only accept mode changes from team-lead
|
||||
if (m.from !== 'team-lead') {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Ignoring mode set request from non-team-lead: ${m.from}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const parsed = isModeSetRequest(m.text)
|
||||
if (!parsed) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Failed to parse mode set request: ${m.text.substring(0, 100)}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const targetMode = permissionModeFromString(parsed.mode)
|
||||
logForDebugging(
|
||||
`[InboxPoller] Applying mode change from team-lead: ${targetMode}`,
|
||||
)
|
||||
|
||||
// Update local permission context
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
toolPermissionContext: applyPermissionUpdate(
|
||||
prev.toolPermissionContext,
|
||||
{
|
||||
type: 'setMode',
|
||||
mode: toExternalPermissionMode(targetMode),
|
||||
destination: 'session',
|
||||
},
|
||||
),
|
||||
}))
|
||||
|
||||
// Update config.json so team lead can see the new mode
|
||||
const teamName = currentAppState.teamContext?.teamName
|
||||
const agentName = getAgentName()
|
||||
if (teamName && agentName) {
|
||||
setMemberMode(teamName, agentName, targetMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle plan approval requests (leader side) - auto-approve and write response to teammate inbox
|
||||
if (
|
||||
planApprovalRequests.length > 0 &&
|
||||
isTeamLead(currentAppState.teamContext)
|
||||
) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Found ${planApprovalRequests.length} plan approval request(s), auto-approving`,
|
||||
)
|
||||
|
||||
const teamName = currentAppState.teamContext?.teamName
|
||||
const leaderExternalMode = toExternalPermissionMode(
|
||||
currentAppState.toolPermissionContext.mode,
|
||||
)
|
||||
const modeToInherit =
|
||||
leaderExternalMode === 'plan' ? 'default' : leaderExternalMode
|
||||
|
||||
for (const m of planApprovalRequests) {
|
||||
const parsed = isPlanApprovalRequest(m.text)
|
||||
if (!parsed) continue
|
||||
|
||||
// Write approval response to teammate's inbox
|
||||
const approvalResponse = {
|
||||
type: 'plan_approval_response',
|
||||
requestId: parsed.requestId,
|
||||
approved: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
permissionMode: modeToInherit,
|
||||
}
|
||||
|
||||
void writeToMailbox(
|
||||
m.from,
|
||||
{
|
||||
from: TEAM_LEAD_NAME,
|
||||
text: jsonStringify(approvalResponse),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
teamName,
|
||||
)
|
||||
|
||||
// Update in-process teammate task state if applicable
|
||||
const taskId = findInProcessTeammateTaskId(m.from, currentAppState)
|
||||
if (taskId) {
|
||||
handlePlanApprovalResponse(
|
||||
taskId,
|
||||
{
|
||||
type: 'plan_approval_response',
|
||||
requestId: parsed.requestId,
|
||||
approved: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
permissionMode: modeToInherit,
|
||||
},
|
||||
setAppState,
|
||||
)
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[InboxPoller] Auto-approved plan from ${m.from} (request ${parsed.requestId})`,
|
||||
)
|
||||
|
||||
// Still pass through as a regular message so the model has context
|
||||
// about what the teammate is doing, but the approval is already sent
|
||||
regularMessages.push(m)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle shutdown requests (teammate side) - preserve JSON for UI rendering
|
||||
if (shutdownRequests.length > 0 && isTeammate()) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Found ${shutdownRequests.length} shutdown request(s)`,
|
||||
)
|
||||
|
||||
// Pass through shutdown requests - the UI component will Render them nicely
|
||||
// and the model will receive instructions via the tool prompt documentation
|
||||
for (const m of shutdownRequests) {
|
||||
regularMessages.push(m)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle shutdown approvals (leader side) - kill the teammate's pane
|
||||
if (
|
||||
shutdownApprovals.length > 0 &&
|
||||
isTeamLead(currentAppState.teamContext)
|
||||
) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Found ${shutdownApprovals.length} shutdown approval(s)`,
|
||||
)
|
||||
|
||||
for (const m of shutdownApprovals) {
|
||||
const parsed = isShutdownApproved(m.text)
|
||||
if (!parsed) continue
|
||||
|
||||
// Kill the pane if we have the info (pane-based teammates)
|
||||
if (parsed.paneId && parsed.backendType) {
|
||||
void (async () => {
|
||||
try {
|
||||
// Ensure backend classes are imported (no subprocess probes)
|
||||
await ensureBackendsRegistered()
|
||||
const insideTmux = await isInsideTmux()
|
||||
const backend = getBackendByType(
|
||||
parsed.backendType as PaneBackendType,
|
||||
)
|
||||
const success = await backend?.killPane(
|
||||
parsed.paneId!,
|
||||
!insideTmux,
|
||||
)
|
||||
logForDebugging(
|
||||
`[InboxPoller] Killed pane ${parsed.paneId} for ${parsed.from}: ${success}`,
|
||||
)
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Failed to kill pane for ${parsed.from}: ${error}`,
|
||||
)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// Remove the teammate from teamContext.teammates so the count is accurate
|
||||
const teammateToRemove = parsed.from
|
||||
if (teammateToRemove && currentAppState.teamContext?.teammates) {
|
||||
// Find the teammate ID by name
|
||||
const teammateId = Object.entries(
|
||||
currentAppState.teamContext.teammates,
|
||||
).find(([, t]) => t.name === teammateToRemove)?.[0]
|
||||
|
||||
if (teammateId) {
|
||||
// Remove from team file (leader owns team file mutations)
|
||||
const teamName = currentAppState.teamContext?.teamName
|
||||
if (teamName) {
|
||||
removeTeammateFromTeamFile(teamName, {
|
||||
agentId: teammateId,
|
||||
name: teammateToRemove,
|
||||
})
|
||||
}
|
||||
|
||||
// Unassign tasks and build notification message
|
||||
const { notificationMessage } = teamName
|
||||
? await unassignTeammateTasks(
|
||||
teamName,
|
||||
teammateId,
|
||||
teammateToRemove,
|
||||
'shutdown',
|
||||
)
|
||||
: { notificationMessage: `${teammateToRemove} has shut down.` }
|
||||
|
||||
setAppState(prev => {
|
||||
if (!prev.teamContext?.teammates) return prev
|
||||
if (!(teammateId in prev.teamContext.teammates)) return prev
|
||||
const { [teammateId]: _, ...remainingTeammates } =
|
||||
prev.teamContext.teammates
|
||||
|
||||
// Mark the teammate's task as completed so hasRunningTeammates
|
||||
// becomes false and the spinner stops. Without this, out-of-process
|
||||
// (tmux) teammate tasks stay status:'running' forever because
|
||||
// only in-process teammates have a runner that sets 'completed'.
|
||||
const updatedTasks = { ...prev.tasks }
|
||||
for (const [tid, task] of Object.entries(updatedTasks)) {
|
||||
if (
|
||||
isInProcessTeammateTask(task) &&
|
||||
task.identity.agentId === teammateId
|
||||
) {
|
||||
updatedTasks[tid] = {
|
||||
...task,
|
||||
status: 'completed' as const,
|
||||
endTime: Date.now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
tasks: updatedTasks,
|
||||
teamContext: {
|
||||
...prev.teamContext,
|
||||
teammates: remainingTeammates,
|
||||
},
|
||||
inbox: {
|
||||
messages: [
|
||||
...prev.inbox.messages,
|
||||
{
|
||||
id: randomUUID(),
|
||||
from: 'system',
|
||||
text: jsonStringify({
|
||||
type: 'teammate_terminated',
|
||||
message: notificationMessage,
|
||||
}),
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'pending' as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
})
|
||||
logForDebugging(
|
||||
`[InboxPoller] Removed ${teammateToRemove} (${teammateId}) from teamContext`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Pass through for UI rendering - the component will render it nicely
|
||||
regularMessages.push(m)
|
||||
}
|
||||
}
|
||||
|
||||
// Process regular teammate messages (existing logic)
|
||||
if (regularMessages.length === 0) {
|
||||
// No regular messages, but we may have processed non-regular messages
|
||||
// (permissions, shutdown requests, etc.) above — mark those as read.
|
||||
markRead()
|
||||
return
|
||||
}
|
||||
|
||||
// Format messages with XML wrapper for Claude (include color if available)
|
||||
// Transform plan approval requests to include instructions for Claude
|
||||
const formatted = regularMessages
|
||||
.map(m => {
|
||||
const colorAttr = m.color ? ` color="${m.color}"` : ''
|
||||
const summaryAttr = m.summary ? ` summary="${m.summary}"` : ''
|
||||
const messageContent = m.text
|
||||
|
||||
return `<${TEAMMATE_MESSAGE_TAG} teammate_id="${m.from}"${colorAttr}${summaryAttr}>\n${messageContent}\n</${TEAMMATE_MESSAGE_TAG}>`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
// Helper to queue messages in AppState for later delivery
|
||||
const queueMessages = () => {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
inbox: {
|
||||
messages: [
|
||||
...prev.inbox.messages,
|
||||
...regularMessages.map(m => ({
|
||||
id: randomUUID(),
|
||||
from: m.from,
|
||||
text: m.text,
|
||||
timestamp: m.timestamp,
|
||||
status: 'pending' as const,
|
||||
color: m.color,
|
||||
summary: m.summary,
|
||||
})),
|
||||
],
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
if (!isLoading && !focusedInputDialog) {
|
||||
// IDLE: Submit as new turn immediately
|
||||
logForDebugging(`[InboxPoller] Session idle, submitting immediately`)
|
||||
const submitted = onSubmitTeammateMessage(formatted)
|
||||
if (!submitted) {
|
||||
// Submission rejected (query already running), queue for later
|
||||
logForDebugging(
|
||||
`[InboxPoller] Submission rejected, queuing for later delivery`,
|
||||
)
|
||||
queueMessages()
|
||||
}
|
||||
} else {
|
||||
// BUSY: Add to inbox queue for UI display + later delivery
|
||||
logForDebugging(`[InboxPoller] Session busy, queuing for later delivery`)
|
||||
queueMessages()
|
||||
}
|
||||
|
||||
// Mark messages as read only after they have been successfully delivered
|
||||
// or reliably queued in AppState. This prevents permanent message loss
|
||||
// when the session is busy — if we crash before this point, the messages
|
||||
// will be re-read on the next poll cycle instead of being silently dropped.
|
||||
markRead()
|
||||
}, [
|
||||
enabled,
|
||||
isLoading,
|
||||
focusedInputDialog,
|
||||
onSubmitTeammateMessage,
|
||||
setAppState,
|
||||
terminal,
|
||||
store,
|
||||
])
|
||||
|
||||
// When session becomes idle, deliver any pending messages and clean up processed ones
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
// Skip if busy or in a dialog
|
||||
if (isLoading || focusedInputDialog) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use ref to avoid dependency on appState object (prevents infinite loop)
|
||||
const currentAppState = store.getState()
|
||||
const agentName = getAgentNameToPoll(currentAppState)
|
||||
if (!agentName) return
|
||||
|
||||
const pendingMessages = currentAppState.inbox.messages.filter(
|
||||
m => m.status === 'pending',
|
||||
)
|
||||
const processedMessages = currentAppState.inbox.messages.filter(
|
||||
m => m.status === 'processed',
|
||||
)
|
||||
|
||||
// Clean up processed messages (they were already delivered mid-turn as attachments)
|
||||
if (processedMessages.length > 0) {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Cleaning up ${processedMessages.length} processed message(s) that were delivered mid-turn`,
|
||||
)
|
||||
const processedIds = new Set(processedMessages.map(m => m.id))
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
inbox: {
|
||||
messages: prev.inbox.messages.filter(m => !processedIds.has(m.id)),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// No pending messages to deliver
|
||||
if (pendingMessages.length === 0) return
|
||||
|
||||
logForDebugging(
|
||||
`[InboxPoller] Session idle, delivering ${pendingMessages.length} pending message(s)`,
|
||||
)
|
||||
|
||||
// Format messages with XML wrapper for Claude (include color if available)
|
||||
const formatted = pendingMessages
|
||||
.map(m => {
|
||||
const colorAttr = m.color ? ` color="${m.color}"` : ''
|
||||
const summaryAttr = m.summary ? ` summary="${m.summary}"` : ''
|
||||
return `<${TEAMMATE_MESSAGE_TAG} teammate_id="${m.from}"${colorAttr}${summaryAttr}>\n${m.text}\n</${TEAMMATE_MESSAGE_TAG}>`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
// Try to submit - only clear messages if successful
|
||||
const submitted = onSubmitTeammateMessage(formatted)
|
||||
if (submitted) {
|
||||
// Clear the specific messages we just submitted by their IDs
|
||||
const submittedIds = new Set(pendingMessages.map(m => m.id))
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
inbox: {
|
||||
messages: prev.inbox.messages.filter(m => !submittedIds.has(m.id)),
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
logForDebugging(
|
||||
`[InboxPoller] Submission rejected, keeping messages queued`,
|
||||
)
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
isLoading,
|
||||
focusedInputDialog,
|
||||
onSubmitTeammateMessage,
|
||||
setAppState,
|
||||
inboxMessageCount,
|
||||
store,
|
||||
])
|
||||
|
||||
// Poll if running as a teammate or as a team lead
|
||||
const shouldPoll = enabled && !!getAgentNameToPoll(store.getState())
|
||||
useInterval(() => void poll(), shouldPoll ? INBOX_POLL_INTERVAL_MS : null)
|
||||
|
||||
// Initial poll on mount (only once)
|
||||
const hasDoneInitialPollRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
if (hasDoneInitialPollRef.current) return
|
||||
// Use store.getState() to avoid dependency on appState object
|
||||
if (getAgentNameToPoll(store.getState())) {
|
||||
hasDoneInitialPollRef.current = true
|
||||
void poll()
|
||||
}
|
||||
// Note: poll uses store.getState() (not appState) so it won't re-run on appState changes
|
||||
// The ref guard is a safety measure to ensure initial poll only happens once
|
||||
}, [enabled, poll, store])
|
||||
}
|
||||
137
claude-code源码-中文注释/src/hooks/useInputBuffer.ts
Normal file
137
claude-code源码-中文注释/src/hooks/useInputBuffer.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { PastedContent } from '../utils/config.js'
|
||||
|
||||
export type BufferEntry = {
|
||||
text: string
|
||||
cursorOffset: number
|
||||
pastedContents: Record<number, PastedContent>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type UseInputBufferProps = {
|
||||
maxBufferSize: number
|
||||
debounceMs: number
|
||||
}
|
||||
|
||||
export type UseInputBufferResult = {
|
||||
pushToBuffer: (
|
||||
text: string,
|
||||
cursorOffset: number,
|
||||
pastedContents?: Record<number, PastedContent>,
|
||||
) => void
|
||||
undo: () => BufferEntry | undefined
|
||||
canUndo: boolean
|
||||
clearBuffer: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理输入缓冲区的 Hook,支持撤销功能。
|
||||
* 用于存储用户输入历史,允许撤销先前的输入。
|
||||
* 缓冲区有最大大小限制,支持防抖以避免快速连续变化。
|
||||
*/
|
||||
export function useInputBuffer({
|
||||
maxBufferSize,
|
||||
debounceMs,
|
||||
}: UseInputBufferProps): UseInputBufferResult {
|
||||
const [buffer, setBuffer] = useState<BufferEntry[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||
const lastPushTime = useRef<number>(0)
|
||||
const pendingPush = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const pushToBuffer = useCallback(
|
||||
(
|
||||
text: string,
|
||||
cursorOffset: number,
|
||||
pastedContents: Record<number, PastedContent> = {},
|
||||
) => {
|
||||
const now = Date.now()
|
||||
|
||||
// 清除任何待处理的推送
|
||||
if (pendingPush.current) {
|
||||
clearTimeout(pendingPush.current)
|
||||
pendingPush.current = null
|
||||
}
|
||||
|
||||
// 防抖快速变化
|
||||
if (now - lastPushTime.current < debounceMs) {
|
||||
pendingPush.current = setTimeout(
|
||||
pushToBuffer,
|
||||
debounceMs,
|
||||
text,
|
||||
cursorOffset,
|
||||
pastedContents,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
lastPushTime.current = now
|
||||
|
||||
setBuffer(prevBuffer => {
|
||||
// 如果不在缓冲区末尾,则截断当前位置之后的所有内容
|
||||
const newBuffer =
|
||||
currentIndex >= 0 ? prevBuffer.slice(0, currentIndex + 1) : prevBuffer
|
||||
|
||||
// 如果与最后一个条目相同则不添加
|
||||
const lastEntry = newBuffer[newBuffer.length - 1]
|
||||
if (lastEntry && lastEntry.text === text) {
|
||||
return newBuffer
|
||||
}
|
||||
|
||||
// 添加新条目
|
||||
const updatedBuffer = [
|
||||
...newBuffer,
|
||||
{ text, cursorOffset, pastedContents, timestamp: now },
|
||||
]
|
||||
|
||||
// 限制缓冲区大小
|
||||
if (updatedBuffer.length > maxBufferSize) {
|
||||
return updatedBuffer.slice(-maxBufferSize)
|
||||
}
|
||||
|
||||
return updatedBuffer
|
||||
})
|
||||
|
||||
// 更新当前索引指向新条目
|
||||
setCurrentIndex(prev => {
|
||||
const newIndex = prev >= 0 ? prev + 1 : buffer.length
|
||||
return Math.min(newIndex, maxBufferSize - 1)
|
||||
})
|
||||
},
|
||||
[debounceMs, maxBufferSize, currentIndex, buffer.length],
|
||||
)
|
||||
|
||||
const undo = useCallback((): BufferEntry | undefined => {
|
||||
if (currentIndex < 0 || buffer.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const targetIndex = Math.max(0, currentIndex - 1)
|
||||
const entry = buffer[targetIndex]
|
||||
|
||||
if (entry) {
|
||||
setCurrentIndex(targetIndex)
|
||||
return entry
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, [buffer, currentIndex])
|
||||
|
||||
const clearBuffer = useCallback(() => {
|
||||
setBuffer([])
|
||||
setCurrentIndex(-1)
|
||||
lastPushTime.current = 0
|
||||
if (pendingPush.current) {
|
||||
clearTimeout(pendingPush.current)
|
||||
pendingPush.current = null
|
||||
}
|
||||
}, [lastPushTime, pendingPush])
|
||||
|
||||
const canUndo = currentIndex > 0 && buffer.length > 1
|
||||
|
||||
return {
|
||||
pushToBuffer,
|
||||
undo,
|
||||
canUndo,
|
||||
clearBuffer,
|
||||
}
|
||||
}
|
||||
148
claude-code源码-中文注释/src/hooks/useIssueFlagBanner.ts
Normal file
148
claude-code源码-中文注释/src/hooks/useIssueFlagBanner.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import { getUserMessageText } from '../utils/messages.js'
|
||||
|
||||
// 外部命令模式列表
|
||||
const EXTERNAL_COMMAND_PATTERNS = [
|
||||
/\bcurl\b/,
|
||||
/\bwget\b/,
|
||||
/\bssh\b/,
|
||||
/\bkubectl\b/,
|
||||
/\bsrun\b/,
|
||||
/\bdocker\b/,
|
||||
/\bbq\b/,
|
||||
/\bgsutil\b/,
|
||||
/\bgcloud\b/,
|
||||
/\baws\b/,
|
||||
/\bgit\s+push\b/,
|
||||
/\bgit\s+pull\b/,
|
||||
/\bgit\s+fetch\b/,
|
||||
/\bgh\s+(pr|issue)\b/,
|
||||
/\bnc\b/,
|
||||
/\bncat\b/,
|
||||
/\btelnet\b/,
|
||||
/\bftp\b/,
|
||||
]
|
||||
|
||||
// 摩擦信号模式列表
|
||||
const FRICTION_PATTERNS = [
|
||||
// "No," 或 "No!" 在开头 — 逗号/感叹号暗示纠正语气
|
||||
//(避免 "No problem", "No thanks", "No I think we should...")
|
||||
/^no[,!]\s/i,
|
||||
// 关于 Claude 输出的直接纠正
|
||||
/\bthat'?s (wrong|incorrect|not (what|right|correct))\b/i,
|
||||
/\bnot what I (asked|wanted|meant|said)\b/i,
|
||||
// 引用 Claude 错过的先前指示
|
||||
/\bI (said|asked|wanted|told you|already said)\b/i,
|
||||
// 质疑 Claude 的行为
|
||||
/\bwhy did you\b/i,
|
||||
/\byou should(n'?t| not)? have\b/i,
|
||||
/\byou were supposed to\b/i,
|
||||
// 明确的 Claude 工作重试/撤销
|
||||
/\btry again\b/i,
|
||||
/\b(undo|revert) (that|this|it|what you)\b/i,
|
||||
]
|
||||
|
||||
/**
|
||||
* 检查会话是否与容器兼容。
|
||||
* 如果消息包含 MCP 工具或外部命令(如 curl、ssh、docker 等),返回 false。
|
||||
*/
|
||||
export function isSessionContainerCompatible(messages: Message[]): boolean {
|
||||
for (const msg of messages) {
|
||||
if (msg.type !== 'assistant') {
|
||||
continue
|
||||
}
|
||||
const content = msg.message.content
|
||||
if (!Array.isArray(content)) {
|
||||
continue
|
||||
}
|
||||
for (const block of content) {
|
||||
if (block.type !== 'tool_use' || !('name' in block)) {
|
||||
continue
|
||||
}
|
||||
const toolName = block.name as string
|
||||
if (toolName.startsWith('mcp__')) {
|
||||
return false
|
||||
}
|
||||
if (toolName === BASH_TOOL_NAME) {
|
||||
const input = (block as { input?: Record<string, unknown> }).input
|
||||
const command = (input?.command as string) || ''
|
||||
if (EXTERNAL_COMMAND_PATTERNS.some(p => p.test(command))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查消息中是否存在摩擦信号。
|
||||
* 摩擦信号表示用户可能对 Claude 的行为不满意或需要纠正。
|
||||
*/
|
||||
export function hasFrictionSignal(messages: Message[]): boolean {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]!
|
||||
if (msg.type !== 'user') {
|
||||
continue
|
||||
}
|
||||
const text = getUserMessageText(msg)
|
||||
if (!text) {
|
||||
continue
|
||||
}
|
||||
return FRICTION_PATTERNS.some(p => p.test(text))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const MIN_SUBMIT_COUNT = 3
|
||||
const COOLDOWN_MS = 30 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 决定是否显示问题标记横幅的 Hook。
|
||||
* 仅适用于 ANT 用户。当检测到摩擦信号且会话与容器兼容时触发。
|
||||
* 有冷却期以避免频繁显示。
|
||||
*/
|
||||
export function useIssueFlagBanner(
|
||||
messages: Message[],
|
||||
submitCount: number,
|
||||
): boolean {
|
||||
if (process.env.USER_TYPE !== 'ant') {
|
||||
return false
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant
|
||||
const lastTriggeredAtRef = useRef(0)
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant
|
||||
const activeForSubmitRef = useRef(-1)
|
||||
|
||||
// 记忆化 O(messages) 扫描。这个 hook 在每次 REPL 渲染时运行
|
||||
//(包括每次按键),但 messages 在输入时是稳定的。
|
||||
// isSessionContainerCompatible 遍历所有消息 + 对每个
|
||||
// bash 命令进行正则测试 — 这里是迄今为止最重的工作。
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant
|
||||
const shouldTrigger = useMemo(
|
||||
() => isSessionContainerCompatible(messages) && hasFrictionSignal(messages),
|
||||
[messages],
|
||||
)
|
||||
|
||||
// 保持显示横幅直到用户提交另一条消息
|
||||
if (activeForSubmitRef.current === submitCount) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Date.now() - lastTriggeredAtRef.current < COOLDOWN_MS) {
|
||||
return false
|
||||
}
|
||||
if (submitCount < MIN_SUBMIT_COUNT) {
|
||||
return false
|
||||
}
|
||||
if (!shouldTrigger) {
|
||||
return false
|
||||
}
|
||||
|
||||
lastTriggeredAtRef.current = Date.now()
|
||||
activeForSubmitRef.current = submitCount
|
||||
return true
|
||||
}
|
||||
119
claude-code源码-中文注释/src/hooks/useLogMessages.ts
Normal file
119
claude-code源码-中文注释/src/hooks/useLogMessages.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { UUID } from 'crypto'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useAppState } from '../state/AppState.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'
|
||||
import {
|
||||
cleanMessagesForLogging,
|
||||
isChainParticipant,
|
||||
recordTranscript,
|
||||
} from '../utils/sessionStorage.js'
|
||||
|
||||
/**
|
||||
* 将消息记录到转录本的 Hook
|
||||
* 对话 ID 仅在开始新对话时更改。
|
||||
*
|
||||
* @param messages 当前的对话消息
|
||||
* @param ignore 为 true 时,消息将不被记录到转录本
|
||||
*/
|
||||
export function useLogMessages(messages: Message[], ignore: boolean = false) {
|
||||
const teamContext = useAppState(s => s.teamContext)
|
||||
|
||||
// 消息在压缩之间是仅追加的,所以跟踪我们离开的位置
|
||||
// 只将新尾部传递给 recordTranscript。避免每次 setMessages
|
||||
//(约 20 次/turn)O(n) 过滤+扫描,所以 n=3000 是约 120k 浪费迭代。
|
||||
const lastRecordedLengthRef = useRef(0)
|
||||
const lastParentUuidRef = useRef<UUID | undefined>(undefined)
|
||||
// 第一个 uuid 更改 = 压缩或 /clear 重建了数组;仅长度
|
||||
// 无法检测到这一点,因为压缩后 [CB,summary,...keep,new] 可能更长。
|
||||
const firstMessageUuidRef = useRef<UUID | undefined>(undefined)
|
||||
// 防止过时的 async .then() 在增量渲染触发时覆盖更新的同步更新,
|
||||
// 此时 compaction .then() 尚未解析。
|
||||
const callSeqRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (ignore) return
|
||||
|
||||
const currentFirstUuid = messages[0]?.uuid as UUID | undefined
|
||||
const prevLength = lastRecordedLengthRef.current
|
||||
|
||||
// 首次渲染:firstMessageUuidRef 是 undefined。压缩:第一个 uuid 更改。
|
||||
// 两者都是 !isIncremental,但首次渲染同步遍历是安全的(没有 messagesToKeep)。
|
||||
const wasFirstRender = firstMessageUuidRef.current === undefined
|
||||
const isIncremental =
|
||||
currentFirstUuid !== undefined &&
|
||||
!wasFirstRender &&
|
||||
currentFirstUuid === firstMessageUuidRef.current &&
|
||||
prevLength <= messages.length
|
||||
// 相同头部缩小:墓碑过滤、回退、剪断、部分压缩。
|
||||
// 与压缩的区别(第一个 uuid 更改)是因为尾部
|
||||
// 要么是磁盘上已存在的消息,要么是这条效果
|
||||
// 的 recordTranscript(fullArray) 将写入的新消息 — 见
|
||||
// 下面的同步遍历守卫。
|
||||
const isSameHeadShrink =
|
||||
currentFirstUuid !== undefined &&
|
||||
!wasFirstRender &&
|
||||
currentFirstUuid === firstMessageUuidRef.current &&
|
||||
prevLength > messages.length
|
||||
|
||||
const startIndex = isIncremental ? prevLength : 0
|
||||
if (startIndex === messages.length) return
|
||||
|
||||
// 首次调用 + 压缩后的完整数组:recordTranscript 的
|
||||
// O(n) 去重循环在那里正确处理 messagesToKeep 交错。
|
||||
const slice = startIndex === 0 ? messages : messages.slice(startIndex)
|
||||
const parentHint = isIncremental ? lastParentUuidRef.current : undefined
|
||||
|
||||
// 即发即弃 — 我们不想阻塞 UI。
|
||||
const seq = ++callSeqRef.current
|
||||
void recordTranscript(
|
||||
slice,
|
||||
isAgentSwarmsEnabled()
|
||||
? {
|
||||
teamName: teamContext?.teamName,
|
||||
agentName: teamContext?.selfAgentName,
|
||||
}
|
||||
: {},
|
||||
parentHint,
|
||||
messages,
|
||||
).then(lastRecordedUuid => {
|
||||
// 对于压缩/完整数组情况(!isIncremental):使用异步返回值。
|
||||
// 压缩后,messagesToKeep 在数组中被跳过(已在转录本中),
|
||||
// 所以同步循环会找到错误的 UUID。
|
||||
// 如果更新的效果已经运行则跳过(过时的闭包会覆盖
|
||||
// 来自后续增量渲染的更新同步更新)。
|
||||
if (seq !== callSeqRef.current) return
|
||||
if (lastRecordedUuid && !isIncremental) {
|
||||
lastParentUuidRef.current = lastRecordedUuid
|
||||
}
|
||||
})
|
||||
|
||||
// 同步遍历对以下安全:增量(纯新尾部切片)、
|
||||
// 首次渲染(没有 messagesToKeep 交错)和相同头部缩小。
|
||||
// 缩小是微妙的一个:选取的 uuid 要么已在磁盘上(墓碑/回退
|
||||
// — 幸存者在之前已写入),要么正被此效果的
|
||||
// recordTranscript(fullArray) 调用写入(剪断边界/部分压缩尾部
|
||||
// — enqueueWrite 排序保证它在任何后续写入之前落地
|
||||
// 链接到它)。没有这个,ref 在墓碑 uuid 处保持陈旧:
|
||||
// 异步 .then() 纠正被下一个效果的 seq 碰撞所竞争,
|
||||
// 在大型会话中 recordTranscript(fullArray) 很慢。仅
|
||||
// 压缩情况(第一个 uuid 更改)仍然不安全 — 尾部可能是
|
||||
// messagesToKeep,其最后实际记录的 uuid 不同。
|
||||
if (isIncremental || wasFirstRender || isSameHeadShrink) {
|
||||
// 与 recordTranscript 持久化的内容完全匹配:cleanMessagesForLogging
|
||||
// 应用 isLoggableMessage 过滤器,对于外部用户应用
|
||||
// REPL-strip + isVirtual-promote 转换。在这里使用原始谓词
|
||||
// 会选取转换会删除的 UUID,使 parent hint
|
||||
// 指向从未到达磁盘的消息。传递完整消息作为
|
||||
// replId 上下文 — REPL tool_use 及其 tool_result 落在单独的
|
||||
// 渲染周期中,所以单独的切片无法配对它们。
|
||||
const last = cleanMessagesForLogging(slice, messages).findLast(
|
||||
isChainParticipant,
|
||||
)
|
||||
if (last) lastParentUuidRef.current = last.uuid as UUID
|
||||
}
|
||||
|
||||
lastRecordedLengthRef.current = messages.length
|
||||
firstMessageUuidRef.current = currentFirstUuid
|
||||
}, [messages, ignore, teamContext?.teamName, teamContext?.selfAgentName])
|
||||
}
|
||||
26
claude-code源码-中文注释/src/hooks/useMailboxBridge.ts
Normal file
26
claude-code源码-中文注释/src/hooks/useMailboxBridge.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'
|
||||
import { useMailbox } from '../context/mailbox.js'
|
||||
|
||||
type Props = {
|
||||
isLoading: boolean
|
||||
onSubmitMessage: (content: string) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* useMailboxBridge - 桥接 mailbox 与消息提交
|
||||
* 使用 useSyncExternalStore 订阅 mailbox 的修订版本,
|
||||
* 当检测到新消息时调用 onSubmitMessage 提交内容。
|
||||
*/
|
||||
export function useMailboxBridge({ isLoading, onSubmitMessage }: Props): void {
|
||||
const mailbox = useMailbox()
|
||||
|
||||
const subscribe = useMemo(() => mailbox.subscribe.bind(mailbox), [mailbox])
|
||||
const getSnapshot = useCallback(() => mailbox.revision, [mailbox])
|
||||
const revision = useSyncExternalStore(subscribe, getSnapshot)
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
const msg = mailbox.poll()
|
||||
if (msg) onSubmitMessage(msg.content)
|
||||
}, [isLoading, revision, mailbox, onSubmitMessage])
|
||||
}
|
||||
40
claude-code源码-中文注释/src/hooks/useMainLoopModel.ts
Normal file
40
claude-code源码-中文注释/src/hooks/useMainLoopModel.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useReducer } from 'react'
|
||||
import { onGrowthBookRefresh } from '../services/analytics/growthbook.js'
|
||||
import { useAppState } from '../state/AppState.js'
|
||||
import {
|
||||
getDefaultMainLoopModelSetting,
|
||||
type ModelName,
|
||||
parseUserSpecifiedModel,
|
||||
} from '../utils/model/model.js'
|
||||
|
||||
// 选择器的值是一个完整的模型名称,可以直接在
|
||||
// API 调用中使用。当组件需要在模型配置更改时更新时,
|
||||
// 使用这个而不是 getMainLoopModel()。
|
||||
/**
|
||||
* 获取主循环模型的 Hook。
|
||||
*
|
||||
* 解析用户指定的模型名称,优先使用会话级覆盖。
|
||||
* 订阅 GrowthBook 刷新信号以在远程评估更新时强制重新渲染,
|
||||
* 避免 API 采样一个模型而 /model 显示另一个模型的问题。
|
||||
*/
|
||||
export function useMainLoopModel(): ModelName {
|
||||
const mainLoopModel = useAppState(s => s.mainLoopModel)
|
||||
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession)
|
||||
|
||||
// parseUserSpecifiedModel 通过 _CACHED_MAY_BE_STALE(在 resolveAntModel 中)
|
||||
// 读取 tengu_ant_model_override。在 GB 初始化完成之前,
|
||||
// 这是过时的磁盘缓存;之后,它是内存中的 remoteEval map。
|
||||
// AppState 在 GB 初始化完成时不会改变,所以我们在
|
||||
// 刷新信号上订阅并强制重新渲染以使用新值重新解析。
|
||||
// 没有这个,别名解析会冻结直到有其他事情触发组件重新渲染 —
|
||||
// API 会采样一个模型而 /model(也会重新解析)显示另一个。
|
||||
const [, forceRerender] = useReducer(x => x + 1, 0)
|
||||
useEffect(() => onGrowthBookRefresh(forceRerender), [])
|
||||
|
||||
const model = parseUserSpecifiedModel(
|
||||
mainLoopModelForSession ??
|
||||
mainLoopModel ??
|
||||
getDefaultMainLoopModelSetting(),
|
||||
)
|
||||
return model
|
||||
}
|
||||
301
claude-code源码-中文注释/src/hooks/useManagePlugins.ts
Normal file
301
claude-code源码-中文注释/src/hooks/useManagePlugins.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import type { Command } from '../commands.js'
|
||||
import { useNotifications } from '../context/notifications.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../services/analytics/index.js'
|
||||
import { reinitializeLspServerManager } from '../services/lsp/manager.js'
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js'
|
||||
import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
|
||||
import { count } from '../utils/array.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
||||
import { toError } from '../utils/errors.js'
|
||||
import { logError } from '../utils/log.js'
|
||||
import { loadPluginAgents } from '../utils/plugins/loadPluginAgents.js'
|
||||
import { getPluginCommands } from '../utils/plugins/loadPluginCommands.js'
|
||||
import { loadPluginHooks } from '../utils/plugins/loadPluginHooks.js'
|
||||
import { loadPluginLspServers } from '../utils/plugins/lspPluginIntegration.js'
|
||||
import { loadPluginMcpServers } from '../utils/plugins/mcpPluginIntegration.js'
|
||||
import { detectAndUninstallDelistedPlugins } from '../utils/plugins/pluginBlocklist.js'
|
||||
import { getFlaggedPlugins } from '../utils/plugins/pluginFlagging.js'
|
||||
import { loadAllPlugins } from '../utils/plugins/pluginLoader.js'
|
||||
|
||||
/**
|
||||
* 管理插件状态并与 AppState 同步的 Hook。
|
||||
*
|
||||
* 挂载时:加载所有插件,运行除名 enforcement,显示标记插件
|
||||
* 通知,填充 AppState.plugins。这是初始 Layer-3 加载 —
|
||||
* 后续刷新通过 /reload-plugins 进行。
|
||||
*
|
||||
* 需要刷新时:显示通知引导用户运行 /reload-plugins。
|
||||
* 不会自动刷新。所有 Layer-3 交换(命令、代理、hooks、MCP)
|
||||
* 都通过 refreshActivePlugins() 进行,以保持一致的心智模型。
|
||||
* 参见 Outline: declarative-settings-hXHBMDIf4b PR 5c。
|
||||
*/
|
||||
export function useManagePlugins({
|
||||
enabled = true,
|
||||
}: {
|
||||
enabled?: boolean
|
||||
} = {}) {
|
||||
const setAppState = useSetAppState()
|
||||
const needsRefresh = useAppState(s => s.plugins.needsRefresh)
|
||||
const { addNotification } = useNotifications()
|
||||
|
||||
// 初始插件加载。挂载时运行一次。不用于刷新 —
|
||||
// 所有挂载后刷新都通过 /reload-plugins → refreshActivePlugins() 进行。
|
||||
// 与 refreshActivePlugins 不同,这也运行除名 enforcement 和
|
||||
// 标记插件通知(会话启动关注点),并且不会增加
|
||||
// mcp.pluginReconnectKey(MCP 效果在自己的挂载上触发)。
|
||||
const initialPluginLoad = useCallback(async () => {
|
||||
try {
|
||||
// 加载所有插件 - 捕获错误数组
|
||||
const { enabled, disabled, errors } = await loadAllPlugins()
|
||||
|
||||
// 检测已除名的插件,自动卸载并记录为标记。
|
||||
await detectAndUninstallDelistedPlugins()
|
||||
|
||||
// 如果有标记的插件待处理则通知
|
||||
const flagged = getFlaggedPlugins()
|
||||
if (Object.keys(flagged).length > 0) {
|
||||
addNotification({
|
||||
key: 'plugin-delisted-flagged',
|
||||
text: 'Plugins flagged. Check /plugins',
|
||||
color: 'warning',
|
||||
priority: 'high',
|
||||
})
|
||||
}
|
||||
|
||||
// 加载命令、代理和 hooks,带有单独的錯誤處理
|
||||
// 错误被添加到错误数组中,以便在 Doctor UI 中对用户可见
|
||||
let commands: Command[] = []
|
||||
let agents: AgentDefinition[] = []
|
||||
|
||||
try {
|
||||
commands = await getPluginCommands()
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
errors.push({
|
||||
type: 'generic-error',
|
||||
source: 'plugin-commands',
|
||||
error: `Failed to load plugin commands: ${errorMessage}`,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
agents = await loadPluginAgents()
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
errors.push({
|
||||
type: 'generic-error',
|
||||
source: 'plugin-agents',
|
||||
error: `Failed to load plugin agents: ${errorMessage}`,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await loadPluginHooks()
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
errors.push({
|
||||
type: 'generic-error',
|
||||
source: 'plugin-hooks',
|
||||
error: `Failed to load plugin hooks: ${errorMessage}`,
|
||||
})
|
||||
}
|
||||
|
||||
// 加载每个插件的 MCP 服务器配置以获取准确计数。
|
||||
// LoadedPlugin.mcpServers 不是由 loadAllPlugins 填充的 — 它是一个
|
||||
// 缓存槽,extractMcpServersFromPlugins 稍后填充,这与这个指标竞争。
|
||||
// 直接调用 loadPluginMcpServers(如同 cli/handlers/plugins.ts 所做)
|
||||
// 可以获得正确的计数,并且还可以为 MCP 连接管理器预热缓存。
|
||||
//
|
||||
// 在 setAppState 之前运行,以便这些加载器推送的错误进入
|
||||
// AppState.plugins.errors(Doctor UI),而不仅仅是遥测。
|
||||
const mcpServerCounts = await Promise.all(
|
||||
enabled.map(async p => {
|
||||
if (p.mcpServers) return Object.keys(p.mcpServers).length
|
||||
const servers = await loadPluginMcpServers(p, errors)
|
||||
if (servers) p.mcpServers = servers
|
||||
return servers ? Object.keys(servers).length : 0
|
||||
}),
|
||||
)
|
||||
const mcp_count = mcpServerCounts.reduce((sum, n) => sum + n, 0)
|
||||
|
||||
// LSP:#15521 的主要修复在 refresh.ts(通过
|
||||
// performBackgroundPluginInstallations → refreshActivePlugins,
|
||||
// 它首先清除缓存)。这个重新初始化是防御性的 — 它读取与原始初始化
|
||||
// 相同的 memoized loadAllPlugins() 结果,除非在 main.tsx:3203 和
|
||||
// REPL 挂载之间发生了缓存失效(例如 seed marketplace 注册或
|
||||
// policySettings 热重载)。
|
||||
const lspServerCounts = await Promise.all(
|
||||
enabled.map(async p => {
|
||||
if (p.lspServers) return Object.keys(p.lspServers).length
|
||||
const servers = await loadPluginLspServers(p, errors)
|
||||
if (servers) p.lspServers = servers
|
||||
return servers ? Object.keys(servers).length : 0
|
||||
}),
|
||||
)
|
||||
const lsp_count = lspServerCounts.reduce((sum, n) => sum + n, 0)
|
||||
reinitializeLspServerManager()
|
||||
|
||||
// 更新 AppState - 合并错误以保留 LSP 错误
|
||||
setAppState(prevState => {
|
||||
// 保留现有的 LSP/非插件加载错误(source 为 'lsp-manager' 或 'plugin:*')
|
||||
const existingLspErrors = prevState.plugins.errors.filter(
|
||||
e => e.source === 'lsp-manager' || e.source.startsWith('plugin:'),
|
||||
)
|
||||
// 去重:删除也存在于新错误中的现有 LSP 错误
|
||||
const newErrorKeys = new Set(
|
||||
errors.map(e =>
|
||||
e.type === 'generic-error'
|
||||
? `generic-error:${e.source}:${e.error}`
|
||||
: `${e.type}:${e.source}`,
|
||||
),
|
||||
)
|
||||
const filteredExisting = existingLspErrors.filter(e => {
|
||||
const key =
|
||||
e.type === 'generic-error'
|
||||
? `generic-error:${e.source}:${e.error}`
|
||||
: `${e.type}:${e.source}`
|
||||
return !newErrorKeys.has(key)
|
||||
})
|
||||
const mergedErrors = [...filteredExisting, ...errors]
|
||||
|
||||
return {
|
||||
...prevState,
|
||||
plugins: {
|
||||
...prevState.plugins,
|
||||
enabled,
|
||||
disabled,
|
||||
commands,
|
||||
errors: mergedErrors,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
logForDebugging(
|
||||
`Loaded plugins - Enabled: ${enabled.length}, Disabled: ${disabled.length}, Commands: ${commands.length}, Agents: ${agents.length}, Errors: ${errors.length}`,
|
||||
)
|
||||
|
||||
// 跨启用插件计数组件类型
|
||||
const hook_count = enabled.reduce((sum, p) => {
|
||||
if (!p.hooksConfig) return sum
|
||||
return (
|
||||
sum +
|
||||
Object.values(p.hooksConfig).reduce(
|
||||
(s, matchers) =>
|
||||
s + (matchers?.reduce((h, m) => h + m.hooks.length, 0) ?? 0),
|
||||
0,
|
||||
)
|
||||
)
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
enabled_count: enabled.length,
|
||||
disabled_count: disabled.length,
|
||||
inline_count: count(enabled, p => p.source.endsWith('@inline')),
|
||||
marketplace_count: count(enabled, p => !p.source.endsWith('@inline')),
|
||||
error_count: errors.length,
|
||||
skill_count: commands.length,
|
||||
agent_count: agents.length,
|
||||
hook_count,
|
||||
mcp_count,
|
||||
lsp_count,
|
||||
// 仅 Ant:启用了哪些插件,以与 RSS/FPS 相关。
|
||||
// 与基本指标分开,因此不会流入 logForDiagnosticsNoPII。
|
||||
ant_enabled_names:
|
||||
process.env.USER_TYPE === 'ant' && enabled.length > 0
|
||||
? (enabled
|
||||
.map(p => p.name)
|
||||
.sort()
|
||||
.join(
|
||||
',',
|
||||
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
|
||||
: undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
// 只有插件加载错误应该到达这里 - 记录用于监控
|
||||
const errorObj = toError(error)
|
||||
logError(errorObj)
|
||||
logForDebugging(`Error loading plugins: ${error}`)
|
||||
// 出错时设置空状态,但保留 LSP 错误并添加新错误
|
||||
setAppState(prevState => {
|
||||
// 保留现有的 LSP/非插件加载错误
|
||||
const existingLspErrors = prevState.plugins.errors.filter(
|
||||
e => e.source === 'lsp-manager' || e.source.startsWith('plugin:'),
|
||||
)
|
||||
const newError = {
|
||||
type: 'generic-error' as const,
|
||||
source: 'plugin-system',
|
||||
error: errorObj.message,
|
||||
}
|
||||
return {
|
||||
...prevState,
|
||||
plugins: {
|
||||
...prevState.plugins,
|
||||
enabled: [],
|
||||
disabled: [],
|
||||
commands: [],
|
||||
errors: [...existingLspErrors, newError],
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
enabled_count: 0,
|
||||
disabled_count: 0,
|
||||
inline_count: 0,
|
||||
marketplace_count: 0,
|
||||
error_count: 1,
|
||||
skill_count: 0,
|
||||
agent_count: 0,
|
||||
hook_count: 0,
|
||||
mcp_count: 0,
|
||||
lsp_count: 0,
|
||||
load_failed: true,
|
||||
ant_enabled_names: undefined,
|
||||
}
|
||||
}
|
||||
}, [setAppState, addNotification])
|
||||
|
||||
// 挂载时加载插件并发出遥测
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
void initialPluginLoad().then(metrics => {
|
||||
const { ant_enabled_names, ...baseMetrics } = metrics
|
||||
const allMetrics = {
|
||||
...baseMetrics,
|
||||
has_custom_plugin_cache_dir: !!process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR,
|
||||
}
|
||||
logEvent('tengu_plugins_loaded', {
|
||||
...allMetrics,
|
||||
...(ant_enabled_names !== undefined && {
|
||||
enabled_names: ant_enabled_names,
|
||||
}),
|
||||
})
|
||||
logForDiagnosticsNoPII('info', 'tengu_plugins_loaded', allMetrics)
|
||||
})
|
||||
}, [initialPluginLoad, enabled])
|
||||
|
||||
// 插件状态在磁盘上更改(后台协调,/plugin 菜单,
|
||||
// 外部设置编辑)。显示通知;用户运行 /reload-plugins 应用。
|
||||
// 之前的自动刷新有陈旧缓存 bug(只清除 loadAllPlugins,
|
||||
// 下游 memoized 加载器返回旧数据)而且不完整(无 MCP,无 agentDefinitions)。
|
||||
// /reload-plugins 通过 refreshActivePlugins() 正确处理所有这些。
|
||||
useEffect(() => {
|
||||
if (!enabled || !needsRefresh) return
|
||||
addNotification({
|
||||
key: 'plugin-reload-pending',
|
||||
text: 'Plugins changed. Run /reload-plugins to activate.',
|
||||
color: 'suggestion',
|
||||
priority: 'low',
|
||||
})
|
||||
// 不要自动刷新。不要重置 needsRefresh — /reload-plugins
|
||||
// 通过 refreshActivePlugins() 消费它。
|
||||
}, [enabled, needsRefresh, addNotification])
|
||||
}
|
||||
39
claude-code源码-中文注释/src/hooks/useMemoryUsage.ts
Normal file
39
claude-code源码-中文注释/src/hooks/useMemoryUsage.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useState } from 'react'
|
||||
import { useInterval } from 'usehooks-ts'
|
||||
|
||||
export type MemoryUsageStatus = 'normal' | 'high' | 'critical'
|
||||
|
||||
export type MemoryUsageInfo = {
|
||||
heapUsed: number
|
||||
status: MemoryUsageStatus
|
||||
}
|
||||
|
||||
const HIGH_MEMORY_THRESHOLD = 1.5 * 1024 * 1024 * 1024 // 1.5GB in bytes
|
||||
const CRITICAL_MEMORY_THRESHOLD = 2.5 * 1024 * 1024 * 1024 // 2.5GB in bytes
|
||||
|
||||
/**
|
||||
* 监控 Node.js 进程内存使用情况的 Hook。
|
||||
* 每 10 秒轮询一次;状态为 'normal' 时返回 null。
|
||||
*/
|
||||
export function useMemoryUsage(): MemoryUsageInfo | null {
|
||||
const [memoryUsage, setMemoryUsage] = useState<MemoryUsageInfo | null>(null)
|
||||
|
||||
useInterval(() => {
|
||||
const heapUsed = process.memoryUsage().heapUsed
|
||||
const status: MemoryUsageStatus =
|
||||
heapUsed >= CRITICAL_MEMORY_THRESHOLD
|
||||
? 'critical'
|
||||
: heapUsed >= HIGH_MEMORY_THRESHOLD
|
||||
? 'high'
|
||||
: 'normal'
|
||||
setMemoryUsage(prev => {
|
||||
// 状态为 'normal' 时跳过 — 不显示任何内容,所以 heapUsed
|
||||
// 无关,我们避免每 10 秒为从未达到 1.5GB 的 99%+ 用户
|
||||
// 重新渲染整个 Notifications 子树。
|
||||
if (status === 'normal') return prev === null ? prev : null
|
||||
return { heapUsed, status }
|
||||
})
|
||||
}, 10_000)
|
||||
|
||||
return memoryUsage
|
||||
}
|
||||
27
claude-code源码-中文注释/src/hooks/useMergedClients.ts
Normal file
27
claude-code源码-中文注释/src/hooks/useMergedClients.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import uniqBy from 'lodash-es/uniqBy.js'
|
||||
import { useMemo } from 'react'
|
||||
import type { MCPServerConnection } from '../services/mcp/types.js'
|
||||
|
||||
/**
|
||||
* 合并初始 MCP 客户端和动态发现的 MCP 客户端。
|
||||
* 使用 uniqBy 按名称去重。
|
||||
*/
|
||||
export function mergeClients(
|
||||
initialClients: MCPServerConnection[] | undefined,
|
||||
mcpClients: readonly MCPServerConnection[] | undefined,
|
||||
): MCPServerConnection[] {
|
||||
if (initialClients && mcpClients && mcpClients.length > 0) {
|
||||
return uniqBy([...initialClients, ...mcpClients], 'name')
|
||||
}
|
||||
return initialClients || []
|
||||
}
|
||||
|
||||
export function useMergedClients(
|
||||
initialClients: MCPServerConnection[] | undefined,
|
||||
mcpClients: MCPServerConnection[] | undefined,
|
||||
): MCPServerConnection[] {
|
||||
return useMemo(
|
||||
() => mergeClients(initialClients, mcpClients),
|
||||
[initialClients, mcpClients],
|
||||
)
|
||||
}
|
||||
19
claude-code源码-中文注释/src/hooks/useMergedCommands.ts
Normal file
19
claude-code源码-中文注释/src/hooks/useMergedCommands.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import uniqBy from 'lodash-es/uniqBy.js'
|
||||
import { useMemo } from 'react'
|
||||
import type { Command } from '../commands.js'
|
||||
|
||||
/**
|
||||
* 合并初始命令和 MCP 命令的 Hook。
|
||||
* 使用 uniqBy 按名称去重,MCP 命令在有冲突时优先。
|
||||
*/
|
||||
export function useMergedCommands(
|
||||
initialCommands: Command[],
|
||||
mcpCommands: Command[],
|
||||
): Command[] {
|
||||
return useMemo(() => {
|
||||
if (mcpCommands.length > 0) {
|
||||
return uniqBy([...initialCommands, ...mcpCommands], 'name')
|
||||
}
|
||||
return initialCommands
|
||||
}, [initialCommands, mcpCommands])
|
||||
}
|
||||
44
claude-code源码-中文注释/src/hooks/useMergedTools.ts
Normal file
44
claude-code源码-中文注释/src/hooks/useMergedTools.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||
import { useMemo } from 'react'
|
||||
import type { Tools, ToolPermissionContext } from '../Tool.js'
|
||||
import { assembleToolPool } from '../tools.js'
|
||||
import { useAppState } from '../state/AppState.js'
|
||||
import { mergeAndFilterTools } from '../utils/toolPool.js'
|
||||
|
||||
/**
|
||||
* 为 REPL 组装完整工具池的 React Hook。
|
||||
*
|
||||
* 使用 assembleToolPool()(REPL 和 runAgent 使用的共享纯函数)
|
||||
* 组合内置工具和 MCP 工具,应用拒绝规则和去重。
|
||||
* 任何额外的 initialTools 都在顶部合并。
|
||||
*
|
||||
* @param initialTools - 要包含的额外工具(内置 + 来自 props 的启动 MCP)。
|
||||
* 这些与组装池合并,在去重中优先。
|
||||
* @param mcpTools - 动态发现的 MCP 工具(来自 mcp 状态)
|
||||
* @param toolPermissionContext - 用于过滤的权限上下文
|
||||
*/
|
||||
export function useMergedTools(
|
||||
initialTools: Tools,
|
||||
mcpTools: Tools,
|
||||
toolPermissionContext: ToolPermissionContext,
|
||||
): Tools {
|
||||
let replBridgeEnabled = false
|
||||
let replBridgeOutboundOnly = false
|
||||
return useMemo(() => {
|
||||
// assembleToolPool 是 REPL 和 runAgent 都使用的共享函数。
|
||||
// 它处理:getTools() + MCP 拒绝规则过滤 + 去重 + MCP CLI 排除。
|
||||
const assembled = assembleToolPool(toolPermissionContext, mcpTools)
|
||||
|
||||
return mergeAndFilterTools(
|
||||
initialTools,
|
||||
assembled,
|
||||
toolPermissionContext.mode,
|
||||
)
|
||||
}, [
|
||||
initialTools,
|
||||
mcpTools,
|
||||
toolPermissionContext,
|
||||
replBridgeEnabled,
|
||||
replBridgeOutboundOnly,
|
||||
])
|
||||
}
|
||||
35
claude-code源码-中文注释/src/hooks/useMinDisplayTime.ts
Normal file
35
claude-code源码-中文注释/src/hooks/useMinDisplayTime.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* 节流值,以便每个不同的值至少可见 minMs。
|
||||
* 防止快速循环的进度文本在可读之前闪烁过去。
|
||||
*
|
||||
* 与 debounce(等待安静)或 throttle(限制速率)不同,
|
||||
* 这保证每个值在被替换之前都有其最低屏幕时间。
|
||||
*/
|
||||
export function useMinDisplayTime<T>(value: T, minMs: number): T {
|
||||
const [displayed, setDisplayed] = useState(value)
|
||||
const lastShownAtRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const elapsed = Date.now() - lastShownAtRef.current
|
||||
if (elapsed >= minMs) {
|
||||
lastShownAtRef.current = Date.now()
|
||||
setDisplayed(value)
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(
|
||||
(shownAtRef, setFn, v) => {
|
||||
shownAtRef.current = Date.now()
|
||||
setFn(v)
|
||||
},
|
||||
minMs - elapsed,
|
||||
lastShownAtRef,
|
||||
setDisplayed,
|
||||
value,
|
||||
)
|
||||
return () => clearTimeout(timer)
|
||||
}, [value, minMs])
|
||||
|
||||
return displayed
|
||||
}
|
||||
64
claude-code源码-中文注释/src/hooks/useNotifyAfterTimeout.ts
Normal file
64
claude-code源码-中文注释/src/hooks/useNotifyAfterTimeout.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
getLastInteractionTime,
|
||||
updateLastInteractionTime,
|
||||
} from '../bootstrap/state.js'
|
||||
import { useTerminalNotification } from '../ink/useTerminalNotification.js'
|
||||
import { sendNotification } from '../services/notifier.js'
|
||||
// 考虑交互为"最近"的时间阈值(毫秒)(6 秒)
|
||||
export const DEFAULT_INTERACTION_THRESHOLD_MS = 6000
|
||||
|
||||
function getTimeSinceLastInteraction(): number {
|
||||
return Date.now() - getLastInteractionTime()
|
||||
}
|
||||
|
||||
function hasRecentInteraction(threshold: number): boolean {
|
||||
return getTimeSinceLastInteraction() < threshold
|
||||
}
|
||||
|
||||
function shouldNotify(threshold: number): boolean {
|
||||
return process.env.NODE_ENV !== 'test' && !hasRecentInteraction(threshold)
|
||||
}
|
||||
|
||||
// 注意:用户交互跟踪现在在 App.tsx 的 processKeysInBatch
|
||||
// 函数中完成,该函数在任何输入被接收时调用 updateLastInteractionTime()。
|
||||
// 这避免使用单独的 stdin 'data' 监听器与主 'readable' 监听器竞争,
|
||||
// 并导致输入字符丢失。
|
||||
|
||||
/**
|
||||
* 管理超时后桌面通知的 Hook。
|
||||
*
|
||||
* 在两种情况下显示通知:
|
||||
* 1. 如果应用空闲时间超过阈值,立即显示
|
||||
* 2. 如果用户在指定时间内未交互,则在超时后显示
|
||||
*
|
||||
* @param message - 要显示的通知消息
|
||||
* @param timeout - 超时时间(毫秒)(默认为 6000ms)
|
||||
*/
|
||||
export function useNotifyAfterTimeout(
|
||||
message: string,
|
||||
notificationType: string,
|
||||
): void {
|
||||
const terminal = useTerminalNotification()
|
||||
|
||||
// 重置交互时间以确保完成时间很长的请求
|
||||
// 不会立即弹出通知。必须是立即的,因为 useEffect 在
|
||||
// Ink 的渲染周期已经刷新后运行;没有它,时间戳保持陈旧,
|
||||
// 如果用户空闲(没有后续渲染刷新),则会触发过早通知。
|
||||
useEffect(() => {
|
||||
updateLastInteractionTime(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let hasNotified = false
|
||||
const timer = setInterval(() => {
|
||||
if (shouldNotify(DEFAULT_INTERACTION_THRESHOLD_MS) && !hasNotified) {
|
||||
hasNotified = true
|
||||
clearInterval(timer)
|
||||
void sendNotification({ message, notificationType }, terminal)
|
||||
}
|
||||
}, DEFAULT_INTERACTION_THRESHOLD_MS)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [message, notificationType, terminal])
|
||||
}
|
||||
289
claude-code源码-中文注释/src/hooks/usePasteHandler.ts
Normal file
289
claude-code源码-中文注释/src/hooks/usePasteHandler.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { basename } from 'path'
|
||||
import React from 'react'
|
||||
import { logError } from 'src/utils/log.js'
|
||||
import { useDebounceCallback } from 'usehooks-ts'
|
||||
import type { InputEvent, Key } from '../ink.js'
|
||||
import {
|
||||
getImageFromClipboard,
|
||||
isImageFilePath,
|
||||
PASTE_THRESHOLD,
|
||||
tryReadImageFromPath,
|
||||
} from '../utils/imagePaste.js'
|
||||
import type { ImageDimensions } from '../utils/imageResizer.js'
|
||||
import { getPlatform } from '../utils/platform.js'
|
||||
|
||||
const CLIPBOARD_CHECK_DEBOUNCE_MS = 50
|
||||
const PASTE_COMPLETION_TIMEOUT_MS = 100
|
||||
|
||||
type PasteHandlerProps = {
|
||||
onPaste?: (text: string) => void
|
||||
onInput: (input: string, key: Key) => void
|
||||
onImagePaste?: (
|
||||
base64Image: string,
|
||||
mediaType?: string,
|
||||
filename?: string,
|
||||
dimensions?: ImageDimensions,
|
||||
sourcePath?: string,
|
||||
) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理粘贴处理的 Hook。
|
||||
* 检测粘贴事件,处理图像文件路径,支持剪贴板中的图像。
|
||||
*/
|
||||
export function usePasteHandler({
|
||||
onPaste,
|
||||
onInput,
|
||||
onImagePaste,
|
||||
}: PasteHandlerProps): {
|
||||
wrappedOnInput: (input: string, key: Key, event: InputEvent) => void
|
||||
pasteState: {
|
||||
chunks: string[]
|
||||
timeoutId: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
isPasting: boolean
|
||||
} {
|
||||
const [pasteState, setPasteState] = React.useState<{
|
||||
chunks: string[]
|
||||
timeoutId: ReturnType<typeof setTimeout> | null
|
||||
}>({ chunks: [], timeoutId: null })
|
||||
const [isPasting, setIsPasting] = React.useState(false)
|
||||
const isMountedRef = React.useRef(true)
|
||||
// Mirrors pasteState.timeoutId but updated synchronously. When paste + a
|
||||
// keystroke arrive in the same stdin chunk, both wrappedOnInput calls run
|
||||
// in the same discreteUpdates batch before React commits — the second call
|
||||
// reads stale pasteState.timeoutId (null) and takes the onInput path. If
|
||||
// that key is Enter, it submits the old input and the paste is lost.
|
||||
const pastePendingRef = React.useRef(false)
|
||||
|
||||
const isMacOS = React.useMemo(() => getPlatform() === 'macos', [])
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const checkClipboardForImageImpl = React.useCallback(() => {
|
||||
if (!onImagePaste || !isMountedRef.current) return
|
||||
|
||||
void getImageFromClipboard()
|
||||
.then(imageData => {
|
||||
if (imageData && isMountedRef.current) {
|
||||
onImagePaste(
|
||||
imageData.base64,
|
||||
imageData.mediaType,
|
||||
undefined, // no filename for clipboard images
|
||||
imageData.dimensions,
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (isMountedRef.current) {
|
||||
logError(error as Error)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMountedRef.current) {
|
||||
setIsPasting(false)
|
||||
}
|
||||
})
|
||||
}, [onImagePaste])
|
||||
|
||||
const checkClipboardForImage = useDebounceCallback(
|
||||
checkClipboardForImageImpl,
|
||||
CLIPBOARD_CHECK_DEBOUNCE_MS,
|
||||
)
|
||||
|
||||
const resetPasteTimeout = React.useCallback(
|
||||
(currentTimeoutId: ReturnType<typeof setTimeout> | null) => {
|
||||
if (currentTimeoutId) {
|
||||
clearTimeout(currentTimeoutId)
|
||||
}
|
||||
return setTimeout(
|
||||
(
|
||||
setPasteState,
|
||||
onImagePaste,
|
||||
onPaste,
|
||||
setIsPasting,
|
||||
checkClipboardForImage,
|
||||
isMacOS,
|
||||
pastePendingRef,
|
||||
) => {
|
||||
pastePendingRef.current = false
|
||||
setPasteState(({ chunks }) => {
|
||||
// Join chunks and filter out orphaned focus sequences
|
||||
// These can appear when focus events split during paste
|
||||
const pastedText = chunks
|
||||
.join('')
|
||||
.replace(/\[I$/, '')
|
||||
.replace(/\[O$/, '')
|
||||
|
||||
// Check if the pasted text contains image file paths
|
||||
// When dragging multiple images, they may come as:
|
||||
// 1. Newline-separated paths (common in some terminals)
|
||||
// 2. Space-separated paths (common when dragging from Finder)
|
||||
// For space-separated paths, we split on spaces that precede absolute paths:
|
||||
// - Unix: space followed by `/` (e.g., `/Users/...`)
|
||||
// - Windows: space followed by drive letter and `:\` (e.g., `C:\Users\...`)
|
||||
// This works because spaces within paths are escaped (e.g., `file\ name.png`)
|
||||
const lines = pastedText
|
||||
.split(/ (?=\/|[A-Za-z]:\\)/)
|
||||
.flatMap(part => part.split('\n'))
|
||||
.filter(line => line.trim())
|
||||
const imagePaths = lines.filter(line => isImageFilePath(line))
|
||||
|
||||
if (onImagePaste && imagePaths.length > 0) {
|
||||
const isTempScreenshot =
|
||||
/\/TemporaryItems\/.*screencaptureui.*\/Screenshot/i.test(
|
||||
pastedText,
|
||||
)
|
||||
|
||||
// Process all image paths
|
||||
void Promise.all(
|
||||
imagePaths.map(imagePath => tryReadImageFromPath(imagePath)),
|
||||
).then(results => {
|
||||
const validImages = results.filter(
|
||||
(r): r is NonNullable<typeof r> => r !== null,
|
||||
)
|
||||
|
||||
if (validImages.length > 0) {
|
||||
// Successfully read at least one image
|
||||
for (const imageData of validImages) {
|
||||
const filename = basename(imageData.path)
|
||||
onImagePaste(
|
||||
imageData.base64,
|
||||
imageData.mediaType,
|
||||
filename,
|
||||
imageData.dimensions,
|
||||
imageData.path,
|
||||
)
|
||||
}
|
||||
// If some paths weren't images, paste them as text
|
||||
const nonImageLines = lines.filter(
|
||||
line => !isImageFilePath(line),
|
||||
)
|
||||
if (nonImageLines.length > 0 && onPaste) {
|
||||
onPaste(nonImageLines.join('\n'))
|
||||
}
|
||||
setIsPasting(false)
|
||||
} else if (isTempScreenshot && isMacOS) {
|
||||
// For temporary screenshot files that no longer exist, try clipboard
|
||||
checkClipboardForImage()
|
||||
} else {
|
||||
if (onPaste) {
|
||||
onPaste(pastedText)
|
||||
}
|
||||
setIsPasting(false)
|
||||
}
|
||||
})
|
||||
return { chunks: [], timeoutId: null }
|
||||
}
|
||||
|
||||
// If paste is empty (common when trying to paste images with Cmd+V),
|
||||
// check if clipboard has an image (macOS only)
|
||||
if (isMacOS && onImagePaste && pastedText.length === 0) {
|
||||
checkClipboardForImage()
|
||||
return { chunks: [], timeoutId: null }
|
||||
}
|
||||
|
||||
// Handle regular paste
|
||||
if (onPaste) {
|
||||
onPaste(pastedText)
|
||||
}
|
||||
// Reset isPasting state after paste is complete
|
||||
setIsPasting(false)
|
||||
return { chunks: [], timeoutId: null }
|
||||
})
|
||||
},
|
||||
PASTE_COMPLETION_TIMEOUT_MS,
|
||||
setPasteState,
|
||||
onImagePaste,
|
||||
onPaste,
|
||||
setIsPasting,
|
||||
checkClipboardForImage,
|
||||
isMacOS,
|
||||
pastePendingRef,
|
||||
)
|
||||
},
|
||||
[checkClipboardForImage, isMacOS, onImagePaste, onPaste],
|
||||
)
|
||||
|
||||
// Paste detection is now done via the InputEvent's keypress.isPasted flag,
|
||||
// which is set by the keypress parser when it detects bracketed paste mode.
|
||||
// This avoids the race condition caused by having multiple listeners on stdin.
|
||||
// Previously, we had a stdin.on('data') listener here which competed with
|
||||
// the 'readable' listener in App.tsx, causing dropped characters.
|
||||
|
||||
const wrappedOnInput = (input: string, key: Key, event: InputEvent): void => {
|
||||
// Detect paste from the parsed keypress event.
|
||||
// The keypress parser sets isPasted=true for content within bracketed paste.
|
||||
const isFromPaste = event.keypress.isPasted
|
||||
|
||||
// If this is pasted content, set isPasting state for UI feedback
|
||||
if (isFromPaste) {
|
||||
setIsPasting(true)
|
||||
}
|
||||
|
||||
// Handle large pastes (>PASTE_THRESHOLD chars)
|
||||
// Usually we get one or two input characters at a time. If we
|
||||
// get more than the threshold, the user has probably pasted.
|
||||
// Unfortunately node batches long pastes, so it's possible
|
||||
// that we would see e.g. 1024 characters and then just a few
|
||||
// more in the next frame that belong with the original paste.
|
||||
// This batching number is not consistent.
|
||||
|
||||
// Handle potential image filenames (even if they're shorter than paste threshold)
|
||||
// When dragging multiple images, they may come as newline-separated or
|
||||
// space-separated paths. Split on spaces preceding absolute paths:
|
||||
// - Unix: ` /` - Windows: ` C:\` etc.
|
||||
const hasImageFilePath = input
|
||||
.split(/ (?=\/|[A-Za-z]:\\)/)
|
||||
.flatMap(part => part.split('\n'))
|
||||
.some(line => isImageFilePath(line.trim()))
|
||||
|
||||
// Handle empty paste (clipboard image on macOS)
|
||||
// When the user pastes an image with Cmd+V, the terminal sends an empty
|
||||
// bracketed paste sequence. The keypress parser emits this as isPasted=true
|
||||
// with empty input.
|
||||
if (isFromPaste && input.length === 0 && isMacOS && onImagePaste) {
|
||||
checkClipboardForImage()
|
||||
// Reset isPasting since there's no text content to process
|
||||
setIsPasting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we should handle as paste (from bracketed paste, large input, or continuation)
|
||||
const shouldHandleAsPaste =
|
||||
onPaste &&
|
||||
(input.length > PASTE_THRESHOLD ||
|
||||
pastePendingRef.current ||
|
||||
hasImageFilePath ||
|
||||
isFromPaste)
|
||||
|
||||
if (shouldHandleAsPaste) {
|
||||
pastePendingRef.current = true
|
||||
setPasteState(({ chunks, timeoutId }) => {
|
||||
return {
|
||||
chunks: [...chunks, input],
|
||||
timeoutId: resetPasteTimeout(timeoutId),
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
onInput(input, key)
|
||||
if (input.length > 10) {
|
||||
// Ensure that setIsPasting is turned off on any other multicharacter
|
||||
// input, because the stdin buffer may chunk at arbitrary points and split
|
||||
// the closing escape sequence if the input length is too long for the
|
||||
// stdin buffer.
|
||||
setIsPasting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
wrappedOnInput,
|
||||
pasteState,
|
||||
isPasting,
|
||||
}
|
||||
}
|
||||
98
claude-code源码-中文注释/src/hooks/usePrStatus.ts
Normal file
98
claude-code源码-中文注释/src/hooks/usePrStatus.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTheme } from '../components/design-system/ThemeProvider.js'
|
||||
import type { useSelection } from '../ink/hooks/use-selection.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { getTheme } from '../utils/theme.js'
|
||||
|
||||
type Selection = ReturnType<typeof useSelection>
|
||||
|
||||
/**
|
||||
* 在用户完成拖动(带非空选择的 mouse-up)
|
||||
* 或多次单击选择单词/行时自动将选择复制到剪贴板。
|
||||
* 镜像 iTerm2 的"选择时复制到剪贴板" — 高亮保留
|
||||
* 完好以便用户可以看到复制的内容。仅在 alt-screen 模式下触发
|
||||
*(选择状态由 ink 实例拥有;在 alt-screen 外,
|
||||
* 原生终端处理选择,此 hook 通过 ink stub 成为空操作)。
|
||||
*
|
||||
* selection.subscribe 在每次变更时触发(开始/更新/完成/清除/
|
||||
* 多击)。字符拖动和多击在按下时都设置 isDragging=true,
|
||||
* 所以用 isDragging=false 出现的选择总是拖动完成。
|
||||
* copiedRef 防止虚假通知的重复触发。
|
||||
*
|
||||
* onCopied 是可选的 — 省略时,复制是静默的(写入剪贴板
|
||||
* 但不触发 toast/通知)。FleetView 使用此静默模式;
|
||||
* 全屏 REPL 传递 showCopiedToast 以获得用户反馈。
|
||||
*/
|
||||
export function useCopyOnSelect(
|
||||
selection: Selection,
|
||||
isActive: boolean,
|
||||
onCopied?: (text: string) => void,
|
||||
): void {
|
||||
// 跟踪前一个通知是否具有 isDragging=false 的可见选择
|
||||
//(即我们已经自动复制了它)。没有这个,
|
||||
// 完成→清除转换看起来像新的选择空闲事件,
|
||||
// 我们会为单次拖动 toast 两次。
|
||||
const copiedRef = useRef(false)
|
||||
// onCopied 每次渲染都是一个新的闭包;通过 ref 读取,
|
||||
// 以便效果不会重新订阅(这会通过卸载重置 copiedRef)。
|
||||
const onCopiedRef = useRef(onCopied)
|
||||
onCopiedRef.current = onCopied
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
|
||||
const unsubscribe = selection.subscribe(() => {
|
||||
const sel = selection.getState()
|
||||
const has = selection.hasSelection()
|
||||
// 拖动进行中 — 等待完成。重置复制标志,
|
||||
// 以便在相同范围结束的新拖动仍然触发新的复制。
|
||||
if (sel?.isDragging) {
|
||||
copiedRef.current = false
|
||||
return
|
||||
}
|
||||
// 无选择(已清除,或点击而无拖动)— 重置。
|
||||
if (!has) {
|
||||
copiedRef.current = false
|
||||
return
|
||||
}
|
||||
// 选择已稳定(拖动完成或多击)。已经复制了
|
||||
// 这个 — 在不经过 isDragging 或 !has 的情况下再次到达
|
||||
// 这儿的唯一方法是虚假通知(不应该发生,但安全)。
|
||||
if (copiedRef.current) return
|
||||
|
||||
// 默认为 true:macOS 用户期望 cmd+c 工作。但它不能 —
|
||||
// 终端的 Edit > Copy 在 pty 看到它之前拦截它,
|
||||
// 找不到原生选择(鼠标跟踪禁用了它)。
|
||||
// mouse-up 时自动复制使 cmd+c 成为空操作,
|
||||
// 保持剪贴板内容正确,粘贴按预期工作。
|
||||
const enabled = getGlobalConfig().copyOnSelect ?? true
|
||||
if (!enabled) return
|
||||
|
||||
const text = selection.copySelectionNoClear()
|
||||
// 仅空白(例如空行多击)— 不值得
|
||||
// 写入剪贴板或 toast。仍然设置 copiedRef 以便不重试。
|
||||
if (!text || !text.trim()) {
|
||||
copiedRef.current = true
|
||||
return
|
||||
}
|
||||
copiedRef.current = true
|
||||
onCopiedRef.current?.(text)
|
||||
})
|
||||
return unsubscribe
|
||||
}, [isActive, selection])
|
||||
}
|
||||
|
||||
/**
|
||||
* 将主题的 selectionBg 颜色传入 Ink StylePool,
|
||||
* 以便选择覆盖层呈现纯蓝色背景而不是 SGR-7 反转。
|
||||
* Ink 是主题无关的(分层:colorize.ts"主题解析发生在
|
||||
* 组件层,而不是这里")— 这是桥梁。在挂载时触发
|
||||
*(在任何鼠标输入之前)并在 /theme 切换时再次触发,
|
||||
* 以便选择颜色实时跟踪主题。
|
||||
*/
|
||||
export function useSelectionBgColor(selection: Selection): void {
|
||||
const [themeName] = useTheme()
|
||||
useEffect(() => {
|
||||
selection.setSelectionBgColor(getTheme(themeName).selectionBg)
|
||||
}, [selection, themeName])
|
||||
}
|
||||
183
claude-code源码-中文注释/src/hooks/usePromptSuggestion.ts
Normal file
183
claude-code源码-中文注释/src/hooks/usePromptSuggestion.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useTerminalFocus } from '../ink/hooks/use-terminal-focus.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../services/analytics/index.js'
|
||||
import { abortSpeculation } from '../services/PromptSuggestion/speculation.js'
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js'
|
||||
|
||||
type Props = {
|
||||
inputValue: string
|
||||
isAssistantResponding: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理提示建议的 Hook,包括接受、显示和提交时的遥测记录。
|
||||
* 当助手正在响应或用户已有输入时,建议为 null。
|
||||
* 跟踪焦点状态和第一次击键时间以进行遥测分析。
|
||||
*/
|
||||
export function usePromptSuggestion({
|
||||
inputValue,
|
||||
isAssistantResponding,
|
||||
}: Props): {
|
||||
suggestion: string | null
|
||||
markAccepted: () => void
|
||||
markShown: () => void
|
||||
logOutcomeAtSubmission: (
|
||||
finalInput: string,
|
||||
opts?: { skipReset: boolean },
|
||||
) => void
|
||||
} {
|
||||
const promptSuggestion = useAppState(s => s.promptSuggestion)
|
||||
const setAppState = useSetAppState()
|
||||
const isTerminalFocused = useTerminalFocus()
|
||||
const {
|
||||
text: suggestionText,
|
||||
promptId,
|
||||
shownAt,
|
||||
acceptedAt,
|
||||
generationRequestId,
|
||||
} = promptSuggestion
|
||||
|
||||
// 助手正在响应或已有输入时,不显示建议
|
||||
const suggestion =
|
||||
isAssistantResponding || inputValue.length > 0 ? null : suggestionText
|
||||
|
||||
const isValidSuggestion = suggestionText && shownAt > 0
|
||||
|
||||
// 跟踪用户参与深度以进行遥测
|
||||
const firstKeystrokeAt = useRef<number>(0)
|
||||
const wasFocusedWhenShown = useRef<boolean>(true)
|
||||
const prevShownAt = useRef<number>(0)
|
||||
|
||||
// 当显示时间变化时捕获焦点状态(shownAt 变化)
|
||||
if (shownAt > 0 && shownAt !== prevShownAt.current) {
|
||||
prevShownAt.current = shownAt
|
||||
wasFocusedWhenShown.current = isTerminalFocused
|
||||
firstKeystrokeAt.current = 0
|
||||
} else if (shownAt === 0) {
|
||||
prevShownAt.current = 0
|
||||
}
|
||||
|
||||
// 当建议可见时记录第一次击键
|
||||
if (
|
||||
inputValue.length > 0 &&
|
||||
firstKeystrokeAt.current === 0 &&
|
||||
isValidSuggestion
|
||||
) {
|
||||
firstKeystrokeAt.current = Date.now()
|
||||
}
|
||||
|
||||
const resetSuggestion = useCallback(() => {
|
||||
abortSpeculation(setAppState)
|
||||
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
promptSuggestion: {
|
||||
text: null,
|
||||
promptId: null,
|
||||
shownAt: 0,
|
||||
acceptedAt: 0,
|
||||
generationRequestId: null,
|
||||
},
|
||||
}))
|
||||
}, [setAppState])
|
||||
|
||||
const markAccepted = useCallback(() => {
|
||||
if (!isValidSuggestion) return
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
promptSuggestion: {
|
||||
...prev.promptSuggestion,
|
||||
acceptedAt: Date.now(),
|
||||
},
|
||||
}))
|
||||
}, [isValidSuggestion, setAppState])
|
||||
|
||||
const markShown = useCallback(() => {
|
||||
// 在 setAppState 回调内部检查 shownAt 以避免依赖它
|
||||
//(依赖 shownAt 会导致此回调被调用时出现无限循环)
|
||||
setAppState(prev => {
|
||||
// 仅在尚未显示且建议存在时标记为已显示
|
||||
if (prev.promptSuggestion.shownAt !== 0 || !prev.promptSuggestion.text) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
promptSuggestion: {
|
||||
...prev.promptSuggestion,
|
||||
shownAt: Date.now(),
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [setAppState])
|
||||
|
||||
const logOutcomeAtSubmission = useCallback(
|
||||
(finalInput: string, opts?: { skipReset: boolean }) => {
|
||||
if (!isValidSuggestion) return
|
||||
|
||||
// 确定是否被接受:按下了 Tab(acceptedAt 已设置)或
|
||||
// 最终输入与建议匹配(空 Enter 情况)
|
||||
const tabWasPressed = acceptedAt > shownAt
|
||||
const wasAccepted = tabWasPressed || finalInput === suggestionText
|
||||
const timeMs = wasAccepted ? acceptedAt || Date.now() : Date.now()
|
||||
|
||||
logEvent('tengu_prompt_suggestion', {
|
||||
source:
|
||||
'cli' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
outcome: (wasAccepted
|
||||
? 'accepted'
|
||||
: 'ignored') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
prompt_id:
|
||||
promptId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
...(generationRequestId && {
|
||||
generationRequestId:
|
||||
generationRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
}),
|
||||
...(wasAccepted && {
|
||||
acceptMethod: (tabWasPressed
|
||||
? 'tab'
|
||||
: 'enter') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
}),
|
||||
...(wasAccepted && {
|
||||
timeToAcceptMs: timeMs - shownAt,
|
||||
}),
|
||||
...(!wasAccepted && {
|
||||
timeToIgnoreMs: timeMs - shownAt,
|
||||
}),
|
||||
...(firstKeystrokeAt.current > 0 && {
|
||||
timeToFirstKeystrokeMs: firstKeystrokeAt.current - shownAt,
|
||||
}),
|
||||
wasFocusedWhenShown: wasFocusedWhenShown.current,
|
||||
similarity:
|
||||
Math.round(
|
||||
(finalInput.length / (suggestionText?.length || 1)) * 100,
|
||||
) / 100,
|
||||
...(process.env.USER_TYPE === 'ant' && {
|
||||
suggestion:
|
||||
suggestionText as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
userInput:
|
||||
finalInput as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
}),
|
||||
})
|
||||
if (!opts?.skipReset) resetSuggestion()
|
||||
},
|
||||
[
|
||||
isValidSuggestion,
|
||||
acceptedAt,
|
||||
shownAt,
|
||||
suggestionText,
|
||||
promptId,
|
||||
generationRequestId,
|
||||
resetSuggestion,
|
||||
],
|
||||
)
|
||||
|
||||
return {
|
||||
suggestion,
|
||||
markAccepted,
|
||||
markShown,
|
||||
logOutcomeAtSubmission,
|
||||
}
|
||||
}
|
||||
68
claude-code源码-中文注释/src/hooks/useQueueProcessor.ts
Normal file
68
claude-code源码-中文注释/src/hooks/useQueueProcessor.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import type { QueuedCommand } from '../types/textInputTypes.js'
|
||||
import {
|
||||
getCommandQueueSnapshot,
|
||||
subscribeToCommandQueue,
|
||||
} from '../utils/messageQueueManager.js'
|
||||
import type { QueryGuard } from '../utils/QueryGuard.js'
|
||||
import { processQueueIfReady } from '../utils/queueProcessor.js'
|
||||
|
||||
type UseQueueProcessorParams = {
|
||||
executeQueuedInput: (commands: QueuedCommand[]) => Promise<void>
|
||||
hasActiveLocalJsxUI: boolean
|
||||
queryGuard: QueryGuard
|
||||
}
|
||||
|
||||
/**
|
||||
* 在条件满足时处理排队命令的 Hook。
|
||||
*
|
||||
* 使用单个统一命令队列(模块级存储)。优先级决定
|
||||
* 处理顺序:'now' > 'next'(用户输入)> 'later'(任务通知)。
|
||||
* dequeue() 函数自动处理优先级排序。
|
||||
*
|
||||
* 处理触发条件:
|
||||
* - 无活动查询(queryGuard — 通过 useSyncExternalStore 响应式)
|
||||
* - 队列有项目
|
||||
* - 无活动本地 JSX UI 阻止输入
|
||||
*/
|
||||
export function useQueueProcessor({
|
||||
executeQueuedInput,
|
||||
hasActiveLocalJsxUI,
|
||||
queryGuard,
|
||||
}: UseQueueProcessorParams): void {
|
||||
// 订阅查询守卫。当查询开始或结束时重新渲染
|
||||
//(或当 reserve/cancelReservation 转换调度状态时)。
|
||||
const isQueryActive = useSyncExternalStore(
|
||||
queryGuard.subscribe,
|
||||
queryGuard.getSnapshot,
|
||||
)
|
||||
|
||||
// 通过 useSyncExternalStore 订阅统一命令队列。
|
||||
// 这保证在存储更改时重新渲染,绕过
|
||||
// React context 传播延迟,这在 Ink 中会导致错过通知。
|
||||
const queueSnapshot = useSyncExternalStore(
|
||||
subscribeToCommandQueue,
|
||||
getCommandQueueSnapshot,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isQueryActive) return
|
||||
if (hasActiveLocalJsxUI) return
|
||||
if (queueSnapshot.length === 0) return
|
||||
|
||||
// 预留现在由 handlePromptSubmit 拥有(在 executeUserInput 的
|
||||
// try 块内)。同步链 executeQueuedInput → handlePromptSubmit →
|
||||
// executeUserInput → queryGuard.reserve() 在第一个真正的 await 之前运行,
|
||||
// 所以当 React 重新运行此效果时(由于出队触发的
|
||||
// 快照更改),isQueryActive 已经是 true(调度中),
|
||||
// 上面的守卫提前返回。handlePromptSubmit 的 finally 通过
|
||||
// cancelReservation() 释放预留(如果 onQuery 已经运行 end() 则为空操作)。
|
||||
processQueueIfReady({ executeInput: executeQueuedInput })
|
||||
}, [
|
||||
queueSnapshot,
|
||||
isQueryActive,
|
||||
executeQueuedInput,
|
||||
hasActiveLocalJsxUI,
|
||||
queryGuard,
|
||||
])
|
||||
}
|
||||
603
claude-code源码-中文注释/src/hooks/useRemoteSession.ts
Normal file
603
claude-code源码-中文注释/src/hooks/useRemoteSession.ts
Normal file
@@ -0,0 +1,603 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { BoundedUUIDSet } from '../bridge/bridgeMessaging.js'
|
||||
import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js'
|
||||
import type { SpinnerMode } from '../components/Spinner/types.js'
|
||||
import {
|
||||
type RemotePermissionResponse,
|
||||
type RemoteSessionConfig,
|
||||
RemoteSessionManager,
|
||||
} from '../remote/RemoteSessionManager.js'
|
||||
import {
|
||||
createSyntheticAssistantMessage,
|
||||
createToolStub,
|
||||
} from '../remote/remotePermissionBridge.js'
|
||||
import {
|
||||
convertSDKMessage,
|
||||
isSessionEndMessage,
|
||||
} from '../remote/sdkMessageAdapter.js'
|
||||
import { useSetAppState } from '../state/AppState.js'
|
||||
import type { AppState } from '../state/AppStateStore.js'
|
||||
import type { Tool } from '../Tool.js'
|
||||
import { findToolByName } from '../Tool.js'
|
||||
import type { Message as MessageType } from '../types/message.js'
|
||||
import type { PermissionAskDecision } from '../types/permissions.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { truncateToWidth } from '../utils/format.js'
|
||||
import {
|
||||
createSystemMessage,
|
||||
extractTextContent,
|
||||
handleMessageFromStream,
|
||||
type StreamingToolUse,
|
||||
} from '../utils/messages.js'
|
||||
import { generateSessionTitle } from '../utils/sessionTitle.js'
|
||||
import type { RemoteMessageContent } from '../utils/teleport/api.js'
|
||||
import { updateSessionTitle } from '../utils/teleport/api.js'
|
||||
|
||||
// 在显示警告之前等待响应的时间
|
||||
const RESPONSE_TIMEOUT_MS = 60000 // 60 秒
|
||||
// 压缩期间的扩展超时 — compact API 调用需要 5-30s 并
|
||||
// 阻塞其他 SDK 消息,所以当压缩本身运行时接近边缘时,
|
||||
// 正常的 60s 超时不够。
|
||||
const COMPACTION_TIMEOUT_MS = 180000 // 3 分钟
|
||||
|
||||
type UseRemoteSessionProps = {
|
||||
config: RemoteSessionConfig | undefined
|
||||
setMessages: React.Dispatch<React.SetStateAction<MessageType[]>>
|
||||
setIsLoading: (loading: boolean) => void
|
||||
onInit?: (slashCommands: string[]) => void
|
||||
setToolUseConfirmQueue: React.Dispatch<React.SetStateAction<ToolUseConfirm[]>>
|
||||
tools: Tool[]
|
||||
setStreamingToolUses?: React.Dispatch<
|
||||
React.SetStateAction<StreamingToolUse[]>
|
||||
>
|
||||
setStreamMode?: React.Dispatch<React.SetStateAction<SpinnerMode>>
|
||||
setInProgressToolUseIDs?: (f: (prev: Set<string>) => Set<string>) => void
|
||||
}
|
||||
|
||||
type UseRemoteSessionResult = {
|
||||
isRemoteMode: boolean
|
||||
sendMessage: (
|
||||
content: RemoteMessageContent,
|
||||
opts?: { uuid?: string },
|
||||
) => Promise<boolean>
|
||||
cancelRequest: () => void
|
||||
disconnect: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 REPL 中管理远程 CCR 会话的 Hook。
|
||||
*
|
||||
* 处理:
|
||||
* - 到 CCR 的 WebSocket 连接
|
||||
* - 将 SDK 消息转换为 REPL 消息
|
||||
* - 通过 HTTP POST 发送用户输入到 CCR
|
||||
* - 通过现有 ToolUseConfirm 队列的权限请求/响应流程
|
||||
*/
|
||||
export function useRemoteSession({
|
||||
config,
|
||||
setMessages,
|
||||
setIsLoading,
|
||||
onInit,
|
||||
setToolUseConfirmQueue,
|
||||
tools,
|
||||
setStreamingToolUses,
|
||||
setStreamMode,
|
||||
setInProgressToolUseIDs,
|
||||
}: UseRemoteSessionProps): UseRemoteSessionResult {
|
||||
const isRemoteMode = !!config
|
||||
|
||||
const setAppState = useSetAppState()
|
||||
const setConnStatus = useCallback(
|
||||
(s: AppState['remoteConnectionStatus']) =>
|
||||
setAppState(prev =>
|
||||
prev.remoteConnectionStatus === s
|
||||
? prev
|
||||
: { ...prev, remoteConnectionStatus: s },
|
||||
),
|
||||
[setAppState],
|
||||
)
|
||||
|
||||
// 事件来源的远程守护进程子进程中运行的子代理计数。
|
||||
// 查看器自己的 AppState.tasks 是空的 — 任务存在于不同的
|
||||
// 进程中。task_started/task_notification 通过 bridge WS 到达我们。
|
||||
const runningTaskIdsRef = useRef(new Set<string>())
|
||||
const writeTaskCount = useCallback(() => {
|
||||
const n = runningTaskIdsRef.current.size
|
||||
setAppState(prev =>
|
||||
prev.remoteBackgroundTaskCount === n
|
||||
? prev
|
||||
: { ...prev, remoteBackgroundTaskCount: n },
|
||||
)
|
||||
}, [setAppState])
|
||||
|
||||
// 用于检测卡住会话的计时器
|
||||
const responseTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 跟踪远程会话是否正在压缩。在压缩期间
|
||||
// CLI 工作线程正忙于 API 调用,不会发出一段时间的消息;
|
||||
// 使用更长的超时并抑制虚假的"无响应"警告。
|
||||
const isCompactingRef = useRef(false)
|
||||
|
||||
const managerRef = useRef<RemoteSessionManager | null>(null)
|
||||
|
||||
// 跟踪我们是否已经更新了会话标题(对于无初始提示的会话)
|
||||
const hasUpdatedTitleRef = useRef(false)
|
||||
|
||||
// 我们在本地 POST 的用户消息的 UUID — WS 回显它们,我们必须
|
||||
// 在 convertUserTextMessages 开启时过滤它们,否则
|
||||
// 查看器会看到每条输入消息两次(一次来自本地 createUserMessage,
|
||||
// 一次来自回显)。单个 POST 可以用相同的 uuid 回显多次:
|
||||
// 服务器可能直接向 /subscribe 广播 POST,而工作线程
|
||||
//(cowork desktop / CLI daemon)在其写入路径上再次回显它。
|
||||
// 删除时首次匹配的 Set 会让第二次回显通过 — 使用
|
||||
// 有界环代替。上限是慷慨的:用户输入 50 条消息的速度
|
||||
// 不会快于回显到达。
|
||||
// 注意:这不会在附加时对历史与实时重叠进行去重(没有什么
|
||||
// 从历史 UUID 播种集合;只有 sendMessage 填充它)。
|
||||
const sentUUIDsRef = useRef(new BoundedUUIDSet(50))
|
||||
|
||||
// 保持对 tools 的 ref 以便 WebSocket 回调不会过时
|
||||
const toolsRef = useRef(tools)
|
||||
useEffect(() => {
|
||||
toolsRef.current = tools
|
||||
}, [tools])
|
||||
|
||||
// 初始化并连接到远程会话
|
||||
useEffect(() => {
|
||||
// 如果不在远程模式,则跳过
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[useRemoteSession] Initializing for session ${config.sessionId}`,
|
||||
)
|
||||
|
||||
const manager = new RemoteSessionManager(config, {
|
||||
onMessage: sdkMessage => {
|
||||
const parts = [`type=${sdkMessage.type}`]
|
||||
if ('subtype' in sdkMessage) parts.push(`subtype=${sdkMessage.subtype}`)
|
||||
if (sdkMessage.type === 'user') {
|
||||
const c = sdkMessage.message?.content
|
||||
parts.push(
|
||||
`content=${Array.isArray(c) ? c.map(b => b.type).join(',') : typeof c}`,
|
||||
)
|
||||
}
|
||||
logForDebugging(`[useRemoteSession] Received ${parts.join(' ')}`)
|
||||
|
||||
// 在接收到的任何消息上清除响应超时 — 包括
|
||||
// 我们自己 POST 的回显,它充当心跳。这必须运行
|
||||
// 在回显过滤器之前,否则慢速流代理(压缩、冷
|
||||
// 启动)会虚假地触发 60s 无响应警告 + 重连。
|
||||
if (responseTimeoutRef.current) {
|
||||
clearTimeout(responseTimeoutRef.current)
|
||||
responseTimeoutRef.current = null
|
||||
}
|
||||
|
||||
// 回显过滤器:丢弃我们之前在本地添加的用户消息。
|
||||
// 服务器和/或工作线程用与我们传递给 sendEventToRemoteSession 相同的 uuid
|
||||
// 在 WS 上回显我们自己的发送。不要删除
|
||||
// 匹配 — 相同的 uuid 可以回显多次(服务器广播 +
|
||||
// 工作线程回显),BoundedUUIDSet 已经通过其环限制增长。
|
||||
if (
|
||||
sdkMessage.type === 'user' &&
|
||||
sdkMessage.uuid &&
|
||||
sentUUIDsRef.current.has(sdkMessage.uuid)
|
||||
) {
|
||||
logForDebugging(
|
||||
`[useRemoteSession] Dropping echoed user message ${sdkMessage.uuid}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
// 处理 init 消息 — 提取可用的斜杠命令
|
||||
if (
|
||||
sdkMessage.type === 'system' &&
|
||||
sdkMessage.subtype === 'init' &&
|
||||
onInit
|
||||
) {
|
||||
logForDebugging(
|
||||
`[useRemoteSession] Init received with ${sdkMessage.slash_commands.length} slash commands`,
|
||||
)
|
||||
onInit(sdkMessage.slash_commands)
|
||||
}
|
||||
|
||||
// 跟踪远程子代理生命周期以获取"后台 N 个"计数器。
|
||||
// 所有任务类型(Agent/teammate/workflow/bash)都流经
|
||||
// registerTask() → task_started,并通过 task_notification 完成。
|
||||
// 提前返回 — 这些是状态信号,不是可渲染消息。
|
||||
if (sdkMessage.type === 'system') {
|
||||
if (sdkMessage.subtype === 'task_started') {
|
||||
runningTaskIdsRef.current.add(sdkMessage.task_id)
|
||||
writeTaskCount()
|
||||
return
|
||||
}
|
||||
if (sdkMessage.subtype === 'task_notification') {
|
||||
runningTaskIdsRef.current.delete(sdkMessage.task_id)
|
||||
writeTaskCount()
|
||||
return
|
||||
}
|
||||
if (sdkMessage.subtype === 'task_progress') {
|
||||
return
|
||||
}
|
||||
// 跟踪压缩状态。CLI 在开始时发出 status='compacting',
|
||||
// 在完成时发出 status=null;compact_boundary 也
|
||||
// 信号完成。重复的 'compacting' 状态消息
|
||||
//(keep-alive tick)更新 ref 但不追加到消息。
|
||||
if (sdkMessage.subtype === 'status') {
|
||||
const wasCompacting = isCompactingRef.current
|
||||
isCompactingRef.current = sdkMessage.status === 'compacting'
|
||||
if (wasCompacting && isCompactingRef.current) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (sdkMessage.subtype === 'compact_boundary') {
|
||||
isCompactingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
// 检查会话是否结束
|
||||
if (isSessionEndMessage(sdkMessage)) {
|
||||
isCompactingRef.current = false
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
// 当 tool_result 到达时清除进行中的 tool_use ID。
|
||||
// 必须读取原始 sdkMessage:在非 viewerOnly 模式,
|
||||
// convertSDKMessage 对用户消息返回 {type:'ignored'},所以
|
||||
// 删除在转换后永远不会触发。镜像下面的添加位置
|
||||
// 和 inProcessRunner.ts;如果没有这个,集合在
|
||||
// 会话生命周期内无限增长(BQ:CCR 队列显示 5.2x 更高的 RSS 斜率)。
|
||||
if (setInProgressToolUseIDs && sdkMessage.type === 'user') {
|
||||
const content = sdkMessage.message?.content
|
||||
if (Array.isArray(content)) {
|
||||
const resultIds: string[] = []
|
||||
for (const block of content) {
|
||||
if (block.type === 'tool_result') {
|
||||
resultIds.push(block.tool_use_id)
|
||||
}
|
||||
}
|
||||
if (resultIds.length > 0) {
|
||||
setInProgressToolUseIDs(prev => {
|
||||
const next = new Set(prev)
|
||||
for (const id of resultIds) next.delete(id)
|
||||
return next.size === prev.size ? prev : next
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 将 SDK 消息转换为 REPL 消息。在 viewerOnly 模式,
|
||||
// 远程代理运行 BriefTool (SendUserMessage) — 其 tool_use 块
|
||||
// 渲染为空(userFacingName() === ''),实际内容在
|
||||
// tool_result 中。所以我们必须转换 tool_results 以渲染它们。
|
||||
const converted = convertSDKMessage(
|
||||
sdkMessage,
|
||||
config.viewerOnly
|
||||
? { convertToolResults: true, convertUserTextMessages: true }
|
||||
: undefined,
|
||||
)
|
||||
|
||||
if (converted.type === 'message') {
|
||||
// 当收到完整消息时,清除流式 tool use
|
||||
// 因为完整消息替换了部分流式状态
|
||||
setStreamingToolUses?.(prev => (prev.length > 0 ? [] : prev))
|
||||
|
||||
// 将 tool_use 块标记为进行中,以便 UI 显示正确的
|
||||
// 旋转器状态而不是"等待…"(排队)。在本地会话中,
|
||||
// toolOrchestration.ts 处理这个,但远程会话接收
|
||||
// 预构建的助理消息而不运行本地工具执行。
|
||||
if (
|
||||
setInProgressToolUseIDs &&
|
||||
converted.message.type === 'assistant'
|
||||
) {
|
||||
const toolUseIds = converted.message.message.content
|
||||
.filter(block => block.type === 'tool_use')
|
||||
.map(block => block.id)
|
||||
if (toolUseIds.length > 0) {
|
||||
setInProgressToolUseIDs(prev => {
|
||||
const next = new Set(prev)
|
||||
for (const id of toolUseIds) {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, converted.message])
|
||||
// 注意:在助理消息上不要停止加载 — 代理可能仍在
|
||||
// 工作(工具使用循环)。加载仅在会话结束或权限请求时停止。
|
||||
} else if (converted.type === 'stream_event') {
|
||||
// 处理流事件以实时更新 UI
|
||||
if (setStreamingToolUses && setStreamMode) {
|
||||
handleMessageFromStream(
|
||||
converted.event,
|
||||
message => setMessages(prev => [...prev, message]),
|
||||
() => {
|
||||
// 响应长度无操作 — 远程会话不跟踪此
|
||||
},
|
||||
setStreamMode,
|
||||
setStreamingToolUses,
|
||||
)
|
||||
} else {
|
||||
logForDebugging(
|
||||
`[useRemoteSession] Stream event received but streaming callbacks not provided`,
|
||||
)
|
||||
}
|
||||
}
|
||||
// 'ignored' 消息被静默丢弃
|
||||
},
|
||||
onPermissionRequest: (request, requestId) => {
|
||||
logForDebugging(
|
||||
`[useRemoteSession] Permission request for tool: ${request.tool_name}`,
|
||||
)
|
||||
|
||||
// 按名称查找 Tool 对象,或为未知工具创建 stub
|
||||
const tool =
|
||||
findToolByName(toolsRef.current, request.tool_name) ??
|
||||
createToolStub(request.tool_name)
|
||||
|
||||
const syntheticMessage = createSyntheticAssistantMessage(
|
||||
request,
|
||||
requestId,
|
||||
)
|
||||
|
||||
const permissionResult: PermissionAskDecision = {
|
||||
behavior: 'ask',
|
||||
message:
|
||||
request.description ?? `${request.tool_name} requires permission`,
|
||||
suggestions: request.permission_suggestions,
|
||||
blockedPath: request.blocked_path,
|
||||
}
|
||||
|
||||
const toolUseConfirm: ToolUseConfirm = {
|
||||
assistantMessage: syntheticMessage,
|
||||
tool,
|
||||
description:
|
||||
request.description ?? `${request.tool_name} requires permission`,
|
||||
input: request.input,
|
||||
toolUseContext: {} as ToolUseConfirm['toolUseContext'],
|
||||
toolUseID: request.tool_use_id,
|
||||
permissionResult,
|
||||
permissionPromptStartTimeMs: Date.now(),
|
||||
onUserInteraction() {
|
||||
// 远程无操作 — 分类器在容器上运行
|
||||
},
|
||||
onAbort() {
|
||||
const response: RemotePermissionResponse = {
|
||||
behavior: 'deny',
|
||||
message: 'User aborted',
|
||||
}
|
||||
manager.respondToPermissionRequest(requestId, response)
|
||||
setToolUseConfirmQueue(queue =>
|
||||
queue.filter(item => item.toolUseID !== request.tool_use_id),
|
||||
)
|
||||
},
|
||||
onAllow(updatedInput, _permissionUpdates, _feedback) {
|
||||
const response: RemotePermissionResponse = {
|
||||
behavior: 'allow',
|
||||
updatedInput,
|
||||
}
|
||||
manager.respondToPermissionRequest(requestId, response)
|
||||
setToolUseConfirmQueue(queue =>
|
||||
queue.filter(item => item.toolUseID !== request.tool_use_id),
|
||||
)
|
||||
// 批准后恢复加载指示器
|
||||
setIsLoading(true)
|
||||
},
|
||||
onReject(feedback?: string) {
|
||||
const response: RemotePermissionResponse = {
|
||||
behavior: 'deny',
|
||||
message: feedback ?? 'User denied permission',
|
||||
}
|
||||
manager.respondToPermissionRequest(requestId, response)
|
||||
setToolUseConfirmQueue(queue =>
|
||||
queue.filter(item => item.toolUseID !== request.tool_use_id),
|
||||
)
|
||||
},
|
||||
async recheckPermission() {
|
||||
// 远程无操作 — 权限状态在容器上
|
||||
},
|
||||
}
|
||||
|
||||
setToolUseConfirmQueue(queue => [...queue, toolUseConfirm])
|
||||
// 等待权限时暂停加载指示器
|
||||
setIsLoading(false)
|
||||
},
|
||||
onPermissionCancelled: (requestId, toolUseId) => {
|
||||
logForDebugging(
|
||||
`[useRemoteSession] Permission request cancelled: ${requestId}`,
|
||||
)
|
||||
const idToRemove = toolUseId ?? requestId
|
||||
setToolUseConfirmQueue(queue =>
|
||||
queue.filter(item => item.toolUseID !== idToRemove),
|
||||
)
|
||||
setIsLoading(true)
|
||||
},
|
||||
onConnected: () => {
|
||||
logForDebugging('[useRemoteSession] Connected')
|
||||
setConnStatus('connected')
|
||||
},
|
||||
onReconnecting: () => {
|
||||
logForDebugging('[useRemoteSession] Reconnecting')
|
||||
setConnStatus('reconnecting')
|
||||
// WS 间隙 = 我们可能错过 task_notification 事件。清除而不是
|
||||
// 永远保持高位。覆盖跨越间隙的任务;可接受。
|
||||
runningTaskIdsRef.current.clear()
|
||||
writeTaskCount()
|
||||
// 同样适用于 tool_use ID:间隙期间错过的 tool_result 会
|
||||
// 永远留下陈旧的旋转器状态。
|
||||
setInProgressToolUseIDs?.(prev => (prev.size > 0 ? new Set() : prev))
|
||||
},
|
||||
onDisconnected: () => {
|
||||
logForDebugging('[useRemoteSession] Disconnected')
|
||||
setConnStatus('disconnected')
|
||||
setIsLoading(false)
|
||||
runningTaskIdsRef.current.clear()
|
||||
writeTaskCount()
|
||||
setInProgressToolUseIDs?.(prev => (prev.size > 0 ? new Set() : prev))
|
||||
},
|
||||
onError: error => {
|
||||
logForDebugging(`[useRemoteSession] Error: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
managerRef.current = manager
|
||||
manager.connect()
|
||||
|
||||
return () => {
|
||||
logForDebugging('[useRemoteSession] Cleanup - disconnecting')
|
||||
// 清除任何待处理超时
|
||||
if (responseTimeoutRef.current) {
|
||||
clearTimeout(responseTimeoutRef.current)
|
||||
responseTimeoutRef.current = null
|
||||
}
|
||||
manager.disconnect()
|
||||
managerRef.current = null
|
||||
}
|
||||
}, [
|
||||
config,
|
||||
setMessages,
|
||||
setIsLoading,
|
||||
onInit,
|
||||
setToolUseConfirmQueue,
|
||||
setStreamingToolUses,
|
||||
setStreamMode,
|
||||
setInProgressToolUseIDs,
|
||||
setConnStatus,
|
||||
writeTaskCount,
|
||||
])
|
||||
|
||||
// 发送用户消息到远程会话
|
||||
const sendMessage = useCallback(
|
||||
async (
|
||||
content: RemoteMessageContent,
|
||||
opts?: { uuid?: string },
|
||||
): Promise<boolean> => {
|
||||
const manager = managerRef.current
|
||||
if (!manager) {
|
||||
logForDebugging('[useRemoteSession] Cannot send - no manager')
|
||||
return false
|
||||
}
|
||||
|
||||
// 清除任何现有超时
|
||||
if (responseTimeoutRef.current) {
|
||||
clearTimeout(responseTimeoutRef.current)
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
// 跟踪本地添加的消息 UUID,以便可以过滤 WS 回显。
|
||||
// 必须在 POST 之前记录以关闭回显在 POST promise 解析之前到达的竞态。
|
||||
if (opts?.uuid) sentUUIDsRef.current.add(opts.uuid)
|
||||
|
||||
const success = await manager.sendMessage(content, opts)
|
||||
|
||||
if (!success) {
|
||||
// 不需要撤销 POST 之前的添加 — BoundedUUIDSet 的环会驱逐它。
|
||||
setIsLoading(false)
|
||||
return false
|
||||
}
|
||||
|
||||
// 在第一个消息之后更新会话标题,当时没有提供初始提示。
|
||||
// 这给了会话一个有意义的标题在 claude.ai 上而不是"后台任务"。
|
||||
// 在 viewerOnly 模式中跳过 — 远程代理拥有会话标题。
|
||||
if (
|
||||
!hasUpdatedTitleRef.current &&
|
||||
config &&
|
||||
!config.hasInitialPrompt &&
|
||||
!config.viewerOnly
|
||||
) {
|
||||
hasUpdatedTitleRef.current = true
|
||||
const sessionId = config.sessionId
|
||||
// 从内容中提取纯文本(可能是字符串或内容块数组)
|
||||
const description =
|
||||
typeof content === 'string'
|
||||
? content
|
||||
: extractTextContent(content, ' ')
|
||||
if (description) {
|
||||
// generateSessionTitle 从不拒绝(包装体在 try/catch 中,
|
||||
// 失败时返回 null),所以这个链上不需要 .catch。
|
||||
void generateSessionTitle(
|
||||
description,
|
||||
new AbortController().signal,
|
||||
).then(title => {
|
||||
void updateSessionTitle(
|
||||
sessionId,
|
||||
title ?? truncateToWidth(description, 75),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 启动超时以检测卡住的会话。在 viewerOnly 模式中跳过 —
|
||||
// 远程代理可能空闲关闭并需要 >60s 才能重启。
|
||||
// 当远程会话正在压缩时使用更长的超时,因为
|
||||
// CLI 工作线程正忙于 API 调用,不会发出消息。
|
||||
if (!config?.viewerOnly) {
|
||||
const timeoutMs = isCompactingRef.current
|
||||
? COMPACTION_TIMEOUT_MS
|
||||
: RESPONSE_TIMEOUT_MS
|
||||
responseTimeoutRef.current = setTimeout(
|
||||
(setMessages, manager) => {
|
||||
logForDebugging(
|
||||
'[useRemoteSession] Response timeout - attempting reconnect',
|
||||
)
|
||||
// 向对话添加警告消息
|
||||
const warningMessage = createSystemMessage(
|
||||
'Remote session may be unresponsive. Attempting to reconnect…',
|
||||
'warning',
|
||||
)
|
||||
setMessages(prev => [...prev, warningMessage])
|
||||
|
||||
// 尝试重新连接 WebSocket — 订阅可能已变得陈旧
|
||||
manager.reconnect()
|
||||
},
|
||||
timeoutMs,
|
||||
setMessages,
|
||||
manager,
|
||||
)
|
||||
}
|
||||
|
||||
return success
|
||||
},
|
||||
[config, setIsLoading, setMessages],
|
||||
)
|
||||
|
||||
// 取消远程会话上的当前请求
|
||||
const cancelRequest = useCallback(() => {
|
||||
// 清除任何待处理超时
|
||||
if (responseTimeoutRef.current) {
|
||||
clearTimeout(responseTimeoutRef.current)
|
||||
responseTimeoutRef.current = null
|
||||
}
|
||||
|
||||
// 向 CCR 发送中断信号。在 viewerOnly 模式中跳过 — Ctrl+C
|
||||
// 不应中断远程代理。
|
||||
if (!config?.viewerOnly) {
|
||||
managerRef.current?.cancelSession()
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}, [config, setIsLoading])
|
||||
|
||||
// 断开与会话的连接
|
||||
const disconnect = useCallback(() => {
|
||||
// 清除任何待处理超时
|
||||
if (responseTimeoutRef.current) {
|
||||
clearTimeout(responseTimeoutRef.current)
|
||||
responseTimeoutRef.current = null
|
||||
}
|
||||
managerRef.current?.disconnect()
|
||||
managerRef.current = null
|
||||
}, [])
|
||||
|
||||
// 所有四个字段已经稳定(布尔值派生自在会话中不会更改的 prop,
|
||||
// 三个 useCallback 具有稳定 deps)。结果对象被 REPL 的 onSubmit useCallback deps 消耗 —
|
||||
// 没有记忆化,每次 REPL 渲染时新鲜字面量都会失效,
|
||||
// 这反过来又折腾 PromptInput 的 props 和下游记忆化。
|
||||
return useMemo(
|
||||
() => ({ isRemoteMode, sendMessage, cancelRequest, disconnect }),
|
||||
[isRemoteMode, sendMessage, cancelRequest, disconnect],
|
||||
)
|
||||
}
|
||||
241
claude-code源码-中文注释/src/hooks/useSSHSession.ts
Normal file
241
claude-code源码-中文注释/src/hooks/useSSHSession.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* REPL 集成 hook,用于 `claude ssh` 会话。
|
||||
*
|
||||
* useDirectConnect 的兄弟 — 相同形状(isRemoteMode/sendMessage/
|
||||
* cancelRequest/disconnect),相同 REPL 布线,但驱动 SSH 子
|
||||
* 进程而不是 WebSocket。保持分离而不是泛化
|
||||
* useDirectConnect,因为生命周期不同:ssh 进程和 auth
|
||||
* proxy 在此 hook 运行之前创建(在启动期间,在 main.tsx 中)
|
||||
* 并传入;useDirectConnect 在效果内创建其 WebSocket。
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js'
|
||||
import {
|
||||
createSyntheticAssistantMessage,
|
||||
createToolStub,
|
||||
} from '../remote/remotePermissionBridge.js'
|
||||
import {
|
||||
convertSDKMessage,
|
||||
isSessionEndMessage,
|
||||
} from '../remote/sdkMessageAdapter.js'
|
||||
import type { SSHSession } from '../ssh/createSSHSession.js'
|
||||
import type { SSHSessionManager } from '../ssh/SSHSessionManager.js'
|
||||
import type { Tool } from '../Tool.js'
|
||||
import { findToolByName } from '../Tool.js'
|
||||
import type { Message as MessageType } from '../types/message.js'
|
||||
import type { PermissionAskDecision } from '../types/permissions.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { gracefulShutdown } from '../utils/gracefulShutdown.js'
|
||||
import type { RemoteMessageContent } from '../utils/teleport/api.js'
|
||||
|
||||
type UseSSHSessionResult = {
|
||||
isRemoteMode: boolean
|
||||
sendMessage: (content: RemoteMessageContent) => Promise<boolean>
|
||||
cancelRequest: () => void
|
||||
disconnect: () => void
|
||||
}
|
||||
|
||||
type UseSSHSessionProps = {
|
||||
session: SSHSession | undefined
|
||||
setMessages: React.Dispatch<React.SetStateAction<MessageType[]>>
|
||||
setIsLoading: (loading: boolean) => void
|
||||
setToolUseConfirmQueue: React.Dispatch<React.SetStateAction<ToolUseConfirm[]>>
|
||||
tools: Tool[]
|
||||
}
|
||||
|
||||
export function useSSHSession({
|
||||
session,
|
||||
setMessages,
|
||||
setIsLoading,
|
||||
setToolUseConfirmQueue,
|
||||
tools,
|
||||
}: UseSSHSessionProps): UseSSHSessionResult {
|
||||
const isRemoteMode = !!session
|
||||
|
||||
const managerRef = useRef<SSHSessionManager | null>(null)
|
||||
const hasReceivedInitRef = useRef(false)
|
||||
const isConnectedRef = useRef(false)
|
||||
|
||||
const toolsRef = useRef(tools)
|
||||
useEffect(() => {
|
||||
toolsRef.current = tools
|
||||
}, [tools])
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
|
||||
hasReceivedInitRef.current = false
|
||||
logForDebugging('[useSSHSession] wiring SSH session manager')
|
||||
|
||||
const manager = session.createManager({
|
||||
onMessage: sdkMessage => {
|
||||
if (isSessionEndMessage(sdkMessage)) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
// 跳过重复的 init 消息(来自 stream-json 模式的每次 turn 一个)。
|
||||
if (sdkMessage.type === 'system' && sdkMessage.subtype === 'init') {
|
||||
if (hasReceivedInitRef.current) return
|
||||
hasReceivedInitRef.current = true
|
||||
}
|
||||
|
||||
const converted = convertSDKMessage(sdkMessage, {
|
||||
convertToolResults: true,
|
||||
})
|
||||
if (converted.type === 'message') {
|
||||
setMessages(prev => [...prev, converted.message])
|
||||
}
|
||||
},
|
||||
onPermissionRequest: (request, requestId) => {
|
||||
logForDebugging(
|
||||
`[useSSHSession] permission request: ${request.tool_name}`,
|
||||
)
|
||||
|
||||
const tool =
|
||||
findToolByName(toolsRef.current, request.tool_name) ??
|
||||
createToolStub(request.tool_name)
|
||||
|
||||
const syntheticMessage = createSyntheticAssistantMessage(
|
||||
request,
|
||||
requestId,
|
||||
)
|
||||
|
||||
const permissionResult: PermissionAskDecision = {
|
||||
behavior: 'ask',
|
||||
message:
|
||||
request.description ?? `${request.tool_name} requires permission`,
|
||||
suggestions: request.permission_suggestions,
|
||||
blockedPath: request.blocked_path,
|
||||
}
|
||||
|
||||
const toolUseConfirm: ToolUseConfirm = {
|
||||
assistantMessage: syntheticMessage,
|
||||
tool,
|
||||
description:
|
||||
request.description ?? `${request.tool_name} requires permission`,
|
||||
input: request.input,
|
||||
toolUseContext: {} as ToolUseConfirm['toolUseContext'],
|
||||
toolUseID: request.tool_use_id,
|
||||
permissionResult,
|
||||
permissionPromptStartTimeMs: Date.now(),
|
||||
onUserInteraction() {},
|
||||
onAbort() {
|
||||
manager.respondToPermissionRequest(requestId, {
|
||||
behavior: 'deny',
|
||||
message: 'User aborted',
|
||||
})
|
||||
setToolUseConfirmQueue(q =>
|
||||
q.filter(i => i.toolUseID !== request.tool_use_id),
|
||||
)
|
||||
},
|
||||
onAllow(updatedInput) {
|
||||
manager.respondToPermissionRequest(requestId, {
|
||||
behavior: 'allow',
|
||||
updatedInput,
|
||||
})
|
||||
setToolUseConfirmQueue(q =>
|
||||
q.filter(i => i.toolUseID !== request.tool_use_id),
|
||||
)
|
||||
setIsLoading(true)
|
||||
},
|
||||
onReject(feedback) {
|
||||
manager.respondToPermissionRequest(requestId, {
|
||||
behavior: 'deny',
|
||||
message: feedback ?? 'User denied permission',
|
||||
})
|
||||
setToolUseConfirmQueue(q =>
|
||||
q.filter(i => i.toolUseID !== request.tool_use_id),
|
||||
)
|
||||
},
|
||||
async recheckPermission() {},
|
||||
}
|
||||
|
||||
setToolUseConfirmQueue(q => [...q, toolUseConfirm])
|
||||
setIsLoading(false)
|
||||
},
|
||||
onConnected: () => {
|
||||
logForDebugging('[useSSHSession] connected')
|
||||
isConnectedRef.current = true
|
||||
},
|
||||
onReconnecting: (attempt, max) => {
|
||||
logForDebugging(
|
||||
`[useSSHSession] ssh dropped, reconnecting (${attempt}/${max})`,
|
||||
)
|
||||
isConnectedRef.current = false
|
||||
// 在转录本中显示瞬态系统消息,
|
||||
// 以便用户知道发生了什么 — 下一个 onConnected 清除状态。
|
||||
// 任何飞行中的请求都会丢失;远程的 --continue 重新加载
|
||||
// 历史,但没有正在进行的 turn 可以恢复。
|
||||
setIsLoading(false)
|
||||
const msg: MessageType = {
|
||||
type: 'system',
|
||||
subtype: 'informational',
|
||||
content: `SSH connection dropped — reconnecting (attempt ${attempt}/${max})...`,
|
||||
timestamp: new Date().toISOString(),
|
||||
uuid: randomUUID(),
|
||||
level: 'warning',
|
||||
}
|
||||
setMessages(prev => [...prev, msg])
|
||||
},
|
||||
onDisconnected: () => {
|
||||
logForDebugging('[useSSHSession] ssh process exited (giving up)')
|
||||
const stderr = session.getStderrTail().trim()
|
||||
const connected = isConnectedRef.current
|
||||
const exitCode = session.proc.exitCode
|
||||
isConnectedRef.current = false
|
||||
setIsLoading(false)
|
||||
|
||||
let msg = connected
|
||||
? 'Remote session ended.'
|
||||
: 'SSH session failed before connecting.'
|
||||
// 如果远程 stderr 看起来像错误,则显示它(连接前始终,
|
||||
// 连接后仅在非零退出时 — 否则为正常的 --verbose 噪音)。
|
||||
if (stderr && (!connected || exitCode !== 0)) {
|
||||
msg += `\nRemote stderr (exit ${exitCode ?? 'signal ' + session.proc.signalCode}):\n${stderr}`
|
||||
}
|
||||
void gracefulShutdown(1, 'other', { finalMessage: msg })
|
||||
},
|
||||
onError: error => {
|
||||
logForDebugging(`[useSSHSession] error: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
managerRef.current = manager
|
||||
manager.connect()
|
||||
|
||||
return () => {
|
||||
logForDebugging('[useSSHSession] cleanup')
|
||||
manager.disconnect()
|
||||
session.proxy.stop()
|
||||
managerRef.current = null
|
||||
}
|
||||
}, [session, setMessages, setIsLoading, setToolUseConfirmQueue])
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: RemoteMessageContent): Promise<boolean> => {
|
||||
const m = managerRef.current
|
||||
if (!m) return false
|
||||
setIsLoading(true)
|
||||
return m.sendMessage(content)
|
||||
},
|
||||
[setIsLoading],
|
||||
)
|
||||
|
||||
const cancelRequest = useCallback(() => {
|
||||
managerRef.current?.sendInterrupt()
|
||||
setIsLoading(false)
|
||||
}, [setIsLoading])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
managerRef.current?.disconnect()
|
||||
managerRef.current = null
|
||||
isConnectedRef.current = false
|
||||
}, [])
|
||||
|
||||
return useMemo(
|
||||
() => ({ isRemoteMode, sendMessage, cancelRequest, disconnect }),
|
||||
[isRemoteMode, sendMessage, cancelRequest, disconnect],
|
||||
)
|
||||
}
|
||||
125
claude-code源码-中文注释/src/hooks/useScheduledTasks.ts
Normal file
125
claude-code源码-中文注释/src/hooks/useScheduledTasks.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
getTerminalFocusState,
|
||||
subscribeTerminalFocus,
|
||||
} from '../ink/terminal-focus-state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
||||
import { generateAwaySummary } from '../services/awaySummary.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import { createAwaySummaryMessage } from '../utils/messages.js'
|
||||
|
||||
const BLUR_DELAY_MS = 5 * 60_000
|
||||
|
||||
type SetMessages = (updater: (prev: Message[]) => Message[]) => void
|
||||
|
||||
function hasSummarySinceLastUserTurn(messages: readonly Message[]): boolean {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!
|
||||
if (m.type === 'user' && !m.isMeta && !m.isCompactSummary) return false
|
||||
if (m.type === 'system' && m.subtype === 'away_summary') return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 在终端失焦 5 分钟后追加"离开期间摘要"消息。
|
||||
* 仅在以下条件满足时触发:(a) 失焦后 5 分钟,(b) 当前无进行中的对话,
|
||||
* (c) 自上次用户消息以来没有已有的 away_summary。
|
||||
*
|
||||
* Focus state 为 'unknown'(终端不支持 DECSET 1004)时无操作。
|
||||
*/
|
||||
export function useAwaySummary(
|
||||
messages: readonly Message[],
|
||||
setMessages: SetMessages,
|
||||
isLoading: boolean,
|
||||
): void {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const messagesRef = useRef(messages)
|
||||
const isLoadingRef = useRef(isLoading)
|
||||
const pendingRef = useRef(false)
|
||||
const generateRef = useRef<(() => Promise<void>) | null>(null)
|
||||
|
||||
messagesRef.current = messages
|
||||
isLoadingRef.current = isLoading
|
||||
|
||||
// 3P 默认值: false
|
||||
const gbEnabled = getFeatureValue_CACHED_MAY_BE_STALE(
|
||||
'tengu_sedge_lantern',
|
||||
false,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!feature('AWAY_SUMMARY')) return
|
||||
if (!gbEnabled) return
|
||||
|
||||
function clearTimer(): void {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
function abortInFlight(): void {
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
}
|
||||
|
||||
async function generate(): Promise<void> {
|
||||
pendingRef.current = false
|
||||
if (hasSummarySinceLastUserTurn(messagesRef.current)) return
|
||||
abortInFlight()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const text = await generateAwaySummary(
|
||||
messagesRef.current,
|
||||
controller.signal,
|
||||
)
|
||||
if (controller.signal.aborted || text === null) return
|
||||
setMessages(prev => [...prev, createAwaySummaryMessage(text)])
|
||||
}
|
||||
|
||||
function onBlurTimerFire(): void {
|
||||
timerRef.current = null
|
||||
if (isLoadingRef.current) {
|
||||
pendingRef.current = true
|
||||
return
|
||||
}
|
||||
void generate()
|
||||
}
|
||||
|
||||
function onFocusChange(): void {
|
||||
const state = getTerminalFocusState()
|
||||
if (state === 'blurred') {
|
||||
clearTimer()
|
||||
timerRef.current = setTimeout(onBlurTimerFire, BLUR_DELAY_MS)
|
||||
} else if (state === 'focused') {
|
||||
clearTimer()
|
||||
abortInFlight()
|
||||
pendingRef.current = false
|
||||
}
|
||||
// 'unknown' → 无操作
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeTerminalFocus(onFocusChange)
|
||||
// 处理效果挂载时已经失焦的情况
|
||||
onFocusChange()
|
||||
generateRef.current = generate
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
clearTimer()
|
||||
abortInFlight()
|
||||
generateRef.current = null
|
||||
}
|
||||
}, [gbEnabled, setMessages])
|
||||
|
||||
// 定时器在对话期间触发 → 在对话结束时触发(如果仍然失焦)
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
if (!pendingRef.current) return
|
||||
if (getTerminalFocusState() !== 'blurred') return
|
||||
void generateRef.current?.()
|
||||
}, [isLoading])
|
||||
}
|
||||
365
claude-code源码-中文注释/src/hooks/useSearchInput.ts
Normal file
365
claude-code源码-中文注释/src/hooks/useSearchInput.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { KeyboardEvent } from '../ink/events/keyboard-event.js'
|
||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- backward-compat bridge until consumers wire handleKeyDown to <Box onKeyDown>
|
||||
import { useInput } from '../ink.js'
|
||||
import {
|
||||
Cursor,
|
||||
getLastKill,
|
||||
pushToKillRing,
|
||||
recordYank,
|
||||
resetKillAccumulation,
|
||||
resetYankState,
|
||||
updateYankLength,
|
||||
yankPop,
|
||||
} from '../utils/Cursor.js'
|
||||
import { useTerminalSize } from './useTerminalSize.js'
|
||||
|
||||
type UseSearchInputOptions = {
|
||||
isActive: boolean
|
||||
onExit: () => void
|
||||
/** Esc + Ctrl+C 放弃(与 onExit = Enter 提交不同)。当提供时:
|
||||
* 单次 Esc 直接调用此函数(不是先清除再退出的两按)。当缺席时:
|
||||
* 当前行为 — Esc 清除非空查询,空时退出;Ctrl+C 静默吞下(无 switch case)。 */
|
||||
onCancel?: () => void
|
||||
onExitUp?: () => void
|
||||
columns?: number
|
||||
passthroughCtrlKeys?: string[]
|
||||
initialQuery?: string
|
||||
/** 空查询时的 Backspace(和 ctrl+h)调用 onCancel ?? onExit — less/vim
|
||||
* "delete past the /" 约定。只想 Esc 取消的对话框将此设为 false,
|
||||
* 这样按住 backspace 不会弹出用户。 */
|
||||
backspaceExitsOnEmpty?: boolean
|
||||
}
|
||||
|
||||
type UseSearchInputReturn = {
|
||||
query: string
|
||||
setQuery: (q: string) => void
|
||||
cursorOffset: number
|
||||
handleKeyDown: (e: KeyboardEvent) => void
|
||||
}
|
||||
|
||||
function isKillKey(e: KeyboardEvent): boolean {
|
||||
if (e.ctrl && (e.key === 'k' || e.key === 'u' || e.key === 'w')) {
|
||||
return true
|
||||
}
|
||||
if (e.meta && e.key === 'backspace') {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isYankKey(e: KeyboardEvent): boolean {
|
||||
return (e.ctrl || e.meta) && e.key === 'y'
|
||||
}
|
||||
|
||||
// 在上面的文本输入分支之前提前返回的特殊键名
|
||||
//(return/escape/arrows/home/end/tab/backspace/delete 都 early-return)。
|
||||
// 拒绝这些以防止 e.g. PageUp 泄漏 'pageup' 作为字面文本。
|
||||
// 下面的 length>=1 检查有意宽松 — 批处理的输入如 stdin.write('abc')
|
||||
// 作为一个多字符 e.key 到达,匹配旧的 useInput(input) 行为,
|
||||
// 其中 cursor.insert(input) 插入完整块。
|
||||
const UNHANDLED_SPECIAL_KEYS = new Set([
|
||||
'pageup',
|
||||
'pagedown',
|
||||
'insert',
|
||||
'wheelup',
|
||||
'wheeldown',
|
||||
'mouse',
|
||||
'f1',
|
||||
'f2',
|
||||
'f3',
|
||||
'f4',
|
||||
'f5',
|
||||
'f6',
|
||||
'f7',
|
||||
'f8',
|
||||
'f9',
|
||||
'f10',
|
||||
'f11',
|
||||
'f12',
|
||||
])
|
||||
|
||||
/**
|
||||
* 管理搜索输入(如 / 搜索)的 Hook。
|
||||
* 支持 readline 风格的快捷键(Ctrl+A/E/F/B/K/U/Y 等)。
|
||||
*/
|
||||
export function useSearchInput({
|
||||
isActive,
|
||||
onExit,
|
||||
onCancel,
|
||||
onExitUp,
|
||||
columns,
|
||||
passthroughCtrlKeys = [],
|
||||
initialQuery = '',
|
||||
backspaceExitsOnEmpty = true,
|
||||
}: UseSearchInputOptions): UseSearchInputReturn {
|
||||
const { columns: terminalColumns } = useTerminalSize()
|
||||
const effectiveColumns = columns ?? terminalColumns
|
||||
const [query, setQueryState] = useState(initialQuery)
|
||||
const [cursorOffset, setCursorOffset] = useState(initialQuery.length)
|
||||
|
||||
const setQuery = useCallback((q: string) => {
|
||||
setQueryState(q)
|
||||
setCursorOffset(q.length)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (!isActive) return
|
||||
|
||||
const cursor = Cursor.fromText(query, effectiveColumns, cursorOffset)
|
||||
|
||||
// 检查透传 ctrl 键
|
||||
if (e.ctrl && passthroughCtrlKeys.includes(e.key.toLowerCase())) {
|
||||
return
|
||||
}
|
||||
|
||||
// 非 kill 键重置 kill 累加
|
||||
if (!isKillKey(e)) {
|
||||
resetKillAccumulation()
|
||||
}
|
||||
|
||||
// 非 yank 键重置 yank 状态
|
||||
if (!isYankKey(e)) {
|
||||
resetYankState()
|
||||
}
|
||||
|
||||
// 退出条件
|
||||
if (e.key === 'return' || e.key === 'down') {
|
||||
e.preventDefault()
|
||||
onExit()
|
||||
return
|
||||
}
|
||||
if (e.key === 'up') {
|
||||
e.preventDefault()
|
||||
if (onExitUp) {
|
||||
onExitUp()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === 'escape') {
|
||||
e.preventDefault()
|
||||
if (onCancel) {
|
||||
onCancel()
|
||||
} else if (query.length > 0) {
|
||||
setQueryState('')
|
||||
setCursorOffset(0)
|
||||
} else {
|
||||
onExit()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Backspace/Delete
|
||||
if (e.key === 'backspace') {
|
||||
e.preventDefault()
|
||||
if (e.meta) {
|
||||
// Meta+Backspace: 删除光标前的单词
|
||||
const { cursor: newCursor, killed } = cursor.deleteWordBefore()
|
||||
pushToKillRing(killed, 'prepend')
|
||||
setQueryState(newCursor.text)
|
||||
setCursorOffset(newCursor.offset)
|
||||
return
|
||||
}
|
||||
if (query.length === 0) {
|
||||
// Backspace past the / — 取消(清除 + 弹回),不是提交。
|
||||
// less: 相同。vim: 删除 / 并退出命令模式。
|
||||
if (backspaceExitsOnEmpty) (onCancel ?? onExit)()
|
||||
return
|
||||
}
|
||||
const newCursor = cursor.backspace()
|
||||
setQueryState(newCursor.text)
|
||||
setCursorOffset(newCursor.offset)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'delete') {
|
||||
e.preventDefault()
|
||||
const newCursor = cursor.del()
|
||||
setQueryState(newCursor.text)
|
||||
setCursorOffset(newCursor.offset)
|
||||
return
|
||||
}
|
||||
|
||||
// 带修饰符的方向键(单词跳转)
|
||||
if (e.key === 'left' && (e.ctrl || e.meta || e.fn)) {
|
||||
e.preventDefault()
|
||||
const newCursor = cursor.prevWord()
|
||||
setCursorOffset(newCursor.offset)
|
||||
return
|
||||
}
|
||||
if (e.key === 'right' && (e.ctrl || e.meta || e.fn)) {
|
||||
e.preventDefault()
|
||||
const newCursor = cursor.nextWord()
|
||||
setCursorOffset(newCursor.offset)
|
||||
return
|
||||
}
|
||||
|
||||
// 普通方向键
|
||||
if (e.key === 'left') {
|
||||
e.preventDefault()
|
||||
const newCursor = cursor.left()
|
||||
setCursorOffset(newCursor.offset)
|
||||
return
|
||||
}
|
||||
if (e.key === 'right') {
|
||||
e.preventDefault()
|
||||
const newCursor = cursor.right()
|
||||
setCursorOffset(newCursor.offset)
|
||||
return
|
||||
}
|
||||
|
||||
// Home/End
|
||||
if (e.key === 'home') {
|
||||
e.preventDefault()
|
||||
setCursorOffset(0)
|
||||
return
|
||||
}
|
||||
if (e.key === 'end') {
|
||||
e.preventDefault()
|
||||
setCursorOffset(query.length)
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl 键绑定
|
||||
if (e.ctrl) {
|
||||
e.preventDefault()
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'a':
|
||||
setCursorOffset(0)
|
||||
return
|
||||
case 'e':
|
||||
setCursorOffset(query.length)
|
||||
return
|
||||
case 'b':
|
||||
setCursorOffset(cursor.left().offset)
|
||||
return
|
||||
case 'f':
|
||||
setCursorOffset(cursor.right().offset)
|
||||
return
|
||||
case 'd': {
|
||||
if (query.length === 0) {
|
||||
;(onCancel ?? onExit)()
|
||||
return
|
||||
}
|
||||
const newCursor = cursor.del()
|
||||
setQueryState(newCursor.text)
|
||||
setCursorOffset(newCursor.offset)
|
||||
return
|
||||
}
|
||||
case 'h': {
|
||||
if (query.length === 0) {
|
||||
if (backspaceExitsOnEmpty) (onCancel ?? onExit)()
|
||||
return
|
||||
}
|
||||
const newCursor = cursor.backspace()
|
||||
setQueryState(newCursor.text)
|
||||
setCursorOffset(newCursor.offset)
|
||||
return
|
||||
}
|
||||
case 'k': {
|
||||
const { cursor: newCursor, killed } = cursor.deleteToLineEnd()
|
||||
pushToKillRing(killed, 'append')
|
||||
setQueryState(newCursor.text)
|
||||
setCursorOffset(newCursor.offset)
|
||||
return
|
||||
}
|
||||
case 'u': {
|
||||
const { cursor: newCursor, killed } = cursor.deleteToLineStart()
|
||||
pushToKillRing(killed, 'prepend')
|
||||
setQueryState(newCursor.text)
|
||||
setCursorOffset(newCursor.offset)
|
||||
return
|
||||
}
|
||||
case 'w': {
|
||||
const { cursor: newCursor, killed } = cursor.deleteWordBefore()
|
||||
pushToKillRing(killed, 'prepend')
|
||||
setQueryState(newCursor.text)
|
||||
setCursorOffset(newCursor.offset)
|
||||
return
|
||||
}
|
||||
case 'y': {
|
||||
const text = getLastKill()
|
||||
if (text.length > 0) {
|
||||
const startOffset = cursor.offset
|
||||
const newCursor = cursor.insert(text)
|
||||
recordYank(startOffset, text.length)
|
||||
setQueryState(newCursor.text)
|
||||
setCursorOffset(newCursor.offset)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'g':
|
||||
case 'c':
|
||||
// 取消(放弃搜索)。ctrl+g 是 less 的取消键。仅在提供 onCancel 时触发 —
|
||||
// 否则静默返回(11 个调用点,大多数期望 ctrl+c 无操作)。
|
||||
if (onCancel) {
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Meta 键绑定
|
||||
if (e.meta) {
|
||||
e.preventDefault()
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'b':
|
||||
setCursorOffset(cursor.prevWord().offset)
|
||||
return
|
||||
case 'f':
|
||||
setCursorOffset(cursor.nextWord().offset)
|
||||
return
|
||||
case 'd': {
|
||||
const newCursor = cursor.deleteWordAfter()
|
||||
setQueryState(newCursor.text)
|
||||
setCursorOffset(newCursor.offset)
|
||||
return
|
||||
}
|
||||
case 'y': {
|
||||
const popResult = yankPop()
|
||||
if (popResult) {
|
||||
const { text, start, length } = popResult
|
||||
const before = query.slice(0, start)
|
||||
const after = query.slice(start + length)
|
||||
const newText = before + text + after
|
||||
const newOffset = start + text.length
|
||||
updateYankLength(text.length)
|
||||
setQueryState(newText)
|
||||
setCursorOffset(newOffset)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Tab: 忽略
|
||||
if (e.key === 'tab') {
|
||||
return
|
||||
}
|
||||
|
||||
// 常规字符输入。接受 multi-char e.key 以便批处理写入
|
||||
//(测试中的 stdin.write('abc'),或括号粘贴模式外的粘贴)
|
||||
// 插入完整块 — 匹配旧的 useInput 行为。
|
||||
if (e.key.length >= 1 && !UNHANDLED_SPECIAL_KEYS.has(e.key)) {
|
||||
e.preventDefault()
|
||||
const newCursor = cursor.insert(e.key)
|
||||
setQueryState(newCursor.text)
|
||||
setCursorOffset(newCursor.offset)
|
||||
}
|
||||
}
|
||||
|
||||
// 向后兼容桥接:现有消费者尚未将 handleKeyDown 连接到 <Box onKeyDown>。
|
||||
// 通过 useInput 订阅并适配 InputEvent → KeyboardEvent,
|
||||
// 直到所有 11 个调用点迁移完成(单独的 PR)。
|
||||
// TODO(onKeyDown-migration): 一旦所有消费者传递 handleKeyDown,删除此代码。
|
||||
useInput(
|
||||
(_input, _key, event) => {
|
||||
handleKeyDown(new KeyboardEvent(event.keypress))
|
||||
},
|
||||
{ isActive },
|
||||
)
|
||||
|
||||
return { query, setQuery, cursorOffset, handleKeyDown }
|
||||
}
|
||||
158
claude-code源码-中文注释/src/hooks/useSessionBackgrounding.ts
Normal file
158
claude-code源码-中文注释/src/hooks/useSessionBackgrounding.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 用于管理会话后台化的 Hook(Ctrl+B 用于后台化/前景化会话)。
|
||||
*
|
||||
* 处理:
|
||||
* - 调用 onBackgroundQuery 为当前查询生成后台任务
|
||||
* - 重新后台化前景任务
|
||||
* - 将前景任务消息/状态同步到主视图
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
|
||||
type UseSessionBackgroundingProps = {
|
||||
setMessages: (messages: Message[] | ((prev: Message[]) => Message[])) => void
|
||||
setIsLoading: (loading: boolean) => void
|
||||
resetLoadingState: () => void
|
||||
setAbortController: (controller: AbortController | null) => void
|
||||
onBackgroundQuery: () => void
|
||||
}
|
||||
|
||||
type UseSessionBackgroundingResult = {
|
||||
/** 当用户想要后台化时调用(Ctrl+B) */
|
||||
handleBackgroundSession: () => void
|
||||
}
|
||||
|
||||
export function useSessionBackgrounding({
|
||||
setMessages,
|
||||
setIsLoading,
|
||||
resetLoadingState,
|
||||
setAbortController,
|
||||
onBackgroundQuery,
|
||||
}: UseSessionBackgroundingProps): UseSessionBackgroundingResult {
|
||||
const foregroundedTaskId = useAppState(s => s.foregroundedTaskId)
|
||||
const foregroundedTask = useAppState(s =>
|
||||
s.foregroundedTaskId ? s.tasks[s.foregroundedTaskId] : undefined,
|
||||
)
|
||||
const setAppState = useSetAppState()
|
||||
const lastSyncedMessagesLengthRef = useRef<number>(0)
|
||||
|
||||
const handleBackgroundSession = useCallback(() => {
|
||||
if (foregroundedTaskId) {
|
||||
// 重新后台化前景任务
|
||||
setAppState(prev => {
|
||||
const taskId = prev.foregroundedTaskId
|
||||
if (!taskId) return prev
|
||||
const task = prev.tasks[taskId]
|
||||
if (!task) {
|
||||
return { ...prev, foregroundedTaskId: undefined }
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
foregroundedTaskId: undefined,
|
||||
tasks: {
|
||||
...prev.tasks,
|
||||
[taskId]: { ...task, isBackgrounded: true },
|
||||
},
|
||||
}
|
||||
})
|
||||
setMessages([])
|
||||
resetLoadingState()
|
||||
setAbortController(null)
|
||||
return
|
||||
}
|
||||
|
||||
onBackgroundQuery()
|
||||
}, [
|
||||
foregroundedTaskId,
|
||||
setAppState,
|
||||
setMessages,
|
||||
resetLoadingState,
|
||||
setAbortController,
|
||||
onBackgroundQuery,
|
||||
])
|
||||
|
||||
// 将前景任务的消息和加载状态同步到主视图
|
||||
useEffect(() => {
|
||||
if (!foregroundedTaskId) {
|
||||
// 没有前景任务时重置
|
||||
lastSyncedMessagesLengthRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (!foregroundedTask || foregroundedTask.type !== 'local_agent') {
|
||||
setAppState(prev => ({ ...prev, foregroundedTaskId: undefined }))
|
||||
resetLoadingState()
|
||||
lastSyncedMessagesLengthRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
// 将后台任务的消息同步到主视图
|
||||
// 仅在实际更改时更新以避免冗余渲染
|
||||
const taskMessages = foregroundedTask.messages ?? []
|
||||
if (taskMessages.length !== lastSyncedMessagesLengthRef.current) {
|
||||
lastSyncedMessagesLengthRef.current = taskMessages.length
|
||||
setMessages([...taskMessages])
|
||||
}
|
||||
|
||||
if (foregroundedTask.status === 'running') {
|
||||
// 检查任务是否已被中止(用户按下了 Escape)
|
||||
const taskAbortController = foregroundedTask.abortController
|
||||
if (taskAbortController?.signal.aborted) {
|
||||
// 任务已被中止 - 立即清除前景状态
|
||||
setAppState(prev => {
|
||||
if (!prev.foregroundedTaskId) return prev
|
||||
const task = prev.tasks[prev.foregroundedTaskId]
|
||||
if (!task) return { ...prev, foregroundedTaskId: undefined }
|
||||
return {
|
||||
...prev,
|
||||
foregroundedTaskId: undefined,
|
||||
tasks: {
|
||||
...prev.tasks,
|
||||
[prev.foregroundedTaskId]: { ...task, isBackgrounded: true },
|
||||
},
|
||||
}
|
||||
})
|
||||
resetLoadingState()
|
||||
setAbortController(null)
|
||||
lastSyncedMessagesLengthRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
// 将中止控制器设置为前景任务的控制器以处理 Escape
|
||||
if (taskAbortController) {
|
||||
setAbortController(taskAbortController)
|
||||
}
|
||||
} else {
|
||||
// 任务完成 - 恢复到后台并清除前景视图
|
||||
setAppState(prev => {
|
||||
const taskId = prev.foregroundedTaskId
|
||||
if (!taskId) return prev
|
||||
const task = prev.tasks[taskId]
|
||||
if (!task) return { ...prev, foregroundedTaskId: undefined }
|
||||
return {
|
||||
...prev,
|
||||
foregroundedTaskId: undefined,
|
||||
tasks: { ...prev.tasks, [taskId]: { ...task, isBackgrounded: true } },
|
||||
}
|
||||
})
|
||||
resetLoadingState()
|
||||
setAbortController(null)
|
||||
lastSyncedMessagesLengthRef.current = 0
|
||||
}
|
||||
}, [
|
||||
foregroundedTaskId,
|
||||
foregroundedTask,
|
||||
setAppState,
|
||||
setMessages,
|
||||
setIsLoading,
|
||||
resetLoadingState,
|
||||
setAbortController,
|
||||
])
|
||||
|
||||
return {
|
||||
handleBackgroundSession,
|
||||
}
|
||||
}
|
||||
17
claude-code源码-中文注释/src/hooks/useSettings.ts
Normal file
17
claude-code源码-中文注释/src/hooks/useSettings.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { type AppState, useAppState } from '../state/AppState.js'
|
||||
|
||||
/**
|
||||
* Settings type as stored in AppState (DeepImmutable wrapped).
|
||||
* Use this type when you need to annotate variables that hold settings from useSettings().
|
||||
*/
|
||||
export type ReadonlySettings = AppState['settings']
|
||||
|
||||
/**
|
||||
* React hook to access current settings from AppState.
|
||||
* Settings automatically update when files change on disk via settingsChangeDetector.
|
||||
*
|
||||
* Use this instead of getSettings_DEPRECATED() in React components for reactive updates.
|
||||
*/
|
||||
export function useSettings(): ReadonlySettings {
|
||||
return useAppState(s => s.settings)
|
||||
}
|
||||
34
claude-code源码-中文注释/src/hooks/useSettingsChange.ts
Normal file
34
claude-code源码-中文注释/src/hooks/useSettingsChange.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { settingsChangeDetector } from '../utils/settings/changeDetector.js'
|
||||
import type { SettingSource } from '../utils/settings/constants.js'
|
||||
import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
|
||||
import type { SettingsJson } from '../utils/settings/types.js'
|
||||
|
||||
/**
|
||||
* 订阅设置变更的 Hook。
|
||||
* 通过 settingsChangeDetector 监听设置变化,
|
||||
* 当变化时调用 onChange 回调并传递新的设置值。
|
||||
*
|
||||
* 注意:缓存由 notifier(changeDetector.fanOut)重置。
|
||||
* 在这里重置会导致 N 个订阅者之间的缓存震荡:
|
||||
* 每个订阅者清除缓存、从磁盘重新读取、然后下一个又清除。
|
||||
*/
|
||||
export function useSettingsChange(
|
||||
onChange: (source: SettingSource, settings: SettingsJson) => void,
|
||||
): void {
|
||||
const handleChange = useCallback(
|
||||
(source: SettingSource) => {
|
||||
// 缓存已由 notifier(changeDetector.fanOut)重置 -
|
||||
// 在这里重置会导致 N 个订阅者之间的缓存震荡:每个订阅者清除缓存、
|
||||
// 从磁盘重新读取,然后下一个又清除。
|
||||
const newSettings = getSettings_DEPRECATED()
|
||||
onChange(source, newSettings)
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => settingsChangeDetector.subscribe(handleChange),
|
||||
[handleChange],
|
||||
)
|
||||
}
|
||||
110
claude-code源码-中文注释/src/hooks/useSkillImprovementSurvey.ts
Normal file
110
claude-code源码-中文注释/src/hooks/useSkillImprovementSurvey.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { FeedbackSurveyResponse } from '../components/FeedbackSurvey/utils.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
logEvent,
|
||||
} from '../services/analytics/index.js'
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import type { SkillUpdate } from '../utils/hooks/skillImprovement.js'
|
||||
import { applySkillImprovement } from '../utils/hooks/skillImprovement.js'
|
||||
import { createSystemMessage } from '../utils/messages.js'
|
||||
|
||||
type SkillImprovementSuggestion = {
|
||||
skillName: string
|
||||
updates: SkillUpdate[]
|
||||
}
|
||||
|
||||
type SetMessages = (fn: (prev: Message[]) => Message[]) => void
|
||||
|
||||
/**
|
||||
* 管理技能改进建议调查的 Hook。
|
||||
* 当收到新建议时打开调查,处理用户选择(应用或忽略)。
|
||||
* 记录遥测事件并应用技能更新。
|
||||
*/
|
||||
export function useSkillImprovementSurvey(setMessages: SetMessages): {
|
||||
isOpen: boolean
|
||||
suggestion: SkillImprovementSuggestion | null
|
||||
handleSelect: (selected: FeedbackSurveyResponse) => void
|
||||
} {
|
||||
const suggestion = useAppState(s => s.skillImprovement.suggestion)
|
||||
const setAppState = useSetAppState()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const lastSuggestionRef = useRef(suggestion)
|
||||
const loggedAppearanceRef = useRef(false)
|
||||
|
||||
// 跟踪建议以便在清除 AppState 后仍能显示
|
||||
if (suggestion) {
|
||||
lastSuggestionRef.current = suggestion
|
||||
}
|
||||
|
||||
// 当收到新建议时打开
|
||||
if (suggestion && !isOpen) {
|
||||
setIsOpen(true)
|
||||
if (!loggedAppearanceRef.current) {
|
||||
loggedAppearanceRef.current = true
|
||||
logEvent('tengu_skill_improvement_survey', {
|
||||
event_type:
|
||||
'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
// _PROTO_skill_name 路由到特权 skill_name BQ 列。
|
||||
// 未编辑的名称不会进入 additional_metadata。
|
||||
_PROTO_skill_name: (suggestion.skillName ??
|
||||
'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selected: FeedbackSurveyResponse) => {
|
||||
const current = lastSuggestionRef.current
|
||||
if (!current) return
|
||||
|
||||
const applied = selected !== 'dismissed'
|
||||
|
||||
logEvent('tengu_skill_improvement_survey', {
|
||||
event_type:
|
||||
'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
response: (applied
|
||||
? 'applied'
|
||||
: 'dismissed') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
// _PROTO_skill_name 路由到特权 skill_name BQ 列。
|
||||
// 未编辑的名称不会进入 additional_metadata。
|
||||
_PROTO_skill_name:
|
||||
current.skillName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
})
|
||||
|
||||
if (applied) {
|
||||
void applySkillImprovement(current.skillName, current.updates).then(
|
||||
() => {
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
createSystemMessage(
|
||||
`Skill "${current.skillName}" updated with improvements.`,
|
||||
'suggestion',
|
||||
),
|
||||
])
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 关闭并清除
|
||||
setIsOpen(false)
|
||||
loggedAppearanceRef.current = false
|
||||
setAppState(prev => {
|
||||
if (!prev.skillImprovement.suggestion) return prev
|
||||
return {
|
||||
...prev,
|
||||
skillImprovement: { suggestion: null },
|
||||
}
|
||||
})
|
||||
},
|
||||
[setAppState, setMessages],
|
||||
)
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
suggestion: lastSuggestionRef.current,
|
||||
handleSelect,
|
||||
}
|
||||
}
|
||||
62
claude-code源码-中文注释/src/hooks/useSkillsChange.ts
Normal file
62
claude-code源码-中文注释/src/hooks/useSkillsChange.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import type { Command } from '../commands.js'
|
||||
import {
|
||||
clearCommandMemoizationCaches,
|
||||
clearCommandsCache,
|
||||
getCommands,
|
||||
} from '../commands.js'
|
||||
import { onGrowthBookRefresh } from '../services/analytics/growthbook.js'
|
||||
import { logError } from '../utils/log.js'
|
||||
import { skillChangeDetector } from '../utils/skills/skillChangeDetector.js'
|
||||
|
||||
/**
|
||||
* 通过两个触发器保持命令列表新鲜:
|
||||
*
|
||||
* 1. Skill 文件更改(监视器)— 完全缓存清除 + 磁盘重新扫描,
|
||||
* 因为 skill 内容在磁盘上更改了。
|
||||
* 2. GrowthBook 初始化/刷新 — 仅 memo 清除,因为只有 isEnabled()
|
||||
* 谓词可能已更改。处理像 /btw 这样的命令,其 gate
|
||||
* 读取在磁盘缓存中尚不存在的标志,在标志
|
||||
* 重命名后的第一个会话:getCommands() 在 GB 初始化之前运行
|
||||
*(main.tsx:2855 vs :3106 的 showSetupScreens),所以 memoized 列表
|
||||
* 与默认值一起烘焙。一旦初始化填充了 remoteEvalFeatureValues,重新过滤。
|
||||
*/
|
||||
export function useSkillsChange(
|
||||
cwd: string | undefined,
|
||||
onCommandsChange: (commands: Command[]) => void,
|
||||
): void {
|
||||
const handleChange = useCallback(async () => {
|
||||
if (!cwd) return
|
||||
try {
|
||||
// 清除所有命令缓存以确保重新加载
|
||||
clearCommandsCache()
|
||||
const commands = await getCommands(cwd)
|
||||
onCommandsChange(commands)
|
||||
} catch (error) {
|
||||
// 重新加载期间的错误是非致命的 - 记录并继续
|
||||
if (error instanceof Error) {
|
||||
logError(error)
|
||||
}
|
||||
}
|
||||
}, [cwd, onCommandsChange])
|
||||
|
||||
useEffect(() => skillChangeDetector.subscribe(handleChange), [handleChange])
|
||||
|
||||
const handleGrowthBookRefresh = useCallback(async () => {
|
||||
if (!cwd) return
|
||||
try {
|
||||
clearCommandMemoizationCaches()
|
||||
const commands = await getCommands(cwd)
|
||||
onCommandsChange(commands)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logError(error)
|
||||
}
|
||||
}
|
||||
}, [cwd, onCommandsChange])
|
||||
|
||||
useEffect(
|
||||
() => onGrowthBookRefresh(handleGrowthBookRefresh),
|
||||
[handleGrowthBookRefresh],
|
||||
)
|
||||
}
|
||||
81
claude-code源码-中文注释/src/hooks/useSwarmInitialization.ts
Normal file
81
claude-code源码-中文注释/src/hooks/useSwarmInitialization.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Swarm 初始化 Hook
|
||||
*
|
||||
* 初始化 swarm 功能:队友 hooks 和上下文。
|
||||
* 处理新的 spawn 和恢复的队友会话。
|
||||
*
|
||||
* 此 hook 是条件加载的,以允许在 swarm 禁用时进行死代码消除。
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { getSessionId } from '../bootstrap/state.js'
|
||||
import type { AppState } from '../state/AppState.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'
|
||||
import { initializeTeammateContextFromSession } from '../utils/swarm/reconnection.js'
|
||||
import { readTeamFile } from '../utils/swarm/teamHelpers.js'
|
||||
import { initializeTeammateHooks } from '../utils/swarm/teammateInit.js'
|
||||
import { getDynamicTeamContext } from '../utils/teammate.js'
|
||||
|
||||
type SetAppState = (f: (prevState: AppState) => AppState) => void
|
||||
|
||||
/**
|
||||
* 当 ENABLE_AGENT_SWARMS 为 true 时初始化 swarm 功能的 Hook。
|
||||
*
|
||||
* 处理以下两种情况:
|
||||
* - 从 --resume 或 /resume 恢复的队友会话,其中 teamName/agentName
|
||||
* 存储在转录消息中
|
||||
* - 从环境变量读取上下文的新 spawn
|
||||
*/
|
||||
export function useSwarmInitialization(
|
||||
setAppState: SetAppState,
|
||||
initialMessages: Message[] | undefined,
|
||||
{ enabled = true }: { enabled?: boolean } = {},
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
if (isAgentSwarmsEnabled()) {
|
||||
// 检查这是否是恢复的代理会话(从 --resume 或 /resume)
|
||||
// 恢复的会话有存储在转录消息中的 teamName/agentName
|
||||
const firstMessage = initialMessages?.[0]
|
||||
const teamName =
|
||||
firstMessage && 'teamName' in firstMessage
|
||||
? (firstMessage.teamName as string | undefined)
|
||||
: undefined
|
||||
const agentName =
|
||||
firstMessage && 'agentName' in firstMessage
|
||||
? (firstMessage.agentName as string | undefined)
|
||||
: undefined
|
||||
|
||||
if (teamName && agentName) {
|
||||
// 恢复的代理会话 - 从存储的信息设置团队上下文
|
||||
initializeTeammateContextFromSession(setAppState, teamName, agentName)
|
||||
|
||||
// 从团队文件获取 agentId 用于 hook 初始化
|
||||
const teamFile = readTeamFile(teamName)
|
||||
const member = teamFile?.members.find(
|
||||
(m: { name: string }) => m.name === agentName,
|
||||
)
|
||||
if (member) {
|
||||
initializeTeammateHooks(setAppState, getSessionId(), {
|
||||
teamName,
|
||||
agentId: member.agentId,
|
||||
agentName,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 新的 spawn 或独立会话
|
||||
// teamContext 已经在 main.tsx 中通过 computeInitialTeamContext() 计算
|
||||
// 并包含在 initialState 中,所以我们只需要在这里初始化 hooks
|
||||
const context = getDynamicTeamContext?.()
|
||||
if (context?.teamName && context?.agentId && context?.agentName) {
|
||||
initializeTeammateHooks(setAppState, getSessionId(), {
|
||||
teamName: context.teamName,
|
||||
agentId: context.agentId,
|
||||
agentName: context.agentName,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [setAppState, initialMessages, enabled])
|
||||
}
|
||||
329
claude-code源码-中文注释/src/hooks/useSwarmPermissionPoller.ts
Normal file
329
claude-code源码-中文注释/src/hooks/useSwarmPermissionPoller.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Swarm 权限轮询 Hook
|
||||
*
|
||||
* 当作为 swarm 中的工作代理运行时,此 hook 轮询来自团队领导的权限响应。
|
||||
* 当收到响应时,它调用适当的回调(onAllow/onReject)以继续执行。
|
||||
*
|
||||
* 此 hook 应与 useCanUseTool.ts 中的工作端集成结合使用,
|
||||
* 它创建此 hook 监视的待处理请求。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useInterval } from 'usehooks-ts'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import {
|
||||
type PermissionUpdate,
|
||||
permissionUpdateSchema,
|
||||
} from '../utils/permissions/PermissionUpdateSchema.js'
|
||||
import {
|
||||
isSwarmWorker,
|
||||
type PermissionResponse,
|
||||
pollForResponse,
|
||||
removeWorkerResponse,
|
||||
} from '../utils/swarm/permissionSync.js'
|
||||
import { getAgentName, getTeamName } from '../utils/teammate.js'
|
||||
|
||||
const POLL_INTERVAL_MS = 500
|
||||
|
||||
/**
|
||||
* 验证外部来源的 permissionUpdates(邮箱 IPC、磁盘轮询)。
|
||||
* 来自有 bug/旧队友进程的格式错误条目被过滤掉,
|
||||
* 而不是未检查地传播到 callback.onAllow()。
|
||||
*/
|
||||
function parsePermissionUpdates(raw: unknown): PermissionUpdate[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return []
|
||||
}
|
||||
const schema = permissionUpdateSchema()
|
||||
const valid: PermissionUpdate[] = []
|
||||
for (const entry of raw) {
|
||||
const result = schema.safeParse(entry)
|
||||
if (result.success) {
|
||||
valid.push(result.data)
|
||||
} else {
|
||||
logForDebugging(
|
||||
`[SwarmPermissionPoller] Dropping malformed permissionUpdate entry: ${result.error.message}`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理权限响应的回调签名
|
||||
*/
|
||||
export type PermissionResponseCallback = {
|
||||
requestId: string
|
||||
toolUseId: string
|
||||
onAllow: (
|
||||
updatedInput: Record<string, unknown> | undefined,
|
||||
permissionUpdates: PermissionUpdate[],
|
||||
feedback?: string,
|
||||
) => void
|
||||
onReject: (feedback?: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 待处理权限请求回调的注册表
|
||||
* 这允许轮询器在响应到达时找到并调用正确的回调
|
||||
*/
|
||||
type PendingCallbackRegistry = Map<string, PermissionResponseCallback>
|
||||
|
||||
// 模块级注册表,在渲染之间持久化
|
||||
const pendingCallbacks: PendingCallbackRegistry = new Map()
|
||||
|
||||
/**
|
||||
* 为待处理权限请求注册回调
|
||||
* 当工作代理提交权限请求时由 useCanUseTool 调用
|
||||
*/
|
||||
export function registerPermissionCallback(
|
||||
callback: PermissionResponseCallback,
|
||||
): void {
|
||||
pendingCallbacks.set(callback.requestId, callback)
|
||||
logForDebugging(
|
||||
`[SwarmPermissionPoller] Registered callback for request ${callback.requestId}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消注册回调(例如,当请求在本地解决或超时时)
|
||||
*/
|
||||
export function unregisterPermissionCallback(requestId: string): void {
|
||||
pendingCallbacks.delete(requestId)
|
||||
logForDebugging(
|
||||
`[SwarmPermissionPoller] Unregistered callback for request ${requestId}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查请求是否有已注册的回调
|
||||
*/
|
||||
export function hasPermissionCallback(requestId: string): boolean {
|
||||
return pendingCallbacks.has(requestId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有待处理回调(权限和沙箱)。
|
||||
* 从 /clear 上的 clearSessionCaches() 调用以重置陈旧状态,
|
||||
* 也用于测试隔离。
|
||||
*/
|
||||
export function clearAllPendingCallbacks(): void {
|
||||
pendingCallbacks.clear()
|
||||
pendingSandboxCallbacks.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理来自邮箱消息的权限响应。
|
||||
* 当检测到 permission_response 消息时由邮箱轮询器调用。
|
||||
*
|
||||
* @returns 如果响应被处理则返回 true,如果没有回调被注册则返回 false
|
||||
*/
|
||||
export function processMailboxPermissionResponse(params: {
|
||||
requestId: string
|
||||
decision: 'approved' | 'rejected'
|
||||
feedback?: string
|
||||
updatedInput?: Record<string, unknown>
|
||||
permissionUpdates?: unknown
|
||||
}): boolean {
|
||||
const callback = pendingCallbacks.get(params.requestId)
|
||||
|
||||
if (!callback) {
|
||||
logForDebugging(
|
||||
`[SwarmPermissionPoller] No callback registered for mailbox response ${params.requestId}`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[SwarmPermissionPoller] Processing mailbox response for request ${params.requestId}: ${params.decision}`,
|
||||
)
|
||||
|
||||
// 在调用回调之前从注册表删除
|
||||
pendingCallbacks.delete(params.requestId)
|
||||
|
||||
if (params.decision === 'approved') {
|
||||
const permissionUpdates = parsePermissionUpdates(params.permissionUpdates)
|
||||
const updatedInput = params.updatedInput
|
||||
callback.onAllow(updatedInput, permissionUpdates)
|
||||
} else {
|
||||
callback.onReject(params.feedback)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 沙箱权限回调注册表
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 处理沙箱权限响应的回调签名
|
||||
*/
|
||||
export type SandboxPermissionResponseCallback = {
|
||||
requestId: string
|
||||
host: string
|
||||
resolve: (allow: boolean) => void
|
||||
}
|
||||
|
||||
// 沙箱权限回调的模块级注册表
|
||||
const pendingSandboxCallbacks: Map<string, SandboxPermissionResponseCallback> =
|
||||
new Map()
|
||||
|
||||
/**
|
||||
* 为待处理沙箱权限请求注册回调
|
||||
* 当工作代理向领导发送沙箱权限请求时调用
|
||||
*/
|
||||
export function registerSandboxPermissionCallback(
|
||||
callback: SandboxPermissionResponseCallback,
|
||||
): void {
|
||||
pendingSandboxCallbacks.set(callback.requestId, callback)
|
||||
logForDebugging(
|
||||
`[SwarmPermissionPoller] Registered sandbox callback for request ${callback.requestId}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查沙箱请求是否有已注册的回调
|
||||
*/
|
||||
export function hasSandboxPermissionCallback(requestId: string): boolean {
|
||||
return pendingSandboxCallbacks.has(requestId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理来自邮箱消息的沙箱权限响应。
|
||||
* 当检测到 sandbox_permission_response 消息时由邮箱轮询器调用。
|
||||
*
|
||||
* @returns 如果响应被处理则返回 true,如果没有回调被注册则返回 false
|
||||
*/
|
||||
export function processSandboxPermissionResponse(params: {
|
||||
requestId: string
|
||||
host: string
|
||||
allow: boolean
|
||||
}): boolean {
|
||||
const callback = pendingSandboxCallbacks.get(params.requestId)
|
||||
|
||||
if (!callback) {
|
||||
logForDebugging(
|
||||
`[SwarmPermissionPoller] No sandbox callback registered for request ${params.requestId}`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[SwarmPermissionPoller] Processing sandbox response for request ${params.requestId}: allow=${params.allow}`,
|
||||
)
|
||||
|
||||
// 在调用回调之前从注册表删除
|
||||
pendingSandboxCallbacks.delete(params.requestId)
|
||||
|
||||
// 用 allow 决策解决 promise
|
||||
callback.resolve(params.allow)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过调用已注册的回调来处理权限响应
|
||||
*/
|
||||
function processResponse(response: PermissionResponse): boolean {
|
||||
const callback = pendingCallbacks.get(response.requestId)
|
||||
|
||||
if (!callback) {
|
||||
logForDebugging(
|
||||
`[SwarmPermissionPoller] No callback registered for request ${response.requestId}`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[SwarmPermissionPoller] Processing response for request ${response.requestId}: ${response.decision}`,
|
||||
)
|
||||
|
||||
// 在调用回调之前从注册表删除
|
||||
pendingCallbacks.delete(response.requestId)
|
||||
|
||||
if (response.decision === 'approved') {
|
||||
const permissionUpdates = parsePermissionUpdates(response.permissionUpdates)
|
||||
const updatedInput = response.updatedInput
|
||||
callback.onAllow(updatedInput, permissionUpdates)
|
||||
} else {
|
||||
callback.onReject(response.feedback)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 在作为 swarm 工作运行时轮询权限响应的 Hook。
|
||||
*
|
||||
* 此 hook:
|
||||
* 1. 仅在 isSwarmWorker() 返回 true 时激活
|
||||
* 2. 每 500ms 轮询响应
|
||||
* 3. 当找到响应时,调用已注册的回调
|
||||
* 4. 处理后清理响应文件
|
||||
*/
|
||||
export function useSwarmPermissionPoller(): void {
|
||||
const isProcessingRef = useRef(false)
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
// 如果不是 swarm 工作代理,则不轮询
|
||||
if (!isSwarmWorker()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 防止并发轮询
|
||||
if (isProcessingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果没有注册的回调,则不轮询
|
||||
if (pendingCallbacks.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
isProcessingRef.current = true
|
||||
|
||||
try {
|
||||
const agentName = getAgentName()
|
||||
const teamName = getTeamName()
|
||||
|
||||
if (!agentName || !teamName) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查每个待处理请求的响应
|
||||
for (const [requestId, _callback] of pendingCallbacks) {
|
||||
const response = await pollForResponse(requestId, agentName, teamName)
|
||||
|
||||
if (response) {
|
||||
// 处理响应
|
||||
const processed = processResponse(response)
|
||||
|
||||
if (processed) {
|
||||
// 从工作代理的收件箱清理响应
|
||||
await removeWorkerResponse(requestId, agentName, teamName)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`[SwarmPermissionPoller] Error during poll: ${errorMessage(error)}`,
|
||||
)
|
||||
} finally {
|
||||
isProcessingRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 仅在我们是 swarm 工作代理时轮询
|
||||
const shouldPoll = isSwarmWorker()
|
||||
useInterval(() => void poll(), shouldPoll ? POLL_INTERVAL_MS : null)
|
||||
|
||||
// 挂载时初始轮询
|
||||
useEffect(() => {
|
||||
if (isSwarmWorker()) {
|
||||
void poll()
|
||||
}
|
||||
}, [poll])
|
||||
}
|
||||
220
claude-code源码-中文注释/src/hooks/useTaskListWatcher.ts
Normal file
220
claude-code源码-中文注释/src/hooks/useTaskListWatcher.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { type FSWatcher, watch } from 'fs'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import {
|
||||
claimTask,
|
||||
DEFAULT_TASKS_MODE_TASK_LIST_ID,
|
||||
ensureTasksDir,
|
||||
getTasksDir,
|
||||
listTasks,
|
||||
type Task,
|
||||
updateTask,
|
||||
} from '../utils/tasks.js'
|
||||
|
||||
const DEBOUNCE_MS = 1000
|
||||
|
||||
type Props = {
|
||||
/** 当 undefined 时,hook 不执行任何操作。任务列表 ID 也用作代理 ID。 */
|
||||
taskListId?: string
|
||||
isLoading: boolean
|
||||
/**
|
||||
* 当任务准备好被处理时调用。
|
||||
* 如果提交成功返回 true,如果被拒绝返回 false。
|
||||
*/
|
||||
onSubmitTask: (prompt: string) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 监视任务列表目录并自动拾取
|
||||
* 打开的、无主人的任务来处理的 Hook。
|
||||
*
|
||||
* 这启用了"任务模式",其中 Claude 监视外部创建的
|
||||
* 任务并一次处理一个。
|
||||
*/
|
||||
export function useTaskListWatcher({
|
||||
taskListId,
|
||||
isLoading,
|
||||
onSubmitTask,
|
||||
}: Props): void {
|
||||
const currentTaskRef = useRef<string | null>(null)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// 通过 refs 稳定不稳定的 props,以便监视器效果不依赖于
|
||||
// 它们。isLoading 每个 turn 翻转,onSubmitTask 的身份在
|
||||
// onQuery 的依赖更改时更改。没有这个,监视器效果会重新运行
|
||||
// 每次 turn,调用 watcher.close() + watch() — 这是
|
||||
// Bun 的 PathWatcherManager 死锁的触发器(oven-sh/bun#27469)。
|
||||
const isLoadingRef = useRef(isLoading)
|
||||
isLoadingRef.current = isLoading
|
||||
const onSubmitTaskRef = useRef(onSubmitTask)
|
||||
onSubmitTaskRef.current = onSubmitTask
|
||||
|
||||
const enabled = taskListId !== undefined
|
||||
const agentId = taskListId ?? DEFAULT_TASKS_MODE_TASK_LIST_ID
|
||||
|
||||
// checkForTasks 从 refs 读取 isLoading 和 onSubmitTask —
|
||||
// 始终最新,无 stale 闭包,并且不会为每次渲染强制新的函数身份。
|
||||
// 存储在 ref 中以便监视器效果可以调用它而不依赖于它。
|
||||
const checkForTasksRef = useRef<() => Promise<void>>(async () => {})
|
||||
checkForTasksRef.current = async () => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已经在工作则不需要提交新任务
|
||||
if (isLoadingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const tasks = await listTasks(taskListId)
|
||||
|
||||
// 如果有当前任务,检查它是否已解决
|
||||
if (currentTaskRef.current !== null) {
|
||||
const currentTask = tasks.find(t => t.id === currentTaskRef.current)
|
||||
if (!currentTask || currentTask.status === 'completed') {
|
||||
logForDebugging(
|
||||
`[TaskListWatcher] Task #${currentTaskRef.current} is marked complete, ready for next task`,
|
||||
)
|
||||
currentTaskRef.current = null
|
||||
} else {
|
||||
// 仍在处理当前任务
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 找一个没有所有者的打开任务且未被阻止
|
||||
const availableTask = findAvailableTask(tasks)
|
||||
|
||||
if (!availableTask) {
|
||||
return
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[TaskListWatcher] Found available task #${availableTask.id}: ${availableTask.subject}`,
|
||||
)
|
||||
|
||||
// 使用任务列表的代理 ID 认领任务
|
||||
const result = await claimTask(taskListId, availableTask.id, agentId)
|
||||
|
||||
if (!result.success) {
|
||||
logForDebugging(
|
||||
`[TaskListWatcher] Failed to claim task #${availableTask.id}: ${result.reason}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
currentTaskRef.current = availableTask.id
|
||||
|
||||
// 将任务格式化为 prompt
|
||||
const prompt = formatTaskAsPrompt(availableTask)
|
||||
|
||||
logForDebugging(
|
||||
`[TaskListWatcher] Submitting task #${availableTask.id} as prompt`,
|
||||
)
|
||||
|
||||
const submitted = onSubmitTaskRef.current(prompt)
|
||||
if (!submitted) {
|
||||
logForDebugging(
|
||||
`[TaskListWatcher] Failed to submit task #${availableTask.id}, releasing claim`,
|
||||
)
|
||||
// 释放认领
|
||||
await updateTask(taskListId, availableTask.id, { owner: undefined })
|
||||
currentTaskRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// -- 监视器设置
|
||||
|
||||
// 在 DEBOUNCE_MS 后调度检查,折叠快速 fs 事件。
|
||||
// 在监视器回调和下面的空闲触发效果之间共享。
|
||||
const scheduleCheckRef = useRef<() => void>(() => {})
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
void ensureTasksDir(taskListId)
|
||||
const tasksDir = getTasksDir(taskListId)
|
||||
|
||||
let watcher: FSWatcher | null = null
|
||||
|
||||
const debouncedCheck = (): void => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
debounceTimerRef.current = setTimeout(
|
||||
ref => void ref.current(),
|
||||
DEBOUNCE_MS,
|
||||
checkForTasksRef,
|
||||
)
|
||||
}
|
||||
scheduleCheckRef.current = debouncedCheck
|
||||
|
||||
try {
|
||||
watcher = watch(tasksDir, debouncedCheck)
|
||||
watcher.unref()
|
||||
logForDebugging(`[TaskListWatcher] Watching for tasks in ${tasksDir}`)
|
||||
} catch (error) {
|
||||
// fs.watch 在 ENOENT 时同步抛出 — ensureTasksDir 应该已经
|
||||
// 创建了目录,但优雅地处理竞态
|
||||
logForDebugging(`[TaskListWatcher] Failed to watch ${tasksDir}: ${error}`)
|
||||
}
|
||||
|
||||
// 初始检查
|
||||
debouncedCheck()
|
||||
|
||||
return () => {
|
||||
// 此清理仅在 taskListId 更改或卸载时触发 —
|
||||
// 从不每个 turn。这将 watcher.close() 保持在 Bun
|
||||
// PathWatcherManager 死锁窗口之外。
|
||||
scheduleCheckRef.current = () => {}
|
||||
if (watcher) {
|
||||
watcher.close()
|
||||
}
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [enabled, taskListId])
|
||||
|
||||
// 之前,监视器效果依赖于 checkForTasks(间接地
|
||||
// 是 isLoading),所以空闲触发重新设置,其初始 debouncedCheck
|
||||
// 会拾取下一个任务。显式保留该行为:当
|
||||
// isLoading 下降时,调度检查。
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
if (isLoading) return
|
||||
scheduleCheckRef.current()
|
||||
}, [enabled, isLoading])
|
||||
}
|
||||
|
||||
/**
|
||||
* 找一个可以处理的可用任务:
|
||||
* - 状态为 'pending'
|
||||
* - 未分配所有者
|
||||
* - 未被任何未解决任务阻止
|
||||
*/
|
||||
function findAvailableTask(tasks: Task[]): Task | undefined {
|
||||
const unresolvedTaskIds = new Set(
|
||||
tasks.filter(t => t.status !== 'completed').map(t => t.id),
|
||||
)
|
||||
|
||||
return tasks.find(task => {
|
||||
if (task.status !== 'pending') return false
|
||||
if (task.owner) return false
|
||||
// 检查所有阻止者都已完成
|
||||
return task.blockedBy.every(id => !unresolvedTaskIds.has(id))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将任务格式化为 Claude 处理的 prompt。
|
||||
*/
|
||||
function formatTaskAsPrompt(task: Task): string {
|
||||
let prompt = `Complete all open tasks. Start with task #${task.id}: \n\n ${task.subject}`
|
||||
|
||||
if (task.description) {
|
||||
prompt += `\n\n${task.description}`
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
250
claude-code源码-中文注释/src/hooks/useTasksV2.ts
Normal file
250
claude-code源码-中文注释/src/hooks/useTasksV2.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { type FSWatcher, watch } from 'fs'
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js'
|
||||
import { createSignal } from '../utils/signal.js'
|
||||
import type { Task } from '../utils/tasks.js'
|
||||
import {
|
||||
getTaskListId,
|
||||
getTasksDir,
|
||||
isTodoV2Enabled,
|
||||
listTasks,
|
||||
onTasksUpdated,
|
||||
resetTaskList,
|
||||
} from '../utils/tasks.js'
|
||||
import { isTeamLead } from '../utils/teammate.js'
|
||||
|
||||
const HIDE_DELAY_MS = 5000
|
||||
const DEBOUNCE_MS = 50
|
||||
const FALLBACK_POLL_MS = 5000 // 以防 fs.watch 错过事件的回退
|
||||
|
||||
/**
|
||||
* TodoV2 任务列表的单例存储。拥有文件监视器、计时器和
|
||||
* 缓存任务列表。多个 hook 实例(REPL、Spinner、
|
||||
* PromptInputFooterLeftSide)订阅一个共享存储,而不是每个
|
||||
* 都设置自己的 fs.watch 监视同一目录。Spinner 每 turn
|
||||
* 挂载/卸载 — 每个 hook 的监视器导致持续的监视/取消监视变动。
|
||||
*
|
||||
* 实现 useSyncExternalStore 契约:subscribe/getSnapshot。
|
||||
*/
|
||||
class TasksV2Store {
|
||||
/** 稳定数组引用;仅在获取时替换。启动前为 undefined。 */
|
||||
#tasks: Task[] | undefined = undefined
|
||||
/**
|
||||
* 当隐藏计时器已过(所有任务完成 >5s)或
|
||||
* 任务列表为空时设置。开始为 false 以便第一次获取运行
|
||||
* "全部完成 → 调度 5s 隐藏"路径(匹配原始行为:
|
||||
* 恢复带有已完成任务的会话时会短暂显示它们)。
|
||||
*/
|
||||
#hidden = false
|
||||
#watcher: FSWatcher | null = null
|
||||
#watchedDir: string | null = null
|
||||
#hideTimer: ReturnType<typeof setTimeout> | null = null
|
||||
#debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
#pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
#unsubscribeTasksUpdated: (() => void) | null = null
|
||||
#changed = createSignal()
|
||||
#subscriberCount = 0
|
||||
#started = false
|
||||
|
||||
/**
|
||||
* useSyncExternalStore 快照。在更新之间返回相同的 Task[] 引用
|
||||
*(Object.is 稳定性需要)。隐藏时返回 undefined。
|
||||
*/
|
||||
getSnapshot = (): Task[] | undefined => {
|
||||
return this.#hidden ? undefined : this.#tasks
|
||||
}
|
||||
|
||||
subscribe = (fn: () => void): (() => void) => {
|
||||
// 延迟初始化:第一个订阅者时。useSyncExternalStore 在
|
||||
// 提交后调用它,所以这里的 I/O 是安全的(无渲染阶段副作用)。
|
||||
// REPL.tsx 在整个会话期间保持订阅活跃,因此
|
||||
// Spinner 挂载/卸载变动永远不会将计数驱动为零。
|
||||
const unsubscribe = this.#changed.subscribe(fn)
|
||||
this.#subscriberCount++
|
||||
if (!this.#started) {
|
||||
this.#started = true
|
||||
this.#unsubscribeTasksUpdated = onTasksUpdated(this.#debouncedFetch)
|
||||
// 即发即弃:subscribe 在提交后调用(不是在渲染中),
|
||||
// 存储在获取解析时通知订阅者。
|
||||
void this.#fetch()
|
||||
}
|
||||
let unsubscribed = false
|
||||
return () => {
|
||||
if (unsubscribed) return
|
||||
unsubscribed = true
|
||||
unsubscribe()
|
||||
this.#subscriberCount--
|
||||
if (this.#subscriberCount === 0) this.#stop()
|
||||
}
|
||||
}
|
||||
|
||||
#notify(): void {
|
||||
this.#changed.emit()
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件监视器指向当前任务目录。在启动时调用
|
||||
* 以及每当 #fetch 检测到任务列表 ID 已更改时
|
||||
*(例如当 TeamCreateTool 在会话中设置 leaderTeamName 时)。
|
||||
*/
|
||||
#rewatch(dir: string): void {
|
||||
// 即使在同一目录也重试(如果之前的监视尝试失败)。
|
||||
// 一旦监视器建立,同目录是无操作的。
|
||||
if (dir === this.#watchedDir && this.#watcher !== null) return
|
||||
this.#watcher?.close()
|
||||
this.#watcher = null
|
||||
this.#watchedDir = dir
|
||||
try {
|
||||
this.#watcher = watch(dir, this.#debouncedFetch)
|
||||
this.#watcher.unref()
|
||||
} catch {
|
||||
// 目录可能尚不存在(ensureTasksDir 由写入器调用)。
|
||||
// 不关键 — onTasksUpdated 覆盖进程内更新,
|
||||
// poll 计时器覆盖跨进程更新。
|
||||
}
|
||||
}
|
||||
|
||||
#debouncedFetch = (): void => {
|
||||
if (this.#debounceTimer) clearTimeout(this.#debounceTimer)
|
||||
this.#debounceTimer = setTimeout(() => void this.#fetch(), DEBOUNCE_MS)
|
||||
this.#debounceTimer.unref()
|
||||
}
|
||||
|
||||
#fetch = async (): Promise<void> => {
|
||||
const taskListId = getTaskListId()
|
||||
// 任务列表 ID 可能在会话中更改(TeamCreateTool 设置
|
||||
// leaderTeamName)— 将监视器指向当前目录。
|
||||
this.#rewatch(getTasksDir(taskListId))
|
||||
const current = (await listTasks(taskListId)).filter(
|
||||
t => !t.metadata?._internal,
|
||||
)
|
||||
this.#tasks = current
|
||||
|
||||
const hasIncomplete = current.some(t => t.status !== 'completed')
|
||||
|
||||
if (hasIncomplete || current.length === 0) {
|
||||
// 有未完成任务(open/in_progress)或为空 — 重置隐藏状态
|
||||
this.#hidden = current.length === 0
|
||||
this.#clearHideTimer()
|
||||
} else if (this.#hideTimer === null && !this.#hidden) {
|
||||
// 所有任务刚刚完成 — 调度清除
|
||||
this.#hideTimer = setTimeout(
|
||||
this.#onHideTimerFired.bind(this, taskListId),
|
||||
HIDE_DELAY_MS,
|
||||
)
|
||||
this.#hideTimer.unref()
|
||||
}
|
||||
|
||||
this.#notify()
|
||||
|
||||
// 仅在有需要监控的未完成任务时调度回退 poll。
|
||||
// 当所有任务都完成时(或没有任务),
|
||||
// fs.watch 监视器和 onTasksUpdated 回调足以
|
||||
// 检测新活动 — 不需要继续 poll 和重新渲染。
|
||||
if (this.#pollTimer) {
|
||||
clearTimeout(this.#pollTimer)
|
||||
this.#pollTimer = null
|
||||
}
|
||||
if (hasIncomplete) {
|
||||
this.#pollTimer = setTimeout(this.#debouncedFetch, FALLBACK_POLL_MS)
|
||||
this.#pollTimer.unref()
|
||||
}
|
||||
}
|
||||
|
||||
#onHideTimerFired(scheduledForTaskListId: string): void {
|
||||
this.#hideTimer = null
|
||||
// 如果任务列表 ID 在调度后更改则退出(团队在
|
||||
// 5s 窗口期间创建/删除)— 不要重置错误的列表。
|
||||
const currentId = getTaskListId()
|
||||
if (currentId !== scheduledForTaskListId) return
|
||||
// 验证所有任务在清除前仍然完成
|
||||
void listTasks(currentId).then(async tasksToCheck => {
|
||||
const allStillCompleted =
|
||||
tasksToCheck.length > 0 &&
|
||||
tasksToCheck.every(t => t.status === 'completed')
|
||||
if (allStillCompleted) {
|
||||
await resetTaskList(currentId)
|
||||
this.#tasks = []
|
||||
this.#hidden = true
|
||||
}
|
||||
this.#notify()
|
||||
})
|
||||
}
|
||||
|
||||
#clearHideTimer(): void {
|
||||
if (this.#hideTimer) {
|
||||
clearTimeout(this.#hideTimer)
|
||||
this.#hideTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拆除监视器、计时器和进程内订阅。当
|
||||
* 最后一个订阅者取消订阅时调用。保留 #tasks/#hidden 缓存,
|
||||
* 以便后续重新订阅立即呈现最后已知状态。
|
||||
*/
|
||||
#stop(): void {
|
||||
this.#watcher?.close()
|
||||
this.#watcher = null
|
||||
this.#watchedDir = null
|
||||
this.#unsubscribeTasksUpdated?.()
|
||||
this.#unsubscribeTasksUpdated = null
|
||||
this.#clearHideTimer()
|
||||
if (this.#debounceTimer) clearTimeout(this.#debounceTimer)
|
||||
if (this.#pollTimer) clearTimeout(this.#pollTimer)
|
||||
this.#debounceTimer = null
|
||||
this.#pollTimer = null
|
||||
this.#started = false
|
||||
}
|
||||
}
|
||||
|
||||
let _store: TasksV2Store | null = null
|
||||
function getStore(): TasksV2Store {
|
||||
return (_store ??= new TasksV2Store())
|
||||
}
|
||||
|
||||
// 禁用路径的稳定空操作,以便 useSyncExternalStore 不会
|
||||
// 在每次渲染时变动其订阅。
|
||||
const NOOP = (): void => {}
|
||||
const NOOP_SUBSCRIBE = (): (() => void) => NOOP
|
||||
const NOOP_SNAPSHOT = (): undefined => undefined
|
||||
|
||||
/**
|
||||
* 用于获取持久 UI 显示的当前任务列表的 Hook。
|
||||
* 当 TodoV2 启用时返回任务,否则返回 undefined。
|
||||
* 所有 hook 实例通过 TasksV2Store 共享单个文件监视器。
|
||||
* 如果没有打开的任务,5 秒后隐藏列表。
|
||||
*/
|
||||
export function useTasksV2(): Task[] | undefined {
|
||||
const teamContext = useAppState(s => s.teamContext)
|
||||
|
||||
const enabled = isTodoV2Enabled() && (!teamContext || isTeamLead(teamContext))
|
||||
|
||||
const store = enabled ? getStore() : null
|
||||
|
||||
return useSyncExternalStore(
|
||||
store ? store.subscribe : NOOP_SUBSCRIBE,
|
||||
store ? store.getSnapshot : NOOP_SNAPSHOT,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 useTasksV2 相同,外加在列表隐藏时折叠展开的任务视图。
|
||||
* 仅从一个始终挂载的组件(REPL)调用,
|
||||
* 以便折叠效果运行一次而不是每个消费者 N 次。
|
||||
*/
|
||||
export function useTasksV2WithCollapseEffect(): Task[] | undefined {
|
||||
const tasks = useTasksV2()
|
||||
const setAppState = useSetAppState()
|
||||
|
||||
const hidden = tasks === undefined
|
||||
useEffect(() => {
|
||||
if (!hidden) return
|
||||
setAppState(prev => {
|
||||
if (prev.expandedView !== 'tasks') return prev
|
||||
return { ...prev, expandedView: 'none' as const }
|
||||
})
|
||||
}, [hidden, setAppState])
|
||||
|
||||
return tasks
|
||||
}
|
||||
62
claude-code源码-中文注释/src/hooks/useTeammateViewAutoExit.ts
Normal file
62
claude-code源码-中文注释/src/hooks/useTeammateViewAutoExit.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js'
|
||||
import { exitTeammateView } from '../state/teammateViewHelpers.js'
|
||||
import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js'
|
||||
|
||||
/**
|
||||
* 当查看的队友被终止或遇到错误时,自动退出队友查看模式。
|
||||
* 用户保持查看已完成的队友,以便他们可以查看完整转录本。
|
||||
*/
|
||||
export function useTeammateViewAutoExit(): void {
|
||||
const setAppState = useSetAppState()
|
||||
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
|
||||
// 仅选择查看的任务,而不是完整任务映射 — 否则
|
||||
// 任何队友的任何流更新都会重新渲染此 hook。
|
||||
const task = useAppState(s =>
|
||||
s.viewingAgentTaskId ? s.tasks[s.viewingAgentTaskId] : undefined,
|
||||
)
|
||||
|
||||
const viewedTask = task && isInProcessTeammateTask(task) ? task : undefined
|
||||
const viewedStatus = viewedTask?.status
|
||||
const viewedError = viewedTask?.error
|
||||
const taskExists = task !== undefined
|
||||
|
||||
useEffect(() => {
|
||||
// 未在查看任何队友
|
||||
if (!viewingAgentTaskId) {
|
||||
return
|
||||
}
|
||||
|
||||
// 任务在映射中不再存在 — 从我们下面驱逐。
|
||||
// 检查原始 `task` 而不是 teammate 缩小的 `viewedTask`;local_agent
|
||||
// 任务存在但缩小为 undefined,这会立即弹出。
|
||||
if (!taskExists) {
|
||||
exitTeammateView(setAppState)
|
||||
return
|
||||
}
|
||||
// 以下状态检查仅适用于队友(viewedTask 是 teammate 缩小的)。
|
||||
// 对于 local_agent,viewedStatus 是 undefined → 所有检查都为假 → 不弹出。
|
||||
if (!viewedTask) return
|
||||
|
||||
// 如果队友被终止、停止、有错误或不再运行,则自动退出
|
||||
// 这处理队友变为非活动的关闭场景
|
||||
if (
|
||||
viewedStatus === 'killed' ||
|
||||
viewedStatus === 'failed' ||
|
||||
viewedError ||
|
||||
(viewedStatus !== 'running' &&
|
||||
viewedStatus !== 'completed' &&
|
||||
viewedStatus !== 'pending')
|
||||
) {
|
||||
exitTeammateView(setAppState)
|
||||
return
|
||||
}
|
||||
}, [
|
||||
viewingAgentTaskId,
|
||||
taskExists,
|
||||
viewedTask,
|
||||
viewedStatus,
|
||||
viewedError,
|
||||
setAppState,
|
||||
])
|
||||
}
|
||||
15
claude-code源码-中文注释/src/hooks/useTerminalSize.ts
Normal file
15
claude-code源码-中文注释/src/hooks/useTerminalSize.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useContext } from 'react'
|
||||
import {
|
||||
type TerminalSize,
|
||||
TerminalSizeContext,
|
||||
} from 'src/ink/components/TerminalSizeContext.js'
|
||||
|
||||
export function useTerminalSize(): TerminalSize {
|
||||
const size = useContext(TerminalSizeContext)
|
||||
|
||||
if (!size) {
|
||||
throw new Error('useTerminalSize must be used within an Ink App component')
|
||||
}
|
||||
|
||||
return size
|
||||
}
|
||||
527
claude-code源码-中文注释/src/hooks/useTextInput.ts
Normal file
527
claude-code源码-中文注释/src/hooks/useTextInput.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
import { isInputModeCharacter } from 'src/components/PromptInput/inputModes.js'
|
||||
import { useNotifications } from 'src/context/notifications.js'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
import { markBackslashReturnUsed } from '../commands/terminalSetup/terminalSetup.js'
|
||||
import { addToHistory } from '../history.js'
|
||||
import type { Key } from '../ink.js'
|
||||
import type {
|
||||
InlineGhostText,
|
||||
TextInputState,
|
||||
} from '../types/textInputTypes.js'
|
||||
import {
|
||||
Cursor,
|
||||
getLastKill,
|
||||
pushToKillRing,
|
||||
recordYank,
|
||||
resetKillAccumulation,
|
||||
resetYankState,
|
||||
updateYankLength,
|
||||
yankPop,
|
||||
} from '../utils/Cursor.js'
|
||||
import { env } from '../utils/env.js'
|
||||
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'
|
||||
import type { ImageDimensions } from '../utils/imageResizer.js'
|
||||
import { isModifierPressed, prewarmModifiers } from '../utils/modifiers.js'
|
||||
import { useDoublePress } from './useDoublePress.js'
|
||||
|
||||
type MaybeCursor = void | Cursor
|
||||
type InputHandler = (input: string) => MaybeCursor
|
||||
type InputMapper = (input: string) => MaybeCursor
|
||||
const NOOP_HANDLER: InputHandler = () => {}
|
||||
function mapInput(input_map: Array<[string, InputHandler]>): InputMapper {
|
||||
const map = new Map(input_map)
|
||||
return function (input: string): MaybeCursor {
|
||||
return (map.get(input) ?? NOOP_HANDLER)(input)
|
||||
}
|
||||
}
|
||||
|
||||
export type UseTextInputProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit?: (value: string) => void
|
||||
onExit?: () => void
|
||||
onExitMessage?: (show: boolean, key?: string) => void
|
||||
onHistoryUp?: () => void
|
||||
onHistoryDown?: () => void
|
||||
onHistoryReset?: () => void
|
||||
onClearInput?: () => void
|
||||
focus?: boolean
|
||||
mask?: string
|
||||
multiline?: boolean
|
||||
cursorChar: string
|
||||
highlightPastedText?: boolean
|
||||
invert: (text: string) => string
|
||||
themeText: (text: string) => string
|
||||
columns: number
|
||||
onImagePaste?: (
|
||||
base64Image: string,
|
||||
mediaType?: string,
|
||||
filename?: string,
|
||||
dimensions?: ImageDimensions,
|
||||
sourcePath?: string,
|
||||
) => void
|
||||
disableCursorMovementForUpDownKeys?: boolean
|
||||
disableEscapeDoublePress?: boolean
|
||||
maxVisibleLines?: number
|
||||
externalOffset: number
|
||||
onOffsetChange: (offset: number) => void
|
||||
inputFilter?: (input: string, key: Key) => string
|
||||
inlineGhostText?: InlineGhostText
|
||||
dim?: (text: string) => string
|
||||
}
|
||||
|
||||
export function useTextInput({
|
||||
value: originalValue,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onExit,
|
||||
onExitMessage,
|
||||
onHistoryUp,
|
||||
onHistoryDown,
|
||||
onHistoryReset,
|
||||
onClearInput,
|
||||
mask = '',
|
||||
multiline = false,
|
||||
cursorChar,
|
||||
invert,
|
||||
columns,
|
||||
onImagePaste: _onImagePaste,
|
||||
disableCursorMovementForUpDownKeys = false,
|
||||
disableEscapeDoublePress = false,
|
||||
maxVisibleLines,
|
||||
externalOffset,
|
||||
onOffsetChange,
|
||||
inputFilter,
|
||||
inlineGhostText,
|
||||
dim,
|
||||
}: UseTextInputProps): TextInputState {
|
||||
// 为 Apple Terminal 预热 modifiers 模块(有内部守卫,多次调用安全)
|
||||
if (env.terminal === 'Apple_Terminal') {
|
||||
prewarmModifiers()
|
||||
}
|
||||
|
||||
const offset = externalOffset
|
||||
const setOffset = onOffsetChange
|
||||
const cursor = Cursor.fromText(originalValue, columns, offset)
|
||||
const { addNotification, removeNotification } = useNotifications()
|
||||
|
||||
const handleCtrlC = useDoublePress(
|
||||
show => {
|
||||
onExitMessage?.(show, 'Ctrl-C')
|
||||
},
|
||||
() => onExit?.(),
|
||||
() => {
|
||||
if (originalValue) {
|
||||
onChange('')
|
||||
setOffset(0)
|
||||
onHistoryReset?.()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 注意(keybindings):此 escape 处理程序有意不迁移到键绑定系统。
|
||||
// 这是用于清除输入的文本级双按 escape,而不是操作级键绑定。
|
||||
// 双按 Esc 清除输入并保存到历史记录 - 这是文本编辑行为,
|
||||
// 而不是对话框解除,需要双按安全机制。
|
||||
const handleEscape = useDoublePress(
|
||||
(show: boolean) => {
|
||||
if (!originalValue || !show) {
|
||||
return
|
||||
}
|
||||
addNotification({
|
||||
key: 'escape-again-to-clear',
|
||||
text: 'Esc again to clear',
|
||||
priority: 'immediate',
|
||||
timeoutMs: 1000,
|
||||
})
|
||||
},
|
||||
() => {
|
||||
// 立即移除"Esc again to clear"通知
|
||||
removeNotification('escape-again-to-clear')
|
||||
onClearInput?.()
|
||||
if (originalValue) {
|
||||
// 跟踪双按 escape 使用情况以进行功能发现
|
||||
// 在清除前保存到历史记录
|
||||
if (originalValue.trim() !== '') {
|
||||
addToHistory(originalValue)
|
||||
}
|
||||
onChange('')
|
||||
setOffset(0)
|
||||
onHistoryReset?.()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const handleEmptyCtrlD = useDoublePress(
|
||||
show => {
|
||||
if (originalValue !== '') {
|
||||
return
|
||||
}
|
||||
onExitMessage?.(show, 'Ctrl-D')
|
||||
},
|
||||
() => {
|
||||
if (originalValue !== '') {
|
||||
return
|
||||
}
|
||||
onExit?.()
|
||||
},
|
||||
)
|
||||
|
||||
function handleCtrlD(): MaybeCursor {
|
||||
if (cursor.text === '') {
|
||||
// 当输入为空时,处理双按
|
||||
handleEmptyCtrlD()
|
||||
return cursor
|
||||
}
|
||||
// 当输入不为空时,像 iPython 一样向前删除
|
||||
return cursor.del()
|
||||
}
|
||||
|
||||
function killToLineEnd(): Cursor {
|
||||
const { cursor: newCursor, killed } = cursor.deleteToLineEnd()
|
||||
pushToKillRing(killed, 'append')
|
||||
return newCursor
|
||||
}
|
||||
|
||||
function killToLineStart(): Cursor {
|
||||
const { cursor: newCursor, killed } = cursor.deleteToLineStart()
|
||||
pushToKillRing(killed, 'prepend')
|
||||
return newCursor
|
||||
}
|
||||
|
||||
function killWordBefore(): Cursor {
|
||||
const { cursor: newCursor, killed } = cursor.deleteWordBefore()
|
||||
pushToKillRing(killed, 'prepend')
|
||||
return newCursor
|
||||
}
|
||||
|
||||
function yank(): Cursor {
|
||||
const text = getLastKill()
|
||||
if (text.length > 0) {
|
||||
const startOffset = cursor.offset
|
||||
const newCursor = cursor.insert(text)
|
||||
recordYank(startOffset, text.length)
|
||||
return newCursor
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
function handleYankPop(): Cursor {
|
||||
const popResult = yankPop()
|
||||
if (!popResult) {
|
||||
return cursor
|
||||
}
|
||||
const { text, start, length } = popResult
|
||||
// 用新的文本替换先前拉取的文本
|
||||
const before = cursor.text.slice(0, start)
|
||||
const after = cursor.text.slice(start + length)
|
||||
const newText = before + text + after
|
||||
const newOffset = start + text.length
|
||||
updateYankLength(text.length)
|
||||
return Cursor.fromText(newText, columns, newOffset)
|
||||
}
|
||||
|
||||
const handleCtrl = mapInput([
|
||||
['a', () => cursor.startOfLine()],
|
||||
['b', () => cursor.left()],
|
||||
['c', handleCtrlC],
|
||||
['d', handleCtrlD],
|
||||
['e', () => cursor.endOfLine()],
|
||||
['f', () => cursor.right()],
|
||||
['h', () => cursor.deleteTokenBefore() ?? cursor.backspace()],
|
||||
['k', killToLineEnd],
|
||||
['n', () => downOrHistoryDown()],
|
||||
['p', () => upOrHistoryUp()],
|
||||
['u', killToLineStart],
|
||||
['w', killWordBefore],
|
||||
['y', yank],
|
||||
])
|
||||
|
||||
const handleMeta = mapInput([
|
||||
['b', () => cursor.prevWord()],
|
||||
['f', () => cursor.nextWord()],
|
||||
['d', () => cursor.deleteWordAfter()],
|
||||
['y', handleYankPop],
|
||||
])
|
||||
|
||||
function handleEnter(key: Key) {
|
||||
if (
|
||||
multiline &&
|
||||
cursor.offset > 0 &&
|
||||
cursor.text[cursor.offset - 1] === '\\'
|
||||
) {
|
||||
// 跟踪用户使用了反斜杠+返回
|
||||
markBackslashReturnUsed()
|
||||
return cursor.backspace().insert('\n')
|
||||
}
|
||||
// Meta+Enter 或 Shift+Enter 插入换行符
|
||||
if (key.meta || key.shift) {
|
||||
return cursor.insert('\n')
|
||||
}
|
||||
// Apple Terminal 不支持自定义 Shift+Enter 键绑定,
|
||||
// 所以我们使用原生 macOS 修饰符检测来检查是否按住了 Shift
|
||||
if (env.terminal === 'Apple_Terminal' && isModifierPressed('shift')) {
|
||||
return cursor.insert('\n')
|
||||
}
|
||||
onSubmit?.(originalValue)
|
||||
}
|
||||
|
||||
function upOrHistoryUp() {
|
||||
if (disableCursorMovementForUpDownKeys) {
|
||||
onHistoryUp?.()
|
||||
return cursor
|
||||
}
|
||||
// 首先尝试按换行行移动
|
||||
const cursorUp = cursor.up()
|
||||
if (!cursorUp.equals(cursor)) {
|
||||
return cursorUp
|
||||
}
|
||||
|
||||
// 如果我们不能按换行行移动且这是多行输入,
|
||||
// 尝试按逻辑行移动(以处理段落边界)
|
||||
if (multiline) {
|
||||
const cursorUpLogical = cursor.upLogicalLine()
|
||||
if (!cursorUpLogical.equals(cursor)) {
|
||||
return cursorUpLogical
|
||||
}
|
||||
}
|
||||
|
||||
// 完全无法向上移动 - 触发历史记录导航
|
||||
onHistoryUp?.()
|
||||
return cursor
|
||||
}
|
||||
function downOrHistoryDown() {
|
||||
if (disableCursorMovementForUpDownKeys) {
|
||||
onHistoryDown?.()
|
||||
return cursor
|
||||
}
|
||||
// 首先尝试按换行行移动
|
||||
const cursorDown = cursor.down()
|
||||
if (!cursorDown.equals(cursor)) {
|
||||
return cursorDown
|
||||
}
|
||||
|
||||
// 如果我们不能按换行行移动且这是多行输入,
|
||||
// 尝试按逻辑行移动(以处理段落边界)
|
||||
if (multiline) {
|
||||
const cursorDownLogical = cursor.downLogicalLine()
|
||||
if (!cursorDownLogical.equals(cursor)) {
|
||||
return cursorDownLogical
|
||||
}
|
||||
}
|
||||
|
||||
// 完全无法向下移动 - 触发历史记录导航
|
||||
onHistoryDown?.()
|
||||
return cursor
|
||||
}
|
||||
|
||||
function mapKey(key: Key): InputMapper {
|
||||
switch (true) {
|
||||
case key.escape:
|
||||
return () => {
|
||||
// 当键绑定上下文(例如 Autocomplete)拥有 escape 时跳过。
|
||||
// useKeybindings 无法通过 stopImmediatePropagation 保护我们 —
|
||||
// BaseTextInput 的 useInput 首先注册(子效果在父效果之前触发),
|
||||
// 所以这个处理程序在键绑定的处理程序停止传播时已经运行了。
|
||||
if (disableEscapeDoublePress) return cursor
|
||||
handleEscape()
|
||||
// 返回当前光标不变 - handleEscape 内部管理状态
|
||||
return cursor
|
||||
}
|
||||
case key.leftArrow && (key.ctrl || key.meta || key.fn):
|
||||
return () => cursor.prevWord()
|
||||
case key.rightArrow && (key.ctrl || key.meta || key.fn):
|
||||
return () => cursor.nextWord()
|
||||
case key.backspace:
|
||||
return key.meta || key.ctrl
|
||||
? killWordBefore
|
||||
: () => cursor.deleteTokenBefore() ?? cursor.backspace()
|
||||
case key.delete:
|
||||
return key.meta ? killToLineEnd : () => cursor.del()
|
||||
case key.ctrl:
|
||||
return handleCtrl
|
||||
case key.home:
|
||||
return () => cursor.startOfLine()
|
||||
case key.end:
|
||||
return () => cursor.endOfLine()
|
||||
case key.pageDown:
|
||||
// 在全屏模式下,PgUp/PgDn 滚动消息视口而不是移动光标 —
|
||||
// 这里是无操作,ScrollKeybindingHandler 处理它。
|
||||
if (isFullscreenEnvEnabled()) {
|
||||
return NOOP_HANDLER
|
||||
}
|
||||
return () => cursor.endOfLine()
|
||||
case key.pageUp:
|
||||
if (isFullscreenEnvEnabled()) {
|
||||
return NOOP_HANDLER
|
||||
}
|
||||
return () => cursor.startOfLine()
|
||||
case key.wheelUp:
|
||||
case key.wheelDown:
|
||||
// 鼠标滚轮事件仅在全屏鼠标跟踪开启时存在。
|
||||
// ScrollKeybindingHandler 处理它们;这里无操作以避免
|
||||
// 将原始 SGR 序列作为文本插入。
|
||||
return NOOP_HANDLER
|
||||
case key.return:
|
||||
// 必须在 key.meta 之前,以便 Option+Return 插入换行符
|
||||
return () => handleEnter(key)
|
||||
case key.meta:
|
||||
return handleMeta
|
||||
case key.tab:
|
||||
return () => cursor
|
||||
case key.upArrow && !key.shift:
|
||||
return upOrHistoryUp
|
||||
case key.downArrow && !key.shift:
|
||||
return downOrHistoryDown
|
||||
case key.leftArrow:
|
||||
return () => cursor.left()
|
||||
case key.rightArrow:
|
||||
return () => cursor.right()
|
||||
default: {
|
||||
return function (input: string) {
|
||||
switch (true) {
|
||||
// Home 键
|
||||
case input === '\x1b[H' || input === '\x1b[1~':
|
||||
return cursor.startOfLine()
|
||||
// End 键
|
||||
case input === '\x1b[F' || input === '\x1b[4~':
|
||||
return cursor.endOfLine()
|
||||
default: {
|
||||
// 在 SSH 合并 Enter 后("o\r")的尾随 \r —
|
||||
// 剥离它以便 Enter 不作为内容插入。单独的 \r
|
||||
// 这里是从 Alt+Enter 泄漏的(META_KEY_CODE_RE 不
|
||||
// 匹配 \x1b\r)— 为下面的 \r→\n 留下。嵌入的 \r
|
||||
// 是来自没有括号粘贴的终端的多行粘贴 — 转换为 \n。
|
||||
// 反斜杠+\r 是一个陈旧的 VS Code
|
||||
// Shift+Enter 绑定(#8991 之前 /terminal-setup 写入
|
||||
// args.text "\\\r\n" 到 keybindings.json);保留 \r 以便
|
||||
// 它在下面变为 \n(anthropics/claude-code#31316)。
|
||||
const text = stripAnsi(input)
|
||||
// eslint-disable-next-line custom-rules/no-lookbehind-regex -- .replace(re, str) 在 1-2 字符按键上:no-match 返回相同字符串(Object.is),regex 永远不会运行
|
||||
.replace(/(?<=[^\\\r\n])\r$/, '')
|
||||
.replace(/\r/g, '\n')
|
||||
if (cursor.isAtStart() && isInputModeCharacter(input)) {
|
||||
return cursor.insert(text).left()
|
||||
}
|
||||
return cursor.insert(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查这是否是 kill 命令(Ctrl+K、Ctrl+U、Ctrl+W 或 Meta+Backspace/Delete)
|
||||
function isKillKey(key: Key, input: string): boolean {
|
||||
if (key.ctrl && (input === 'k' || input === 'u' || input === 'w')) {
|
||||
return true
|
||||
}
|
||||
if (key.meta && (key.backspace || key.delete)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查这是否是 yank 命令(Ctrl+Y 或 Alt+Y)
|
||||
function isYankKey(key: Key, input: string): boolean {
|
||||
return (key.ctrl || key.meta) && input === 'y'
|
||||
}
|
||||
|
||||
function onInput(input: string, key: Key): void {
|
||||
// 注意:图像粘贴快捷键(chat:imagePaste)通过 PromptInput 中的 useKeybindings 处理
|
||||
|
||||
// 如果提供则应用过滤器
|
||||
const filteredInput = inputFilter ? inputFilter(input, key) : input
|
||||
|
||||
// 如果输入被过滤掉了,什么都不做
|
||||
if (filteredInput === '' && input !== '') {
|
||||
return
|
||||
}
|
||||
|
||||
// 修复问题 #1853:过滤干扰 SSH/tmux 中 backspace 的 DEL 字符
|
||||
// 在 SSH/tmux 环境中,backspace 生成键事件和原始 DEL 字符
|
||||
if (!key.backspace && !key.delete && input.includes('\x7f')) {
|
||||
const delCount = (input.match(/\x7f/g) || []).length
|
||||
|
||||
// 同步应用所有 DEL 字符作为 backspace 操作
|
||||
// 首先尝试删除 token,回退到字符 backspace
|
||||
let currentCursor = cursor
|
||||
for (let i = 0; i < delCount; i++) {
|
||||
currentCursor =
|
||||
currentCursor.deleteTokenBefore() ?? currentCursor.backspace()
|
||||
}
|
||||
|
||||
// 用最终结果一次性更新状态
|
||||
if (!cursor.equals(currentCursor)) {
|
||||
if (cursor.text !== currentCursor.text) {
|
||||
onChange(currentCursor.text)
|
||||
}
|
||||
setOffset(currentCursor.offset)
|
||||
}
|
||||
resetKillAccumulation()
|
||||
resetYankState()
|
||||
return
|
||||
}
|
||||
|
||||
// 对于非 kill 键重置 kill 累积
|
||||
if (!isKillKey(key, filteredInput)) {
|
||||
resetKillAccumulation()
|
||||
}
|
||||
|
||||
// 对于非 yank 键重置 yank 状态(打破 yank-pop 链)
|
||||
if (!isYankKey(key, filteredInput)) {
|
||||
resetYankState()
|
||||
}
|
||||
|
||||
const nextCursor = mapKey(key)(filteredInput)
|
||||
if (nextCursor) {
|
||||
if (!cursor.equals(nextCursor)) {
|
||||
if (cursor.text !== nextCursor.text) {
|
||||
onChange(nextCursor.text)
|
||||
}
|
||||
setOffset(nextCursor.offset)
|
||||
}
|
||||
// SSH 合并 Enter:在慢速链接上,"o" + Enter 可能作为一个块 "o\r" 到达。
|
||||
// parseKeypress 仅匹配 s === '\r',所以它命中了上面的默认处理程序
|
||||
//(它剥离了尾随 \r)。正好有一个尾随 \r 的文本是合并 Enter;
|
||||
// 单独的 \r 是 Alt+Enter(换行);嵌入的 \r 是多行粘贴。
|
||||
if (
|
||||
filteredInput.length > 1 &&
|
||||
filteredInput.endsWith('\r') &&
|
||||
!filteredInput.slice(0, -1).includes('\r') &&
|
||||
// 反斜杠+CR 是一个陈旧的 VS Code Shift+Enter 绑定,不是
|
||||
// 合并 Enter。见上面的默认处理程序。
|
||||
filteredInput[filteredInput.length - 2] !== '\\'
|
||||
) {
|
||||
onSubmit?.(nextCursor.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 准备用于渲染的 ghost 文本 - 验证 insertPosition 与当前
|
||||
// 光标偏移匹配,以防止先前按键的过时 ghost 文本导致
|
||||
// 一帧抖动(ghost 文本状态通过 useEffect 在渲染后更新)
|
||||
const ghostTextForRender =
|
||||
inlineGhostText && dim && inlineGhostText.insertPosition === offset
|
||||
? { text: inlineGhostText.text, dim }
|
||||
: undefined
|
||||
|
||||
const cursorPos = cursor.getPosition()
|
||||
|
||||
return {
|
||||
onInput,
|
||||
renderedValue: cursor.render(
|
||||
cursorChar,
|
||||
mask,
|
||||
invert,
|
||||
ghostTextForRender,
|
||||
maxVisibleLines,
|
||||
),
|
||||
offset,
|
||||
setOffset,
|
||||
cursorLine: cursorPos.line - cursor.getViewportStartLine(maxVisibleLines),
|
||||
cursorColumn: cursorPos.column,
|
||||
viewportCharOffset: cursor.getViewportCharOffset(maxVisibleLines),
|
||||
viewportCharEnd: cursor.getViewportCharEnd(maxVisibleLines),
|
||||
}
|
||||
}
|
||||
18
claude-code源码-中文注释/src/hooks/useTimeout.ts
Normal file
18
claude-code源码-中文注释/src/hooks/useTimeout.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 在给定延迟后返回 true 的 Hook。
|
||||
* 当 resetTrigger 变化时重置计时器。
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function useTimeout(delay: number, resetTrigger?: number): boolean {
|
||||
const [isElapsed, setIsElapsed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsElapsed(false)
|
||||
const timer = setTimeout(setIsElapsed, delay, true)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [delay, resetTrigger])
|
||||
|
||||
return isElapsed
|
||||
}
|
||||
213
claude-code源码-中文注释/src/hooks/useTurnDiffs.ts
Normal file
213
claude-code源码-中文注释/src/hooks/useTurnDiffs.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import type { StructuredPatchHunk } from 'diff'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import type { FileEditOutput } from '../tools/FileEditTool/types.js'
|
||||
import type { Output as FileWriteOutput } from '../tools/FileWriteTool/FileWriteTool.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
|
||||
export type TurnFileDiff = {
|
||||
filePath: string
|
||||
hunks: StructuredPatchHunk[]
|
||||
isNewFile: boolean
|
||||
linesAdded: number
|
||||
linesRemoved: number
|
||||
}
|
||||
|
||||
export type TurnDiff = {
|
||||
turnIndex: number
|
||||
userPromptPreview: string
|
||||
timestamp: string
|
||||
files: Map<string, TurnFileDiff>
|
||||
stats: {
|
||||
filesChanged: number
|
||||
linesAdded: number
|
||||
linesRemoved: number
|
||||
}
|
||||
}
|
||||
|
||||
type FileEditResult = FileEditOutput | FileWriteOutput
|
||||
|
||||
type TurnDiffCache = {
|
||||
completedTurns: TurnDiff[]
|
||||
currentTurn: TurnDiff | null
|
||||
lastProcessedIndex: number
|
||||
lastTurnIndex: number
|
||||
}
|
||||
|
||||
function isFileEditResult(result: unknown): result is FileEditResult {
|
||||
if (!result || typeof result !== 'object') return false
|
||||
const r = result as Record<string, unknown>
|
||||
// FileEditTool: has structuredPatch with content
|
||||
// FileWriteTool (update): has structuredPatch with content
|
||||
// FileWriteTool (create): has type='create' and content (structuredPatch is empty)
|
||||
const hasFilePath = typeof r.filePath === 'string'
|
||||
const hasStructuredPatch =
|
||||
Array.isArray(r.structuredPatch) && r.structuredPatch.length > 0
|
||||
const isNewFile = r.type === 'create' && typeof r.content === 'string'
|
||||
return hasFilePath && (hasStructuredPatch || isNewFile)
|
||||
}
|
||||
|
||||
function isFileWriteOutput(result: FileEditResult): result is FileWriteOutput {
|
||||
return (
|
||||
'type' in result && (result.type === 'create' || result.type === 'update')
|
||||
)
|
||||
}
|
||||
|
||||
function countHunkLines(hunks: StructuredPatchHunk[]): {
|
||||
added: number
|
||||
removed: number
|
||||
} {
|
||||
let added = 0
|
||||
let removed = 0
|
||||
for (const hunk of hunks) {
|
||||
for (const line of hunk.lines) {
|
||||
if (line.startsWith('+')) added++
|
||||
else if (line.startsWith('-')) removed++
|
||||
}
|
||||
}
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
function getUserPromptPreview(message: Message): string {
|
||||
if (message.type !== 'user') return ''
|
||||
const content = message.message.content
|
||||
const text = typeof content === 'string' ? content : ''
|
||||
// 截断到约 30 个字符
|
||||
if (text.length <= 30) return text
|
||||
return text.slice(0, 29) + '…'
|
||||
}
|
||||
|
||||
function computeTurnStats(turn: TurnDiff): void {
|
||||
let totalAdded = 0
|
||||
let totalRemoved = 0
|
||||
for (const file of turn.files.values()) {
|
||||
totalAdded += file.linesAdded
|
||||
totalRemoved += file.linesRemoved
|
||||
}
|
||||
turn.stats = {
|
||||
filesChanged: turn.files.size,
|
||||
linesAdded: totalAdded,
|
||||
linesRemoved: totalRemoved,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从消息中提取基于回合的 diff。
|
||||
* 回合定义为用户提示后跟助手响应和工具结果。
|
||||
* 每个包含文件编辑的回合都包含在结果中。
|
||||
*
|
||||
* 使用增量累加 — 只处理自上次渲染以来的新消息。
|
||||
*/
|
||||
export function useTurnDiffs(messages: Message[]): TurnDiff[] {
|
||||
const cache = useRef<TurnDiffCache>({
|
||||
completedTurns: [],
|
||||
currentTurn: null,
|
||||
lastProcessedIndex: 0,
|
||||
lastTurnIndex: 0,
|
||||
})
|
||||
|
||||
return useMemo(() => {
|
||||
const c = cache.current
|
||||
|
||||
// 如果消息缩小则重置(用户回退了对话)
|
||||
if (messages.length < c.lastProcessedIndex) {
|
||||
c.completedTurns = []
|
||||
c.currentTurn = null
|
||||
c.lastProcessedIndex = 0
|
||||
c.lastTurnIndex = 0
|
||||
}
|
||||
|
||||
// 只处理新消息
|
||||
for (let i = c.lastProcessedIndex; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
if (!message || message.type !== 'user') continue
|
||||
|
||||
// 检查这是否是用户提示(不是工具结果)
|
||||
const isToolResult =
|
||||
message.toolUseResult ||
|
||||
(Array.isArray(message.message.content) &&
|
||||
message.message.content[0]?.type === 'tool_result')
|
||||
|
||||
if (!isToolResult && !message.isMeta) {
|
||||
// 在用户提示时开始新回合
|
||||
if (c.currentTurn && c.currentTurn.files.size > 0) {
|
||||
computeTurnStats(c.currentTurn)
|
||||
c.completedTurns.push(c.currentTurn)
|
||||
}
|
||||
|
||||
c.lastTurnIndex++
|
||||
c.currentTurn = {
|
||||
turnIndex: c.lastTurnIndex,
|
||||
userPromptPreview: getUserPromptPreview(message),
|
||||
timestamp: message.timestamp,
|
||||
files: new Map(),
|
||||
stats: { filesChanged: 0, linesAdded: 0, linesRemoved: 0 },
|
||||
}
|
||||
} else if (c.currentTurn && message.toolUseResult) {
|
||||
// 从工具结果收集文件编辑
|
||||
const result = message.toolUseResult
|
||||
if (isFileEditResult(result)) {
|
||||
const { filePath, structuredPatch } = result
|
||||
const isNewFile = 'type' in result && result.type === 'create'
|
||||
|
||||
// 获取或创建文件条目
|
||||
let fileEntry = c.currentTurn.files.get(filePath)
|
||||
if (!fileEntry) {
|
||||
fileEntry = {
|
||||
filePath,
|
||||
hunks: [],
|
||||
isNewFile,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
}
|
||||
c.currentTurn.files.set(filePath, fileEntry)
|
||||
}
|
||||
|
||||
// 对于新文件,从内容生成合成 hunk
|
||||
if (
|
||||
isNewFile &&
|
||||
structuredPatch.length === 0 &&
|
||||
isFileWriteOutput(result)
|
||||
) {
|
||||
const content = result.content
|
||||
const lines = content.split('\n')
|
||||
const syntheticHunk: StructuredPatchHunk = {
|
||||
oldStart: 0,
|
||||
oldLines: 0,
|
||||
newStart: 1,
|
||||
newLines: lines.length,
|
||||
lines: lines.map(l => '+' + l),
|
||||
}
|
||||
fileEntry.hunks.push(syntheticHunk)
|
||||
fileEntry.linesAdded += lines.length
|
||||
} else {
|
||||
// 追加 hunks(同一文件可能在回合中被编辑多次)
|
||||
fileEntry.hunks.push(...structuredPatch)
|
||||
|
||||
// 更新行数
|
||||
const { added, removed } = countHunkLines(structuredPatch)
|
||||
fileEntry.linesAdded += added
|
||||
fileEntry.linesRemoved += removed
|
||||
}
|
||||
|
||||
// 如果文件被创建然后编辑,它仍然是新文件
|
||||
if (isNewFile) {
|
||||
fileEntry.isNewFile = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.lastProcessedIndex = messages.length
|
||||
|
||||
// 构建结果:已完成的回合 + 当前有文件的当前回合
|
||||
const result = [...c.completedTurns]
|
||||
if (c.currentTurn && c.currentTurn.files.size > 0) {
|
||||
// 在包含之前计算当前回合的统计
|
||||
computeTurnStats(c.currentTurn)
|
||||
result.push(c.currentTurn)
|
||||
}
|
||||
|
||||
// 返回反向顺序(最新的在前)
|
||||
return result.reverse()
|
||||
}, [messages])
|
||||
}
|
||||
34
claude-code源码-中文注释/src/hooks/useUpdateNotification.ts
Normal file
34
claude-code源码-中文注释/src/hooks/useUpdateNotification.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useState } from 'react'
|
||||
import { major, minor, patch } from 'semver'
|
||||
|
||||
export function getSemverPart(version: string): string {
|
||||
return `${major(version, { loose: true })}.${minor(version, { loose: true })}.${patch(version, { loose: true })}`
|
||||
}
|
||||
|
||||
export function shouldShowUpdateNotification(
|
||||
updatedVersion: string,
|
||||
lastNotifiedSemver: string | null,
|
||||
): boolean {
|
||||
const updatedSemver = getSemverPart(updatedVersion)
|
||||
return updatedSemver !== lastNotifiedSemver
|
||||
}
|
||||
|
||||
export function useUpdateNotification(
|
||||
updatedVersion: string | null | undefined,
|
||||
initialVersion: string = MACRO.VERSION,
|
||||
): string | null {
|
||||
const [lastNotifiedSemver, setLastNotifiedSemver] = useState<string | null>(
|
||||
() => getSemverPart(initialVersion),
|
||||
)
|
||||
|
||||
if (!updatedVersion) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedSemver = getSemverPart(updatedVersion)
|
||||
if (updatedSemver !== lastNotifiedSemver) {
|
||||
setLastNotifiedSemver(updatedSemver)
|
||||
return updatedSemver
|
||||
}
|
||||
return null
|
||||
}
|
||||
321
claude-code源码-中文注释/src/hooks/useVimInput.ts
Normal file
321
claude-code源码-中文注释/src/hooks/useVimInput.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import type { Key } from '../ink.js'
|
||||
import type { VimInputState, VimMode } from '../types/textInputTypes.js'
|
||||
import { Cursor } from '../utils/Cursor.js'
|
||||
import { lastGrapheme } from '../utils/intl.js'
|
||||
import {
|
||||
executeIndent,
|
||||
executeJoin,
|
||||
executeOpenLine,
|
||||
executeOperatorFind,
|
||||
executeOperatorMotion,
|
||||
executeOperatorTextObj,
|
||||
executeReplace,
|
||||
executeToggleCase,
|
||||
executeX,
|
||||
type OperatorContext,
|
||||
} from '../vim/operators.js'
|
||||
import { type TransitionContext, transition } from '../vim/transitions.js'
|
||||
import {
|
||||
createInitialPersistentState,
|
||||
createInitialVimState,
|
||||
type PersistentState,
|
||||
type RecordedChange,
|
||||
type VimState,
|
||||
} from '../vim/types.js'
|
||||
import { type UseTextInputProps, useTextInput } from './useTextInput.js'
|
||||
|
||||
type UseVimInputProps = Omit<UseTextInputProps, 'inputFilter'> & {
|
||||
onModeChange?: (mode: VimMode) => void
|
||||
onUndo?: () => void
|
||||
inputFilter?: UseTextInputProps['inputFilter']
|
||||
}
|
||||
|
||||
/**
|
||||
* 集成 Vim 模式到文本输入的 Hook。
|
||||
* 支持 INSERT 和 NORMAL 模式,包括操作符、计数、文本对象等。
|
||||
* 实现了 Vim 的点重复(dot-repeat)功能。
|
||||
*/
|
||||
export function useVimInput(props: UseVimInputProps): VimInputState {
|
||||
const vimStateRef = React.useRef<VimState>(createInitialVimState())
|
||||
const [mode, setMode] = useState<VimMode>('INSERT')
|
||||
|
||||
const persistentRef = React.useRef<PersistentState>(
|
||||
createInitialPersistentState(),
|
||||
)
|
||||
|
||||
// inputFilter 在 handleVimInput 顶部应用一次(不是在这里),这样
|
||||
// vim 处理路径在返回而不调用 textInput.onInput 时仍然运行过滤器 —
|
||||
// 否则有状态过滤器(如 lazy-space-after-pill)会在 Escape → NORMAL → INSERT
|
||||
// 往返之间保持武装状态。
|
||||
const textInput = useTextInput({ ...props, inputFilter: undefined })
|
||||
const { onModeChange, inputFilter } = props
|
||||
|
||||
const switchToInsertMode = useCallback(
|
||||
(offset?: number): void => {
|
||||
if (offset !== undefined) {
|
||||
textInput.setOffset(offset)
|
||||
}
|
||||
vimStateRef.current = { mode: 'INSERT', insertedText: '' }
|
||||
setMode('INSERT')
|
||||
onModeChange?.('INSERT')
|
||||
},
|
||||
[textInput, onModeChange],
|
||||
)
|
||||
|
||||
const switchToNormalMode = useCallback((): void => {
|
||||
const current = vimStateRef.current
|
||||
if (current.mode === 'INSERT' && current.insertedText) {
|
||||
persistentRef.current.lastChange = {
|
||||
type: 'insert',
|
||||
text: current.insertedText,
|
||||
}
|
||||
}
|
||||
|
||||
// Vim 行为:退出插入模式时将光标左移 1
|
||||
//(除非在行首或 offset 0)
|
||||
const offset = textInput.offset
|
||||
if (offset > 0 && props.value[offset - 1] !== '\n') {
|
||||
textInput.setOffset(offset - 1)
|
||||
}
|
||||
|
||||
vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
|
||||
setMode('NORMAL')
|
||||
onModeChange?.('NORMAL')
|
||||
}, [onModeChange, textInput, props.value])
|
||||
|
||||
function createOperatorContext(
|
||||
cursor: Cursor,
|
||||
isReplay: boolean = false,
|
||||
): OperatorContext {
|
||||
return {
|
||||
cursor,
|
||||
text: props.value,
|
||||
setText: (newText: string) => props.onChange(newText),
|
||||
setOffset: (offset: number) => textInput.setOffset(offset),
|
||||
enterInsert: (offset: number) => switchToInsertMode(offset),
|
||||
getRegister: () => persistentRef.current.register,
|
||||
setRegister: (content: string, linewise: boolean) => {
|
||||
persistentRef.current.register = content
|
||||
persistentRef.current.registerIsLinewise = linewise
|
||||
},
|
||||
getLastFind: () => persistentRef.current.lastFind,
|
||||
setLastFind: (type, char) => {
|
||||
persistentRef.current.lastFind = { type, char }
|
||||
},
|
||||
recordChange: isReplay
|
||||
? () => {}
|
||||
: (change: RecordedChange) => {
|
||||
persistentRef.current.lastChange = change
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function replayLastChange(): void {
|
||||
const change = persistentRef.current.lastChange
|
||||
if (!change) return
|
||||
|
||||
const cursor = Cursor.fromText(props.value, props.columns, textInput.offset)
|
||||
const ctx = createOperatorContext(cursor, true)
|
||||
|
||||
switch (change.type) {
|
||||
case 'insert':
|
||||
if (change.text) {
|
||||
const newCursor = cursor.insert(change.text)
|
||||
props.onChange(newCursor.text)
|
||||
textInput.setOffset(newCursor.offset)
|
||||
}
|
||||
break
|
||||
|
||||
case 'x':
|
||||
executeX(change.count, ctx)
|
||||
break
|
||||
|
||||
case 'replace':
|
||||
executeReplace(change.char, change.count, ctx)
|
||||
break
|
||||
|
||||
case 'toggleCase':
|
||||
executeToggleCase(change.count, ctx)
|
||||
break
|
||||
|
||||
case 'indent':
|
||||
executeIndent(change.dir, change.count, ctx)
|
||||
break
|
||||
|
||||
case 'join':
|
||||
executeJoin(change.count, ctx)
|
||||
break
|
||||
|
||||
case 'openLine':
|
||||
executeOpenLine(change.direction, ctx)
|
||||
break
|
||||
|
||||
case 'operator':
|
||||
executeOperatorMotion(change.op, change.motion, change.count, ctx)
|
||||
break
|
||||
|
||||
case 'operatorFind':
|
||||
executeOperatorFind(
|
||||
change.op,
|
||||
change.find,
|
||||
change.char,
|
||||
change.count,
|
||||
ctx,
|
||||
)
|
||||
break
|
||||
|
||||
case 'operatorTextObj':
|
||||
executeOperatorTextObj(
|
||||
change.op,
|
||||
change.scope,
|
||||
change.objType,
|
||||
change.count,
|
||||
ctx,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleVimInput(rawInput: string, key: Key): void {
|
||||
const state = vimStateRef.current
|
||||
// 在所有模式下运行 inputFilter,以便有状态过滤器在任何键上解除武装,
|
||||
// 但只在 INSERT 模式应用转换后的输入 — NORMAL 模式命令
|
||||
// 查找需要单个字符,前置空格会破坏它们。
|
||||
const filtered = inputFilter ? inputFilter(rawInput, key) : rawInput
|
||||
const input = state.mode === 'INSERT' ? filtered : rawInput
|
||||
const cursor = Cursor.fromText(props.value, props.columns, textInput.offset)
|
||||
|
||||
if (key.ctrl) {
|
||||
textInput.onInput(input, key)
|
||||
return
|
||||
}
|
||||
|
||||
// NOTE(keybindings): 这个 escape 处理程序有意不迁移到键绑定系统。
|
||||
// 这是 vim 标准的 INSERT→NORMAL 模式切换 - 一个 vim 特定行为,
|
||||
// 不应该通过键绑定配置。Vim 用户期望 Esc 总是退出 INSERT 模式。
|
||||
if (key.escape && state.mode === 'INSERT') {
|
||||
switchToNormalMode()
|
||||
return
|
||||
}
|
||||
|
||||
// NORMAL 模式下的 Escape 取消任何待处理命令(替换、操作符等)
|
||||
if (key.escape && state.mode === 'NORMAL') {
|
||||
vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
|
||||
return
|
||||
}
|
||||
|
||||
// 无论模式如何,将 Enter 传递给基础处理程序(允许从 NORMAL 提交)
|
||||
if (key.return) {
|
||||
textInput.onInput(input, key)
|
||||
return
|
||||
}
|
||||
|
||||
if (state.mode === 'INSERT') {
|
||||
// 跟踪插入的文本以进行点重复
|
||||
if (key.backspace || key.delete) {
|
||||
if (state.insertedText.length > 0) {
|
||||
vimStateRef.current = {
|
||||
mode: 'INSERT',
|
||||
insertedText: state.insertedText.slice(
|
||||
0,
|
||||
-(lastGrapheme(state.insertedText).length || 1),
|
||||
),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
vimStateRef.current = {
|
||||
mode: 'INSERT',
|
||||
insertedText: state.insertedText + input,
|
||||
}
|
||||
}
|
||||
textInput.onInput(input, key)
|
||||
return
|
||||
}
|
||||
|
||||
if (state.mode !== 'NORMAL') {
|
||||
return
|
||||
}
|
||||
|
||||
// 在空闲状态,将方向键委托给基础处理程序进行光标移动
|
||||
// 和历史回退(upOrHistoryUp / downOrHistoryDown)
|
||||
if (
|
||||
state.command.type === 'idle' &&
|
||||
(key.upArrow || key.downArrow || key.leftArrow || key.rightArrow)
|
||||
) {
|
||||
textInput.onInput(input, key)
|
||||
return
|
||||
}
|
||||
|
||||
const ctx: TransitionContext = {
|
||||
...createOperatorContext(cursor, false),
|
||||
onUndo: props.onUndo,
|
||||
onDotRepeat: replayLastChange,
|
||||
}
|
||||
|
||||
// Backspace/Delete 只在期望动作的状态中映射。在
|
||||
// 字面符状态(replace、find、operatorFind)中映射会将
|
||||
// r+Backspace 变成"用 h 替换"并将 df+Delete 变成"删除到下一个 x"。
|
||||
// Delete 还会跳过计数状态:在 vim 中,N<Del> 删除计数
|
||||
// 数字而不是执行 Nx;我们不实现数字删除,但至少不应该
|
||||
// 将取消变成破坏性的 Nx。
|
||||
const expectsMotion =
|
||||
state.command.type === 'idle' ||
|
||||
state.command.type === 'count' ||
|
||||
state.command.type === 'operator' ||
|
||||
state.command.type === 'operatorCount'
|
||||
|
||||
// 在 NORMAL 模式中将方向键映射到 vim 动作
|
||||
let vimInput = input
|
||||
if (key.leftArrow) vimInput = 'h'
|
||||
else if (key.rightArrow) vimInput = 'l'
|
||||
else if (key.upArrow) vimInput = 'k'
|
||||
else if (key.downArrow) vimInput = 'j'
|
||||
else if (expectsMotion && key.backspace) vimInput = 'h'
|
||||
else if (expectsMotion && state.command.type !== 'count' && key.delete)
|
||||
vimInput = 'x'
|
||||
|
||||
const result = transition(state.command, vimInput, ctx)
|
||||
|
||||
if (result.execute) {
|
||||
result.execute()
|
||||
}
|
||||
|
||||
// 更新命令状态(只在 execute 没有切换到 INSERT 时)
|
||||
if (vimStateRef.current.mode === 'NORMAL') {
|
||||
if (result.next) {
|
||||
vimStateRef.current = { mode: 'NORMAL', command: result.next }
|
||||
} else if (result.execute) {
|
||||
vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input === '?' &&
|
||||
state.mode === 'NORMAL' &&
|
||||
state.command.type === 'idle'
|
||||
) {
|
||||
props.onChange('?')
|
||||
}
|
||||
}
|
||||
|
||||
const setModeExternal = useCallback(
|
||||
(newMode: VimMode) => {
|
||||
if (newMode === 'INSERT') {
|
||||
vimStateRef.current = { mode: 'INSERT', insertedText: '' }
|
||||
} else {
|
||||
vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
|
||||
}
|
||||
setMode(newMode)
|
||||
onModeChange?.(newMode)
|
||||
},
|
||||
[onModeChange],
|
||||
)
|
||||
|
||||
return {
|
||||
...textInput,
|
||||
onInput: handleVimInput,
|
||||
mode,
|
||||
setMode: setModeExternal,
|
||||
}
|
||||
}
|
||||
34
claude-code源码-中文注释/src/hooks/useVirtualScroll.ts
Normal file
34
claude-code源码-中文注释/src/hooks/useVirtualScroll.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { type DOMElement, useAnimationFrame, useTerminalFocus } from '../ink.js'
|
||||
|
||||
const BLINK_INTERVAL_MS = 600
|
||||
|
||||
/**
|
||||
* 用于同步闪烁动画的 Hook,在屏幕外时暂停。
|
||||
*
|
||||
* 返回要附加到动画元素的 ref 和当前闪烁状态。
|
||||
* 所有实例一起闪烁,因为它们从相同的动画时钟派生状态。
|
||||
* 时钟仅在至少一个订阅者可见时运行。
|
||||
* 终端模糊时暂停。
|
||||
*
|
||||
* @param enabled - 闪烁是否激活
|
||||
* @returns [ref, isVisible] - 附加到元素的 ref,在闪烁周期中可见时为 true
|
||||
*
|
||||
* @example
|
||||
* function BlinkingDot({ shouldAnimate }) {
|
||||
* const [ref, isVisible] = useBlink(shouldAnimate)
|
||||
* return <Box ref={ref}>{isVisible ? '●' : ' '}</Box>
|
||||
* }
|
||||
*/
|
||||
export function useBlink(
|
||||
enabled: boolean,
|
||||
intervalMs: number = BLINK_INTERVAL_MS,
|
||||
): [ref: (element: DOMElement | null) => void, isVisible: boolean] {
|
||||
const focused = useTerminalFocus()
|
||||
const [ref, time] = useAnimationFrame(enabled && focused ? intervalMs : null)
|
||||
|
||||
if (!enabled || !focused) return [ref, true]
|
||||
|
||||
// 从时间派生闪烁状态 - 所有实例看到相同的时间所以它们同步
|
||||
const isVisible = Math.floor(time / intervalMs) % 2 === 0
|
||||
return [ref, isVisible]
|
||||
}
|
||||
1141
claude-code源码-中文注释/src/hooks/useVoice.ts
Normal file
1141
claude-code源码-中文注释/src/hooks/useVoice.ts
Normal file
File diff suppressed because it is too large
Load Diff
25
claude-code源码-中文注释/src/hooks/useVoiceEnabled.ts
Normal file
25
claude-code源码-中文注释/src/hooks/useVoiceEnabled.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useAppState } from '../state/AppState.js'
|
||||
import {
|
||||
hasVoiceAuth,
|
||||
isVoiceGrowthBookEnabled,
|
||||
} from '../voice/voiceModeEnabled.js'
|
||||
|
||||
/**
|
||||
* 将用户意图(settings.voiceEnabled)与 auth + GB 终止开关结合。
|
||||
* 仅 auth 部分在 authVersion 上记忆化 — 它是昂贵的
|
||||
*(冷启动 getClaudeAIOAuthTokens 记忆化 → 同步 `security` spawn,
|
||||
* 约 60ms/调用,在配置文件中约 180ms 总计当 token 刷新清除会话中的缓存时)。
|
||||
* GB 是廉价的缓存映射查找,位于记忆化之外,
|
||||
* 以便会话中的终止开关翻转仍然在下一次渲染时生效。
|
||||
*
|
||||
* authVersion 仅在 /login 时增加。后台 token 刷新不触碰它
|
||||
*(用户仍然通过 auth),因此 auth 记忆化保持正确而无需重新评估。
|
||||
*/
|
||||
export function useVoiceEnabled(): boolean {
|
||||
const userIntent = useAppState(s => s.settings.voiceEnabled === true)
|
||||
const authVersion = useAppState(s => s.authVersion)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const authed = useMemo(hasVoiceAuth, [authVersion])
|
||||
return userIntent && authed && isVoiceGrowthBookEnabled()
|
||||
}
|
||||
Reference in New Issue
Block a user