feat: build
This commit is contained in:
commit
f90eee85d8
1906 files changed
+514339
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user