first commit

This commit is contained in:
H
2026-04-03 13:01:19 +08:00
commit 538eced414
2575 changed files with 645911 additions and 0 deletions

View 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)
}

View File

@@ -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])
}

View File

@@ -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])
}

View File

@@ -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])
}

View 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])
}

View 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,
}

View File

@@ -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 }

View File

@@ -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
// 提升以便 onDismissCheckmarkcheckmark 窗口期间的 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 信号上
// 注册,直到会话结束。不是功能性 bugMap.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 }

View File

@@ -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 }

View 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 }

View 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)
}

View 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)
}
}, [])
}

View 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,
}
}

View 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 }
}

View 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])
}

View 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,
}
}

View 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]
}

View 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 处理模式退出。
// 这仅适用于 EscapeCtrl+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
}

View 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])
}

View 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)
}

View 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 代表 leader0+ 是队友
// 当 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 }
}

View 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])
}

View 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])
}

View 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'
)
}

View 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],
)
}

View 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])
}

View 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
}

View 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)
}

View 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
* 如果已处理则返回 truefalse 回退到双按退出。
* @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
}

View File

@@ -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)
}

View 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
}

View 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,
}
}

View 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])
}

View 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])
}

View 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])
}

View 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])
}

View 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])
}

View 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,
}
}

View 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
}

View 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 次/turnO(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])
}

View 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])
}

View 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
}

View 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.pluginReconnectKeyMCP 效果在自己的挂载上触发)。
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.errorsDoctor 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])
}

View 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
}

View 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],
)
}

View 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])
}

View 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,
])
}

View 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
}

View 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])
}

View 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,
}
}

View 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
// 默认为 truemacOS 用户期望 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])
}

View 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
// 确定是否被接受:按下了 TabacceptedAt 已设置)或
// 最终输入与建议匹配(空 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,
}
}

View 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,
])
}

View 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=nullcompact_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如果没有这个集合在
// 会话生命周期内无限增长BQCCR 队列显示 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],
)
}

View 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],
)
}

View 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])
}

View 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 }
}

View File

@@ -0,0 +1,158 @@
/**
* 用于管理会话后台化的 HookCtrl+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,
}
}

View 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)
}

View 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 回调并传递新的设置值。
*
* 注意:缓存由 notifierchangeDetector.fanOut重置。
* 在这里重置会导致 N 个订阅者之间的缓存震荡:
* 每个订阅者清除缓存、从磁盘重新读取、然后下一个又清除。
*/
export function useSettingsChange(
onChange: (source: SettingSource, settings: SettingsJson) => void,
): void {
const handleChange = useCallback(
(source: SettingSource) => {
// 缓存已由 notifierchangeDetector.fanOut重置 -
// 在这里重置会导致 N 个订阅者之间的缓存震荡:每个订阅者清除缓存、
// 从磁盘重新读取,然后下一个又清除。
const newSettings = getSettings_DEPRECATED()
onChange(source, newSettings)
},
[onChange],
)
useEffect(
() => settingsChangeDetector.subscribe(handleChange),
[handleChange],
)
}

View 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,
}
}

View 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],
)
}

View 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])
}

View 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])
}

View 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
}

View 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
}

View 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_agentviewedStatus 是 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,
])
}

View 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
}

View 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 以便
// 它在下面变为 \nanthropics/claude-code#31316
const text = stripAnsi(input)
// eslint-disable-next-line custom-rules/no-lookbehind-regex -- .replace(re, str) 在 1-2 字符按键上no-match 返回相同字符串Object.isregex 永远不会运行
.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),
}
}

View 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
}

View 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])
}

View 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
}

View 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,
}
}

View 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]
}

File diff suppressed because it is too large Load Diff

View 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()
}