feat: build

This commit is contained in:
claude-code-best committed 2026-03-31 19:22:47 +08:00
commit f90eee85d8
1906 files changed
+514339

No files matched your search

+811
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)
}
@@ -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'
/**
* Shows a one-shot notification when the shift-tab carousel wraps past where
* auto mode would have been. Covers all reasons (settings, circuit-breaker,
* org-allowlist). The startup case (defaultMode: auto silently downgraded) is
* handled by verifyAutoModeGateAccess → checkAndDisableAutoModeIfNeeded.
*/
export function useAutoModeUnavailableNotification(): void {
const { addNotification } = useNotifications()
const mode = useAppState(s => s.toolPermissionContext.mode)
const isAutoModeAvailable = useAppState(
s => s.toolPermissionContext.isAutoModeAvailable,
)
const shownRef = useRef(false)
const prevModeRef = useRef<PermissionMode>(mode)
useEffect(() => {
const prevMode = prevModeRef.current
prevModeRef.current = mode
if (!feature('TRANSCRIPT_CLASSIFIER')) return
if (getIsRemoteMode()) return
if (shownRef.current) return
const wrappedPastAutoSlot =
mode === 'default' &&
prevMode !== 'default' &&
prevMode !== 'auto' &&
!isAutoModeAvailable &&
hasAutoModeOptIn()
if (!wrappedPastAutoSlot) return
const reason = getAutoModeUnavailableReason()
if (!reason) return
shownRef.current = true
addNotification({
key: 'auto-mode-unavailable',
text: getAutoModeUnavailableNotification(reason),
color: 'warning',
priority: 'medium',
})
}, [mode, isAutoModeAvailable, addNotification])
}
@@ -0,0 +1,60 @@
import * as React from 'react';
import { getOauthProfileFromApiKey } from 'src/services/oauth/getOauthProfile.js';
import { isClaudeAISubscriber } from 'src/utils/auth.js';
import { Text } from '../../ink.js';
import { logEvent } from '../../services/analytics/index.js';
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
import { useStartupNotification } from './useStartupNotification.js';
const MAX_SHOW_COUNT = 3;
/**
* Hook to check if the user has a subscription on Console but isn't logged into it.
*/
export function useCanSwitchToExistingSubscription() {
useStartupNotification(_temp2);
}
/**
* Checks if the user has a subscription but is not currently logged into it.
* This helps inform users they should run /login to access their subscription.
*/
async function _temp2() {
if ((getGlobalConfig().subscriptionNoticeCount ?? 0) >= MAX_SHOW_COUNT) {
return null;
}
const subscriptionType = await getExistingClaudeSubscription();
if (subscriptionType === null) {
return null;
}
saveGlobalConfig(_temp);
logEvent("tengu_switch_to_subscription_notice_shown", {});
return {
key: "switch-to-subscription",
jsx: <Text color="suggestion">Use your existing Claude {subscriptionType} plan with Claude Code<Text color="text" dimColor={true}>{" "}· /login to activate</Text></Text>,
priority: "low"
};
}
function _temp(current) {
return {
...current,
subscriptionNoticeCount: (current.subscriptionNoticeCount ?? 0) + 1
};
}
async function getExistingClaudeSubscription(): Promise<'Max' | 'Pro' | null> {
// If already using subscription auth, there is nothing to switch to
if (isClaudeAISubscriber()) {
return null;
}
const profile = await getOauthProfileFromApiKey();
if (!profile) {
return null;
}
if (profile.account.has_claude_max) {
return 'Max';
}
if (profile.account.has_claude_pro) {
return 'Pro';
}
return null;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsImdldE9hdXRoUHJvZmlsZUZyb21BcGlLZXkiLCJpc0NsYXVkZUFJU3Vic2NyaWJlciIsIlRleHQiLCJsb2dFdmVudCIsImdldEdsb2JhbENvbmZpZyIsInNhdmVHbG9iYWxDb25maWciLCJ1c2VTdGFydHVwTm90aWZpY2F0aW9uIiwiTUFYX1NIT1dfQ09VTlQiLCJ1c2VDYW5Td2l0Y2hUb0V4aXN0aW5nU3Vic2NyaXB0aW9uIiwiX3RlbXAyIiwic3Vic2NyaXB0aW9uTm90aWNlQ291bnQiLCJzdWJzY3JpcHRpb25UeXBlIiwiZ2V0RXhpc3RpbmdDbGF1ZGVTdWJzY3JpcHRpb24iLCJfdGVtcCIsImtleSIsImpzeCIsInByaW9yaXR5IiwiY3VycmVudCIsIlByb21pc2UiLCJwcm9maWxlIiwiYWNjb3VudCIsImhhc19jbGF1ZGVfbWF4IiwiaGFzX2NsYXVkZV9wcm8iXSwic291cmNlcyI6WyJ1c2VDYW5Td2l0Y2hUb0V4aXN0aW5nU3Vic2NyaXB0aW9uLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IGdldE9hdXRoUHJvZmlsZUZyb21BcGlLZXkgfSBmcm9tICdzcmMvc2VydmljZXMvb2F1dGgvZ2V0T2F1dGhQcm9maWxlLmpzJ1xuaW1wb3J0IHsgaXNDbGF1ZGVBSVN1YnNjcmliZXIgfSBmcm9tICdzcmMvdXRpbHMvYXV0aC5qcydcbmltcG9ydCB7IFRleHQgfSBmcm9tICcuLi8uLi9pbmsuanMnXG5pbXBvcnQgeyBsb2dFdmVudCB9IGZyb20gJy4uLy4uL3NlcnZpY2VzL2FuYWx5dGljcy9pbmRleC5qcydcbmltcG9ydCB7IGdldEdsb2JhbENvbmZpZywgc2F2ZUdsb2JhbENvbmZpZyB9IGZyb20gJy4uLy4uL3V0aWxzL2NvbmZpZy5qcydcbmltcG9ydCB7IHVzZVN0YXJ0dXBOb3RpZmljYXRpb24gfSBmcm9tICcuL3VzZVN0YXJ0dXBOb3RpZmljYXRpb24uanMnXG5cbmNvbnN0IE1BWF9TSE9XX0NPVU5UID0gM1xuXG4vKipcbiAqIEhvb2sgdG8gY2hlY2sgaWYgdGhlIHVzZXIgaGFzIGEgc3Vic2NyaXB0aW9uIG9uIENvbnNvbGUgYnV0IGlzbid0IGxvZ2dlZCBpbnRvIGl0LlxuICovXG5leHBvcnQgZnVuY3Rpb24gdXNlQ2FuU3dpdGNoVG9FeGlzdGluZ1N1YnNjcmlwdGlvbigpOiB2b2lkIHtcbiAgdXNlU3RhcnR1cE5vdGlmaWNhdGlvbihhc3luYyAoKSA9PiB7XG4gICAgaWYgKChnZXRHbG9iYWxDb25maWcoKS5zdWJzY3JpcHRpb25Ob3RpY2VDb3VudCA/PyAwKSA+PSBNQVhfU0hPV19DT1VOVCkge1xuICAgICAgcmV0dXJuIG51bGxcbiAgICB9XG4gICAgY29uc3Qgc3Vic2NyaXB0aW9uVHlwZSA9IGF3YWl0IGdldEV4aXN0aW5nQ2xhdWRlU3Vic2NyaXB0aW9uKClcbiAgICBpZiAoc3Vic2NyaXB0aW9uVHlwZSA9PT0gbnVsbCkgcmV0dXJuIG51bGxcblxuICAgIHNhdmVHbG9iYWxDb25maWcoY3VycmVudCA9PiAoe1xuICAgICAgLi4uY3VycmVudCxcbiAgICAgIHN1YnNjcmlwdGlvbk5vdGljZUNvdW50OiAoY3VycmVudC5zdWJzY3JpcHRpb25Ob3RpY2VDb3VudCA/PyAwKSArIDEsXG4gICAgfSkpXG4gICAgbG9nRXZlbnQoJ3Rlbmd1X3N3aXRjaF90b19zdWJzY3JpcHRpb25fbm90aWNlX3Nob3duJywge30pXG5cbiAgICByZXR1cm4ge1xuICAgICAga2V5OiAnc3dpdGNoLXRvLXN1YnNjcmlwdGlvbicsXG4gICAgICBqc3g6IChcbiAgICAgICAgPFRleHQgY29sb3I9XCJzdWdnZXN0aW9uXCI+XG4gICAgICAgICAgVXNlIHlvdXIgZXhpc3RpbmcgQ2xhdWRlIHtzdWJzY3JpcHRpb25UeXBlfSBwbGFuIHdpdGggQ2xhdWRlIENvZGVcbiAgICAgICAgICA8VGV4dCBjb2xvcj1cInRleHRcIiBkaW1Db2xvcj5cbiAgICAgICAgICAgIHsnICd9XG4gICAgICAgICAgICDCtyAvbG9naW4gdG8gYWN0aXZhdGVcbiAgICAgICAgICA8L1RleHQ+XG4gICAgICAgIDwvVGV4dD5cbiAgICAgICksXG4gICAgICBwcmlvcml0eTogJ2xvdycsXG4gICAgfVxuICB9KVxufVxuXG4vKipcbiAqIENoZWNrcyBpZiB0aGUgdXNlciBoYXMgYSBzdWJzY3JpcHRpb24gYnV0IGlzIG5vdCBjdXJyZW50bHkgbG9nZ2VkIGludG8gaXQuXG4gKiBUaGlzIGhlbHBzIGluZm9ybSB1c2VycyB0aGV5IHNob3VsZCBydW4gL2xvZ2luIHRvIGFjY2VzcyB0aGVpciBzdWJzY3JpcHRpb24uXG4gKi9cbmFzeW5jIGZ1bmN0aW9uIGdldEV4aXN0aW5nQ2xhdWRlU3Vic2NyaXB0aW9uKCk6IFByb21pc2U8J01heCcgfCAnUHJvJyB8IG51bGw+IHtcbiAgLy8gSWYgYWxyZWFkeSB1c2luZyBzdWJzY3JpcHRpb24gYXV0aCwgdGhlcmUgaXMgbm90aGluZyB0byBzd2l0Y2ggdG9cbiAgaWYgKGlzQ2xhdWRlQUlTdWJzY3JpYmVyKCkpIHtcbiAgICByZXR1cm4gbnVsbFxuICB9XG4gIGNvbnN0IHByb2ZpbGUgPSBhd2FpdCBnZXRPYXV0aFByb2ZpbGVGcm9tQXBpS2V5KClcbiAgaWYgKCFwcm9maWxlKSB7XG4gICAgcmV0dXJuIG51bGxcbiAgfVxuXG4gIGlmIChwcm9maWxlLmFjY291bnQuaGFzX2NsYXVkZV9tYXgpIHtcbiAgICByZXR1cm4gJ01heCdcbiAgfVxuXG4gIGlmIChwcm9maWxlLmFjY291bnQuaGFzX2NsYXVkZV9wcm8pIHtcbiAgICByZXR1cm4gJ1BybydcbiAgfVxuXG4gIHJldHVybiBudWxsXG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBS0EsS0FBSyxNQUFNLE9BQU87QUFDOUIsU0FBU0MseUJBQXlCLFFBQVEsdUNBQXVDO0FBQ2pGLFNBQVNDLG9CQUFvQixRQUFRLG1CQUFtQjtBQUN4RCxTQUFTQyxJQUFJLFFBQVEsY0FBYztBQUNuQyxTQUFTQyxRQUFRLFFBQVEsbUNBQW1DO0FBQzVELFNBQVNDLGVBQWUsRUFBRUMsZ0JBQWdCLFFBQVEsdUJBQXVCO0FBQ3pFLFNBQVNDLHNCQUFzQixRQUFRLDZCQUE2QjtBQUVwRSxNQUFNQyxjQUFjLEdBQUcsQ0FBQzs7QUFFeEI7QUFDQTtBQUNBO0FBQ0EsT0FBTyxTQUFBQyxtQ0FBQTtFQUNMRixzQkFBc0IsQ0FBQ0csTUEwQnRCLENBQUM7QUFBQTs7QUFHSjtBQUNBO0FBQ0E7QUFDQTtBQWpDTyxlQUFBQSxPQUFBO0VBRUgsSUFBSSxDQUFDTCxlQUFlLENBQUMsQ0FBQyxDQUFBTSx1QkFBNkIsSUFBOUMsQ0FBOEMsS0FBS0gsY0FBYztJQUFBLE9BQzdELElBQUk7RUFBQTtFQUViLE1BQUFJLGdCQUFBLEdBQXlCLE1BQU1DLDZCQUE2QixDQUFDLENBQUM7RUFDOUQsSUFBSUQsZ0JBQWdCLEtBQUssSUFBSTtJQUFBLE9BQVMsSUFBSTtFQUFBO0VBRTFDTixnQkFBZ0IsQ0FBQ1EsS0FHZixDQUFDO0VBQ0hWLFFBQVEsQ0FBQywyQ0FBMkMsRUFBRSxDQUFDLENBQUMsQ0FBQztFQUFBLE9BRWxEO0lBQUFXLEdBQUEsRUFDQSx3QkFBd0I7SUFBQUMsR0FBQSxFQUUzQixDQUFDLElBQUksQ0FBTyxLQUFZLENBQVosWUFBWSxDQUFDLHlCQUNHSixpQkFBZSxDQUFFLHNCQUMzQyxDQUFDLElBQUksQ0FBTyxLQUFNLENBQU4sTUFBTSxDQUFDLFFBQVEsQ0FBUixLQUFPLENBQUMsQ0FDeEIsSUFBRSxDQUFFLG9CQUVQLEVBSEMsSUFBSSxDQUlQLEVBTkMsSUFBSSxDQU1FO0lBQUFLLFFBQUEsRUFFQztFQUNaLENBQUM7QUFBQTtBQTFCRSxTQUFBSCxNQUFBSSxPQUFBO0VBQUEsT0FRMEI7SUFBQSxHQUN4QkEsT0FBTztJQUFBUCx1QkFBQSxFQUNlLENBQUNPLE9BQU8sQ0FBQVAsdUJBQTZCLElBQXBDLENBQW9DLElBQUk7RUFDcEUsQ0FBQztBQUFBO0FBdUJMLGVBQWVFLDZCQUE2QkEsQ0FBQSxDQUFFLEVBQUVNLE9BQU8sQ0FBQyxLQUFLLEdBQUcsS0FBSyxHQUFHLElBQUksQ0FBQyxDQUFDO0VBQzVFO0VBQ0EsSUFBSWpCLG9CQUFvQixDQUFDLENBQUMsRUFBRTtJQUMxQixPQUFPLElBQUk7RUFDYjtFQUNBLE1BQU1rQix Line truncated
@@ -0,0 +1,44 @@
import { c as _c } from "react/compiler-runtime";
import { useEffect, useRef } from 'react';
import { useNotifications } from 'src/context/notifications.js';
import { getModelDeprecationWarning } from 'src/utils/model/deprecation.js';
import { getIsRemoteMode } from '../../bootstrap/state.js';
export function useDeprecationWarningNotification(model) {
const $ = _c(4);
const {
addNotification
} = useNotifications();
const lastWarningRef = useRef(null);
let t0;
let t1;
if ($[0] !== addNotification || $[1] !== model) {
t0 = () => {
if (getIsRemoteMode()) {
return;
}
const deprecationWarning = getModelDeprecationWarning(model);
if (deprecationWarning && deprecationWarning !== lastWarningRef.current) {
lastWarningRef.current = deprecationWarning;
addNotification({
key: "model-deprecation-warning",
text: deprecationWarning,
color: "warning",
priority: "high"
});
}
if (!deprecationWarning) {
lastWarningRef.current = null;
}
};
t1 = [model, addNotification];
$[0] = addNotification;
$[1] = model;
$[2] = t0;
$[3] = t1;
} else {
t0 = $[2];
t1 = $[3];
}
useEffect(t0, t1);
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJ1c2VFZmZlY3QiLCJ1c2VSZWYiLCJ1c2VOb3RpZmljYXRpb25zIiwiZ2V0TW9kZWxEZXByZWNhdGlvbldhcm5pbmciLCJnZXRJc1JlbW90ZU1vZGUiLCJ1c2VEZXByZWNhdGlvbldhcm5pbmdOb3RpZmljYXRpb24iLCJtb2RlbCIsIiQiLCJfYyIsImFkZE5vdGlmaWNhdGlvbiIsImxhc3RXYXJuaW5nUmVmIiwidDAiLCJ0MSIsImRlcHJlY2F0aW9uV2FybmluZyIsImN1cnJlbnQiLCJrZXkiLCJ0ZXh0IiwiY29sb3IiLCJwcmlvcml0eSJdLCJzb3VyY2VzIjpbInVzZURlcHJlY2F0aW9uV2FybmluZ05vdGlmaWNhdGlvbi50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgdXNlRWZmZWN0LCB1c2VSZWYgfSBmcm9tICdyZWFjdCdcbmltcG9ydCB7IHVzZU5vdGlmaWNhdGlvbnMgfSBmcm9tICdzcmMvY29udGV4dC9ub3RpZmljYXRpb25zLmpzJ1xuaW1wb3J0IHsgZ2V0TW9kZWxEZXByZWNhdGlvbldhcm5pbmcgfSBmcm9tICdzcmMvdXRpbHMvbW9kZWwvZGVwcmVjYXRpb24uanMnXG5pbXBvcnQgeyBnZXRJc1JlbW90ZU1vZGUgfSBmcm9tICcuLi8uLi9ib290c3RyYXAvc3RhdGUuanMnXG5cbmV4cG9ydCBmdW5jdGlvbiB1c2VEZXByZWNhdGlvbldhcm5pbmdOb3RpZmljYXRpb24obW9kZWw6IHN0cmluZyk6IHZvaWQge1xuICBjb25zdCB7IGFkZE5vdGlmaWNhdGlvbiB9ID0gdXNlTm90aWZpY2F0aW9ucygpXG4gIGNvbnN0IGxhc3RXYXJuaW5nUmVmID0gdXNlUmVmPHN0cmluZyB8IG51bGw+KG51bGwpXG5cbiAgdXNlRWZmZWN0KCgpID0+IHtcbiAgICBpZiAoZ2V0SXNSZW1vdGVNb2RlKCkpIHJldHVyblxuICAgIGNvbnN0IGRlcHJlY2F0aW9uV2FybmluZyA9IGdldE1vZGVsRGVwcmVjYXRpb25XYXJuaW5nKG1vZGVsKVxuXG4gICAgLy8gU2hvdyB3YXJuaW5nIGlmIG1vZGVsIGlzIGRlcHJlY2F0ZWQgYW5kIHdlIGhhdmVuJ3Qgc2hvd24gdGhpcyBleGFjdCB3YXJuaW5nIHlldFxuICAgIGlmIChkZXByZWNhdGlvbldhcm5pbmcgJiYgZGVwcmVjYXRpb25XYXJuaW5nICE9PSBsYXN0V2FybmluZ1JlZi5jdXJyZW50KSB7XG4gICAgICBsYXN0V2FybmluZ1JlZi5jdXJyZW50ID0gZGVwcmVjYXRpb25XYXJuaW5nXG4gICAgICBhZGROb3RpZmljYXRpb24oe1xuICAgICAgICBrZXk6ICdtb2RlbC1kZXByZWNhdGlvbi13YXJuaW5nJyxcbiAgICAgICAgdGV4dDogZGVwcmVjYXRpb25XYXJuaW5nLFxuICAgICAgICBjb2xvcjogJ3dhcm5pbmcnLFxuICAgICAgICBwcmlvcml0eTogJ2hpZ2gnLFxuICAgICAgfSlcbiAgICB9XG5cbiAgICAvLyBSZXNldCB0cmFja2luZyBpZiBtb2RlbCBjaGFuZ2VzIHRvIG5vbi1kZXByZWNhdGVkXG4gICAgaWYgKCFkZXByZWNhdGlvbldhcm5pbmcpIHtcbiAgICAgIGxhc3RXYXJuaW5nUmVmLmN1cnJlbnQgPSBudWxsXG4gICAgfVxuICB9LCBbbW9kZWwsIGFkZE5vdGlmaWNhdGlvbl0pXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxTQUFTQSxTQUFTLEVBQUVDLE1BQU0sUUFBUSxPQUFPO0FBQ3pDLFNBQVNDLGdCQUFnQixRQUFRLDhCQUE4QjtBQUMvRCxTQUFTQywwQkFBMEIsUUFBUSxnQ0FBZ0M7QUFDM0UsU0FBU0MsZUFBZSxRQUFRLDBCQUEwQjtBQUUxRCxPQUFPLFNBQUFDLGtDQUFBQyxLQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQ0w7SUFBQUM7RUFBQSxJQUE0QlAsZ0JBQWdCLENBQUMsQ0FBQztFQUM5QyxNQUFBUSxjQUFBLEdBQXVCVCxNQUFNLENBQWdCLElBQUksQ0FBQztFQUFBLElBQUFVLEVBQUE7RUFBQSxJQUFBQyxFQUFBO0VBQUEsSUFBQUwsQ0FBQSxRQUFBRSxlQUFBLElBQUFGLENBQUEsUUFBQUQsS0FBQTtJQUV4Q0ssRUFBQSxHQUFBQSxDQUFBO01BQ1IsSUFBSVAsZUFBZSxDQUFDLENBQUM7UUFBQTtNQUFBO01BQ3JCLE1BQUFTLGtCQUFBLEdBQTJCViwwQkFBMEIsQ0FBQ0csS0FBSyxDQUFDO01BRzVELElBQUlPLGtCQUFtRSxJQUE3Q0Esa0JBQWtCLEtBQUtILGNBQWMsQ0FBQUksT0FBUTtRQUNyRUosY0FBYyxDQUFBSSxPQUFBLEdBQVdELGtCQUFIO1FBQ3RCSixlQUFlLENBQUM7VUFBQU0sR0FBQSxFQUNULDJCQUEyQjtVQUFBQyxJQUFBLEVBQzFCSCxrQkFBa0I7VUFBQUksS0FBQSxFQUNqQixTQUFTO1VBQUFDLFFBQUEsRUFDTjtRQUNaLENBQUMsQ0FBQztNQUFBO01BSUosSUFBSSxDQUFDTCxrQkFBa0I7UUFDckJILGNBQWMsQ0FBQUksT0FBQSxHQUFXLElBQUg7TUFBQTtJQUN2QixDQUNGO0lBQUVGLEVBQUEsSUFBQ04sS0FBSyxFQUFFRyxlQUFlLENBQUM7SUFBQUYsQ0FBQSxNQUFBRSxlQUFBO0lBQUFGLENBQUEsTUFBQUQsS0FBQTtJQUFBQyxDQUFBLE1BQUFJLEVBQUE7SUFBQUosQ0FBQSxNQUFBSyxFQUFBO0VBQUE7SUFBQUQsRUFBQSxHQUFBSixDQUFBO0lBQUFLLEVBQUEsR0FBQUwsQ0FBQTtFQUFBO0VBbkIzQlAsU0FBUyxDQUFDVyxFQW1CVCxFQUFFQyxFQUF3QixDQUFDO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
@@ -0,0 +1,162 @@
import { c as _c } from "react/compiler-runtime";
import { useEffect } from 'react';
import { useNotifications } from 'src/context/notifications.js';
import { useAppState, useSetAppState } from 'src/state/AppState.js';
import { type CooldownReason, isFastModeEnabled, onCooldownExpired, onCooldownTriggered, onFastModeOverageRejection, onOrgFastModeChanged } from 'src/utils/fastMode.js';
import { formatDuration } from 'src/utils/format.js';
import { getIsRemoteMode } from '../../bootstrap/state.js';
const COOLDOWN_STARTED_KEY = 'fast-mode-cooldown-started';
const COOLDOWN_EXPIRED_KEY = 'fast-mode-cooldown-expired';
const ORG_CHANGED_KEY = 'fast-mode-org-changed';
const OVERAGE_REJECTED_KEY = 'fast-mode-overage-rejected';
export function useFastModeNotification() {
const $ = _c(13);
const {
addNotification
} = useNotifications();
const isFastMode = useAppState(_temp);
const setAppState = useSetAppState();
let t0;
let t1;
if ($[0] !== addNotification || $[1] !== isFastMode || $[2] !== setAppState) {
t0 = () => {
if (getIsRemoteMode()) {
return;
}
if (!isFastModeEnabled()) {
return;
}
return onOrgFastModeChanged(orgEnabled => {
if (orgEnabled) {
addNotification({
key: ORG_CHANGED_KEY,
color: "fastMode",
priority: "immediate",
text: "Fast mode is now available \xB7 /fast to turn on"
});
} else {
if (isFastMode) {
setAppState(_temp2);
addNotification({
key: ORG_CHANGED_KEY,
color: "warning",
priority: "immediate",
text: "Fast mode has been disabled by your organization"
});
}
}
});
};
t1 = [addNotification, isFastMode, setAppState];
$[0] = addNotification;
$[1] = isFastMode;
$[2] = setAppState;
$[3] = t0;
$[4] = t1;
} else {
t0 = $[3];
t1 = $[4];
}
useEffect(t0, t1);
let t2;
let t3;
if ($[5] !== addNotification || $[6] !== setAppState) {
t2 = () => {
if (getIsRemoteMode()) {
return;
}
if (!isFastModeEnabled()) {
return;
}
return onFastModeOverageRejection(message => {
setAppState(_temp3);
addNotification({
key: OVERAGE_REJECTED_KEY,
color: "warning",
priority: "immediate",
text: message
});
});
};
t3 = [addNotification, setAppState];
$[5] = addNotification;
$[6] = setAppState;
$[7] = t2;
$[8] = t3;
} else {
t2 = $[7];
t3 = $[8];
}
useEffect(t2, t3);
let t4;
let t5;
if ($[9] !== addNotification || $[10] !== isFastMode) {
t4 = () => {
if (getIsRemoteMode()) {
return;
}
if (!isFastMode) {
return;
}
const unsubTriggered = onCooldownTriggered((resetAt, reason) => {
const resetIn = formatDuration(resetAt - Date.now(), {
hideTrailingZeros: true
});
const message_0 = getCooldownMessage(reason, resetIn);
addNotification({
key: COOLDOWN_STARTED_KEY,
invalidates: [COOLDOWN_EXPIRED_KEY],
text: message_0,
color: "warning",
priority: "immediate"
});
});
const unsubExpired = onCooldownExpired(() => {
addNotification({
key: COOLDOWN_EXPIRED_KEY,
invalidates: [COOLDOWN_STARTED_KEY],
color: "fastMode",
text: "Fast limit reset \xB7 now using fast mode",
priority: "immediate"
});
});
return () => {
unsubTriggered();
unsubExpired();
};
};
t5 = [addNotification, isFastMode];
$[9] = addNotification;
$[10] = isFastMode;
$[11] = t4;
$[12] = t5;
} else {
t4 = $[11];
t5 = $[12];
}
useEffect(t4, t5);
}
function _temp3(prev_0) {
return {
...prev_0,
fastMode: false
};
}
function _temp2(prev) {
return {
...prev,
fastMode: false
};
}
function _temp(s) {
return s.fastMode;
}
function getCooldownMessage(reason: CooldownReason, resetIn: string): string {
switch (reason) {
case 'overloaded':
return `Fast mode overloaded and is temporarily unavailable · resets in ${resetIn}`;
case 'rate_limit':
return `Fast limit reached and temporarily disabled · resets in ${resetIn}`;
}
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJ1c2VFZmZlY3QiLCJ1c2VOb3RpZmljYXRpb25zIiwidXNlQXBwU3RhdGUiLCJ1c2VTZXRBcHBTdGF0ZSIsIkNvb2xkb3duUmVhc29uIiwiaXNGYXN0TW9kZUVuYWJsZWQiLCJvbkNvb2xkb3duRXhwaXJlZCIsIm9uQ29vbGRvd25UcmlnZ2VyZWQiLCJvbkZhc3RNb2RlT3ZlcmFnZVJlamVjdGlvbiIsIm9uT3JnRmFzdE1vZGVDaGFuZ2VkIiwiZm9ybWF0RHVyYXRpb24iLCJnZXRJc1JlbW90ZU1vZGUiLCJDT09MRE9XTl9TVEFSVEVEX0tFWSIsIkNPT0xET1dOX0VYUElSRURfS0VZIiwiT1JHX0NIQU5HRURfS0VZIiwiT1ZFUkFHRV9SRUpFQ1RFRF9LRVkiLCJ1c2VGYXN0TW9kZU5vdGlmaWNhdGlvbiIsIiQiLCJfYyIsImFkZE5vdGlmaWNhdGlvbiIsImlzRmFzdE1vZGUiLCJfdGVtcCIsInNldEFwcFN0YXRlIiwidDAiLCJ0MSIsIm9yZ0VuYWJsZWQiLCJrZXkiLCJjb2xvciIsInByaW9yaXR5IiwidGV4dCIsIl90ZW1wMiIsInQyIiwidDMiLCJtZXNzYWdlIiwiX3RlbXAzIiwidDQiLCJ0NSIsInVuc3ViVHJpZ2dlcmVkIiwicmVzZXRBdCIsInJlYXNvbiIsInJlc2V0SW4iLCJEYXRlIiwibm93IiwiaGlkZVRyYWlsaW5nWmVyb3MiLCJtZXNzYWdlXzAiLCJnZXRDb29sZG93bk1lc3NhZ2UiLCJpbnZhbGlkYXRlcyIsInVuc3ViRXhwaXJlZCIsInByZXZfMCIsInByZXYiLCJmYXN0TW9kZSIsInMiXSwic291cmNlcyI6WyJ1c2VGYXN0TW9kZU5vdGlmaWNhdGlvbi50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgdXNlRWZmZWN0IH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgeyB1c2VOb3RpZmljYXRpb25zIH0gZnJvbSAnc3JjL2NvbnRleHQvbm90aWZpY2F0aW9ucy5qcydcbmltcG9ydCB7IHVzZUFwcFN0YXRlLCB1c2VTZXRBcHBTdGF0ZSB9IGZyb20gJ3NyYy9zdGF0ZS9BcHBTdGF0ZS5qcydcbmltcG9ydCB7XG4gIHR5cGUgQ29vbGRvd25SZWFzb24sXG4gIGlzRmFzdE1vZGVFbmFibGVkLFxuICBvbkNvb2xkb3duRXhwaXJlZCxcbiAgb25Db29sZG93blRyaWdnZXJlZCxcbiAgb25GYXN0TW9kZU92ZXJhZ2VSZWplY3Rpb24sXG4gIG9uT3JnRmFzdE1vZGVDaGFuZ2VkLFxufSBmcm9tICdzcmMvdXRpbHMvZmFzdE1vZGUuanMnXG5pbXBvcnQgeyBmb3JtYXREdXJhdGlvbiB9IGZyb20gJ3NyYy91dGlscy9mb3JtYXQuanMnXG5pbXBvcnQgeyBnZXRJc1JlbW90ZU1vZGUgfSBmcm9tICcuLi8uLi9ib290c3RyYXAvc3RhdGUuanMnXG5cbmNvbnN0IENPT0xET1dOX1NUQVJURURfS0VZID0gJ2Zhc3QtbW9kZS1jb29sZG93bi1zdGFydGVkJ1xuY29uc3QgQ09PTERPV05fRVhQSVJFRF9LRVkgPSAnZmFzdC1tb2RlLWNvb2xkb3duLWV4cGlyZWQnXG5jb25zdCBPUkdfQ0hBTkdFRF9LRVkgPSAnZmFzdC1tb2RlLW9yZy1jaGFuZ2VkJ1xuY29uc3QgT1ZFUkFHRV9SRUpFQ1RFRF9LRVkgPSAnZmFzdC1tb2RlLW92ZXJhZ2UtcmVqZWN0ZWQnXG5cbmV4cG9ydCBmdW5jdGlvbiB1c2VGYXN0TW9kZU5vdGlmaWNhdGlvbigpOiB2b2lkIHtcbiAgY29uc3QgeyBhZGROb3RpZmljYXRpb24gfSA9IHVzZU5vdGlmaWNhdGlvbnMoKVxuICBjb25zdCBpc0Zhc3RNb2RlID0gdXNlQXBwU3RhdGUocyA9PiBzLmZhc3RNb2RlKVxuICBjb25zdCBzZXRBcHBTdGF0ZSA9IHVzZVNldEFwcFN0YXRlKClcblxuICAvLyBOb3RpZnkgd2hlbiBvcmcgZmFzdCBtb2RlIHN0YXR1cyBjaGFuZ2VzXG4gIHVzZUVmZmVjdCgoKSA9PiB7XG4gICAgaWYgKGdldElzUmVtb3RlTW9kZSgpKSByZXR1cm5cbiAgICBpZiAoIWlzRmFzdE1vZGVFbmFibGVkKCkpIHtcbiAgICAgIHJldHVyblxuICAgIH1cblxuICAgIHJldHVybiBvbk9yZ0Zhc3RNb2RlQ2hhbmdlZChvcmdFbmFibGVkID0+IHtcbiAgICAgIGlmIChvcmdFbmFibGVkKSB7XG4gICAgICAgIGFkZE5vdGlmaWNhdGlvbih7XG4gICAgICAgICAga2V5OiBPUkdfQ0hBTkdFRF9LRVksXG4gICAgICAgICAgY29sb3I6ICdmYXN0TW9kZScsXG4gICAgICAgICAgcHJpb3JpdHk6ICdpbW1lZGlhdGUnLFxuICAgICAgICAgIHRleHQ6ICdGYXN0IG1vZGUgaXMgbm93IGF2YWlsYWJsZSDCtyAvZmFzdCB0byB0dXJuIG9uJyxcbiAgICAgICAgfSlcbiAgICAgIH0gZWxzZSBpZiAoaXNGYXN0TW9kZSkge1xuICAgICAgICAvLyBPcmcgZGlzYWJsZWQgZmFzdCBtb2RlIOKAlCBwZXJtYW5lbnRseSB0dXJuIG9mZiBmYXN0IG1vZGVcbiAgICAgICAgc2V0QXBwU3RhdGUocHJldiA9PiAoeyAuLi5wcmV2LCBmYXN0TW9kZTogZmFsc2UgfSkpXG4gICAgICAgIGFkZE5vdGlmaWNhdGlvbih7XG4gICAgICAgICAga2V5OiBPUkdfQ0hBTkdFRF9LRVksXG4gICAgICAgICAgY29sb3I6ICd3YXJuaW5nJyxcbiAgICAgICAgICBwcmlvcml0eTogJ2ltbWVkaWF0ZScsXG4gICAgICAgICAgdGV4dDogJ0Zhc3QgbW9kZSBoYXMgYmVlbiBkaXNhYmxlZCBieSB5b3VyIG9yZ2FuaXphdGlvbicsXG4gICAgICAgIH0pXG4gICAgICB9XG4gICAgfSlcbiAgfSwgW2FkZE5vdGlmaWNhdGlvbiwgaXNGYXN0TW9kZSwgc2V0QXBwU3RhdGVdKVxuXG4gIC8vIE5vdGlmeSB3aGVuIGZhc3QgbW9kZSBpcyByZWplY3RlZCBkdWUgdG8gb3ZlcmFnZS9leHRyYSB1c2FnZSBpc3N1ZXNcbiAgdXNlRWZmZWN0KCgpID0+IHtcbiAgICBpZiAoZ2V0SXNSZW1vdGVNb2RlKCkpIHJldHVyblxuICAgIGlmICghaXNGYXN0TW9kZUVuYWJsZWQoKSkgcmV0dXJuXG5cbiAgICByZXR1cm4gb25GYXN0TW9kZU92ZXJhZ2VSZWplY3Rpb24obWVzc2FnZSA9PiB7XG4gICAgICBzZXRBcHBTdGF0ZShwcmV2ID0+ICh7IC4uLnByZXYsIGZhc3RNb2RlOiBmYWxzZSB9KSlcbiAgICAgIGFkZE5vdGlmaWNhdGlvbih7XG4gICAgICAgIGtleTogT1ZFUkFHRV9SRUpFQ1RFRF9LRVksXG4gICAgICAgIGNvbG9yOiAnd2FybmluZycsXG4gICAgICAgIHByaW9yaXR5OiAnaW1tZWRpYXRlJyxcbiAgICAgICAgdGV4dDogbWVzc2FnZSxcbiAgICAgIH0pXG4gICAgfSlcbiAgfSwgW2FkZE5vdGlmaWNhdGlvbiwgc2V0QXBwU3RhdGVdKVxuXG4gIHVzZUVmZmVjdCgoKSA9PiB7XG4gICAgaWYgKGdldElzUmVtb3RlTW9kZSgpKSByZXR1cm5cbiAgICBpZiAoIWlzRmFzdE1vZGUpIHtcbiAgICAgIHJldHVyblxuICAgIH1cblxuICAgIGNvbnN0IHVuc3ViVHJpZ2dlcmVkID0gb25Db29sZG93blRyaWdnZXJlZCgocmVzZXRBdCwgcmVhc29uKSA9PiB7XG4gICAgICBjb25zdCByZXNldEluID0gZm9ybWF0RHVyYXRpb24ocmVzZXRBdCAtIERhdGUubm93KCksIHtcbiAgICAgICAgaGlkZVRyYWlsaW5nWmVyb3M6IHRydWUsXG4gICAgICB9KVxuICAgICAgY29uc3QgbWVzc2FnZSA9IGdldENvb2xkb3duTWVzc2FnZShyZWFzb24sIHJlc2V0SW4pXG4gICAgICBhZGROb3RpZmljYXRpb24oe1xuICAgICAgICBrZXk6IENPT0xET1dOX1NUQVJURURfS0VZLFxuICAgICAgICBpbnZhbGlkYXRlczogW0NPT0xET1dOX0VYUElSRURfS0VZXSxcbiAgICAgICAgdGV4dDogbWVzc2FnZSxcbiAgICAgICAgY29sb3I6ICd3YXJuaW5nJyxcbiAgICAgICAgcHJpb3JpdHk6ICdpbW1lZGlhdGUnLFxuICAgICAgfSlcbiAgICB9KVxuICAgIGNvbnN0IHVuc3ViRXhwaXJlZCA9IG9uQ29vbGRvd25FeHBpcmVkKCgpID0+IHtcbiAgICAgIGFkZE5vdGlmaWNhdGlvbih7XG4gICAgICAgIGtleTogQ09PTERPV05fRVhQSVJFRF9LRVksXG4gICAgICAgIGludmFsaWRhdGVzOiBbQ09PTER Line truncated
+186
View File
@@ -0,0 +1,186 @@
import { c as _c } from "react/compiler-runtime";
import React, { useEffect, useRef } from 'react';
import { useNotifications } from 'src/context/notifications.js';
import { Text } from 'src/ink.js';
import type { MCPServerConnection } from 'src/services/mcp/types.js';
import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js';
import { detectIDEs, type IDEExtensionInstallationStatus, isJetBrainsIde, isSupportedTerminal } from 'src/utils/ide.js';
import { getIsRemoteMode } from '../../bootstrap/state.js';
import { useIdeConnectionStatus } from '../useIdeConnectionStatus.js';
import type { IDESelection } from '../useIdeSelection.js';
const MAX_IDE_HINT_SHOW_COUNT = 5;
type Props = {
ideInstallationStatus: IDEExtensionInstallationStatus | null;
ideSelection: IDESelection | undefined;
mcpClients: MCPServerConnection[];
};
export function useIDEStatusIndicator(t0) {
const $ = _c(26);
const {
ideSelection,
mcpClients,
ideInstallationStatus
} = t0;
const {
addNotification,
removeNotification
} = useNotifications();
const {
status: ideStatus,
ideName
} = useIdeConnectionStatus(mcpClients);
const hasShownHintRef = useRef(false);
let t1;
if ($[0] !== ideInstallationStatus) {
t1 = ideInstallationStatus ? isJetBrainsIde(ideInstallationStatus?.ideType) : false;
$[0] = ideInstallationStatus;
$[1] = t1;
} else {
t1 = $[1];
}
const isJetBrains = t1;
const showIDEInstallErrorOrJetBrainsInfo = ideInstallationStatus?.error || isJetBrains;
const shouldShowIdeSelection = ideStatus === "connected" && (ideSelection?.filePath || ideSelection?.text && ideSelection.lineCount > 0);
const shouldShowConnected = ideStatus === "connected" && !shouldShowIdeSelection;
const showIDEInstallError = showIDEInstallErrorOrJetBrainsInfo && !isJetBrains && !shouldShowConnected && !shouldShowIdeSelection;
const showJetBrainsInfo = showIDEInstallErrorOrJetBrainsInfo && isJetBrains && !shouldShowConnected && !shouldShowIdeSelection;
let t2;
let t3;
if ($[2] !== addNotification || $[3] !== ideStatus || $[4] !== removeNotification || $[5] !== showJetBrainsInfo) {
t2 = () => {
if (getIsRemoteMode()) {
return;
}
if (isSupportedTerminal() || ideStatus !== null || showJetBrainsInfo) {
removeNotification("ide-status-hint");
return;
}
if (hasShownHintRef.current || (getGlobalConfig().ideHintShownCount ?? 0) >= MAX_IDE_HINT_SHOW_COUNT) {
return;
}
const timeoutId = setTimeout(_temp2, 3000, hasShownHintRef, addNotification);
return () => clearTimeout(timeoutId);
};
t3 = [addNotification, removeNotification, ideStatus, showJetBrainsInfo];
$[2] = addNotification;
$[3] = ideStatus;
$[4] = removeNotification;
$[5] = showJetBrainsInfo;
$[6] = t2;
$[7] = t3;
} else {
t2 = $[6];
t3 = $[7];
}
useEffect(t2, t3);
let t4;
let t5;
if ($[8] !== addNotification || $[9] !== ideName || $[10] !== ideStatus || $[11] !== removeNotification || $[12] !== showIDEInstallError || $[13] !== showJetBrainsInfo) {
t4 = () => {
if (getIsRemoteMode()) {
return;
}
if (showIDEInstallError || showJetBrainsInfo || ideStatus !== "disconnected" || !ideName) {
removeNotification("ide-status-disconnected");
return;
}
addNotification({
key: "ide-status-disconnected",
text: `${ideName} disconnected`,
color: "error",
priority: "medium"
});
};
t5 = [addNotification, removeNotification, ideStatus, ideName, showIDEInstallError, showJetBrainsInfo];
$[8] = addNotification;
$[9] = ideName;
$[10] = ideStatus;
$[11] = removeNotification;
$[12] = showIDEInstallError;
$[13] = showJetBrainsInfo;
$[14] = t4;
$[15] = t5;
} else {
t4 = $[14];
t5 = $[15];
}
useEffect(t4, t5);
let t6;
let t7;
if ($[16] !== addNotification || $[17] !== removeNotification || $[18] !== showJetBrainsInfo) {
t6 = () => {
if (getIsRemoteMode()) {
return;
}
if (!showJetBrainsInfo) {
removeNotification("ide-status-jetbrains-disconnected");
return;
}
addNotification({
key: "ide-status-jetbrains-disconnected",
text: "IDE plugin not connected \xB7 /status for info",
priority: "medium"
});
};
t7 = [addNotification, removeNotification, showJetBrainsInfo];
$[16] = addNotification;
$[17] = removeNotification;
$[18] = showJetBrainsInfo;
$[19] = t6;
$[20] = t7;
} else {
t6 = $[19];
t7 = $[20];
}
useEffect(t6, t7);
let t8;
let t9;
if ($[21] !== addNotification || $[22] !== removeNotification || $[23] !== showIDEInstallError) {
t8 = () => {
if (getIsRemoteMode()) {
return;
}
if (!showIDEInstallError) {
removeNotification("ide-status-install-error");
return;
}
addNotification({
key: "ide-status-install-error",
text: "IDE extension install failed (see /status for info)",
color: "error",
priority: "medium"
});
};
t9 = [addNotification, removeNotification, showIDEInstallError];
$[21] = addNotification;
$[22] = removeNotification;
$[23] = showIDEInstallError;
$[24] = t8;
$[25] = t9;
} else {
t8 = $[24];
t9 = $[25];
}
useEffect(t8, t9);
}
function _temp2(hasShownHintRef_0, addNotification_0) {
detectIDEs(true).then(infos => {
const ideName_0 = infos[0]?.name;
if (ideName_0 && !hasShownHintRef_0.current) {
hasShownHintRef_0.current = true;
saveGlobalConfig(_temp);
addNotification_0({
key: "ide-status-hint",
jsx: <Text dimColor={true}>/ide for <Text color="ide">{ideName_0}</Text></Text>,
priority: "low"
});
}
});
}
function _temp(current) {
return {
...current,
ideHintShownCount: (current.ideHintShownCount ?? 0) + 1
};
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZUVmZmVjdCIsInVzZVJlZiIsInVzZU5vdGlmaWNhdGlvbnMiLCJUZXh0IiwiTUNQU2VydmVyQ29ubmVjdGlvbiIsImdldEdsb2JhbENvbmZpZyIsInNhdmVHbG9iYWxDb25maWciLCJkZXRlY3RJREVzIiwiSURFRXh0ZW5zaW9uSW5zdGFsbGF0aW9uU3RhdHVzIiwiaXNKZXRCcmFpbnNJZGUiLCJpc1N1cHBvcnRlZFRlcm1pbmFsIiwiZ2V0SXNSZW1vdGVNb2RlIiwidXNlSWRlQ29ubmVjdGlvblN0YXR1cyIsIklERVNlbGVjdGlvbiIsIk1BWF9JREVfSElOVF9TSE9XX0NPVU5UIiwiUHJvcHMiLCJpZGVJbnN0YWxsYXRpb25TdGF0dXMiLCJpZGVTZWxlY3Rpb24iLCJtY3BDbGllbnRzIiwidXNlSURFU3RhdHVzSW5kaWNhdG9yIiwidDAiLCIkIiwiX2MiLCJhZGROb3RpZmljYXRpb24iLCJyZW1vdmVOb3RpZmljYXRpb24iLCJzdGF0dXMiLCJpZGVTdGF0dXMiLCJpZGVOYW1lIiwiaGFzU2hvd25IaW50UmVmIiwidDEiLCJpZGVUeXBlIiwiaXNKZXRCcmFpbnMiLCJzaG93SURFSW5zdGFsbEVycm9yT3JKZXRCcmFpbnNJbmZvIiwiZXJyb3IiLCJzaG91bGRTaG93SWRlU2VsZWN0aW9uIiwiZmlsZVBhdGgiLCJ0ZXh0IiwibGluZUNvdW50Iiwic2hvdWxkU2hvd0Nvbm5lY3RlZCIsInNob3dJREVJbnN0YWxsRXJyb3IiLCJzaG93SmV0QnJhaW5zSW5mbyIsInQyIiwidDMiLCJjdXJyZW50IiwiaWRlSGludFNob3duQ291bnQiLCJ0aW1lb3V0SWQiLCJzZXRUaW1lb3V0IiwiX3RlbXAyIiwiY2xlYXJUaW1lb3V0IiwidDQiLCJ0NSIsImtleSIsImNvbG9yIiwicHJpb3JpdHkiLCJ0NiIsInQ3IiwidDgiLCJ0OSIsImhhc1Nob3duSGludFJlZl8wIiwiYWRkTm90aWZpY2F0aW9uXzAiLCJ0aGVuIiwiaW5mb3MiLCJpZGVOYW1lXzAiLCJuYW1lIiwiX3RlbXAiLCJqc3giXSwic291cmNlcyI6WyJ1c2VJREVTdGF0dXNJbmRpY2F0b3IudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBSZWFjdCwgeyB1c2VFZmZlY3QsIHVzZVJlZiB9IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgdXNlTm90aWZpY2F0aW9ucyB9IGZyb20gJ3NyYy9jb250ZXh0L25vdGlmaWNhdGlvbnMuanMnXG5pbXBvcnQgeyBUZXh0IH0gZnJvbSAnc3JjL2luay5qcydcbmltcG9ydCB0eXBlIHsgTUNQU2VydmVyQ29ubmVjdGlvbiB9IGZyb20gJ3NyYy9zZXJ2aWNlcy9tY3AvdHlwZXMuanMnXG5pbXBvcnQgeyBnZXRHbG9iYWxDb25maWcsIHNhdmVHbG9iYWxDb25maWcgfSBmcm9tICdzcmMvdXRpbHMvY29uZmlnLmpzJ1xuaW1wb3J0IHtcbiAgZGV0ZWN0SURFcyxcbiAgdHlwZSBJREVFeHRlbnNpb25JbnN0YWxsYXRpb25TdGF0dXMsXG4gIGlzSmV0QnJhaW5zSWRlLFxuICBpc1N1cHBvcnRlZFRlcm1pbmFsLFxufSBmcm9tICdzcmMvdXRpbHMvaWRlLmpzJ1xuaW1wb3J0IHsgZ2V0SXNSZW1vdGVNb2RlIH0gZnJvbSAnLi4vLi4vYm9vdHN0cmFwL3N0YXRlLmpzJ1xuaW1wb3J0IHsgdXNlSWRlQ29ubmVjdGlvblN0YXR1cyB9IGZyb20gJy4uL3VzZUlkZUNvbm5lY3Rpb25TdGF0dXMuanMnXG5pbXBvcnQgdHlwZSB7IElERVNlbGVjdGlvbiB9IGZyb20gJy4uL3VzZUlkZVNlbGVjdGlvbi5qcydcblxuY29uc3QgTUFYX0lERV9ISU5UX1NIT1dfQ09VTlQgPSA1XG5cbnR5cGUgUHJvcHMgPSB7XG4gIGlkZUluc3RhbGxhdGlvblN0YXR1czogSURFRXh0ZW5zaW9uSW5zdGFsbGF0aW9uU3RhdHVzIHwgbnVsbFxuICBpZGVTZWxlY3Rpb246IElERVNlbGVjdGlvbiB8IHVuZGVmaW5lZFxuICBtY3BDbGllbnRzOiBNQ1BTZXJ2ZXJDb25uZWN0aW9uW11cbn1cblxuZXhwb3J0IGZ1bmN0aW9uIHVzZUlERVN0YXR1c0luZGljYXRvcih7XG4gIGlkZVNlbGVjdGlvbixcbiAgbWNwQ2xpZW50cyxcbiAgaWRlSW5zdGFsbGF0aW9uU3RhdHVzLFxufTogUHJvcHMpOiB2b2lkIHtcbiAgY29uc3QgeyBhZGROb3RpZmljYXRpb24sIHJlbW92ZU5vdGlmaWNhdGlvbiB9ID0gdXNlTm90aWZpY2F0aW9ucygpXG4gIGNvbnN0IHsgc3RhdHVzOiBpZGVTdGF0dXMsIGlkZU5hbWUgfSA9IHVzZUlkZUNvbm5lY3Rpb25TdGF0dXMobWNwQ2xpZW50cylcbiAgY29uc3QgaGFzU2hvd25IaW50UmVmID0gdXNlUmVmKGZhbHNlKVxuXG4gIGNvbnN0IGlzSmV0QnJhaW5zID0gaWRlSW5zdGFsbGF0aW9uU3RhdHVzXG4gICAgPyBpc0pldEJyYWluc0lkZShpZGVJbnN0YWxsYXRpb25TdGF0dXM/LmlkZVR5cGUpXG4gICAgOiBmYWxzZVxuICBjb25zdCBzaG93SURFSW5zdGFsbEVycm9yT3JKZXRCcmFpbnNJbmZvID1cbiAgICBpZGVJbnN0YWxsYXRpb25TdGF0dXM/LmVycm9yIHx8IGlzSmV0QnJhaW5zXG5cbiAgY29uc3Qgc2hvdWxkU2hvd0lkZVNlbGVjdGlvbiA9XG4gICAgaWRlU3RhdHVzID09PSAnY29ubmVjdGVkJyAmJlxuICAgIChpZGVTZWxlY3Rpb24/LmZpbGVQYXRoIHx8XG4gICAgICAoaWRlU2VsZWN0aW9uPy50ZXh0ICYmIGlkZVNlbGVjdGlvbi5saW5lQ291bnQgPiAwKSlcblxuICAvLyBPbmx5IHNob3cgdGhlIGNvbm5lY3RlZCBpZiBub3Qgc2hvd2luZyBjb250ZXh0XG4gIGNvbnN0IHNob3VsZFNob3dDb25uZWN0ZWQgPVxuICAgIGlkZVN0YXR1cyA9PT0gJ2Nvbm5lY3RlZCcgJiYgIXNob3VsZFNob3dJZGVTZWxlY3Rpb25cblxuICBjb25zdCBzaG93SURFSW5zdGFsbEVycm9yID1cbiAgICBzaG93SURFSW5zdGFsbEVycm9yT3JKZXRCcmFpbnNJbmZvICYmXG4gICAgIWlzSmV0QnJhaW5zICYmXG4gICAgIXNob3VsZFNob3dDb25uZWN0ZWQgJiZcbiAgICAhc2hvdWxkU2hvd0lkZVNlbGVjdGlvblxuXG4gIGNvbnN0IHNob3dKZXRCcmFpbnNJbmZvID1cbiAgICBzaG93SURFSW5zdGFsbEVycm9yT3JKZXRCcmFpbnNJbmZvICYmXG4gICAgaXNKZXRCcmFpbnMgJiZcbiAgICAhc2hvdWxkU2hvd0Nvbm5lY3RlZCAmJlxuICAgICFzaG91bGRTaG93SWRlU2VsZWN0aW9uXG5cbiAgLy8gU2hvdyB0aGUgL2lkZSBjb21tYW5kIGhpbnQgaWYgcnVubmluZyBmcm9tIGFuIGV4dGVybmFsIHRlcm1pbmFsIGFuZCBmb3VuZCBydW5uaW5nIElERShzKVxuICAvLyBEZWxheSBzaG93aW5nIGhpbnQgdG8gYXZvaWQgYnJpZWYgZmxhc2ggZHVyaW5nIGF1dG8tY29ubmVjdCBzdGFydHVwXG4gIHVzZUVmZmVjdCgoKSA9PiB7XG4gICAgaWYgKGdldElzUmVtb3RlTW9kZSgpKSByZXR1cm5cbiAgICBpZiAoaXNTdXBwb3J0ZWRUZXJtaW5hbCgpIHx8IGlkZVN0YXR1cyAhPT0gbnVsbCB8fCBzaG93SmV0QnJhaW5zSW5mbykge1xuICAgICAgcmVtb3ZlTm90aWZpY2F0aW9uKCdpZGUtc3RhdHVzLWhpbnQnKVxuICAgICAgcmV0dXJuXG4gICAgfVxuICAgIC8vIFdhaXQgYSBiaXQgdG8gbGV0IGF1dG8tY29ubmVjdCBoYXBwZW4gZmlyc3QsIGF2b2lkaW5nIGJyaWVmIGhpbnQgZmxhc2hcbiAgICBpZiAoXG4gICAgICBoYXNTaG93bkhpbnRSZWYuY3VycmVudCB8fFxuICAgICAgKGdldEdsb2JhbENvbmZpZygpLmlkZUhpbnRTaG93bkNvdW50ID8/IDApID49IE1BWF9JREVfSElOVF9TSE9XX0NPVU5UXG4gICAgKSB7XG4gICAgICByZXR1cm5cbiAgICB9XG4gICAgY29uc3QgdGltZW91dElkID0gc2V0VGltZW91dChcbiAgICAgIChoYXNTaG93bkhpbnRSZWYsIGFkZE5vdGlmaWNhdGlvbikgPT4ge1xuICAgICAgICB2b2lkIGRldGVjdElERXModHJ1ZSkudGhlbihpbmZvcyA9PiB7XG4gICAgICAgICAgY29uc3QgaWRlTmFtZSA9IGluZm9zWzBdPy5uYW1lXG4gICAgICA Line truncated
+26
View File
@@ -0,0 +1,26 @@
import { checkInstall } from 'src/utils/nativeInstaller/index.js';
import { useStartupNotification } from './useStartupNotification.js';
export function useInstallMessages() {
useStartupNotification(_temp2);
}
async function _temp2() {
const messages = await checkInstall();
return messages.map(_temp);
}
function _temp(message, index) {
let priority = "low";
if (message.type === "error" || message.userActionRequired) {
priority = "high";
} else {
if (message.type === "path" || message.type === "alias") {
priority = "medium";
}
}
return {
key: `install-message-${index}-${message.type}`,
text: message.message,
priority,
color: message.type === "error" ? "error" : "warning"
};
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJjaGVja0luc3RhbGwiLCJ1c2VTdGFydHVwTm90aWZpY2F0aW9uIiwidXNlSW5zdGFsbE1lc3NhZ2VzIiwiX3RlbXAyIiwibWVzc2FnZXMiLCJtYXAiLCJfdGVtcCIsIm1lc3NhZ2UiLCJpbmRleCIsInByaW9yaXR5IiwidHlwZSIsInVzZXJBY3Rpb25SZXF1aXJlZCIsImtleSIsInRleHQiLCJjb2xvciJdLCJzb3VyY2VzIjpbInVzZUluc3RhbGxNZXNzYWdlcy50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgY2hlY2tJbnN0YWxsIH0gZnJvbSAnc3JjL3V0aWxzL25hdGl2ZUluc3RhbGxlci9pbmRleC5qcydcbmltcG9ydCB7IHVzZVN0YXJ0dXBOb3RpZmljYXRpb24gfSBmcm9tICcuL3VzZVN0YXJ0dXBOb3RpZmljYXRpb24uanMnXG5cbmV4cG9ydCBmdW5jdGlvbiB1c2VJbnN0YWxsTWVzc2FnZXMoKTogdm9pZCB7XG4gIHVzZVN0YXJ0dXBOb3RpZmljYXRpb24oYXN5bmMgKCkgPT4ge1xuICAgIGNvbnN0IG1lc3NhZ2VzID0gYXdhaXQgY2hlY2tJbnN0YWxsKClcbiAgICByZXR1cm4gbWVzc2FnZXMubWFwKChtZXNzYWdlLCBpbmRleCkgPT4ge1xuICAgICAgbGV0IHByaW9yaXR5OiAnbG93JyB8ICdtZWRpdW0nIHwgJ2hpZ2gnIHwgJ2ltbWVkaWF0ZScgPSAnbG93J1xuICAgICAgaWYgKG1lc3NhZ2UudHlwZSA9PT0gJ2Vycm9yJyB8fCBtZXNzYWdlLnVzZXJBY3Rpb25SZXF1aXJlZCkge1xuICAgICAgICBwcmlvcml0eSA9ICdoaWdoJ1xuICAgICAgfSBlbHNlIGlmIChtZXNzYWdlLnR5cGUgPT09ICdwYXRoJyB8fCBtZXNzYWdlLnR5cGUgPT09ICdhbGlhcycpIHtcbiAgICAgICAgcHJpb3JpdHkgPSAnbWVkaXVtJ1xuICAgICAgfVxuICAgICAgcmV0dXJuIHtcbiAgICAgICAga2V5OiBgaW5zdGFsbC1tZXNzYWdlLSR7aW5kZXh9LSR7bWVzc2FnZS50eXBlfWAsXG4gICAgICAgIHRleHQ6IG1lc3NhZ2UubWVzc2FnZSxcbiAgICAgICAgcHJpb3JpdHksXG4gICAgICAgIGNvbG9yOiBtZXNzYWdlLnR5cGUgPT09ICdlcnJvcicgPyAnZXJyb3InIDogJ3dhcm5pbmcnLFxuICAgICAgfVxuICAgIH0pXG4gIH0pXG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLFNBQVNBLFlBQVksUUFBUSxvQ0FBb0M7QUFDakUsU0FBU0Msc0JBQXNCLFFBQVEsNkJBQTZCO0FBRXBFLE9BQU8sU0FBQUMsbUJBQUE7RUFDTEQsc0JBQXNCLENBQUNFLE1BZ0J0QixDQUFDO0FBQUE7QUFqQkcsZUFBQUEsT0FBQTtFQUVILE1BQUFDLFFBQUEsR0FBaUIsTUFBTUosWUFBWSxDQUFDLENBQUM7RUFBQSxPQUM5QkksUUFBUSxDQUFBQyxHQUFJLENBQUNDLEtBYW5CLENBQUM7QUFBQTtBQWhCQyxTQUFBQSxNQUFBQyxPQUFBLEVBQUFDLEtBQUE7RUFJRCxJQUFBQyxRQUFBLEdBQXdELEtBQUs7RUFDN0QsSUFBSUYsT0FBTyxDQUFBRyxJQUFLLEtBQUssT0FBcUMsSUFBMUJILE9BQU8sQ0FBQUksa0JBQW1CO0lBQ3hERixRQUFBLENBQUFBLENBQUEsQ0FBV0EsTUFBTTtFQUFUO0lBQ0gsSUFBSUYsT0FBTyxDQUFBRyxJQUFLLEtBQUssTUFBa0MsSUFBeEJILE9BQU8sQ0FBQUcsSUFBSyxLQUFLLE9BQU87TUFDNURELFFBQUEsQ0FBQUEsQ0FBQSxDQUFXQSxRQUFRO0lBQVg7RUFDVDtFQUFBLE9BQ007SUFBQUcsR0FBQSxFQUNBLG1CQUFtQkosS0FBSyxJQUFJRCxPQUFPLENBQUFHLElBQUssRUFBRTtJQUFBRyxJQUFBLEVBQ3pDTixPQUFPLENBQUFBLE9BQVE7SUFBQUUsUUFBQTtJQUFBSyxLQUFBLEVBRWRQLE9BQU8sQ0FBQUcsSUFBSyxLQUFLLE9BQTZCLEdBQTlDLE9BQThDLEdBQTlDO0VBQ1QsQ0FBQztBQUFBIiwiaWdub3JlTGlzdCI6W119
@@ -0,0 +1,143 @@
import { c as _c } from "react/compiler-runtime";
import * as React from 'react';
import { useInterval } from 'usehooks-ts';
import { getIsRemoteMode, getIsScrollDraining } from '../../bootstrap/state.js';
import { useNotifications } from '../../context/notifications.js';
import { Text } from '../../ink.js';
import { getInitializationStatus, getLspServerManager } from '../../services/lsp/manager.js';
import { useSetAppState } from '../../state/AppState.js';
import { logForDebugging } from '../../utils/debug.js';
import { isEnvTruthy } from '../../utils/envUtils.js';
const LSP_POLL_INTERVAL_MS = 5000;
/**
* Hook that polls LSP status and shows a notification when:
* 1. Manager initialization fails
* 2. Any LSP server enters an error state
*
* Also adds errors to appState.plugins.errors for /doctor display.
*
* Only active when ENABLE_LSP_TOOL is set.
*/
export function useLspInitializationNotification() {
const $ = _c(10);
const {
addNotification
} = useNotifications();
const setAppState = useSetAppState();
const [shouldPoll, setShouldPoll] = React.useState(_temp);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = new Set();
$[0] = t0;
} else {
t0 = $[0];
}
const notifiedErrorsRef = React.useRef(t0);
let t1;
if ($[1] !== addNotification || $[2] !== setAppState) {
t1 = (source, errorMessage) => {
const errorKey = `${source}:${errorMessage}`;
if (notifiedErrorsRef.current.has(errorKey)) {
return;
}
notifiedErrorsRef.current.add(errorKey);
logForDebugging(`LSP error: ${source} - ${errorMessage}`);
setAppState(prev => {
const existingKeys = new Set(prev.plugins.errors.map(_temp2));
const stateErrorKey = `generic-error:${source}:${errorMessage}`;
if (existingKeys.has(stateErrorKey)) {
return prev;
}
return {
...prev,
plugins: {
...prev.plugins,
errors: [...prev.plugins.errors, {
type: "generic-error" as const,
source,
error: errorMessage
}]
}
};
});
const displayName = source.startsWith("plugin:") ? source.split(":")[1] ?? source : source;
addNotification({
key: `lsp-error-${source}`,
jsx: <><Text color="error">LSP for {displayName} failed</Text><Text dimColor={true}> · /plugin for details</Text></>,
priority: "medium",
timeoutMs: 8000
});
};
$[1] = addNotification;
$[2] = setAppState;
$[3] = t1;
} else {
t1 = $[3];
}
const addError = t1;
let t2;
if ($[4] !== addError) {
t2 = () => {
if (getIsRemoteMode()) {
return;
}
if (getIsScrollDraining()) {
return;
}
const status = getInitializationStatus();
if (status.status === "failed") {
addError("lsp-manager", status.error.message);
setShouldPoll(false);
return;
}
if (status.status === "pending" || status.status === "not-started") {
return;
}
const manager = getLspServerManager();
if (manager) {
const servers = manager.getAllServers();
for (const [serverName, server] of servers) {
if (server.state === "error" && server.lastError) {
addError(serverName, server.lastError.message);
}
}
}
};
$[4] = addError;
$[5] = t2;
} else {
t2 = $[5];
}
const poll = t2;
useInterval(poll, shouldPoll ? LSP_POLL_INTERVAL_MS : null);
let t3;
let t4;
if ($[6] !== poll || $[7] !== shouldPoll) {
t3 = () => {
if (getIsRemoteMode() || !shouldPoll) {
return;
}
poll();
};
t4 = [poll, shouldPoll];
$[6] = poll;
$[7] = shouldPoll;
$[8] = t3;
$[9] = t4;
} else {
t3 = $[8];
t4 = $[9];
}
React.useEffect(t3, t4);
}
function _temp2(e) {
if (e.type === "generic-error") {
return `generic-error:${e.source}:${e.error}`;
}
return `${e.type}:${e.source}`;
}
function _temp() {
return isEnvTruthy("true");
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZUludGVydmFsIiwiZ2V0SXNSZW1vdGVNb2RlIiwiZ2V0SXNTY3JvbGxEcmFpbmluZyIsInVzZU5vdGlmaWNhdGlvbnMiLCJUZXh0IiwiZ2V0SW5pdGlhbGl6YXRpb25TdGF0dXMiLCJnZXRMc3BTZXJ2ZXJNYW5hZ2VyIiwidXNlU2V0QXBwU3RhdGUiLCJsb2dGb3JEZWJ1Z2dpbmciLCJpc0VudlRydXRoeSIsIkxTUF9QT0xMX0lOVEVSVkFMX01TIiwidXNlTHNwSW5pdGlhbGl6YXRpb25Ob3RpZmljYXRpb24iLCIkIiwiX2MiLCJhZGROb3RpZmljYXRpb24iLCJzZXRBcHBTdGF0ZSIsInNob3VsZFBvbGwiLCJzZXRTaG91bGRQb2xsIiwidXNlU3RhdGUiLCJfdGVtcCIsInQwIiwiU3ltYm9sIiwiZm9yIiwiU2V0Iiwibm90aWZpZWRFcnJvcnNSZWYiLCJ1c2VSZWYiLCJ0MSIsInNvdXJjZSIsImVycm9yTWVzc2FnZSIsImVycm9yS2V5IiwiY3VycmVudCIsImhhcyIsImFkZCIsInByZXYiLCJleGlzdGluZ0tleXMiLCJwbHVnaW5zIiwiZXJyb3JzIiwibWFwIiwiX3RlbXAyIiwic3RhdGVFcnJvcktleSIsInR5cGUiLCJjb25zdCIsImVycm9yIiwiZGlzcGxheU5hbWUiLCJzdGFydHNXaXRoIiwic3BsaXQiLCJrZXkiLCJqc3giLCJwcmlvcml0eSIsInRpbWVvdXRNcyIsImFkZEVycm9yIiwidDIiLCJzdGF0dXMiLCJtZXNzYWdlIiwibWFuYWdlciIsInNlcnZlcnMiLCJnZXRBbGxTZXJ2ZXJzIiwic2VydmVyTmFtZSIsInNlcnZlciIsInN0YXRlIiwibGFzdEVycm9yIiwicG9sbCIsInQzIiwidDQiLCJ1c2VFZmZlY3QiLCJlIl0sInNvdXJjZXMiOlsidXNlTHNwSW5pdGlhbGl6YXRpb25Ob3RpZmljYXRpb24udHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCAqIGFzIFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgdXNlSW50ZXJ2YWwgfSBmcm9tICd1c2Vob29rcy10cydcbmltcG9ydCB7IGdldElzUmVtb3RlTW9kZSwgZ2V0SXNTY3JvbGxEcmFpbmluZyB9IGZyb20gJy4uLy4uL2Jvb3RzdHJhcC9zdGF0ZS5qcydcbmltcG9ydCB7IHVzZU5vdGlmaWNhdGlvbnMgfSBmcm9tICcuLi8uLi9jb250ZXh0L25vdGlmaWNhdGlvbnMuanMnXG5pbXBvcnQgeyBUZXh0IH0gZnJvbSAnLi4vLi4vaW5rLmpzJ1xuaW1wb3J0IHtcbiAgZ2V0SW5pdGlhbGl6YXRpb25TdGF0dXMsXG4gIGdldExzcFNlcnZlck1hbmFnZXIsXG59IGZyb20gJy4uLy4uL3NlcnZpY2VzL2xzcC9tYW5hZ2VyLmpzJ1xuaW1wb3J0IHsgdXNlU2V0QXBwU3RhdGUgfSBmcm9tICcuLi8uLi9zdGF0ZS9BcHBTdGF0ZS5qcydcbmltcG9ydCB7IGxvZ0ZvckRlYnVnZ2luZyB9IGZyb20gJy4uLy4uL3V0aWxzL2RlYnVnLmpzJ1xuaW1wb3J0IHsgaXNFbnZUcnV0aHkgfSBmcm9tICcuLi8uLi91dGlscy9lbnZVdGlscy5qcydcblxuY29uc3QgTFNQX1BPTExfSU5URVJWQUxfTVMgPSA1MDAwXG5cbi8qKlxuICogSG9vayB0aGF0IHBvbGxzIExTUCBzdGF0dXMgYW5kIHNob3dzIGEgbm90aWZpY2F0aW9uIHdoZW46XG4gKiAxLiBNYW5hZ2VyIGluaXRpYWxpemF0aW9uIGZhaWxzXG4gKiAyLiBBbnkgTFNQIHNlcnZlciBlbnRlcnMgYW4gZXJyb3Igc3RhdGVcbiAqXG4gKiBBbHNvIGFkZHMgZXJyb3JzIHRvIGFwcFN0YXRlLnBsdWdpbnMuZXJyb3JzIGZvciAvZG9jdG9yIGRpc3BsYXkuXG4gKlxuICogT25seSBhY3RpdmUgd2hlbiBFTkFCTEVfTFNQX1RPT0wgaXMgc2V0LlxuICovXG5leHBvcnQgZnVuY3Rpb24gdXNlTHNwSW5pdGlhbGl6YXRpb25Ob3RpZmljYXRpb24oKTogdm9pZCB7XG4gIGNvbnN0IHsgYWRkTm90aWZpY2F0aW9uIH0gPSB1c2VOb3RpZmljYXRpb25zKClcbiAgY29uc3Qgc2V0QXBwU3RhdGUgPSB1c2VTZXRBcHBTdGF0ZSgpXG4gIC8vIExhenkgaW5pdGlhbGl6ZXIg4oCUIGVhZ2VyIGZvcm0gcmUtZXZhbHVhdGVzIGlzRW52VHJ1dGh5IG9uIGV2ZXJ5IFJFUExcbiAgLy8gcmVuZGVyICh0aGUgYXJnIGV4cHJlc3Npb24gcnVucyBldmVuIHRob3VnaCB1c2VTdGF0ZSBpZ25vcmVzIGl0IGFmdGVyXG4gIC8vIG1vdW50KS4gU2hvd2VkIHVwIGFzIDcuMnMgaXNFbnZUcnV0aHkgc2VsZi10aW1lIGR1cmluZyBQYWdlVXAgc3BhbVxuICAvLyBhZnRlciAjMjQ0OTggc3dhcHBlZCBjaGVhcCAhIXByb2Nlc3MuZW52LlggZm9yIGlzRW52VHJ1dGh5KCkuXG4gIGNvbnN0IFtzaG91bGRQb2xsLCBzZXRTaG91bGRQb2xsXSA9IFJlYWN0LnVzZVN0YXRlKCgpID0+XG4gICAgaXNFbnZUcnV0aHkoXCJ0cnVlXCIpLFxuICApXG4gIC8vIFRyYWNrIHdoaWNoIGVycm9ycyB3ZSd2ZSBhbHJlYWR5IG5vdGlmaWVkIGFib3V0IHRvIGF2b2lkIGR1cGxpY2F0ZXNcbiAgY29uc3Qgbm90aWZpZWRFcnJvcnNSZWYgPSBSZWFjdC51c2VSZWY8U2V0PHN0cmluZz4+KG5ldyBTZXQoKSlcblxuICBjb25zdCBhZGRFcnJvciA9IFJlYWN0LnVzZUNhbGxiYWNrKFxuICAgIChzb3VyY2U6IHN0cmluZywgZXJyb3JNZXNzYWdlOiBzdHJpbmcpID0+IHtcbiAgICAgIGNvbnN0IGVycm9yS2V5ID0gYCR7c291cmNlfToke2Vycm9yTWVzc2FnZX1gXG4gICAgICBpZiAobm90aWZpZWRFcnJvcnNSZWYuY3VycmVudC5oYXMoZXJyb3JLZXkpKSB7XG4gICAgICAgIHJldHVybiAvLyBBbHJlYWR5IG5vdGlmaWVkXG4gICAgICB9XG4gICAgICBub3RpZmllZEVycm9yc1JlZi5jdXJyZW50LmFkZChlcnJvcktleSlcblxuICAgICAgbG9nRm9yRGVidWdnaW5nKGBMU1AgZXJyb3I6ICR7c291cmNlfSAtICR7ZXJyb3JNZXNzYWdlfWApXG5cbiAgICAgIC8vIEFkZCBlcnJvciB0byBhcHBTdGF0ZS5wbHVnaW5zLmVycm9yc1xuICAgICAgc2V0QXBwU3RhdGUocHJldiA9PiB7XG4gICAgICAgIC8vIENoZWNrIGlmIHRoaXMgZXJyb3IgYWxyZWFkeSBleGlzdHMgdG8gYXZvaWQgZHVwbGljYXRlc1xuICAgICAgICBjb25zdCBleGlzdGluZ0tleXMgPSBuZXcgU2V0KFxuICAgICAgICAgIHByZXYucGx1Z2lucy5lcnJvcnMubWFwKGUgPT4ge1xuICAgICAgICAgICAgaWYgKGUudHlwZSA9PT0gJ2dlbmVyaWMtZXJyb3InKSB7XG4gICAgICAgICAgICAgIHJldHVybiBgZ2VuZXJpYy1lcnJvcjoke2Uuc291cmNlfToke2UuZXJyb3J9YFxuICAgICAgICAgICAgfVxuICAgICAgICAgICAgcmV0dXJuIGAke2UudHlwZX06JHtlLnNvdXJjZX1gXG4gICAgICAgICAgfSksXG4gICAgICAgIClcblxuICAgICAgICBjb25zdCBzdGF0ZUVycm9yS2V5ID0gYGdlbmVyaWMtZXJyb3I6JHtzb3VyY2V9OiR7ZXJyb3JNZXNzYWdlfWBcbiAgICAgICAgaWYgKGV4aXN0aW5nS2V5cy5oYXMoc3RhdGVFcnJvcktleSkpIHtcbiAgICAgICAgICByZXR1cm4gcHJldlxuICAgICAgICB9XG5cbiAgICAgICAgcmV0dXJuIHtcbiAgICAgICAgICAuLi5wcmV2LFxuICAgICAgICAgIHBsdWdpbnM6IHtcbiAgICAgICAgICAgIC4uLnByZXYucGx1Z2lucyxcbiAgICAgICAgICAgIGVycm9yczogW1xuICAgICAgICAgICAgICAuLi5wcmV2LnBsdWdpbnMuZXJyb3JzLFxuICAgICAgICAgICAgICB7XG4gICAgICAgICAgICAgICAgdHlwZTogJ2dlbmVyaWMtZXJyb3InIGFzIGNvbnN0LFxuICAgICAgICAgICAgICAgIHNvdXJjZSxcbiAgICAgICAgICAgICAgICBlcnJvcjogZXJyb3JNZXNzYWdlLFxuICAgICAgICAgICAgICB9LFxuICAgICAgICAgICAgXSxcbiAgICAgICAgICB9LFxuICAgICAgICB9XG4gICAgICB9KVxuXG4gICAgICAvLyBTaG93IG5vdGlmaWNhdGlvbiAtIGV4dHJhY3QgcGx1Z2l Line truncated
@@ -0,0 +1,88 @@
import { c as _c } from "react/compiler-runtime";
import * as React from 'react';
import { useEffect } from 'react';
import { useNotifications } from 'src/context/notifications.js';
import { getIsRemoteMode } from '../../bootstrap/state.js';
import { Text } from '../../ink.js';
import { hasClaudeAiMcpEverConnected } from '../../services/mcp/claudeai.js';
import type { MCPServerConnection } from '../../services/mcp/types.js';
type Props = {
mcpClients?: MCPServerConnection[];
};
const EMPTY_MCP_CLIENTS: MCPServerConnection[] = [];
export function useMcpConnectivityStatus(t0) {
const $ = _c(4);
const {
mcpClients: t1
} = t0;
const mcpClients = t1 === undefined ? EMPTY_MCP_CLIENTS : t1;
const {
addNotification
} = useNotifications();
let t2;
let t3;
if ($[0] !== addNotification || $[1] !== mcpClients) {
t2 = () => {
if (getIsRemoteMode()) {
return;
}
const failedLocalClients = mcpClients.filter(_temp);
const failedClaudeAiClients = mcpClients.filter(_temp2);
const needsAuthLocalServers = mcpClients.filter(_temp3);
const needsAuthClaudeAiServers = mcpClients.filter(_temp4);
if (failedLocalClients.length === 0 && failedClaudeAiClients.length === 0 && needsAuthLocalServers.length === 0 && needsAuthClaudeAiServers.length === 0) {
return;
}
if (failedLocalClients.length > 0) {
addNotification({
key: "mcp-failed",
jsx: <><Text color="error">{failedLocalClients.length} MCP{" "}{failedLocalClients.length === 1 ? "server" : "servers"} failed</Text><Text dimColor={true}> · /mcp</Text></>,
priority: "medium"
});
}
if (failedClaudeAiClients.length > 0) {
addNotification({
key: "mcp-claudeai-failed",
jsx: <><Text color="error">{failedClaudeAiClients.length} claude.ai{" "}{failedClaudeAiClients.length === 1 ? "connector" : "connectors"}{" "}unavailable</Text><Text dimColor={true}> · /mcp</Text></>,
priority: "medium"
});
}
if (needsAuthLocalServers.length > 0) {
addNotification({
key: "mcp-needs-auth",
jsx: <><Text color="warning">{needsAuthLocalServers.length} MCP{" "}{needsAuthLocalServers.length === 1 ? "server needs" : "servers need"}{" "}auth</Text><Text dimColor={true}> · /mcp</Text></>,
priority: "medium"
});
}
if (needsAuthClaudeAiServers.length > 0) {
addNotification({
key: "mcp-claudeai-needs-auth",
jsx: <><Text color="warning">{needsAuthClaudeAiServers.length} claude.ai{" "}{needsAuthClaudeAiServers.length === 1 ? "connector needs" : "connectors need"}{" "}auth</Text><Text dimColor={true}> · /mcp</Text></>,
priority: "medium"
});
}
};
t3 = [addNotification, mcpClients];
$[0] = addNotification;
$[1] = mcpClients;
$[2] = t2;
$[3] = t3;
} else {
t2 = $[2];
t3 = $[3];
}
useEffect(t2, t3);
}
function _temp4(client_2) {
return client_2.type === "needs-auth" && client_2.config.type === "claudeai-proxy" && hasClaudeAiMcpEverConnected(client_2.name);
}
function _temp3(client_1) {
return client_1.type === "needs-auth" && client_1.config.type !== "claudeai-proxy";
}
function _temp2(client_0) {
return client_0.type === "failed" && client_0.config.type === "claudeai-proxy" && hasClaudeAiMcpEverConnected(client_0.name);
}
function _temp(client) {
return client.type === "failed" && client.config.type !== "sse-ide" && client.config.type !== "ws-ide" && client.config.type !== "claudeai-proxy";
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZUVmZmVjdCIsInVzZU5vdGlmaWNhdGlvbnMiLCJnZXRJc1JlbW90ZU1vZGUiLCJUZXh0IiwiaGFzQ2xhdWRlQWlNY3BFdmVyQ29ubmVjdGVkIiwiTUNQU2VydmVyQ29ubmVjdGlvbiIsIlByb3BzIiwibWNwQ2xpZW50cyIsIkVNUFRZX01DUF9DTElFTlRTIiwidXNlTWNwQ29ubmVjdGl2aXR5U3RhdHVzIiwidDAiLCIkIiwiX2MiLCJ0MSIsInVuZGVmaW5lZCIsImFkZE5vdGlmaWNhdGlvbiIsInQyIiwidDMiLCJmYWlsZWRMb2NhbENsaWVudHMiLCJmaWx0ZXIiLCJfdGVtcCIsImZhaWxlZENsYXVkZUFpQ2xpZW50cyIsIl90ZW1wMiIsIm5lZWRzQXV0aExvY2FsU2VydmVycyIsIl90ZW1wMyIsIm5lZWRzQXV0aENsYXVkZUFpU2VydmVycyIsIl90ZW1wNCIsImxlbmd0aCIsImtleSIsImpzeCIsInByaW9yaXR5IiwiY2xpZW50XzIiLCJjbGllbnQiLCJ0eXBlIiwiY29uZmlnIiwibmFtZSIsImNsaWVudF8xIiwiY2xpZW50XzAiXSwic291cmNlcyI6WyJ1c2VNY3BDb25uZWN0aXZpdHlTdGF0dXMudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCAqIGFzIFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgdXNlRWZmZWN0IH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgeyB1c2VOb3RpZmljYXRpb25zIH0gZnJvbSAnc3JjL2NvbnRleHQvbm90aWZpY2F0aW9ucy5qcydcbmltcG9ydCB7IGdldElzUmVtb3RlTW9kZSB9IGZyb20gJy4uLy4uL2Jvb3RzdHJhcC9zdGF0ZS5qcydcbmltcG9ydCB7IFRleHQgfSBmcm9tICcuLi8uLi9pbmsuanMnXG5pbXBvcnQgeyBoYXNDbGF1ZGVBaU1jcEV2ZXJDb25uZWN0ZWQgfSBmcm9tICcuLi8uLi9zZXJ2aWNlcy9tY3AvY2xhdWRlYWkuanMnXG5pbXBvcnQgdHlwZSB7IE1DUFNlcnZlckNvbm5lY3Rpb24gfSBmcm9tICcuLi8uLi9zZXJ2aWNlcy9tY3AvdHlwZXMuanMnXG5cbnR5cGUgUHJvcHMgPSB7XG4gIG1jcENsaWVudHM/OiBNQ1BTZXJ2ZXJDb25uZWN0aW9uW11cbn1cblxuY29uc3QgRU1QVFlfTUNQX0NMSUVOVFM6IE1DUFNlcnZlckNvbm5lY3Rpb25bXSA9IFtdXG5cbmV4cG9ydCBmdW5jdGlvbiB1c2VNY3BDb25uZWN0aXZpdHlTdGF0dXMoe1xuICBtY3BDbGllbnRzID0gRU1QVFlfTUNQX0NMSUVOVFMsXG59OiBQcm9wcyk6IHZvaWQge1xuICBjb25zdCB7IGFkZE5vdGlmaWNhdGlvbiB9ID0gdXNlTm90aWZpY2F0aW9ucygpXG4gIHVzZUVmZmVjdCgoKSA9PiB7XG4gICAgaWYgKGdldElzUmVtb3RlTW9kZSgpKSByZXR1cm5cbiAgICBjb25zdCBmYWlsZWRMb2NhbENsaWVudHMgPSBtY3BDbGllbnRzLmZpbHRlcihcbiAgICAgIGNsaWVudCA9PlxuICAgICAgICBjbGllbnQudHlwZSA9PT0gJ2ZhaWxlZCcgJiZcbiAgICAgICAgY2xpZW50LmNvbmZpZy50eXBlICE9PSAnc3NlLWlkZScgJiZcbiAgICAgICAgY2xpZW50LmNvbmZpZy50eXBlICE9PSAnd3MtaWRlJyAmJlxuICAgICAgICBjbGllbnQuY29uZmlnLnR5cGUgIT09ICdjbGF1ZGVhaS1wcm94eScsXG4gICAgKVxuICAgIC8vIGNsYXVkZS5haSBmYWlsdXJlcyBnZXQgYSBzZXBhcmF0ZSBub3RpZmljYXRpb246IHRoZXkgYWxtb3N0IGFsd2F5cyBpbmRpY2F0ZVxuICAgIC8vIGEgdG9vbGJveC1zZXJ2aWNlIG91dGFnZSAoc2hhcmVkIGF1dGggYmFja2VuZCksIG5vdCBhIGxvY2FsIGNvbmZpZyBpc3N1ZS5cbiAgICAvLyBPbmx5IGZsYWcgY29ubmVjdG9ycyB0aGF0IGhhdmUgcHJldmlvdXNseSBjb25uZWN0ZWQgc3VjY2Vzc2Z1bGx5IOKAlCBhblxuICAgIC8vIG9yZy1jb25maWd1cmVkIGNvbm5lY3RvciB0aGF0J3MgYmVlbiBuZWVkcy1hdXRoIHNpbmNlIGl0IGFwcGVhcmVkIGlzIG9uZVxuICAgIC8vIHRoZSB1c2VyIGhhcyBpZ25vcmVkIGFuZCBzaG91bGRuJ3QgbmFnIGFib3V0OyBvbmUgdGhhdCB3YXMgd29ya2luZ1xuICAgIC8vIHllc3RlcmRheSBhbmQgaXMgbm93IGZhaWxlZCBpcyBhIHN0YXRlIGNoYW5nZSB3b3J0aCBzdXJmYWNpbmcuXG4gICAgY29uc3QgZmFpbGVkQ2xhdWRlQWlDbGllbnRzID0gbWNwQ2xpZW50cy5maWx0ZXIoXG4gICAgICBjbGllbnQgPT5cbiAgICAgICAgY2xpZW50LnR5cGUgPT09ICdmYWlsZWQnICYmXG4gICAgICAgIGNsaWVudC5jb25maWcudHlwZSA9PT0gJ2NsYXVkZWFpLXByb3h5JyAmJlxuICAgICAgICBoYXNDbGF1ZGVBaU1jcEV2ZXJDb25uZWN0ZWQoY2xpZW50Lm5hbWUpLFxuICAgIClcbiAgICBjb25zdCBuZWVkc0F1dGhMb2NhbFNlcnZlcnMgPSBtY3BDbGllbnRzLmZpbHRlcihcbiAgICAgIGNsaWVudCA9PlxuICAgICAgICBjbGllbnQudHlwZSA9PT0gJ25lZWRzLWF1dGgnICYmIGNsaWVudC5jb25maWcudHlwZSAhPT0gJ2NsYXVkZWFpLXByb3h5JyxcbiAgICApXG4gICAgY29uc3QgbmVlZHNBdXRoQ2xhdWRlQWlTZXJ2ZXJzID0gbWNwQ2xpZW50cy5maWx0ZXIoXG4gICAgICBjbGllbnQgPT5cbiAgICAgICAgY2xpZW50LnR5cGUgPT09ICduZWVkcy1hdXRoJyAmJlxuICAgICAgICBjbGllbnQuY29uZmlnLnR5cGUgPT09ICdjbGF1ZGVhaS1wcm94eScgJiZcbiAgICAgICAgaGFzQ2xhdWRlQWlNY3BFdmVyQ29ubmVjdGVkKGNsaWVudC5uYW1lKSxcbiAgICApXG4gICAgaWYgKFxuICAgICAgZmFpbGVkTG9jYWxDbGllbnRzLmxlbmd0aCA9PT0gMCAmJlxuICAgICAgZmFpbGVkQ2xhdWRlQWlDbGllbnRzLmxlbmd0aCA9PT0gMCAmJlxuICAgICAgbmVlZHNBdXRoTG9jYWxTZXJ2ZXJzLmxlbmd0aCA9PT0gMCAmJlxuICAgICAgbmVlZHNBdXRoQ2xhdWRlQWlTZXJ2ZXJzLmxlbmd0aCA9PT0gMFxuICAgICkge1xuICAgICAgcmV0dXJuXG4gICAgfVxuICAgIGlmIChmYWlsZWRMb2NhbENsaWVudHMubGVuZ3RoID4gMCkge1xuICAgICAgYWRkTm90aWZpY2F0aW9uKHtcbiAgICAgICAga2V5OiAnbWNwLWZhaWxlZCcsXG4gICAgICAgIGpzeDogKFxuICAgICAgICAgIDw+XG4gICAgICAgICAgICA8VGV4dCBjb2xvcj1cImVycm9yXCI+XG4gICAgICAgICAgICAgIHtmYWlsZWRMb2NhbENsaWVudHMubGVuZ3RofSBNQ1B7JyAnfVxuICAgICAgICAgICAgICB7ZmFpbGVkTG9jYWxDbGllbnRzLmxlbmd0aCA9PT0gMSA/ICdzZXJ2ZXInIDogJ3NlcnZlcnMnfSBmYWlsZWRcbiAgICAgICAgICAgIDwvVGV4dD5cbiAgICAgICAgICAgIDxUZXh0IGRpbUNvbG9yPiDCtyAvbWNwPC9UZXh0PlxuICAgICAgICAgIDwvPlxuICAgICAgICApLFxuICAgICAgICBwcmlvcml0eTogJ21lZGl1bScsXG4gICAgICB9KVxuICAgIH1cbiAgICBpZiAoZmFpbGVkQ2xhdWRlQWlDbGllbnRzLmxlbmd0aCA+IDApIHtcbiAgICAgIGFkZE5vdGlmaWNhdGlvbih7XG4gICAgICAgIGtleTogJ21jcC1jbGF1ZGVhaS1mYWlsZWQnLFxuICAgICAgICBqc3g6IChcbiAgICAgICAgICA8PlxuICAgICAgICAgICAgPFRleHQgY29sb3I9XCJlcnJvclwiPlxuICAgICAgICAgICAgICB7ZmFpbGVkQ2xhdWRlQWlDbGllbnRzLmxlbmd0aH0gY2xhdWRlLmFpeycgJ31cbiAgICAgICAgICAgICAge2ZhaWxlZENsYXVkZUFpQ2xpZW50cy5sZW5ndGggPT09IDEgPyAnY29ubmVjdG9yJyA6ICdjb25uZWN0b3JzJ317JyAnfVxuICAgICAgICAgICAgICB1bmF2YWlsYWJsZVxuICAgICAgICAgICAgPC9UZXh0PlxuICAgICAgICAgICAgPFRleHQgZGltQ29sb3I+IMK3IC9tY3A8L1RleHQ+XG4gICAgICAgICAgPC8+XG4gICAgICAgICksXG4gICAgICAgIHByaW9yaXR5OiAnbWVkaXV Line truncated
@@ -0,0 +1,52 @@
import type { Notification } from 'src/context/notifications.js';
import { type GlobalConfig, getGlobalConfig } from 'src/utils/config.js';
import { useStartupNotification } from './useStartupNotification.js';
// Shows a one-time notification right after a model migration writes its
// timestamp to config. Each entry reads its own timestamp field(s) and emits
// a notification if the write happened within the last 3s (i.e. this launch).
// Future model migrations: add an entry to MIGRATIONS below.
const MIGRATIONS: ((c: GlobalConfig) => Notification | undefined)[] = [
// Sonnet 4.5 → 4.6 (pro/max/team premium)
c => {
if (!recent(c.sonnet45To46MigrationTimestamp)) return;
return {
key: 'sonnet-46-update',
text: 'Model updated to Sonnet 4.6',
color: 'suggestion',
priority: 'high',
timeoutMs: 3000
};
},
// Opus Pro → default, or pinned 4.0/4.1 → opus alias. Both land on the
// current Opus default (4.6 for 1P).
c => {
const isLegacyRemap = Boolean(c.legacyOpusMigrationTimestamp);
const ts = c.legacyOpusMigrationTimestamp ?? c.opusProMigrationTimestamp;
if (!recent(ts)) return;
return {
key: 'opus-pro-update',
text: isLegacyRemap ? 'Model updated to Opus 4.6 · Set CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP=1 to opt out' : 'Model updated to Opus 4.6',
color: 'suggestion',
priority: 'high',
timeoutMs: isLegacyRemap ? 8000 : 3000
};
}];
export function useModelMigrationNotifications() {
useStartupNotification(_temp);
}
function _temp() {
const config = getGlobalConfig();
const notifs = [];
for (const migration of MIGRATIONS) {
const notif = migration(config);
if (notif) {
notifs.push(notif);
}
}
return notifs.length > 0 ? notifs : null;
}
function recent(ts: number | undefined): boolean {
return ts !== undefined && Date.now() - ts < 3000;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJOb3RpZmljYXRpb24iLCJHbG9iYWxDb25maWciLCJnZXRHbG9iYWxDb25maWciLCJ1c2VTdGFydHVwTm90aWZpY2F0aW9uIiwiTUlHUkFUSU9OUyIsImMiLCJyZWNlbnQiLCJzb25uZXQ0NVRvNDZNaWdyYXRpb25UaW1lc3RhbXAiLCJrZXkiLCJ0ZXh0IiwiY29sb3IiLCJwcmlvcml0eSIsInRpbWVvdXRNcyIsImlzTGVnYWN5UmVtYXAiLCJCb29sZWFuIiwibGVnYWN5T3B1c01pZ3JhdGlvblRpbWVzdGFtcCIsInRzIiwib3B1c1Byb01pZ3JhdGlvblRpbWVzdGFtcCIsInVzZU1vZGVsTWlncmF0aW9uTm90aWZpY2F0aW9ucyIsIl90ZW1wIiwiY29uZmlnIiwibm90aWZzIiwibWlncmF0aW9uIiwibm90aWYiLCJwdXNoIiwibGVuZ3RoIiwidW5kZWZpbmVkIiwiRGF0ZSIsIm5vdyJdLCJzb3VyY2VzIjpbInVzZU1vZGVsTWlncmF0aW9uTm90aWZpY2F0aW9ucy50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHR5cGUgeyBOb3RpZmljYXRpb24gfSBmcm9tICdzcmMvY29udGV4dC9ub3RpZmljYXRpb25zLmpzJ1xuaW1wb3J0IHsgdHlwZSBHbG9iYWxDb25maWcsIGdldEdsb2JhbENvbmZpZyB9IGZyb20gJ3NyYy91dGlscy9jb25maWcuanMnXG5pbXBvcnQgeyB1c2VTdGFydHVwTm90aWZpY2F0aW9uIH0gZnJvbSAnLi91c2VTdGFydHVwTm90aWZpY2F0aW9uLmpzJ1xuXG4vLyBTaG93cyBhIG9uZS10aW1lIG5vdGlmaWNhdGlvbiByaWdodCBhZnRlciBhIG1vZGVsIG1pZ3JhdGlvbiB3cml0ZXMgaXRzXG4vLyB0aW1lc3RhbXAgdG8gY29uZmlnLiBFYWNoIGVudHJ5IHJlYWRzIGl0cyBvd24gdGltZXN0YW1wIGZpZWxkKHMpIGFuZCBlbWl0c1xuLy8gYSBub3RpZmljYXRpb24gaWYgdGhlIHdyaXRlIGhhcHBlbmVkIHdpdGhpbiB0aGUgbGFzdCAzcyAoaS5lLiB0aGlzIGxhdW5jaCkuXG4vLyBGdXR1cmUgbW9kZWwgbWlncmF0aW9uczogYWRkIGFuIGVudHJ5IHRvIE1JR1JBVElPTlMgYmVsb3cuXG5jb25zdCBNSUdSQVRJT05TOiAoKGM6IEdsb2JhbENvbmZpZykgPT4gTm90aWZpY2F0aW9uIHwgdW5kZWZpbmVkKVtdID0gW1xuICAvLyBTb25uZXQgNC41IOKGkiA0LjYgKHByby9tYXgvdGVhbSBwcmVtaXVtKVxuICBjID0+IHtcbiAgICBpZiAoIXJlY2VudChjLnNvbm5ldDQ1VG80Nk1pZ3JhdGlvblRpbWVzdGFtcCkpIHJldHVyblxuICAgIHJldHVybiB7XG4gICAgICBrZXk6ICdzb25uZXQtNDYtdXBkYXRlJyxcbiAgICAgIHRleHQ6ICdNb2RlbCB1cGRhdGVkIHRvIFNvbm5ldCA0LjYnLFxuICAgICAgY29sb3I6ICdzdWdnZXN0aW9uJyxcbiAgICAgIHByaW9yaXR5OiAnaGlnaCcsXG4gICAgICB0aW1lb3V0TXM6IDMwMDAsXG4gICAgfVxuICB9LFxuICAvLyBPcHVzIFBybyDihpIgZGVmYXVsdCwgb3IgcGlubmVkIDQuMC80LjEg4oaSIG9wdXMgYWxpYXMuIEJvdGggbGFuZCBvbiB0aGVcbiAgLy8gY3VycmVudCBPcHVzIGRlZmF1bHQgKDQuNiBmb3IgMVApLlxuICBjID0+IHtcbiAgICBjb25zdCBpc0xlZ2FjeVJlbWFwID0gQm9vbGVhbihjLmxlZ2FjeU9wdXNNaWdyYXRpb25UaW1lc3RhbXApXG4gICAgY29uc3QgdHMgPSBjLmxlZ2FjeU9wdXNNaWdyYXRpb25UaW1lc3RhbXAgPz8gYy5vcHVzUHJvTWlncmF0aW9uVGltZXN0YW1wXG4gICAgaWYgKCFyZWNlbnQodHMpKSByZXR1cm5cbiAgICByZXR1cm4ge1xuICAgICAga2V5OiAnb3B1cy1wcm8tdXBkYXRlJyxcbiAgICAgIHRleHQ6IGlzTGVnYWN5UmVtYXBcbiAgICAgICAgPyAnTW9kZWwgdXBkYXRlZCB0byBPcHVzIDQuNiDCtyBTZXQgQ0xBVURFX0NPREVfRElTQUJMRV9MRUdBQ1lfTU9ERUxfUkVNQVA9MSB0byBvcHQgb3V0J1xuICAgICAgICA6ICdNb2RlbCB1cGRhdGVkIHRvIE9wdXMgNC42JyxcbiAgICAgIGNvbG9yOiAnc3VnZ2VzdGlvbicsXG4gICAgICBwcmlvcml0eTogJ2hpZ2gnLFxuICAgICAgdGltZW91dE1zOiBpc0xlZ2FjeVJlbWFwID8gODAwMCA6IDMwMDAsXG4gICAgfVxuICB9LFxuXVxuXG5leHBvcnQgZnVuY3Rpb24gdXNlTW9kZWxNaWdyYXRpb25Ob3RpZmljYXRpb25zKCk6IHZvaWQge1xuICB1c2VTdGFydHVwTm90aWZpY2F0aW9uKCgpID0+IHtcbiAgICBjb25zdCBjb25maWcgPSBnZXRHbG9iYWxDb25maWcoKVxuICAgIGNvbnN0IG5vdGlmczogTm90aWZpY2F0aW9uW10gPSBbXVxuICAgIGZvciAoY29uc3QgbWlncmF0aW9uIG9mIE1JR1JBVElPTlMpIHtcbiAgICAgIGNvbnN0IG5vdGlmID0gbWlncmF0aW9uKGNvbmZpZylcbiAgICAgIGlmIChub3RpZikgbm90aWZzLnB1c2gobm90aWYpXG4gICAgfVxuICAgIHJldHVybiBub3RpZnMubGVuZ3RoID4gMCA/IG5vdGlmcyA6IG51bGxcbiAgfSlcbn1cblxuZnVuY3Rpb24gcmVjZW50KHRzOiBudW1iZXIgfCB1bmRlZmluZWQpOiBib29sZWFuIHtcbiAgcmV0dXJuIHRzICE9PSB1bmRlZmluZWQgJiYgRGF0ZS5ub3coKSAtIHRzIDwgMzAwMFxufVxuIl0sIm1hcHBpbmdzIjoiQUFBQSxjQUFjQSxZQUFZLFFBQVEsOEJBQThCO0FBQ2hFLFNBQVMsS0FBS0MsWUFBWSxFQUFFQyxlQUFlLFFBQVEscUJBQXFCO0FBQ3hFLFNBQVNDLHNCQUFzQixRQUFRLDZCQUE2Qjs7QUFFcEU7QUFDQTtBQUNBO0FBQ0E7QUFDQSxNQUFNQyxVQUFVLEVBQUUsQ0FBQyxDQUFDQyxDQUFDLEVBQUVKLFlBQVksRUFBRSxHQUFHRCxZQUFZLEdBQUcsU0FBUyxDQUFDLEVBQUUsR0FBRztBQUNwRTtBQUNBSyxDQUFDLElBQUk7RUFDSCxJQUFJLENBQUNDLE1BQU0sQ0FBQ0QsQ0FBQyxDQUFDRSw4QkFBOEIsQ0FBQyxFQUFFO0VBQy9DLE9BQU87SUFDTEMsR0FBRyxFQUFFLGtCQUFrQjtJQUN2QkMsSUFBSSxFQUFFLDZCQUE2QjtJQUNuQ0MsS0FBSyxFQUFFLFlBQVk7SUFDbkJDLFFBQVEsRUFBRSxNQUFNO0lBQ2hCQyxTQUFTLEVBQUU7RUFDYixDQUFDO0FBQ0gsQ0FBQztBQUNEO0FBQ0E7QUFDQVAsQ0FBQyxJQUFJO0VBQ0gsTUFBTVEsYUFBYSxHQUFHQyxPQUFPLENBQUNULENBQUMsQ0FBQ1UsNEJBQTRCLENBQUM7RUFDN0QsTUFBTUMsRUFBRSxHQUFHWCxDQUFDLENBQUNVLDRCQUE0QixJQUFJVixDQUFDLENBQUNZLHlCQUF5QjtFQUN4RSxJQUFJLENBQUNYLE1BQU0sQ0FBQ1UsRUFBRSxDQUFDLEVBQUU7RUFDakIsT0FBTztJQUNMUixHQUFHLEVBQUUsaUJBQWlCO0lBQ3RCQyxJQUFJLEVBQUVJLGFBQWEsR0FDZixxRkFBcUYsR0FDckYsMkJBQTJCO0lBQy9CSCxLQUFLLEVBQUUsWUFBWTtJQUNuQkMsUUFBUSxFQUFFLE1BQU07SUFDaEJDLFNBQVMsRUFBRUMsYUFBYSxHQUFHLElBQUksR0FBRztFQUNwQyxDQUFDO0FBQ0gsQ0FBQyxDQUNGO0FBRUQsT0FBTyxTQUFBSywrQkFBQTtFQUNMZixzQkFBc0IsQ0FBQ2dCLEtBUXRCLENBQUM7QUFBQTtBQVRHLFNBQUFBLE1BQUE7RUFFSCxNQUFBQyxNQUFBLEdBQWVsQixlQUFlLENBQUMsQ0FBQztFQUNoQyxNQUFBbUIsTUFBQSxHQUErQixFQUFFO0VBQ2pDLEtBQUssTUFBQUMsU0FBZSxJQUFJbEIsVUFBVTtJQUNoQyxNQUFBbUIsS0FBQSxHQUFjRCxTQUFTLENBQUNGLE1BQU0sQ0FBQztJQUMvQixJQUFJRyxLQUFLO01BQUVGLE1BQU0sQ0FBQUcsSUFBSyxDQUFDRCxLQUFLLENBQUM7SUFBQTtFQUFBO0VBQzlCLE9BQ01GLE1BQU0sQ0FBQUksTUFBTyxHQUFHLENBQWlCLEdBQWpDSixNQUFpQyxHQUFqQyxJQUFpQztBQUFBO0FBSTVDLFNBQVNmLE1BQU1BLENBQUNVLEVBQUUsRUFBRSxNQUFNLEdBQUcsU0FBUyxDQUFDLEVBQUUsT0FBTyxDQUFDO0VBQy9 Line truncated
@@ -0,0 +1,25 @@
import { isInBundledMode } from 'src/utils/bundledMode.js';
import { getCurrentInstallationType } from 'src/utils/doctorDiagnostic.js';
import { isEnvTruthy } from 'src/utils/envUtils.js';
import { useStartupNotification } from './useStartupNotification.js';
const NPM_DEPRECATION_MESSAGE = 'Claude Code has switched from npm to native installer. Run `claude install` or see https://docs.anthropic.com/en/docs/claude-code/getting-started for more options.';
export function useNpmDeprecationNotification() {
useStartupNotification(_temp);
}
async function _temp() {
if (isInBundledMode() || isEnvTruthy(process.env.DISABLE_INSTALLATION_CHECKS)) {
return null;
}
const installationType = await getCurrentInstallationType();
if (installationType === "development") {
return null;
}
return {
timeoutMs: 15000,
key: "npm-deprecation-warning",
text: NPM_DEPRECATION_MESSAGE,
color: "warning",
priority: "high"
};
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJpc0luQnVuZGxlZE1vZGUiLCJnZXRDdXJyZW50SW5zdGFsbGF0aW9uVHlwZSIsImlzRW52VHJ1dGh5IiwidXNlU3RhcnR1cE5vdGlmaWNhdGlvbiIsIk5QTV9ERVBSRUNBVElPTl9NRVNTQUdFIiwidXNlTnBtRGVwcmVjYXRpb25Ob3RpZmljYXRpb24iLCJfdGVtcCIsInByb2Nlc3MiLCJlbnYiLCJESVNBQkxFX0lOU1RBTExBVElPTl9DSEVDS1MiLCJpbnN0YWxsYXRpb25UeXBlIiwidGltZW91dE1zIiwia2V5IiwidGV4dCIsImNvbG9yIiwicHJpb3JpdHkiXSwic291cmNlcyI6WyJ1c2VOcG1EZXByZWNhdGlvbk5vdGlmaWNhdGlvbi50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgaXNJbkJ1bmRsZWRNb2RlIH0gZnJvbSAnc3JjL3V0aWxzL2J1bmRsZWRNb2RlLmpzJ1xuaW1wb3J0IHsgZ2V0Q3VycmVudEluc3RhbGxhdGlvblR5cGUgfSBmcm9tICdzcmMvdXRpbHMvZG9jdG9yRGlhZ25vc3RpYy5qcydcbmltcG9ydCB7IGlzRW52VHJ1dGh5IH0gZnJvbSAnc3JjL3V0aWxzL2VudlV0aWxzLmpzJ1xuaW1wb3J0IHsgdXNlU3RhcnR1cE5vdGlmaWNhdGlvbiB9IGZyb20gJy4vdXNlU3RhcnR1cE5vdGlmaWNhdGlvbi5qcydcblxuY29uc3QgTlBNX0RFUFJFQ0FUSU9OX01FU1NBR0UgPVxuICAnQ2xhdWRlIENvZGUgaGFzIHN3aXRjaGVkIGZyb20gbnBtIHRvIG5hdGl2ZSBpbnN0YWxsZXIuIFJ1biBgY2xhdWRlIGluc3RhbGxgIG9yIHNlZSBodHRwczovL2RvY3MuYW50aHJvcGljLmNvbS9lbi9kb2NzL2NsYXVkZS1jb2RlL2dldHRpbmctc3RhcnRlZCBmb3IgbW9yZSBvcHRpb25zLidcblxuZXhwb3J0IGZ1bmN0aW9uIHVzZU5wbURlcHJlY2F0aW9uTm90aWZpY2F0aW9uKCk6IHZvaWQge1xuICB1c2VTdGFydHVwTm90aWZpY2F0aW9uKGFzeW5jICgpID0+IHtcbiAgICBpZiAoXG4gICAgICBpc0luQnVuZGxlZE1vZGUoKSB8fFxuICAgICAgaXNFbnZUcnV0aHkocHJvY2Vzcy5lbnYuRElTQUJMRV9JTlNUQUxMQVRJT05fQ0hFQ0tTKVxuICAgICkge1xuICAgICAgcmV0dXJuIG51bGxcbiAgICB9XG4gICAgY29uc3QgaW5zdGFsbGF0aW9uVHlwZSA9IGF3YWl0IGdldEN1cnJlbnRJbnN0YWxsYXRpb25UeXBlKClcbiAgICBpZiAoaW5zdGFsbGF0aW9uVHlwZSA9PT0gJ2RldmVsb3BtZW50JykgcmV0dXJuIG51bGxcbiAgICByZXR1cm4ge1xuICAgICAgdGltZW91dE1zOiAxNTAwMCxcbiAgICAgIGtleTogJ25wbS1kZXByZWNhdGlvbi13YXJuaW5nJyxcbiAgICAgIHRleHQ6IE5QTV9ERVBSRUNBVElPTl9NRVNTQUdFLFxuICAgICAgY29sb3I6ICd3YXJuaW5nJyxcbiAgICAgIHByaW9yaXR5OiAnaGlnaCcsXG4gICAgfVxuICB9KVxufVxuIl0sIm1hcHBpbmdzIjoiQUFBQSxTQUFTQSxlQUFlLFFBQVEsMEJBQTBCO0FBQzFELFNBQVNDLDBCQUEwQixRQUFRLCtCQUErQjtBQUMxRSxTQUFTQyxXQUFXLFFBQVEsdUJBQXVCO0FBQ25ELFNBQVNDLHNCQUFzQixRQUFRLDZCQUE2QjtBQUVwRSxNQUFNQyx1QkFBdUIsR0FDM0IscUtBQXFLO0FBRXZLLE9BQU8sU0FBQUMsOEJBQUE7RUFDTEYsc0JBQXNCLENBQUNHLEtBZ0J0QixDQUFDO0FBQUE7QUFqQkcsZUFBQUEsTUFBQTtFQUVILElBQ0VOLGVBQWUsQ0FDb0MsQ0FBQyxJQUFwREUsV0FBVyxDQUFDSyxPQUFPLENBQUFDLEdBQUksQ0FBQUMsMkJBQTRCLENBQUM7SUFBQSxPQUU3QyxJQUFJO0VBQUE7RUFFYixNQUFBQyxnQkFBQSxHQUF5QixNQUFNVCwwQkFBMEIsQ0FBQyxDQUFDO0VBQzNELElBQUlTLGdCQUFnQixLQUFLLGFBQWE7SUFBQSxPQUFTLElBQUk7RUFBQTtFQUFBLE9BQzVDO0lBQUFDLFNBQUEsRUFDTSxLQUFLO0lBQUFDLEdBQUEsRUFDWCx5QkFBeUI7SUFBQUMsSUFBQSxFQUN4QlQsdUJBQXVCO0lBQUFVLEtBQUEsRUFDdEIsU0FBUztJQUFBQyxRQUFBLEVBQ047RUFDWixDQUFDO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
@@ -0,0 +1,83 @@
import { c as _c } from "react/compiler-runtime";
import * as React from 'react';
import { useEffect, useState } from 'react';
import { getIsRemoteMode } from '../../bootstrap/state.js';
import { useNotifications } from '../../context/notifications.js';
import { Text } from '../../ink.js';
import { logForDebugging } from '../../utils/debug.js';
import { onPluginsAutoUpdated } from '../../utils/plugins/pluginAutoupdate.js';
/**
* Hook that displays a notification when plugins have been auto-updated.
* The notification tells the user to run /reload-plugins to apply the updates.
*/
export function usePluginAutoupdateNotification() {
const $ = _c(7);
const {
addNotification
} = useNotifications();
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = [];
$[0] = t0;
} else {
t0 = $[0];
}
const [updatedPlugins, setUpdatedPlugins] = useState(t0);
let t1;
let t2;
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
t1 = () => {
if (getIsRemoteMode()) {
return;
}
const unsubscribe = onPluginsAutoUpdated(plugins => {
logForDebugging(`Plugin autoupdate notification: ${plugins.length} plugin(s) updated`);
setUpdatedPlugins(plugins);
});
return unsubscribe;
};
t2 = [];
$[1] = t1;
$[2] = t2;
} else {
t1 = $[1];
t2 = $[2];
}
useEffect(t1, t2);
let t3;
let t4;
if ($[3] !== addNotification || $[4] !== updatedPlugins) {
t3 = () => {
if (getIsRemoteMode()) {
return;
}
if (updatedPlugins.length === 0) {
return;
}
const pluginNames = updatedPlugins.map(_temp);
const displayNames = pluginNames.length <= 2 ? pluginNames.join(" and ") : `${pluginNames.length} plugins`;
addNotification({
key: "plugin-autoupdate-restart",
jsx: <><Text color="success">{pluginNames.length === 1 ? "Plugin" : "Plugins"} updated:{" "}{displayNames}</Text><Text dimColor={true}> · Run /reload-plugins to apply</Text></>,
priority: "low",
timeoutMs: 10000
});
logForDebugging(`Showing plugin autoupdate notification for: ${pluginNames.join(", ")}`);
};
t4 = [updatedPlugins, addNotification];
$[3] = addNotification;
$[4] = updatedPlugins;
$[5] = t3;
$[6] = t4;
} else {
t3 = $[5];
t4 = $[6];
}
useEffect(t3, t4);
}
function _temp(id) {
const atIndex = id.indexOf("@");
return atIndex > 0 ? id.substring(0, atIndex) : id;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZUVmZmVjdCIsInVzZVN0YXRlIiwiZ2V0SXNSZW1vdGVNb2RlIiwidXNlTm90aWZpY2F0aW9ucyIsIlRleHQiLCJsb2dGb3JEZWJ1Z2dpbmciLCJvblBsdWdpbnNBdXRvVXBkYXRlZCIsInVzZVBsdWdpbkF1dG91cGRhdGVOb3RpZmljYXRpb24iLCIkIiwiX2MiLCJhZGROb3RpZmljYXRpb24iLCJ0MCIsIlN5bWJvbCIsImZvciIsInVwZGF0ZWRQbHVnaW5zIiwic2V0VXBkYXRlZFBsdWdpbnMiLCJ0MSIsInQyIiwidW5zdWJzY3JpYmUiLCJwbHVnaW5zIiwibGVuZ3RoIiwidDMiLCJ0NCIsInBsdWdpbk5hbWVzIiwibWFwIiwiX3RlbXAiLCJkaXNwbGF5TmFtZXMiLCJqb2luIiwia2V5IiwianN4IiwicHJpb3JpdHkiLCJ0aW1lb3V0TXMiLCJpZCIsImF0SW5kZXgiLCJpbmRleE9mIiwic3Vic3RyaW5nIl0sInNvdXJjZXMiOlsidXNlUGx1Z2luQXV0b3VwZGF0ZU5vdGlmaWNhdGlvbi50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0ICogYXMgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyB1c2VFZmZlY3QsIHVzZVN0YXRlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBnZXRJc1JlbW90ZU1vZGUgfSBmcm9tICcuLi8uLi9ib290c3RyYXAvc3RhdGUuanMnXG5pbXBvcnQgeyB1c2VOb3RpZmljYXRpb25zIH0gZnJvbSAnLi4vLi4vY29udGV4dC9ub3RpZmljYXRpb25zLmpzJ1xuaW1wb3J0IHsgVGV4dCB9IGZyb20gJy4uLy4uL2luay5qcydcbmltcG9ydCB7IGxvZ0ZvckRlYnVnZ2luZyB9IGZyb20gJy4uLy4uL3V0aWxzL2RlYnVnLmpzJ1xuaW1wb3J0IHsgb25QbHVnaW5zQXV0b1VwZGF0ZWQgfSBmcm9tICcuLi8uLi91dGlscy9wbHVnaW5zL3BsdWdpbkF1dG91cGRhdGUuanMnXG5cbi8qKlxuICogSG9vayB0aGF0IGRpc3BsYXlzIGEgbm90aWZpY2F0aW9uIHdoZW4gcGx1Z2lucyBoYXZlIGJlZW4gYXV0by11cGRhdGVkLlxuICogVGhlIG5vdGlmaWNhdGlvbiB0ZWxscyB0aGUgdXNlciB0byBydW4gL3JlbG9hZC1wbHVnaW5zIHRvIGFwcGx5IHRoZSB1cGRhdGVzLlxuICovXG5leHBvcnQgZnVuY3Rpb24gdXNlUGx1Z2luQXV0b3VwZGF0ZU5vdGlmaWNhdGlvbigpOiB2b2lkIHtcbiAgY29uc3QgeyBhZGROb3RpZmljYXRpb24gfSA9IHVzZU5vdGlmaWNhdGlvbnMoKVxuICBjb25zdCBbdXBkYXRlZFBsdWdpbnMsIHNldFVwZGF0ZWRQbHVnaW5zXSA9IHVzZVN0YXRlPHN0cmluZ1tdPihbXSlcblxuICAvLyBSZWdpc3RlciBmb3IgYXV0b3VwZGF0ZSBub3RpZmljYXRpb25zXG4gIHVzZUVmZmVjdCgoKSA9PiB7XG4gICAgaWYgKGdldElzUmVtb3RlTW9kZSgpKSByZXR1cm5cbiAgICBjb25zdCB1bnN1YnNjcmliZSA9IG9uUGx1Z2luc0F1dG9VcGRhdGVkKHBsdWdpbnMgPT4ge1xuICAgICAgbG9nRm9yRGVidWdnaW5nKFxuICAgICAgICBgUGx1Z2luIGF1dG91cGRhdGUgbm90aWZpY2F0aW9uOiAke3BsdWdpbnMubGVuZ3RofSBwbHVnaW4ocykgdXBkYXRlZGAsXG4gICAgICApXG4gICAgICBzZXRVcGRhdGVkUGx1Z2lucyhwbHVnaW5zKVxuICAgIH0pXG5cbiAgICByZXR1cm4gdW5zdWJzY3JpYmVcbiAgfSwgW10pXG5cbiAgLy8gU2hvdyBub3RpZmljYXRpb24gd2hlbiBwbHVnaW5zIGFyZSB1cGRhdGVkXG4gIHVzZUVmZmVjdCgoKSA9PiB7XG4gICAgaWYgKGdldElzUmVtb3RlTW9kZSgpKSByZXR1cm5cbiAgICBpZiAodXBkYXRlZFBsdWdpbnMubGVuZ3RoID09PSAwKSB7XG4gICAgICByZXR1cm5cbiAgICB9XG5cbiAgICAvLyBFeHRyYWN0IHBsdWdpbiBuYW1lcyBmcm9tIHBsdWdpbiBJRHMgKGZvcm1hdDogXCJuYW1lQG1hcmtldHBsYWNlXCIpXG4gICAgY29uc3QgcGx1Z2luTmFtZXMgPSB1cGRhdGVkUGx1Z2lucy5tYXAoaWQgPT4ge1xuICAgICAgY29uc3QgYXRJbmRleCA9IGlkLmluZGV4T2YoJ0AnKVxuICAgICAgcmV0dXJuIGF0SW5kZXggPiAwID8gaWQuc3Vic3RyaW5nKDAsIGF0SW5kZXgpIDogaWRcbiAgICB9KVxuXG4gICAgY29uc3QgZGlzcGxheU5hbWVzID1cbiAgICAgIHBsdWdpbk5hbWVzLmxlbmd0aCA8PSAyXG4gICAgICAgID8gcGx1Z2luTmFtZXMuam9pbignIGFuZCAnKVxuICAgICAgICA6IGAke3BsdWdpbk5hbWVzLmxlbmd0aH0gcGx1Z2luc2BcblxuICAgIGFkZE5vdGlmaWNhdGlvbih7XG4gICAgICBrZXk6ICdwbHVnaW4tYXV0b3VwZGF0ZS1yZXN0YXJ0JyxcbiAgICAgIGpzeDogKFxuICAgICAgICA8PlxuICAgICAgICAgIDxUZXh0IGNvbG9yPVwic3VjY2Vzc1wiPlxuICAgICAgICAgICAge3BsdWdpbk5hbWVzLmxlbmd0aCA9PT0gMSA/ICdQbHVnaW4nIDogJ1BsdWdpbnMnfSB1cGRhdGVkOnsnICd9XG4gICAgICAgICAgICB7ZGlzcGxheU5hbWVzfVxuICAgICAgICAgIDwvVGV4dD5cbiAgICAgICAgICA8VGV4dCBkaW1Db2xvcj4gwrcgUnVuIC9yZWxvYWQtcGx1Z2lucyB0byBhcHBseTwvVGV4dD5cbiAgICAgICAgPC8+XG4gICAgICApLFxuICAgICAgcHJpb3JpdHk6ICdsb3cnLFxuICAgICAgdGltZW91dE1zOiAxMDAwMCxcbiAgICB9KVxuXG4gICAgbG9nRm9yRGVidWdnaW5nKFxuICAgICAgYFNob3dpbmcgcGx1Z2luIGF1dG91cGRhdGUgbm90aWZpY2F0aW9uIGZvcjogJHtwbHVnaW5OYW1lcy5qb2luKCcsICcpfWAsXG4gICAgKVxuICB9LCBbdXBkYXRlZFBsdWdpbnMsIGFkZE5vdGlmaWNhdGlvbl0pXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPLEtBQUtBLEtBQUssTUFBTSxPQUFPO0FBQzlCLFNBQVNDLFNBQVMsRUFBRUMsUUFBUSxRQUFRLE9BQU87QUFDM0MsU0FBU0MsZUFBZSxRQUFRLDBCQUEwQjtBQUMxRCxTQUFTQyxnQkFBZ0IsUUFBUSxnQ0FBZ0M7QUFDakUsU0FBU0MsSUFBSSxRQUFRLGNBQWM7QUFDbkMsU0FBU0MsZUFBZSxRQUFRLHNCQUFzQjtBQUN0RCxTQUFTQyxvQkFBb0IsUUFBUSx5Q0FBeUM7O0FBRTlFO0FBQ0E7QUFDQTtBQUNBO0FBQ0EsT0FBTyxTQUFBQyxnQ0FBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUNMO0lBQUFDO0VBQUEsSUFBNEJQLGdCQUFnQixDQUFDLENBQUM7RUFBQSxJQUFBUSxFQUFBO0VBQUEsSUFBQUgsQ0FBQSxRQUFBSSxNQUFBLENBQUFDLEdBQUE7SUFDaUJGLEVBQUEsS0FBRTtJQUFBSCxDQUFBLE1BQUFHLEVBQUE7RUFBQTtJQUFBQSxFQUFBLEdBQUFILENBQUE7RUFBQTtFQUFqRSxPQUFBTSxjQUFBLEVBQUFDLGlCQUFBLElBQTRDZCxRQUFRLENBQVdVLEVBQUUsQ0FBQztFQUFBLElBQUFLLEVBQUE7RUFBQSxJQUFBQyxFQUFBO0VBQUEsSUFBQVQsQ0FBQSxRQUFBSSxNQUFBLENBQUFDLEdBQUE7SUFHeERHLEVBQUEsR0FBQUEsQ0FBQTtNQUNSLElBQUlkLGVBQWUsQ0FBQyxDQUFDO1FBQUE7TUFBQTtNQUNyQixNQUFBZ0IsV0FBQSxHQUFvQlosb0JBQW9CLENBQUNhLE9BQUE7UUFDdkNkLGVBQWUsQ0FDYixtQ0FBbUNjLE9BQU8sQ0FBQUMsTUFBTyxvQkFDbkQsQ0FBQztRQUNETCxpQkFBaUIsQ0FBQ0ksT0FBTyxDQUFDO01BQUEsQ0FDM0IsQ0FBQztNQUFBLE9BRUtELFdBQVc7SUFBQSxDQUNuQjtJQUFFRCxFQUFBLEtBQUU7SUFBQVQsQ0FBQSxNQUFBUSxFQUFBO0lBQUFSLENBQUEsTUFBQVMsRUFBQTtFQUFBO0lBQUFELEVBQUEsR0FBQVIsQ0FBQTtJQUFBUyxFQUFBLEdBQUFULENBQUE7RUFBQTtFQVZMUixTQUFTLENBQUNnQixFQVVULEVBQUVDLEVBQUUsQ0FBQztFQUFBLElBQUFJLEVBQUE7RUFBQSxJQUFBQyxFQUFBO0VBQUEsSUFBQWQsQ0FBQSxRQUFBRSx Line truncated
@@ -0,0 +1,128 @@
import { c as _c } from "react/compiler-runtime";
import * as React from 'react';
import { useEffect, useMemo } from 'react';
import { getIsRemoteMode } from '../../bootstrap/state.js';
import { useNotifications } from '../../context/notifications.js';
import { Text } from '../../ink.js';
import { useAppState } from '../../state/AppState.js';
import { logForDebugging } from '../../utils/debug.js';
import { plural } from '../../utils/stringUtils.js';
export function usePluginInstallationStatus() {
const $ = _c(20);
const {
addNotification
} = useNotifications();
const installationStatus = useAppState(_temp);
let t0;
bb0: {
if (!installationStatus) {
let t1;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t1 = {
totalFailed: 0,
failedMarketplacesCount: 0,
failedPluginsCount: 0
};
$[0] = t1;
} else {
t1 = $[0];
}
t0 = t1;
break bb0;
}
let t1;
if ($[1] !== installationStatus.marketplaces) {
t1 = installationStatus.marketplaces.filter(_temp2);
$[1] = installationStatus.marketplaces;
$[2] = t1;
} else {
t1 = $[2];
}
const failedMarketplaces = t1;
let t2;
if ($[3] !== installationStatus.plugins) {
t2 = installationStatus.plugins.filter(_temp3);
$[3] = installationStatus.plugins;
$[4] = t2;
} else {
t2 = $[4];
}
const failedPlugins = t2;
const t3 = failedMarketplaces.length + failedPlugins.length;
let t4;
if ($[5] !== failedMarketplaces.length || $[6] !== failedPlugins.length || $[7] !== t3) {
t4 = {
totalFailed: t3,
failedMarketplacesCount: failedMarketplaces.length,
failedPluginsCount: failedPlugins.length
};
$[5] = failedMarketplaces.length;
$[6] = failedPlugins.length;
$[7] = t3;
$[8] = t4;
} else {
t4 = $[8];
}
t0 = t4;
}
const {
totalFailed,
failedMarketplacesCount,
failedPluginsCount
} = t0;
let t1;
if ($[9] !== addNotification || $[10] !== failedMarketplacesCount || $[11] !== failedPluginsCount || $[12] !== installationStatus || $[13] !== totalFailed) {
t1 = () => {
if (getIsRemoteMode()) {
return;
}
if (!installationStatus) {
logForDebugging("No installation status to monitor");
return;
}
if (totalFailed === 0) {
return;
}
logForDebugging(`Plugin installation status: ${failedMarketplacesCount} failed marketplaces, ${failedPluginsCount} failed plugins`);
if (totalFailed === 0) {
return;
}
logForDebugging(`Adding notification for ${totalFailed} failed installations`);
addNotification({
key: "plugin-install-failed",
jsx: <><Text color="error">{totalFailed} {plural(totalFailed, "plugin")} failed to install</Text><Text dimColor={true}> · /plugin for details</Text></>,
priority: "medium"
});
};
$[9] = addNotification;
$[10] = failedMarketplacesCount;
$[11] = failedPluginsCount;
$[12] = installationStatus;
$[13] = totalFailed;
$[14] = t1;
} else {
t1 = $[14];
}
let t2;
if ($[15] !== addNotification || $[16] !== failedMarketplacesCount || $[17] !== failedPluginsCount || $[18] !== totalFailed) {
t2 = [addNotification, totalFailed, failedMarketplacesCount, failedPluginsCount];
$[15] = addNotification;
$[16] = failedMarketplacesCount;
$[17] = failedPluginsCount;
$[18] = totalFailed;
$[19] = t2;
} else {
t2 = $[19];
}
useEffect(t1, t2);
}
function _temp3(p) {
return p.status === "failed";
}
function _temp2(m) {
return m.status === "failed";
}
function _temp(s) {
return s.plugins.installationStatus;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZUVmZmVjdCIsInVzZU1lbW8iLCJnZXRJc1JlbW90ZU1vZGUiLCJ1c2VOb3RpZmljYXRpb25zIiwiVGV4dCIsInVzZUFwcFN0YXRlIiwibG9nRm9yRGVidWdnaW5nIiwicGx1cmFsIiwidXNlUGx1Z2luSW5zdGFsbGF0aW9uU3RhdHVzIiwiJCIsIl9jIiwiYWRkTm90aWZpY2F0aW9uIiwiaW5zdGFsbGF0aW9uU3RhdHVzIiwiX3RlbXAiLCJ0MCIsImJiMCIsInQxIiwiU3ltYm9sIiwiZm9yIiwidG90YWxGYWlsZWQiLCJmYWlsZWRNYXJrZXRwbGFjZXNDb3VudCIsImZhaWxlZFBsdWdpbnNDb3VudCIsIm1hcmtldHBsYWNlcyIsImZpbHRlciIsIl90ZW1wMiIsImZhaWxlZE1hcmtldHBsYWNlcyIsInQyIiwicGx1Z2lucyIsIl90ZW1wMyIsImZhaWxlZFBsdWdpbnMiLCJ0MyIsImxlbmd0aCIsInQ0Iiwia2V5IiwianN4IiwicHJpb3JpdHkiLCJwIiwic3RhdHVzIiwibSIsInMiXSwic291cmNlcyI6WyJ1c2VQbHVnaW5JbnN0YWxsYXRpb25TdGF0dXMudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCAqIGFzIFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgdXNlRWZmZWN0LCB1c2VNZW1vIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBnZXRJc1JlbW90ZU1vZGUgfSBmcm9tICcuLi8uLi9ib290c3RyYXAvc3RhdGUuanMnXG5pbXBvcnQgeyB1c2VOb3RpZmljYXRpb25zIH0gZnJvbSAnLi4vLi4vY29udGV4dC9ub3RpZmljYXRpb25zLmpzJ1xuaW1wb3J0IHsgVGV4dCB9IGZyb20gJy4uLy4uL2luay5qcydcbmltcG9ydCB7IHVzZUFwcFN0YXRlIH0gZnJvbSAnLi4vLi4vc3RhdGUvQXBwU3RhdGUuanMnXG5pbXBvcnQgeyBsb2dGb3JEZWJ1Z2dpbmcgfSBmcm9tICcuLi8uLi91dGlscy9kZWJ1Zy5qcydcbmltcG9ydCB7IHBsdXJhbCB9IGZyb20gJy4uLy4uL3V0aWxzL3N0cmluZ1V0aWxzLmpzJ1xuXG5leHBvcnQgZnVuY3Rpb24gdXNlUGx1Z2luSW5zdGFsbGF0aW9uU3RhdHVzKCk6IHZvaWQge1xuICBjb25zdCB7IGFkZE5vdGlmaWNhdGlvbiB9ID0gdXNlTm90aWZpY2F0aW9ucygpXG4gIGNvbnN0IGluc3RhbGxhdGlvblN0YXR1cyA9IHVzZUFwcFN0YXRlKHMgPT4gcy5wbHVnaW5zLmluc3RhbGxhdGlvblN0YXR1cylcblxuICAvLyBNZW1vaXplIHRoZSBmYWlsZWQgY291bnRzIHRvIHByZXZlbnQgdW5uZWNlc3NhcnkgZWZmZWN0IHRyaWdnZXJzXG4gIGNvbnN0IHsgdG90YWxGYWlsZWQsIGZhaWxlZE1hcmtldHBsYWNlc0NvdW50LCBmYWlsZWRQbHVnaW5zQ291bnQgfSA9XG4gICAgdXNlTWVtbygoKSA9PiB7XG4gICAgICBpZiAoIWluc3RhbGxhdGlvblN0YXR1cykge1xuICAgICAgICByZXR1cm4ge1xuICAgICAgICAgIHRvdGFsRmFpbGVkOiAwLFxuICAgICAgICAgIGZhaWxlZE1hcmtldHBsYWNlc0NvdW50OiAwLFxuICAgICAgICAgIGZhaWxlZFBsdWdpbnNDb3VudDogMCxcbiAgICAgICAgfVxuICAgICAgfVxuXG4gICAgICBjb25zdCBmYWlsZWRNYXJrZXRwbGFjZXMgPSBpbnN0YWxsYXRpb25TdGF0dXMubWFya2V0cGxhY2VzLmZpbHRlcihcbiAgICAgICAgbSA9PiBtLnN0YXR1cyA9PT0gJ2ZhaWxlZCcsXG4gICAgICApXG4gICAgICBjb25zdCBmYWlsZWRQbHVnaW5zID0gaW5zdGFsbGF0aW9uU3RhdHVzLnBsdWdpbnMuZmlsdGVyKFxuICAgICAgICBwID0+IHAuc3RhdHVzID09PSAnZmFpbGVkJyxcbiAgICAgIClcblxuICAgICAgcmV0dXJuIHtcbiAgICAgICAgdG90YWxGYWlsZWQ6IGZhaWxlZE1hcmtldHBsYWNlcy5sZW5ndGggKyBmYWlsZWRQbHVnaW5zLmxlbmd0aCxcbiAgICAgICAgZmFpbGVkTWFya2V0cGxhY2VzQ291bnQ6IGZhaWxlZE1hcmtldHBsYWNlcy5sZW5ndGgsXG4gICAgICAgIGZhaWxlZFBsdWdpbnNDb3VudDogZmFpbGVkUGx1Z2lucy5sZW5ndGgsXG4gICAgICB9XG4gICAgfSwgW2luc3RhbGxhdGlvblN0YXR1c10pXG5cbiAgdXNlRWZmZWN0KCgpID0+IHtcbiAgICBpZiAoZ2V0SXNSZW1vdGVNb2RlKCkpIHJldHVyblxuICAgIGlmICghaW5zdGFsbGF0aW9uU3RhdHVzKSB7XG4gICAgICBsb2dGb3JEZWJ1Z2dpbmcoJ05vIGluc3RhbGxhdGlvbiBzdGF0dXMgdG8gbW9uaXRvcicpXG4gICAgICByZXR1cm5cbiAgICB9XG5cbiAgICBpZiAodG90YWxGYWlsZWQgPT09IDApIHtcbiAgICAgIHJldHVyblxuICAgIH1cblxuICAgIGxvZ0ZvckRlYnVnZ2luZyhcbiAgICAgIGBQbHVnaW4gaW5zdGFsbGF0aW9uIHN0YXR1czogJHtmYWlsZWRNYXJrZXRwbGFjZXNDb3VudH0gZmFpbGVkIG1hcmtldHBsYWNlcywgJHtmYWlsZWRQbHVnaW5zQ291bnR9IGZhaWxlZCBwbHVnaW5zYCxcbiAgICApXG5cbiAgICBpZiAodG90YWxGYWlsZWQgPT09IDApIHtcbiAgICAgIHJldHVyblxuICAgIH1cblxuICAgIC8vIEFkZCBub3RpZmljYXRpb24gZm9yIGZhaWx1cmVzXG4gICAgbG9nRm9yRGVidWdnaW5nKFxuICAgICAgYEFkZGluZyBub3RpZmljYXRpb24gZm9yICR7dG90YWxGYWlsZWR9IGZhaWxlZCBpbnN0YWxsYXRpb25zYCxcbiAgICApXG4gICAgYWRkTm90aWZpY2F0aW9uKHtcbiAgICAgIGtleTogJ3BsdWdpbi1pbnN0YWxsLWZhaWxlZCcsXG4gICAgICBqc3g6IChcbiAgICAgICAgPD5cbiAgICAgICAgICA8VGV4dCBjb2xvcj1cImVycm9yXCI+XG4gICAgICAgICAgICB7dG90YWxGYWlsZWR9IHtwbHVyYWwodG90YWxGYWlsZWQsICdwbHVnaW4nKX0gZmFpbGVkIHRvIGluc3RhbGxcbiAgICAgICAgICA8L1RleHQ+XG4gICAgICAgICAgPFRleHQgZGltQ29sb3I+IMK3IC9wbHVnaW4gZm9yIGRldGFpbHM8L1RleHQ+XG4gICAgICAgIDwvPlxuICAgICAgKSxcbiAgICAgIHByaW9yaXR5OiAnbWVkaXVtJyxcbiAgICB9KVxuICB9LCBbXG4gICAgYWRkTm90aWZpY2F0aW9uLFxuICAgIHRvdGFsRmFpbGVkLFxuICAgIGZhaWxlZE1hcmtldHBsYWNlc0NvdW50LFxuICAgIGZhaWxlZFBsdWdpbnNDb3VudCxcbiAgXSlcbn1cbiJdLCJtYXBwaW5ncyI6IjtBQUFBLE9BQU8sS0FBS0EsS0FBSyxNQUFNLE9BQU87QUFDOUIsU0FBU0MsU0FBUyxFQUFFQyxPQUFPLFFBQVEsT0FBTztBQUMxQyxTQUFTQyxlQUFlLFFBQVEsMEJBQTBCO0FBQzFELFNBQVNDLGdCQUFnQixRQUFRLGdDQUFnQztBQUNqRSxTQUFTQyxJQUFJLFFBQVEsY0FBYztBQUNuQyxTQUFTQyxXQUFXLFFBQVEseUJBQXlCO0FBQ3JELFNBQVNDLGVBQWUsUUFBUSxzQkFBc0I7QUFDdEQsU0FBU0MsTUFBTSxRQUFRLDRCQUE0QjtBQUVuRCxPQUFPLFNBQUFDLDRCQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQ0w7SUFBQUM7RUFBQSxJQUE0QlIsZ0JBQWdCLENBQUMsQ0FBQztFQUM5QyxNQUFBUyxrQkFBQSxHQUEyQlAsV0FBVyxDQUFDUSxLQUFpQyxDQUFDO0VBQUEsSUFBQUMsRUFBQTtFQUFBQyxHQUFBO0lBS3JFLElBQUksQ0FBQ0gsa0JBQWtCO01BQUEsSUFBQUksRUFBQTtNQUFBLElBQUFQLENBQUEsUUFBQVEsTUFBQSxDQUFBQyxHQUFBO1FBQ2RGLEVBQUE7VUFBQUcsV0FBQSxFQUNRLENBQUM7VUFBQUMsdUJBQUEsRUFDVyxDQUFDO1VBQUFDLGtCQUFBLEVBQ047UUFDdEIsQ0FBQztRQUFBWixDQUFBLE1BQUFPLEVBQUE7TUFBQTtRQUFBQSxFQUFBLEdBQUFQLENBQUE7TUFBQTtNQUpESyxFQUFBLEdBQU9FLEVBSU47TUFKRCxNQUFBRCxHQUFBO0lBSUM7SUFDRixJQUFBQyxFQUFBO0lBQUEsSUFBQVAsQ0FBQSxRQUFBRyxrQkFBQSxDQUFBVSxZQUFBO01BRTBCTixFQUFBLEdBQUF Line truncated
@@ -0,0 +1,114 @@
import { c as _c } from "react/compiler-runtime";
import * as React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNotifications } from 'src/context/notifications.js';
import { Text } from 'src/ink.js';
import { getRateLimitWarning, getUsingOverageText } from 'src/services/claudeAiLimits.js';
import { useClaudeAiLimits } from 'src/services/claudeAiLimitsHook.js';
import { getSubscriptionType } from 'src/utils/auth.js';
import { hasClaudeAiBillingAccess } from 'src/utils/billing.js';
import { getIsRemoteMode } from '../../bootstrap/state.js';
export function useRateLimitWarningNotification(model) {
const $ = _c(17);
const {
addNotification
} = useNotifications();
const claudeAiLimits = useClaudeAiLimits();
let t0;
if ($[0] !== claudeAiLimits || $[1] !== model) {
t0 = getRateLimitWarning(claudeAiLimits, model);
$[0] = claudeAiLimits;
$[1] = model;
$[2] = t0;
} else {
t0 = $[2];
}
const rateLimitWarning = t0;
let t1;
if ($[3] !== claudeAiLimits) {
t1 = getUsingOverageText(claudeAiLimits);
$[3] = claudeAiLimits;
$[4] = t1;
} else {
t1 = $[4];
}
const usingOverageText = t1;
const shownWarningRef = useRef(null);
let t2;
if ($[5] === Symbol.for("react.memo_cache_sentinel")) {
t2 = getSubscriptionType();
$[5] = t2;
} else {
t2 = $[5];
}
const subscriptionType = t2;
let t3;
if ($[6] === Symbol.for("react.memo_cache_sentinel")) {
t3 = hasClaudeAiBillingAccess();
$[6] = t3;
} else {
t3 = $[6];
}
const hasBillingAccess = t3;
const isTeamOrEnterprise = subscriptionType === "team" || subscriptionType === "enterprise";
const [hasShownOverageNotification, setHasShownOverageNotification] = useState(false);
let t4;
let t5;
if ($[7] !== addNotification || $[8] !== claudeAiLimits.isUsingOverage || $[9] !== hasShownOverageNotification || $[10] !== usingOverageText) {
t4 = () => {
if (getIsRemoteMode()) {
return;
}
if (claudeAiLimits.isUsingOverage && !hasShownOverageNotification && (!isTeamOrEnterprise || hasBillingAccess)) {
addNotification({
key: "limit-reached",
text: usingOverageText,
priority: "immediate"
});
setHasShownOverageNotification(true);
} else {
if (!claudeAiLimits.isUsingOverage && hasShownOverageNotification) {
setHasShownOverageNotification(false);
}
}
};
t5 = [claudeAiLimits.isUsingOverage, usingOverageText, hasShownOverageNotification, addNotification, hasBillingAccess, isTeamOrEnterprise];
$[7] = addNotification;
$[8] = claudeAiLimits.isUsingOverage;
$[9] = hasShownOverageNotification;
$[10] = usingOverageText;
$[11] = t4;
$[12] = t5;
} else {
t4 = $[11];
t5 = $[12];
}
useEffect(t4, t5);
let t6;
let t7;
if ($[13] !== addNotification || $[14] !== rateLimitWarning) {
t6 = () => {
if (getIsRemoteMode()) {
return;
}
if (rateLimitWarning && rateLimitWarning !== shownWarningRef.current) {
shownWarningRef.current = rateLimitWarning;
addNotification({
key: "rate-limit-warning",
jsx: <Text><Text color="warning">{rateLimitWarning}</Text></Text>,
priority: "high"
});
}
};
t7 = [rateLimitWarning, addNotification];
$[13] = addNotification;
$[14] = rateLimitWarning;
$[15] = t6;
$[16] = t7;
} else {
t6 = $[15];
t7 = $[16];
}
useEffect(t6, t7);
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZUVmZmVjdCIsInVzZU1lbW8iLCJ1c2VSZWYiLCJ1c2VTdGF0ZSIsInVzZU5vdGlmaWNhdGlvbnMiLCJUZXh0IiwiZ2V0UmF0ZUxpbWl0V2FybmluZyIsImdldFVzaW5nT3ZlcmFnZVRleHQiLCJ1c2VDbGF1ZGVBaUxpbWl0cyIsImdldFN1YnNjcmlwdGlvblR5cGUiLCJoYXNDbGF1ZGVBaUJpbGxpbmdBY2Nlc3MiLCJnZXRJc1JlbW90ZU1vZGUiLCJ1c2VSYXRlTGltaXRXYXJuaW5nTm90aWZpY2F0aW9uIiwibW9kZWwiLCIkIiwiX2MiLCJhZGROb3RpZmljYXRpb24iLCJjbGF1ZGVBaUxpbWl0cyIsInQwIiwicmF0ZUxpbWl0V2FybmluZyIsInQxIiwidXNpbmdPdmVyYWdlVGV4dCIsInNob3duV2FybmluZ1JlZiIsInQyIiwiU3ltYm9sIiwiZm9yIiwic3Vic2NyaXB0aW9uVHlwZSIsInQzIiwiaGFzQmlsbGluZ0FjY2VzcyIsImlzVGVhbU9yRW50ZXJwcmlzZSIsImhhc1Nob3duT3ZlcmFnZU5vdGlmaWNhdGlvbiIsInNldEhhc1Nob3duT3ZlcmFnZU5vdGlmaWNhdGlvbiIsInQ0IiwidDUiLCJpc1VzaW5nT3ZlcmFnZSIsImtleSIsInRleHQiLCJwcmlvcml0eSIsInQ2IiwidDciLCJjdXJyZW50IiwianN4Il0sInNvdXJjZXMiOlsidXNlUmF0ZUxpbWl0V2FybmluZ05vdGlmaWNhdGlvbi50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0ICogYXMgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyB1c2VFZmZlY3QsIHVzZU1lbW8sIHVzZVJlZiwgdXNlU3RhdGUgfSBmcm9tICdyZWFjdCdcbmltcG9ydCB7IHVzZU5vdGlmaWNhdGlvbnMgfSBmcm9tICdzcmMvY29udGV4dC9ub3RpZmljYXRpb25zLmpzJ1xuaW1wb3J0IHsgVGV4dCB9IGZyb20gJ3NyYy9pbmsuanMnXG5pbXBvcnQge1xuICBnZXRSYXRlTGltaXRXYXJuaW5nLFxuICBnZXRVc2luZ092ZXJhZ2VUZXh0LFxufSBmcm9tICdzcmMvc2VydmljZXMvY2xhdWRlQWlMaW1pdHMuanMnXG5pbXBvcnQgeyB1c2VDbGF1ZGVBaUxpbWl0cyB9IGZyb20gJ3NyYy9zZXJ2aWNlcy9jbGF1ZGVBaUxpbWl0c0hvb2suanMnXG5pbXBvcnQgeyBnZXRTdWJzY3JpcHRpb25UeXBlIH0gZnJvbSAnc3JjL3V0aWxzL2F1dGguanMnXG5pbXBvcnQgeyBoYXNDbGF1ZGVBaUJpbGxpbmdBY2Nlc3MgfSBmcm9tICdzcmMvdXRpbHMvYmlsbGluZy5qcydcbmltcG9ydCB7IGdldElzUmVtb3RlTW9kZSB9IGZyb20gJy4uLy4uL2Jvb3RzdHJhcC9zdGF0ZS5qcydcblxuZXhwb3J0IGZ1bmN0aW9uIHVzZVJhdGVMaW1pdFdhcm5pbmdOb3RpZmljYXRpb24obW9kZWw6IHN0cmluZyk6IHZvaWQge1xuICBjb25zdCB7IGFkZE5vdGlmaWNhdGlvbiB9ID0gdXNlTm90aWZpY2F0aW9ucygpXG4gIGNvbnN0IGNsYXVkZUFpTGltaXRzID0gdXNlQ2xhdWRlQWlMaW1pdHMoKVxuICAvLyBjbGF1ZGVBaUxpbWl0cyByZWZlcmVuY2UgaXMgc3RhYmxlIHVudGlsIHN0YXR1c0xpc3RlbmVycyBmaXJlIChBUElcbiAgLy8gcmVzcG9uc2UpLCBzbyB0aGVzZSBza2lwIHRoZSBJbnRsIGZvcm1hdHRpbmcgd29yayBvbiBtb3N0IFJFUEwgcmVuZGVycy5cbiAgY29uc3QgcmF0ZUxpbWl0V2FybmluZyA9IHVzZU1lbW8oXG4gICAgKCkgPT4gZ2V0UmF0ZUxpbWl0V2FybmluZyhjbGF1ZGVBaUxpbWl0cywgbW9kZWwpLFxuICAgIFtjbGF1ZGVBaUxpbWl0cywgbW9kZWxdLFxuICApXG4gIGNvbnN0IHVzaW5nT3ZlcmFnZVRleHQgPSB1c2VNZW1vKFxuICAgICgpID0+IGdldFVzaW5nT3ZlcmFnZVRleHQoY2xhdWRlQWlMaW1pdHMpLFxuICAgIFtjbGF1ZGVBaUxpbWl0c10sXG4gIClcbiAgY29uc3Qgc2hvd25XYXJuaW5nUmVmID0gdXNlUmVmPHN0cmluZyB8IG51bGw+KG51bGwpXG4gIGNvbnN0IHN1YnNjcmlwdGlvblR5cGUgPSBnZXRTdWJzY3JpcHRpb25UeXBlKClcbiAgY29uc3QgaGFzQmlsbGluZ0FjY2VzcyA9IGhhc0NsYXVkZUFpQmlsbGluZ0FjY2VzcygpXG4gIGNvbnN0IGlzVGVhbU9yRW50ZXJwcmlzZSA9XG4gICAgc3Vic2NyaXB0aW9uVHlwZSA9PT0gJ3RlYW0nIHx8IHN1YnNjcmlwdGlvblR5cGUgPT09ICdlbnRlcnByaXNlJ1xuXG4gIC8vIFRyYWNrIG92ZXJhZ2UgbW9kZSB0cmFuc2l0aW9uc1xuICBjb25zdCBbaGFzU2hvd25PdmVyYWdlTm90aWZpY2F0aW9uLCBzZXRIYXNTaG93bk92ZXJhZ2VOb3RpZmljYXRpb25dID1cbiAgICB1c2VTdGF0ZShmYWxzZSlcblxuICAvLyBTaG93IGltbWVkaWF0ZSBub3RpZmljYXRpb24gd2hlbiBlbnRlcmluZyBvdmVyYWdlIG1vZGVcbiAgdXNlRWZmZWN0KCgpID0+IHtcbiAgICBpZiAoZ2V0SXNSZW1vdGVNb2RlKCkpIHJldHVyblxuICAgIGlmIChcbiAgICAgIGNsYXVkZUFpTGltaXRzLmlzVXNpbmdPdmVyYWdlICYmXG4gICAgICAhaGFzU2hvd25PdmVyYWdlTm90aWZpY2F0aW9uICYmXG4gICAgICAoIWlzVGVhbU9yRW50ZXJwcmlzZSB8fCBoYXNCaWxsaW5nQWNjZXNzKVxuICAgICkge1xuICAgICAgYWRkTm90aWZpY2F0aW9uKHtcbiAgICAgICAga2V5OiAnbGltaXQtcmVhY2hlZCcsXG4gICAgICAgIHRleHQ6IHVzaW5nT3ZlcmFnZVRleHQsXG4gICAgICAgIHByaW9yaXR5OiAnaW1tZWRpYXRlJyxcbiAgICAgIH0pXG4gICAgICBzZXRIYXNTaG93bk92ZXJhZ2VOb3RpZmljYXRpb24odHJ1ZSlcbiAgICB9IGVsc2UgaWYgKCFjbGF1ZGVBaUxpbWl0cy5pc1VzaW5nT3ZlcmFnZSAmJiBoYXNTaG93bk92ZXJhZ2VOb3RpZmljYXRpb24pIHtcbiAgICAgIC8vIFJlc2V0IHdoZW4gbm8gbG9uZ2VyIGluIG92ZXJhZ2UgbW9kZVxuICAgICAgc2V0SGFzU2hvd25PdmVyYWdlTm90aWZpY2F0aW9uKGZhbHNlKVxuICAgIH1cbiAgfSwgW1xuICAgIGNsYXVkZUFpTGltaXRzLmlzVXNpbmdPdmVyYWdlLFxuICAgIHVzaW5nT3ZlcmFnZVRleHQsXG4gICAgaGFzU2hvd25PdmVyYWdlTm90aWZpY2F0aW9uLFxuICAgIGFkZE5vdGlmaWNhdGlvbixcbiAgICBoYXNCaWxsaW5nQWNjZXNzLFxuICAgIGlzVGVhbU9yRW50ZXJwcmlzZSxcbiAgXSlcblxuICAvLyBTaG93IHdhcm5pbmcgbm90aWZpY2F0aW9uIGZvciBhcHByb2FjaGluZyBsaW1pdHNcbiAgdXNlRWZmZWN0KCgpID0+IHtcbiAgICBpZiAoZ2V0SXNSZW1vdGVNb2RlKCkpIHJldHVyblxuICAgIGlmIChyYXRlTGltaXRXYXJuaW5nICYmIHJhdGVMaW1pdFdhcm5pbmcgIT09IHNob3duV2FybmluZ1JlZi5jdXJyZW50KSB7XG4gICAgICBzaG93bldhcm5pbmdSZWYuY3VycmVudCA9IHJhdGVMaW1pdFdhcm5pbmdcbiAgICAgIGFkZE5vdGlmaWNhdGlvbih7XG4gICAgICAgIGtleTogJ3JhdGUtbGltaXQtd2FybmluZycsXG4gICAgICAgIGpzeDogKFxuICAgICAgICAgIDxUZXh0PlxuICAgICAgICAgICAgPFRleHQgY29sb3I9XCJ3YXJuaW5nXCI+e3JhdGVMaW1pdFdhcm5pbmd9PC9UZXh0PlxuICAgICAgICAgIDwvVGV4dD5cbiAgICAgICAgKSxcbiAgICAgICAgcHJpb3JpdHk6ICdoaWdoJyxcbiAgICAgIH0pXG4gICAgfVxuICB9LCBbcmF0ZUxpbWl0V2FybmluZywgYWRkTm90aWZpY2F0aW9uXSlcbn1cbiJdLCJtYXBwaW5ncyI6IjtBQUFBLE9BQU8sS0FBS0EsS0FBSyxNQUFNLE9BQU87QUFDOUIsU0FBU0MsU0FBUyxFQUFFQyxPQUFPLEVBQUVDLE1BQU0sRUFBRUMsUUFBUSxRQUFRLE9BQU87QUFDNUQsU0FBU0MsZ0JBQWdCLFFBQVEsOEJBQThCO0FBQy9ELFNBQVNDLElBQUksUUFBUSxZQUFZO0FBQ2pDLFNBQ0VDLG1CQUFtQixFQUNuQkMsbUJBQW1CLFFBQ2QsZ0NBQWd Line truncated
+69
View File
@@ -0,0 +1,69 @@
import { c as _c } from "react/compiler-runtime";
import { useCallback, useEffect, useState } from 'react';
import { useNotifications } from 'src/context/notifications.js';
import { getIsRemoteMode } from '../../bootstrap/state.js';
import { getSettingsWithAllErrors } from '../../utils/settings/allErrors.js';
import type { ValidationError } from '../../utils/settings/validation.js';
import { useSettingsChange } from '../useSettingsChange.js';
const SETTINGS_ERRORS_NOTIFICATION_KEY = 'settings-errors';
export function useSettingsErrors() {
const $ = _c(6);
const {
addNotification,
removeNotification
} = useNotifications();
const [errors_0, setErrors] = useState(_temp);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = () => {
const {
errors: errors_1
} = getSettingsWithAllErrors();
setErrors(errors_1);
};
$[0] = t0;
} else {
t0 = $[0];
}
const handleSettingsChange = t0;
useSettingsChange(handleSettingsChange);
let t1;
let t2;
if ($[1] !== addNotification || $[2] !== errors_0 || $[3] !== removeNotification) {
t1 = () => {
if (getIsRemoteMode()) {
return;
}
if (errors_0.length > 0) {
const message = `Found ${errors_0.length} settings ${errors_0.length === 1 ? "issue" : "issues"} · /doctor for details`;
addNotification({
key: SETTINGS_ERRORS_NOTIFICATION_KEY,
text: message,
color: "warning",
priority: "high",
timeoutMs: 60000
});
} else {
removeNotification(SETTINGS_ERRORS_NOTIFICATION_KEY);
}
};
t2 = [errors_0, addNotification, removeNotification];
$[1] = addNotification;
$[2] = errors_0;
$[3] = removeNotification;
$[4] = t1;
$[5] = t2;
} else {
t1 = $[4];
t2 = $[5];
}
useEffect(t1, t2);
return errors_0;
}
function _temp() {
const {
errors
} = getSettingsWithAllErrors();
return errors;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJ1c2VDYWxsYmFjayIsInVzZUVmZmVjdCIsInVzZVN0YXRlIiwidXNlTm90aWZpY2F0aW9ucyIsImdldElzUmVtb3RlTW9kZSIsImdldFNldHRpbmdzV2l0aEFsbEVycm9ycyIsIlZhbGlkYXRpb25FcnJvciIsInVzZVNldHRpbmdzQ2hhbmdlIiwiU0VUVElOR1NfRVJST1JTX05PVElGSUNBVElPTl9LRVkiLCJ1c2VTZXR0aW5nc0Vycm9ycyIsIiQiLCJfYyIsImFkZE5vdGlmaWNhdGlvbiIsInJlbW92ZU5vdGlmaWNhdGlvbiIsImVycm9yc18wIiwic2V0RXJyb3JzIiwiX3RlbXAiLCJ0MCIsIlN5bWJvbCIsImZvciIsImVycm9ycyIsImVycm9yc18xIiwiaGFuZGxlU2V0dGluZ3NDaGFuZ2UiLCJ0MSIsInQyIiwibGVuZ3RoIiwibWVzc2FnZSIsImtleSIsInRleHQiLCJjb2xvciIsInByaW9yaXR5IiwidGltZW91dE1zIl0sInNvdXJjZXMiOlsidXNlU2V0dGluZ3NFcnJvcnMudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IHVzZUNhbGxiYWNrLCB1c2VFZmZlY3QsIHVzZVN0YXRlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgeyB1c2VOb3RpZmljYXRpb25zIH0gZnJvbSAnc3JjL2NvbnRleHQvbm90aWZpY2F0aW9ucy5qcydcbmltcG9ydCB7IGdldElzUmVtb3RlTW9kZSB9IGZyb20gJy4uLy4uL2Jvb3RzdHJhcC9zdGF0ZS5qcydcbmltcG9ydCB7IGdldFNldHRpbmdzV2l0aEFsbEVycm9ycyB9IGZyb20gJy4uLy4uL3V0aWxzL3NldHRpbmdzL2FsbEVycm9ycy5qcydcbmltcG9ydCB0eXBlIHsgVmFsaWRhdGlvbkVycm9yIH0gZnJvbSAnLi4vLi4vdXRpbHMvc2V0dGluZ3MvdmFsaWRhdGlvbi5qcydcbmltcG9ydCB7IHVzZVNldHRpbmdzQ2hhbmdlIH0gZnJvbSAnLi4vdXNlU2V0dGluZ3NDaGFuZ2UuanMnXG5cbmNvbnN0IFNFVFRJTkdTX0VSUk9SU19OT1RJRklDQVRJT05fS0VZID0gJ3NldHRpbmdzLWVycm9ycydcblxuZXhwb3J0IGZ1bmN0aW9uIHVzZVNldHRpbmdzRXJyb3JzKCk6IFZhbGlkYXRpb25FcnJvcltdIHtcbiAgY29uc3QgeyBhZGROb3RpZmljYXRpb24sIHJlbW92ZU5vdGlmaWNhdGlvbiB9ID0gdXNlTm90aWZpY2F0aW9ucygpXG4gIGNvbnN0IFtlcnJvcnMsIHNldEVycm9yc10gPSB1c2VTdGF0ZTxWYWxpZGF0aW9uRXJyb3JbXT4oKCkgPT4ge1xuICAgIGNvbnN0IHsgZXJyb3JzIH0gPSBnZXRTZXR0aW5nc1dpdGhBbGxFcnJvcnMoKVxuICAgIHJldHVybiBlcnJvcnNcbiAgfSlcblxuICBjb25zdCBoYW5kbGVTZXR0aW5nc0NoYW5nZSA9IHVzZUNhbGxiYWNrKCgpID0+IHtcbiAgICBjb25zdCB7IGVycm9ycyB9ID0gZ2V0U2V0dGluZ3NXaXRoQWxsRXJyb3JzKClcbiAgICBzZXRFcnJvcnMoZXJyb3JzKVxuICB9LCBbXSlcblxuICB1c2VTZXR0aW5nc0NoYW5nZShoYW5kbGVTZXR0aW5nc0NoYW5nZSlcblxuICB1c2VFZmZlY3QoKCkgPT4ge1xuICAgIGlmIChnZXRJc1JlbW90ZU1vZGUoKSkgcmV0dXJuXG4gICAgaWYgKGVycm9ycy5sZW5ndGggPiAwKSB7XG4gICAgICBjb25zdCBtZXNzYWdlID0gYEZvdW5kICR7ZXJyb3JzLmxlbmd0aH0gc2V0dGluZ3MgJHtlcnJvcnMubGVuZ3RoID09PSAxID8gJ2lzc3VlJyA6ICdpc3N1ZXMnfSDCtyAvZG9jdG9yIGZvciBkZXRhaWxzYFxuICAgICAgYWRkTm90aWZpY2F0aW9uKHtcbiAgICAgICAga2V5OiBTRVRUSU5HU19FUlJPUlNfTk9USUZJQ0FUSU9OX0tFWSxcbiAgICAgICAgdGV4dDogbWVzc2FnZSxcbiAgICAgICAgY29sb3I6ICd3YXJuaW5nJyxcbiAgICAgICAgcHJpb3JpdHk6ICdoaWdoJyxcbiAgICAgICAgdGltZW91dE1zOiA2MDAwMCxcbiAgICAgIH0pXG4gICAgfSBlbHNlIHtcbiAgICAgIHJlbW92ZU5vdGlmaWNhdGlvbihTRVRUSU5HU19FUlJPUlNfTk9USUZJQ0FUSU9OX0tFWSlcbiAgICB9XG4gIH0sIFtlcnJvcnMsIGFkZE5vdGlmaWNhdGlvbiwgcmVtb3ZlTm90aWZpY2F0aW9uXSlcblxuICByZXR1cm4gZXJyb3JzXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxTQUFTQSxXQUFXLEVBQUVDLFNBQVMsRUFBRUMsUUFBUSxRQUFRLE9BQU87QUFDeEQsU0FBU0MsZ0JBQWdCLFFBQVEsOEJBQThCO0FBQy9ELFNBQVNDLGVBQWUsUUFBUSwwQkFBMEI7QUFDMUQsU0FBU0Msd0JBQXdCLFFBQVEsbUNBQW1DO0FBQzVFLGNBQWNDLGVBQWUsUUFBUSxvQ0FBb0M7QUFDekUsU0FBU0MsaUJBQWlCLFFBQVEseUJBQXlCO0FBRTNELE1BQU1DLGdDQUFnQyxHQUFHLGlCQUFpQjtBQUUxRCxPQUFPLFNBQUFDLGtCQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQ0w7SUFBQUMsZUFBQTtJQUFBQztFQUFBLElBQWdEVixnQkFBZ0IsQ0FBQyxDQUFDO0VBQ2xFLE9BQUFXLFFBQUEsRUFBQUMsU0FBQSxJQUE0QmIsUUFBUSxDQUFvQmMsS0FHdkQsQ0FBQztFQUFBLElBQUFDLEVBQUE7RUFBQSxJQUFBUCxDQUFBLFFBQUFRLE1BQUEsQ0FBQUMsR0FBQTtJQUV1Q0YsRUFBQSxHQUFBQSxDQUFBO01BQ3ZDO1FBQUFHLE1BQUEsRUFBQUM7TUFBQSxJQUFtQmhCLHdCQUF3QixDQUFDLENBQUM7TUFDN0NVLFNBQVMsQ0FBQ0ssUUFBTSxDQUFDO0lBQUEsQ0FDbEI7SUFBQVYsQ0FBQSxNQUFBTyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBUCxDQUFBO0VBQUE7RUFIRCxNQUFBWSxvQkFBQSxHQUE2QkwsRUFHdkI7RUFFTlYsaUJBQWlCLENBQUNlLG9CQUFvQixDQUFDO0VBQUEsSUFBQUMsRUFBQTtFQUFBLElBQUFDLEVBQUE7RUFBQSxJQUFBZCxDQUFBLFFBQUFFLGVBQUEsSUFBQUYsQ0FBQSxRQUFBSSxRQUFBLElBQUFKLENBQUEsUUFBQUcsa0JBQUE7SUFFN0JVLEVBQUEsR0FBQUEsQ0FBQTtNQUNSLElBQUluQixlQUFlLENBQUMsQ0FBQztRQUFBO01BQUE7TUFDckIsSUFBSWdCLFFBQU0sQ0FBQUssTUFBTyxHQUFHLENBQUM7UUFDbkIsTUFBQUMsT0FBQSxHQUFnQixTQUFTTixRQUFNLENBQUFLLE1BQU8sYUFBYUwsUUFBTSxDQUFBSyxNQUFPLEtBQUssQ0FBc0IsR0FBeEMsT0FBd0MsR0FBeEMsUUFBd0Msd0JBQXdCO1FBQ25IYixlQUFlLENBQUM7VUFBQWUsR0FBQSxFQUNUbkIsZ0NBQWdDO1VBQUFvQixJQUFBLEVBQy9CRixPQUFPO1VBQUFHLEtBQUEsRUFDTixTQUFTO1VBQUFDLFFBQUEsRUFDTixNQUFNO1VBQUFDLFNBQUEsRUFDTDtRQUNiLENBQUMsQ0FBQztNQUFBO1FBRUZsQixrQkFBa0IsQ0FBQ0wsZ0NBQWdDLENBQUM7TUFBQTtJQUNyRCxDQUNGO0lBQUVnQixFQUFBLElBQUNKLFFBQU0sRUFBRVIsZUFBZSxFQUFFQyxrQkFBa0IsQ0FBQztJQUFBSCxDQUFBLE1BQUFFLGVBQUE7SUFBQUYsQ0FBQSxNQUFBSSxRQUFBO0lBQUFKLENBQUEsTUFBQUcsa0JBQUE7SUFBQUgsQ0FBQSxNQUFBYSxFQUFBO0lBQUFiLENBQUEsTUFBQWMsRUFBQTtFQUFBO0lBQUFELEVBQUEsR0FBQWIsQ0FBQTtJQUFBYyxFQUFBLEdBQUFkLENBQUE7RUFBQTtFQWRoRFQsU0FBUyxDQUFDc0IsRUFjVCxFQUFFQyxFQUE2QyxDQUFDO0VBQUEsT0FFMUNKLFFBQU07QUFBQTtBQTlCUixTQUFBSixNQUFBO0VBR0g7SUFBQUk7RUFBQSxJQUFtQmYsd0JBQXdCLENBQUMsQ0FBQztFQUFBLE9BQ3RDZSxNQUFNO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
@@ -0,0 +1,41 @@
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
/**
* Fires notification(s) once on mount. Encapsulates the remote-mode gate and
* once-per-session ref guard that was hand-rolled across 10+ notifs/ hooks.
*
* The compute fn runs exactly once on first effect. Return null to skip,
* a Notification to fire one, or an array to fire several. Sync or async.
* Rejections are routed to logError.
*/
export function useStartupNotification(
compute: () => Result | Promise<Result>,
): void {
const { addNotification } = useNotifications()
const hasRunRef = useRef(false)
const computeRef = useRef(compute)
computeRef.current = compute
useEffect(() => {
if (getIsRemoteMode() || hasRunRef.current) return
hasRunRef.current = true
void Promise.resolve()
.then(() => computeRef.current())
.then(result => {
if (!result) return
for (const n of Array.isArray(result) ? result : [result]) {
addNotification(n)
}
})
.catch(logError)
}, [addNotification])
}
@@ -0,0 +1,78 @@
import { useEffect, useRef } from 'react'
import { getIsRemoteMode } from '../../bootstrap/state.js'
import {
type Notification,
useNotifications,
} from '../../context/notifications.js'
import { useAppState } from '../../state/AppState.js'
import { isInProcessTeammateTask } from '../../tasks/InProcessTeammateTask/types.js'
function parseCount(notif: Notification): number {
if (!('text' in notif)) {
return 1
}
const match = notif.text.match(/^(\d+)/)
return match?.[1] ? parseInt(match[1], 10) : 1
}
function foldSpawn(acc: Notification, _incoming: Notification): Notification {
return makeSpawnNotif(parseCount(acc) + 1)
}
function makeSpawnNotif(count: number): Notification {
return {
key: 'teammate-spawn',
text: count === 1 ? '1 agent spawned' : `${count} agents spawned`,
priority: 'low',
timeoutMs: 5000,
fold: foldSpawn,
}
}
function foldShutdown(
acc: Notification,
_incoming: Notification,
): Notification {
return makeShutdownNotif(parseCount(acc) + 1)
}
function makeShutdownNotif(count: number): Notification {
return {
key: 'teammate-shutdown',
text: count === 1 ? '1 agent shut down' : `${count} agents shut down`,
priority: 'low',
timeoutMs: 5000,
fold: foldShutdown,
}
}
/**
* Fires batched notifications when in-process teammates spawn or shut down.
* Uses fold() to combine repeated events into a single notification
* like "3 agents spawned" or "2 agents shut down".
*/
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])
}
+51
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) {
// Voice recording: show only the cursor, no placeholder text
renderedPlaceholder =
showCursor && focus && terminalFocus ? invert(' ') : ''
} else {
renderedPlaceholder = chalk.dim(placeholder)
// Show inverse cursor only when both input and terminal are focused
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,
}
}
@@ -0,0 +1,388 @@
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 }
// Generic interface for permission queue operations, decoupled from React.
// In the REPL, these are backed by React state.
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
/**
* Atomically check-and-mark as resolved. Returns true if this caller
* won the race (nobody else has resolved yet), false otherwise.
* Use this in async callbacks BEFORE awaiting, to close the window
* between the `isResolved()` check and the actual `resolve()` call.
*/
claim(): boolean
}
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
},
}
}
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>
/**
* Create a PermissionQueueOps backed by a React state setter.
* This is the bridge between React's `setToolUseConfirmQueue` and the
* generic queue interface used by PermissionContext.
*/
function createPermissionQueueOps(
setToolUseConfirmQueue: React.Dispatch<
React.SetStateAction<ToolUseConfirm[]>
>,
): PermissionQueueOps {
return {
push(item: ToolUseConfirm) {
setToolUseConfirmQueue(queue => [...queue, item])
},
remove(toolUseID: string) {
setToolUseConfirmQueue(queue =>
queue.filter(item => item.toolUseID !== toolUseID),
)
},
update(toolUseID: string, patch: Partial<ToolUseConfirm>) {
setToolUseConfirmQueue(queue =>
queue.map(item =>
item.toolUseID === toolUseID ? { ...item, ...patch } : item,
),
)
},
}
}
export { createPermissionContext, createPermissionQueueOps, createResolveOnce }
export type {
PermissionContext,
PermissionApprovalSource,
PermissionQueueOps,
PermissionRejectionSource,
ResolveOnce,
}
@@ -0,0 +1,65 @@
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
}
/**
* Handles the coordinator worker permission flow.
*
* For coordinator workers, automated checks (hooks and classifier) are
* awaited sequentially before falling through to the interactive dialog.
*
* Returns a PermissionDecision if the automated checks resolved the
* permission, or null if the caller should fall through to the
* interactive dialog.
*/
async function handleCoordinatorPermission(
params: CoordinatorPermissionParams,
): Promise<PermissionDecision | null> {
const { ctx, updatedInput, suggestions, permissionMode } = params
try {
// 1. Try permission hooks first (fast, local)
const hookResult = await ctx.runHooks(
permissionMode,
suggestions,
updatedInput,
)
if (hookResult) return hookResult
// 2. Try classifier (slow, inference -- bash only)
const classifierResult = feature('BASH_CLASSIFIER')
? await ctx.tryClassifier?.(params.pendingClassifierCheck, updatedInput)
: null
if (classifierResult) {
return classifierResult
}
} catch (error) {
// If automated checks fail unexpectedly, fall through to show the dialog
// so the user can decide manually. Non-Error throws get a context prefix
// so the log is traceable — intentionally NOT toError(), which would drop
// the prefix.
if (error instanceof Error) {
logError(error)
} else {
logError(new Error(`Automated permission check failed: ${String(error)}`))
}
}
// 3. Neither resolved (or checks failed) -- fall through to dialog below.
// Hooks already ran, classifier already consumed.
return null
}
export { handleCoordinatorPermission }
export type { CoordinatorPermissionParams }
@@ -0,0 +1,536 @@
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
}
/**
* Handles the interactive (main-agent) permission flow.
*
* Pushes a ToolUseConfirm entry to the confirm queue with callbacks:
* onAbort, onAllow, onReject, recheckPermission, onUserInteraction.
*
* Runs permission hooks and bash classifier checks asynchronously in the
* background, racing them against user interaction. Uses a resolve-once
* guard and `userInteracted` flag to prevent multiple resolutions.
*
* This function does NOT return a Promise -- it sets up callbacks that
* eventually call `resolve()` to resolve the outer promise owned by
* the caller.
*/
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
// Hoisted so onDismissCheckmark (Esc during checkmark window) can also
// remove the abort listener — not just the timer callback.
let checkmarkAbortHandler: (() => void) | undefined
const bridgeRequestId = bridgeCallbacks ? randomUUID() : undefined
// Hoisted so local/hook/classifier wins can remove the pending channel
// entry. No "tell remote to dismiss" equivalent — the text sits in your
// phone, and a stale "yes abc123" after local-resolve falls through
// tryConsumeReply (entry gone) and gets enqueued as normal chat.
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() {
// Called when user starts interacting with the permission dialog
// (e.g., arrow keys, tab, typing feedback)
// Hide the classifier indicator since auto-approve is no longer possible
//
// Grace period: ignore interactions in the first 200ms to prevent
// accidental keypresses from canceling the classifier prematurely
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 // atomic check-and-mark before 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() (atomic check-and-mark), not isResolved() — the async
// hasPermissionsToUseTool call above opens a window where CCR
// could have responded in flight. Matches onAllow/onReject/hook
// paths. cancelRequest tells CCR to dismiss its prompt — without
// it, the web UI shows a stale prompt for a tool that's already
// executing (particularly visible when recheck is triggered by
// a CCR-initiated mode switch, the very case this callback exists
// for after useReplBridge started calling it).
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: Bridge permission response from CCR (claude.ai)
// When the bridge is connected, send the permission request to CCR and
// subscribe for a response. Whichever side (CLI or CCR) responds first
// wins via claim().
//
// All tools are forwarded — CCR's generic allow/deny modal handles any
// tool, and can return `updatedInput` when it has a dedicated renderer
// (e.g. plan edit). Tools whose local dialog injects fields (ReviewArtifact
// `selected`, AskUserQuestion `answers`) tolerate the field being missing
// so generic remote approval degrades gracefully instead of throwing.
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 // Local user/hook/classifier already responded
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 })
}
// Channel permission relay — races alongside the bridge block above. Send a
// permission prompt to every active channel (Telegram, iMessage, etc.) via
// its MCP send_message tool, then race the reply against local/bridge/hook/
// classifier. The inbound "yes abc123" is intercepted in the notification
// handler (useManageMCPConnections.ts) BEFORE enqueue, so it never reaches
// Claude as a conversation turn.
//
// Unlike the bridge block, this still guards on `requiresUserInteraction` —
// channel replies are pure yes/no with no `updatedInput` path. In practice
// the guard is dead code today: all three `requiresUserInteraction` tools
// (ExitPlanMode, AskUserQuestion, ReviewArtifact) return `isEnabled()===false`
// when channels are configured, so they never reach this handler.
//
// Fire-and-forget send: if callTool fails (channel down, tool missing),
// the subscription never fires and another racer wins. Graceful degradation
// — the local dialog is always there as the floor.
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) {
// Outbound is structured too (Kenneth's symmetry ask) — server owns
// message formatting for its platform (Telegram markdown, iMessage
// rich text, Discord embed). CC sends the RAW parts; server composes.
// The old callTool('send_message', {text,content,message}) triple-key
// hack is gone — no more guessing which arg name each plugin takes.
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 // refine for 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
// Wrap so BOTH the map delete AND the abort-listener teardown happen
// at every call site. The 6 channelUnsubscribe?.() sites after local/
// hook/classifier wins previously only deleted the map entry — the
// dead closure stayed registered on the session-scoped abort signal
// until the session ended. Not a functional bug (Map.delete is
// idempotent), but it held the closure alive.
const mapUnsub = channelCallbacks.onResponse(
channelRequestId,
response => {
if (!claim()) return // Another racer won
channelUnsubscribe?.() // both: map delete + listener remove
clearClassifierChecking(ctx.toolUseID)
clearClassifierIndicator()
ctx.removeFromQueue()
// Bridge is the other remote — tell it we're done.
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,
})
}
}
// Skip hooks if they were already awaited in the coordinator branch above
if (!awaitAutomatedChecksBeforeDialog) {
// Execute PermissionRequest hooks asynchronously
// If hook returns a decision before user responds, apply it
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)
})()
}
// Execute bash classifier check asynchronously (if applicable)
if (
feature('BASH_CLASSIFIER') &&
result.pendingClassifierCheck &&
ctx.tool.name === BASH_TOOL_NAME &&
!awaitAutomatedChecksBeforeDialog
) {
// UI indicator for "classifier running" — set here (not in
// toolExecution.ts) so commands that auto-allow via prefix rules
// don't flash the indicator for a split second before allow returns.
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
// Show auto-approved transition with dimmed options
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 }))
// Keep checkmark visible, then remove dialog.
// 3s if terminal is focused (user can see it), 1s if not.
// User can dismiss early with Esc via onDismissCheckmark.
const signal = ctx.toolUseContext.abortController.signal
checkmarkAbortHandler = () => {
if (checkmarkTransitionTimer) {
clearTimeout(checkmarkTransitionTimer)
checkmarkTransitionTimer = undefined
// Sibling Bash error can fire this (StreamingToolExecutor
// cascades via siblingAbortController) — must drop the
// cosmetic ✓ dialog or it blocks the next queued item.
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 => {
// Log classifier API errors for debugging but don't propagate them as interruptions
// These errors can be network failures, rate limits, or model issues - not user cancellations
logForDebugging(`Async classifier check failed: ${errorMessage(error)}`, {
level: 'error',
})
})
}
}
// --
export { handleInteractivePermission }
export type { InteractivePermissionParams }
@@ -0,0 +1,159 @@
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
}
/**
* Handles the swarm worker permission flow.
*
* When running as a swarm worker:
* 1. Tries classifier auto-approval for bash commands
* 2. Forwards the permission request to the leader via mailbox
* 3. Registers callbacks for when the leader responds
* 4. Sets the pending indicator while waiting
*
* Returns a PermissionDecision if the classifier auto-approves,
* or a Promise that resolves when the leader responds.
* Returns null if swarms are not enabled or this is not a swarm worker,
* so the caller can fall through to interactive handling.
*/
async function handleSwarmWorkerPermission(
params: SwarmWorkerPermissionParams,
): Promise<PermissionDecision | null> {
if (!isAgentSwarmsEnabled() || !isSwarmWorker()) {
return null
}
const { ctx, description, updatedInput, suggestions } = params
// For bash commands, try classifier auto-approval before forwarding to
// the leader. Agents await the classifier result (rather than racing it
// against user interaction like the main agent).
const classifierResult = feature('BASH_CLASSIFIER')
? await ctx.tryClassifier?.(params.pendingClassifierCheck, updatedInput)
: null
if (classifierResult) {
return classifierResult
}
// Forward permission request to the leader via mailbox
try {
const clearPendingRequest = (): void =>
ctx.toolUseContext.setAppState(prev => ({
...prev,
pendingWorkerRequest: null,
}))
const decision = await new Promise<PermissionDecision>(resolve => {
const { resolve: resolveOnce, claim } = createResolveOnce(resolve)
// Create the permission request
const request = createPermissionRequest({
toolName: ctx.tool.name,
toolUseId: ctx.toolUseID,
input: ctx.input,
description,
permissionSuggestions: suggestions,
})
// Register callback BEFORE sending the request to avoid race condition
// where leader responds before callback is registered
registerPermissionCallback({
requestId: request.id,
toolUseId: ctx.toolUseID,
async onAllow(
allowedInput: Record<string, unknown> | undefined,
permissionUpdates: PermissionUpdate[],
feedback?: string,
contentBlocks?: ContentBlockParam[],
) {
if (!claim()) return // atomic check-and-mark before await
clearPendingRequest()
// Merge the updated input with the original input
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))
},
})
// Now that callback is registered, send the request to the leader
void sendPermissionRequestViaMailbox(request)
// Show visual indicator that we're waiting for leader approval
ctx.toolUseContext.setAppState(prev => ({
...prev,
pendingWorkerRequest: {
toolName: ctx.tool.name,
toolUseId: ctx.toolUseID,
description,
},
}))
// If the abort signal fires while waiting for the leader response,
// resolve the promise with a cancel decision so it does not hang.
ctx.toolUseContext.abortController.signal.addEventListener(
'abort',
() => {
if (!claim()) return
clearPendingRequest()
ctx.logCancelled()
resolveOnce(ctx.cancelAndAbort(undefined, true))
},
{ once: true },
)
})
return decision
} catch (error) {
// If swarm permission submission fails, fall back to local handling
logError(toError(error))
// Continue to local UI handling below
return null
}
}
export { handleSwarmWorkerPermission }
export type { SwarmWorkerPermissionParams }
@@ -0,0 +1,238 @@
// Centralized analytics/telemetry logging for tool permission decisions.
// All permission approve/reject events flow through logPermissionDecision(),
// which fans out to Statsig analytics, OTel telemetry, and code-edit metrics.
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
}
// Discriminated union: 'accept' pairs with approval sources, 'reject' with rejection sources
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)
}
// Builds OTel counter attributes for code editing tools, enriching with
// language when the tool's target file path can be extracted from input
async function buildCodeEditToolAttributes(
tool: ToolType,
input: unknown,
decision: 'accept' | 'reject',
source: string,
): Promise<Record<string, string>> {
// Derive language from file path if the tool exposes one (e.g., 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 }),
}
}
// Flattens structured source into a string label for analytics/OTel events
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(),
// Only include wait time when the user was actually prompted (not auto-approved)
...(waitMs !== undefined && { waiting_for_user_permission_ms: waitMs }),
}
}
// Emits a distinct analytics event name per approval source for funnel analysis
function logApprovalEvent(
tool: ToolType,
messageId: string,
source: PermissionApprovalSource | 'config',
waitMs: number | undefined,
): void {
if (source === 'config') {
// Auto-approved by allowlist in settings -- no user wait time
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
}
}
// Rejections share a single event name, differentiated by metadata fields
function logRejectionEvent(
tool: ToolType,
messageId: string,
source: PermissionRejectionSource | 'config',
waitMs: number | undefined,
): void {
if (source === 'config') {
// Denied by denylist in settings
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),
// Distinguish hook rejections from user rejections via separate fields
...(source.type === 'hook'
? { isHook: true }
: {
hasFeedback:
source.type === 'user_reject' ? source.hasFeedback : false,
}),
})
}
// Single entry point for all permission decision logging. Called by permission
// handlers after every approve/reject. Fans out to: analytics events, OTel
// telemetry, code-edit OTel counters, and toolUseContext decision storage.
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
// Log the analytics event
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)
// Track code editing tool metrics
if (isCodeEditingTool(tool.name)) {
void buildCodeEditToolAttributes(tool, input, decision, sourceString).then(
attributes => getCodeEditToolDecisionCounter()?.add(1, attributes),
)
}
// Persist decision on the context so downstream code can inspect what happened
if (!toolUseContext.toolDecisions) {
toolUseContext.toolDecisions = new Map()
}
toolUseContext.toolDecisions.set(toolUseID, {
source: sourceString,
decision,
timestamp: Date.now(),
})
void logOTelEvent('tool_decision', {
decision,
source: sourceString,
tool_name: sanitizeToolNameForAnalytics(tool.name),
})
}
export { isCodeEditingTool, buildCodeEditToolAttributes, logPermissionDecision }
export type { PermissionLogContext, PermissionDecisionArgs }
+202
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
/**
* Creates a unified suggestion item from a source
*/
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]
// Score non-file sources with Fuse.js
// File sources are already scored by Rust/nucleo
type ScoredSource = { source: SuggestionSource; score: number }
const scoredResults: ScoredSource[] = []
// Add file sources with their nucleo scores (already 0-1, lower is better)
for (const fileSource of fileSources) {
scoredResults.push({
source: fileSource,
score: fileSource.score ?? 0.5, // Default to middle score if missing
})
}
// Score non-file sources with Fuse.js and add them
if (nonFileSources.length > 0) {
const fuse = new Fuse(nonFileSources, {
includeScore: true,
threshold: 0.6, // Allow more matches through, we'll sort by score
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,
})
}
}
// Sort all results by score (lower is better) and return top results
scoredResults.sort((a, b) => a.score - b.score)
return scoredResults
.slice(0, MAX_UNIFIED_SUGGESTIONS)
.map(r => r.source)
.map(createSuggestionFromSource)
}
+17
View File
@@ -0,0 +1,17 @@
import { useEffect } from 'react'
import { isEnvTruthy } from '../utils/envUtils.js'
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)
}
}, [])
}
+84
View File
@@ -0,0 +1,84 @@
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
}
export function useApiKeyVerification(): ApiKeyVerificationResult {
const [status, setStatus] = useState<VerificationStatus>(() => {
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {
return 'valid'
}
// Use skipRetrievingKeyFromApiKeyHelper to avoid executing apiKeyHelper
// before trust dialog is shown (security: prevents RCE via settings.json)
const { key, source } = getAnthropicApiKeyWithSource({
skipRetrievingKeyFromApiKeyHelper: true,
})
// If apiKeyHelper is configured, we have a key source even though we
// haven't executed it yet - return 'loading' to indicate we'll verify later
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
}
// Warm the apiKeyHelper cache (no-op if not configured), then read from
// all sources. getAnthropicApiKeyWithSource() reads the now-warm cache.
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) {
// This happens when there an error response from the API but it's not an invalid API key error
// In this case, we still mark the API key as invalid - but we also log the error so we can
// display it to the user to be more helpful
setError(error as Error)
const newStatus = 'error'
setStatus(newStatus)
return
}
}, [])
return {
status,
reverify: verify,
error,
}
}
+229
View File
@@ -0,0 +1,229 @@
import React, { useCallback, useRef, useState } from 'react';
import { getModeFromInput } from 'src/components/PromptInput/inputModes.js';
import { useNotifications } from 'src/context/notifications.js';
import { ConfigurableShortcutHint } from '../components/ConfigurableShortcutHint.js';
import { FOOTER_TEMPORARY_STATUS_TIMEOUT } from '../components/PromptInput/Notifications.js';
import { getHistory } from '../history.js';
import { Text } from '../ink.js';
import type { PromptInputMode } from '../types/textInputTypes.js';
import type { HistoryEntry, PastedContent } from '../utils/config.js';
export type HistoryMode = PromptInputMode;
// Load history entries in chunks to reduce disk reads on rapid keypresses
const HISTORY_CHUNK_SIZE = 10;
// Shared state for batching concurrent load requests into a single disk read
// Mode filter is included to ensure we don't mix filtered and unfiltered caches
let pendingLoad: Promise<HistoryEntry[]> | null = null;
let pendingLoadTarget = 0;
let pendingLoadModeFilter: HistoryMode | undefined = undefined;
async function loadHistoryEntries(minCount: number, modeFilter?: HistoryMode): Promise<HistoryEntry[]> {
// Round up to next chunk to avoid repeated small reads
const target = Math.ceil(minCount / HISTORY_CHUNK_SIZE) * HISTORY_CHUNK_SIZE;
// If a load is already pending with the same mode filter and will satisfy our needs, wait for it
if (pendingLoad && pendingLoadTarget >= target && pendingLoadModeFilter === modeFilter) {
return pendingLoad;
}
// If a load is pending but won't satisfy our needs or has different filter, we need to wait for it
// to complete first, then start a new one (can't interrupt an ongoing read)
if (pendingLoad) {
await pendingLoad;
}
// Start a new load
pendingLoadTarget = target;
pendingLoadModeFilter = modeFilter;
pendingLoad = (async () => {
const entries: HistoryEntry[] = [];
let loaded = 0;
for await (const entry of getHistory()) {
// If mode filter is specified, only include entries that match the mode
if (modeFilter) {
const entryMode = getModeFromInput(entry.display);
if (entryMode !== modeFilter) {
continue;
}
}
entries.push(entry);
loaded++;
if (loaded >= pendingLoadTarget) break;
}
return entries;
})();
try {
return await pendingLoad;
} finally {
pendingLoad = null;
pendingLoadTarget = 0;
pendingLoadModeFilter = undefined;
}
}
export function useArrowKeyHistory(onSetInput: (value: string, mode: HistoryMode, pastedContents: Record<number, PastedContent>) => void, currentInput: string, pastedContents: Record<number, PastedContent>, setCursorOffset?: (offset: number) => void, currentMode?: HistoryMode): {
historyIndex: number;
setHistoryIndex: (index: number) => void;
onHistoryUp: () => void;
onHistoryDown: () => boolean;
resetHistory: () => void;
dismissSearchHint: () => void;
} {
const [historyIndex, setHistoryIndex] = useState(0);
const [lastShownHistoryEntry, setLastShownHistoryEntry] = useState<(HistoryEntry & {
mode?: HistoryMode;
}) | undefined>(undefined);
const hasShownSearchHintRef = useRef(false);
const {
addNotification,
removeNotification
} = useNotifications();
// Cache loaded history entries
const historyCache = useRef<HistoryEntry[]>([]);
// Track which mode filter the cache was loaded with
const historyCacheModeFilter = useRef<HistoryMode | undefined>(undefined);
// Synchronous tracker for history index to avoid stale closure issues
// React state updates are async, so rapid keypresses can see stale values
const historyIndexRef = useRef(0);
// Track the mode filter that was active when history navigation started
// This is set on the first arrow press and stays fixed until reset
const initialModeFilterRef = useRef<HistoryMode | undefined>(undefined);
// Refs to track current input values for draft preservation
// These ensure we capture the draft with the latest values, not stale closure values
const currentInputRef = useRef(currentInput);
const pastedContentsRef = useRef(pastedContents);
const currentModeRef = useRef(currentMode);
// Keep refs in sync with props (synchronous update on each render)
currentInputRef.current = currentInput;
pastedContentsRef.current = pastedContents;
currentModeRef.current = currentMode;
const setInputWithCursor = useCallback((value: string, mode: HistoryMode, contents: Record<number, PastedContent>, cursorToStart = false): void => {
onSetInput(value, mode, contents);
setCursorOffset?.(cursorToStart ? 0 : value.length);
}, [onSetInput, setCursorOffset]);
const updateInput = useCallback((input: HistoryEntry | undefined, cursorToStart_0 = false): void => {
if (!input || !input.display) return;
const mode_0 = getModeFromInput(input.display);
const value_0 = mode_0 === 'bash' ? input.display.slice(1) : input.display;
setInputWithCursor(value_0, mode_0, input.pastedContents ?? {}, cursorToStart_0);
}, [setInputWithCursor]);
const showSearchHint = useCallback((): void => {
addNotification({
key: 'search-history-hint',
jsx: <Text dimColor>
<ConfigurableShortcutHint action="history:search" context="Global" fallback="ctrl+r" description="search history" />
</Text>,
priority: 'immediate',
timeoutMs: FOOTER_TEMPORARY_STATUS_TIMEOUT
});
}, [addNotification]);
const onHistoryUp = useCallback((): void => {
// Capture and increment synchronously to handle rapid keypresses
const targetIndex = historyIndexRef.current;
historyIndexRef.current++;
const inputAtPress = currentInputRef.current;
const pastedContentsAtPress = pastedContentsRef.current;
const modeAtPress = currentModeRef.current;
if (targetIndex === 0) {
initialModeFilterRef.current = modeAtPress === 'bash' ? modeAtPress : undefined;
// Save draft synchronously using refs for the latest values
// This ensures we capture the draft before any async operations or re-renders
const hasInput = inputAtPress.trim() !== '';
setLastShownHistoryEntry(hasInput ? {
display: inputAtPress,
pastedContents: pastedContentsAtPress,
mode: modeAtPress
} : undefined);
}
const modeFilter = initialModeFilterRef.current;
void (async () => {
const neededCount = targetIndex + 1; // How many entries we need
// If mode filter changed, invalidate cache
if (historyCacheModeFilter.current !== modeFilter) {
historyCache.current = [];
historyCacheModeFilter.current = modeFilter;
historyIndexRef.current = 0;
}
// Load more entries if needed
if (historyCache.current.length < neededCount) {
// Batches concurrent requests - rapid keypresses share a single disk read
const entries = await loadHistoryEntries(neededCount, modeFilter);
// Only update cache if we loaded more than currently cached
// (handles race condition where multiple loads complete out of order)
if (entries.length > historyCache.current.length) {
historyCache.current = entries;
}
}
// Check if we can navigate
if (targetIndex >= historyCache.current.length) {
// Rollback the ref since we can't navigate
historyIndexRef.current--;
// Keep the draft intact - user stays on their current input
return;
}
const newIndex = targetIndex + 1;
setHistoryIndex(newIndex);
updateInput(historyCache.current[targetIndex], true);
// Show hint once per session after navigating through 2 history entries
if (newIndex >= 2 && !hasShownSearchHintRef.current) {
hasShownSearchHintRef.current = true;
showSearchHint();
}
})();
}, [updateInput, showSearchHint]);
const onHistoryDown = useCallback((): boolean => {
// Use the ref for consistent reads
const currentIndex = historyIndexRef.current;
if (currentIndex > 1) {
historyIndexRef.current--;
setHistoryIndex(currentIndex - 1);
updateInput(historyCache.current[currentIndex - 2]);
} else if (currentIndex === 1) {
historyIndexRef.current = 0;
setHistoryIndex(0);
if (lastShownHistoryEntry) {
// Restore the draft with its saved mode if available
const savedMode = lastShownHistoryEntry.mode;
if (savedMode) {
setInputWithCursor(lastShownHistoryEntry.display, savedMode, lastShownHistoryEntry.pastedContents ?? {});
} else {
updateInput(lastShownHistoryEntry);
}
} else {
// When in filtered mode, stay in that mode when clearing input
setInputWithCursor('', initialModeFilterRef.current ?? 'prompt', {});
}
}
return currentIndex <= 0;
}, [lastShownHistoryEntry, updateInput, setInputWithCursor]);
const resetHistory = useCallback((): void => {
setLastShownHistoryEntry(undefined);
setHistoryIndex(0);
historyIndexRef.current = 0;
initialModeFilterRef.current = undefined;
removeNotification('search-history-hint');
historyCache.current = [];
historyCacheModeFilter.current = undefined;
}, [removeNotification]);
const dismissSearchHint = useCallback((): void => {
removeNotification('search-history-hint');
}, [removeNotification]);
return {
historyIndex,
setHistoryIndex,
onHistoryUp,
onHistoryDown,
resetHistory,
dismissSearchHint
};
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZUNhbGxiYWNrIiwidXNlUmVmIiwidXNlU3RhdGUiLCJnZXRNb2RlRnJvbUlucHV0IiwidXNlTm90aWZpY2F0aW9ucyIsIkNvbmZpZ3VyYWJsZVNob3J0Y3V0SGludCIsIkZPT1RFUl9URU1QT1JBUllfU1RBVFVTX1RJTUVPVVQiLCJnZXRIaXN0b3J5IiwiVGV4dCIsIlByb21wdElucHV0TW9kZSIsIkhpc3RvcnlFbnRyeSIsIlBhc3RlZENvbnRlbnQiLCJIaXN0b3J5TW9kZSIsIkhJU1RPUllfQ0hVTktfU0laRSIsInBlbmRpbmdMb2FkIiwiUHJvbWlzZSIsInBlbmRpbmdMb2FkVGFyZ2V0IiwicGVuZGluZ0xvYWRNb2RlRmlsdGVyIiwidW5kZWZpbmVkIiwibG9hZEhpc3RvcnlFbnRyaWVzIiwibWluQ291bnQiLCJtb2RlRmlsdGVyIiwidGFyZ2V0IiwiTWF0aCIsImNlaWwiLCJlbnRyaWVzIiwibG9hZGVkIiwiZW50cnkiLCJlbnRyeU1vZGUiLCJkaXNwbGF5IiwicHVzaCIsInVzZUFycm93S2V5SGlzdG9yeSIsIm9uU2V0SW5wdXQiLCJ2YWx1ZSIsIm1vZGUiLCJwYXN0ZWRDb250ZW50cyIsIlJlY29yZCIsImN1cnJlbnRJbnB1dCIsInNldEN1cnNvck9mZnNldCIsIm9mZnNldCIsImN1cnJlbnRNb2RlIiwiaGlzdG9yeUluZGV4Iiwic2V0SGlzdG9yeUluZGV4IiwiaW5kZXgiLCJvbkhpc3RvcnlVcCIsIm9uSGlzdG9yeURvd24iLCJyZXNldEhpc3RvcnkiLCJkaXNtaXNzU2VhcmNoSGludCIsImxhc3RTaG93bkhpc3RvcnlFbnRyeSIsInNldExhc3RTaG93bkhpc3RvcnlFbnRyeSIsImhhc1Nob3duU2VhcmNoSGludFJlZiIsImFkZE5vdGlmaWNhdGlvbiIsInJlbW92ZU5vdGlmaWNhdGlvbiIsImhpc3RvcnlDYWNoZSIsImhpc3RvcnlDYWNoZU1vZGVGaWx0ZXIiLCJoaXN0b3J5SW5kZXhSZWYiLCJpbml0aWFsTW9kZUZpbHRlclJlZiIsImN1cnJlbnRJbnB1dFJlZiIsInBhc3RlZENvbnRlbnRzUmVmIiwiY3VycmVudE1vZGVSZWYiLCJjdXJyZW50Iiwic2V0SW5wdXRXaXRoQ3Vyc29yIiwiY29udGVudHMiLCJjdXJzb3JUb1N0YXJ0IiwibGVuZ3RoIiwidXBkYXRlSW5wdXQiLCJpbnB1dCIsInNsaWNlIiwic2hvd1NlYXJjaEhpbnQiLCJrZXkiLCJqc3giLCJwcmlvcml0eSIsInRpbWVvdXRNcyIsInRhcmdldEluZGV4IiwiaW5wdXRBdFByZXNzIiwicGFzdGVkQ29udGVudHNBdFByZXNzIiwibW9kZUF0UHJlc3MiLCJoYXNJbnB1dCIsInRyaW0iLCJuZWVkZWRDb3VudCIsIm5ld0luZGV4IiwiY3VycmVudEluZGV4Iiwic2F2ZWRNb2RlIl0sInNvdXJjZXMiOlsidXNlQXJyb3dLZXlIaXN0b3J5LnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QsIHsgdXNlQ2FsbGJhY2ssIHVzZVJlZiwgdXNlU3RhdGUgfSBmcm9tICdyZWFjdCdcbmltcG9ydCB7IGdldE1vZGVGcm9tSW5wdXQgfSBmcm9tICdzcmMvY29tcG9uZW50cy9Qcm9tcHRJbnB1dC9pbnB1dE1vZGVzLmpzJ1xuaW1wb3J0IHsgdXNlTm90aWZpY2F0aW9ucyB9IGZyb20gJ3NyYy9jb250ZXh0L25vdGlmaWNhdGlvbnMuanMnXG5pbXBvcnQgeyBDb25maWd1cmFibGVTaG9ydGN1dEhpbnQgfSBmcm9tICcuLi9jb21wb25lbnRzL0NvbmZpZ3VyYWJsZVNob3J0Y3V0SGludC5qcydcbmltcG9ydCB7IEZPT1RFUl9URU1QT1JBUllfU1RBVFVTX1RJTUVPVVQgfSBmcm9tICcuLi9jb21wb25lbnRzL1Byb21wdElucHV0L05vdGlmaWNhdGlvbnMuanMnXG5pbXBvcnQgeyBnZXRIaXN0b3J5IH0gZnJvbSAnLi4vaGlzdG9yeS5qcydcbmltcG9ydCB7IFRleHQgfSBmcm9tICcuLi9pbmsuanMnXG5pbXBvcnQgdHlwZSB7IFByb21wdElucHV0TW9kZSB9IGZyb20gJy4uL3R5cGVzL3RleHRJbnB1dFR5cGVzLmpzJ1xuaW1wb3J0IHR5cGUgeyBIaXN0b3J5RW50cnksIFBhc3RlZENvbnRlbnQgfSBmcm9tICcuLi91dGlscy9jb25maWcuanMnXG5cbmV4cG9ydCB0eXBlIEhpc3RvcnlNb2RlID0gUHJvbXB0SW5wdXRNb2RlXG5cbi8vIExvYWQgaGlzdG9yeSBlbnRyaWVzIGluIGNodW5rcyB0byByZWR1Y2UgZGlzayByZWFkcyBvbiByYXBpZCBrZXlwcmVzc2VzXG5jb25zdCBISVNUT1JZX0NIVU5LX1NJWkUgPSAxMFxuXG4vLyBTaGFyZWQgc3RhdGUgZm9yIGJhdGNoaW5nIGNvbmN1cnJlbnQgbG9hZCByZXF1ZXN0cyBpbnRvIGEgc2luZ2xlIGRpc2sgcmVhZFxuLy8gTW9kZSBmaWx0ZXIgaXMgaW5jbHVkZWQgdG8gZW5zdXJlIHdlIGRvbid0IG1peCBmaWx0ZXJlZCBhbmQgdW5maWx0ZXJlZCBjYWNoZXNcbmxldCBwZW5kaW5nTG9hZDogUHJvbWlzZTxIaXN0b3J5RW50cnlbXT4gfCBudWxsID0gbnVsbFxubGV0IHBlbmRpbmdMb2FkVGFyZ2V0ID0gMFxubGV0IHBlbmRpbmdMb2FkTW9kZUZpbHRlcjogSGlzdG9yeU1vZGUgfCB1bmRlZmluZWQgPSB1bmRlZmluZWRcblxuYXN5bmMgZnVuY3Rpb24gbG9hZEhpc3RvcnlFbnRyaWVzKFxuICBtaW5Db3VudDogbnVtYmVyLFxuICBtb2RlRmlsdGVyPzogSGlzdG9yeU1vZGUsXG4pOiBQcm9taXNlPEhpc3RvcnlFbnRyeVtdPiB7XG4gIC8vIFJvdW5kIHVwIHRvIG5leHQgY2h1bmsgdG8gYXZvaWQgcmVwZWF0ZWQgc21hbGwgcmVhZHNcbiAgY29uc3QgdGFyZ2V0ID0gTWF0aC5jZWlsKG1pbkNvdW50IC8gSElTVE9SWV9DSFVOS19TSVpFKSAqIEhJU1RPUllfQ0hVTktfU0laRVxuXG4gIC8vIElmIGEgbG9hZCBpcyBhbHJlYWR5IHBlbmRpbmcgd2l0aCB0aGUgc2FtZSBtb2RlIGZpbHRlciBhbmQgd2lsbCBzYXRpc2Z5IG91ciBuZWVkcywgd2FpdCBmb3IgaXRcbiAgaWYgKFxuICAgIHBlbmRpbmdMb2FkICYmXG4gICAgcGVuZGluZ0xvYWRUYXJnZXQgPj0gdGFyZ2V0ICYmXG4gICAgcGVuZGluZ0xvYWRNb2RlRmlsdGVyID09PSBtb2RlRmlsdGVyXG4gICkge1xuICAgIHJldHVybiBwZW5kaW5nTG9hZFxuICB9XG5cbiAgLy8gSWYgYSBsb2FkIGlzIHBlbmRpbmcgYnV0IHdvbid0IHNhdGlzZnkgb3VyIG5lZWRzIG9yIGhhcyBkaWZmZXJlbnQgZmlsdGVyLCB3ZSBuZWVkIHRvIHdhaXQgZm9yIGl0XG4gIC8vIHRvIGNvbXBsZXRlIGZpcnN0LCB0aGVuIHN0YXJ0IGEgbmV3IG9uZSAoY2FuJ3QgaW50ZXJydXB0IGFuIG9uZ29pbmcgcmVhZClcbiAgaWYgKHBlbmRpbmdMb2FkKSB7XG4gICAgYXdhaXQgcGVuZGluZ0xvYWRcbiAgfVxuXG4gIC8vIFN0YXJ0IGEgbmV3IGxvYWRcbiAgcGVuZGluZ0xvYWRUYXJnZXQgPSB0YXJnZXRcbiAgcGVuZGluZ0xvYWRNb2RlRmlsdGVyID0gbW9kZUZpbHRlclxuICBwZW5kaW5nTG9hZCA9IChhc3luYyAoKSA9PiB7XG4gICAgY29uc3QgZW50cmllczogSGlzdG9yeUVudHJ5W10gPSBbXVxuICAgIGxldCBsb2FkZWQgPSAwXG4gICAgZm9yIGF3YWl0IChjb25zdCBlbnRyeSBvZiBnZXRIaXN0b3J5KCkpIHtcbiAgICAgIC8vIElmIG1vZGUgZmlsdGVyIGlzIHNwZWNpZmllZCwgb25seSBpbmNsdWRlIGVudHJpZXMgdGhhdCBtYXRjaCB0aGUgbW9kZVxuICAgICAgaWYgKG1vZGVGaWx0ZXIpIHtcbiAgICAgICAgY29uc3QgZW50cnlNb2RlID0gZ2V0TW9kZUZyb21JbnB1dChlbnRyeS5kaXNwbGF5KVxuICAgICAgICBpZiAoZW50cnlNb2RlICE9PSBtb2RlRmlsdGVyKSB7XG4gICAgICAgICAgY29udGludWVcbiAgICAgICAgfVxuICAgICAgfVxuICAgICAgZW50cmllcy5wdXNoKGVudHJ5KVxuICAgICAgbG9hZGVkKytcbiAgICAgIGlmIChsb2FkZWQgPj0gcGVuZGluZ0xvYWRUYXJnZXQpIGJyZWFrXG4gICAgfVxuICAgIHJldHVybiBlbnRyaWVzXG4gIH0pKCl Line truncated
+250
View File
@@ -0,0 +1,250 @@
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
}
/**
* Lazy-load `claude assistant` history on scroll-up.
*
* On mount: fetch newest page via anchor_to_latest, prepend to messages.
* On scroll-up near top: fetch next-older page via before_id, prepend with
* scroll anchoring (viewport stays put).
*
* No-op unless config.viewerOnly. REPL only calls this hook inside a
* feature('KAIROS') gate, so build-time elimination is handled there.
*/
export function useAssistantHistory({
config,
setMessages,
scrollRef,
onPrepend,
}: Props): Result {
const enabled = config?.viewerOnly === true
// Cursor state: ref-only (no re-render on cursor change). `null` = no
// older pages. `undefined` = initial page not fetched yet.
const cursorRef = useRef<string | null | undefined>(undefined)
const ctxRef = useRef<HistoryAuthCtx | null>(null)
const inflightRef = useRef(false)
// Scroll-anchor: snapshot height + prepended count before setMessages;
// compensate in useLayoutEffect after React commits. getFreshScrollHeight
// reads Yoga directly so the value is correct post-commit.
const anchorRef = useRef<{ beforeHeight: number; count: number } | null>(null)
// Fill-viewport chaining: after the initial page commits, if content doesn't
// fill the viewport yet, load another page. Self-chains via the layout effect
// until filled or the budget runs out. Budget set once on initial load; user
// scroll-ups don't need it (maybeLoadOlder re-fires on next wheel event).
const fillBudgetRef = useRef(0)
// Stable sentinel UUID — reused across swaps so virtual-scroll treats it
// as one item (text-only mutation, not remove+insert).
const sentinelUuidRef = useRef(randomUUID())
function mkSentinel(text: string): SystemInformationalMessage {
return {
type: 'system',
subtype: 'informational',
content: text,
isMeta: false,
timestamp: new Date().toISOString(),
uuid: sentinelUuidRef.current,
level: 'info',
}
}
/** Prepend a page at the front, with scroll-anchor snapshot for non-initial.
* Replaces the sentinel (always at index 0 when present) in-place. */
const prepend = useCallback(
(page: HistoryPage, isInitial: boolean) => {
const msgs = pageToMessages(page)
cursorRef.current = page.hasMore ? page.firstId : null
if (!isInitial) {
const s = scrollRef.current
anchorRef.current = s
? { beforeHeight: s.getFreshScrollHeight(), count: msgs.length }
: null
}
const sentinel = page.hasMore ? null : mkSentinel(SENTINEL_START)
setMessages(prev => {
// Drop existing sentinel (index 0, known stable UUID — O(1)).
const base =
prev[0]?.uuid === sentinelUuidRef.current ? prev.slice(1) : prev
return sentinel ? [sentinel, ...msgs, ...base] : [...msgs, ...base]
})
logForDebugging(
`[useAssistantHistory] ${isInitial ? 'initial' : 'older'} page: ${msgs.length} msgs (raw ${page.events.length}), hasMore=${page.hasMore}`,
)
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- scrollRef is a stable ref; mkSentinel reads refs only
[setMessages],
)
// Initial fetch on mount — best-effort.
useEffect(() => {
if (!enabled || !config) return
let cancelled = false
void (async () => {
const ctx = await createHistoryAuthCtx(config.sessionId).catch(() => null)
if (!ctx || cancelled) return
ctxRef.current = ctx
const page = await fetchLatestEvents(ctx)
if (cancelled || !page) return
fillBudgetRef.current = MAX_FILL_PAGES
prepend(page, true)
})()
return () => {
cancelled = true
}
// config identity is stable (created once in main.tsx, never recreated)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled])
const loadOlder = useCallback(async () => {
if (!enabled || inflightRef.current) return
const cursor = cursorRef.current
const ctx = ctxRef.current
if (!cursor || !ctx) return // null=exhausted, undefined=initial pending
inflightRef.current = true
// Swap sentinel to "loading…" — O(1) slice since sentinel is at index 0.
setMessages(prev => {
const base =
prev[0]?.uuid === sentinelUuidRef.current ? prev.slice(1) : prev
return [mkSentinel(SENTINEL_LOADING), ...base]
})
try {
const page = await fetchOlderEvents(ctx, cursor)
if (!page) {
// Fetch failed — revert sentinel back to "start" placeholder so the user
// can retry on next scroll-up. Cursor is preserved (not nulled out).
setMessages(prev => {
const base =
prev[0]?.uuid === sentinelUuidRef.current ? prev.slice(1) : prev
return [mkSentinel(SENTINEL_LOADING_FAILED), ...base]
})
return
}
prepend(page, false)
} finally {
inflightRef.current = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- mkSentinel reads refs only
}, [enabled, prepend, setMessages])
// Scroll-anchor compensation — after React commits the prepended items,
// shift scrollTop by the height delta so the viewport stays put. Also
// fire onPrepend here (not in prepend()) so dividerIndex + baseline ref
// are shifted with the ACTUAL height delta, not an estimate.
// No deps: runs every render; cheap no-op when anchorRef is null.
useLayoutEffect(() => {
const anchor = anchorRef.current
if (anchor === null) return
anchorRef.current = null
const s = scrollRef.current
if (!s || s.isSticky()) return // sticky = pinned bottom; prepend is invisible
const delta = s.getFreshScrollHeight() - anchor.beforeHeight
if (delta > 0) s.scrollBy(delta)
onPrepend?.(anchor.count, delta)
})
// Fill-viewport chain: after paint, if content doesn't exceed the viewport,
// load another page. Runs as useEffect (not layout effect) so Ink has
// painted and scrollViewportHeight is populated. Self-chains via next
// render's effect; budget caps the chain.
//
// The ScrollBox content wrapper has flexGrow:1 flexShrink:0 — it's clamped
// to ≥ viewport. So `content < viewport` is never true; `<=` detects "no
// overflow yet" correctly. Stops once there's at least something to scroll.
useEffect(() => {
if (
fillBudgetRef.current <= 0 ||
!cursorRef.current ||
inflightRef.current
) {
return
}
const s = scrollRef.current
if (!s) return
const contentH = s.getFreshScrollHeight()
const viewH = s.getViewportHeight()
logForDebugging(
`[useAssistantHistory] fill-check: content=${contentH} viewport=${viewH} budget=${fillBudgetRef.current}`,
)
if (contentH <= viewH) {
fillBudgetRef.current--
void loadOlder()
} else {
fillBudgetRef.current = 0
}
})
// Trigger wrapper for onScroll composition in REPL.
const maybeLoadOlder = useCallback(
(handle: ScrollBoxHandle) => {
if (handle.getScrollTop() < PREFETCH_THRESHOLD_ROWS) void loadOlder()
},
[loadOlder],
)
return { maybeLoadOlder }
}
+125
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
}
/**
* Appends a "while you were away" summary message after the terminal has been
* blurred for 5 minutes. Fires only when (a) 5min since blur, (b) no turn in
* progress, and (c) no existing away_summary since the last user message.
*
* Focus state 'unknown' (terminal doesn't support DECSET 1004) is a no-op.
*/
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 default: 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' → no-op
}
const unsubscribe = subscribeTerminalFocus(onFocusChange)
// Handle the case where we're already blurred when the effect mounts
onFocusChange()
generateRef.current = generate
return () => {
unsubscribe()
clearTimer()
abortInFlight()
generateRef.current = null
}
}, [gbEnabled, setMessages])
// Timer fired mid-turn → fire when turn ends (if still blurred)
useEffect(() => {
if (isLoading) return
if (!pendingRef.current) return
if (getTerminalFocusState() !== 'blurred') return
void generateRef.current?.()
}, [isLoading])
}
+251
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 -- backward-compat bridge until REPL wires handleKeyDown to <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'
// Step teammate selection by delta, wrapping across leader(-1)..teammates(0..n-1)..hide(n).
// First step from a collapsed tree expands it and parks on 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 row
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',
}
})
}
/**
* Custom hook that handles Shift+Up/Down keyboard navigation for background tasks.
* When teammates (swarm) are present, navigates between leader and teammates.
* When only non-teammate background tasks exist, opens the background tasks dialog.
* Also handles Enter to confirm selection, 'f' to view transcript, and 'k' to kill.
*/
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()
// Filter to running teammates and sort alphabetically to match TeammateSpinnerTree display
const teammateTasks = getRunningTeammatesSorted(tasks)
const teammateCount = teammateTasks.length
// Check for non-teammate background tasks (local_agent, local_bash, etc.)
const hasNonTeammateBackgroundTasks = Object.values(tasks).some(
t => isBackgroundTask(t) && t.type !== 'in_process_teammate',
)
// Track previous teammate count to detect when teammates are removed
const prevTeammateCountRef = useRef<number>(teammateCount)
// Clamp selection index if teammates are removed or reset when count becomes 0
useEffect(() => {
const prevCount = prevTeammateCountRef.current
prevTeammateCountRef.current = teammateCount
setAppState(prev => {
const currentTeammates = getRunningTeammatesSorted(prev.tasks)
const currentCount = currentTeammates.length
// When teammates are removed (count goes from >0 to 0), reset selection
// Only reset if we previously had teammates (not on initial mount with 0)
// Don't clobber viewSelectionMode if actively viewing a teammate transcript —
// the user may be reviewing a completed teammate and needs escape to exit
if (
currentCount === 0 &&
prevCount > 0 &&
prev.selectedIPAgentIndex !== -1
) {
if (prev.viewSelectionMode === 'viewing-agent') {
return {
...prev,
selectedIPAgentIndex: -1,
}
}
return {
...prev,
selectedIPAgentIndex: -1,
viewSelectionMode: 'none',
}
}
// Clamp if index is out of bounds
// Max valid index is currentCount (the "hide" row) when spinner tree is shown
const maxIndex =
prev.expandedView === 'teammates' ? currentCount : currentCount - 1
if (currentCount > 0 && prev.selectedIPAgentIndex > maxIndex) {
return {
...prev,
selectedIPAgentIndex: maxIndex,
}
}
return prev
})
}, [teammateCount, setAppState])
// Get the selected teammate's task info
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 in viewing mode:
// - If teammate is running: abort current work only (stops current turn, teammate stays alive)
// - If teammate is not running (completed/killed/failed): exit the view back to 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') {
// Abort currentWorkAbortController (stops current turn) NOT abortController (kills teammate)
task.currentWorkAbortController?.abort()
return
}
}
// Teammate is not running or task doesn't exist — exit the view
exitTeammateView(setAppState)
return
}
// Escape in selection mode: exit selection without aborting leader
if (e.key === 'escape' && viewSelectionMode === 'selecting-agent') {
e.preventDefault()
setAppState(prev => ({
...prev,
viewSelectionMode: 'none',
selectedIPAgentIndex: -1,
}))
return
}
// Shift+Up/Down for teammate transcript switching (with wrapping)
// Index -1 represents the leader, 0+ are teammates
// When showSpinnerTree is true, index === teammateCount is the "hide" row
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' to view selected teammate's transcript (only in selecting mode)
if (
e.key === 'f' &&
viewSelectionMode === 'selecting-agent' &&
teammateCount > 0
) {
e.preventDefault()
const selected = getSelectedTeammate()
if (selected) {
enterTeammateView(selected.taskId, setAppState)
}
return
}
// Enter to confirm selection (only when in selecting mode)
if (e.key === 'return' && viewSelectionMode === 'selecting-agent') {
e.preventDefault()
if (selectedIPAgentIndex === -1) {
exitTeammateView(setAppState)
} else if (selectedIPAgentIndex >= teammateCount) {
// "Hide" row selected - collapse the spinner tree
setAppState(prev => ({
...prev,
expandedView: 'none' as const,
viewSelectionMode: 'none',
selectedIPAgentIndex: -1,
}))
} else {
const selected = getSelectedTeammate()
if (selected) {
enterTeammateView(selected.taskId, setAppState)
}
}
return
}
// k to kill selected teammate (only in selecting mode)
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
}
}
// Backward-compat bridge: REPL.tsx doesn't yet wire handleKeyDown to
// <Box onKeyDown>. Subscribe via useInput and adapt InputEvent →
// KeyboardEvent until the consumer is migrated (separate PR).
// TODO(onKeyDown-migration): remove once REPL passes handleKeyDown.
useInput((_input, _key, event) => {
handleKeyDown(new KeyboardEvent(event.keypress))
})
return { handleKeyDown }
}
+34
View File
@@ -0,0 +1,34 @@
import { type DOMElement, useAnimationFrame, useTerminalFocus } from '../ink.js'
const BLINK_INTERVAL_MS = 600
/**
* Hook for synchronized blinking animations that pause when offscreen.
*
* Returns a ref to attach to the animated element and the current blink state.
* All instances blink together because they derive state from the same
* animation clock. The clock only runs when at least one subscriber is visible.
* Pauses when the terminal is blurred.
*
* @param enabled - Whether blinking is active
* @returns [ref, isVisible] - Ref to attach to element, true when visible in blink cycle
*
* @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]
// Derive blink state from time - all instances see the same time so they sync
const isVisible = Math.floor(time / intervalMs) % 2 === 0
return [ref, isVisible]
}
+204
View File
@@ -0,0 +1,204 @@
import { c as _c } from "react/compiler-runtime";
import { feature } from 'bun:bundle';
import { APIUserAbortError } from '@anthropic-ai/sdk';
import * as React from 'react';
import { useCallback } from 'react';
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 { Text } from '../ink.js';
import type { ToolPermissionContext, Tool as ToolType, ToolUseContext } from '../Tool.js';
import { consumeSpeculativeClassifierCheck, peekSpeculativeClassifierCheck } from '../tools/BashTool/bashPermissions.js';
import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js';
import type { AssistantMessage } from '../types/message.js';
import { recordAutoModeDenial } from '../utils/autoModeDenials.js';
import { clearClassifierChecking, setClassifierApproval, setYoloClassifierApproval } from '../utils/classifierApprovals.js';
import { logForDebugging } from '../utils/debug.js';
import { AbortError } from '../utils/errors.js';
import { logError } from '../utils/log.js';
import type { PermissionDecision } from '../utils/permissions/PermissionResult.js';
import { hasPermissionsToUseTool } from '../utils/permissions/permissions.js';
import { jsonStringify } from '../utils/slowOperations.js';
import { handleCoordinatorPermission } from './toolPermission/handlers/coordinatorHandler.js';
import { handleInteractivePermission } from './toolPermission/handlers/interactiveHandler.js';
import { handleSwarmWorkerPermission } from './toolPermission/handlers/swarmWorkerHandler.js';
import { createPermissionContext, createPermissionQueueOps } from './toolPermission/PermissionContext.js';
import { logPermissionDecision } from './toolPermission/permissionLogging.js';
export type CanUseToolFn<Input extends Record<string, unknown> = Record<string, unknown>> = (tool: ToolType, input: Input, toolUseContext: ToolUseContext, assistantMessage: AssistantMessage, toolUseID: string, forceDecision?: PermissionDecision<Input>) => Promise<PermissionDecision<Input>>;
function useCanUseTool(setToolUseConfirmQueue, setToolPermissionContext) {
const $ = _c(3);
let t0;
if ($[0] !== setToolPermissionContext || $[1] !== setToolUseConfirmQueue) {
t0 = async (tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision) => new Promise(resolve => {
const ctx = createPermissionContext(tool, input, toolUseContext, assistantMessage, toolUseID, setToolPermissionContext, createPermissionQueueOps(setToolUseConfirmQueue));
if (ctx.resolveIfAborted(resolve)) {
return;
}
const decisionPromise = forceDecision !== undefined ? Promise.resolve(forceDecision) : hasPermissionsToUseTool(tool, input, toolUseContext, assistantMessage, toolUseID);
return decisionPromise.then(async result => {
if (result.behavior === "allow") {
if (ctx.resolveIfAborted(resolve)) {
return;
}
if (feature("TRANSCRIPT_CLASSIFIER") && result.decisionReason?.type === "classifier" && result.decisionReason.classifier === "auto-mode") {
setYoloClassifierApproval(toolUseID, result.decisionReason.reason);
}
ctx.logDecision({
decision: "accept",
source: "config"
});
resolve(ctx.buildAllow(result.updatedInput ?? input, {
decisionReason: result.decisionReason
}));
return;
}
const appState = toolUseContext.getAppState();
const description = await tool.description(input as never, {
isNonInteractiveSession: toolUseContext.options.isNonInteractiveSession,
toolPermissionContext: appState.toolPermissionContext,
tools: toolUseContext.options.tools
});
if (ctx.resolveIfAborted(resolve)) {
return;
}
switch (result.behavior) {
case "deny":
{
logPermissionDecision({
tool,
input,
toolUseContext,
messageId: ctx.messageId,
toolUseID
}, {
decision: "reject",
source: "config"
});
if (feature("TRANSCRIPT_CLASSIFIER") && result.decisionReason?.type === "classifier" && result.decisionReason.classifier === "auto-mode") {
recordAutoModeDenial({
toolName: tool.name,
display: description,
reason: result.decisionReason.reason ?? "",
timestamp: Date.now()
});
toolUseContext.addNotification?.({
key: "auto-mode-denied",
priority: "immediate",
jsx: <><Text color="error">{tool.userFacingName(input).toLowerCase()} denied by auto mode</Text><Text dimColor={true}> · /permissions</Text></>
});
}
resolve(result);
return;
}
case "ask":
{
if (appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog) {
const coordinatorDecision = await handleCoordinatorPermission({
ctx,
...(feature("BASH_CLASSIFIER") ? {
pendingClassifierCheck: result.pendingClassifierCheck
} : {}),
updatedInput: result.updatedInput,
suggestions: result.suggestions,
permissionMode: appState.toolPermissionContext.mode
});
if (coordinatorDecision) {
resolve(coordinatorDecision);
return;
}
}
if (ctx.resolveIfAborted(resolve)) {
return;
}
const swarmDecision = await handleSwarmWorkerPermission({
ctx,
description,
...(feature("BASH_CLASSIFIER") ? {
pendingClassifierCheck: result.pendingClassifierCheck
} : {}),
updatedInput: result.updatedInput,
suggestions: result.suggestions
});
if (swarmDecision) {
resolve(swarmDecision);
return;
}
if (feature("BASH_CLASSIFIER") && result.pendingClassifierCheck && tool.name === BASH_TOOL_NAME && !appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog) {
const speculativePromise = peekSpeculativeClassifierCheck((input as {
command: string;
}).command);
if (speculativePromise) {
const raceResult = await Promise.race([speculativePromise.then(_temp), new Promise(_temp2)]);
if (ctx.resolveIfAborted(resolve)) {
return;
}
if (raceResult.type === "result" && raceResult.result.matches && raceResult.result.confidence === "high" && feature("BASH_CLASSIFIER")) {
consumeSpeculativeClassifierCheck((input as {
command: string;
}).command);
const matchedRule = raceResult.result.matchedDescription ?? undefined;
if (matchedRule) {
setClassifierApproval(toolUseID, matchedRule);
}
ctx.logDecision({
decision: "accept",
source: {
type: "classifier"
}
});
resolve(ctx.buildAllow(result.updatedInput ?? input as Record<string, unknown>, {
decisionReason: {
type: "classifier" as const,
classifier: "bash_allow" as const,
reason: `Allowed by prompt rule: "${raceResult.result.matchedDescription}"`
}
}));
return;
}
}
}
handleInteractivePermission({
ctx,
description,
result,
awaitAutomatedChecksBeforeDialog: appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog,
bridgeCallbacks: feature("BRIDGE_MODE") ? appState.replBridgePermissionCallbacks : undefined,
channelCallbacks: feature("KAIROS") || feature("KAIROS_CHANNELS") ? appState.channelPermissionCallbacks : undefined
}, resolve);
return;
}
}
}).catch(error => {
if (error instanceof AbortError || error instanceof APIUserAbortError) {
logForDebugging(`Permission check threw ${error.constructor.name} for tool=${tool.name}: ${error.message}`);
ctx.logCancelled();
resolve(ctx.cancelAndAbort(undefined, true));
} else {
logError(error);
resolve(ctx.cancelAndAbort(undefined, true));
}
}).finally(() => {
clearClassifierChecking(toolUseID);
});
});
$[0] = setToolPermissionContext;
$[1] = setToolUseConfirmQueue;
$[2] = t0;
} else {
t0 = $[2];
}
return t0;
}
function _temp2(res) {
return setTimeout(res, 2000, {
type: "timeout" as const
});
}
function _temp(r) {
return {
type: "result" as const,
result: r
};
}
export default useCanUseTool;
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmZWF0dXJlIiwiQVBJVXNlckFib3J0RXJyb3IiLCJSZWFjdCIsInVzZUNhbGxiYWNrIiwiQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX05PVF9DT0RFX09SX0ZJTEVQQVRIUyIsImxvZ0V2ZW50Iiwic2FuaXRpemVUb29sTmFtZUZvckFuYWx5dGljcyIsIlRvb2xVc2VDb25maXJtIiwiVGV4dCIsIlRvb2xQZXJtaXNzaW9uQ29udGV4dCIsIlRvb2wiLCJUb29sVHlwZSIsIlRvb2xVc2VDb250ZXh0IiwiY29uc3VtZVNwZWN1bGF0aXZlQ2xhc3NpZmllckNoZWNrIiwicGVla1NwZWN1bGF0aXZlQ2xhc3NpZmllckNoZWNrIiwiQkFTSF9UT09MX05BTUUiLCJBc3Npc3RhbnRNZXNzYWdlIiwicmVjb3JkQXV0b01vZGVEZW5pYWwiLCJjbGVhckNsYXNzaWZpZXJDaGVja2luZyIsInNldENsYXNzaWZpZXJBcHByb3ZhbCIsInNldFlvbG9DbGFzc2lmaWVyQXBwcm92YWwiLCJsb2dGb3JEZWJ1Z2dpbmciLCJBYm9ydEVycm9yIiwibG9nRXJyb3IiLCJQZXJtaXNzaW9uRGVjaXNpb24iLCJoYXNQZXJtaXNzaW9uc1RvVXNlVG9vbCIsImpzb25TdHJpbmdpZnkiLCJoYW5kbGVDb29yZGluYXRvclBlcm1pc3Npb24iLCJoYW5kbGVJbnRlcmFjdGl2ZVBlcm1pc3Npb24iLCJoYW5kbGVTd2FybVdvcmtlclBlcm1pc3Npb24iLCJjcmVhdGVQZXJtaXNzaW9uQ29udGV4dCIsImNyZWF0ZVBlcm1pc3Npb25RdWV1ZU9wcyIsImxvZ1Blcm1pc3Npb25EZWNpc2lvbiIsIkNhblVzZVRvb2xGbiIsIlJlY29yZCIsInRvb2wiLCJpbnB1dCIsIklucHV0IiwidG9vbFVzZUNvbnRleHQiLCJhc3Npc3RhbnRNZXNzYWdlIiwidG9vbFVzZUlEIiwiZm9yY2VEZWNpc2lvbiIsIlByb21pc2UiLCJ1c2VDYW5Vc2VUb29sIiwic2V0VG9vbFVzZUNvbmZpcm1RdWV1ZSIsInNldFRvb2xQZXJtaXNzaW9uQ29udGV4dCIsIiQiLCJfYyIsInQwIiwicmVzb2x2ZSIsImN0eCIsInJlc29sdmVJZkFib3J0ZWQiLCJkZWNpc2lvblByb21pc2UiLCJ1bmRlZmluZWQiLCJ0aGVuIiwicmVzdWx0IiwiYmVoYXZpb3IiLCJkZWNpc2lvblJlYXNvbiIsInR5cGUiLCJjbGFzc2lmaWVyIiwicmVhc29uIiwibG9nRGVjaXNpb24iLCJkZWNpc2lvbiIsInNvdXJjZSIsImJ1aWxkQWxsb3ciLCJ1cGRhdGVkSW5wdXQiLCJhcHBTdGF0ZSIsImdldEFwcFN0YXRlIiwiZGVzY3JpcHRpb24iLCJpc05vbkludGVyYWN0aXZlU2Vzc2lvbiIsIm9wdGlvbnMiLCJ0b29sUGVybWlzc2lvbkNvbnRleHQiLCJ0b29scyIsIm1lc3NhZ2VJZCIsInRvb2xOYW1lIiwibmFtZSIsImRpc3BsYXkiLCJ0aW1lc3RhbXAiLCJEYXRlIiwibm93IiwiYWRkTm90aWZpY2F0aW9uIiwia2V5IiwicHJpb3JpdHkiLCJqc3giLCJ1c2VyRmFjaW5nTmFtZSIsInRvTG93ZXJDYXNlIiwiYXdhaXRBdXRvbWF0ZWRDaGVja3NCZWZvcmVEaWFsb2ciLCJjb29yZGluYXRvckRlY2lzaW9uIiwicGVuZGluZ0NsYXNzaWZpZXJDaGVjayIsInN1Z2dlc3Rpb25zIiwicGVybWlzc2lvbk1vZGUiLCJtb2RlIiwic3dhcm1EZWNpc2lvbiIsInNwZWN1bGF0aXZlUHJvbWlzZSIsImNvbW1hbmQiLCJyYWNlUmVzdWx0IiwicmFjZSIsIl90ZW1wIiwiX3RlbXAyIiwibWF0Y2hlcyIsImNvbmZpZGVuY2UiLCJtYXRjaGVkUnVsZSIsIm1hdGNoZWREZXNjcmlwdGlvbiIsImNvbnN0IiwiYnJpZGdlQ2FsbGJhY2tzIiwicmVwbEJyaWRnZVBlcm1pc3Npb25DYWxsYmFja3MiLCJjaGFubmVsQ2FsbGJhY2tzIiwiY2hhbm5lbFBlcm1pc3Npb25DYWxsYmFja3MiLCJjYXRjaCIsImVycm9yIiwiY29uc3RydWN0b3IiLCJtZXNzYWdlIiwibG9nQ2FuY2VsbGVkIiwiY2FuY2VsQW5kQWJvcnQiLCJmaW5hbGx5IiwicmVzIiwic2V0VGltZW91dCIsInIiXSwic291cmNlcyI6WyJ1c2VDYW5Vc2VUb29sLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgeyBmZWF0dXJlIH0gZnJvbSAnYnVuOmJ1bmRsZSdcbmltcG9ydCB7IEFQSVVzZXJBYm9ydEVycm9yIH0gZnJvbSAnQGFudGhyb3BpYy1haS9zZGsnXG5pbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IHVzZUNhbGxiYWNrIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQge1xuICB0eXBlIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gIGxvZ0V2ZW50LFxufSBmcm9tICdzcmMvc2VydmljZXMvYW5hbHl0aWNzL2luZGV4LmpzJ1xuaW1wb3J0IHsgc2FuaXRpemVUb29sTmFtZUZvckFuYWx5dGljcyB9IGZyb20gJ3NyYy9zZXJ2aWNlcy9hbmFseXRpY3MvbWV0YWRhdGEuanMnXG5pbXBvcnQgdHlwZSB7IFRvb2xVc2VDb25maXJtIH0gZnJvbSAnLi4vY29tcG9uZW50cy9wZXJtaXNzaW9ucy9QZXJtaXNzaW9uUmVxdWVzdC5qcydcbmltcG9ydCB7IFRleHQgfSBmcm9tICcuLi9pbmsuanMnXG5pbXBvcnQgdHlwZSB7XG4gIFRvb2xQZXJtaXNzaW9uQ29udGV4dCxcbiAgVG9vbCBhcyBUb29sVHlwZSxcbiAgVG9vbFVzZUNvbnRleHQsXG59IGZyb20gJy4uL1Rvb2wuanMnXG5pbXBvcnQge1xuICBjb25zdW1lU3BlY3VsYXRpdmVDbGFzc2lmaWVyQ2hlY2ssXG4gIHBlZWtTcGVjdWxhdGl2ZUNsYXNzaWZpZXJDaGVjayxcbn0gZnJvbSAnLi4vdG9vbHMvQmFzaFRvb2wvYmFzaFBlcm1pc3Npb25zLmpzJ1xuaW1wb3J0IHsgQkFTSF9UT09MX05BTUUgfSBmcm9tICcuLi90b29scy9CYXNoVG9vbC90b29sTmFtZS5qcydcbmltcG9ydCB0eXBlIHsgQXNzaXN0YW50TWVzc2FnZSB9IGZyb20gJy4uL3R5cGVzL21lc3NhZ2UuanMnXG5pbXBvcnQgeyByZWNvcmRBdXRvTW9kZURlbmlhbCB9IGZyb20gJy4uL3V0aWxzL2F1dG9Nb2RlRGVuaWFscy5qcydcbmltcG9ydCB7XG4gIGNsZWFyQ2xhc3NpZmllckNoZWNraW5nLFxuICBzZXRDbGFzc2lmaWVyQXBwcm92YWwsXG4gIHNldFlvbG9DbGFzc2lmaWVyQXBwcm92YWwsXG59IGZyb20gJy4uL3V0aWxzL2NsYXNzaWZpZXJBcHByb3ZhbHMuanMnXG5pbXBvcnQgeyBsb2dGb3JEZWJ1Z2dpbmcgfSBmcm9tICcuLi91dGlscy9kZWJ1Zy5qcydcbmltcG9ydCB7IEFib3J0RXJyb3IgfSBmcm9tICcuLi91dGlscy9lcnJvcnMuanMnXG5pbXBvcnQgeyBsb2dFcnJvciB9IGZyb20gJy4uL3V0aWxzL2xvZy5qcydcbmltcG9ydCB0eXBlIHsgUGVybWlzc2lvbkRlY2lzaW9uIH0gZnJvbSAnLi4vdXRpbHMvcGVybWlzc2lvbnMvUGVybWlzc2lvblJlc3VsdC5qcydcbmltcG9ydCB7IGhhc1Blcm1pc3Npb25zVG9Vc2VUb29sIH0gZnJvbSAnLi4vdXRpbHMvcGVybWlzc2lvbnMvcGVybWlzc2lvbnMuanMnXG5pbXBvcnQgeyBqc29uU3RyaW5naWZ5IH0gZnJvbSAnLi4vdXRpbHMvc2xvd09wZXJhdGlvbnMuanMnXG5pbXBvcnQgeyBoYW5kbGVDb29yZGluYXRvclBlcm1pc3Npb24gfSBmcm9tICcuL3Rvb2xQZXJtaXNzaW9uL2hhbmRsZXJzL2Nvb3JkaW5hdG9ySGFuZGxlci5qcydcbmltcG9ydCB7IGhhbmRsZUludGVyYWN0aXZlUGVybWlzc2lvbiB9IGZyb20gJy4vdG9vbFBlcm1pc3Npb24vaGFuZGxlcnMvaW50ZXJhY3RpdmVIYW5kbGVyLmpzJ1xuaW1wb3J0IHsgaGFuZGxlU3dhcm1Xb3JrZXJQZXJtaXNzaW9uIH0gZnJvbSAnLi90b29sUGVybWlzc2lvbi9oYW5kbGVycy9zd2FybVdvcmtlckhhbmRsZXIuanMnXG5pbXBvcnQge1xuICBjcmVhdGVQZXJtaXNzaW9uQ29udGV4dCxcbiAgY3JlYXRlUGVybWlzc2lvblF1ZXVlT3BzLFxufSBmcm9tICcuL3R Line truncated
+276
View File
@@ -0,0 +1,276 @@
/**
* CancelRequestHandler component for handling cancel/escape keybinding.
*
* Must be rendered inside KeybindingSetup to have access to the keybinding context.
* This component renders nothing - it just registers the cancel keybinding handler.
*/
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'
/** Time window in ms during which a second press kills all background agents. */
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
}
/**
* Component that handles cancel requests via keybinding.
* Renders null but registers the 'chat:cancel' keybinding handler.
*/
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,
}
// Priority 1: If there's an active task running, cancel it first
// This takes precedence over queue management so users can always interrupt Claude
if (abortSignal !== undefined && !abortSignal.aborted) {
logEvent('tengu_cancel', cancelProps)
setToolUseConfirmQueue(() => [])
onCancel()
return
}
// Priority 2: Pop queue when Claude is idle (no running task to cancel)
if (hasCommandsInQueue()) {
if (popCommandFromQueue) {
popCommandFromQueue()
return
}
}
// Fallback: nothing to cancel or pop (shouldn't reach here if isActive is correct)
logEvent('tengu_cancel', cancelProps)
setToolUseConfirmQueue(() => [])
onCancel()
}, [
abortSignal,
popCommandFromQueue,
setToolUseConfirmQueue,
onCancel,
streamMode,
])
// Determine if this handler should be active
// Other contexts (Transcript, HistorySearch, Help) have their own escape handlers
// Overlays (ModelPicker, ThinkingToggle, etc.) register themselves via useRegisterOverlay
// Local JSX commands (like /model, /btw) handle their own input
const isOverlayActive = useIsOverlayActive()
const canCancelRunningTask = abortSignal !== undefined && !abortSignal.aborted
const hasQueuedCommands = queuedCommandsLength > 0
// When in bash/background mode with empty input, escape should exit the mode
// rather than cancel the request. Let PromptInput handle mode exit.
// This only applies to Escape, not Ctrl+C which should always cancel.
const isInSpecialModeWithEmptyInput =
inputMode !== undefined && inputMode !== 'prompt' && !inputValue
// When viewing a teammate's transcript, let useBackgroundTaskNavigation handle Escape
const isViewingTeammate = viewSelectionMode === 'viewing-agent'
// Context guards: other screens/overlays handle their own cancel
const isContextActive =
screen !== 'transcript' &&
!isSearchingHistory &&
!isMessageSelectorVisible &&
!isLocalJSXCommand &&
!isHelpOpen &&
!isOverlayActive &&
!(isVimModeEnabled() && vimMode === 'INSERT')
// Escape (chat:cancel) defers to mode-exit when in special mode with empty
// input, and to useBackgroundTaskNavigation when viewing a teammate
const isEscapeActive =
isContextActive &&
(canCancelRunningTask || hasQueuedCommands) &&
!isInSpecialModeWithEmptyInput &&
!isViewingTeammate
// Ctrl+C (app:interrupt): when viewing a teammate, stops everything and
// returns to main thread. Otherwise just handleCancel. Must NOT claim
// ctrl+c when main is idle at the prompt — that blocks the copy-selection
// handler and double-press-to-exit from ever seeing the keypress.
const isCtrlCActive =
isContextActive &&
(canCancelRunningTask || hasQueuedCommands || isViewingTeammate)
useKeybinding('chat:cancel', handleCancel, {
context: 'Chat',
isActive: isEscapeActive,
})
// Shared kill path: stop all agents, suppress per-agent notifications,
// emit SDK events, enqueue a single aggregate model-facing notification.
// Returns true if anything was killed.
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). Scoped to teammate-view: killing agents from the
// main prompt stays a deliberate gesture (chat:killAgents), not a
// side-effect of cancelling a turn.
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 uses a two-press pattern: first press shows a
// confirmation hint, second press within the window actually kills all
// agents. Reads tasks from the store directly to avoid stale closures.
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) {
// Second press within window -- kill all background agents
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
}
// First press -- show confirmation hint in status bar
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])
// Must stay always-active: ctrl+x is consumed as a chord prefix regardless
// of isActive (because ctrl+x ctrl+e is always live), so an inactive handler
// here would leak ctrl+k to readline kill-line. Handler gates internally.
useKeybinding('chat:killAgents', handleKillAgents, {
context: 'Chat',
})
return null
}
@@ -0,0 +1,50 @@
import * as React from 'react';
import { Text } from '../ink.js';
import { isClaudeAISubscriber } from '../utils/auth.js';
import { isChromeExtensionInstalled, shouldEnableClaudeInChrome } from '../utils/claudeInChrome/setup.js';
import { isRunningOnHomespace } from '../utils/envUtils.js';
import { useStartupNotification } from './notifs/useStartupNotification.js';
function getChromeFlag(): boolean | undefined {
if (process.argv.includes('--chrome')) {
return true;
}
if (process.argv.includes('--no-chrome')) {
return false;
}
return undefined;
}
export function useChromeExtensionNotification() {
useStartupNotification(_temp);
}
async function _temp() {
const chromeFlag = getChromeFlag();
if (!shouldEnableClaudeInChrome(chromeFlag)) {
return null;
}
if (true && !isClaudeAISubscriber()) {
return {
key: "chrome-requires-subscription",
jsx: <Text color="error">Claude in Chrome requires a claude.ai subscription</Text>,
priority: "immediate",
timeoutMs: 5000
};
}
const installed = await isChromeExtensionInstalled();
if (!installed && !isRunningOnHomespace()) {
return {
key: "chrome-extension-not-detected",
jsx: <Text color="warning">Chrome extension not detected · https://claude.ai/chrome to install</Text>,
priority: "immediate",
timeoutMs: 3000
};
}
if (chromeFlag === undefined) {
return {
key: "claude-in-chrome-default-enabled",
text: "Claude in Chrome enabled \xB7 /chrome",
priority: "low"
};
}
return null;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlRleHQiLCJpc0NsYXVkZUFJU3Vic2NyaWJlciIsImlzQ2hyb21lRXh0ZW5zaW9uSW5zdGFsbGVkIiwic2hvdWxkRW5hYmxlQ2xhdWRlSW5DaHJvbWUiLCJpc1J1bm5pbmdPbkhvbWVzcGFjZSIsInVzZVN0YXJ0dXBOb3RpZmljYXRpb24iLCJnZXRDaHJvbWVGbGFnIiwicHJvY2VzcyIsImFyZ3YiLCJpbmNsdWRlcyIsInVuZGVmaW5lZCIsInVzZUNocm9tZUV4dGVuc2lvbk5vdGlmaWNhdGlvbiIsIl90ZW1wIiwiY2hyb21lRmxhZyIsImtleSIsImpzeCIsInByaW9yaXR5IiwidGltZW91dE1zIiwiaW5zdGFsbGVkIiwidGV4dCJdLCJzb3VyY2VzIjpbInVzZUNocm9tZUV4dGVuc2lvbk5vdGlmaWNhdGlvbi50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0ICogYXMgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBUZXh0IH0gZnJvbSAnLi4vaW5rLmpzJ1xuaW1wb3J0IHsgaXNDbGF1ZGVBSVN1YnNjcmliZXIgfSBmcm9tICcuLi91dGlscy9hdXRoLmpzJ1xuaW1wb3J0IHtcbiAgaXNDaHJvbWVFeHRlbnNpb25JbnN0YWxsZWQsXG4gIHNob3VsZEVuYWJsZUNsYXVkZUluQ2hyb21lLFxufSBmcm9tICcuLi91dGlscy9jbGF1ZGVJbkNocm9tZS9zZXR1cC5qcydcbmltcG9ydCB7IGlzUnVubmluZ09uSG9tZXNwYWNlIH0gZnJvbSAnLi4vdXRpbHMvZW52VXRpbHMuanMnXG5pbXBvcnQgeyB1c2VTdGFydHVwTm90aWZpY2F0aW9uIH0gZnJvbSAnLi9ub3RpZnMvdXNlU3RhcnR1cE5vdGlmaWNhdGlvbi5qcydcblxuZnVuY3Rpb24gZ2V0Q2hyb21lRmxhZygpOiBib29sZWFuIHwgdW5kZWZpbmVkIHtcbiAgaWYgKHByb2Nlc3MuYXJndi5pbmNsdWRlcygnLS1jaHJvbWUnKSkge1xuICAgIHJldHVybiB0cnVlXG4gIH1cbiAgaWYgKHByb2Nlc3MuYXJndi5pbmNsdWRlcygnLS1uby1jaHJvbWUnKSkge1xuICAgIHJldHVybiBmYWxzZVxuICB9XG4gIHJldHVybiB1bmRlZmluZWRcbn1cblxuZXhwb3J0IGZ1bmN0aW9uIHVzZUNocm9tZUV4dGVuc2lvbk5vdGlmaWNhdGlvbigpOiB2b2lkIHtcbiAgdXNlU3RhcnR1cE5vdGlmaWNhdGlvbihhc3luYyAoKSA9PiB7XG4gICAgY29uc3QgY2hyb21lRmxhZyA9IGdldENocm9tZUZsYWcoKVxuICAgIGlmICghc2hvdWxkRW5hYmxlQ2xhdWRlSW5DaHJvbWUoY2hyb21lRmxhZykpIHJldHVybiBudWxsXG5cbiAgICAvLyBDbGF1ZGUgaW4gQ2hyb21lIGlzIG9ubHkgc3VwcG9ydGVkIGZvciBjbGF1ZGUuYWkgc3Vic2NyaWJlcnMgKHVubGVzcyB1c2VyIGlzIGFudClcbiAgICBpZiAoXCJleHRlcm5hbFwiICE9PSAnYW50JyAmJiAhaXNDbGF1ZGVBSVN1YnNjcmliZXIoKSkge1xuICAgICAgcmV0dXJuIHtcbiAgICAgICAga2V5OiAnY2hyb21lLXJlcXVpcmVzLXN1YnNjcmlwdGlvbicsXG4gICAgICAgIGpzeDogKFxuICAgICAgICAgIDxUZXh0IGNvbG9yPVwiZXJyb3JcIj5cbiAgICAgICAgICAgIENsYXVkZSBpbiBDaHJvbWUgcmVxdWlyZXMgYSBjbGF1ZGUuYWkgc3Vic2NyaXB0aW9uXG4gICAgICAgICAgPC9UZXh0PlxuICAgICAgICApLFxuICAgICAgICBwcmlvcml0eTogJ2ltbWVkaWF0ZScsXG4gICAgICAgIHRpbWVvdXRNczogNTAwMCxcbiAgICAgIH1cbiAgICB9XG5cbiAgICBjb25zdCBpbnN0YWxsZWQgPSBhd2FpdCBpc0Nocm9tZUV4dGVuc2lvbkluc3RhbGxlZCgpXG4gICAgaWYgKCFpbnN0YWxsZWQgJiYgIWlzUnVubmluZ09uSG9tZXNwYWNlKCkpIHtcbiAgICAgIC8vIFNraXAgbm90aWZpY2F0aW9uIG9uIEhvbWVzcGFjZSBzaW5jZSBDaHJvbWUgc2V0dXAgcmVxdWlyZXMgZGlmZmVyZW50IHN0ZXBzIChzZWUgZ28vaHNwcm94eSlcbiAgICAgIHJldHVybiB7XG4gICAgICAgIGtleTogJ2Nocm9tZS1leHRlbnNpb24tbm90LWRldGVjdGVkJyxcbiAgICAgICAganN4OiAoXG4gICAgICAgICAgPFRleHQgY29sb3I9XCJ3YXJuaW5nXCI+XG4gICAgICAgICAgICBDaHJvbWUgZXh0ZW5zaW9uIG5vdCBkZXRlY3RlZCDCtyBodHRwczovL2NsYXVkZS5haS9jaHJvbWUgdG8gaW5zdGFsbFxuICAgICAgICAgIDwvVGV4dD5cbiAgICAgICAgKSxcbiAgICAgICAgLy8gVE9ETyhoYWNreW9uKTogTG93ZXIgdGhlIHByaW9yaXR5IGlmIHRoZSBjbGF1ZGUtaW4tY2hyb21lIGludGVncmF0aW9uIGlzIG5vIGxvbmdlciBvcHQtaW5cbiAgICAgICAgcHJpb3JpdHk6ICdpbW1lZGlhdGUnLFxuICAgICAgICB0aW1lb3V0TXM6IDMwMDAsXG4gICAgICB9XG4gICAgfVxuICAgIGlmIChjaHJvbWVGbGFnID09PSB1bmRlZmluZWQpIHtcbiAgICAgIC8vIFNob3cgbG93IHByaW9yaXR5IG5vdGlmaWNhdGlvbiBvbmx5IHdoZW4gQ2hyb21lIGlzIGVuYWJsZWQgYnkgZGVmYXVsdFxuICAgICAgLy8gKG5vdCBleHBsaWNpdGx5IGVuYWJsZWQgd2l0aCAtLWNocm9tZSBvciBkaXNhYmxlZCB3aXRoIC0tbm8tY2hyb21lKVxuICAgICAgcmV0dXJuIHtcbiAgICAgICAga2V5OiAnY2xhdWRlLWluLWNocm9tZS1kZWZhdWx0LWVuYWJsZWQnLFxuICAgICAgICB0ZXh0OiBgQ2xhdWRlIGluIENocm9tZSBlbmFibGVkIMK3IC9jaHJvbWVgLFxuICAgICAgICBwcmlvcml0eTogJ2xvdycsXG4gICAgICB9XG4gICAgfVxuICAgIHJldHVybiBudWxsXG4gIH0pXG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBS0EsS0FBSyxNQUFNLE9BQU87QUFDOUIsU0FBU0MsSUFBSSxRQUFRLFdBQVc7QUFDaEMsU0FBU0Msb0JBQW9CLFFBQVEsa0JBQWtCO0FBQ3ZELFNBQ0VDLDBCQUEwQixFQUMxQkMsMEJBQTBCLFFBQ3JCLGtDQUFrQztBQUN6QyxTQUFTQyxvQkFBb0IsUUFBUSxzQkFBc0I7QUFDM0QsU0FBU0Msc0JBQXNCLFFBQVEsb0NBQW9DO0FBRTNFLFNBQVNDLGFBQWFBLENBQUEsQ0FBRSxFQUFFLE9BQU8sR0FBRyxTQUFTLENBQUM7RUFDNUMsSUFBSUMsT0FBTyxDQUFDQyxJQUFJLENBQUNDLFFBQVEsQ0FBQyxVQUFVLENBQUMsRUFBRTtJQUNyQyxPQUFPLElBQUk7RUFDYjtFQUNBLElBQUlGLE9BQU8sQ0FBQ0MsSUFBSSxDQUFDQyxRQUFRLENBQUMsYUFBYSxDQUFDLEVBQUU7SUFDeEMsT0FBTyxLQUFLO0VBQ2Q7RUFDQSxPQUFPQyxTQUFTO0FBQ2xCO0FBRUEsT0FBTyxTQUFBQywrQkFBQTtFQUNMTixzQkFBc0IsQ0FBQ08sS0EyQ3RCLENBQUM7QUFBQTtBQTVDRyxlQUFBQSxNQUFBO0VBRUgsTUFBQUMsVUFBQSxHQUFtQlAsYUFBYSxDQUFDLENBQUM7RUFDbEMsSUFBSSxDQUFDSCwwQkFBMEIsQ0FBQ1UsVUFBVSxDQUFDO0lBQUEsT0FBUyxJQUFJO0VBQUE7RUFHeEQsSUFBSSxJQUErQyxJQUEvQyxDQUF5Qlosb0JBQW9CLENBQUMsQ0FBQztJQUFBLE9BQzFDO01BQUFhLEdBQUEsRUFDQSw4QkFBOEI7TUFBQUMsR0FBQSxFQUVqQyxDQUFDLElBQUksQ0FBTyxLQUFPLENBQVAsT0FBTyxDQUFDLGtEQUVwQixFQUZDLElBQUksQ0FFRTtNQUFBQyxRQUFBLEVBRUMsV0FBVztNQUFBQyxTQUFBLEVBQ1Y7SUFDYixDQUFDO0VBQUE7RUFHSCxNQUFBQyxTQUFBLEdBQWtCLE1BQU1oQiwwQkFBMEIsQ0FBQyxDQUFDO0VBQ3BELElBQUksQ0FBQ2dCLFNBQW9DLElBQXJDLENBQWVkLG9CQUFvQixDQUFDLENBQUM7SUFBQSxPQUVoQztNQUFBVSxHQUFBLEVBQ0EsK0JBQStCO01BQUFDLEdBQUEsRUFFbEMsQ0FBQyxJQUFJLENBQU8sS0FBUyxDQUFULFNBQVMsQ0FBQyxtRUFFdEIsRUFGQyxJQUFJLENBRUU7TUFBQUMsUUFBQSxFQUdDLFdBQVc7TUFBQUMsU0FBQSxFQUNWO0lBQ2IsQ0FBQzt Line truncated
@@ -0,0 +1,129 @@
import { c as _c } from "react/compiler-runtime";
/**
* Surfaces plugin-install prompts driven by `<claude-code-hint />` tags
* that CLIs/SDKs emit to stderr. See docs/claude-code-hints.md.
*
* Show-once semantics: each plugin is prompted for at most once ever,
* recorded in config regardless of yes/no. The pre-store gate in
* maybeRecordPluginHint already dropped installed/shown/capped hints, so
* anything that reaches this hook is worth resolving.
*/
import * as React from 'react';
import { useNotifications } from '../context/notifications.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 { clearPendingHint, getPendingHintSnapshot, markShownThisSession, subscribeToPendingHint } from '../utils/claudeCodeHints.js';
import { logForDebugging } from '../utils/debug.js';
import { disableHintRecommendations, markHintPluginShown, type PluginHintRecommendation, resolvePluginHint } from '../utils/plugins/hintRecommendation.js';
import { installPluginFromMarketplace } from '../utils/plugins/pluginInstallationHelpers.js';
import { installPluginAndNotify, usePluginRecommendationBase } from './usePluginRecommendationBase.js';
type UseClaudeCodeHintRecommendationResult = {
recommendation: PluginHintRecommendation | null;
handleResponse: (response: 'yes' | 'no' | 'disable') => void;
};
export function useClaudeCodeHintRecommendation() {
const $ = _c(11);
const pendingHint = React.useSyncExternalStore(subscribeToPendingHint, getPendingHintSnapshot);
const {
addNotification
} = useNotifications();
const {
recommendation,
clearRecommendation,
tryResolve
} = usePluginRecommendationBase();
let t0;
let t1;
if ($[0] !== pendingHint || $[1] !== tryResolve) {
t0 = () => {
if (!pendingHint) {
return;
}
tryResolve(async () => {
const resolved = await resolvePluginHint(pendingHint);
if (resolved) {
logForDebugging(`[useClaudeCodeHintRecommendation] surfacing ${resolved.pluginId} from ${resolved.sourceCommand}`);
markShownThisSession();
}
if (getPendingHintSnapshot() === pendingHint) {
clearPendingHint();
}
return resolved;
});
};
t1 = [pendingHint, tryResolve];
$[0] = pendingHint;
$[1] = tryResolve;
$[2] = t0;
$[3] = t1;
} else {
t0 = $[2];
t1 = $[3];
}
React.useEffect(t0, t1);
let t2;
if ($[4] !== addNotification || $[5] !== clearRecommendation || $[6] !== recommendation) {
t2 = response => {
if (!recommendation) {
return;
}
markHintPluginShown(recommendation.pluginId);
logEvent("tengu_plugin_hint_response", {
_PROTO_plugin_name: recommendation.pluginName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
_PROTO_marketplace_name: recommendation.marketplaceName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
response: response as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
bb15: switch (response) {
case "yes":
{
const {
pluginId,
pluginName,
marketplaceName
} = recommendation;
installPluginAndNotify(pluginId, pluginName, "hint-plugin", addNotification, async pluginData => {
const result = await installPluginFromMarketplace({
pluginId,
entry: pluginData.entry,
marketplaceName,
scope: "user",
trigger: "hint"
});
if (!result.success) {
throw new Error(result.error);
}
});
break bb15;
}
case "disable":
{
disableHintRecommendations();
break bb15;
}
case "no":
}
clearRecommendation();
};
$[4] = addNotification;
$[5] = clearRecommendation;
$[6] = recommendation;
$[7] = t2;
} else {
t2 = $[7];
}
const handleResponse = t2;
let t3;
if ($[8] !== handleResponse || $[9] !== recommendation) {
t3 = {
recommendation,
handleResponse
};
$[8] = handleResponse;
$[9] = recommendation;
$[10] = t3;
} else {
t3 = $[10];
}
return t3;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZU5vdGlmaWNhdGlvbnMiLCJBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTIiwiQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX1BJSV9UQUdHRUQiLCJsb2dFdmVudCIsImNsZWFyUGVuZGluZ0hpbnQiLCJnZXRQZW5kaW5nSGludFNuYXBzaG90IiwibWFya1Nob3duVGhpc1Nlc3Npb24iLCJzdWJzY3JpYmVUb1BlbmRpbmdIaW50IiwibG9nRm9yRGVidWdnaW5nIiwiZGlzYWJsZUhpbnRSZWNvbW1lbmRhdGlvbnMiLCJtYXJrSGludFBsdWdpblNob3duIiwiUGx1Z2luSGludFJlY29tbWVuZGF0aW9uIiwicmVzb2x2ZVBsdWdpbkhpbnQiLCJpbnN0YWxsUGx1Z2luRnJvbU1hcmtldHBsYWNlIiwiaW5zdGFsbFBsdWdpbkFuZE5vdGlmeSIsInVzZVBsdWdpblJlY29tbWVuZGF0aW9uQmFzZSIsIlVzZUNsYXVkZUNvZGVIaW50UmVjb21tZW5kYXRpb25SZXN1bHQiLCJyZWNvbW1lbmRhdGlvbiIsImhhbmRsZVJlc3BvbnNlIiwicmVzcG9uc2UiLCJ1c2VDbGF1ZGVDb2RlSGludFJlY29tbWVuZGF0aW9uIiwiJCIsIl9jIiwicGVuZGluZ0hpbnQiLCJ1c2VTeW5jRXh0ZXJuYWxTdG9yZSIsImFkZE5vdGlmaWNhdGlvbiIsImNsZWFyUmVjb21tZW5kYXRpb24iLCJ0cnlSZXNvbHZlIiwidDAiLCJ0MSIsInJlc29sdmVkIiwicGx1Z2luSWQiLCJzb3VyY2VDb21tYW5kIiwidXNlRWZmZWN0IiwidDIiLCJfUFJPVE9fcGx1Z2luX25hbWUiLCJwbHVnaW5OYW1lIiwiX1BST1RPX21hcmtldHBsYWNlX25hbWUiLCJtYXJrZXRwbGFjZU5hbWUiLCJiYjE1IiwicGx1Z2luRGF0YSIsInJlc3VsdCIsImVudHJ5Iiwic2NvcGUiLCJ0cmlnZ2VyIiwic3VjY2VzcyIsIkVycm9yIiwiZXJyb3IiLCJ0MyJdLCJzb3VyY2VzIjpbInVzZUNsYXVkZUNvZGVIaW50UmVjb21tZW5kYXRpb24udHN4Il0sInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogU3VyZmFjZXMgcGx1Z2luLWluc3RhbGwgcHJvbXB0cyBkcml2ZW4gYnkgYDxjbGF1ZGUtY29kZS1oaW50IC8+YCB0YWdzXG4gKiB0aGF0IENMSXMvU0RLcyBlbWl0IHRvIHN0ZGVyci4gU2VlIGRvY3MvY2xhdWRlLWNvZGUtaGludHMubWQuXG4gKlxuICogU2hvdy1vbmNlIHNlbWFudGljczogZWFjaCBwbHVnaW4gaXMgcHJvbXB0ZWQgZm9yIGF0IG1vc3Qgb25jZSBldmVyLFxuICogcmVjb3JkZWQgaW4gY29uZmlnIHJlZ2FyZGxlc3Mgb2YgeWVzL25vLiBUaGUgcHJlLXN0b3JlIGdhdGUgaW5cbiAqIG1heWJlUmVjb3JkUGx1Z2luSGludCBhbHJlYWR5IGRyb3BwZWQgaW5zdGFsbGVkL3Nob3duL2NhcHBlZCBoaW50cywgc29cbiAqIGFueXRoaW5nIHRoYXQgcmVhY2hlcyB0aGlzIGhvb2sgaXMgd29ydGggcmVzb2x2aW5nLlxuICovXG5cbmltcG9ydCAqIGFzIFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgdXNlTm90aWZpY2F0aW9ucyB9IGZyb20gJy4uL2NvbnRleHQvbm90aWZpY2F0aW9ucy5qcydcbmltcG9ydCB7XG4gIHR5cGUgQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX05PVF9DT0RFX09SX0ZJTEVQQVRIUyxcbiAgdHlwZSBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfUElJX1RBR0dFRCxcbiAgbG9nRXZlbnQsXG59IGZyb20gJy4uL3NlcnZpY2VzL2FuYWx5dGljcy9pbmRleC5qcydcbmltcG9ydCB7XG4gIGNsZWFyUGVuZGluZ0hpbnQsXG4gIGdldFBlbmRpbmdIaW50U25hcHNob3QsXG4gIG1hcmtTaG93blRoaXNTZXNzaW9uLFxuICBzdWJzY3JpYmVUb1BlbmRpbmdIaW50LFxufSBmcm9tICcuLi91dGlscy9jbGF1ZGVDb2RlSGludHMuanMnXG5pbXBvcnQgeyBsb2dGb3JEZWJ1Z2dpbmcgfSBmcm9tICcuLi91dGlscy9kZWJ1Zy5qcydcbmltcG9ydCB7XG4gIGRpc2FibGVIaW50UmVjb21tZW5kYXRpb25zLFxuICBtYXJrSGludFBsdWdpblNob3duLFxuICB0eXBlIFBsdWdpbkhpbnRSZWNvbW1lbmRhdGlvbixcbiAgcmVzb2x2ZVBsdWdpbkhpbnQsXG59IGZyb20gJy4uL3V0aWxzL3BsdWdpbnMvaGludFJlY29tbWVuZGF0aW9uLmpzJ1xuaW1wb3J0IHsgaW5zdGFsbFBsdWdpbkZyb21NYXJrZXRwbGFjZSB9IGZyb20gJy4uL3V0aWxzL3BsdWdpbnMvcGx1Z2luSW5zdGFsbGF0aW9uSGVscGVycy5qcydcbmltcG9ydCB7XG4gIGluc3RhbGxQbHVnaW5BbmROb3RpZnksXG4gIHVzZVBsdWdpblJlY29tbWVuZGF0aW9uQmFzZSxcbn0gZnJvbSAnLi91c2VQbHVnaW5SZWNvbW1lbmRhdGlvbkJhc2UuanMnXG5cbnR5cGUgVXNlQ2xhdWRlQ29kZUhpbnRSZWNvbW1lbmRhdGlvblJlc3VsdCA9IHtcbiAgcmVjb21tZW5kYXRpb246IFBsdWdpbkhpbnRSZWNvbW1lbmRhdGlvbiB8IG51bGxcbiAgaGFuZGxlUmVzcG9uc2U6IChyZXNwb25zZTogJ3llcycgfCAnbm8nIHwgJ2Rpc2FibGUnKSA9PiB2b2lkXG59XG5cbmV4cG9ydCBmdW5jdGlvbiB1c2VDbGF1ZGVDb2RlSGludFJlY29tbWVuZGF0aW9uKCk6IFVzZUNsYXVkZUNvZGVIaW50UmVjb21tZW5kYXRpb25SZXN1bHQge1xuICBjb25zdCBwZW5kaW5nSGludCA9IFJlYWN0LnVzZVN5bmNFeHRlcm5hbFN0b3JlKFxuICAgIHN1YnNjcmliZVRvUGVuZGluZ0hpbnQsXG4gICAgZ2V0UGVuZGluZ0hpbnRTbmFwc2hvdCxcbiAgKVxuICBjb25zdCB7IGFkZE5vdGlmaWNhdGlvbiB9ID0gdXNlTm90aWZpY2F0aW9ucygpXG4gIGNvbnN0IHsgcmVjb21tZW5kYXRpb24sIGNsZWFyUmVjb21tZW5kYXRpb24sIHRyeVJlc29sdmUgfSA9XG4gICAgdXNlUGx1Z2luUmVjb21tZW5kYXRpb25CYXNlPFBsdWdpbkhpbnRSZWNvbW1lbmRhdGlvbj4oKVxuXG4gIFJlYWN0LnVzZUVmZmVjdCgoKSA9PiB7XG4gICAgaWYgKCFwZW5kaW5nSGludCkgcmV0dXJuXG4gICAgdHJ5UmVzb2x2ZShhc3luYyAoKSA9PiB7XG4gICAgICBjb25zdCByZXNvbHZlZCA9IGF3YWl0IHJlc29sdmVQbHVnaW5IaW50KHBlbmRpbmdIaW50KVxuICAgICAgaWYgKHJlc29sdmVkKSB7XG4gICAgICAgIGxvZ0ZvckRlYnVnZ2luZyhcbiAgICAgICAgICBgW3VzZUNsYXVkZUNvZGVIaW50UmVjb21tZW5kYXRpb25dIHN1cmZhY2luZyAke3Jlc29sdmVkLnBsdWdpbklkfSBmcm9tICR7cmVzb2x2ZWQuc291cmNlQ29tbWFuZH1gLFxuICAgICAgICApXG4gICAgICAgIG1hcmtTaG93blRoaXNTZXNzaW9uKClcbiAgICAgIH1cbiAgICAgIC8vIERyb3AgdGhlIHNsb3Qg4oCUIGJ1dCBvbmx5IGlmIGl0IHN0aWxsIGhvbGRzIHRoZSBoaW50IHdlIGp1c3RcbiAgICAgIC8vIHJlc29sdmVkLiBBIG5ld2VyIGhpbnQgbWF5IGhhdmUgb3ZlcndyaXR0ZW4gaXQgZHVyaW5nIHRoZSBhc3luY1xuICAgICAgLy8gbG9va3VwOyBkb24ndCBjbG9iYmVyIHRoYXQuXG4gICAgICBpZiAoZ2V0UGVuZGluZ0hpbnRTbmFwc2hvdCgpID09PSBwZW5kaW5nSGludCkge1xuICAgICAgICBjbGVhclBlbmRpbmdIaW50KClcbiAgICAgIH1cbiAgICAgIHJldHVybiByZXNvbHZlZFxuICAgIH0pXG4gIH0sIFtwZW5kaW5nSGludCwgdHJ5UmVzb2x2ZV0pXG5cbiAgY29uc3QgaGFuZGxlUmVzcG9uc2UgPSBSZWFjdC51c2VDYWxsYmFjayhcbiAgICAocmVzcG9uc2U6ICd5ZXMnIHwgJ25vJyB8ICdkaXNhYmxlJykgPT4ge1xuICAgICAgaWYgKCFyZWNvbW1lbmRhdGlvbikgcmV0dXJuXG5cbiAgICAgIC8vIFJlY29yZCBzaG93LW9uY2UgaGVyZSwgbm90IGF0IHJlc29sdXRpb24 Line truncated
+77
View File
@@ -0,0 +1,77 @@
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 that shows a notification when the terminal regains focus
* and the clipboard contains an image.
*
* @param isFocused - Whether the terminal is currently focused
* @param enabled - Whether image paste is enabled (onImagePaste is defined)
*/
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(() => {
// Only trigger on focus regain (was unfocused, now focused)
const wasFocused = lastFocusedRef.current
lastFocusedRef.current = isFocused
if (!enabled || !isFocused || wasFocused) {
return
}
// Clear any pending check
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])
}
+108
View File
@@ -0,0 +1,108 @@
import { c as _c } from "react/compiler-runtime";
/**
* Component that registers keybinding handlers for command bindings.
*
* Must be rendered inside KeybindingSetup to have access to the keybinding context.
* Reads "command:*" actions from the current keybinding configuration and registers
* handlers that invoke the corresponding slash command via onSubmit.
*
* Commands triggered via keybinding are treated as "immediate" - they execute right
* away and preserve the user's existing input text (the prompt is not cleared).
*/
import { useMemo } from 'react';
import { useIsModalOverlayActive } from '../context/overlayContext.js';
import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js';
import { useKeybindings } from '../keybindings/useKeybinding.js';
import type { PromptInputHelpers } from '../utils/handlePromptSubmit.js';
type Props = {
// onSubmit accepts additional parameters beyond what we pass here,
// so we use a rest parameter to allow any additional args
onSubmit: (input: string, helpers: PromptInputHelpers, ...rest: [speculationAccept?: undefined, options?: {
fromKeybinding?: boolean;
}]) => void;
/** Set to false to disable command keybindings (e.g., when a dialog is open) */
isActive?: boolean;
};
const NOOP_HELPERS: PromptInputHelpers = {
setCursorOffset: () => {},
clearBuffer: () => {},
resetHistory: () => {}
};
/**
* Registers keybinding handlers for all "command:*" actions found in the
* user's keybinding configuration. When triggered, each handler submits
* the corresponding slash command (e.g., "command:commit" submits "/commit").
*/
export function CommandKeybindingHandlers(t0) {
const $ = _c(8);
const {
onSubmit,
isActive: t1
} = t0;
const isActive = t1 === undefined ? true : t1;
const keybindingContext = useOptionalKeybindingContext();
const isModalOverlayActive = useIsModalOverlayActive();
let t2;
bb0: {
if (!keybindingContext) {
let t3;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t3 = new Set();
$[0] = t3;
} else {
t3 = $[0];
}
t2 = t3;
break bb0;
}
let actions;
if ($[1] !== keybindingContext.bindings) {
actions = new Set();
for (const binding of keybindingContext.bindings) {
if (binding.action?.startsWith("command:")) {
actions.add(binding.action);
}
}
$[1] = keybindingContext.bindings;
$[2] = actions;
} else {
actions = $[2];
}
t2 = actions;
}
const commandActions = t2;
let map;
if ($[3] !== commandActions || $[4] !== onSubmit) {
map = {};
for (const action of commandActions) {
const commandName = action.slice(8);
map[action] = () => {
onSubmit(`/${commandName}`, NOOP_HELPERS, undefined, {
fromKeybinding: true
});
};
}
$[3] = commandActions;
$[4] = onSubmit;
$[5] = map;
} else {
map = $[5];
}
const handlers = map;
const t3 = isActive && !isModalOverlayActive;
let t4;
if ($[6] !== t3) {
t4 = {
context: "Chat",
isActive: t3
};
$[6] = t3;
$[7] = t4;
} else {
t4 = $[7];
}
useKeybindings(handlers, t4);
return null;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJ1c2VNZW1vIiwidXNlSXNNb2RhbE92ZXJsYXlBY3RpdmUiLCJ1c2VPcHRpb25hbEtleWJpbmRpbmdDb250ZXh0IiwidXNlS2V5YmluZGluZ3MiLCJQcm9tcHRJbnB1dEhlbHBlcnMiLCJQcm9wcyIsIm9uU3VibWl0IiwiaW5wdXQiLCJoZWxwZXJzIiwicmVzdCIsInNwZWN1bGF0aW9uQWNjZXB0Iiwib3B0aW9ucyIsImZyb21LZXliaW5kaW5nIiwiaXNBY3RpdmUiLCJOT09QX0hFTFBFUlMiLCJzZXRDdXJzb3JPZmZzZXQiLCJjbGVhckJ1ZmZlciIsInJlc2V0SGlzdG9yeSIsIkNvbW1hbmRLZXliaW5kaW5nSGFuZGxlcnMiLCJ0MCIsIiQiLCJfYyIsInQxIiwidW5kZWZpbmVkIiwia2V5YmluZGluZ0NvbnRleHQiLCJpc01vZGFsT3ZlcmxheUFjdGl2ZSIsInQyIiwiYmIwIiwidDMiLCJTeW1ib2wiLCJmb3IiLCJTZXQiLCJhY3Rpb25zIiwiYmluZGluZ3MiLCJiaW5kaW5nIiwiYWN0aW9uIiwic3RhcnRzV2l0aCIsImFkZCIsImNvbW1hbmRBY3Rpb25zIiwibWFwIiwiY29tbWFuZE5hbWUiLCJzbGljZSIsImhhbmRsZXJzIiwidDQiLCJjb250ZXh0Il0sInNvdXJjZXMiOlsidXNlQ29tbWFuZEtleWJpbmRpbmdzLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyIvKipcbiAqIENvbXBvbmVudCB0aGF0IHJlZ2lzdGVycyBrZXliaW5kaW5nIGhhbmRsZXJzIGZvciBjb21tYW5kIGJpbmRpbmdzLlxuICpcbiAqIE11c3QgYmUgcmVuZGVyZWQgaW5zaWRlIEtleWJpbmRpbmdTZXR1cCB0byBoYXZlIGFjY2VzcyB0byB0aGUga2V5YmluZGluZyBjb250ZXh0LlxuICogUmVhZHMgXCJjb21tYW5kOipcIiBhY3Rpb25zIGZyb20gdGhlIGN1cnJlbnQga2V5YmluZGluZyBjb25maWd1cmF0aW9uIGFuZCByZWdpc3RlcnNcbiAqIGhhbmRsZXJzIHRoYXQgaW52b2tlIHRoZSBjb3JyZXNwb25kaW5nIHNsYXNoIGNvbW1hbmQgdmlhIG9uU3VibWl0LlxuICpcbiAqIENvbW1hbmRzIHRyaWdnZXJlZCB2aWEga2V5YmluZGluZyBhcmUgdHJlYXRlZCBhcyBcImltbWVkaWF0ZVwiIC0gdGhleSBleGVjdXRlIHJpZ2h0XG4gKiBhd2F5IGFuZCBwcmVzZXJ2ZSB0aGUgdXNlcidzIGV4aXN0aW5nIGlucHV0IHRleHQgKHRoZSBwcm9tcHQgaXMgbm90IGNsZWFyZWQpLlxuICovXG5pbXBvcnQgeyB1c2VNZW1vIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgeyB1c2VJc01vZGFsT3ZlcmxheUFjdGl2ZSB9IGZyb20gJy4uL2NvbnRleHQvb3ZlcmxheUNvbnRleHQuanMnXG5pbXBvcnQgeyB1c2VPcHRpb25hbEtleWJpbmRpbmdDb250ZXh0IH0gZnJvbSAnLi4va2V5YmluZGluZ3MvS2V5YmluZGluZ0NvbnRleHQuanMnXG5pbXBvcnQgeyB1c2VLZXliaW5kaW5ncyB9IGZyb20gJy4uL2tleWJpbmRpbmdzL3VzZUtleWJpbmRpbmcuanMnXG5pbXBvcnQgdHlwZSB7IFByb21wdElucHV0SGVscGVycyB9IGZyb20gJy4uL3V0aWxzL2hhbmRsZVByb21wdFN1Ym1pdC5qcydcblxudHlwZSBQcm9wcyA9IHtcbiAgLy8gb25TdWJtaXQgYWNjZXB0cyBhZGRpdGlvbmFsIHBhcmFtZXRlcnMgYmV5b25kIHdoYXQgd2UgcGFzcyBoZXJlLFxuICAvLyBzbyB3ZSB1c2UgYSByZXN0IHBhcmFtZXRlciB0byBhbGxvdyBhbnkgYWRkaXRpb25hbCBhcmdzXG4gIG9uU3VibWl0OiAoXG4gICAgaW5wdXQ6IHN0cmluZyxcbiAgICBoZWxwZXJzOiBQcm9tcHRJbnB1dEhlbHBlcnMsXG4gICAgLi4ucmVzdDogW1xuICAgICAgc3BlY3VsYXRpb25BY2NlcHQ/OiB1bmRlZmluZWQsXG4gICAgICBvcHRpb25zPzogeyBmcm9tS2V5YmluZGluZz86IGJvb2xlYW4gfSxcbiAgICBdXG4gICkgPT4gdm9pZFxuICAvKiogU2V0IHRvIGZhbHNlIHRvIGRpc2FibGUgY29tbWFuZCBrZXliaW5kaW5ncyAoZS5nLiwgd2hlbiBhIGRpYWxvZyBpcyBvcGVuKSAqL1xuICBpc0FjdGl2ZT86IGJvb2xlYW5cbn1cblxuY29uc3QgTk9PUF9IRUxQRVJTOiBQcm9tcHRJbnB1dEhlbHBlcnMgPSB7XG4gIHNldEN1cnNvck9mZnNldDogKCkgPT4ge30sXG4gIGNsZWFyQnVmZmVyOiAoKSA9PiB7fSxcbiAgcmVzZXRIaXN0b3J5OiAoKSA9PiB7fSxcbn1cblxuLyoqXG4gKiBSZWdpc3RlcnMga2V5YmluZGluZyBoYW5kbGVycyBmb3IgYWxsIFwiY29tbWFuZDoqXCIgYWN0aW9ucyBmb3VuZCBpbiB0aGVcbiAqIHVzZXIncyBrZXliaW5kaW5nIGNvbmZpZ3VyYXRpb24uIFdoZW4gdHJpZ2dlcmVkLCBlYWNoIGhhbmRsZXIgc3VibWl0c1xuICogdGhlIGNvcnJlc3BvbmRpbmcgc2xhc2ggY29tbWFuZCAoZS5nLiwgXCJjb21tYW5kOmNvbW1pdFwiIHN1Ym1pdHMgXCIvY29tbWl0XCIpLlxuICovXG5leHBvcnQgZnVuY3Rpb24gQ29tbWFuZEtleWJpbmRpbmdIYW5kbGVycyh7XG4gIG9uU3VibWl0LFxuICBpc0FjdGl2ZSA9IHRydWUsXG59OiBQcm9wcyk6IG51bGwge1xuICBjb25zdCBrZXliaW5kaW5nQ29udGV4dCA9IHVzZU9wdGlvbmFsS2V5YmluZGluZ0NvbnRleHQoKVxuICBjb25zdCBpc01vZGFsT3ZlcmxheUFjdGl2ZSA9IHVzZUlzTW9kYWxPdmVybGF5QWN0aXZlKClcblxuICAvLyBFeHRyYWN0IGNvbW1hbmQgYWN0aW9ucyBmcm9tIHBhcnNlZCBiaW5kaW5nc1xuICBjb25zdCBjb21tYW5kQWN0aW9ucyA9IHVzZU1lbW8oKCkgPT4ge1xuICAgIGlmICgha2V5YmluZGluZ0NvbnRleHQpIHJldHVybiBuZXcgU2V0PHN0cmluZz4oKVxuICAgIGNvbnN0IGFjdGlvbnMgPSBuZXcgU2V0PHN0cmluZz4oKVxuICAgIGZvciAoY29uc3QgYmluZGluZyBvZiBrZXliaW5kaW5nQ29udGV4dC5iaW5kaW5ncykge1xuICAgICAgaWYgKGJpbmRpbmcuYWN0aW9uPy5zdGFydHNXaXRoKCdjb21tYW5kOicpKSB7XG4gICAgICAgIGFjdGlvbnMuYWRkKGJpbmRpbmcuYWN0aW9uKVxuICAgICAgfVxuICAgIH1cbiAgICByZXR1cm4gYWN0aW9uc1xuICB9LCBba2V5YmluZGluZ0NvbnRleHRdKVxuXG4gIC8vIEJ1aWxkIGhhbmRsZXIgbWFwIGZvciBhbGwgY29tbWFuZCBhY3Rpb25zXG4gIGNvbnN0IGhhbmRsZXJzID0gdXNlTWVtbygoKSA9PiB7XG4gICAgY29uc3QgbWFwOiBSZWNvcmQ8c3RyaW5nLCAoKSA9PiB2b2lkPiA9IHt9XG4gICAgZm9yIChjb25zdCBhY3Rpb24gb2YgY29tbWFuZEFjdGlvbnMpIHtcbiAgICAgIGNvbnN0IGNvbW1hbmROYW1lID0gYWN0aW9uLnNsaWNlKCdjb21tYW5kOicubGVuZ3RoKVxuICAgICAgbWFwW2FjdGlvbl0gPSAoKSA9PiB7XG4gICAgICAgIG9uU3VibWl0KGAvJHtjb21tYW5kTmFtZX1gLCBOT09QX0hFTFBFUlMsIHVuZGVmaW5lZCwge1xuICAgICAgICAgIGZyb21LZXliaW5kaW5nOiB0cnVlLFxuICAgICAgICB9KVxuICAgICAgfVxuICAgIH1cbiAgICByZXR1cm4gbWFwXG4gIH0sIFtjb21tYW5kQWN0aW9ucywgb25TdWJtaXRdKVxuXG4gIHVzZUtleWJpbmRpbmdzKGhhbmRsZXJzLCB7XG4gICAgY29udGV4dDogJ0NoYXQnLFxuICAgIGlzQWN0aXZlOiBpc0FjdGl2ZSAmJiAhaXNNb2RhbE92ZXJsYXlBY3RpdmUsXG4gIH0pXG5cbiAgcmV0dXJuIG51bGxcbn1cbiJdLCJtYXBwaW5ncyI6IjtBQUFBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0EsU0FBU0EsT0FBTyxRQUFRLE9BQU87QUFDL0IsU0FBU0MsdUJBQXVCLFFBQVEsOEJBQThCO0FBQ3RFLFNBQVNDLDRCQUE0QixRQUFRLHFDQUFxQztBQUNsRixTQUFTQyxjQUFjLFFBQVEsaUNBQWlDO0FBQ2hFLGNBQWNDLGtCQUFrQixRQUFRLGdDQUFnQztBQUV4RSxLQUFLQyxLQUFLLEdBQUc Line truncated
+15
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 to subscribe to the unified command queue.
* Returns a frozen array that only changes reference on mutation.
* Components re-render only when the queue changes.
*/
export function useCommandQueue(): readonly QueuedCommand[] {
return useSyncExternalStore(subscribeToCommandQueue, getCommandQueueSnapshot)
}
+98
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>
/**
* Auto-copy the selection to the clipboard when the user finishes dragging
* (mouse-up with a non-empty selection) or multi-clicks to select a word/line.
* Mirrors iTerm2's "Copy to pasteboard on selection" — the highlight is left
* intact so the user can see what was copied. Only fires in alt-screen mode
* (selection state is ink-instance-owned; outside alt-screen, the native
* terminal handles selection and this hook is a no-op via the ink stub).
*
* selection.subscribe fires on every mutation (start/update/finish/clear/
* multiclick). Both char drags and multi-clicks set isDragging=true while
* pressed, so a selection appearing with isDragging=false is always a
* drag-finish. copiedRef guards against double-firing on spurious notifies.
*
* onCopied is optional — when omitted, copy is silent (clipboard is written
* but no toast/notification fires). FleetView uses this silent mode; the
* fullscreen REPL passes showCopiedToast for user feedback.
*/
export function useCopyOnSelect(
selection: Selection,
isActive: boolean,
onCopied?: (text: string) => void,
): void {
// Tracks whether the *previous* notification had a visible selection with
// isDragging=false (i.e., we already auto-copied it). Without this, the
// finish→clear transition would look like a fresh selection-gone-idle
// event and we'd toast twice for a single drag.
const copiedRef = useRef(false)
// onCopied is a fresh closure each render; read through a ref so the
// effect doesn't re-subscribe (which would reset copiedRef via unmount).
const onCopiedRef = useRef(onCopied)
onCopiedRef.current = onCopied
useEffect(() => {
if (!isActive) return
const unsubscribe = selection.subscribe(() => {
const sel = selection.getState()
const has = selection.hasSelection()
// Drag in progress — wait for finish. Reset copied flag so a new drag
// that ends on the same range still triggers a fresh copy.
if (sel?.isDragging) {
copiedRef.current = false
return
}
// No selection (cleared, or click-without-drag) — reset.
if (!has) {
copiedRef.current = false
return
}
// Selection settled (drag finished OR multi-click). Already copied
// this one — the only way to get here again without going through
// isDragging or !has is a spurious notify (shouldn't happen, but safe).
if (copiedRef.current) return
// Default true: macOS users expect cmd+c to work. It can't — the
// terminal's Edit > Copy intercepts it before the pty sees it, and
// finds no native selection (mouse tracking disabled it). Auto-copy
// on mouse-up makes cmd+c a no-op that leaves the clipboard intact
// with the right content, so paste works as expected.
const enabled = getGlobalConfig().copyOnSelect ?? true
if (!enabled) return
const text = selection.copySelectionNoClear()
// Whitespace-only (e.g., blank-line multi-click) — not worth a
// clipboard write or toast. Still set copiedRef so we don't retry.
if (!text || !text.trim()) {
copiedRef.current = true
return
}
copiedRef.current = true
onCopiedRef.current?.(text)
})
return unsubscribe
}, [isActive, selection])
}
/**
* Pipe the theme's selectionBg color into the Ink StylePool so the
* selection overlay renders a solid blue bg instead of SGR-7 inverse.
* Ink is theme-agnostic (layering: colorize.ts "theme resolution happens
* at component layer, not here") — this is the bridge. Fires on mount
* (before any mouse input is possible) and again whenever /theme flips,
* so the selection color tracks the theme live.
*/
export function useSelectionBgColor(selection: Selection): void {
const [themeName] = useTheme()
useEffect(() => {
selection.setSelectionBgColor(getTheme(themeName).selectionBg)
}, [selection, themeName])
}
+46
View File
@@ -0,0 +1,46 @@
import { useCallback, useEffect, useRef } from 'react'
import type { HookResultMessage, Message } from '../types/message.js'
/**
* Manages deferred SessionStart hook messages so the REPL can render
* immediately instead of blocking on hook execution (~500ms).
*
* Hook messages are injected asynchronously when the promise resolves.
* Returns a callback that onSubmit should call before the first API
* request to ensure the model always sees hook context.
*/
export function useDeferredHookMessages(
pendingHookMessages: Promise<HookResultMessage[]> | undefined,
setMessages: (action: React.SetStateAction<Message[]>) => void,
): () => Promise<void> {
const pendingRef = useRef(pendingHookMessages ?? null)
const resolvedRef = useRef(!pendingHookMessages)
useEffect(() => {
const promise = pendingRef.current
if (!promise) return
let cancelled = false
promise.then(msgs => {
if (cancelled) return
resolvedRef.current = true
pendingRef.current = null
if (msgs.length > 0) {
setMessages(prev => [...msgs, ...prev])
}
})
return () => {
cancelled = true
}
}, [setMessages])
return useCallback(async () => {
if (resolvedRef.current || !pendingRef.current) return
const msgs = await pendingRef.current
if (resolvedRef.current) return
resolvedRef.current = true
pendingRef.current = null
if (msgs.length > 0) {
setMessages(prev => [...msgs, ...prev])
}
}, [setMessages])
}
+110
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
}
/**
* Hook to fetch current git diff data on demand.
* Fetches both stats and hunks when component mounts.
*/
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)
// Fetch diff data on mount
useEffect(() => {
let cancelled = false
async function loadDiffData() {
try {
// Fetch both stats and 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[] = []
// Iterate over perFileStats to get all files including large/skipped ones
for (const [path, fileStats] of perFileStats) {
const fileHunks = hunks.get(path)
const isUntracked = fileStats.isUntracked ?? false
// Detect large file (in perFileStats but not in hunks, and not binary/untracked)
const isLargeFile = !fileStats.isBinary && !isUntracked && !fileHunks
// Detect truncated file (total > limit means we truncated)
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])
}
+379
View File
@@ -0,0 +1,379 @@
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'
}
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,
}
}
/**
* Re-computes the edits from the old and new contents. This is necessary
* to apply any edits the user may have made to the new contents.
*/
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)
}
/**
* Done if:
*
* 1. Tab is closed in IDE
* 2. Tab is saved in IDE (we then close the tab)
* 3. User selected an option in IDE
* 4. User selected an option in terminal (or hit esc)
*
* Resolves with the new file content.
*
* TODO: Time out after 5 mins of inactivity?
* TODO: Update auto-approval UI when IDE exits
* TODO: Close the IDE tab when the approval prompt is unmounted
*/
async function showDiffInIDE(
file_path: string,
edits: FileEdit[],
toolUseContext: ToolUseContext,
tabName: string,
): Promise<{ oldContent: string; newContent: string }> {
let isCleanedUp = false
const oldFilePath = expandPath(file_path)
let oldContent = ''
try {
oldContent = readFileSync(oldFilePath)
} catch (e: unknown) {
if (!isENOENT(e)) {
throw e
}
}
async function cleanup() {
// Careful to avoid race conditions, since this
// function can be called from multiple places.
if (isCleanedUp) {
return
}
isCleanedUp = true
// Don't fail if this fails
try {
await closeTabInIDE(tabName, ideClient)
} catch (e) {
logError(e as Error)
}
process.off('beforeExit', cleanup)
toolUseContext.abortController.signal.removeEventListener('abort', cleanup)
}
// Cleanup if the user hits esc to cancel the tool call - or on exit
toolUseContext.abortController.signal.addEventListener('abort', cleanup)
process.on('beforeExit', cleanup)
// Open the diff in the IDE
const ideClient = getConnectedIdeClient(toolUseContext.options.mcpClients)
try {
const { updatedFile } = getPatchForEdits({
filePath: oldFilePath,
fileContents: oldContent,
edits,
})
if (!ideClient || ideClient.type !== 'connected') {
throw new Error('IDE client not available')
}
let ideOldPath = oldFilePath
// Only convert paths if we're in WSL and IDE is on Windows
const ideRunningInWindows =
(ideClient.config as McpSSEIDEServerConfig | McpWebSocketIDEServerConfig)
.ideRunningInWindows === true
if (
getPlatform() === 'wsl' &&
ideRunningInWindows &&
process.env.WSL_DISTRO_NAME
) {
const converter = new WindowsToWSLConverter(process.env.WSL_DISTRO_NAME)
ideOldPath = converter.toIDEPath(oldFilePath)
}
const rpcResult = await callIdeRpc(
'openDiff',
{
old_file_path: ideOldPath,
new_file_path: ideOldPath,
new_file_contents: updatedFile,
tab_name: tabName,
},
ideClient,
)
// Convert the raw RPC result to a ToolCallResponse format
const data = Array.isArray(rpcResult) ? rpcResult : [rpcResult]
// If the user saved the file then take the new contents and resolve with that.
if (isSaveMessage(data)) {
void cleanup()
return {
oldContent: oldContent,
newContent: data[1].text,
}
} else if (isClosedMessage(data)) {
void cleanup()
return {
oldContent: oldContent,
newContent: updatedFile,
}
} else if (isRejectedMessage(data)) {
void cleanup()
return {
oldContent: oldContent,
newContent: oldContent,
}
}
// Indicates that the tool call completed with none of the expected
// results. Did the user close the IDE?
throw new Error('Not accepted')
} catch (error) {
logError(error as Error)
void cleanup()
throw error
}
}
async function closeTabInIDE(
tabName: string,
ideClient?: MCPServerConnection | undefined,
): Promise<void> {
try {
if (!ideClient || ideClient.type !== 'connected') {
throw new Error('IDE client not available')
}
// Use direct RPC to close the tab
await callIdeRpc('close_tab', { tab_name: tabName }, ideClient)
} catch (error) {
logError(error as Error)
// Don't throw - this is a cleanup operation
}
}
function isClosedMessage(data: unknown): data is { text: 'TAB_CLOSED' } {
return (
Array.isArray(data) &&
typeof data[0] === 'object' &&
data[0] !== null &&
'type' in data[0] &&
data[0].type === 'text' &&
'text' in data[0] &&
data[0].text === 'TAB_CLOSED'
)
}
function isRejectedMessage(data: unknown): data is { text: 'DIFF_REJECTED' } {
return (
Array.isArray(data) &&
typeof data[0] === 'object' &&
data[0] !== null &&
'type' in data[0] &&
data[0].type === 'text' &&
'text' in data[0] &&
data[0].text === 'DIFF_REJECTED'
)
}
function isSaveMessage(
data: unknown,
): data is [{ text: 'FILE_SAVED' }, { text: string }] {
return (
Array.isArray(data) &&
data[0]?.type === 'text' &&
data[0].text === 'FILE_SAVED' &&
typeof data[1].text === 'string'
)
}
+229
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)
// Keep a ref to tools so the WebSocket callback doesn't go stale
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)
}
// Skip duplicate init messages (server sends one per 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() {
// No-op for remote
},
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() {
// No-op for remote
},
}
setToolUseConfirmQueue(queue => [...queue, toolUseConfirm])
setIsLoading(false)
},
onConnected: () => {
logForDebugging('[useDirectConnect] Connected')
isConnectedRef.current = true
},
onDisconnected: () => {
logForDebugging('[useDirectConnect] Disconnected')
if (!isConnectedRef.current) {
// Never connected — connection failure (e.g. auth rejected)
process.stderr.write(
`\nFailed to connect to server at ${config.wsUrl}\n`,
)
} else {
// Was connected then lost — server process exited or network dropped
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],
)
// Cancel the current request
const cancelRequest = useCallback(() => {
// Send interrupt signal to the server
managerRef.current?.sendInterrupt()
setIsLoading(false)
}, [setIsLoading])
const disconnect = useCallback(() => {
managerRef.current?.disconnect()
managerRef.current = null
isConnectedRef.current = false
}, [])
// Same stability concern as useRemoteSession — memoize so consumers
// that depend on the result object don't see a fresh reference per render.
return useMemo(
() => ({ isRemoteMode, sendMessage, cancelRequest, disconnect }),
[isRemoteMode, sendMessage, cancelRequest, disconnect],
)
}
+62
View File
@@ -0,0 +1,62 @@
// Creates a function that calls one function on the first call and another
// function on the second call within a certain timeout
import { useCallback, useEffect, useRef } from 'react'
export const DOUBLE_PRESS_TIMEOUT_MS = 800
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
}
}, [])
// Cleanup timeout on unmount
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) {
// Double press detected
clearTimeoutSafe()
setPending(false)
onDoublePress()
} else {
// First press
onFirstPress?.()
setPending(true)
// Clear any existing timeout and set new one
clearTimeoutSafe()
timeoutRef.current = setTimeout(
(setPending, timeoutRef) => {
setPending(false)
timeoutRef.current = undefined
},
DOUBLE_PRESS_TIMEOUT_MS,
setPending,
timeoutRef,
)
}
lastPressRef.current = now
}, [setPending, onDoublePress, onFirstPress, clearTimeoutSafe])
}
+22
View File
@@ -0,0 +1,22 @@
import React from 'react'
import { getDynamicConfig_BLOCKS_ON_INIT } from '../services/analytics/growthbook.js'
/**
* React hook for dynamic config values.
* Returns the default value initially, then updates when the config is fetched.
*/
export function useDynamicConfig<T>(configName: string, defaultValue: T): T {
const [configValue, setConfigValue] = React.useState<T>(defaultValue)
React.useEffect(() => {
if (process.env.NODE_ENV === 'test') {
// Prevents a test hang when using this hook in tests
return
}
void getDynamicConfig_BLOCKS_ON_INIT<T>(configName, defaultValue).then(
setConfigValue,
)
}, [configName, defaultValue])
return configValue
}
+37
View File
@@ -0,0 +1,37 @@
import { useCallback, useSyncExternalStore } from 'react'
import { formatDuration } from '../utils/format.js'
/**
* Hook that returns formatted elapsed time since startTime.
* Uses useSyncExternalStore with interval-based updates for efficiency.
*
* @param startTime - Unix timestamp in ms
* @param isRunning - Whether to actively update the timer
* @param ms - How often should we trigger updates?
* @param pausedMs - Total paused duration to subtract
* @param endTime - If set, freezes the duration at this timestamp (for
* terminal tasks). Without this, viewing a 2-min task 30 min after
* completion would show "32m".
* @returns Formatted duration string (e.g., "1m 23s")
*/
export function useElapsedTime(
startTime: number,
isRunning: boolean,
ms: number = 1000,
pausedMs: number = 0,
endTime?: number,
): string {
const get = () =>
formatDuration(Math.max(0, (endTime ?? Date.now()) - startTime - pausedMs))
const subscribe = useCallback(
(notify: () => void) => {
if (!isRunning) return () => {}
const interval = setInterval(notify, ms)
return () => clearInterval(interval)
},
[isRunning, ms],
)
return useSyncExternalStore(subscribe, get, get)
}
+95
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
/**
* Handle ctrl+c and ctrl+d for exiting the application.
*
* Uses a time-based double-press mechanism:
* - First press: Shows "Press X again to exit" message
* - Second press within timeout: Exits the application
*
* Note: We use time-based double-press rather than the chord system because
* we want the first ctrl+c to also trigger interrupt (handled elsewhere).
* The chord system would prevent the first press from firing any action.
*
* These keys are hardcoded and cannot be rebound via keybindings.json.
*
* @param useKeybindingsHook - The useKeybindings hook to use for registering handlers
* (dependency injection to avoid import cycles)
* @param onInterrupt - Optional callback for features to handle interrupt (ctrl+c).
* Return true if handled, false to fall through to double-press exit.
* @param onExit - Optional custom exit handler
* @param isActive - Whether the keybinding is active (default true). Set false
* while an embedded TextInput is focused — TextInput's own
* ctrl+c/d handlers will manage cancel/exit, and Dialog's
* handler would otherwise double-fire (child useInput runs
* before parent useKeybindings, so both see every keypress).
*/
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])
// Double-press handler for ctrl+c
const handleCtrlCDoublePress = useDoublePress(
pending => setExitState({ pending, keyName: 'Ctrl-C' }),
exitFn,
)
// Double-press handler for ctrl+d
const handleCtrlDDoublePress = useDoublePress(
pending => setExitState({ pending, keyName: 'Ctrl-D' }),
exitFn,
)
// Handler for app:interrupt (ctrl+c by default)
// Let features handle interrupt first via callback
const handleInterrupt = useCallback(() => {
if (onInterrupt?.()) return // Feature handled it
handleCtrlCDoublePress()
}, [handleCtrlCDoublePress, onInterrupt])
// Handler for app:exit (ctrl+d by default)
// This also uses double-press to confirm exit
const handleExit = useCallback(() => {
handleCtrlDDoublePress()
}, [handleCtrlDDoublePress])
const handlers = useMemo(
() => ({
'app:interrupt': handleInterrupt,
'app:exit': handleExit,
}),
[handleInterrupt, handleExit],
)
useKeybindingsHook(handlers, { context: 'Global', isActive })
return exitState
}
@@ -0,0 +1,24 @@
import { useKeybindings } from '../keybindings/useKeybinding.js'
import { type ExitState, useExitOnCtrlCD } from './useExitOnCtrlCD.js'
export type { ExitState }
/**
* Convenience hook that wires up useExitOnCtrlCD with useKeybindings.
*
* This is the standard way to use useExitOnCtrlCD in components.
* The separation exists to avoid import cycles - useExitOnCtrlCD.ts
* doesn't import from the keybindings module directly.
*
* @param onExit - Optional custom exit handler
* @param onInterrupt - Optional callback for features to handle interrupt (ctrl+c).
* Return true if handled, false to fall through to double-press exit.
* @param isActive - Whether the keybinding is active (default true).
*/
export function useExitOnCtrlCDWithKeybindings(
onExit?: () => void,
onInterrupt?: () => boolean,
isActive?: boolean,
): ExitState {
return useExitOnCtrlCD(useKeybindings, onInterrupt, onExit, isActive)
}
+25
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])
}
+249
View File
@@ -0,0 +1,249 @@
/**
* Component that registers global keybinding handlers.
*
* Must be rendered inside KeybindingSetup to have access to the keybinding context.
* This component renders nothing - it just registers the keybinding handlers.
*/
import { feature } from 'bun:bundle';
import { useCallback } from 'react';
import instances from '../ink/instances.js';
import { useKeybinding } from '../keybindings/useKeybinding.js';
import type { Screen } from '../screens/REPL.js';
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js';
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../services/analytics/index.js';
import { useAppState, useSetAppState } from '../state/AppState.js';
import { count } from '../utils/array.js';
import { getTerminalPanel } from '../utils/terminalPanel.js';
type Props = {
screen: Screen;
setScreen: React.Dispatch<React.SetStateAction<Screen>>;
showAllInTranscript: boolean;
setShowAllInTranscript: React.Dispatch<React.SetStateAction<boolean>>;
messageCount: number;
onEnterTranscript?: () => void;
onExitTranscript?: () => void;
virtualScrollActive?: boolean;
searchBarOpen?: boolean;
};
/**
* Registers global keybinding handlers for:
* - ctrl+t: Toggle todo list
* - ctrl+o: Toggle transcript mode
* - ctrl+e: Toggle showing all messages in transcript
* - ctrl+c/escape: Exit transcript mode
*/
export function GlobalKeybindingHandlers({
screen,
setScreen,
showAllInTranscript,
setShowAllInTranscript,
messageCount,
onEnterTranscript,
onExitTranscript,
virtualScrollActive,
searchBarOpen = false
}: Props): null {
const expandedView = useAppState(s => s.expandedView);
const setAppState = useSetAppState();
// Toggle todo list (ctrl+t) - cycles through views
const handleToggleTodos = useCallback(() => {
logEvent('tengu_toggle_todos', {
is_expanded: expandedView === 'tasks'
});
setAppState(prev => {
const {
getAllInProcessTeammateTasks
} =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js');
const hasTeammates = count(getAllInProcessTeammateTasks(prev.tasks), t => t.status === 'running') > 0;
if (hasTeammates) {
// Both exist: none → tasks → teammates → none
switch (prev.expandedView) {
case 'none':
return {
...prev,
expandedView: 'tasks' as const
};
case 'tasks':
return {
...prev,
expandedView: 'teammates' as const
};
case 'teammates':
return {
...prev,
expandedView: 'none' as const
};
}
}
// Only tasks: none ↔ tasks
return {
...prev,
expandedView: prev.expandedView === 'tasks' ? 'none' as const : 'tasks' as const
};
});
}, [expandedView, setAppState]);
// Toggle transcript mode (ctrl+o). Two-way prompt ↔ transcript.
// Brief view has its own dedicated toggle on ctrl+shift+b.
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ?
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAppState(s_0 => s_0.isBriefOnly) : false;
const handleToggleTranscript = useCallback(() => {
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
// Escape hatch: GB kill-switch while defaultView=chat was persisted
// can leave isBriefOnly stuck on, showing a blank filterForBriefTool
// view. Users will reach for ctrl+o — clear the stuck state first.
// Only needed in the prompt screen — transcript mode already ignores
// isBriefOnly (Messages.tsx filter is gated on !isTranscriptMode).
/* eslint-disable @typescript-eslint/no-require-imports */
const {
isBriefEnabled
} = require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js');
/* eslint-enable @typescript-eslint/no-require-imports */
if (!isBriefEnabled() && isBriefOnly && screen !== 'transcript') {
setAppState(prev_0 => {
if (!prev_0.isBriefOnly) return prev_0;
return {
...prev_0,
isBriefOnly: false
};
});
return;
}
}
const isEnteringTranscript = screen !== 'transcript';
logEvent('tengu_toggle_transcript', {
is_entering: isEnteringTranscript,
show_all: showAllInTranscript,
message_count: messageCount
});
setScreen(s_1 => s_1 === 'transcript' ? 'prompt' : 'transcript');
setShowAllInTranscript(false);
if (isEnteringTranscript && onEnterTranscript) {
onEnterTranscript();
}
if (!isEnteringTranscript && onExitTranscript) {
onExitTranscript();
}
}, [screen, setScreen, isBriefOnly, showAllInTranscript, setShowAllInTranscript, messageCount, setAppState, onEnterTranscript, onExitTranscript]);
// Toggle showing all messages in transcript mode (ctrl+e)
const handleToggleShowAll = useCallback(() => {
logEvent('tengu_transcript_toggle_show_all', {
is_expanding: !showAllInTranscript,
message_count: messageCount
});
setShowAllInTranscript(prev_1 => !prev_1);
}, [showAllInTranscript, setShowAllInTranscript, messageCount]);
// Exit transcript mode (ctrl+c or escape)
const handleExitTranscript = useCallback(() => {
logEvent('tengu_transcript_exit', {
show_all: showAllInTranscript,
message_count: messageCount
});
setScreen('prompt');
setShowAllInTranscript(false);
if (onExitTranscript) {
onExitTranscript();
}
}, [setScreen, showAllInTranscript, setShowAllInTranscript, messageCount, onExitTranscript]);
// Toggle brief-only view (ctrl+shift+b). Pure display filter toggle —
// does not touch opt-in state. Asymmetric gate (mirrors /brief): OFF
// transition always allowed so the same key that got you in gets you
// out even if the GB kill-switch fires mid-session.
const handleToggleBrief = useCallback(() => {
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const {
isBriefEnabled: isBriefEnabled_0
} = require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js');
/* eslint-enable @typescript-eslint/no-require-imports */
if (!isBriefEnabled_0() && !isBriefOnly) return;
const next = !isBriefOnly;
logEvent('tengu_brief_mode_toggled', {
enabled: next,
gated: false,
source: 'keybinding' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
setAppState(prev_2 => {
if (prev_2.isBriefOnly === next) return prev_2;
return {
...prev_2,
isBriefOnly: next
};
});
}
}, [isBriefOnly, setAppState]);
// Register keybinding handlers
useKeybinding('app:toggleTodos', handleToggleTodos, {
context: 'Global'
});
useKeybinding('app:toggleTranscript', handleToggleTranscript, {
context: 'Global'
});
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useKeybinding('app:toggleBrief', handleToggleBrief, {
context: 'Global'
});
}
// Register teammate keybinding
useKeybinding('app:toggleTeammatePreview', () => {
setAppState(prev_3 => ({
...prev_3,
showTeammateMessagePreview: !prev_3.showTeammateMessagePreview
}));
}, {
context: 'Global'
});
// Toggle built-in terminal panel (meta+j).
// toggle() blocks in spawnSync until the user detaches from tmux.
const handleToggleTerminal = useCallback(() => {
if (feature('TERMINAL_PANEL')) {
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_panel', false)) {
return;
}
getTerminalPanel().toggle();
}
}, []);
useKeybinding('app:toggleTerminal', handleToggleTerminal, {
context: 'Global'
});
// Clear screen and force full redraw (ctrl+l). Recovery path when the
// terminal was cleared externally (macOS Cmd+K) and Ink's diff engine
// thinks unchanged cells don't need repainting.
const handleRedraw = useCallback(() => {
instances.get(process.stdout)?.forceRedraw();
}, []);
useKeybinding('app:redraw', handleRedraw, {
context: 'Global'
});
// Transcript-specific bindings (only active when in transcript mode)
const isInTranscript = screen === 'transcript';
useKeybinding('transcript:toggleShowAll', handleToggleShowAll, {
context: 'Transcript',
isActive: isInTranscript && !virtualScrollActive
});
useKeybinding('transcript:exit', handleExitTranscript, {
context: 'Transcript',
// Bar-open is a mode (owns keystrokes). Navigating (highlights
// visible, n/N active, bar closed) is NOT — Esc exits transcript
// directly, same as less q. useSearchInput doesn't stopPropagation,
// so without this gate its onCancel AND this handler would both
// fire on one Esc (child registers first, fires first, bubbles).
isActive: isInTranscript && !searchBarOpen
});
return null;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmZWF0dXJlIiwidXNlQ2FsbGJhY2siLCJpbnN0YW5jZXMiLCJ1c2VLZXliaW5kaW5nIiwiU2NyZWVuIiwiZ2V0RmVhdHVyZVZhbHVlX0NBQ0hFRF9NQVlfQkVfU1RBTEUiLCJBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTIiwibG9nRXZlbnQiLCJ1c2VBcHBTdGF0ZSIsInVzZVNldEFwcFN0YXRlIiwiY291bnQiLCJnZXRUZXJtaW5hbFBhbmVsIiwiUHJvcHMiLCJzY3JlZW4iLCJzZXRTY3JlZW4iLCJSZWFjdCIsIkRpc3BhdGNoIiwiU2V0U3RhdGVBY3Rpb24iLCJzaG93QWxsSW5UcmFuc2NyaXB0Iiwic2V0U2hvd0FsbEluVHJhbnNjcmlwdCIsIm1lc3NhZ2VDb3VudCIsIm9uRW50ZXJUcmFuc2NyaXB0Iiwib25FeGl0VHJhbnNjcmlwdCIsInZpcnR1YWxTY3JvbGxBY3RpdmUiLCJzZWFyY2hCYXJPcGVuIiwiR2xvYmFsS2V5YmluZGluZ0hhbmRsZXJzIiwiZXhwYW5kZWRWaWV3IiwicyIsInNldEFwcFN0YXRlIiwiaGFuZGxlVG9nZ2xlVG9kb3MiLCJpc19leHBhbmRlZCIsInByZXYiLCJnZXRBbGxJblByb2Nlc3NUZWFtbWF0ZVRhc2tzIiwicmVxdWlyZSIsImhhc1RlYW1tYXRlcyIsInRhc2tzIiwidCIsInN0YXR1cyIsImNvbnN0IiwiaXNCcmllZk9ubHkiLCJoYW5kbGVUb2dnbGVUcmFuc2NyaXB0IiwiaXNCcmllZkVuYWJsZWQiLCJpc0VudGVyaW5nVHJhbnNjcmlwdCIsImlzX2VudGVyaW5nIiwic2hvd19hbGwiLCJtZXNzYWdlX2NvdW50IiwiaGFuZGxlVG9nZ2xlU2hvd0FsbCIsImlzX2V4cGFuZGluZyIsImhhbmRsZUV4aXRUcmFuc2NyaXB0IiwiaGFuZGxlVG9nZ2xlQnJpZWYiLCJuZXh0IiwiZW5hYmxlZCIsImdhdGVkIiwic291cmNlIiwiY29udGV4dCIsInNob3dUZWFtbWF0ZU1lc3NhZ2VQcmV2aWV3IiwiaGFuZGxlVG9nZ2xlVGVybWluYWwiLCJ0b2dnbGUiLCJoYW5kbGVSZWRyYXciLCJnZXQiLCJwcm9jZXNzIiwic3Rkb3V0IiwiZm9yY2VSZWRyYXciLCJpc0luVHJhbnNjcmlwdCIsImlzQWN0aXZlIl0sInNvdXJjZXMiOlsidXNlR2xvYmFsS2V5YmluZGluZ3MudHN4Il0sInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogQ29tcG9uZW50IHRoYXQgcmVnaXN0ZXJzIGdsb2JhbCBrZXliaW5kaW5nIGhhbmRsZXJzLlxuICpcbiAqIE11c3QgYmUgcmVuZGVyZWQgaW5zaWRlIEtleWJpbmRpbmdTZXR1cCB0byBoYXZlIGFjY2VzcyB0byB0aGUga2V5YmluZGluZyBjb250ZXh0LlxuICogVGhpcyBjb21wb25lbnQgcmVuZGVycyBub3RoaW5nIC0gaXQganVzdCByZWdpc3RlcnMgdGhlIGtleWJpbmRpbmcgaGFuZGxlcnMuXG4gKi9cbmltcG9ydCB7IGZlYXR1cmUgfSBmcm9tICdidW46YnVuZGxlJ1xuaW1wb3J0IHsgdXNlQ2FsbGJhY2sgfSBmcm9tICdyZWFjdCdcbmltcG9ydCBpbnN0YW5jZXMgZnJvbSAnLi4vaW5rL2luc3RhbmNlcy5qcydcbmltcG9ydCB7IHVzZUtleWJpbmRpbmcgfSBmcm9tICcuLi9rZXliaW5kaW5ncy91c2VLZXliaW5kaW5nLmpzJ1xuaW1wb3J0IHR5cGUgeyBTY3JlZW4gfSBmcm9tICcuLi9zY3JlZW5zL1JFUEwuanMnXG5pbXBvcnQgeyBnZXRGZWF0dXJlVmFsdWVfQ0FDSEVEX01BWV9CRV9TVEFMRSB9IGZyb20gJy4uL3NlcnZpY2VzL2FuYWx5dGljcy9ncm93dGhib29rLmpzJ1xuaW1wb3J0IHtcbiAgdHlwZSBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICBsb2dFdmVudCxcbn0gZnJvbSAnLi4vc2VydmljZXMvYW5hbHl0aWNzL2luZGV4LmpzJ1xuaW1wb3J0IHsgdXNlQXBwU3RhdGUsIHVzZVNldEFwcFN0YXRlIH0gZnJvbSAnLi4vc3RhdGUvQXBwU3RhdGUuanMnXG5pbXBvcnQgeyBjb3VudCB9IGZyb20gJy4uL3V0aWxzL2FycmF5LmpzJ1xuaW1wb3J0IHsgZ2V0VGVybWluYWxQYW5lbCB9IGZyb20gJy4uL3V0aWxzL3Rlcm1pbmFsUGFuZWwuanMnXG5cbnR5cGUgUHJvcHMgPSB7XG4gIHNjcmVlbjogU2NyZWVuXG4gIHNldFNjcmVlbjogUmVhY3QuRGlzcGF0Y2g8UmVhY3QuU2V0U3RhdGVBY3Rpb248U2NyZWVuPj5cbiAgc2hvd0FsbEluVHJhbnNjcmlwdDogYm9vbGVhblxuICBzZXRTaG93QWxsSW5UcmFuc2NyaXB0OiBSZWFjdC5EaXNwYXRjaDxSZWFjdC5TZXRTdGF0ZUFjdGlvbjxib29sZWFuPj5cbiAgbWVzc2FnZUNvdW50OiBudW1iZXJcbiAgb25FbnRlclRyYW5zY3JpcHQ/OiAoKSA9PiB2b2lkXG4gIG9uRXhpdFRyYW5zY3JpcHQ/OiAoKSA9PiB2b2lkXG4gIHZpcnR1YWxTY3JvbGxBY3RpdmU/OiBib29sZWFuXG4gIHNlYXJjaEJhck9wZW4/OiBib29sZWFuXG59XG5cbi8qKlxuICogUmVnaXN0ZXJzIGdsb2JhbCBrZXliaW5kaW5nIGhhbmRsZXJzIGZvcjpcbiAqIC0gY3RybCt0OiBUb2dnbGUgdG9kbyBsaXN0XG4gKiAtIGN0cmwrbzogVG9nZ2xlIHRyYW5zY3JpcHQgbW9kZVxuICogLSBjdHJsK2U6IFRvZ2dsZSBzaG93aW5nIGFsbCBtZXNzYWdlcyBpbiB0cmFuc2NyaXB0XG4gKiAtIGN0cmwrYy9lc2NhcGU6IEV4aXQgdHJhbnNjcmlwdCBtb2RlXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBHbG9iYWxLZXliaW5kaW5nSGFuZGxlcnMoe1xuICBzY3JlZW4sXG4gIHNldFNjcmVlbixcbiAgc2hvd0FsbEluVHJhbnNjcmlwdCxcbiAgc2V0U2hvd0FsbEluVHJhbnNjcmlwdCxcbiAgbWVzc2FnZUNvdW50LFxuICBvbkVudGVyVHJhbnNjcmlwdCxcbiAgb25FeGl0VHJhbnNjcmlwdCxcbiAgdmlydHVhbFNjcm9sbEFjdGl2ZSxcbiAgc2VhcmNoQmFyT3BlbiA9IGZhbHNlLFxufTogUHJvcHMpOiBudWxsIHtcbiAgY29uc3QgZXhwYW5kZWRWaWV3ID0gdXNlQXBwU3RhdGUocyA9PiBzLmV4cGFuZGVkVmlldylcbiAgY29uc3Qgc2V0QXBwU3RhdGUgPSB1c2VTZXRBcHBTdGF0ZSgpXG5cbiAgLy8gVG9nZ2xlIHRvZG8gbGlzdCAoY3RybCt0KSAtIGN5Y2xlcyB0aHJvdWdoIHZpZXdzXG4gIGNvbnN0IGhhbmRsZVRvZ2dsZVRvZG9zID0gdXNlQ2FsbGJhY2soKCkgPT4ge1xuICAgIGxvZ0V2ZW50KCd0ZW5ndV90b2dnbGVfdG9kb3MnLCB7XG4gICAgICBpc19leHBhbmRlZDogZXhwYW5kZWRWaWV3ID09PSAndGFza3MnLFxuICAgIH0pXG4gICAgc2V0QXBwU3RhdGUocHJldiA9PiB7XG4gICAgICBjb25zdCB7IGdldEFsbEluUHJvY2Vzc1RlYW1tYXRlVGFza3MgfSA9XG4gICAgICAgIC8vIGVzbGludC1kaXNhYmxlLW5leHQtbGluZSBAdHlwZXNjcmlwdC1lc2xpbnQvbm8tcmVxdWlyZS1pbXBvcnRzXG4gICAgICAgIHJlcXVpcmUoJy4uL3Rhc2tzL0luUHJvY2Vzc1RlYW1tYXRlVGFzay9JblByb2Nlc3NUZWFtbWF0ZVRhc2suanMnKSBhcyB0eXBlb2YgaW1wb3J0KCcuLi90YXNrcy9JblByb2Nlc3NUZWFtbWF0ZVRhc2svSW5Qcm9jZXNzVGVhbW1hdGVUYXNrLmpzJylcbiAgICAgIGNvbnN0IGhhc1RlYW1tYXRlcyA9XG4gICAgICAgIGNvdW50KFxuICAgICAgICAgIGdldEFsbEluUHJvY2Vzc1RlYW1tYXRlVGFza3MocHJldi50YXNrcyksXG4gICAgICAgICAgdCA9PiB0LnN0YXR1cyA9PT0gJ3J1bm5pbmcnLFxuICAgICAgICApID4gMFxuXG4gICAgICBpZiAoaGFzVGVhbW1hdGVzKSB7XG4gICAgICAgIC8vIEJvdGggZXhpc3Q6IG5vbmUg4oaSIHRhc2tzIOKGkiB0ZWFtbWF0ZXMg4oaSIG5vbmVcbiAgICAgICAgc3dpdGNoIChwcmV2LmV4cGFuZGVkVmlldykge1xuICAgICAgICAgIGNhc2UgJ25vbmUnOlxuICAgICAgICAgICAgcmV0dXJ Line truncated
+303
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 -- backward-compat bridge until consumers wire handleKeyDown to <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) {
// Must explicitly call .return() to trigger the finally block in readLinesReverse,
// which closes the file handle. Without this, file descriptors leak.
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) {
// No match found - keep last match but mark as failed
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)
// Position cursor relative to the clean value, not the 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,
],
)
// Handler: Start history search (when not searching)
const handleStartSearch = useCallback(() => {
setIsSearching(true)
setOriginalInput(currentInput)
setOriginalCursorOffset(currentCursorOffset)
setOriginalMode(currentMode)
setOriginalPastedContents(currentPastedContents)
historyReader.current = makeHistoryReader()
seenPrompts.current.clear()
}, [
setIsSearching,
currentInput,
currentCursorOffset,
currentMode,
currentPastedContents,
])
// Handler: Find next match (when searching)
const handleNextMatch = useCallback(() => {
void searchHistory(true)
}, [searchHistory])
// Handler: Accept current match and exit search
const handleAccept = useCallback(() => {
if (historyMatch) {
const mode = getModeFromInput(historyMatch.display)
const value = getValueFromInput(historyMatch.display)
onInputChange(value)
onModeChange(mode)
setPastedContents(historyMatch.pastedContents)
} else {
// No match - restore original pasted contents
setPastedContents(originalPastedContents)
}
reset()
}, [
historyMatch,
onInputChange,
onModeChange,
setPastedContents,
originalPastedContents,
reset,
])
// Handler: Cancel search and restore original input
const handleCancel = useCallback(() => {
onInputChange(originalInput)
onCursorChange(originalCursorOffset)
setPastedContents(originalPastedContents)
reset()
}, [
onInputChange,
onCursorChange,
setPastedContents,
originalInput,
originalCursorOffset,
originalPastedContents,
reset,
])
// Handler: Execute (accept and submit)
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,
])
// Gated off under HISTORY_PICKER — the modal dialog owns ctrl+r there.
useKeybinding('history:search', handleStartSearch, {
context: 'Global',
isActive: feature('HISTORY_PICKER') ? false : !isSearching,
})
// History search context keybindings (only active when searching)
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,
})
// Handle backspace when query is empty (cancels search)
// This is a conditional behavior that doesn't fit the keybinding model
// well (backspace only cancels when query is empty)
const handleKeyDown = (e: KeyboardEvent): void => {
if (!isSearching) return
if (e.key === 'backspace' && historyQuery === '') {
e.preventDefault()
handleCancel()
}
}
// Backward-compat bridge: PromptInput doesn't yet wire handleKeyDown to
// <Box onKeyDown>. Subscribe via useInput and adapt InputEvent →
// KeyboardEvent until the consumer is migrated (separate PR).
// TODO(onKeyDown-migration): remove once PromptInput passes handleKeyDown.
useInput(
(_input, _key, event) => {
handleKeyDown(new KeyboardEvent(event.keypress))
},
{ isActive: isSearching },
)
// Keep a ref to searchHistory to avoid it being a dependency of useEffect
const searchHistoryRef = useRef(searchHistory)
searchHistoryRef.current = searchHistory
// Reset history search when query changes
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,
}
}
+70
View File
@@ -0,0 +1,70 @@
import { c as _c } from "react/compiler-runtime";
import { useEffect } from 'react';
import type { ScopedMcpServerConfig } from '../services/mcp/types.js';
import { getGlobalConfig } from '../utils/config.js';
import { isEnvDefinedFalsy, isEnvTruthy } from '../utils/envUtils.js';
import type { DetectedIDEInfo } from '../utils/ide.js';
import { type IDEExtensionInstallationStatus, type IdeType, initializeIdeIntegration, isSupportedTerminal } from '../utils/ide.js';
type UseIDEIntegrationProps = {
autoConnectIdeFlag?: boolean;
ideToInstallExtension: IdeType | null;
setDynamicMcpConfig: React.Dispatch<React.SetStateAction<Record<string, ScopedMcpServerConfig> | undefined>>;
setShowIdeOnboarding: React.Dispatch<React.SetStateAction<boolean>>;
setIDEInstallationState: React.Dispatch<React.SetStateAction<IDEExtensionInstallationStatus | null>>;
};
export function useIDEIntegration(t0) {
const $ = _c(7);
const {
autoConnectIdeFlag,
ideToInstallExtension,
setDynamicMcpConfig,
setShowIdeOnboarding,
setIDEInstallationState
} = t0;
let t1;
let t2;
if ($[0] !== autoConnectIdeFlag || $[1] !== ideToInstallExtension || $[2] !== setDynamicMcpConfig || $[3] !== setIDEInstallationState || $[4] !== setShowIdeOnboarding) {
t1 = () => {
const addIde = function addIde(ide) {
if (!ide) {
return;
}
const globalConfig = getGlobalConfig();
const autoConnectEnabled = (globalConfig.autoConnectIde || autoConnectIdeFlag || isSupportedTerminal() || process.env.CLAUDE_CODE_SSE_PORT || ideToInstallExtension || isEnvTruthy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE)) && !isEnvDefinedFalsy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE);
if (!autoConnectEnabled) {
return;
}
setDynamicMcpConfig(prev => {
if (prev?.ide) {
return prev;
}
return {
...prev,
ide: {
type: ide.url.startsWith("ws:") ? "ws-ide" : "sse-ide",
url: ide.url,
ideName: ide.name,
authToken: ide.authToken,
ideRunningInWindows: ide.ideRunningInWindows,
scope: "dynamic" as const
}
};
});
};
initializeIdeIntegration(addIde, ideToInstallExtension, () => setShowIdeOnboarding(true), status => setIDEInstallationState(status));
};
t2 = [autoConnectIdeFlag, ideToInstallExtension, setDynamicMcpConfig, setShowIdeOnboarding, setIDEInstallationState];
$[0] = autoConnectIdeFlag;
$[1] = ideToInstallExtension;
$[2] = setDynamicMcpConfig;
$[3] = setIDEInstallationState;
$[4] = setShowIdeOnboarding;
$[5] = t1;
$[6] = t2;
} else {
t1 = $[5];
t2 = $[6];
}
useEffect(t1, t2);
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJ1c2VFZmZlY3QiLCJTY29wZWRNY3BTZXJ2ZXJDb25maWciLCJnZXRHbG9iYWxDb25maWciLCJpc0VudkRlZmluZWRGYWxzeSIsImlzRW52VHJ1dGh5IiwiRGV0ZWN0ZWRJREVJbmZvIiwiSURFRXh0ZW5zaW9uSW5zdGFsbGF0aW9uU3RhdHVzIiwiSWRlVHlwZSIsImluaXRpYWxpemVJZGVJbnRlZ3JhdGlvbiIsImlzU3VwcG9ydGVkVGVybWluYWwiLCJVc2VJREVJbnRlZ3JhdGlvblByb3BzIiwiYXV0b0Nvbm5lY3RJZGVGbGFnIiwiaWRlVG9JbnN0YWxsRXh0ZW5zaW9uIiwic2V0RHluYW1pY01jcENvbmZpZyIsIlJlYWN0IiwiRGlzcGF0Y2giLCJTZXRTdGF0ZUFjdGlvbiIsIlJlY29yZCIsInNldFNob3dJZGVPbmJvYXJkaW5nIiwic2V0SURFSW5zdGFsbGF0aW9uU3RhdGUiLCJ1c2VJREVJbnRlZ3JhdGlvbiIsInQwIiwiJCIsIl9jIiwidDEiLCJ0MiIsImFkZElkZSIsImlkZSIsImdsb2JhbENvbmZpZyIsImF1dG9Db25uZWN0RW5hYmxlZCIsImF1dG9Db25uZWN0SWRlIiwicHJvY2VzcyIsImVudiIsIkNMQVVERV9DT0RFX1NTRV9QT1JUIiwiQ0xBVURFX0NPREVfQVVUT19DT05ORUNUX0lERSIsInByZXYiLCJ0eXBlIiwidXJsIiwic3RhcnRzV2l0aCIsImlkZU5hbWUiLCJuYW1lIiwiYXV0aFRva2VuIiwiaWRlUnVubmluZ0luV2luZG93cyIsInNjb3BlIiwiY29uc3QiLCJzdGF0dXMiXSwic291cmNlcyI6WyJ1c2VJREVJbnRlZ3JhdGlvbi50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgdXNlRWZmZWN0IH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgdHlwZSB7IFNjb3BlZE1jcFNlcnZlckNvbmZpZyB9IGZyb20gJy4uL3NlcnZpY2VzL21jcC90eXBlcy5qcydcbmltcG9ydCB7IGdldEdsb2JhbENvbmZpZyB9IGZyb20gJy4uL3V0aWxzL2NvbmZpZy5qcydcbmltcG9ydCB7IGlzRW52RGVmaW5lZEZhbHN5LCBpc0VudlRydXRoeSB9IGZyb20gJy4uL3V0aWxzL2VudlV0aWxzLmpzJ1xuaW1wb3J0IHR5cGUgeyBEZXRlY3RlZElERUluZm8gfSBmcm9tICcuLi91dGlscy9pZGUuanMnXG5pbXBvcnQge1xuICB0eXBlIElERUV4dGVuc2lvbkluc3RhbGxhdGlvblN0YXR1cyxcbiAgdHlwZSBJZGVUeXBlLFxuICBpbml0aWFsaXplSWRlSW50ZWdyYXRpb24sXG4gIGlzU3VwcG9ydGVkVGVybWluYWwsXG59IGZyb20gJy4uL3V0aWxzL2lkZS5qcydcblxudHlwZSBVc2VJREVJbnRlZ3JhdGlvblByb3BzID0ge1xuICBhdXRvQ29ubmVjdElkZUZsYWc/OiBib29sZWFuXG4gIGlkZVRvSW5zdGFsbEV4dGVuc2lvbjogSWRlVHlwZSB8IG51bGxcbiAgc2V0RHluYW1pY01jcENvbmZpZzogUmVhY3QuRGlzcGF0Y2g8XG4gICAgUmVhY3QuU2V0U3RhdGVBY3Rpb248UmVjb3JkPHN0cmluZywgU2NvcGVkTWNwU2VydmVyQ29uZmlnPiB8IHVuZGVmaW5lZD5cbiAgPlxuICBzZXRTaG93SWRlT25ib2FyZGluZzogUmVhY3QuRGlzcGF0Y2g8UmVhY3QuU2V0U3RhdGVBY3Rpb248Ym9vbGVhbj4+XG4gIHNldElERUluc3RhbGxhdGlvblN0YXRlOiBSZWFjdC5EaXNwYXRjaDxcbiAgICBSZWFjdC5TZXRTdGF0ZUFjdGlvbjxJREVFeHRlbnNpb25JbnN0YWxsYXRpb25TdGF0dXMgfCBudWxsPlxuICA+XG59XG5cbmV4cG9ydCBmdW5jdGlvbiB1c2VJREVJbnRlZ3JhdGlvbih7XG4gIGF1dG9Db25uZWN0SWRlRmxhZyxcbiAgaWRlVG9JbnN0YWxsRXh0ZW5zaW9uLFxuICBzZXREeW5hbWljTWNwQ29uZmlnLFxuICBzZXRTaG93SWRlT25ib2FyZGluZyxcbiAgc2V0SURFSW5zdGFsbGF0aW9uU3RhdGUsXG59OiBVc2VJREVJbnRlZ3JhdGlvblByb3BzKTogdm9pZCB7XG4gIHVzZUVmZmVjdCgoKSA9PiB7XG4gICAgZnVuY3Rpb24gYWRkSWRlKGlkZTogRGV0ZWN0ZWRJREVJbmZvIHwgbnVsbCkge1xuICAgICAgaWYgKCFpZGUpIHtcbiAgICAgICAgcmV0dXJuXG4gICAgICB9XG5cbiAgICAgIC8vIENoZWNrIGlmIGF1dG8tY29ubmVjdCBpcyBlbmFibGVkXG4gICAgICBjb25zdCBnbG9iYWxDb25maWcgPSBnZXRHbG9iYWxDb25maWcoKVxuICAgICAgY29uc3QgYXV0b0Nvbm5lY3RFbmFibGVkID1cbiAgICAgICAgKGdsb2JhbENvbmZpZy5hdXRvQ29ubmVjdElkZSB8fFxuICAgICAgICAgIGF1dG9Db25uZWN0SWRlRmxhZyB8fFxuICAgICAgICAgIGlzU3VwcG9ydGVkVGVybWluYWwoKSB8fFxuICAgICAgICAgIC8vIHRtdXgvc2NyZWVuIG92ZXJ3cml0ZSBURVJNX1BST0dSQU0sIGJyZWFraW5nIHRlcm1pbmFsIGRldGVjdGlvbiwgYnV0IHRoZVxuICAgICAgICAgIC8vIElERSBleHRlbnNpb24ncyBwb3J0IGVudiB2YXIgaXMgaW5oZXJpdGVkLiBJZiBzZXQsIGF1dG8tY29ubmVjdCBhbnl3YXkuXG4gICAgICAgICAgcHJvY2Vzcy5lbnYuQ0xBVURFX0NPREVfU1NFX1BPUlQgfHxcbiAgICAgICAgICBpZGVUb0luc3RhbGxFeHRlbnNpb24gfHxcbiAgICAgICAgICBpc0VudlRydXRoeShwcm9jZXNzLmVudi5DTEFVREVfQ09ERV9BVVRPX0NPTk5FQ1RfSURFKSkgJiZcbiAgICAgICAgIWlzRW52RGVmaW5lZEZhbHN5KHByb2Nlc3MuZW52LkNMQVVERV9DT0RFX0FVVE9fQ09OTkVDVF9JREUpXG5cbiAgICAgIGlmICghYXV0b0Nvbm5lY3RFbmFibGVkKSB7XG4gICAgICAgIHJldHVyblxuICAgICAgfVxuXG4gICAgICBzZXREeW5hbWljTWNwQ29uZmlnKHByZXYgPT4ge1xuICAgICAgICAvLyBPbmx5IGFkZCB0aGUgSURFIGlmIHdlIGRvbid0IGFscmVhZHkgaGF2ZSBvbmVcbiAgICAgICAgaWYgKHByZXY/LmlkZSkge1xuICAgICAgICAgIHJldHVybiBwcmV2XG4gICAgICAgIH1cbiAgICAgICAgcmV0dXJuIHtcbiAgICAgICAgICAuLi5wcmV2LFxuICAgICAgICAgIGlkZToge1xuICAgICAgICAgICAgdHlwZTogaWRlLnVybC5zdGFydHNXaXRoKCd3czonKSA/ICd3cy1pZGUnIDogJ3NzZS1pZGUnLFxuICAgICAgICAgICAgdXJsOiBpZGUudXJsLFxuICAgICAgICAgICAgaWRlTmFtZTogaWRlLm5hbWUsXG4gICAgICAgICAgICBhdXRoVG9rZW46IGlkZS5hdXRoVG9rZW4sXG4gICAgICAgICAgICBpZGVSdW5uaW5nSW5XaW5kb3dzOiBpZGUuaWRlUnVubmluZ0luV2luZG93cyxcbiAgICAgICAgICAgIHNjb3BlOiAnZHluYW1pYycgYXMgY29uc3QsXG4gICAgICAgICAgfSxcbiAgICAgICAgfVxuICAgICAgfSlcbiAgICB9XG5cbiAgICAvLyBVc2UgdGhlIG5ldyB1dGlsaXR5IGZ1bmN0aW9uXG4gICAgdm9pZCBpbml0aWFsaXplSWRlSW50ZWdyYXRpb24oXG4gICAgICBhZGRJZGUsXG4gICAgICBpZGVUb0luc3RhbGxFeHRlbnNpb24sXG4gICAgICAoKSA9PiBzZXRTaG93SWRlT25ib2FyZGluZyh0cnVlKSxcbiAgICAgIHN0YXR1cyA9PiBzZXRJREVJbnN0YWxsYXRpb25TdGF0ZShzdGF0dXMpLFxuICAgIClcbiAgfSwgW1xuICAgIGF1dG9Db25uZWN0SWRlRmxhZyxcbiAgICBpZGVUb0luc3RhbGxFeHRlbnNpb24sXG4gICAgc2V0RHluYW1pY01jcENvbmZpZyxcbiAgICBzZXRTaG93SWRlT25ib2FyZGluZyxcbiAgICBzZXRJREVJbnN0YWxsYXRpb25TdGF0ZSxcbiAgXSlcbn1cbiJdLCJtYXBwaW5ncyI6IjtBQUFBLFNBQVNBLFNBQVMsUUFBUSxPQUFPO0FBQ2pDLGNBQWNDLHFCQUFxQixRQUFRLDBCQUEwQjtBQUNyRSxTQUFTQyxlQUFlLFFBQVEsb0JBQW9CO0FBQ3BELFNBQVNDLGlCQUFpQixFQUFFQyxXQUFXLFFBQVEsc0JBQXNCO0FBQ3JFLGNBQWNDLGVBQWUsUUFBUSxpQkFBaUI7QUFDdEQsU0FDRSxLQUFLQyw4QkFBOEI Line truncated
+76
View File
@@ -0,0 +1,76 @@
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(),
}),
}),
)
/**
* A hook that tracks IDE at-mention notifications by directly registering
* with MCP client notification handlers,
*/
export function useIdeAtMentioned(
mcpClients: MCPServerConnection[],
onAtMentioned: (atMentioned: IDEAtMentioned) => void,
): void {
const ideClientRef = useRef<ConnectedMCPServer | undefined>(undefined)
useEffect(() => {
// Find the IDE client from the MCP clients list
const ideClient = getConnectedIdeClient(mcpClients)
if (ideClientRef.current !== ideClient) {
ideClientRef.current = ideClient
}
// If we found a connected IDE client, register our handler
if (ideClient) {
ideClient.client.setNotificationHandler(
AtMentionedSchema(),
notification => {
if (ideClientRef.current !== ideClient) {
return
}
try {
const data = notification.params
// Adjust line numbers to be 1-based instead of 0-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)
}
},
)
}
// No cleanup needed as MCP clients manage their own lifecycle
}, [mcpClients, onAtMentioned])
}
+33
View File
@@ -0,0 +1,33 @@
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
}
export function useIdeConnectionStatus(
mcpClients?: MCPServerConnection[],
): IdeConnectionResult {
return useMemo(() => {
const ideClient = mcpClients?.find(client => client.name === 'ide')
if (!ideClient) {
return { status: null, ideName: null }
}
// Extract IDE name from config if available
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])
}
+41
View File
@@ -0,0 +1,41 @@
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(),
}),
}),
)
export function useIdeLogging(mcpClients: MCPServerConnection[]): void {
useEffect(() => {
// Skip if there are no clients
if (!mcpClients.length) {
return
}
// Find the IDE client from the MCP clients list
const ideClient = getConnectedIdeClient(mcpClients)
if (ideClient) {
// Register the log event handler
ideClient.client.setNotificationHandler(
LogEventSchema(),
notification => {
const { eventName, eventData } = notification.params
logEvent(
`tengu_ide_${eventName}`,
eventData as { [key: string]: boolean | number | undefined },
)
},
)
}
}, [mcpClients])
}
+150
View File
@@ -0,0 +1,150 @@
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
}
// Define the selection changed notification schema
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(),
}),
}),
)
/**
* A hook that tracks IDE text selection information by directly registering
* with MCP client notification handlers
*/
export function useIdeSelection(
mcpClients: MCPServerConnection[],
onSelect: (selection: IDESelection) => void,
): void {
const handlersRegistered = useRef(false)
const currentIDERef = useRef<ConnectedMCPServer | null>(null)
useEffect(() => {
// Find the IDE client from the MCP clients list
const ideClient = getConnectedIdeClient(mcpClients)
// If the IDE client changed, we need to re-register handlers.
// Normalize undefined to null so the initial ref value (null) matches
// "no IDE found" (undefined), avoiding spurious resets on every MCP update.
if (currentIDERef.current !== (ideClient ?? null)) {
handlersRegistered.current = false
currentIDERef.current = ideClient || null
// Reset the selection when the IDE client changes.
onSelect({
lineCount: 0,
lineStart: undefined,
text: undefined,
filePath: undefined,
})
}
// Skip if we've already registered handlers for the current IDE or if there's no IDE client
if (handlersRegistered.current || !ideClient) {
return
}
// Handler function for selection changes
const selectionChangeHandler = (data: SelectionData) => {
if (data.selection?.start && data.selection?.end) {
const { start, end } = data.selection
let lineCount = end.line - start.line + 1
// If on the first character of the line, do not count the line
// as being selected.
if (end.character === 0) {
lineCount--
}
const selection = {
lineCount,
lineStart: start.line,
text: data.text,
filePath: data.filePath,
}
onSelect(selection)
}
}
// Register notification handler for selection_changed events
ideClient.client.setNotificationHandler(
SelectionChangedSchema(),
notification => {
if (currentIDERef.current !== ideClient) {
return
}
try {
// Get the selection data from the notification params
const selectionData = notification.params
// Process selection data - validate it has required properties
if (
selectionData.selection &&
selectionData.selection.start &&
selectionData.selection.end
) {
// Handle selection changes
selectionChangeHandler(selectionData as SelectionData)
} else if (selectionData.text !== undefined) {
// Handle empty selection (when text is empty string)
selectionChangeHandler({
selection: null,
text: selectionData.text,
filePath: selectionData.filePath,
})
}
} catch (error) {
logError(error as Error)
}
},
)
// Mark that we've registered handlers
handlersRegistered.current = true
// No cleanup needed as MCP clients manage their own lifecycle
}, [mcpClients, onSelect])
}
+969
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
}
/**
* Polls the teammate inbox for new messages and submits them as turns.
*
* This hook:
* 1. Polls every 1s for unread messages (teammates or team leads)
* 2. When idle: submits messages immediately as a new turn
* 3. When busy: queues messages in AppState.inbox for UI display, delivers when turn ends
*/
export function useInboxPoller({
enabled,
isLoading,
focusedInputDialog,
onSubmitMessage,
}: Props): void {
// Assign to original name for clarity within the function
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])
}
+132
View File
@@ -0,0 +1,132 @@
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
}
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()
// Clear any pending push
if (pendingPush.current) {
clearTimeout(pendingPush.current)
pendingPush.current = null
}
// Debounce rapid changes
if (now - lastPushTime.current < debounceMs) {
pendingPush.current = setTimeout(
pushToBuffer,
debounceMs,
text,
cursorOffset,
pastedContents,
)
return
}
lastPushTime.current = now
setBuffer(prevBuffer => {
// If we're not at the end of the buffer, truncate everything after current position
const newBuffer =
currentIndex >= 0 ? prevBuffer.slice(0, currentIndex + 1) : prevBuffer
// Don't add if it's the same as the last entry
const lastEntry = newBuffer[newBuffer.length - 1]
if (lastEntry && lastEntry.text === text) {
return newBuffer
}
// Add new entry
const updatedBuffer = [
...newBuffer,
{ text, cursorOffset, pastedContents, timestamp: now },
]
// Limit buffer size
if (updatedBuffer.length > maxBufferSize) {
return updatedBuffer.slice(-maxBufferSize)
}
return updatedBuffer
})
// Update current index to point to the new entry
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,
}
}
+133
View File
@@ -0,0 +1,133 @@
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," or "No!" at start — comma/exclamation implies correction tone
// (avoids "No problem", "No thanks", "No I think we should...")
/^no[,!]\s/i,
// Direct corrections about Claude's output
/\bthat'?s (wrong|incorrect|not (what|right|correct))\b/i,
/\bnot what I (asked|wanted|meant|said)\b/i,
// Referencing prior instructions Claude missed
/\bI (said|asked|wanted|told you|already said)\b/i,
// Questioning Claude's actions
/\bwhy did you\b/i,
/\byou should(n'?t| not)? have\b/i,
/\byou were supposed to\b/i,
// Explicit retry/revert of Claude's work
/\btry again\b/i,
/\b(undo|revert) (that|this|it|what you)\b/i,
]
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
}
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
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)
// Memoize the O(messages) scans. This hook runs on every REPL render
// (including every keystroke), but messages is stable during typing.
// isSessionContainerCompatible walks all messages + regex-tests each
// bash command — by far the heaviest work here.
// biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant
const shouldTrigger = useMemo(
() => isSessionContainerCompatible(messages) && hasFrictionSignal(messages),
[messages],
)
// Keep showing the banner until the user submits another message
if (activeForSubmitRef.current === submitCount) {
return true
}
if (Date.now() - lastTriggeredAtRef.current < COOLDOWN_MS) {
return false
}
if (submitCount < MIN_SUBMIT_COUNT) {
return false
}
if (!shouldTrigger) {
return false
}
lastTriggeredAtRef.current = Date.now()
activeForSubmitRef.current = submitCount
return true
}
+119
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 that logs messages to the transcript
* conversation ID that only changes when a new conversation is started.
*
* @param messages The current conversation messages
* @param ignore When true, messages will not be recorded to the transcript
*/
export function useLogMessages(messages: Message[], ignore: boolean = false) {
const teamContext = useAppState(s => s.teamContext)
// messages is append-only between compactions, so track where we left off
// and only pass the new tail to recordTranscript. Avoids O(n) filter+scan
// on every setMessages (~20x/turn, so n=3000 was ~120k wasted iterations).
const lastRecordedLengthRef = useRef(0)
const lastParentUuidRef = useRef<UUID | undefined>(undefined)
// First-uuid change = compaction or /clear rebuilt the array; length alone
// can't detect this since post-compact [CB,summary,...keep,new] may be longer.
const firstMessageUuidRef = useRef<UUID | undefined>(undefined)
// Guard against stale async .then() overwriting a fresher sync update when
// an incremental render fires before the compaction .then() resolves.
const callSeqRef = useRef(0)
useEffect(() => {
if (ignore) return
const currentFirstUuid = messages[0]?.uuid as UUID | undefined
const prevLength = lastRecordedLengthRef.current
// First-render: firstMessageUuidRef is undefined. Compaction: first uuid changes.
// Both are !isIncremental, but first-render sync-walk is safe (no messagesToKeep).
const wasFirstRender = firstMessageUuidRef.current === undefined
const isIncremental =
currentFirstUuid !== undefined &&
!wasFirstRender &&
currentFirstUuid === firstMessageUuidRef.current &&
prevLength <= messages.length
// Same-head shrink: tombstone filter, rewind, snip, partial-compact.
// Distinguished from compaction (first uuid changes) because the tail
// is either an existing on-disk message or a fresh message that this
// same effect's recordTranscript(fullArray) will write — see sync-walk
// guard below.
const isSameHeadShrink =
currentFirstUuid !== undefined &&
!wasFirstRender &&
currentFirstUuid === firstMessageUuidRef.current &&
prevLength > messages.length
const startIndex = isIncremental ? prevLength : 0
if (startIndex === messages.length) return
// Full array on first call + after compaction: recordTranscript's own
// O(n) dedup loop handles messagesToKeep interleaving correctly there.
const slice = startIndex === 0 ? messages : messages.slice(startIndex)
const parentHint = isIncremental ? lastParentUuidRef.current : undefined
// Fire and forget - we don't want to block the UI.
const seq = ++callSeqRef.current
void recordTranscript(
slice,
isAgentSwarmsEnabled()
? {
teamName: teamContext?.teamName,
agentName: teamContext?.selfAgentName,
}
: {},
parentHint,
messages,
).then(lastRecordedUuid => {
// For compaction/full array case (!isIncremental): use the async return
// value. After compaction, messagesToKeep in the array are skipped
// (already in transcript), so the sync loop would find a wrong UUID.
// Skip if a newer effect already ran (stale closure would overwrite the
// fresher sync update from the subsequent incremental render).
if (seq !== callSeqRef.current) return
if (lastRecordedUuid && !isIncremental) {
lastParentUuidRef.current = lastRecordedUuid
}
})
// Sync-walk safe for: incremental (pure new-tail slice), first-render
// (no messagesToKeep interleaving), and same-head shrink. Shrink is the
// subtle one: the picked uuid is either already on disk (tombstone/rewind
// — survivors were written before) or is being written by THIS effect's
// recordTranscript(fullArray) call (snip boundary / partial-compact tail
// — enqueueWrite ordering guarantees it lands before any later write that
// chains to it). Without this, the ref stays stale at a tombstoned uuid:
// the async .then() correction is raced out by the next effect's seq bump
// on large sessions where recordTranscript(fullArray) is slow. Only the
// compaction case (first uuid changed) remains unsafe — tail may be
// messagesToKeep whose last-actually-recorded uuid differs.
if (isIncremental || wasFirstRender || isSameHeadShrink) {
// Match EXACTLY what recordTranscript persists: cleanMessagesForLogging
// applies both the isLoggableMessage filter and (for external users) the
// REPL-strip + isVirtual-promote transform. Using the raw predicate here
// would pick a UUID that the transform drops, leaving the parent hint
// pointing at a message that never reached disk. Pass full messages as
// replId context — REPL tool_use and its tool_result land in separate
// render cycles, so the slice alone can't pair them.
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])
}
+194
View File
@@ -0,0 +1,194 @@
import { c as _c } from "react/compiler-runtime";
/**
* Hook for LSP plugin recommendations
*
* Detects file edits and recommends LSP plugins when:
* - File extension matches an LSP plugin
* - LSP binary is already installed on the system
* - Plugin is not already installed
* - User hasn't disabled recommendations
*
* Only shows one recommendation per session.
*/
import { extname, join } from 'path';
import * as React from 'react';
import { hasShownLspRecommendationThisSession, setLspRecommendationShownThisSession } from '../bootstrap/state.js';
import { useNotifications } from '../context/notifications.js';
import { useAppState } from '../state/AppState.js';
import { saveGlobalConfig } from '../utils/config.js';
import { logForDebugging } from '../utils/debug.js';
import { logError } from '../utils/log.js';
import { addToNeverSuggest, getMatchingLspPlugins, incrementIgnoredCount } from '../utils/plugins/lspRecommendation.js';
import { cacheAndRegisterPlugin } from '../utils/plugins/pluginInstallationHelpers.js';
import { getSettingsForSource, updateSettingsForSource } from '../utils/settings/settings.js';
import { installPluginAndNotify, usePluginRecommendationBase } from './usePluginRecommendationBase.js';
// Threshold for detecting timeout vs explicit dismiss (ms)
// Menu auto-dismisses at 30s, so anything over 28s is likely timeout
const TIMEOUT_THRESHOLD_MS = 28_000;
export type LspRecommendationState = {
pluginId: string;
pluginName: string;
pluginDescription?: string;
fileExtension: string;
shownAt: number; // Timestamp for timeout detection
} | null;
type UseLspPluginRecommendationResult = {
recommendation: LspRecommendationState;
handleResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void;
};
export function useLspPluginRecommendation() {
const $ = _c(12);
const trackedFiles = useAppState(_temp);
const {
addNotification
} = useNotifications();
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = new Set();
$[0] = t0;
} else {
t0 = $[0];
}
const checkedFilesRef = React.useRef(t0);
const {
recommendation,
clearRecommendation,
tryResolve
} = usePluginRecommendationBase();
let t1;
let t2;
if ($[1] !== trackedFiles || $[2] !== tryResolve) {
t1 = () => {
tryResolve(async () => {
if (hasShownLspRecommendationThisSession()) {
return null;
}
const newFiles = [];
for (const file of trackedFiles) {
if (!checkedFilesRef.current.has(file)) {
checkedFilesRef.current.add(file);
newFiles.push(file);
}
}
for (const filePath of newFiles) {
;
try {
const matches = await getMatchingLspPlugins(filePath);
const match = matches[0];
if (match) {
logForDebugging(`[useLspPluginRecommendation] Found match: ${match.pluginName} for ${filePath}`);
setLspRecommendationShownThisSession(true);
return {
pluginId: match.pluginId,
pluginName: match.pluginName,
pluginDescription: match.description,
fileExtension: extname(filePath),
shownAt: Date.now()
};
}
} catch (t3) {
const error = t3;
logError(error);
}
}
return null;
});
};
t2 = [trackedFiles, tryResolve];
$[1] = trackedFiles;
$[2] = tryResolve;
$[3] = t1;
$[4] = t2;
} else {
t1 = $[3];
t2 = $[4];
}
React.useEffect(t1, t2);
let t3;
if ($[5] !== addNotification || $[6] !== clearRecommendation || $[7] !== recommendation) {
t3 = response => {
if (!recommendation) {
return;
}
const {
pluginId,
pluginName,
shownAt
} = recommendation;
logForDebugging(`[useLspPluginRecommendation] User response: ${response} for ${pluginName}`);
bb60: switch (response) {
case "yes":
{
installPluginAndNotify(pluginId, pluginName, "lsp-plugin", addNotification, async pluginData => {
logForDebugging(`[useLspPluginRecommendation] Installing plugin: ${pluginId}`);
const localSourcePath = typeof pluginData.entry.source === "string" ? join(pluginData.marketplaceInstallLocation, pluginData.entry.source) : undefined;
await cacheAndRegisterPlugin(pluginId, pluginData.entry, "user", undefined, localSourcePath);
const settings = getSettingsForSource("userSettings");
updateSettingsForSource("userSettings", {
enabledPlugins: {
...settings?.enabledPlugins,
[pluginId]: true
}
});
logForDebugging(`[useLspPluginRecommendation] Plugin installed: ${pluginId}`);
});
break bb60;
}
case "no":
{
const elapsed = Date.now() - shownAt;
if (elapsed >= TIMEOUT_THRESHOLD_MS) {
logForDebugging(`[useLspPluginRecommendation] Timeout detected (${elapsed}ms), incrementing ignored count`);
incrementIgnoredCount();
}
break bb60;
}
case "never":
{
addToNeverSuggest(pluginId);
break bb60;
}
case "disable":
{
saveGlobalConfig(_temp2);
}
}
clearRecommendation();
};
$[5] = addNotification;
$[6] = clearRecommendation;
$[7] = recommendation;
$[8] = t3;
} else {
t3 = $[8];
}
const handleResponse = t3;
let t4;
if ($[9] !== handleResponse || $[10] !== recommendation) {
t4 = {
recommendation,
handleResponse
};
$[9] = handleResponse;
$[10] = recommendation;
$[11] = t4;
} else {
t4 = $[11];
}
return t4;
}
function _temp2(current) {
if (current.lspRecommendationDisabled) {
return current;
}
return {
...current,
lspRecommendationDisabled: true
};
}
function _temp(s) {
return s.fileHistory.trackedFiles;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJleHRuYW1lIiwiam9pbiIsIlJlYWN0IiwiaGFzU2hvd25Mc3BSZWNvbW1lbmRhdGlvblRoaXNTZXNzaW9uIiwic2V0THNwUmVjb21tZW5kYXRpb25TaG93blRoaXNTZXNzaW9uIiwidXNlTm90aWZpY2F0aW9ucyIsInVzZUFwcFN0YXRlIiwic2F2ZUdsb2JhbENvbmZpZyIsImxvZ0ZvckRlYnVnZ2luZyIsImxvZ0Vycm9yIiwiYWRkVG9OZXZlclN1Z2dlc3QiLCJnZXRNYXRjaGluZ0xzcFBsdWdpbnMiLCJpbmNyZW1lbnRJZ25vcmVkQ291bnQiLCJjYWNoZUFuZFJlZ2lzdGVyUGx1Z2luIiwiZ2V0U2V0dGluZ3NGb3JTb3VyY2UiLCJ1cGRhdGVTZXR0aW5nc0ZvclNvdXJjZSIsImluc3RhbGxQbHVnaW5BbmROb3RpZnkiLCJ1c2VQbHVnaW5SZWNvbW1lbmRhdGlvbkJhc2UiLCJUSU1FT1VUX1RIUkVTSE9MRF9NUyIsIkxzcFJlY29tbWVuZGF0aW9uU3RhdGUiLCJwbHVnaW5JZCIsInBsdWdpbk5hbWUiLCJwbHVnaW5EZXNjcmlwdGlvbiIsImZpbGVFeHRlbnNpb24iLCJzaG93bkF0IiwiVXNlTHNwUGx1Z2luUmVjb21tZW5kYXRpb25SZXN1bHQiLCJyZWNvbW1lbmRhdGlvbiIsImhhbmRsZVJlc3BvbnNlIiwicmVzcG9uc2UiLCJ1c2VMc3BQbHVnaW5SZWNvbW1lbmRhdGlvbiIsIiQiLCJfYyIsInRyYWNrZWRGaWxlcyIsIl90ZW1wIiwiYWRkTm90aWZpY2F0aW9uIiwidDAiLCJTeW1ib2wiLCJmb3IiLCJTZXQiLCJjaGVja2VkRmlsZXNSZWYiLCJ1c2VSZWYiLCJjbGVhclJlY29tbWVuZGF0aW9uIiwidHJ5UmVzb2x2ZSIsInQxIiwidDIiLCJuZXdGaWxlcyIsImZpbGUiLCJjdXJyZW50IiwiaGFzIiwiYWRkIiwicHVzaCIsImZpbGVQYXRoIiwibWF0Y2hlcyIsIm1hdGNoIiwiZGVzY3JpcHRpb24iLCJEYXRlIiwibm93IiwidDMiLCJlcnJvciIsInVzZUVmZmVjdCIsImJiNjAiLCJwbHVnaW5EYXRhIiwibG9jYWxTb3VyY2VQYXRoIiwiZW50cnkiLCJzb3VyY2UiLCJtYXJrZXRwbGFjZUluc3RhbGxMb2NhdGlvbiIsInVuZGVmaW5lZCIsInNldHRpbmdzIiwiZW5hYmxlZFBsdWdpbnMiLCJlbGFwc2VkIiwiX3RlbXAyIiwidDQiLCJsc3BSZWNvbW1lbmRhdGlvbkRpc2FibGVkIiwicyIsImZpbGVIaXN0b3J5Il0sInNvdXJjZXMiOlsidXNlTHNwUGx1Z2luUmVjb21tZW5kYXRpb24udHN4Il0sInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogSG9vayBmb3IgTFNQIHBsdWdpbiByZWNvbW1lbmRhdGlvbnNcbiAqXG4gKiBEZXRlY3RzIGZpbGUgZWRpdHMgYW5kIHJlY29tbWVuZHMgTFNQIHBsdWdpbnMgd2hlbjpcbiAqIC0gRmlsZSBleHRlbnNpb24gbWF0Y2hlcyBhbiBMU1AgcGx1Z2luXG4gKiAtIExTUCBiaW5hcnkgaXMgYWxyZWFkeSBpbnN0YWxsZWQgb24gdGhlIHN5c3RlbVxuICogLSBQbHVnaW4gaXMgbm90IGFscmVhZHkgaW5zdGFsbGVkXG4gKiAtIFVzZXIgaGFzbid0IGRpc2FibGVkIHJlY29tbWVuZGF0aW9uc1xuICpcbiAqIE9ubHkgc2hvd3Mgb25lIHJlY29tbWVuZGF0aW9uIHBlciBzZXNzaW9uLlxuICovXG5cbmltcG9ydCB7IGV4dG5hbWUsIGpvaW4gfSBmcm9tICdwYXRoJ1xuaW1wb3J0ICogYXMgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQge1xuICBoYXNTaG93bkxzcFJlY29tbWVuZGF0aW9uVGhpc1Nlc3Npb24sXG4gIHNldExzcFJlY29tbWVuZGF0aW9uU2hvd25UaGlzU2Vzc2lvbixcbn0gZnJvbSAnLi4vYm9vdHN0cmFwL3N0YXRlLmpzJ1xuaW1wb3J0IHsgdXNlTm90aWZpY2F0aW9ucyB9IGZyb20gJy4uL2NvbnRleHQvbm90aWZpY2F0aW9ucy5qcydcbmltcG9ydCB7IHVzZUFwcFN0YXRlIH0gZnJvbSAnLi4vc3RhdGUvQXBwU3RhdGUuanMnXG5pbXBvcnQgeyBzYXZlR2xvYmFsQ29uZmlnIH0gZnJvbSAnLi4vdXRpbHMvY29uZmlnLmpzJ1xuaW1wb3J0IHsgbG9nRm9yRGVidWdnaW5nIH0gZnJvbSAnLi4vdXRpbHMvZGVidWcuanMnXG5pbXBvcnQgeyBsb2dFcnJvciB9IGZyb20gJy4uL3V0aWxzL2xvZy5qcydcbmltcG9ydCB7XG4gIGFkZFRvTmV2ZXJTdWdnZXN0LFxuICBnZXRNYXRjaGluZ0xzcFBsdWdpbnMsXG4gIGluY3JlbWVudElnbm9yZWRDb3VudCxcbn0gZnJvbSAnLi4vdXRpbHMvcGx1Z2lucy9sc3BSZWNvbW1lbmRhdGlvbi5qcydcbmltcG9ydCB7IGNhY2hlQW5kUmVnaXN0ZXJQbHVnaW4gfSBmcm9tICcuLi91dGlscy9wbHVnaW5zL3BsdWdpbkluc3RhbGxhdGlvbkhlbHBlcnMuanMnXG5pbXBvcnQge1xuICBnZXRTZXR0aW5nc0ZvclNvdXJjZSxcbiAgdXBkYXRlU2V0dGluZ3NGb3JTb3VyY2UsXG59IGZyb20gJy4uL3V0aWxzL3NldHRpbmdzL3NldHRpbmdzLmpzJ1xuaW1wb3J0IHtcbiAgaW5zdGFsbFBsdWdpbkFuZE5vdGlmeSxcbiAgdXNlUGx1Z2luUmVjb21tZW5kYXRpb25CYXNlLFxufSBmcm9tICcuL3VzZVBsdWdpblJlY29tbWVuZGF0aW9uQmFzZS5qcydcblxuLy8gVGhyZXNob2xkIGZvciBkZXRlY3RpbmcgdGltZW91dCB2cyBleHBsaWNpdCBkaXNtaXNzIChtcylcbi8vIE1lbnUgYXV0by1kaXNtaXNzZXMgYXQgMzBzLCBzbyBhbnl0aGluZyBvdmVyIDI4cyBpcyBsaWtlbHkgdGltZW91dFxuY29uc3QgVElNRU9VVF9USFJFU0hPTERfTVMgPSAyOF8wMDBcblxuZXhwb3J0IHR5cGUgTHNwUmVjb21tZW5kYXRpb25TdGF0ZSA9IHtcbiAgcGx1Z2luSWQ6IHN0cmluZ1xuICBwbHVnaW5OYW1lOiBzdHJpbmdcbiAgcGx1Z2luRGVzY3JpcHRpb24/OiBzdHJpbmdcbiAgZmlsZUV4dGVuc2lvbjogc3RyaW5nXG4gIHNob3duQXQ6IG51bWJlciAvLyBUaW1lc3RhbXAgZm9yIHRpbWVvdXQgZGV0ZWN0aW9uXG59IHwgbnVsbFxuXG50eXBlIFVzZUxzcFBsdWdpblJlY29tbWVuZGF0aW9uUmVzdWx0ID0ge1xuICByZWNvbW1lbmRhdGlvbjogTHNwUmVjb21tZW5kYXRpb25TdGF0ZVxuICBoYW5kbGVSZXNwb25zZTogKHJlc3BvbnNlOiAneWVzJyB8ICdubycgfCAnbmV2ZXInIHwgJ2Rpc2FibGUnKSA9PiB2b2lkXG59XG5cbmV4cG9ydCBmdW5jdGlvbiB1c2VMc3BQbHVnaW5SZWNvbW1lbmRhdGlvbigpOiBVc2VMc3BQbHVnaW5SZWNvbW1lbmRhdGlvblJlc3VsdCB7XG4gIGNvbnN0IHRyYWNrZWRGaWxlcyA9IHVzZUFwcFN0YXRlKHMgPT4gcy5maWxlSGlzdG9yeS50cmFja2VkRmlsZXMpXG4gIGNvbnN0IHsgYWRkTm90aWZpY2F0aW9uIH0gPSB1c2VOb3RpZmljYXRpb25zKClcbiAgY29uc3QgY2hlY2tlZEZpbGVzUmVmID0gUmVhY3QudXNlUmVmPFNldDxzdHJpbmc+PihuZXcgU2V0KCkpXG4gIGNvbnN0IHsgcmVjb21tZW5kYXRpb24sIGNsZWFyUmVjb21tZW5kYXRpb24sIHRyeVJlc29sdmUgfSA9XG4gICAgdXNlUGx1Z2luUmVjb21tZW5kYXRpb25CYXNlPE5vbk51bGxhYmxlPExzcFJlY29tbWVuZGF0aW9uU3RhdGU+PigpXG5cbiAgUmVhY3QudXNlRWZmZWN0KCgpID0+IHtcbiAgICB0cnlSZXNvbHZlKGFzeW5jICgpID0+IHtcbiAgICAgIGlmIChoYXNTaG93bkxzcFJlY29tbWVuZGF0aW9uVGhpc1Nlc3Npb24oKSkgcmV0dXJuIG51bGxcblxuICAgICAgY29uc3QgbmV3RmlsZXM6IHN0cmluZ1tdID0gW11cbiAgICAgIGZvciAoY29uc3QgZmlsZSBvZiB0cmFja2VkRmlsZXMpIHtcbiAgICAgICAgaWYgKCFjaGVja2VkRmlsZXNSZWYuY3VycmVudC5oYXMoZmlsZSkpIHtcbiAgICAgICAgICBjaGVja2VkRmlsZXNSZWYuY3VycmVudC5hZGQoZmlsZSlcbiAgICAgICAgICBuZXdGaWxlcy5wdXNoKGZpbGUpXG4gICAgICAgIH1cbiAgICAgIH1cblxuICAgICAgZm9 Line truncated
+21
View File
@@ -0,0 +1,21 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'
import { useMailbox } from '../context/mailbox.js'
type Props = {
isLoading: boolean
onSubmitMessage: (content: string) => boolean
}
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])
}
+34
View File
@@ -0,0 +1,34 @@
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'
// The value of the selector is a full model name that can be used directly in
// API calls. Use this over getMainLoopModel() when the component needs to
// update upon a model config change.
export function useMainLoopModel(): ModelName {
const mainLoopModel = useAppState(s => s.mainLoopModel)
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession)
// parseUserSpecifiedModel reads tengu_ant_model_override via
// _CACHED_MAY_BE_STALE (in resolveAntModel). Until GB init completes,
// that's the stale disk cache; after, it's the in-memory remoteEval map.
// AppState doesn't change when GB init finishes, so we subscribe to the
// refresh signal and force a re-render to re-resolve with fresh values.
// Without this, the alias resolution is frozen until something else
// happens to re-render the component — the API would sample one model
// while /model (which also re-resolves) displays another.
const [, forceRerender] = useReducer(x => x + 1, 0)
useEffect(() => onGrowthBookRefresh(forceRerender), [])
const model = parseUserSpecifiedModel(
mainLoopModelForSession ??
mainLoopModel ??
getDefaultMainLoopModelSetting(),
)
return model
}
+304
View File
@@ -0,0 +1,304 @@
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'
/**
* Hook to manage plugin state and synchronize with AppState.
*
* On mount: loads all plugins, runs delisting enforcement, surfaces flagged-
* plugin notifications, populates AppState.plugins. This is the initial
* Layer-3 load — subsequent refresh goes through /reload-plugins.
*
* On needsRefresh: shows a notification directing the user to /reload-plugins.
* Does NOT auto-refresh. All Layer-3 swap (commands, agents, hooks, MCP)
* goes through refreshActivePlugins() via /reload-plugins for one consistent
* mental model. See 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()
// Initial plugin load. Runs once on mount. NOT used for refresh — all
// post-mount refresh goes through /reload-plugins → refreshActivePlugins().
// Unlike refreshActivePlugins, this also runs delisting enforcement and
// flagged-plugin notifications (session-start concerns), and does NOT bump
// mcp.pluginReconnectKey (MCP effects fire on their own mount).
const initialPluginLoad = useCallback(async () => {
try {
// Load all plugins - capture errors array
const { enabled, disabled, errors } = await loadAllPlugins()
// Detect delisted plugins, auto-uninstall them, and record as flagged.
await detectAndUninstallDelistedPlugins()
// Notify if there are flagged plugins pending dismissal
const flagged = getFlaggedPlugins()
if (Object.keys(flagged).length > 0) {
addNotification({
key: 'plugin-delisted-flagged',
text: 'Plugins flagged. Check /plugins',
color: 'warning',
priority: 'high',
})
}
// Load commands, agents, and hooks with individual error handling
// Errors are added to the errors array for user visibility in 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}`,
})
}
// Load MCP server configs per plugin to get an accurate count.
// LoadedPlugin.mcpServers is not populated by loadAllPlugins — it's a
// cache slot that extractMcpServersFromPlugins fills later, which races
// with this metric. Calling loadPluginMcpServers directly (as
// cli/handlers/plugins.ts does) gives the correct count and also
// warms the cache for the MCP connection manager.
//
// Runs BEFORE setAppState so any errors pushed by these loaders make it
// into AppState.plugins.errors (Doctor UI), not just telemetry.
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: the primary fix for issue #15521 is in refresh.ts (via
// performBackgroundPluginInstallations → refreshActivePlugins, which
// clears caches first). This reinit is defensive — it reads the same
// memoized loadAllPlugins() result as the original init unless a cache
// invalidation happened between main.tsx:3203 and REPL mount (e.g.
// seed marketplace registration or policySettings hot-reload).
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()
// Update AppState - merge errors to preserve LSP errors
setAppState(prevState => {
// Keep existing LSP/non-plugin-loading errors (source 'lsp-manager' or 'plugin:*')
const existingLspErrors = prevState.plugins.errors.filter(
e => e.source === 'lsp-manager' || e.source.startsWith('plugin:'),
)
// Deduplicate: remove existing LSP errors that are also in new errors
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}`,
)
// Count component types across enabled plugins
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-only: which plugins are enabled, to correlate with RSS/FPS.
// Kept separate from base metrics so it doesn't flow into
// 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) {
// Only plugin loading errors should reach here - log for monitoring
const errorObj = toError(error)
logError(errorObj)
logForDebugging(`Error loading plugins: ${error}`)
// Set empty state on error, but preserve LSP errors and add the new error
setAppState(prevState => {
// Keep existing LSP/non-plugin-loading errors
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])
// Load plugins on mount and emit telemetry
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 state changed on disk (background reconcile, /plugin menu,
// external settings edit). Show a notification; user runs /reload-plugins
// to apply. The previous auto-refresh here had a stale-cache bug (only
// cleared loadAllPlugins, downstream memoized loaders returned old data)
// and was incomplete (no MCP, no agentDefinitions). /reload-plugins
// handles all of that correctly via refreshActivePlugins().
useEffect(() => {
if (!enabled || !needsRefresh) return
addNotification({
key: 'plugin-reload-pending',
text: 'Plugins changed. Run /reload-plugins to activate.',
color: 'suggestion',
priority: 'low',
})
// Do NOT auto-refresh. Do NOT reset needsRefresh — /reload-plugins
// consumes it via refreshActivePlugins().
}, [enabled, needsRefresh, addNotification])
}
+39
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
/**
* Hook to monitor Node.js process memory usage.
* Polls every 10 seconds; returns null while status is 'normal'.
*/
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 => {
// Bail when status is 'normal' — nothing is shown, so heapUsed is
// irrelevant and we avoid re-rendering the whole Notifications subtree
// every 10 seconds for the 99%+ of users who never reach 1.5GB.
if (status === 'normal') return prev === null ? prev : null
return { heapUsed, status }
})
}, 10_000)
return memoryUsage
}
+23
View File
@@ -0,0 +1,23 @@
import uniqBy from 'lodash-es/uniqBy.js'
import { useMemo } from 'react'
import type { MCPServerConnection } from '../services/mcp/types.js'
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],
)
}
+15
View File
@@ -0,0 +1,15 @@
import uniqBy from 'lodash-es/uniqBy.js'
import { useMemo } from 'react'
import type { Command } from '../commands.js'
export function useMergedCommands(
initialCommands: Command[],
mcpCommands: Command[],
): Command[] {
return useMemo(() => {
if (mcpCommands.length > 0) {
return uniqBy([...initialCommands, ...mcpCommands], 'name')
}
return initialCommands
}, [initialCommands, mcpCommands])
}
+44
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'
/**
* React hook that assembles the full tool pool for the REPL.
*
* Uses assembleToolPool() (the shared pure function used by both REPL and runAgent)
* to combine built-in tools with MCP tools, applying deny rules and deduplication.
* Any extra initialTools are merged on top.
*
* @param initialTools - Extra tools to include (built-in + startup MCP from props).
* These are merged with the assembled pool and take precedence in deduplication.
* @param mcpTools - MCP tools discovered dynamically (from mcp state)
* @param toolPermissionContext - Permission context for filtering
*/
export function useMergedTools(
initialTools: Tools,
mcpTools: Tools,
toolPermissionContext: ToolPermissionContext,
): Tools {
let replBridgeEnabled = false
let replBridgeOutboundOnly = false
return useMemo(() => {
// assembleToolPool is the shared function that both REPL and runAgent use.
// It handles: getTools() + MCP deny-rule filtering + dedup + MCP CLI exclusion.
const assembled = assembleToolPool(toolPermissionContext, mcpTools)
return mergeAndFilterTools(
initialTools,
assembled,
toolPermissionContext.mode,
)
}, [
initialTools,
mcpTools,
toolPermissionContext,
replBridgeEnabled,
replBridgeOutboundOnly,
])
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useRef, useState } from 'react'
/**
* Throttles a value so each distinct value stays visible for at least `minMs`.
* Prevents fast-cycling progress text from flickering past before it's readable.
*
* Unlike debounce (wait for quiet) or throttle (limit rate), this guarantees
* each value gets its minimum screen time before being replaced.
*/
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
}
+65
View File
@@ -0,0 +1,65 @@
import { useEffect } from 'react'
import {
getLastInteractionTime,
updateLastInteractionTime,
} from '../bootstrap/state.js'
import { useTerminalNotification } from '../ink/useTerminalNotification.js'
import { sendNotification } from '../services/notifier.js'
// The time threshold in milliseconds for considering an interaction "recent" (6 seconds)
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)
}
// NOTE: User interaction tracking is now done in App.tsx's processKeysInBatch
// function, which calls updateLastInteractionTime() when any input is received.
// This avoids having a separate stdin 'data' listener that would compete with
// the main 'readable' listener and cause dropped input characters.
/**
* Hook that manages desktop notifications after a timeout period.
*
* Shows a notification in two cases:
* 1. Immediately if the app has been idle for longer than the threshold
* 2. After the specified timeout if the user doesn't interact within that time
*
* @param message - The notification message to display
* @param timeout - The timeout in milliseconds (defaults to 6000ms)
*/
export function useNotifyAfterTimeout(
message: string,
notificationType: string,
): void {
const terminal = useTerminalNotification()
// Reset interaction time when hook is called to make sure that requests
// that took a long time to complete don't pop up a notification right away.
// Must be immediate because useEffect runs after Ink's render cycle has
// already flushed; without it the timestamp stays stale and a premature
// notification fires if the user is idle (no subsequent renders to flush).
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])
}
@@ -0,0 +1,48 @@
import * as React from 'react';
import type { Notification } from '../context/notifications.js';
import { Text } from '../ink.js';
import { logForDebugging } from '../utils/debug.js';
import { checkAndInstallOfficialMarketplace } from '../utils/plugins/officialMarketplaceStartupCheck.js';
import { useStartupNotification } from './notifs/useStartupNotification.js';
/**
* Hook that handles official marketplace auto-installation and shows
* notifications for success/failure in the bottom right of the REPL.
*/
export function useOfficialMarketplaceNotification() {
useStartupNotification(_temp);
}
async function _temp() {
const result = await checkAndInstallOfficialMarketplace();
const notifs = [];
if (result.configSaveFailed) {
logForDebugging("Showing marketplace config save failure notification");
notifs.push({
key: "marketplace-config-save-failed",
jsx: <Text color="error">Failed to save marketplace retry info · Check ~/.claude.json permissions</Text>,
priority: "immediate",
timeoutMs: 10000
});
}
if (result.installed) {
logForDebugging("Showing marketplace installation success notification");
notifs.push({
key: "marketplace-installed",
jsx: <Text color="success">✓ Anthropic marketplace installed · /plugin to see available plugins</Text>,
priority: "immediate",
timeoutMs: 7000
});
} else {
if (result.skipped && result.reason === "unknown") {
logForDebugging("Showing marketplace installation failure notification");
notifs.push({
key: "marketplace-install-failed",
jsx: <Text color="warning">Failed to install Anthropic marketplace · Will retry on next startup</Text>,
priority: "immediate",
timeoutMs: 8000
});
}
}
return notifs;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIk5vdGlmaWNhdGlvbiIsIlRleHQiLCJsb2dGb3JEZWJ1Z2dpbmciLCJjaGVja0FuZEluc3RhbGxPZmZpY2lhbE1hcmtldHBsYWNlIiwidXNlU3RhcnR1cE5vdGlmaWNhdGlvbiIsInVzZU9mZmljaWFsTWFya2V0cGxhY2VOb3RpZmljYXRpb24iLCJfdGVtcCIsInJlc3VsdCIsIm5vdGlmcyIsImNvbmZpZ1NhdmVGYWlsZWQiLCJwdXNoIiwia2V5IiwianN4IiwicHJpb3JpdHkiLCJ0aW1lb3V0TXMiLCJpbnN0YWxsZWQiLCJza2lwcGVkIiwicmVhc29uIl0sInNvdXJjZXMiOlsidXNlT2ZmaWNpYWxNYXJrZXRwbGFjZU5vdGlmaWNhdGlvbi50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0ICogYXMgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgdHlwZSB7IE5vdGlmaWNhdGlvbiB9IGZyb20gJy4uL2NvbnRleHQvbm90aWZpY2F0aW9ucy5qcydcbmltcG9ydCB7IFRleHQgfSBmcm9tICcuLi9pbmsuanMnXG5pbXBvcnQgeyBsb2dGb3JEZWJ1Z2dpbmcgfSBmcm9tICcuLi91dGlscy9kZWJ1Zy5qcydcbmltcG9ydCB7IGNoZWNrQW5kSW5zdGFsbE9mZmljaWFsTWFya2V0cGxhY2UgfSBmcm9tICcuLi91dGlscy9wbHVnaW5zL29mZmljaWFsTWFya2V0cGxhY2VTdGFydHVwQ2hlY2suanMnXG5pbXBvcnQgeyB1c2VTdGFydHVwTm90aWZpY2F0aW9uIH0gZnJvbSAnLi9ub3RpZnMvdXNlU3RhcnR1cE5vdGlmaWNhdGlvbi5qcydcblxuLyoqXG4gKiBIb29rIHRoYXQgaGFuZGxlcyBvZmZpY2lhbCBtYXJrZXRwbGFjZSBhdXRvLWluc3RhbGxhdGlvbiBhbmQgc2hvd3NcbiAqIG5vdGlmaWNhdGlvbnMgZm9yIHN1Y2Nlc3MvZmFpbHVyZSBpbiB0aGUgYm90dG9tIHJpZ2h0IG9mIHRoZSBSRVBMLlxuICovXG5leHBvcnQgZnVuY3Rpb24gdXNlT2ZmaWNpYWxNYXJrZXRwbGFjZU5vdGlmaWNhdGlvbigpOiB2b2lkIHtcbiAgdXNlU3RhcnR1cE5vdGlmaWNhdGlvbihhc3luYyAoKSA9PiB7XG4gICAgY29uc3QgcmVzdWx0ID0gYXdhaXQgY2hlY2tBbmRJbnN0YWxsT2ZmaWNpYWxNYXJrZXRwbGFjZSgpXG4gICAgY29uc3Qgbm90aWZzOiBOb3RpZmljYXRpb25bXSA9IFtdXG5cbiAgICAvLyBDaGVjayBmb3IgY29uZmlnIHNhdmUgZmFpbHVyZSBmaXJzdCAtIHRoaXMgaXMgY3JpdGljYWxcbiAgICBpZiAocmVzdWx0LmNvbmZpZ1NhdmVGYWlsZWQpIHtcbiAgICAgIGxvZ0ZvckRlYnVnZ2luZygnU2hvd2luZyBtYXJrZXRwbGFjZSBjb25maWcgc2F2ZSBmYWlsdXJlIG5vdGlmaWNhdGlvbicpXG4gICAgICBub3RpZnMucHVzaCh7XG4gICAgICAgIGtleTogJ21hcmtldHBsYWNlLWNvbmZpZy1zYXZlLWZhaWxlZCcsXG4gICAgICAgIGpzeDogKFxuICAgICAgICAgIDxUZXh0IGNvbG9yPVwiZXJyb3JcIj5cbiAgICAgICAgICAgIEZhaWxlZCB0byBzYXZlIG1hcmtldHBsYWNlIHJldHJ5IGluZm8gwrcgQ2hlY2sgfi8uY2xhdWRlLmpzb25cbiAgICAgICAgICAgIHBlcm1pc3Npb25zXG4gICAgICAgICAgPC9UZXh0PlxuICAgICAgICApLFxuICAgICAgICBwcmlvcml0eTogJ2ltbWVkaWF0ZScsXG4gICAgICAgIHRpbWVvdXRNczogMTAwMDAsXG4gICAgICB9KVxuICAgIH1cblxuICAgIGlmIChyZXN1bHQuaW5zdGFsbGVkKSB7XG4gICAgICBsb2dGb3JEZWJ1Z2dpbmcoJ1Nob3dpbmcgbWFya2V0cGxhY2UgaW5zdGFsbGF0aW9uIHN1Y2Nlc3Mgbm90aWZpY2F0aW9uJylcbiAgICAgIG5vdGlmcy5wdXNoKHtcbiAgICAgICAga2V5OiAnbWFya2V0cGxhY2UtaW5zdGFsbGVkJyxcbiAgICAgICAganN4OiAoXG4gICAgICAgICAgPFRleHQgY29sb3I9XCJzdWNjZXNzXCI+XG4gICAgICAgICAgICDinJMgQW50aHJvcGljIG1hcmtldHBsYWNlIGluc3RhbGxlZCDCtyAvcGx1Z2luIHRvIHNlZSBhdmFpbGFibGUgcGx1Z2luc1xuICAgICAgICAgIDwvVGV4dD5cbiAgICAgICAgKSxcbiAgICAgICAgcHJpb3JpdHk6ICdpbW1lZGlhdGUnLFxuICAgICAgICB0aW1lb3V0TXM6IDcwMDAsXG4gICAgICB9KVxuICAgIH0gZWxzZSBpZiAocmVzdWx0LnNraXBwZWQgJiYgcmVzdWx0LnJlYXNvbiA9PT0gJ3Vua25vd24nKSB7XG4gICAgICBsb2dGb3JEZWJ1Z2dpbmcoJ1Nob3dpbmcgbWFya2V0cGxhY2UgaW5zdGFsbGF0aW9uIGZhaWx1cmUgbm90aWZpY2F0aW9uJylcbiAgICAgIG5vdGlmcy5wdXNoKHtcbiAgICAgICAga2V5OiAnbWFya2V0cGxhY2UtaW5zdGFsbC1mYWlsZWQnLFxuICAgICAgICBqc3g6IChcbiAgICAgICAgICA8VGV4dCBjb2xvcj1cIndhcm5pbmdcIj5cbiAgICAgICAgICAgIEZhaWxlZCB0byBpbnN0YWxsIEFudGhyb3BpYyBtYXJrZXRwbGFjZSDCtyBXaWxsIHJldHJ5IG9uIG5leHQgc3RhcnR1cFxuICAgICAgICAgIDwvVGV4dD5cbiAgICAgICAgKSxcbiAgICAgICAgcHJpb3JpdHk6ICdpbW1lZGlhdGUnLFxuICAgICAgICB0aW1lb3V0TXM6IDgwMDAsXG4gICAgICB9KVxuICAgIH1cbiAgICAvLyBEb24ndCBzaG93IG5vdGlmaWNhdGlvbnMgZm9yOlxuICAgIC8vIC0gYWxyZWFkeV9pbnN0YWxsZWQgKHVzZXIgYWxyZWFkeSBoYXMgaXQpXG4gICAgLy8gLSBwb2xpY3lfYmxvY2tlZCAoZW50ZXJwcmlzZSBwb2xpY3ksIGRvbid0IG5hZylcbiAgICAvLyAtIGFscmVhZHlfYXR0ZW1wdGVkIChoYW5kbGVkIGJ5IHJldHJ5IGxvZ2ljIG5vdylcbiAgICAvLyAtIGdpdF91bmF2YWlsYWJsZSAobWFya2V0cGxhY2UgaXMgYSBuaWNlLXRvLWhhdmU7IGlmIGdpdCBpcyBtaXNzaW5nXG4gICAgLy8gICBvciBpcyBhIG5vbi1mdW5jdGlvbmFsIG1hY09TIHhjcnVuIHNoaW0sIHJldHJ5IHNpbGVudGx5IG9uIGJhY2tvZmZcbiAgICAvLyAgIHJhdGhlciB0aGFuIG5hZ2dpbmcg4oCUIHRoZSB1c2VyIHdpbGwgc29ydCBnaXQgb3V0IGZvciBvdGhlciByZWFzb25zKVxuICAgIHJldHVybiBub3RpZnNcbiAgfSlcbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLQSxLQUFLLE1BQU0sT0FBTztBQUM5QixjQUFjQyxZQUFZLFFBQVEsNkJBQTZCO0FBQy9ELFNBQVNDLElBQUksUUFBUSxXQUFXO0FBQ2hDLFNBQVNDLGVBQWUsUUFBUSxtQkFBbUI7QUFDbkQsU0FBU0Msa0NBQWtDLFFBQVEscURBQXFEO0FBQ3hHLFNBQVNDLHNCQUFzQixRQUFRLG9DQUFvQzs7QUFFM0U7QUFDQTtBQUNBO0FBQ0E7QUFDQSxPQUFPLFNBQUFDLG1DQUFBO0VBQ0xELHNCQUFzQixDQUFDRSxLQXFEdEIsQ0FBQztBQUFBO0FBdERHLGVBQUFBLE1BQUE7RUFFSCxNQUFBQyxNQUFBLEdBQWUsTUFBTUosa0NBQWtDLENBQUMsQ0FBQztFQUN6RCxNQUFBSyxNQUFBLEdBQStCLEVBQUU7RUFHakMsSUFBSUQsTUFBTSxDQUFBRSxnQkFBaUI7SUFDekJQLGVBQWUsQ0FBQyxzREFBc0QsQ0FBQztJQUN2RU0sTUFBTSxDQUFBRSxJQUFLLENBQUM7TUFBQUMsR0FBQSxFQUNMLGdDQUFnQztNQUFBQyxHQUFBLEVBRW5DLENBQUMsSUFBSSxDQUFPLEtBQU8sQ0FBUCxPQUFPLENBQUMsd0VBR3BCLEVBSEMsSUFBSSxDQUdFO01BQUFDLFFBQUEsRUFFQyxXQUFXO01BQUFDLFNBQUEsRUFDVjtJQUNiLENBQUMsQ0FBQztFQUFBO0VBR0osSUFBSVAsTUFBTSxDQUFBUSxTQUFVO0lBQ2xCYixlQUFlLENBQUMsdURBQXVELENBQUM7SUFDeEVNLE1BQU0sQ0FBQUUsSUFBSyxDQUFDO01BQUFDLEdBQUEsRUFDTCx1QkFBdUI7TUFBQUMsR0FBQSxFQUUxQixDQUFDLElBQUksQ0FBTyxLQUFTLENBQVQsU0FBUyxDQUFDLG9FQUV0QixFQUZDLElBQUksQ0FFRTtNQUFBQyx Line truncated
+285
View File
@@ -0,0 +1,285 @@
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
}
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,
}
}
+105
View File
@@ -0,0 +1,105 @@
import { c as _c } from "react/compiler-runtime";
/**
* Shared state machine + install helper for plugin-recommendation hooks
* (LSP, claude-code-hint). Centralizes the gate chain, async-guard,
* and success/failure notification JSX so new sources stay small.
*/
import figures from 'figures';
import * as React from 'react';
import { getIsRemoteMode } from '../bootstrap/state.js';
import type { useNotifications } from '../context/notifications.js';
import { Text } from '../ink.js';
import { logError } from '../utils/log.js';
import { getPluginById } from '../utils/plugins/marketplaceManager.js';
type AddNotification = ReturnType<typeof useNotifications>['addNotification'];
type PluginData = NonNullable<Awaited<ReturnType<typeof getPluginById>>>;
/**
* Call tryResolve inside a useEffect; it applies standard gates (remote
* mode, already-showing, in-flight) then runs resolve(). Non-null return
* becomes the recommendation. Include tryResolve in effect deps — its
* identity tracks recommendation, so clearing re-triggers resolution.
*/
export function usePluginRecommendationBase() {
const $ = _c(6);
const [recommendation, setRecommendation] = React.useState(null);
const isCheckingRef = React.useRef(false);
let t0;
if ($[0] !== recommendation) {
t0 = resolve => {
if (getIsRemoteMode()) {
return;
}
if (recommendation) {
return;
}
if (isCheckingRef.current) {
return;
}
isCheckingRef.current = true;
resolve().then(rec => {
if (rec) {
setRecommendation(rec);
}
}).catch(logError).finally(() => {
isCheckingRef.current = false;
});
};
$[0] = recommendation;
$[1] = t0;
} else {
t0 = $[1];
}
const tryResolve = t0;
let t1;
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t1 = () => setRecommendation(null);
$[2] = t1;
} else {
t1 = $[2];
}
const clearRecommendation = t1;
let t2;
if ($[3] !== recommendation || $[4] !== tryResolve) {
t2 = {
recommendation,
clearRecommendation,
tryResolve
};
$[3] = recommendation;
$[4] = tryResolve;
$[5] = t2;
} else {
t2 = $[5];
}
return t2;
}
/** Look up plugin, run install(), emit standard success/failure notification. */
export async function installPluginAndNotify(pluginId: string, pluginName: string, keyPrefix: string, addNotification: AddNotification, install: (pluginData: PluginData) => Promise<void>): Promise<void> {
try {
const pluginData = await getPluginById(pluginId);
if (!pluginData) {
throw new Error(`Plugin ${pluginId} not found in marketplace`);
}
await install(pluginData);
addNotification({
key: `${keyPrefix}-installed`,
jsx: <Text color="success">
{figures.tick} {pluginName} installed · restart to apply
</Text>,
priority: 'immediate',
timeoutMs: 5000
});
} catch (error) {
logError(error);
addNotification({
key: `${keyPrefix}-install-failed`,
jsx: <Text color="error">Failed to install {pluginName}</Text>,
priority: 'immediate',
timeoutMs: 5000
});
}
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmaWd1cmVzIiwiUmVhY3QiLCJnZXRJc1JlbW90ZU1vZGUiLCJ1c2VOb3RpZmljYXRpb25zIiwiVGV4dCIsImxvZ0Vycm9yIiwiZ2V0UGx1Z2luQnlJZCIsIkFkZE5vdGlmaWNhdGlvbiIsIlJldHVyblR5cGUiLCJQbHVnaW5EYXRhIiwiTm9uTnVsbGFibGUiLCJBd2FpdGVkIiwidXNlUGx1Z2luUmVjb21tZW5kYXRpb25CYXNlIiwiJCIsIl9jIiwicmVjb21tZW5kYXRpb24iLCJzZXRSZWNvbW1lbmRhdGlvbiIsInVzZVN0YXRlIiwiaXNDaGVja2luZ1JlZiIsInVzZVJlZiIsInQwIiwicmVzb2x2ZSIsImN1cnJlbnQiLCJ0aGVuIiwicmVjIiwiY2F0Y2giLCJmaW5hbGx5IiwidHJ5UmVzb2x2ZSIsInQxIiwiU3ltYm9sIiwiZm9yIiwiY2xlYXJSZWNvbW1lbmRhdGlvbiIsInQyIiwiaW5zdGFsbFBsdWdpbkFuZE5vdGlmeSIsInBsdWdpbklkIiwicGx1Z2luTmFtZSIsImtleVByZWZpeCIsImFkZE5vdGlmaWNhdGlvbiIsImluc3RhbGwiLCJwbHVnaW5EYXRhIiwiUHJvbWlzZSIsIkVycm9yIiwia2V5IiwianN4IiwidGljayIsInByaW9yaXR5IiwidGltZW91dE1zIiwiZXJyb3IiXSwic291cmNlcyI6WyJ1c2VQbHVnaW5SZWNvbW1lbmRhdGlvbkJhc2UudHN4Il0sInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogU2hhcmVkIHN0YXRlIG1hY2hpbmUgKyBpbnN0YWxsIGhlbHBlciBmb3IgcGx1Z2luLXJlY29tbWVuZGF0aW9uIGhvb2tzXG4gKiAoTFNQLCBjbGF1ZGUtY29kZS1oaW50KS4gQ2VudHJhbGl6ZXMgdGhlIGdhdGUgY2hhaW4sIGFzeW5jLWd1YXJkLFxuICogYW5kIHN1Y2Nlc3MvZmFpbHVyZSBub3RpZmljYXRpb24gSlNYIHNvIG5ldyBzb3VyY2VzIHN0YXkgc21hbGwuXG4gKi9cblxuaW1wb3J0IGZpZ3VyZXMgZnJvbSAnZmlndXJlcydcbmltcG9ydCAqIGFzIFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgZ2V0SXNSZW1vdGVNb2RlIH0gZnJvbSAnLi4vYm9vdHN0cmFwL3N0YXRlLmpzJ1xuaW1wb3J0IHR5cGUgeyB1c2VOb3RpZmljYXRpb25zIH0gZnJvbSAnLi4vY29udGV4dC9ub3RpZmljYXRpb25zLmpzJ1xuaW1wb3J0IHsgVGV4dCB9IGZyb20gJy4uL2luay5qcydcbmltcG9ydCB7IGxvZ0Vycm9yIH0gZnJvbSAnLi4vdXRpbHMvbG9nLmpzJ1xuaW1wb3J0IHsgZ2V0UGx1Z2luQnlJZCB9IGZyb20gJy4uL3V0aWxzL3BsdWdpbnMvbWFya2V0cGxhY2VNYW5hZ2VyLmpzJ1xuXG50eXBlIEFkZE5vdGlmaWNhdGlvbiA9IFJldHVyblR5cGU8dHlwZW9mIHVzZU5vdGlmaWNhdGlvbnM+WydhZGROb3RpZmljYXRpb24nXVxudHlwZSBQbHVnaW5EYXRhID0gTm9uTnVsbGFibGU8QXdhaXRlZDxSZXR1cm5UeXBlPHR5cGVvZiBnZXRQbHVnaW5CeUlkPj4+XG5cbi8qKlxuICogQ2FsbCB0cnlSZXNvbHZlIGluc2lkZSBhIHVzZUVmZmVjdDsgaXQgYXBwbGllcyBzdGFuZGFyZCBnYXRlcyAocmVtb3RlXG4gKiBtb2RlLCBhbHJlYWR5LXNob3dpbmcsIGluLWZsaWdodCkgdGhlbiBydW5zIHJlc29sdmUoKS4gTm9uLW51bGwgcmV0dXJuXG4gKiBiZWNvbWVzIHRoZSByZWNvbW1lbmRhdGlvbi4gSW5jbHVkZSB0cnlSZXNvbHZlIGluIGVmZmVjdCBkZXBzIOKAlCBpdHNcbiAqIGlkZW50aXR5IHRyYWNrcyByZWNvbW1lbmRhdGlvbiwgc28gY2xlYXJpbmcgcmUtdHJpZ2dlcnMgcmVzb2x1dGlvbi5cbiAqL1xuZXhwb3J0IGZ1bmN0aW9uIHVzZVBsdWdpblJlY29tbWVuZGF0aW9uQmFzZTxUPigpOiB7XG4gIHJlY29tbWVuZGF0aW9uOiBUIHwgbnVsbFxuICBjbGVhclJlY29tbWVuZGF0aW9uOiAoKSA9PiB2b2lkXG4gIHRyeVJlc29sdmU6IChyZXNvbHZlOiAoKSA9PiBQcm9taXNlPFQgfCBudWxsPikgPT4gdm9pZFxufSB7XG4gIGNvbnN0IFtyZWNvbW1lbmRhdGlvbiwgc2V0UmVjb21tZW5kYXRpb25dID0gUmVhY3QudXNlU3RhdGU8VCB8IG51bGw+KG51bGwpXG4gIGNvbnN0IGlzQ2hlY2tpbmdSZWYgPSBSZWFjdC51c2VSZWYoZmFsc2UpXG5cbiAgY29uc3QgdHJ5UmVzb2x2ZSA9IFJlYWN0LnVzZUNhbGxiYWNrKFxuICAgIChyZXNvbHZlOiAoKSA9PiBQcm9taXNlPFQgfCBudWxsPikgPT4ge1xuICAgICAgaWYgKGdldElzUmVtb3RlTW9kZSgpKSByZXR1cm5cbiAgICAgIGlmIChyZWNvbW1lbmRhdGlvbikgcmV0dXJuXG4gICAgICBpZiAoaXNDaGVja2luZ1JlZi5jdXJyZW50KSByZXR1cm5cblxuICAgICAgaXNDaGVja2luZ1JlZi5jdXJyZW50ID0gdHJ1ZVxuICAgICAgdm9pZCByZXNvbHZlKClcbiAgICAgICAgLnRoZW4ocmVjID0+IHtcbiAgICAgICAgICBpZiAocmVjKSBzZXRSZWNvbW1lbmRhdGlvbihyZWMpXG4gICAgICAgIH0pXG4gICAgICAgIC5jYXRjaChsb2dFcnJvcilcbiAgICAgICAgLmZpbmFsbHkoKCkgPT4ge1xuICAgICAgICAgIGlzQ2hlY2tpbmdSZWYuY3VycmVudCA9IGZhbHNlXG4gICAgICAgIH0pXG4gICAgfSxcbiAgICBbcmVjb21tZW5kYXRpb25dLFxuICApXG5cbiAgY29uc3QgY2xlYXJSZWNvbW1lbmRhdGlvbiA9IFJlYWN0LnVzZUNhbGxiYWNrKFxuICAgICgpID0+IHNldFJlY29tbWVuZGF0aW9uKG51bGwpLFxuICAgIFtdLFxuICApXG5cbiAgcmV0dXJuIHsgcmVjb21tZW5kYXRpb24sIGNsZWFyUmVjb21tZW5kYXRpb24sIHRyeVJlc29sdmUgfVxufVxuXG4vKiogTG9vayB1cCBwbHVnaW4sIHJ1biBpbnN0YWxsKCksIGVtaXQgc3RhbmRhcmQgc3VjY2Vzcy9mYWlsdXJlIG5vdGlmaWNhdGlvbi4gKi9cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBpbnN0YWxsUGx1Z2luQW5kTm90aWZ5KFxuICBwbHVnaW5JZDogc3RyaW5nLFxuICBwbHVnaW5OYW1lOiBzdHJpbmcsXG4gIGtleVByZWZpeDogc3RyaW5nLFxuICBhZGROb3RpZmljYXRpb246IEFkZE5vdGlmaWNhdGlvbixcbiAgaW5zdGFsbDogKHBsdWdpbkRhdGE6IFBsdWdpbkRhdGEpID0+IFByb21pc2U8dm9pZD4sXG4pOiBQcm9taXNlPHZvaWQ+IHtcbiAgdHJ5IHtcbiAgICBjb25zdCBwbHVnaW5EYXRhID0gYXdhaXQgZ2V0UGx1Z2luQnlJZChwbHVnaW5JZClcbiAgICBpZiAoIXBsdWdpbkRhdGEpIHtcbiAgICAgIHRocm93IG5ldyBFcnJvcihgUGx1Z2luICR7cGx1Z2luSWR9IG5vdCBmb3VuZCBpbiBtYXJrZXRwbGFjZWApXG4gICAgfVxuICAgIGF3YWl0IGluc3RhbGwocGx1Z2luRGF0YSlcbiAgICBhZGROb3RpZmljYXRpb24oe1xuICAgICAga2V5OiBgJHtrZXlQcmVmaXh9LWluc3RhbGxlZGAsXG4gICAgICBqc3g6IChcbiAgICAgICAgPFRleHQgY29sb3I9XCJzdWNjZXNzXCI+XG4gICAgICAgICAge2ZpZ3VyZXMudGlja30ge3BsdWdpbk5hbWV9IGluc3RhbGxlZCDCtyByZXN0YXJ0IHRvIGFwcGx5XG4gICAgICAgIDwvVGV4dD5cbiAgICAgICksXG4gICAgICBwcmlvcml0eTogJ2ltbWVkaWF0ZScsXG4gICAgICB0aW1lb3V0TXM6IDUwMDAsXG4gICAgfSlcbiAgfSBjYXRjaCAoZXJyb3IpIHtcbiAgICBsb2dFcnJvcihlcnJvcilcbiAgICBhZGROb3RpZmljYXRpb24oe1xuICAgICAga2V5OiBgJHtrZXlQcmVmaXh9LWluc3RhbGwtZmFpbGVkYCxcbiAgICAgIGpzeDogPFRleHQgY29sb3I9XCJlcnJvclwiPkZhaWxlZCB0byBpbnN0YWxsIHtwbHVnaW5OYW1lfTwvVGV4dD4sXG4gICAgICBwcmlvcml0eTogJ2ltbWVkaWF0ZScsXG4gICAgICB0aW1lb3V0TXM6IDUwMDAsXG4gICAgfSlcbiAgfVxufVxuIl0sIm1hcHBpbmdzIjoiO0FBQUE7QUFDQTtBQUNBO0FBQ0E7QUFDQTs7QUFFQSxPQUFPQSxPQUFPLE1 Line truncated
+106
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 // stop polling after 60 min idle
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,
}
/**
* Polls PR review status every 60s while the session is active.
* When no interaction is detected for 60 minutes, the loop stops — no
* timers remain. React re-runs the effect when isLoading changes
* (turn starts/ends), restarting the loop. Effect setup schedules
* the next poll relative to the last fetch time so turn boundaries
* don't spawn `gh` more than once per interval. Disables permanently
* if a fetch exceeds 4s.
*
* Pass `enabled: false` to skip polling entirely (hook still must be
* called unconditionally to satisfy the rules of 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
}
+177
View File
@@ -0,0 +1,177 @@
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
}
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
// Track engagement depth for telemetry
const firstKeystrokeAt = useRef<number>(0)
const wasFocusedWhenShown = useRef<boolean>(true)
const prevShownAt = useRef<number>(0)
// Capture focus state when a new suggestion appears (shownAt changes)
if (shownAt > 0 && shownAt !== prevShownAt.current) {
prevShownAt.current = shownAt
wasFocusedWhenShown.current = isTerminalFocused
firstKeystrokeAt.current = 0
} else if (shownAt === 0) {
prevShownAt.current = 0
}
// Record first keystroke while suggestion is visible
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(() => {
// Check shownAt inside setAppState callback to avoid depending on it
// (depending on shownAt causes infinite loop when this callback is called)
setAppState(prev => {
// Only mark shown if not already shown and suggestion exists
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
// Determine if accepted: either Tab was pressed (acceptedAt set) OR
// final input matches suggestion (empty Enter case)
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,
}
}
@@ -0,0 +1,71 @@
import { c as _c } from "react/compiler-runtime";
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs';
import { useEffect, useRef } from 'react';
import { logError } from 'src/utils/log.js';
import { z } from 'zod/v4';
import { callIdeRpc } from '../services/mcp/client.js';
import type { ConnectedMCPServer, MCPServerConnection } from '../services/mcp/types.js';
import type { PermissionMode } from '../types/permissions.js';
import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, isTrackedClaudeInChromeTabId } from '../utils/claudeInChrome/common.js';
import { lazySchema } from '../utils/lazySchema.js';
import { enqueuePendingNotification } from '../utils/messageQueueManager.js';
// Schema for the prompt notification from Chrome extension (JSON-RPC 2.0 format)
const ClaudeInChromePromptNotificationSchema = lazySchema(() => z.object({
method: z.literal('notifications/message'),
params: z.object({
prompt: z.string(),
image: z.object({
type: z.literal('base64'),
media_type: z.enum(['image/jpeg', 'image/png', 'image/gif', 'image/webp']),
data: z.string()
}).optional(),
tabId: z.number().optional()
})
}));
/**
* A hook that listens for prompt notifications from the Claude for Chrome extension,
* enqueues them as user prompts, and syncs permission mode changes to the extension.
*/
export function usePromptsFromClaudeInChrome(mcpClients, toolPermissionMode) {
const $ = _c(6);
useRef(undefined);
let t0;
if ($[0] !== mcpClients) {
t0 = [mcpClients];
$[0] = mcpClients;
$[1] = t0;
} else {
t0 = $[1];
}
useEffect(_temp, t0);
let t1;
let t2;
if ($[2] !== mcpClients || $[3] !== toolPermissionMode) {
t1 = () => {
const chromeClient = findChromeClient(mcpClients);
if (!chromeClient) {
return;
}
const chromeMode = toolPermissionMode === "bypassPermissions" ? "skip_all_permission_checks" : "ask";
callIdeRpc("set_permission_mode", {
mode: chromeMode
}, chromeClient);
};
t2 = [mcpClients, toolPermissionMode];
$[2] = mcpClients;
$[3] = toolPermissionMode;
$[4] = t1;
$[5] = t2;
} else {
t1 = $[4];
t2 = $[5];
}
useEffect(t1, t2);
}
function _temp() {}
function findChromeClient(clients: MCPServerConnection[]): ConnectedMCPServer | undefined {
return clients.find((client): client is ConnectedMCPServer => client.type === 'connected' && client.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME);
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJDb250ZW50QmxvY2tQYXJhbSIsInVzZUVmZmVjdCIsInVzZVJlZiIsImxvZ0Vycm9yIiwieiIsImNhbGxJZGVScGMiLCJDb25uZWN0ZWRNQ1BTZXJ2ZXIiLCJNQ1BTZXJ2ZXJDb25uZWN0aW9uIiwiUGVybWlzc2lvbk1vZGUiLCJDTEFVREVfSU5fQ0hST01FX01DUF9TRVJWRVJfTkFNRSIsImlzVHJhY2tlZENsYXVkZUluQ2hyb21lVGFiSWQiLCJsYXp5U2NoZW1hIiwiZW5xdWV1ZVBlbmRpbmdOb3RpZmljYXRpb24iLCJDbGF1ZGVJbkNocm9tZVByb21wdE5vdGlmaWNhdGlvblNjaGVtYSIsIm9iamVjdCIsIm1ldGhvZCIsImxpdGVyYWwiLCJwYXJhbXMiLCJwcm9tcHQiLCJzdHJpbmciLCJpbWFnZSIsInR5cGUiLCJtZWRpYV90eXBlIiwiZW51bSIsImRhdGEiLCJvcHRpb25hbCIsInRhYklkIiwibnVtYmVyIiwidXNlUHJvbXB0c0Zyb21DbGF1ZGVJbkNocm9tZSIsIm1jcENsaWVudHMiLCJ0b29sUGVybWlzc2lvbk1vZGUiLCIkIiwiX2MiLCJ1bmRlZmluZWQiLCJ0MCIsIl90ZW1wIiwidDEiLCJ0MiIsImNocm9tZUNsaWVudCIsImZpbmRDaHJvbWVDbGllbnQiLCJjaHJvbWVNb2RlIiwibW9kZSIsImNsaWVudHMiLCJmaW5kIiwiY2xpZW50IiwibmFtZSJdLCJzb3VyY2VzIjpbInVzZVByb21wdHNGcm9tQ2xhdWRlSW5DaHJvbWUudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB0eXBlIHsgQ29udGVudEJsb2NrUGFyYW0gfSBmcm9tICdAYW50aHJvcGljLWFpL3Nkay9yZXNvdXJjZXMvbWVzc2FnZXMubWpzJ1xuaW1wb3J0IHsgdXNlRWZmZWN0LCB1c2VSZWYgfSBmcm9tICdyZWFjdCdcbmltcG9ydCB7IGxvZ0Vycm9yIH0gZnJvbSAnc3JjL3V0aWxzL2xvZy5qcydcbmltcG9ydCB7IHogfSBmcm9tICd6b2QvdjQnXG5pbXBvcnQgeyBjYWxsSWRlUnBjIH0gZnJvbSAnLi4vc2VydmljZXMvbWNwL2NsaWVudC5qcydcbmltcG9ydCB0eXBlIHtcbiAgQ29ubmVjdGVkTUNQU2VydmVyLFxuICBNQ1BTZXJ2ZXJDb25uZWN0aW9uLFxufSBmcm9tICcuLi9zZXJ2aWNlcy9tY3AvdHlwZXMuanMnXG5pbXBvcnQgdHlwZSB7IFBlcm1pc3Npb25Nb2RlIH0gZnJvbSAnLi4vdHlwZXMvcGVybWlzc2lvbnMuanMnXG5pbXBvcnQge1xuICBDTEFVREVfSU5fQ0hST01FX01DUF9TRVJWRVJfTkFNRSxcbiAgaXNUcmFja2VkQ2xhdWRlSW5DaHJvbWVUYWJJZCxcbn0gZnJvbSAnLi4vdXRpbHMvY2xhdWRlSW5DaHJvbWUvY29tbW9uLmpzJ1xuaW1wb3J0IHsgbGF6eVNjaGVtYSB9IGZyb20gJy4uL3V0aWxzL2xhenlTY2hlbWEuanMnXG5pbXBvcnQgeyBlbnF1ZXVlUGVuZGluZ05vdGlmaWNhdGlvbiB9IGZyb20gJy4uL3V0aWxzL21lc3NhZ2VRdWV1ZU1hbmFnZXIuanMnXG5cbi8vIFNjaGVtYSBmb3IgdGhlIHByb21wdCBub3RpZmljYXRpb24gZnJvbSBDaHJvbWUgZXh0ZW5zaW9uIChKU09OLVJQQyAyLjAgZm9ybWF0KVxuY29uc3QgQ2xhdWRlSW5DaHJvbWVQcm9tcHROb3RpZmljYXRpb25TY2hlbWEgPSBsYXp5U2NoZW1hKCgpID0+XG4gIHoub2JqZWN0KHtcbiAgICBtZXRob2Q6IHoubGl0ZXJhbCgnbm90aWZpY2F0aW9ucy9tZXNzYWdlJyksXG4gICAgcGFyYW1zOiB6Lm9iamVjdCh7XG4gICAgICBwcm9tcHQ6IHouc3RyaW5nKCksXG4gICAgICBpbWFnZTogelxuICAgICAgICAub2JqZWN0KHtcbiAgICAgICAgICB0eXBlOiB6LmxpdGVyYWwoJ2Jhc2U2NCcpLFxuICAgICAgICAgIG1lZGlhX3R5cGU6IHouZW51bShbXG4gICAgICAgICAgICAnaW1hZ2UvanBlZycsXG4gICAgICAgICAgICAnaW1hZ2UvcG5nJyxcbiAgICAgICAgICAgICdpbWFnZS9naWYnLFxuICAgICAgICAgICAgJ2ltYWdlL3dlYnAnLFxuICAgICAgICAgIF0pLFxuICAgICAgICAgIGRhdGE6IHouc3RyaW5nKCksXG4gICAgICAgIH0pXG4gICAgICAgIC5vcHRpb25hbCgpLFxuICAgICAgdGFiSWQ6IHoubnVtYmVyKCkub3B0aW9uYWwoKSxcbiAgICB9KSxcbiAgfSksXG4pXG5cbi8qKlxuICogQSBob29rIHRoYXQgbGlzdGVucyBmb3IgcHJvbXB0IG5vdGlmaWNhdGlvbnMgZnJvbSB0aGUgQ2xhdWRlIGZvciBDaHJvbWUgZXh0ZW5zaW9uLFxuICogZW5xdWV1ZXMgdGhlbSBhcyB1c2VyIHByb21wdHMsIGFuZCBzeW5jcyBwZXJtaXNzaW9uIG1vZGUgY2hhbmdlcyB0byB0aGUgZXh0ZW5zaW9uLlxuICovXG5leHBvcnQgZnVuY3Rpb24gdXNlUHJvbXB0c0Zyb21DbGF1ZGVJbkNocm9tZShcbiAgbWNwQ2xpZW50czogTUNQU2VydmVyQ29ubmVjdGlvbltdLFxuICB0b29sUGVybWlzc2lvbk1vZGU6IFBlcm1pc3Npb25Nb2RlLFxuKTogdm9pZCB7XG4gIGNvbnN0IG1jcENsaWVudFJlZiA9IHVzZVJlZjxDb25uZWN0ZWRNQ1BTZXJ2ZXIgfCB1bmRlZmluZWQ+KHVuZGVmaW5lZClcblxuICB1c2VFZmZlY3QoKCkgPT4ge1xuICAgIGlmIChcImV4dGVybmFsXCIgIT09ICdhbnQnKSB7XG4gICAgICByZXR1cm5cbiAgICB9XG5cbiAgICBjb25zdCBtY3BDbGllbnQgPSBmaW5kQ2hyb21lQ2xpZW50KG1jcENsaWVudHMpXG4gICAgaWYgKG1jcENsaWVudFJlZi5jdXJyZW50ICE9PSBtY3BDbGllbnQpIHtcbiAgICAgIG1jcENsaWVudFJlZi5jdXJyZW50ID0gbWNwQ2xpZW50XG4gICAgfVxuXG4gICAgaWYgKG1jcENsaWVudCkge1xuICAgICAgbWNwQ2xpZW50LmNsaWVudC5zZXROb3RpZmljYXRpb25IYW5kbGVyKFxuICAgICAgICBDbGF1ZGVJbkNocm9tZVByb21wdE5vdGlmaWNhdGlvblNjaGVtYSgpLFxuICAgICAgICBub3RpZmljYXRpb24gPT4ge1xuICAgICAgICAgIGlmIChtY3BDbGllbnRSZWYuY3VycmVudCAhPT0gbWNwQ2xpZW50KSB7XG4gICAgICAgICAgICByZXR1cm5cbiAgICAgICAgICB9XG4gICAgICAgICAgY29uc3QgeyB0YWJJZCwgcHJvbXB0LCBpbWFnZSB9ID0gbm90aWZpY2F0aW9uLnBhcmFtc1xuXG4gICAgICAgICAgLy8gUHJvY2VzcyBub3RpZmljYXRpb25zIGZyb20gdGFicyB3ZSdyZSB0cmFja2luZyBzaW5jZSBub3RpZmljYXRpb25zIGFyZSBicm9hZGNhc3RlZFxuICAgICAgICAgIGlmIChcbiAgICAgICAgICAgIHR5cGVvZiB0YWJJZCAhPT0gJ251bWJlcicgfHxcbiAgICAgICAgICAgICFpc1RyYWNrZWRDbGF1ZGVJbkNocm9tZVRhYklkKHRhYklkKVxuICAgICAgICAgICkge1xuICAgICAgICAgICAgcmV0dXJuXG4gICAgICAgICAgfVxuXG4gICAgICAgICAgdHJ5IHtcbiAgICAgICAgICAgIC8vIEJ1aWxkIGNvbnRlbnQgYmxvY2tzIGlmIHRoZXJlJ3MgYW4gaW1hZ2UsIG90aGVyd2lzZSBqdXN0IHVzZSB0aGUgcHJvbXB0IHN0cmluZ1xuICAgICAgICAgICAgaWYgKGltYWdlKSB7XG4gICAgICAgICAgICAgIGNvbnN0IGNvbnRlbnRCbG9ja3M6IENvbnRlbnRCbG9ja1BhcmFtW10gPSBbXG4gICAgICAgICAgICAgICAgeyB0eXBlOiAndGV4dCcsIHRleHQ6IHByb21wdCB9LFxuICAgICAgICAgICAgICAgIHtcbiAgICAgICAgICAgICAgICAgIHR5cGU6ICdpbWFnZScsXG4gICAgICAgICAgICAgICAgICBzb3VyY2U6IHtcbiAgICAgICAgICAgICAgICAgICAgdHlwZTogaW1hZ2UudHlwZSxcbiAgICAgICAgICAgICAgICAgICAgbWVkaWFfdHlwZTogaW1hZ2UubWVkaWFfdHlwZSxcbiAgICAgICAgICAgICAgICAgICAgZGF0YTogaW1hZ2UuZGF0YSxcbiAgICAgICAgICAgICAgICAgIH0sXG4gICAgICAgICAgICAgICAgfSxcbiAgICAgICAgICAgICAgXVxuICAgICAgICAgICAgICBlbnF1ZXVlUGVuZGluZ05vdGlmaWNhdGl Line truncated
+68
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 that processes queued commands when conditions are met.
*
* Uses a single unified command queue (module-level store). Priority determines
* processing order: 'now' > 'next' (user input) > 'later' (task notifications).
* The dequeue() function handles priority ordering automatically.
*
* Processing triggers when:
* - No query active (queryGuard — reactive via useSyncExternalStore)
* - Queue has items
* - No active local JSX UI blocking input
*/
export function useQueueProcessor({
executeQueuedInput,
hasActiveLocalJsxUI,
queryGuard,
}: UseQueueProcessorParams): void {
// Subscribe to the query guard. Re-renders when a query starts or ends
// (or when reserve/cancelReservation transitions dispatching state).
const isQueryActive = useSyncExternalStore(
queryGuard.subscribe,
queryGuard.getSnapshot,
)
// Subscribe to the unified command queue via useSyncExternalStore.
// This guarantees re-render when the store changes, bypassing
// React context propagation delays that cause missed notifications in Ink.
const queueSnapshot = useSyncExternalStore(
subscribeToCommandQueue,
getCommandQueueSnapshot,
)
useEffect(() => {
if (isQueryActive) return
if (hasActiveLocalJsxUI) return
if (queueSnapshot.length === 0) return
// Reservation is now owned by handlePromptSubmit (inside executeUserInput's
// try block). The sync chain executeQueuedInput → handlePromptSubmit →
// executeUserInput → queryGuard.reserve() runs before the first real await,
// so by the time React re-runs this effect (due to the dequeue-triggered
// snapshot change), isQueryActive is already true (dispatching) and the
// guard above returns early. handlePromptSubmit's finally releases the
// reservation via cancelReservation() (no-op if onQuery already ran end()).
processQueueIfReady({ executeInput: executeQueuedInput })
}, [
queueSnapshot,
isQueryActive,
executeQueuedInput,
hasActiveLocalJsxUI,
queryGuard,
])
}
+605
View File
@@ -0,0 +1,605 @@
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'
// How long to wait for a response before showing a warning
const RESPONSE_TIMEOUT_MS = 60000 // 60 seconds
// Extended timeout during compaction — compact API calls take 5-30s and
// block other SDK messages, so the normal 60s timeout isn't enough when
// compaction itself runs close to the edge.
const COMPACTION_TIMEOUT_MS = 180000 // 3 minutes
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
}
/**
* Hook for managing a remote CCR session in the REPL.
*
* Handles:
* - WebSocket connection to CCR
* - Converting SDK messages to REPL messages
* - Sending user input to CCR via HTTP POST
* - Permission request/response flow via existing ToolUseConfirm queue
*/
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],
)
// Event-sourced count of subagents running inside the remote daemon child.
// The viewer's own AppState.tasks is empty — tasks live in a different
// process. task_started/task_notification reach us via the 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])
// Timer for detecting stuck sessions
const responseTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Track whether the remote session is compacting. During compaction the
// CLI worker is busy with an API call and won't emit messages for a while;
// use a longer timeout and suppress spurious "unresponsive" warnings.
const isCompactingRef = useRef(false)
const managerRef = useRef<RemoteSessionManager | null>(null)
// Track whether we've already updated the session title (for no-initial-prompt sessions)
const hasUpdatedTitleRef = useRef(false)
// UUIDs of user messages we POSTed locally — the WS echoes them back and
// we must filter them out when convertUserTextMessages is on, or the viewer
// sees every typed message twice (once from local createUserMessage, once
// from the echo). A single POST can echo MULTIPLE times with the same uuid:
// the server may broadcast the POST directly to /subscribe, AND the worker
// (cowork desktop / CLI daemon) echoes it again on its write path. A
// delete-on-first-match Set would let the second echo through — use a
// bounded ring instead. Cap is generous: users don't type 50 messages
// faster than echoes arrive.
// NOTE: this does NOT dedup history-vs-live overlap at attach time (nothing
// seeds the set from history UUIDs; only sendMessage populates it).
const sentUUIDsRef = useRef(new BoundedUUIDSet(50))
// Keep a ref to tools so the WebSocket callback doesn't go stale
const toolsRef = useRef(tools)
useEffect(() => {
toolsRef.current = tools
}, [tools])
// Initialize and connect to remote session
useEffect(() => {
// Skip if not in remote mode
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(' ')}`)
// Clear response timeout on any message received — including the WS
// echo of our own POST, which acts as a heartbeat. This must run
// BEFORE the echo filter, or slow-to-stream agents (compaction, cold
// start) spuriously trip the 60s unresponsive warning + reconnect.
if (responseTimeoutRef.current) {
clearTimeout(responseTimeoutRef.current)
responseTimeoutRef.current = null
}
// Echo filter: drop user messages we already added locally before POST.
// The server and/or worker round-trip our own send back on the WS with
// the same uuid we passed to sendEventToRemoteSession. DO NOT delete on
// match — the same uuid can echo more than once (server broadcast +
// worker echo), and BoundedUUIDSet already caps growth via its ring.
if (
sdkMessage.type === 'user' &&
sdkMessage.uuid &&
sentUUIDsRef.current.has(sdkMessage.uuid)
) {
logForDebugging(
`[useRemoteSession] Dropping echoed user message ${sdkMessage.uuid}`,
)
return
}
// Handle init message - extract available slash commands
if (
sdkMessage.type === 'system' &&
sdkMessage.subtype === 'init' &&
onInit
) {
logForDebugging(
`[useRemoteSession] Init received with ${sdkMessage.slash_commands.length} slash commands`,
)
onInit(sdkMessage.slash_commands)
}
// Track remote subagent lifecycle for the "N in background" counter.
// All task types (Agent/teammate/workflow/bash) flow through
// registerTask() → task_started, and complete via task_notification.
// Return early — these are status signals, not renderable messages.
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
}
// Track compaction state. The CLI emits status='compacting' at
// the start and status=null when done; compact_boundary also
// signals completion. Repeated 'compacting' status messages
// (keep-alive ticks) update the ref but don't append to messages.
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
}
}
// Check if session ended
if (isSessionEndMessage(sdkMessage)) {
isCompactingRef.current = false
setIsLoading(false)
}
// Clear in-progress tool_use IDs when their tool_result arrives.
// Must read the RAW sdkMessage: in non-viewerOnly mode,
// convertSDKMessage returns {type:'ignored'} for user messages, so the
// delete would never fire post-conversion. Mirrors the add site below
// and inProcessRunner.ts; without this the set grows unbounded for the
// session lifetime (BQ: CCR cohort shows 5.2x higher RSS slope).
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
})
}
}
}
// Convert SDK message to REPL message. In viewerOnly mode, the
// remote agent runs BriefTool (SendUserMessage) — its tool_use block
// renders empty (userFacingName() === ''), actual content is in the
// tool_result. So we must convert tool_results to render them.
const converted = convertSDKMessage(
sdkMessage,
config.viewerOnly
? { convertToolResults: true, convertUserTextMessages: true }
: undefined,
)
if (converted.type === 'message') {
// When we receive a complete message, clear streaming tool uses
// since the complete message replaces the partial streaming state
setStreamingToolUses?.(prev => (prev.length > 0 ? [] : prev))
// Mark tool_use blocks as in-progress so the UI shows the correct
// spinner state instead of "Waiting…" (queued). In local sessions,
// toolOrchestration.ts handles this, but remote sessions receive
// pre-built assistant messages without running local tool execution.
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])
// Note: Don't stop loading on assistant messages - the agent may still be
// working (tool use loops). Loading stops only on session end or permission request.
} else if (converted.type === 'stream_event') {
// Process streaming events to update UI in real-time
if (setStreamingToolUses && setStreamMode) {
handleMessageFromStream(
converted.event,
message => setMessages(prev => [...prev, message]),
() => {
// No-op for response length - remote sessions don't track this
},
setStreamMode,
setStreamingToolUses,
)
} else {
logForDebugging(
`[useRemoteSession] Stream event received but streaming callbacks not provided`,
)
}
}
// 'ignored' messages are silently dropped
},
onPermissionRequest: (request, requestId) => {
logForDebugging(
`[useRemoteSession] Permission request for tool: ${request.tool_name}`,
)
// Look up the Tool object by name, or create a stub for unknown tools
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() {
// No-op for remote — classifier runs on the container
},
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),
)
// Resume loading indicator after approving
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() {
// No-op for remote — permission state is on the container
},
}
setToolUseConfirmQueue(queue => [...queue, toolUseConfirm])
// Pause loading indicator while waiting for permission
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 gap = we may miss task_notification events. Clear rather than
// drift high forever. Undercounts tasks that span the gap; accepted.
runningTaskIdsRef.current.clear()
writeTaskCount()
// Same for tool_use IDs: missed tool_result during the gap would
// leave stale spinner state forever.
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')
// Clear any pending timeout
if (responseTimeoutRef.current) {
clearTimeout(responseTimeoutRef.current)
responseTimeoutRef.current = null
}
manager.disconnect()
managerRef.current = null
}
}, [
config,
setMessages,
setIsLoading,
onInit,
setToolUseConfirmQueue,
setStreamingToolUses,
setStreamMode,
setInProgressToolUseIDs,
setConnStatus,
writeTaskCount,
])
// Send a user message to the remote session
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
}
// Clear any existing timeout
if (responseTimeoutRef.current) {
clearTimeout(responseTimeoutRef.current)
}
setIsLoading(true)
// Track locally-added message UUIDs so the WS echo can be filtered.
// Must record BEFORE the POST to close the race where the echo arrives
// before the POST promise resolves.
if (opts?.uuid) sentUUIDsRef.current.add(opts.uuid)
const success = await manager.sendMessage(content, opts)
if (!success) {
// No need to undo the pre-POST add — BoundedUUIDSet's ring evicts it.
setIsLoading(false)
return false
}
// Update the session title after the first message when no initial prompt was provided.
// This gives the session a meaningful title on claude.ai instead of "Background task".
// Skip in viewerOnly mode — the remote agent owns the session title.
if (
!hasUpdatedTitleRef.current &&
config &&
!config.hasInitialPrompt &&
!config.viewerOnly
) {
hasUpdatedTitleRef.current = true
const sessionId = config.sessionId
// Extract plain text from content (may be string or content block array)
const description =
typeof content === 'string'
? content
: extractTextContent(content, ' ')
if (description) {
// generateSessionTitle never rejects (wraps body in try/catch,
// returns null on failure), so no .catch needed on this chain.
void generateSessionTitle(
description,
new AbortController().signal,
).then(title => {
void updateSessionTitle(
sessionId,
title ?? truncateToWidth(description, 75),
)
})
}
}
// Start timeout to detect stuck sessions. Skip in viewerOnly mode —
// the remote agent may be idle-shut and take >60s to respawn.
// Use a longer timeout when the remote session is compacting, since
// the CLI worker is busy with an API call and won't emit messages.
if (!config?.viewerOnly) {
const timeoutMs = isCompactingRef.current
? COMPACTION_TIMEOUT_MS
: RESPONSE_TIMEOUT_MS
responseTimeoutRef.current = setTimeout(
(setMessages, manager) => {
logForDebugging(
'[useRemoteSession] Response timeout - attempting reconnect',
)
// Add a warning message to the conversation
const warningMessage = createSystemMessage(
'Remote session may be unresponsive. Attempting to reconnect…',
'warning',
)
setMessages(prev => [...prev, warningMessage])
// Attempt to reconnect the WebSocket - the subscription may have become stale
manager.reconnect()
},
timeoutMs,
setMessages,
manager,
)
}
return success
},
[config, setIsLoading, setMessages],
)
// Cancel the current request on the remote session
const cancelRequest = useCallback(() => {
// Clear any pending timeout
if (responseTimeoutRef.current) {
clearTimeout(responseTimeoutRef.current)
responseTimeoutRef.current = null
}
// Send interrupt signal to CCR. Skip in viewerOnly mode — Ctrl+C
// should never interrupt the remote agent.
if (!config?.viewerOnly) {
managerRef.current?.cancelSession()
}
setIsLoading(false)
}, [config, setIsLoading])
// Disconnect from the session
const disconnect = useCallback(() => {
// Clear any pending timeout
if (responseTimeoutRef.current) {
clearTimeout(responseTimeoutRef.current)
responseTimeoutRef.current = null
}
managerRef.current?.disconnect()
managerRef.current = null
}, [])
// All four fields are already stable (boolean derived from a prop that
// doesn't change mid-session, three useCallbacks with stable deps). The
// result object is consumed by REPL's onSubmit useCallback deps — without
// memoization the fresh literal invalidates onSubmit on every REPL render,
// which in turn churns PromptInput's props and downstream memoization.
return useMemo(
() => ({ isRemoteMode, sendMessage, cancelRequest, disconnect }),
[isRemoteMode, sendMessage, cancelRequest, disconnect],
)
}
+723
View File
@@ -0,0 +1,723 @@
import { feature } from 'bun:bundle';
import React, { useCallback, useEffect, useRef } from 'react';
import { setMainLoopModelOverride } from '../bootstrap/state.js';
import { type BridgePermissionCallbacks, type BridgePermissionResponse, isBridgePermissionResponse } from '../bridge/bridgePermissionCallbacks.js';
import { buildBridgeConnectUrl } from '../bridge/bridgeStatusUtil.js';
import { extractInboundMessageFields } from '../bridge/inboundMessages.js';
import type { BridgeState, ReplBridgeHandle } from '../bridge/replBridge.js';
import { setReplBridgeHandle } from '../bridge/replBridgeHandle.js';
import type { Command } from '../commands.js';
import { getSlashCommandToolSkills, isBridgeSafeCommand } from '../commands.js';
import { getRemoteSessionUrl } from '../constants/product.js';
import { useNotifications } from '../context/notifications.js';
import type { PermissionMode, SDKMessage } from '../entrypoints/agentSdkTypes.js';
import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js';
import { Text } from '../ink.js';
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js';
import { useAppState, useAppStateStore, useSetAppState } from '../state/AppState.js';
import type { Message } from '../types/message.js';
import { getCwd } from '../utils/cwd.js';
import { logForDebugging } from '../utils/debug.js';
import { errorMessage } from '../utils/errors.js';
import { enqueue } from '../utils/messageQueueManager.js';
import { buildSystemInitMessage } from '../utils/messages/systemInit.js';
import { createBridgeStatusMessage, createSystemMessage } from '../utils/messages.js';
import { getAutoModeUnavailableNotification, getAutoModeUnavailableReason, isAutoModeGateEnabled, isBypassPermissionsModeDisabled, transitionPermissionMode } from '../utils/permissions/permissionSetup.js';
import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js';
/** How long after a failure before replBridgeEnabled is auto-cleared (stops retries). */
export const BRIDGE_FAILURE_DISMISS_MS = 10_000;
/**
* Max consecutive initReplBridge failures before the hook stops re-attempting
* for the session lifetime. Guards against paths that flip replBridgeEnabled
* back on after auto-disable (settings sync, /remote-control, config tool)
* when the underlying OAuth is unrecoverable — each re-attempt is another
* guaranteed 401 against POST /v1/environments/bridge. Datadog 2026-03-08:
* top stuck client generated 2,879 × 401/day alone (17% of all 401s on the
* route).
*/
const MAX_CONSECUTIVE_INIT_FAILURES = 3;
/**
* Hook that initializes an always-on bridge connection in the background
* and writes new user/assistant messages to the bridge session.
*
* Silently skips if bridge is not enabled or user is not OAuth-authenticated.
*
* Watches AppState.replBridgeEnabled — when toggled off (via /config or footer),
* the bridge is torn down. When toggled back on, it re-initializes.
*
* Inbound messages from claude.ai are injected into the REPL via queuedCommands.
*/
export function useReplBridge(messages: Message[], setMessages: (action: React.SetStateAction<Message[]>) => void, abortControllerRef: React.RefObject<AbortController | null>, commands: readonly Command[], mainLoopModel: string): {
sendBridgeResult: () => void;
} {
const handleRef = useRef<ReplBridgeHandle | null>(null);
const teardownPromiseRef = useRef<Promise<void> | undefined>(undefined);
const lastWrittenIndexRef = useRef(0);
// Tracks UUIDs already flushed as initial messages. Persists across
// bridge reconnections so Bridge #2+ only sends new messages — sending
// duplicate UUIDs causes the server to kill the WebSocket.
const flushedUUIDsRef = useRef(new Set<string>());
const failureTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Persists across effect re-runs (unlike the effect's local state). Reset
// only on successful init. Hits MAX_CONSECUTIVE_INIT_FAILURES → fuse blown
// for the session, regardless of replBridgeEnabled re-toggling.
const consecutiveFailuresRef = useRef(0);
const setAppState = useSetAppState();
const commandsRef = useRef(commands);
commandsRef.current = commands;
const mainLoopModelRef = useRef(mainLoopModel);
mainLoopModelRef.current = mainLoopModel;
const messagesRef = useRef(messages);
messagesRef.current = messages;
const store = useAppStateStore();
const {
addNotification
} = useNotifications();
const replBridgeEnabled = feature('BRIDGE_MODE') ?
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAppState(s => s.replBridgeEnabled) : false;
const replBridgeConnected = feature('BRIDGE_MODE') ?
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAppState(s_0 => s_0.replBridgeConnected) : false;
const replBridgeOutboundOnly = feature('BRIDGE_MODE') ?
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAppState(s_1 => s_1.replBridgeOutboundOnly) : false;
const replBridgeInitialName = feature('BRIDGE_MODE') ?
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAppState(s_2 => s_2.replBridgeInitialName) : undefined;
// Initialize/teardown bridge when enabled state changes.
// Passes current messages as initialMessages so the remote session
// starts with the existing conversation context (e.g. from /bridge).
useEffect(() => {
// feature() check must use positive pattern for dead code elimination —
// negative pattern (if (!feature(...)) return) does NOT eliminate
// dynamic imports below.
if (feature('BRIDGE_MODE')) {
if (!replBridgeEnabled) return;
const outboundOnly = replBridgeOutboundOnly;
function notifyBridgeFailed(detail?: string): void {
if (outboundOnly) return;
addNotification({
key: 'bridge-failed',
jsx: <>
<Text color="error">Remote Control failed</Text>
{detail && <Text dimColor> · {detail}</Text>}
</>,
priority: 'immediate'
});
}
if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_INIT_FAILURES) {
logForDebugging(`[bridge:repl] Hook: ${consecutiveFailuresRef.current} consecutive init failures, not retrying this session`);
// Clear replBridgeEnabled so /remote-control doesn't mistakenly show
// BridgeDisconnectDialog for a bridge that never connected.
const fuseHint = 'disabled after repeated failures · restart to retry';
notifyBridgeFailed(fuseHint);
setAppState(prev => {
if (prev.replBridgeError === fuseHint && !prev.replBridgeEnabled) return prev;
return {
...prev,
replBridgeError: fuseHint,
replBridgeEnabled: false
};
});
return;
}
let cancelled = false;
// Capture messages.length now so we don't re-send initial messages
// through writeMessages after the bridge connects.
const initialMessageCount = messages.length;
void (async () => {
try {
// Wait for any in-progress teardown to complete before registering
// a new environment. Without this, the deregister HTTP call from
// the previous teardown races with the new register call, and the
// server may tear down the freshly-created environment.
if (teardownPromiseRef.current) {
logForDebugging('[bridge:repl] Hook: waiting for previous teardown to complete before re-init');
await teardownPromiseRef.current;
teardownPromiseRef.current = undefined;
logForDebugging('[bridge:repl] Hook: previous teardown complete, proceeding with re-init');
}
if (cancelled) return;
// Dynamic import so the module is tree-shaken in external builds
const {
initReplBridge
} = await import('../bridge/initReplBridge.js');
const {
shouldShowAppUpgradeMessage
} = await import('../bridge/envLessBridgeConfig.js');
// Assistant mode: perpetual bridge session — claude.ai shows one
// continuous conversation across CLI restarts instead of a new
// session per invocation. initBridgeCore reads bridge-pointer.json
// (the same crash-recovery file #20735 added) and reuses its
// {environmentId, sessionId} via reuseEnvironmentId +
// api.reconnectSession(). Teardown skips archive/deregister/
// pointer-clear so the session survives clean exits, not just
// crashes. Non-assistant bridges clear the pointer on teardown
// (crash-recovery only).
let perpetual = false;
if (feature('KAIROS')) {
const {
isAssistantMode
} = await import('../assistant/index.js');
perpetual = isAssistantMode();
}
// When a user message arrives from claude.ai, inject it into the REPL.
// Preserves the original UUID so that when the message is forwarded
// back to CCR, it matches the original — avoiding duplicate messages.
//
// Async because file_attachments (if present) need a network fetch +
// disk write before we enqueue with the @path prefix. Caller doesn't
// await — messages with attachments just land in the queue slightly
// later, which is fine (web messages aren't rapid-fire).
async function handleInboundMessage(msg: SDKMessage): Promise<void> {
try {
const fields = extractInboundMessageFields(msg);
if (!fields) return;
const {
uuid
} = fields;
// Dynamic import keeps the bridge code out of non-BRIDGE_MODE builds.
const {
resolveAndPrepend
} = await import('../bridge/inboundAttachments.js');
let sanitized = fields.content;
if (feature('KAIROS_GITHUB_WEBHOOKS')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const {
sanitizeInboundWebhookContent
} = require('../bridge/webhookSanitizer.js') as typeof import('../bridge/webhookSanitizer.js');
/* eslint-enable @typescript-eslint/no-require-imports */
sanitized = sanitizeInboundWebhookContent(fields.content);
}
const content = await resolveAndPrepend(msg, sanitized);
const preview = typeof content === 'string' ? content.slice(0, 80) : `[${content.length} content blocks]`;
logForDebugging(`[bridge:repl] Injecting inbound user message: ${preview}${uuid ? ` uuid=${uuid}` : ''}`);
enqueue({
value: content,
mode: 'prompt' as const,
uuid,
// skipSlashCommands stays true as defense-in-depth —
// processUserInputBase overrides it internally when bridgeOrigin
// is set AND the resolved command passes isBridgeSafeCommand.
// This keeps exit-word suppression and immediate-command blocks
// intact for any code path that checks skipSlashCommands directly.
skipSlashCommands: true,
bridgeOrigin: true
});
} catch (e) {
logForDebugging(`[bridge:repl] handleInboundMessage failed: ${e}`, {
level: 'error'
});
}
}
// State change callback — maps bridge lifecycle events to AppState.
function handleStateChange(state: BridgeState, detail_0?: string): void {
if (cancelled) return;
if (outboundOnly) {
logForDebugging(`[bridge:repl] Mirror state=${state}${detail_0 ? ` detail=${detail_0}` : ''}`);
// Sync replBridgeConnected so the forwarding effect starts/stops
// writing as the transport comes up or dies.
if (state === 'failed') {
setAppState(prev_3 => {
if (!prev_3.replBridgeConnected) return prev_3;
return {
...prev_3,
replBridgeConnected: false
};
});
} else if (state === 'ready' || state === 'connected') {
setAppState(prev_4 => {
if (prev_4.replBridgeConnected) return prev_4;
return {
...prev_4,
replBridgeConnected: true
};
});
}
return;
}
const handle = handleRef.current;
switch (state) {
case 'ready':
setAppState(prev_9 => {
const connectUrl = handle && handle.environmentId !== '' ? buildBridgeConnectUrl(handle.environmentId, handle.sessionIngressUrl) : prev_9.replBridgeConnectUrl;
const sessionUrl = handle ? getRemoteSessionUrl(handle.bridgeSessionId, handle.sessionIngressUrl) : prev_9.replBridgeSessionUrl;
const envId = handle?.environmentId;
const sessionId = handle?.bridgeSessionId;
if (prev_9.replBridgeConnected && !prev_9.replBridgeSessionActive && !prev_9.replBridgeReconnecting && prev_9.replBridgeConnectUrl === connectUrl && prev_9.replBridgeSessionUrl === sessionUrl && prev_9.replBridgeEnvironmentId === envId && prev_9.replBridgeSessionId === sessionId) {
return prev_9;
}
return {
...prev_9,
replBridgeConnected: true,
replBridgeSessionActive: false,
replBridgeReconnecting: false,
replBridgeConnectUrl: connectUrl,
replBridgeSessionUrl: sessionUrl,
replBridgeEnvironmentId: envId,
replBridgeSessionId: sessionId,
replBridgeError: undefined
};
});
break;
case 'connected':
{
setAppState(prev_8 => {
if (prev_8.replBridgeSessionActive) return prev_8;
return {
...prev_8,
replBridgeConnected: true,
replBridgeSessionActive: true,
replBridgeReconnecting: false,
replBridgeError: undefined
};
});
// Send system/init so remote clients (web/iOS/Android) get
// session metadata. REPL uses query() directly — never hits
// QueryEngine's SDKMessage layer — so this is the only path
// to put system/init on the REPL-bridge wire. Skills load is
// async (memoized, cheap after REPL startup); fire-and-forget
// so the connected-state transition isn't blocked.
if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_system_init', false)) {
void (async () => {
try {
const skills = await getSlashCommandToolSkills(getCwd());
if (cancelled) return;
const state_0 = store.getState();
handleRef.current?.writeSdkMessages([buildSystemInitMessage({
// tools/mcpClients/plugins redacted for REPL-bridge:
// MCP-prefixed tool names and server names leak which
// integrations the user has wired up; plugin paths leak
// raw filesystem paths (username, project structure).
// CCR v2 persists SDK messages to Spanner — users who
// tap "Connect from phone" may not expect these on
// Anthropic's servers. QueryEngine (SDK) still emits
// full lists — SDK consumers expect full telemetry.
tools: [],
mcpClients: [],
model: mainLoopModelRef.current,
permissionMode: state_0.toolPermissionContext.mode as PermissionMode,
// TODO: avoid the cast
// Remote clients can only invoke bridge-safe commands —
// advertising unsafe ones (local-jsx, unallowed local)
// would let mobile/web attempt them and hit errors.
commands: commandsRef.current.filter(isBridgeSafeCommand),
agents: state_0.agentDefinitions.activeAgents,
skills,
plugins: [],
fastMode: state_0.fastMode
})]);
} catch (err_0) {
logForDebugging(`[bridge:repl] Failed to send system/init: ${errorMessage(err_0)}`, {
level: 'error'
});
}
})();
}
break;
}
case 'reconnecting':
setAppState(prev_7 => {
if (prev_7.replBridgeReconnecting) return prev_7;
return {
...prev_7,
replBridgeReconnecting: true,
replBridgeSessionActive: false
};
});
break;
case 'failed':
// Clear any previous failure dismiss timer
clearTimeout(failureTimeoutRef.current);
notifyBridgeFailed(detail_0);
setAppState(prev_5 => ({
...prev_5,
replBridgeError: detail_0,
replBridgeReconnecting: false,
replBridgeSessionActive: false,
replBridgeConnected: false
}));
// Auto-disable after timeout so the hook stops retrying.
failureTimeoutRef.current = setTimeout(() => {
if (cancelled) return;
failureTimeoutRef.current = undefined;
setAppState(prev_6 => {
if (!prev_6.replBridgeError) return prev_6;
return {
...prev_6,
replBridgeEnabled: false,
replBridgeError: undefined
};
});
}, BRIDGE_FAILURE_DISMISS_MS);
break;
}
}
// Map of pending bridge permission response handlers, keyed by request_id.
// Each entry is an onResponse handler waiting for CCR to reply.
const pendingPermissionHandlers = new Map<string, (response: BridgePermissionResponse) => void>();
// Dispatch incoming control_response messages to registered handlers
function handlePermissionResponse(msg_0: SDKControlResponse): void {
const requestId = msg_0.response?.request_id;
if (!requestId) return;
const handler = pendingPermissionHandlers.get(requestId);
if (!handler) {
logForDebugging(`[bridge:repl] No handler for control_response request_id=${requestId}`);
return;
}
pendingPermissionHandlers.delete(requestId);
// Extract the permission decision from the control_response payload
const inner = msg_0.response;
if (inner.subtype === 'success' && inner.response && isBridgePermissionResponse(inner.response)) {
handler(inner.response);
}
}
const handle_0 = await initReplBridge({
outboundOnly,
tags: outboundOnly ? ['ccr-mirror'] : undefined,
onInboundMessage: handleInboundMessage,
onPermissionResponse: handlePermissionResponse,
onInterrupt() {
abortControllerRef.current?.abort();
},
onSetModel(model) {
const resolved = model === 'default' ? null : model ?? null;
setMainLoopModelOverride(resolved);
setAppState(prev_10 => {
if (prev_10.mainLoopModelForSession === resolved) return prev_10;
return {
...prev_10,
mainLoopModelForSession: resolved
};
});
},
onSetMaxThinkingTokens(maxTokens) {
const enabled = maxTokens !== null;
setAppState(prev_11 => {
if (prev_11.thinkingEnabled === enabled) return prev_11;
return {
...prev_11,
thinkingEnabled: enabled
};
});
},
onSetPermissionMode(mode) {
// Policy guards MUST fire before transitionPermissionMode —
// its internal auto-gate check is a defensive throw (with a
// setAutoModeActive(true) side-effect BEFORE the throw) rather
// than a graceful reject. Letting that throw escape would:
// (1) leave STATE.autoModeActive=true while the mode is
// unchanged (3-way invariant violation per src/CLAUDE.md)
// (2) fail to send a control_response → server kills WS
// These mirror print.ts handleSetPermissionMode; the bridge
// can't import the checks directly (bootstrap-isolation), so
// it relies on this verdict to emit the error response.
if (mode === 'bypassPermissions') {
if (isBypassPermissionsModeDisabled()) {
return {
ok: false,
error: 'Cannot set permission mode to bypassPermissions because it is disabled by settings or configuration'
};
}
if (!store.getState().toolPermissionContext.isBypassPermissionsModeAvailable) {
return {
ok: false,
error: 'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions'
};
}
}
if (feature('TRANSCRIPT_CLASSIFIER') && mode === 'auto' && !isAutoModeGateEnabled()) {
const reason = getAutoModeUnavailableReason();
return {
ok: false,
error: reason ? `Cannot set permission mode to auto: ${getAutoModeUnavailableNotification(reason)}` : 'Cannot set permission mode to auto'
};
}
// Guards passed — apply via the centralized transition so
// prePlanMode stashing and auto-mode state sync all fire.
setAppState(prev_12 => {
const current = prev_12.toolPermissionContext.mode;
if (current === mode) return prev_12;
const next = transitionPermissionMode(current, mode, prev_12.toolPermissionContext);
return {
...prev_12,
toolPermissionContext: {
...next,
mode
}
};
});
// Recheck queued permission prompts now that mode changed.
setImmediate(() => {
getLeaderToolUseConfirmQueue()?.(currentQueue => {
currentQueue.forEach(item => {
void item.recheckPermission();
});
return currentQueue;
});
});
return {
ok: true
};
},
onStateChange: handleStateChange,
initialMessages: messages.length > 0 ? messages : undefined,
getMessages: () => messagesRef.current,
previouslyFlushedUUIDs: flushedUUIDsRef.current,
initialName: replBridgeInitialName,
perpetual
});
if (cancelled) {
// Effect was cancelled while initReplBridge was in flight.
// Tear down the handle to avoid leaking resources (poll loop,
// WebSocket, registered environment, cleanup callback).
logForDebugging(`[bridge:repl] Hook: init cancelled during flight, tearing down${handle_0 ? ` env=${handle_0.environmentId}` : ''}`);
if (handle_0) {
void handle_0.teardown();
}
return;
}
if (!handle_0) {
// initReplBridge returned null — a precondition failed. For most
// cases (no_oauth, policy_denied, etc.) onStateChange('failed')
// already fired with a specific hint. The GrowthBook-gate-off case
// is intentionally silent — not a failure, just not rolled out.
consecutiveFailuresRef.current++;
logForDebugging(`[bridge:repl] Init returned null (precondition or session creation failed); consecutive failures: ${consecutiveFailuresRef.current}`);
clearTimeout(failureTimeoutRef.current);
setAppState(prev_13 => ({
...prev_13,
replBridgeError: prev_13.replBridgeError ?? 'check debug logs for details'
}));
failureTimeoutRef.current = setTimeout(() => {
if (cancelled) return;
failureTimeoutRef.current = undefined;
setAppState(prev_14 => {
if (!prev_14.replBridgeError) return prev_14;
return {
...prev_14,
replBridgeEnabled: false,
replBridgeError: undefined
};
});
}, BRIDGE_FAILURE_DISMISS_MS);
return;
}
handleRef.current = handle_0;
setReplBridgeHandle(handle_0);
consecutiveFailuresRef.current = 0;
// Skip initial messages in the forwarding effect — they were
// already loaded as session events during creation.
lastWrittenIndexRef.current = initialMessageCount;
if (outboundOnly) {
setAppState(prev_15 => {
if (prev_15.replBridgeConnected && prev_15.replBridgeSessionId === handle_0.bridgeSessionId) return prev_15;
return {
...prev_15,
replBridgeConnected: true,
replBridgeSessionId: handle_0.bridgeSessionId,
replBridgeSessionUrl: undefined,
replBridgeConnectUrl: undefined,
replBridgeError: undefined
};
});
logForDebugging(`[bridge:repl] Mirror initialized, session=${handle_0.bridgeSessionId}`);
} else {
// Build bridge permission callbacks so the interactive permission
// handler can race bridge responses against local user interaction.
const permissionCallbacks: BridgePermissionCallbacks = {
sendRequest(requestId_0, toolName, input, toolUseId, description, permissionSuggestions, blockedPath) {
handle_0.sendControlRequest({
type: 'control_request',
request_id: requestId_0,
request: {
subtype: 'can_use_tool',
tool_name: toolName,
input,
tool_use_id: toolUseId,
description,
...(permissionSuggestions ? {
permission_suggestions: permissionSuggestions
} : {}),
...(blockedPath ? {
blocked_path: blockedPath
} : {})
}
});
},
sendResponse(requestId_1, response) {
const payload: Record<string, unknown> = {
...response
};
handle_0.sendControlResponse({
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId_1,
response: payload
}
});
},
cancelRequest(requestId_2) {
handle_0.sendControlCancelRequest(requestId_2);
},
onResponse(requestId_3, handler_0) {
pendingPermissionHandlers.set(requestId_3, handler_0);
return () => {
pendingPermissionHandlers.delete(requestId_3);
};
}
};
setAppState(prev_16 => ({
...prev_16,
replBridgePermissionCallbacks: permissionCallbacks
}));
const url = getRemoteSessionUrl(handle_0.bridgeSessionId, handle_0.sessionIngressUrl);
// environmentId === '' signals the v2 env-less path. buildBridgeConnectUrl
// builds an env-specific connect URL, which doesn't exist without an env.
const hasEnv = handle_0.environmentId !== '';
const connectUrl_0 = hasEnv ? buildBridgeConnectUrl(handle_0.environmentId, handle_0.sessionIngressUrl) : undefined;
setAppState(prev_17 => {
if (prev_17.replBridgeConnected && prev_17.replBridgeSessionUrl === url) {
return prev_17;
}
return {
...prev_17,
replBridgeConnected: true,
replBridgeSessionUrl: url,
replBridgeConnectUrl: connectUrl_0 ?? prev_17.replBridgeConnectUrl,
replBridgeEnvironmentId: handle_0.environmentId,
replBridgeSessionId: handle_0.bridgeSessionId,
replBridgeError: undefined
};
});
// Show bridge status with URL in the transcript. perpetual (KAIROS
// assistant mode) falls back to v1 at initReplBridge.ts — skip the
// v2-only upgrade nudge for them. Own try/catch so a cosmetic
// GrowthBook hiccup doesn't hit the outer init-failure handler.
const upgradeNudge = !perpetual ? await shouldShowAppUpgradeMessage().catch(() => false) : false;
if (cancelled) return;
setMessages(prev_18 => [...prev_18, createBridgeStatusMessage(url, upgradeNudge ? 'Please upgrade to the latest version of the Claude mobile app to see your Remote Control sessions.' : undefined)]);
logForDebugging(`[bridge:repl] Hook initialized, session=${handle_0.bridgeSessionId}`);
}
} catch (err) {
// Never crash the REPL — surface the error in the UI.
// Check cancelled first (symmetry with the !handle path at line ~386):
// if initReplBridge threw during rapid toggle-off (in-flight network
// error), don't count that toward the fuse or spam a stale error
// into the UI. Also fixes pre-existing spurious setAppState/
// setMessages on cancelled throws.
if (cancelled) return;
consecutiveFailuresRef.current++;
const errMsg = errorMessage(err);
logForDebugging(`[bridge:repl] Init failed: ${errMsg}; consecutive failures: ${consecutiveFailuresRef.current}`);
clearTimeout(failureTimeoutRef.current);
notifyBridgeFailed(errMsg);
setAppState(prev_0 => ({
...prev_0,
replBridgeError: errMsg
}));
failureTimeoutRef.current = setTimeout(() => {
if (cancelled) return;
failureTimeoutRef.current = undefined;
setAppState(prev_1 => {
if (!prev_1.replBridgeError) return prev_1;
return {
...prev_1,
replBridgeEnabled: false,
replBridgeError: undefined
};
});
}, BRIDGE_FAILURE_DISMISS_MS);
if (!outboundOnly) {
setMessages(prev_2 => [...prev_2, createSystemMessage(`Remote Control failed to connect: ${errMsg}`, 'warning')]);
}
}
})();
return () => {
cancelled = true;
clearTimeout(failureTimeoutRef.current);
failureTimeoutRef.current = undefined;
if (handleRef.current) {
logForDebugging(`[bridge:repl] Hook cleanup: starting teardown for env=${handleRef.current.environmentId} session=${handleRef.current.bridgeSessionId}`);
teardownPromiseRef.current = handleRef.current.teardown();
handleRef.current = null;
setReplBridgeHandle(null);
}
setAppState(prev_19 => {
if (!prev_19.replBridgeConnected && !prev_19.replBridgeSessionActive && !prev_19.replBridgeError) {
return prev_19;
}
return {
...prev_19,
replBridgeConnected: false,
replBridgeSessionActive: false,
replBridgeReconnecting: false,
replBridgeConnectUrl: undefined,
replBridgeSessionUrl: undefined,
replBridgeEnvironmentId: undefined,
replBridgeSessionId: undefined,
replBridgeError: undefined,
replBridgePermissionCallbacks: undefined
};
});
lastWrittenIndexRef.current = 0;
};
}
}, [replBridgeEnabled, replBridgeOutboundOnly, setAppState, setMessages, addNotification]);
// Write new messages as they appear.
// Also re-runs when replBridgeConnected changes (bridge finishes init),
// so any messages that arrived before the bridge was ready get written.
useEffect(() => {
// Positive feature() guard — see first useEffect comment
if (feature('BRIDGE_MODE')) {
if (!replBridgeConnected) return;
const handle_1 = handleRef.current;
if (!handle_1) return;
// Clamp the index in case messages were compacted (array shortened).
// After compaction the ref could exceed messages.length, and without
// clamping no new messages would be forwarded.
if (lastWrittenIndexRef.current > messages.length) {
logForDebugging(`[bridge:repl] Compaction detected: lastWrittenIndex=${lastWrittenIndexRef.current} > messages.length=${messages.length}, clamping`);
}
const startIndex = Math.min(lastWrittenIndexRef.current, messages.length);
// Collect new messages since last write
const newMessages: Message[] = [];
for (let i = startIndex; i < messages.length; i++) {
const msg_1 = messages[i];
if (msg_1 && (msg_1.type === 'user' || msg_1.type === 'assistant' || msg_1.type === 'system' && msg_1.subtype === 'local_command')) {
newMessages.push(msg_1);
}
}
lastWrittenIndexRef.current = messages.length;
if (newMessages.length > 0) {
handle_1.writeMessages(newMessages);
}
}
}, [messages, replBridgeConnected]);
const sendBridgeResult = useCallback(() => {
if (feature('BRIDGE_MODE')) {
handleRef.current?.sendResult();
}
}, []);
return {
sendBridgeResult
};
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmZWF0dXJlIiwiUmVhY3QiLCJ1c2VDYWxsYmFjayIsInVzZUVmZmVjdCIsInVzZVJlZiIsInNldE1haW5Mb29wTW9kZWxPdmVycmlkZSIsIkJyaWRnZVBlcm1pc3Npb25DYWxsYmFja3MiLCJCcmlkZ2VQZXJtaXNzaW9uUmVzcG9uc2UiLCJpc0JyaWRnZVBlcm1pc3Npb25SZXNwb25zZSIsImJ1aWxkQnJpZGdlQ29ubmVjdFVybCIsImV4dHJhY3RJbmJvdW5kTWVzc2FnZUZpZWxkcyIsIkJyaWRnZVN0YXRlIiwiUmVwbEJyaWRnZUhhbmRsZSIsInNldFJlcGxCcmlkZ2VIYW5kbGUiLCJDb21tYW5kIiwiZ2V0U2xhc2hDb21tYW5kVG9vbFNraWxscyIsImlzQnJpZGdlU2FmZUNvbW1hbmQiLCJnZXRSZW1vdGVTZXNzaW9uVXJsIiwidXNlTm90aWZpY2F0aW9ucyIsIlBlcm1pc3Npb25Nb2RlIiwiU0RLTWVzc2FnZSIsIlNES0NvbnRyb2xSZXNwb25zZSIsIlRleHQiLCJnZXRGZWF0dXJlVmFsdWVfQ0FDSEVEX01BWV9CRV9TVEFMRSIsInVzZUFwcFN0YXRlIiwidXNlQXBwU3RhdGVTdG9yZSIsInVzZVNldEFwcFN0YXRlIiwiTWVzc2FnZSIsImdldEN3ZCIsImxvZ0ZvckRlYnVnZ2luZyIsImVycm9yTWVzc2FnZSIsImVucXVldWUiLCJidWlsZFN5c3RlbUluaXRNZXNzYWdlIiwiY3JlYXRlQnJpZGdlU3RhdHVzTWVzc2FnZSIsImNyZWF0ZVN5c3RlbU1lc3NhZ2UiLCJnZXRBdXRvTW9kZVVuYXZhaWxhYmxlTm90aWZpY2F0aW9uIiwiZ2V0QXV0b01vZGVVbmF2YWlsYWJsZVJlYXNvbiIsImlzQXV0b01vZGVHYXRlRW5hYmxlZCIsImlzQnlwYXNzUGVybWlzc2lvbnNNb2RlRGlzYWJsZWQiLCJ0cmFuc2l0aW9uUGVybWlzc2lvbk1vZGUiLCJnZXRMZWFkZXJUb29sVXNlQ29uZmlybVF1ZXVlIiwiQlJJREdFX0ZBSUxVUkVfRElTTUlTU19NUyIsIk1BWF9DT05TRUNVVElWRV9JTklUX0ZBSUxVUkVTIiwidXNlUmVwbEJyaWRnZSIsIm1lc3NhZ2VzIiwic2V0TWVzc2FnZXMiLCJhY3Rpb24iLCJTZXRTdGF0ZUFjdGlvbiIsImFib3J0Q29udHJvbGxlclJlZiIsIlJlZk9iamVjdCIsIkFib3J0Q29udHJvbGxlciIsImNvbW1hbmRzIiwibWFpbkxvb3BNb2RlbCIsInNlbmRCcmlkZ2VSZXN1bHQiLCJoYW5kbGVSZWYiLCJ0ZWFyZG93blByb21pc2VSZWYiLCJQcm9taXNlIiwidW5kZWZpbmVkIiwibGFzdFdyaXR0ZW5JbmRleFJlZiIsImZsdXNoZWRVVUlEc1JlZiIsIlNldCIsImZhaWx1cmVUaW1lb3V0UmVmIiwiUmV0dXJuVHlwZSIsInNldFRpbWVvdXQiLCJjb25zZWN1dGl2ZUZhaWx1cmVzUmVmIiwic2V0QXBwU3RhdGUiLCJjb21tYW5kc1JlZiIsImN1cnJlbnQiLCJtYWluTG9vcE1vZGVsUmVmIiwibWVzc2FnZXNSZWYiLCJzdG9yZSIsImFkZE5vdGlmaWNhdGlvbiIsInJlcGxCcmlkZ2VFbmFibGVkIiwicyIsInJlcGxCcmlkZ2VDb25uZWN0ZWQiLCJyZXBsQnJpZGdlT3V0Ym91bmRPbmx5IiwicmVwbEJyaWRnZUluaXRpYWxOYW1lIiwib3V0Ym91bmRPbmx5Iiwibm90aWZ5QnJpZGdlRmFpbGVkIiwiZGV0YWlsIiwia2V5IiwianN4IiwicHJpb3JpdHkiLCJmdXNlSGludCIsInByZXYiLCJyZXBsQnJpZGdlRXJyb3IiLCJjYW5jZWxsZWQiLCJpbml0aWFsTWVzc2FnZUNvdW50IiwibGVuZ3RoIiwiaW5pdFJlcGxCcmlkZ2UiLCJzaG91bGRTaG93QXBwVXBncmFkZU1lc3NhZ2UiLCJwZXJwZXR1YWwiLCJpc0Fzc2lzdGFudE1vZGUiLCJoYW5kbGVJbmJvdW5kTWVzc2FnZSIsIm1zZyIsImZpZWxkcyIsInV1aWQiLCJyZXNvbHZlQW5kUHJlcGVuZCIsInNhbml0aXplZCIsImNvbnRlbnQiLCJzYW5pdGl6ZUluYm91bmRXZWJob29rQ29udGVudCIsInJlcXVpcmUiLCJwcmV2aWV3Iiwic2xpY2UiLCJ2YWx1ZSIsIm1vZGUiLCJjb25zdCIsInNraXBTbGFzaENvbW1hbmRzIiwiYnJpZGdlT3JpZ2luIiwiZSIsImxldmVsIiwiaGFuZGxlU3RhdGVDaGFuZ2UiLCJzdGF0ZSIsImhhbmRsZSIsImNvbm5lY3RVcmwiLCJlbnZpcm9ubWVudElkIiwic2Vzc2lvbkluZ3Jlc3NVcmwiLCJyZXBsQnJpZGdlQ29ubmVjdFVybCIsInNlc3Npb25VcmwiLCJicmlkZ2VTZXNzaW9uSWQiLCJyZXBsQnJpZGdlU2Vzc2lvblVybCIsImVudklkIiwic2Vzc2lvbklkIiwicmVwbEJyaWRnZVNlc3Npb25BY3RpdmUiLCJyZXBsQnJpZGdlUmVjb25uZWN0aW5nIiwicmVwbEJyaWRnZUVudmlyb25tZW50SWQiLCJyZXBsQnJpZGdlU2Vzc2lvbklkIiwic2tpbGxzIiwiZ2V0U3RhdGUiLCJ3cml0ZVNka01lc3NhZ2VzIiwidG9vbHMiLCJtY3BDbGllbnRzIiwibW9kZWwiLCJwZXJtaXNzaW9uTW9kZSIsInRvb2xQZXJtaXNzaW9uQ29udGV4dCIsImZpbHRlciIsImFnZW50cyIsImFnZW50RGVmaW5pdGlvbnMiLCJhY3RpdmVBZ2VudHMiLCJwbHVnaW5zIiwiZmFzdE1vZGUiLCJlcnIiLCJjbGVhclRpbWVvdXQiLCJwZW5kaW5nUGVybWlzc2lvbkhhbmRsZXJzIiwiTWFwIiwicmVzcG9uc2UiLCJoYW5kbGVQZXJtaXNzaW9uUmVzcG9uc2UiLCJyZXF1ZXN0SWQiLCJyZXF1ZXN0X2lkIiwiaGFuZGxlciIsImdldCIsImRlbGV0ZSIsImlubmVyIiwic3VidHlwZSIsInRhZ3MiLCJvbkluYm91bmRNZXNzYWdlIiwib25QZXJtaXNzaW9uUmVzcG9uc2UiLCJvbkludGVycnVwdCIsImFib3J0Iiwib25TZXRNb2RlbCIsInJlc29sdmVkIiwibWFpbkxvb3BNb2RlbEZvclNlc3Npb24iLCJvblNldE1heFRoaW5raW5nVG9rZW5zIiwibWF4VG9rZW5zIiwiZW5hYmxlZCIsInRoaW5raW5nRW5hYmxlZCIsIm9uU2V0UGVybWlzc2lvbk1vZGUiLCJvayIsImVycm9yIiwiaXNCeXBhc3NQZXJtaXNzaW9uc01vZGVBdmFpbGFibGUiLCJyZWFzb24iLCJuZXh0Iiwic2V0SW1tZWRpYXRlIiwiY3VycmVudFF1ZXVlIiwiZm9yRWFjaCIsIml0ZW0iLCJyZWNoZWNrUGVybWlzc2lvbiIsIm9uU3RhdGVDaGFuZ2UiLCJpbml0aWFsTWVzc2FnZXMiLCJnZXRNZXNzYWdlcyIsInByZXZpb3VzbHlGbHVzaGVkVVVJRHMiLCJpbml0aWFsTmFtZSIsInRlYXJkb3duIiwicGVybWlzc2lvbkNhbGxiYWNrcyIsInNlbmRSZXF1ZXN0IiwidG9vbE5hbWUiLCJpbnB1dCIsInRvb2xVc2VJZCIsImRlc2NyaXB0aW9uIiwicGVybWlzc2lvblN1Z2dlc3Rpb25zIiwiYmxvY2tlZFBhdGgiLCJzZW5kQ29udHJvbFJlcXVlc3QiLCJ0eXBlIiwicmVxdWVzdCIsInRvb2xfbmFtZSIsInRvb2xfdXNlX2lkIiwicGVybWlzc2lvbl9zdWdnZXN0aW9ucyIsImJsb2NrZWRfcGF0aCIsInNlbmRSZXNwb25zZSIsInBheWxvYWQiLCJSZWNvcmQiLCJzZW5kQ29udHJvbFJlc3BvbnNlIiwiY2FuY2VsUmVxdWVzdCIsInNlbmRDb250cm9sQ2FuY2VsUmVxdWVzdCIsIm9uUmVzcG9uc2UiLCJzZXQiLCJyZXBsQnJpZGdlUGVybWlzc2lvbkNhbGxiYWNrcyIsInVybCIsImhhc0VudiIsInVwZ3JhZGVOdWRnZSIsImNhdGNoIiwiZXJyTXNnIiwic3RhcnRJbmRleCIsIk1hdGgiLCJtaW4iLCJuZXdNZXNzYWdlcyIsImkiLCJwdXNoIiwid3JpdGVNZXNzYWdlcyIsInNlbmRSZXN1bHQiXSwic291cmNlcyI6WyJ1c2VSZXBsQnJpZGdlLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgeyBmZWF0dXJlIH0gZnJvbSAnYnVuOmJ1bmRsZSdcbmltcG9ydCBSZWFjdCwgeyB1c2VDYWxsYmFjaywgdXNlRWZmZWN0LCB1c2VSZWYgfSBmcm9tICdyZWFjdCdcbmltcG9ydCB7IHNldE1haW5Mb29wTW9kZWxPdmVycmlkZSB9IGZyb20gJy4uL2Jvb3RzdHJhcC9zdGF0ZS5qcydcbmltcG9ydCB7XG4gIHR5cGUgQnJpZGdlUGVybWl Line truncated
+241
View File
@@ -0,0 +1,241 @@
/**
* REPL integration hook for `claude ssh` sessions.
*
* Sibling to useDirectConnect — same shape (isRemoteMode/sendMessage/
* cancelRequest/disconnect), same REPL wiring, but drives an SSH child
* process instead of a WebSocket. Kept separate rather than generalizing
* useDirectConnect because the lifecycle differs: the ssh process and auth
* proxy are created BEFORE this hook runs (during startup, in main.tsx) and
* handed in; useDirectConnect creates its WebSocket inside the effect.
*/
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)
}
// Skip duplicate init messages (one per turn from stream-json mode).
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
// Surface a transient system message in the transcript so the user
// knows what's happening — the next onConnected clears the state.
// Any in-flight request is lost; the remote's --continue reloads
// history but there's no turn in progress to resume.
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.'
// Surface remote stderr if it looks like an error (pre-connect always,
// post-connect only on nonzero exit — normal --verbose noise otherwise).
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],
)
}
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useRef } from 'react'
import { useAppStateStore, useSetAppState } from '../state/AppState.js'
import { isTerminalTaskStatus } from '../Task.js'
import {
findTeammateTaskByAgentId,
injectUserMessageToTeammate,
} from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'
import { isKairosCronEnabled } from '../tools/ScheduleCronTool/prompt.js'
import type { Message } from '../types/message.js'
import { getCronJitterConfig } from '../utils/cronJitterConfig.js'
import { createCronScheduler } from '../utils/cronScheduler.js'
import { removeCronTasks } from '../utils/cronTasks.js'
import { logForDebugging } from '../utils/debug.js'
import { enqueuePendingNotification } from '../utils/messageQueueManager.js'
import { createScheduledTaskFireMessage } from '../utils/messages.js'
import { WORKLOAD_CRON } from '../utils/workloadContext.js'
type Props = {
isLoading: boolean
/**
* When true, bypasses the isLoading gate so tasks can enqueue while a
* query is streaming rather than deferring to the next 1s check tick
* after the turn ends. Assistant mode no longer forces --proactive
* (#20425) so isLoading drops between turns like a normal REPL — this
* bypass is now a latency nicety, not a starvation fix. The prompt is
* enqueued at 'later' priority either way and drains between turns.
*/
assistantMode?: boolean
setMessages: React.Dispatch<React.SetStateAction<Message[]>>
}
/**
* REPL wrapper for the cron scheduler. Mounts the scheduler once and tears
* it down on unmount. Fired prompts go into the command queue as 'later'
* priority, which the REPL drains via useCommandQueue between turns.
*
* Scheduler core (timer, file watcher, fire logic) lives in cronScheduler.ts
* so SDK/-p mode can share it — see print.ts for the headless wiring.
*/
export function useScheduledTasks({
isLoading,
assistantMode = false,
setMessages,
}: Props): void {
// Latest-value ref so the scheduler's isLoading() getter doesn't capture
// a stale closure. The effect mounts once; isLoading changes every turn.
const isLoadingRef = useRef(isLoading)
isLoadingRef.current = isLoading
const store = useAppStateStore()
const setAppState = useSetAppState()
useEffect(() => {
// Runtime gate checked here (not at the hook call site) so the hook
// stays unconditionally mounted — rules-of-hooks forbid wrapping the
// call in a dynamic condition. getFeatureValue_CACHED_WITH_REFRESH
// reads from disk; the 5-min TTL fires a background refetch but the
// effect won't re-run on value flip (assistantMode is the only dep),
// so this guard alone is launch-grain. The mid-session killswitch is
// the isKilled option below — check() polls it every tick.
if (!isKairosCronEnabled()) return
// System-generated — hidden from queue preview and transcript UI.
// In brief mode, executeForkedSlashCommand runs as a background
// subagent and returns no visible messages. In normal mode,
// isMeta is only propagated for plain-text prompts (via
// processTextPrompt); slash commands like /context:fork do not
// forward isMeta, so their messages remain visible in the
// transcript. This is acceptable since normal mode is not the
// primary use case for scheduled tasks.
const enqueueForLead = (prompt: string) =>
enqueuePendingNotification({
value: prompt,
mode: 'prompt',
priority: 'later',
isMeta: true,
// Threaded through to cc_workload= in the billing-header
// attribution block so the API can serve cron-initiated requests
// at lower QoS when capacity is tight. No human is actively
// waiting on this response.
workload: WORKLOAD_CRON,
})
const scheduler = createCronScheduler({
// Missed-task surfacing (onFire fallback). Teammate crons are always
// session-only (durable:false) so they never appear in the missed list,
// which is populated from disk at scheduler startup — this path only
// handles team-lead durable crons.
onFire: enqueueForLead,
// Normal fires receive the full CronTask so we can route by agentId.
onFireTask: task => {
if (task.agentId) {
const teammate = findTeammateTaskByAgentId(
task.agentId,
store.getState().tasks,
)
if (teammate && !isTerminalTaskStatus(teammate.status)) {
injectUserMessageToTeammate(teammate.id, task.prompt, setAppState)
return
}
// Teammate is gone — clean up the orphaned cron so it doesn't keep
// firing into nowhere every tick. One-shots would auto-delete on
// fire anyway, but recurring crons would loop until auto-expiry.
logForDebugging(
`[ScheduledTasks] teammate ${task.agentId} gone, removing orphaned cron ${task.id}`,
)
void removeCronTasks([task.id])
return
}
const msg = createScheduledTaskFireMessage(
`Running scheduled task (${formatCronFireTime(new Date())})`,
)
setMessages(prev => [...prev, msg])
enqueueForLead(task.prompt)
},
isLoading: () => isLoadingRef.current,
assistantMode,
getJitterConfig: getCronJitterConfig,
isKilled: () => !isKairosCronEnabled(),
})
scheduler.start()
return () => scheduler.stop()
// assistantMode is stable for the session lifetime; store/setAppState are
// stable refs from useSyncExternalStore; setMessages is a stable useCallback.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assistantMode])
}
function formatCronFireTime(d: Date): string {
return d
.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
.replace(/,? at |, /, ' ')
.replace(/ ([AP]M)/, (_, ampm) => ampm.toLowerCase())
}
+364
View File
@@ -0,0 +1,364 @@
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 abandon (distinct from onExit = Enter commit). When
* provided: single-Esc calls this directly (no clear-first-then-exit
* two-press). When absent: current behavior — Esc clears non-empty
* query, exits on empty; Ctrl+C silently swallowed (no switch case). */
onCancel?: () => void
onExitUp?: () => void
columns?: number
passthroughCtrlKeys?: string[]
initialQuery?: string
/** Backspace (and ctrl+h) on empty query calls onCancel ?? onExit — the
* less/vim "delete past the /" convention. Dialogs that want Esc-only
* cancel set this false so a held backspace doesn't eject the user. */
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'
}
// Special key names that fall through the explicit handlers above the
// text-input branch (return/escape/arrows/home/end/tab/backspace/delete
// all early-return). Reject these so e.g. PageUp doesn't leak 'pageup'
// as literal text. The length>=1 check below is intentionally loose —
// batched input like stdin.write('abc') arrives as one multi-char e.key,
// matching the old useInput(input) behavior where cursor.insert(input)
// inserted the full chunk.
const UNHANDLED_SPECIAL_KEYS = new Set([
'pageup',
'pagedown',
'insert',
'wheelup',
'wheeldown',
'mouse',
'f1',
'f2',
'f3',
'f4',
'f5',
'f6',
'f7',
'f8',
'f9',
'f10',
'f11',
'f12',
])
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)
// Check passthrough ctrl keys
if (e.ctrl && passthroughCtrlKeys.includes(e.key.toLowerCase())) {
return
}
// Reset kill accumulation for non-kill keys
if (!isKillKey(e)) {
resetKillAccumulation()
}
// Reset yank state for non-yank keys
if (!isYankKey(e)) {
resetYankState()
}
// Exit conditions
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: kill word before
const { cursor: newCursor, killed } = cursor.deleteWordBefore()
pushToKillRing(killed, 'prepend')
setQueryState(newCursor.text)
setCursorOffset(newCursor.offset)
return
}
if (query.length === 0) {
// Backspace past the / — cancel (clear + snap back), not commit.
// less: same. vim: deletes the / and exits command mode.
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
}
// Arrow keys with modifiers (word jump)
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
}
// Plain arrow keys
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 key bindings
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':
// Cancel (abandon search). ctrl+g is less's cancel key. Only
// fires if onCancel provided — otherwise falls through and
// returns silently (11 call sites, most expect ctrl+c to no-op).
if (onCancel) {
onCancel()
return
}
}
return
}
// Meta key bindings
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: ignore
if (e.key === 'tab') {
return
}
// Regular character input. Accepts multi-char e.key so batched writes
// (stdin.write('abc') in tests, or paste outside bracketed-paste mode)
// insert the full chunk — matching the old useInput behavior.
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)
}
}
// Backward-compat bridge: existing consumers don't yet wire handleKeyDown
// to <Box onKeyDown>. Subscribe via useInput and adapt InputEvent →
// KeyboardEvent until all 11 call sites are migrated (separate PRs).
// TODO(onKeyDown-migration): remove once all consumers pass handleKeyDown.
useInput(
(_input, _key, event) => {
handleKeyDown(new KeyboardEvent(event.keypress))
},
{ isActive },
)
return { query, setQuery, cursorOffset, handleKeyDown }
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Hook for managing session backgrounding (Ctrl+B to background/foreground sessions).
*
* Handles:
* - Calling onBackgroundQuery to spawn a background task for the current query
* - Re-backgrounding foregrounded tasks
* - Syncing foregrounded task messages/state to main view
*/
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 = {
/** Call when user wants to background (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) {
// Re-background the foregrounded task
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,
])
// Sync foregrounded task's messages and loading state to the main view
useEffect(() => {
if (!foregroundedTaskId) {
// Reset when no foregrounded task
lastSyncedMessagesLengthRef.current = 0
return
}
if (!foregroundedTask || foregroundedTask.type !== 'local_agent') {
setAppState(prev => ({ ...prev, foregroundedTaskId: undefined }))
resetLoadingState()
lastSyncedMessagesLengthRef.current = 0
return
}
// Sync messages from background task to main view
// Only update if messages have actually changed to avoid redundant renders
const taskMessages = foregroundedTask.messages ?? []
if (taskMessages.length !== lastSyncedMessagesLengthRef.current) {
lastSyncedMessagesLengthRef.current = taskMessages.length
setMessages([...taskMessages])
}
if (foregroundedTask.status === 'running') {
// Check if the task was aborted (user pressed Escape)
const taskAbortController = foregroundedTask.abortController
if (taskAbortController?.signal.aborted) {
// Task was aborted - clear foregrounded state immediately
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)
// Set abort controller to the foregrounded task's controller for Escape handling
if (taskAbortController) {
setAbortController(taskAbortController)
}
} else {
// Task completed - restore to background and clear foregrounded view
setAppState(prev => {
const taskId = prev.foregroundedTaskId
if (!taskId) return prev
const task = prev.tasks[taskId]
if (!task) return { ...prev, foregroundedTaskId: undefined }
return {
...prev,
foregroundedTaskId: undefined,
tasks: { ...prev.tasks, [taskId]: { ...task, isBackgrounded: true } },
}
})
resetLoadingState()
setAbortController(null)
lastSyncedMessagesLengthRef.current = 0
}
}, [
foregroundedTaskId,
foregroundedTask,
setAppState,
setMessages,
setIsLoading,
resetLoadingState,
setAbortController,
])
return {
handleBackgroundSession,
}
}
+17
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)
}
+25
View File
@@ -0,0 +1,25 @@
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'
export function useSettingsChange(
onChange: (source: SettingSource, settings: SettingsJson) => void,
): void {
const handleChange = useCallback(
(source: SettingSource) => {
// Cache is already reset by the notifier (changeDetector.fanOut) —
// resetting here caused N-way thrashing with N subscribers: each
// cleared the cache, re-read from disk, then the next cleared again.
const newSettings = getSettings_DEPRECATED()
onChange(source, newSettings)
},
[onChange],
)
useEffect(
() => settingsChangeDetector.subscribe(handleChange),
[handleChange],
)
}
+105
View File
@@ -0,0 +1,105 @@
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
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)
// Track the suggestion for display even after clearing AppState
if (suggestion) {
lastSuggestionRef.current = suggestion
}
// Open when a new suggestion arrives
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 routes to the privileged skill_name BQ column.
// Unredacted names don't go in 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 routes to the privileged skill_name BQ column.
// Unredacted names don't go in 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',
),
])
},
)
}
// Close and clear
setIsOpen(false)
loggedAppearanceRef.current = false
setAppState(prev => {
if (!prev.skillImprovement.suggestion) return prev
return {
...prev,
skillImprovement: { suggestion: null },
}
})
},
[setAppState, setMessages],
)
return {
isOpen,
suggestion: lastSuggestionRef.current,
handleSelect,
}
}
+62
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'
/**
* Keep the commands list fresh across two triggers:
*
* 1. Skill file changes (watcher) — full cache clear + disk re-scan, since
* skill content changed on disk.
* 2. GrowthBook init/refresh — memo-only clear, since only `isEnabled()`
* predicates may have changed. Handles commands like /btw whose gate
* reads a flag that isn't in the disk cache yet on first session after
* a flag rename: getCommands() runs before GB init (main.tsx:2855 vs
* showSetupScreens at :3106), so the memoized list is baked with the
* default. Once init populates remoteEvalFeatureValues, re-filter.
*/
export function useSkillsChange(
cwd: string | undefined,
onCommandsChange: (commands: Command[]) => void,
): void {
const handleChange = useCallback(async () => {
if (!cwd) return
try {
// Clear all command caches to ensure fresh load
clearCommandsCache()
const commands = await getCommands(cwd)
onCommandsChange(commands)
} catch (error) {
// Errors during reload are non-fatal - log and continue
if (error instanceof Error) {
logError(error)
}
}
}, [cwd, onCommandsChange])
useEffect(() => skillChangeDetector.subscribe(handleChange), [handleChange])
const handleGrowthBookRefresh = useCallback(async () => {
if (!cwd) return
try {
clearCommandMemoizationCaches()
const commands = await getCommands(cwd)
onCommandsChange(commands)
} catch (error) {
if (error instanceof Error) {
logError(error)
}
}
}, [cwd, onCommandsChange])
useEffect(
() => onGrowthBookRefresh(handleGrowthBookRefresh),
[handleGrowthBookRefresh],
)
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Swarm Initialization Hook
*
* Initializes swarm features: teammate hooks and context.
* Handles both fresh spawns and resumed teammate sessions.
*
* This hook is conditionally loaded to allow dead code elimination when swarms are disabled.
*/
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
/**
* Hook that initializes swarm features when ENABLE_AGENT_SWARMS is true.
*
* Handles both:
* - Resumed teammate sessions (from --resume or /resume) where teamName/agentName
* are stored in transcript messages
* - Fresh spawns where context is read from environment variables
*/
export function useSwarmInitialization(
setAppState: SetAppState,
initialMessages: Message[] | undefined,
{ enabled = true }: { enabled?: boolean } = {},
): void {
useEffect(() => {
if (!enabled) return
if (isAgentSwarmsEnabled()) {
// Check if this is a resumed agent session (from --resume or /resume)
// Resumed sessions have teamName/agentName stored in transcript messages
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) {
// Resumed agent session - set up team context from stored info
initializeTeammateContextFromSession(setAppState, teamName, agentName)
// Get agentId from team file for hook initialization
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 {
// Fresh spawn or standalone session
// teamContext is already computed in main.tsx via computeInitialTeamContext()
// and included in initialState, so we only need to initialize hooks here
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])
}
+330
View File
@@ -0,0 +1,330 @@
/**
* Swarm Permission Poller Hook
*
* This hook polls for permission responses from the team leader when running
* as a worker agent in a swarm. When a response is received, it calls the
* appropriate callback (onAllow/onReject) to continue execution.
*
* This hook should be used in conjunction with the worker-side integration
* in useCanUseTool.ts, which creates pending requests that this hook monitors.
*/
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
/**
* Validate permissionUpdates from external sources (mailbox IPC, disk polling).
* Malformed entries from buggy/old teammate processes are filtered out rather
* than propagated unchecked into 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
}
/**
* Callback signature for handling permission responses
*/
export type PermissionResponseCallback = {
requestId: string
toolUseId: string
onAllow: (
updatedInput: Record<string, unknown> | undefined,
permissionUpdates: PermissionUpdate[],
feedback?: string,
) => void
onReject: (feedback?: string) => void
}
/**
* Registry for pending permission request callbacks
* This allows the poller to find and invoke the right callbacks when responses arrive
*/
type PendingCallbackRegistry = Map<string, PermissionResponseCallback>
// Module-level registry that persists across renders
const pendingCallbacks: PendingCallbackRegistry = new Map()
/**
* Register a callback for a pending permission request
* Called by useCanUseTool when a worker submits a permission request
*/
export function registerPermissionCallback(
callback: PermissionResponseCallback,
): void {
pendingCallbacks.set(callback.requestId, callback)
logForDebugging(
`[SwarmPermissionPoller] Registered callback for request ${callback.requestId}`,
)
}
/**
* Unregister a callback (e.g., when the request is resolved locally or times out)
*/
export function unregisterPermissionCallback(requestId: string): void {
pendingCallbacks.delete(requestId)
logForDebugging(
`[SwarmPermissionPoller] Unregistered callback for request ${requestId}`,
)
}
/**
* Check if a request has a registered callback
*/
export function hasPermissionCallback(requestId: string): boolean {
return pendingCallbacks.has(requestId)
}
/**
* Clear all pending callbacks (both permission and sandbox).
* Called from clearSessionCaches() on /clear to reset stale state,
* and also used in tests for isolation.
*/
export function clearAllPendingCallbacks(): void {
pendingCallbacks.clear()
pendingSandboxCallbacks.clear()
}
/**
* Process a permission response from a mailbox message.
* This is called by the inbox poller when it detects a permission_response message.
*
* @returns true if the response was processed, false if no callback was registered
*/
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}`,
)
// Remove from registry before invoking callback
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
}
// ============================================================================
// Sandbox Permission Callback Registry
// ============================================================================
/**
* Callback signature for handling sandbox permission responses
*/
export type SandboxPermissionResponseCallback = {
requestId: string
host: string
resolve: (allow: boolean) => void
}
// Module-level registry for sandbox permission callbacks
const pendingSandboxCallbacks: Map<string, SandboxPermissionResponseCallback> =
new Map()
/**
* Register a callback for a pending sandbox permission request
* Called when a worker sends a sandbox permission request to the leader
*/
export function registerSandboxPermissionCallback(
callback: SandboxPermissionResponseCallback,
): void {
pendingSandboxCallbacks.set(callback.requestId, callback)
logForDebugging(
`[SwarmPermissionPoller] Registered sandbox callback for request ${callback.requestId}`,
)
}
/**
* Check if a sandbox request has a registered callback
*/
export function hasSandboxPermissionCallback(requestId: string): boolean {
return pendingSandboxCallbacks.has(requestId)
}
/**
* Process a sandbox permission response from a mailbox message.
* Called by the inbox poller when it detects a sandbox_permission_response message.
*
* @returns true if the response was processed, false if no callback was registered
*/
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}`,
)
// Remove from registry before invoking callback
pendingSandboxCallbacks.delete(params.requestId)
// Resolve the promise with the allow decision
callback.resolve(params.allow)
return true
}
/**
* Process a permission response by invoking the registered callback
*/
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}`,
)
// Remove from registry before invoking callback
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
}
/**
* Hook that polls for permission responses when running as a swarm worker.
*
* This hook:
* 1. Only activates when isSwarmWorker() returns true
* 2. Polls every 500ms for responses
* 3. When a response is found, invokes the registered callback
* 4. Cleans up the response file after processing
*/
export function useSwarmPermissionPoller(): void {
const isProcessingRef = useRef(false)
const poll = useCallback(async () => {
// Don't poll if not a swarm worker
if (!isSwarmWorker()) {
return
}
// Prevent concurrent polling
if (isProcessingRef.current) {
return
}
// Don't poll if no callbacks are registered
if (pendingCallbacks.size === 0) {
return
}
isProcessingRef.current = true
try {
const agentName = getAgentName()
const teamName = getTeamName()
if (!agentName || !teamName) {
return
}
// Check each pending request for a response
for (const [requestId, _callback] of pendingCallbacks) {
const response = await pollForResponse(requestId, agentName, teamName)
if (response) {
// Process the response
const processed = processResponse(response)
if (processed) {
// Clean up the response from the worker's inbox
await removeWorkerResponse(requestId, agentName, teamName)
}
}
}
} catch (error) {
logForDebugging(
`[SwarmPermissionPoller] Error during poll: ${errorMessage(error)}`,
)
} finally {
isProcessingRef.current = false
}
}, [])
// Only poll if we're a swarm worker
const shouldPoll = isSwarmWorker()
useInterval(() => void poll(), shouldPoll ? POLL_INTERVAL_MS : null)
// Initial poll on mount
useEffect(() => {
if (isSwarmWorker()) {
void poll()
}
}, [poll])
}
+221
View File
@@ -0,0 +1,221 @@
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 = {
/** When undefined, the hook does nothing. The task list id is also used as the agent ID. */
taskListId?: string
isLoading: boolean
/**
* Called when a task is ready to be worked on.
* Returns true if submission succeeded, false if rejected.
*/
onSubmitTask: (prompt: string) => boolean
}
/**
* Hook that watches a task list directory and automatically picks up
* open, unowned tasks to work on.
*
* This enables "tasks mode" where Claude watches for externally-created
* tasks and processes them one at a time.
*/
export function useTaskListWatcher({
taskListId,
isLoading,
onSubmitTask,
}: Props): void {
const currentTaskRef = useRef<string | null>(null)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Stabilize unstable props via refs so the watcher effect doesn't depend on
// them. isLoading flips every turn, and onSubmitTask's identity changes
// whenever onQuery's deps change. Without this, the watcher effect re-runs
// on every turn, calling watcher.close() + watch() each time — which is a
// trigger for Bun's PathWatcherManager deadlock (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 reads isLoading and onSubmitTask from refs — always
// up-to-date, no stale closure, and doesn't force a new function identity
// per render. Stored in a ref so the watcher effect can call it without
// depending on it.
const checkForTasksRef = useRef<() => Promise<void>>(async () => {})
checkForTasksRef.current = async () => {
if (!enabled) {
return
}
// Don't need to submit new tasks if we are already working
if (isLoadingRef.current) {
return
}
const tasks = await listTasks(taskListId)
// If we have a current task, check if it's been resolved
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 {
// Still working on current task
return
}
}
// Find an open task with no owner that isn't blocked
const availableTask = findAvailableTask(tasks)
if (!availableTask) {
return
}
logForDebugging(
`[TaskListWatcher] Found available task #${availableTask.id}: ${availableTask.subject}`,
)
// Claim the task using the task list's agent 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
// Format the task as a 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`,
)
// Release the claim
await updateTask(taskListId, availableTask.id, { owner: undefined })
currentTaskRef.current = null
}
}
// -- Watcher setup
// Schedules a check after DEBOUNCE_MS, collapsing rapid fs events.
// Shared between the watcher callback and the idle-trigger effect below.
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 throws synchronously on ENOENT — ensureTasksDir should have
// created the dir, but handle the race gracefully
logForDebugging(`[TaskListWatcher] Failed to watch ${tasksDir}: ${error}`)
}
// Initial check
debouncedCheck()
return () => {
// This cleanup only fires when taskListId changes or on unmount —
// never per-turn. That keeps watcher.close() out of the Bun
// PathWatcherManager deadlock window.
scheduleCheckRef.current = () => {}
if (watcher) {
watcher.close()
}
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
}
}, [enabled, taskListId])
// Previously, the watcher effect depended on checkForTasks (and transitively
// isLoading), so going idle triggered a re-setup whose initial debouncedCheck
// would pick up the next task. Preserve that behavior explicitly: when
// isLoading drops, schedule a check.
useEffect(() => {
if (!enabled) return
if (isLoading) return
scheduleCheckRef.current()
}, [enabled, isLoading])
}
/**
* Find an available task that can be worked on:
* - Status is 'pending'
* - No owner assigned
* - Not blocked by any unresolved tasks
*/
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
// Check all blockers are completed
return task.blockedBy.every(id => !unresolvedTaskIds.has(id))
})
}
/**
* Format a task as a prompt for Claude to work on.
*/
function formatTaskAsPrompt(task: Task): string {
let prompt = `Complete all open tasks. Start with task #${task.id}: \n\n ${task.subject}`
if (task.description) {
prompt += `\n\n${task.description}`
}
return prompt
}
+250
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 // Fallback in case fs.watch misses events
/**
* Singleton store for the TodoV2 task list. Owns the file watcher, timers,
* and cached task list. Multiple hook instances (REPL, Spinner,
* PromptInputFooterLeftSide) subscribe to one shared store instead of each
* setting up their own fs.watch on the same directory. The Spinner mounts/
* unmounts every turn — per-hook watchers caused constant watch/unwatch churn.
*
* Implements the useSyncExternalStore contract: subscribe/getSnapshot.
*/
class TasksV2Store {
/** Stable array reference; replaced only on fetch. undefined until started. */
#tasks: Task[] | undefined = undefined
/**
* Set when the hide timer has elapsed (all tasks completed for >5s), or
* when the task list is empty. Starts false so the first fetch runs the
* "all completed → schedule 5s hide" path (matches original behavior:
* resuming a session with completed tasks shows them briefly).
*/
#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 snapshot. Returns the same Task[] reference between
* updates (required for Object.is stability). Returns undefined when hidden.
*/
getSnapshot = (): Task[] | undefined => {
return this.#hidden ? undefined : this.#tasks
}
subscribe = (fn: () => void): (() => void) => {
// Lazy init on first subscriber. useSyncExternalStore calls this
// post-commit, so I/O here is safe (no render-phase side effects).
// REPL.tsx keeps a subscription alive for the whole session, so
// Spinner mount/unmount churn never drives the count to zero.
const unsubscribe = this.#changed.subscribe(fn)
this.#subscriberCount++
if (!this.#started) {
this.#started = true
this.#unsubscribeTasksUpdated = onTasksUpdated(this.#debouncedFetch)
// Fire-and-forget: subscribe is called post-commit (not in render),
// and the store notifies subscribers when the fetch resolves.
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()
}
/**
* Point the file watcher at the current tasks directory. Called on start
* and whenever #fetch detects the task list ID has changed (e.g. when
* TeamCreateTool sets leaderTeamName mid-session).
*/
#rewatch(dir: string): void {
// Retry even on same dir if the previous watch attempt failed (dir
// didn't exist yet). Once the watcher is established, same-dir is a no-op.
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 {
// Directory may not exist yet (ensureTasksDir is called by writers).
// Not critical — onTasksUpdated covers in-process updates and the
// poll timer covers cross-process updates.
}
}
#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()
// Task list ID can change mid-session (TeamCreateTool sets
// leaderTeamName) — point the watcher at the current dir.
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) {
// Has unresolved tasks (open/in_progress) or empty — reset hide state
this.#hidden = current.length === 0
this.#clearHideTimer()
} else if (this.#hideTimer === null && !this.#hidden) {
// All tasks just became completed — schedule clear
this.#hideTimer = setTimeout(
this.#onHideTimerFired.bind(this, taskListId),
HIDE_DELAY_MS,
)
this.#hideTimer.unref()
}
this.#notify()
// Schedule fallback poll only when there are incomplete tasks that
// need monitoring. When all tasks are completed (or there are none),
// the fs.watch watcher and onTasksUpdated callback are sufficient to
// detect new activity — no need to keep polling and re-rendering.
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
// Bail if the task list ID changed since scheduling (team created/deleted
// during the 5s window) — don't reset the wrong list.
const currentId = getTaskListId()
if (currentId !== scheduledForTaskListId) return
// Verify all tasks are still completed before clearing
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
}
}
/**
* Tear down the watcher, timers, and in-process subscription. Called when
* the last subscriber unsubscribes. Preserves #tasks/#hidden cache so a
* subsequent re-subscribe renders the last known state immediately.
*/
#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())
}
// Stable no-ops for the disabled path so useSyncExternalStore doesn't
// churn its subscription on every render.
const NOOP = (): void => {}
const NOOP_SUBSCRIBE = (): (() => void) => NOOP
const NOOP_SNAPSHOT = (): undefined => undefined
/**
* Hook to get the current task list for the persistent UI display.
* Returns tasks when TodoV2 is enabled, otherwise returns undefined.
* All hook instances share a single file watcher via TasksV2Store.
* Hides the list after 5 seconds if there are no open tasks.
*/
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,
)
}
/**
* Same as useTasksV2, plus collapses the expanded task view when the list
* becomes hidden. Call this from exactly one always-mounted component (REPL)
* so the collapse effect runs once instead of N× per consumer.
*/
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
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect } from 'react'
import { useAppState, useSetAppState } from '../state/AppState.js'
import { exitTeammateView } from '../state/teammateViewHelpers.js'
import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js'
/**
* Auto-exits teammate viewing mode when the viewed teammate
* is killed or encounters an error. Users stay viewing completed
* teammates so they can review the full transcript.
*/
export function useTeammateViewAutoExit(): void {
const setAppState = useSetAppState()
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
// Select only the viewed task, not the full tasks map — otherwise every
// streaming update from any teammate re-renders this 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(() => {
// Not viewing any teammate
if (!viewingAgentTaskId) {
return
}
// Task no longer exists in the map — evicted out from under us.
// Check raw `task` not teammate-narrowed `viewedTask`; local_agent
// tasks exist but narrow to undefined, which would eject immediately.
if (!taskExists) {
exitTeammateView(setAppState)
return
}
// Status checks below are teammate-only (viewedTask is teammate-narrowed).
// For local_agent, viewedStatus is undefined → all checks falsy → no eject.
if (!viewedTask) return
// Auto-exit if teammate is killed, stopped, has error, or is no longer running
// This handles shutdown scenarios where teammate becomes inactive
if (
viewedStatus === 'killed' ||
viewedStatus === 'failed' ||
viewedError ||
(viewedStatus !== 'running' &&
viewedStatus !== 'completed' &&
viewedStatus !== 'pending')
) {
exitTeammateView(setAppState)
return
}
}, [
viewingAgentTaskId,
taskExists,
viewedTask,
viewedStatus,
viewedError,
setAppState,
])
}
+85
View File
@@ -0,0 +1,85 @@
import { c as _c } from "react/compiler-runtime";
import { useCallback, useState } from 'react';
import { setTeleportedSessionInfo } from 'src/bootstrap/state.js';
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js';
import type { TeleportRemoteResponse } from 'src/utils/conversationRecovery.js';
import type { CodeSession } from 'src/utils/teleport/api.js';
import { errorMessage, TeleportOperationError } from '../utils/errors.js';
import { teleportResumeCodeSession } from '../utils/teleport.js';
export type TeleportResumeError = {
message: string;
formattedMessage?: string;
isOperationError: boolean;
};
export type TeleportSource = 'cliArg' | 'localCommand';
export function useTeleportResume(source) {
const $ = _c(8);
const [isResuming, setIsResuming] = useState(false);
const [error, setError] = useState(null);
const [selectedSession, setSelectedSession] = useState(null);
let t0;
if ($[0] !== source) {
t0 = async session => {
setIsResuming(true);
setError(null);
setSelectedSession(session);
logEvent("tengu_teleport_resume_session", {
source: source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
session_id: session.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
;
try {
const result = await teleportResumeCodeSession(session.id);
setTeleportedSessionInfo({
sessionId: session.id
});
setIsResuming(false);
return result;
} catch (t1) {
const err = t1;
const teleportError = {
message: err instanceof TeleportOperationError ? err.message : errorMessage(err),
formattedMessage: err instanceof TeleportOperationError ? err.formattedMessage : undefined,
isOperationError: err instanceof TeleportOperationError
};
setError(teleportError);
setIsResuming(false);
return null;
}
};
$[0] = source;
$[1] = t0;
} else {
t0 = $[1];
}
const resumeSession = t0;
let t1;
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t1 = () => {
setError(null);
};
$[2] = t1;
} else {
t1 = $[2];
}
const clearError = t1;
let t2;
if ($[3] !== error || $[4] !== isResuming || $[5] !== resumeSession || $[6] !== selectedSession) {
t2 = {
resumeSession,
isResuming,
error,
selectedSession,
clearError
};
$[3] = error;
$[4] = isResuming;
$[5] = resumeSession;
$[6] = selectedSession;
$[7] = t2;
} else {
t2 = $[7];
}
return t2;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJ1c2VDYWxsYmFjayIsInVzZVN0YXRlIiwic2V0VGVsZXBvcnRlZFNlc3Npb25JbmZvIiwiQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX05PVF9DT0RFX09SX0ZJTEVQQVRIUyIsImxvZ0V2ZW50IiwiVGVsZXBvcnRSZW1vdGVSZXNwb25zZSIsIkNvZGVTZXNzaW9uIiwiZXJyb3JNZXNzYWdlIiwiVGVsZXBvcnRPcGVyYXRpb25FcnJvciIsInRlbGVwb3J0UmVzdW1lQ29kZVNlc3Npb24iLCJUZWxlcG9ydFJlc3VtZUVycm9yIiwibWVzc2FnZSIsImZvcm1hdHRlZE1lc3NhZ2UiLCJpc09wZXJhdGlvbkVycm9yIiwiVGVsZXBvcnRTb3VyY2UiLCJ1c2VUZWxlcG9ydFJlc3VtZSIsInNvdXJjZSIsIiQiLCJfYyIsImlzUmVzdW1pbmciLCJzZXRJc1Jlc3VtaW5nIiwiZXJyb3IiLCJzZXRFcnJvciIsInNlbGVjdGVkU2Vzc2lvbiIsInNldFNlbGVjdGVkU2Vzc2lvbiIsInQwIiwic2Vzc2lvbiIsInNlc3Npb25faWQiLCJpZCIsInJlc3VsdCIsInNlc3Npb25JZCIsInQxIiwiZXJyIiwidGVsZXBvcnRFcnJvciIsInVuZGVmaW5lZCIsInJlc3VtZVNlc3Npb24iLCJTeW1ib2wiLCJmb3IiLCJjbGVhckVycm9yIiwidDIiXSwic291cmNlcyI6WyJ1c2VUZWxlcG9ydFJlc3VtZS50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgdXNlQ2FsbGJhY2ssIHVzZVN0YXRlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBzZXRUZWxlcG9ydGVkU2Vzc2lvbkluZm8gfSBmcm9tICdzcmMvYm9vdHN0cmFwL3N0YXRlLmpzJ1xuaW1wb3J0IHtcbiAgdHlwZSBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICBsb2dFdmVudCxcbn0gZnJvbSAnc3JjL3NlcnZpY2VzL2FuYWx5dGljcy9pbmRleC5qcydcbmltcG9ydCB0eXBlIHsgVGVsZXBvcnRSZW1vdGVSZXNwb25zZSB9IGZyb20gJ3NyYy91dGlscy9jb252ZXJzYXRpb25SZWNvdmVyeS5qcydcbmltcG9ydCB0eXBlIHsgQ29kZVNlc3Npb24gfSBmcm9tICdzcmMvdXRpbHMvdGVsZXBvcnQvYXBpLmpzJ1xuaW1wb3J0IHsgZXJyb3JNZXNzYWdlLCBUZWxlcG9ydE9wZXJhdGlvbkVycm9yIH0gZnJvbSAnLi4vdXRpbHMvZXJyb3JzLmpzJ1xuaW1wb3J0IHsgdGVsZXBvcnRSZXN1bWVDb2RlU2Vzc2lvbiB9IGZyb20gJy4uL3V0aWxzL3RlbGVwb3J0LmpzJ1xuXG5leHBvcnQgdHlwZSBUZWxlcG9ydFJlc3VtZUVycm9yID0ge1xuICBtZXNzYWdlOiBzdHJpbmdcbiAgZm9ybWF0dGVkTWVzc2FnZT86IHN0cmluZ1xuICBpc09wZXJhdGlvbkVycm9yOiBib29sZWFuXG59XG5cbmV4cG9ydCB0eXBlIFRlbGVwb3J0U291cmNlID0gJ2NsaUFyZycgfCAnbG9jYWxDb21tYW5kJ1xuXG5leHBvcnQgZnVuY3Rpb24gdXNlVGVsZXBvcnRSZXN1bWUoc291cmNlOiBUZWxlcG9ydFNvdXJjZSkge1xuICBjb25zdCBbaXNSZXN1bWluZywgc2V0SXNSZXN1bWluZ10gPSB1c2VTdGF0ZShmYWxzZSlcbiAgY29uc3QgW2Vycm9yLCBzZXRFcnJvcl0gPSB1c2VTdGF0ZTxUZWxlcG9ydFJlc3VtZUVycm9yIHwgbnVsbD4obnVsbClcbiAgY29uc3QgW3NlbGVjdGVkU2Vzc2lvbiwgc2V0U2VsZWN0ZWRTZXNzaW9uXSA9IHVzZVN0YXRlPENvZGVTZXNzaW9uIHwgbnVsbD4oXG4gICAgbnVsbCxcbiAgKVxuXG4gIGNvbnN0IHJlc3VtZVNlc3Npb24gPSB1c2VDYWxsYmFjayhcbiAgICBhc3luYyAoc2Vzc2lvbjogQ29kZVNlc3Npb24pOiBQcm9taXNlPFRlbGVwb3J0UmVtb3RlUmVzcG9uc2UgfCBudWxsPiA9PiB7XG4gICAgICBzZXRJc1Jlc3VtaW5nKHRydWUpXG4gICAgICBzZXRFcnJvcihudWxsKVxuICAgICAgc2V0U2VsZWN0ZWRTZXNzaW9uKHNlc3Npb24pXG5cbiAgICAgIC8vIExvZyB0ZWxlcG9ydCBzZXNzaW9uIHNlbGVjdGlvblxuICAgICAgbG9nRXZlbnQoJ3Rlbmd1X3RlbGVwb3J0X3Jlc3VtZV9zZXNzaW9uJywge1xuICAgICAgICBzb3VyY2U6XG4gICAgICAgICAgc291cmNlIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgICAgIHNlc3Npb25faWQ6XG4gICAgICAgICAgc2Vzc2lvbi5pZCBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICAgICAgfSlcblxuICAgICAgdHJ5IHtcbiAgICAgICAgY29uc3QgcmVzdWx0ID0gYXdhaXQgdGVsZXBvcnRSZXN1bWVDb2RlU2Vzc2lvbihzZXNzaW9uLmlkKVxuICAgICAgICAvLyBUcmFjayB0ZWxlcG9ydGVkIHNlc3Npb24gZm9yIHJlbGlhYmlsaXR5IGxvZ2dpbmdcbiAgICAgICAgc2V0VGVsZXBvcnRlZFNlc3Npb25JbmZvKHsgc2Vzc2lvbklkOiBzZXNzaW9uLmlkIH0pXG4gICAgICAgIHNldElzUmVzdW1pbmcoZmFsc2UpXG4gICAgICAgIHJldHVybiByZXN1bHRcbiAgICAgIH0gY2F0Y2ggKGVycikge1xuICAgICAgICBjb25zdCB0ZWxlcG9ydEVycm9yOiBUZWxlcG9ydFJlc3VtZUVycm9yID0ge1xuICAgICAgICAgIG1lc3NhZ2U6XG4gICAgICAgICAgICBlcnIgaW5zdGFuY2VvZiBUZWxlcG9ydE9wZXJhdGlvbkVycm9yXG4gICAgICAgICAgICAgID8gZXJyLm1lc3NhZ2VcbiAgICAgICAgICAgICAgOiBlcnJvck1lc3NhZ2UoZXJyKSxcbiAgICAgICAgICBmb3JtYXR0ZWRNZXNzYWdlOlxuICAgICAgICAgICAgZXJyIGluc3RhbmNlb2YgVGVsZXBvcnRPcGVyYXRpb25FcnJvclxuICAgICAgICAgICAgICA/IGVyci5mb3JtYXR0ZWRNZXNzYWdlXG4gICAgICAgICAgICAgIDogdW5kZWZpbmVkLFxuICAgICAgICAgIGlzT3BlcmF0aW9uRXJyb3I6IGVyciBpbnN0YW5jZW9mIFRlbGVwb3J0T3BlcmF0aW9uRXJyb3IsXG4gICAgICAgIH1cbiAgICAgICAgc2V0RXJyb3IodGVsZXBvcnRFcnJvcilcbiAgICAgICAgc2V0SXNSZXN1bWluZyhmYWxzZSlcbiAgICAgICAgcmV0dXJuIG51bGxcbiAgICAgIH1cbiAgICB9LFxuICAgIFtzb3VyY2VdLFxuICApXG5cbiAgY29uc3QgY2xlYXJFcnJvciA9IHVzZUNhbGxiYWNrKCgpID0+IHtcbiAgICBzZXRFcnJvcihudWxsKVxuICB9LCBbXSlcblxuICByZXR1cm4ge1xuICAgIHJlc3VtZVNlc3Npb24sXG4gICAgaXNSZXN1bWluZyxcbiAgICBlcnJvcixcbiAgICBzZWxlY3RlZFNlc3Npb24sXG4gICAgY2xlYXJFcnJvcixcbiAgfVxufVxuIl0sIm1hcHBpbmdzIjoiO0FBQUEsU0FBU0EsV0FBVyxFQUFFQyxRQUFRLFFBQVEsT0FBTztBQUM3QyxTQUFTQyx3QkFBd0IsUUFBUSx3QkFBd0I7QUFDakUsU0FDRSxLQUFLQywwREFBMEQsRUFDL0RDLFFBQVEsUUFDSCxpQ0FBaUM7QUFDeEMsY0FBY0Msc0JBQXNCLFFBQVEsbUNBQW1DO0FBQy9FLGNBQWNDLFdBQVcsUUFBUSwyQkFBMkI7QUFDNUQsU0FBU0MsWUFBWSxFQUFFQyxzQkFBc0IsUUFBUSxvQkFBb0I7QUFDekUsU0FBU0MseUJBQXlCLFFBQVEsc0JBQXNCO0FBRWhFLE9BQU8sS0FBS0MsbUJBQW1CLEdBQUc7RUFDaENDLE9BQU8sRUFBRSxNQUFNO0VBQ2ZDLGdCQUFnQixDQUFDLEVBQUUsTUFBTTtFQUN6QkMsZ0JBQWdCLEVBQUUsT0FBTztBQUMzQixDQUFDO0FBRUQsT0FBTyxLQUFLQyxjQUFjLEdBQUcsUUFBUSxHQUFHLGNBQWM7QUFFdEQsT0FBTyxTQUFBQyxrQkFBQUMsTUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUNMLE9BQUFDLFVBQUEsRUFBQUMsYUFBQSxJQUFvQ25CLFFBQVEsQ0FBQyxLQUFLLENBQUM7RUF Line truncated
+15
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
}
+529
View File
@@ -0,0 +1,529 @@
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 {
// Pre-warm the modifiers module for Apple Terminal (has internal guard, safe to call multiple times)
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?.()
}
},
)
// NOTE(keybindings): This escape handler is intentionally NOT migrated to the keybindings system.
// It's a text-level double-press escape for clearing input, not an action-level keybinding.
// Double-press Esc clears the input and saves to history - this is text editing behavior,
// not dialog dismissal, and needs the double-press safety mechanism.
const handleEscape = useDoublePress(
(show: boolean) => {
if (!originalValue || !show) {
return
}
addNotification({
key: 'escape-again-to-clear',
text: 'Esc again to clear',
priority: 'immediate',
timeoutMs: 1000,
})
},
() => {
// Remove the "Esc again to clear" notification immediately
removeNotification('escape-again-to-clear')
onClearInput?.()
if (originalValue) {
// Track double-escape usage for feature discovery
// Save to history before clearing
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 === '') {
// When input is empty, handle double-press
handleEmptyCtrlD()
return cursor
}
// When input is not empty, delete forward like 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
// Replace the previously yanked text with the new one
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] === '\\'
) {
// Track that the user has used backslash+return
markBackslashReturnUsed()
return cursor.backspace().insert('\n')
}
// Meta+Enter or Shift+Enter inserts a newline
if (key.meta || key.shift) {
return cursor.insert('\n')
}
// Apple Terminal doesn't support custom Shift+Enter keybindings,
// so we use native macOS modifier detection to check if Shift is held
if (env.terminal === 'Apple_Terminal' && isModifierPressed('shift')) {
return cursor.insert('\n')
}
onSubmit?.(originalValue)
}
function upOrHistoryUp() {
if (disableCursorMovementForUpDownKeys) {
onHistoryUp?.()
return cursor
}
// Try to move by wrapped lines first
const cursorUp = cursor.up()
if (!cursorUp.equals(cursor)) {
return cursorUp
}
// If we can't move by wrapped lines and this is multiline input,
// try to move by logical lines (to handle paragraph boundaries)
if (multiline) {
const cursorUpLogical = cursor.upLogicalLine()
if (!cursorUpLogical.equals(cursor)) {
return cursorUpLogical
}
}
// Can't move up at all - trigger history navigation
onHistoryUp?.()
return cursor
}
function downOrHistoryDown() {
if (disableCursorMovementForUpDownKeys) {
onHistoryDown?.()
return cursor
}
// Try to move by wrapped lines first
const cursorDown = cursor.down()
if (!cursorDown.equals(cursor)) {
return cursorDown
}
// If we can't move by wrapped lines and this is multiline input,
// try to move by logical lines (to handle paragraph boundaries)
if (multiline) {
const cursorDownLogical = cursor.downLogicalLine()
if (!cursorDownLogical.equals(cursor)) {
return cursorDownLogical
}
}
// Can't move down at all - trigger history navigation
onHistoryDown?.()
return cursor
}
function mapKey(key: Key): InputMapper {
switch (true) {
case key.escape:
return () => {
// Skip when a keybinding context (e.g. Autocomplete) owns escape.
// useKeybindings can't shield us via stopImmediatePropagation —
// BaseTextInput's useInput registers first (child effects fire
// before parent effects), so this handler has already run by the
// time the keybinding's handler stops propagation.
if (disableEscapeDoublePress) return cursor
handleEscape()
// Return the current cursor unchanged - handleEscape manages state internally
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:
// In fullscreen mode, PgUp/PgDn scroll the message viewport instead
// of moving the cursor — no-op here, ScrollKeybindingHandler handles it.
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:
// Mouse wheel events only exist when fullscreen mouse tracking is on.
// ScrollKeybindingHandler handles them; no-op here to avoid inserting
// the raw SGR sequence as text.
return NOOP_HANDLER
case key.return:
// Must come before key.meta so Option+Return inserts newline
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 key
case input === '\x1b[H' || input === '\x1b[1~':
return cursor.startOfLine()
// End key
case input === '\x1b[F' || input === '\x1b[4~':
return cursor.endOfLine()
default: {
// Trailing \r after text is SSH-coalesced Enter ("o\r") —
// strip it so the Enter isn't inserted as content. Lone \r
// here is Alt+Enter leaking through (META_KEY_CODE_RE doesn't
// match \x1b\r) — leave it for the \r→\n below. Embedded \r
// is multi-line paste from a terminal without bracketed
// paste — convert to \n. Backslash+\r is a stale VS Code
// Shift+Enter binding (pre-#8991 /terminal-setup wrote
// args.text "\\\r\n" to keybindings.json); keep the \r so
// it becomes \n below (anthropics/claude-code#31316).
const text = stripAnsi(input)
// eslint-disable-next-line custom-rules/no-lookbehind-regex -- .replace(re, str) on 1-2 char keystrokes: no-match returns same string (Object.is), regex never runs
.replace(/(?<=[^\\\r\n])\r$/, '')
.replace(/\r/g, '\n')
if (cursor.isAtStart() && isInputModeCharacter(input)) {
return cursor.insert(text).left()
}
return cursor.insert(text)
}
}
}
}
}
}
// Check if this is a kill command (Ctrl+K, Ctrl+U, Ctrl+W, or 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
}
// Check if this is a yank command (Ctrl+Y or Alt+Y)
function isYankKey(key: Key, input: string): boolean {
return (key.ctrl || key.meta) && input === 'y'
}
function onInput(input: string, key: Key): void {
// Note: Image paste shortcut (chat:imagePaste) is handled via useKeybindings in PromptInput
// Apply filter if provided
const filteredInput = inputFilter ? inputFilter(input, key) : input
// If the input was filtered out, do nothing
if (filteredInput === '' && input !== '') {
return
}
// Fix Issue #1853: Filter DEL characters that interfere with backspace in SSH/tmux
// In SSH/tmux environments, backspace generates both key events and raw DEL chars
if (!key.backspace && !key.delete && input.includes('\x7f')) {
const delCount = (input.match(/\x7f/g) || []).length
// Apply all DEL characters as backspace operations synchronously
// Try to delete tokens first, fall back to character backspace
let currentCursor = cursor
for (let i = 0; i < delCount; i++) {
currentCursor =
currentCursor.deleteTokenBefore() ?? currentCursor.backspace()
}
// Update state once with the final result
if (!cursor.equals(currentCursor)) {
if (cursor.text !== currentCursor.text) {
onChange(currentCursor.text)
}
setOffset(currentCursor.offset)
}
resetKillAccumulation()
resetYankState()
return
}
// Reset kill accumulation for non-kill keys
if (!isKillKey(key, filteredInput)) {
resetKillAccumulation()
}
// Reset yank state for non-yank keys (breaks yank-pop chain)
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-coalesced Enter: on slow links, "o" + Enter can arrive as one
// chunk "o\r". parseKeypress only matches s === '\r', so it hit the
// default handler above (which stripped the trailing \r). Text with
// exactly one trailing \r is coalesced Enter; lone \r is Alt+Enter
// (newline); embedded \r is multi-line paste.
if (
filteredInput.length > 1 &&
filteredInput.endsWith('\r') &&
!filteredInput.slice(0, -1).includes('\r') &&
// Backslash+CR is a stale VS Code Shift+Enter binding, not
// coalesced Enter. See default handler above.
filteredInput[filteredInput.length - 2] !== '\\'
) {
onSubmit?.(nextCursor.text)
}
}
}
// Prepare ghost text for rendering - validate insertPosition matches current
// cursor offset to prevent stale ghost text from a previous keystroke causing
// a one-frame jitter (ghost text state is updated via useEffect after render)
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),
}
}
+14
View File
@@ -0,0 +1,14 @@
import { useEffect, useState } from 'react'
export function useTimeout(delay: number, resetTrigger?: number): boolean {
const [isElapsed, setIsElapsed] = useState(false)
useEffect(() => {
setIsElapsed(false)
const timer = setTimeout(setIsElapsed, delay, true)
return () => clearTimeout(timer)
}, [delay, resetTrigger])
return isElapsed
}
+213
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 : ''
// Truncate to ~30 chars
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,
}
}
/**
* Extract turn-based diffs from messages.
* A turn is defined as a user prompt followed by assistant responses and tool results.
* Each turn with file edits is included in the result.
*
* Uses incremental accumulation - only processes new messages since last render.
*/
export function useTurnDiffs(messages: Message[]): TurnDiff[] {
const cache = useRef<TurnDiffCache>({
completedTurns: [],
currentTurn: null,
lastProcessedIndex: 0,
lastTurnIndex: 0,
})
return useMemo(() => {
const c = cache.current
// Reset if messages shrunk (user rewound conversation)
if (messages.length < c.lastProcessedIndex) {
c.completedTurns = []
c.currentTurn = null
c.lastProcessedIndex = 0
c.lastTurnIndex = 0
}
// Process only new messages
for (let i = c.lastProcessedIndex; i < messages.length; i++) {
const message = messages[i]
if (!message || message.type !== 'user') continue
// Check if this is a user prompt (not a tool result)
const isToolResult =
message.toolUseResult ||
(Array.isArray(message.message.content) &&
message.message.content[0]?.type === 'tool_result')
if (!isToolResult && !message.isMeta) {
// Start a new turn on user prompt
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) {
// Collect file edits from tool results
const result = message.toolUseResult
if (isFileEditResult(result)) {
const { filePath, structuredPatch } = result
const isNewFile = 'type' in result && result.type === 'create'
// Get or create file entry
let fileEntry = c.currentTurn.files.get(filePath)
if (!fileEntry) {
fileEntry = {
filePath,
hunks: [],
isNewFile,
linesAdded: 0,
linesRemoved: 0,
}
c.currentTurn.files.set(filePath, fileEntry)
}
// For new files, generate synthetic hunk from content
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 {
// Append hunks (same file may be edited multiple times in a turn)
fileEntry.hunks.push(...structuredPatch)
// Update line counts
const { added, removed } = countHunkLines(structuredPatch)
fileEntry.linesAdded += added
fileEntry.linesRemoved += removed
}
// If file was created and then edited, it's still a new file
if (isNewFile) {
fileEntry.isNewFile = true
}
}
}
}
c.lastProcessedIndex = messages.length
// Build result: completed turns + current turn if it has files
const result = [...c.completedTurns]
if (c.currentTurn && c.currentTurn.files.size > 0) {
// Compute stats for current turn before including
computeTurnStats(c.currentTurn)
result.push(c.currentTurn)
}
// Return in reverse order (most recent first)
return result.reverse()
}, [messages])
}
File diff suppressed because it is too large. Load diff
+34
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
}
+316
View File
@@ -0,0 +1,316 @@
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']
}
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 is applied once at the top of handleVimInput (not here) so
// vim-handled paths that return without calling textInput.onInput still
// run the filter — otherwise a stateful filter (e.g. lazy-space-after-
// pill) stays armed across an Escape → NORMAL → INSERT round-trip.
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 behavior: move cursor left by 1 when exiting insert mode
// (unless at beginning of line or at 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
// Run inputFilter in all modes so stateful filters disarm on any key,
// but only apply the transformed input in INSERT — NORMAL-mode command
// lookups expect single chars and a prepended space would break them.
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): This escape handler is intentionally NOT migrated to the keybindings system.
// It's vim's standard INSERT->NORMAL mode switch - a vim-specific behavior that should not be
// configurable via keybindings. Vim users expect Esc to always exit INSERT mode.
if (key.escape && state.mode === 'INSERT') {
switchToNormalMode()
return
}
// Escape in NORMAL mode cancels any pending command (replace, operator, etc.)
if (key.escape && state.mode === 'NORMAL') {
vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
return
}
// Pass Enter to base handler regardless of mode (allows submission from NORMAL)
if (key.return) {
textInput.onInput(input, key)
return
}
if (state.mode === 'INSERT') {
// Track inserted text for dot-repeat
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
}
// In idle state, delegate arrow keys to base handler for cursor movement
// and history fallback (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 are only mapped in motion-expecting states. In
// literal-char states (replace, find, operatorFind), mapping would turn
// r+Backspace into "replace with h" and df+Delete into "delete to next x".
// Delete additionally skips count state: in vim, N<Del> removes a count
// digit rather than executing Nx; we don't implement digit removal but
// should at least not turn a cancel into a destructive Nx.
const expectsMotion =
state.command.type === 'idle' ||
state.command.type === 'count' ||
state.command.type === 'operator' ||
state.command.type === 'operatorCount'
// Map arrow keys to vim motions in NORMAL mode
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()
}
// Update command state (only if execute didn't switch to 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,
}
}
Loaded 100 of 1906 files, more files were not shown because too many files have changed in this diff. Show more