init claude-code

This commit is contained in:
2026-04-01 17:32:37 +02:00
commit 73b208c009
1902 changed files with 513237 additions and 0 deletions
+567
View File
@@ -0,0 +1,567 @@
import Fuse from 'fuse.js'
import {
type Command,
formatDescriptionWithSource,
getCommand,
getCommandName,
} from '../../commands.js'
import type { SuggestionItem } from '../../components/PromptInput/PromptInputFooterSuggestions.js'
import { getSkillUsageScore } from './skillUsageTracking.js'
// Treat these characters as word separators for command search
const SEPARATORS = /[:_-]/g
type CommandSearchItem = {
descriptionKey: string[]
partKey: string[] | undefined
commandName: string
command: Command
aliasKey: string[] | undefined
}
// Cache the Fuse index keyed by the commands array identity. The commands
// array is stable (memoized in REPL.tsx), so we only rebuild when it changes
// rather than on every keystroke.
let fuseCache: {
commands: Command[]
fuse: Fuse<CommandSearchItem>
} | null = null
function getCommandFuse(commands: Command[]): Fuse<CommandSearchItem> {
if (fuseCache?.commands === commands) {
return fuseCache.fuse
}
const commandData: CommandSearchItem[] = commands
.filter(cmd => !cmd.isHidden)
.map(cmd => {
const commandName = getCommandName(cmd)
const parts = commandName.split(SEPARATORS).filter(Boolean)
return {
descriptionKey: (cmd.description ?? '')
.split(' ')
.map(word => cleanWord(word))
.filter(Boolean),
partKey: parts.length > 1 ? parts : undefined,
commandName,
command: cmd,
aliasKey: cmd.aliases,
}
})
const fuse = new Fuse(commandData, {
includeScore: true,
threshold: 0.3, // relatively strict matching
location: 0, // prefer matches at the beginning of strings
distance: 100, // increased to allow matching in descriptions
keys: [
{
name: 'commandName',
weight: 3, // Highest priority for command names
},
{
name: 'partKey',
weight: 2, // Next highest priority for command parts
},
{
name: 'aliasKey',
weight: 2, // Same high priority for aliases
},
{
name: 'descriptionKey',
weight: 0.5, // Lower priority for descriptions
},
],
})
fuseCache = { commands, fuse }
return fuse
}
/**
* Type guard to check if a suggestion's metadata is a Command.
* Commands have a name string and a type property.
*/
function isCommandMetadata(metadata: unknown): metadata is Command {
return (
typeof metadata === 'object' &&
metadata !== null &&
'name' in metadata &&
typeof (metadata as { name: unknown }).name === 'string' &&
'type' in metadata
)
}
/**
* Represents a slash command found mid-input (not at the start)
*/
export type MidInputSlashCommand = {
token: string // e.g., "/com"
startPos: number // Position of "/"
partialCommand: string // e.g., "com"
}
/**
* Finds a slash command token that appears mid-input (not at position 0).
* A mid-input slash command is a "/" preceded by whitespace, where the cursor
* is at or after the "/".
*
* @param input The full input string
* @param cursorOffset The current cursor position
* @returns The mid-input slash command info, or null if not found
*/
export function findMidInputSlashCommand(
input: string,
cursorOffset: number,
): MidInputSlashCommand | null {
// If input starts with "/", this is start-of-input case (handled elsewhere)
if (input.startsWith('/')) {
return null
}
// Look backwards from cursor to find a "/" preceded by whitespace
const beforeCursor = input.slice(0, cursorOffset)
// Find the last "/" in the text before cursor
// Pattern: whitespace followed by "/" then optional alphanumeric/dash characters.
// Lookbehind (?<=\s) is avoided — it defeats YARR JIT in JSC, and the
// interpreter scans O(n) even with the $ anchor. Capture the whitespace
// instead and offset match.index by 1.
const match = beforeCursor.match(/\s\/([a-zA-Z0-9_:-]*)$/)
if (!match || match.index === undefined) {
return null
}
// Get the full token (may extend past cursor)
const slashPos = match.index + 1
const textAfterSlash = input.slice(slashPos + 1)
// Extract the command portion (until whitespace or end)
const commandMatch = textAfterSlash.match(/^[a-zA-Z0-9_:-]*/)
const fullCommand = commandMatch ? commandMatch[0] : ''
// If cursor is past the command (after a space), don't show ghost text
if (cursorOffset > slashPos + 1 + fullCommand.length) {
return null
}
return {
token: '/' + fullCommand,
startPos: slashPos,
partialCommand: fullCommand,
}
}
/**
* Finds the best matching command for a partial command string.
* Delegates to generateCommandSuggestions and filters to prefix matches.
*
* @param partialCommand The partial command typed by the user (without "/")
* @param commands Available commands
* @returns The completion suffix (e.g., "mit" for partial "com" matching "commit"), or null
*/
export function getBestCommandMatch(
partialCommand: string,
commands: Command[],
): { suffix: string; fullCommand: string } | null {
if (!partialCommand) {
return null
}
// Use existing suggestion logic
const suggestions = generateCommandSuggestions('/' + partialCommand, commands)
if (suggestions.length === 0) {
return null
}
// Find first suggestion that is a prefix match (for inline completion)
const query = partialCommand.toLowerCase()
for (const suggestion of suggestions) {
if (!isCommandMetadata(suggestion.metadata)) {
continue
}
const name = getCommandName(suggestion.metadata)
if (name.toLowerCase().startsWith(query)) {
const suffix = name.slice(partialCommand.length)
// Only return if there's something to complete
if (suffix) {
return { suffix, fullCommand: name }
}
}
}
return null
}
/**
* Checks if input is a command (starts with slash)
*/
export function isCommandInput(input: string): boolean {
return input.startsWith('/')
}
/**
* Checks if a command input has arguments
* A command with just a trailing space is considered to have no arguments
*/
export function hasCommandArgs(input: string): boolean {
if (!isCommandInput(input)) return false
if (!input.includes(' ')) return false
if (input.endsWith(' ')) return false
return true
}
/**
* Formats a command with proper notation
*/
export function formatCommand(command: string): string {
return `/${command} `
}
/**
* Generates a deterministic unique ID for a command suggestion.
* Commands with the same name from different sources get unique IDs.
*
* Only prompt commands can have duplicates (from user settings, project
* settings, plugins, etc). Built-in commands (local, local-jsx) are
* defined once in code and can't have duplicates.
*/
function getCommandId(cmd: Command): string {
const commandName = getCommandName(cmd)
if (cmd.type === 'prompt') {
// For plugin commands, include the repository to disambiguate
if (cmd.source === 'plugin' && cmd.pluginInfo?.repository) {
return `${commandName}:${cmd.source}:${cmd.pluginInfo.repository}`
}
return `${commandName}:${cmd.source}`
}
// Built-in commands include type as fallback for future-proofing
return `${commandName}:${cmd.type}`
}
/**
* Checks if a query matches any of the command's aliases.
* Returns the matched alias if found, otherwise undefined.
*/
function findMatchedAlias(
query: string,
aliases?: string[],
): string | undefined {
if (!aliases || aliases.length === 0 || query === '') {
return undefined
}
// Check if query is a prefix of any alias (case-insensitive)
return aliases.find(alias => alias.toLowerCase().startsWith(query))
}
/**
* Creates a suggestion item from a command.
* Only shows the matched alias in parentheses if the user typed an alias.
*/
function createCommandSuggestionItem(
cmd: Command,
matchedAlias?: string,
): SuggestionItem {
const commandName = getCommandName(cmd)
// Only show the alias if the user typed it
const aliasText = matchedAlias ? ` (${matchedAlias})` : ''
const isWorkflow = cmd.type === 'prompt' && cmd.kind === 'workflow'
const fullDescription =
(isWorkflow ? cmd.description : formatDescriptionWithSource(cmd)) +
(cmd.type === 'prompt' && cmd.argNames?.length
? ` (arguments: ${cmd.argNames.join(', ')})`
: '')
return {
id: getCommandId(cmd),
displayText: `/${commandName}${aliasText}`,
tag: isWorkflow ? 'workflow' : undefined,
description: fullDescription,
metadata: cmd,
}
}
/**
* Generate command suggestions based on input
*/
export function generateCommandSuggestions(
input: string,
commands: Command[],
): SuggestionItem[] {
// Only process command input
if (!isCommandInput(input)) {
return []
}
// If there are arguments, don't show suggestions
if (hasCommandArgs(input)) {
return []
}
const query = input.slice(1).toLowerCase().trim()
// When just typing '/' without additional text
if (query === '') {
const visibleCommands = commands.filter(cmd => !cmd.isHidden)
// Find recently used skills (only prompt commands have usage tracking)
const recentlyUsed: Command[] = []
const commandsWithScores = visibleCommands
.filter(cmd => cmd.type === 'prompt')
.map(cmd => ({
cmd,
score: getSkillUsageScore(getCommandName(cmd)),
}))
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
// Take top 5 recently used skills
for (const item of commandsWithScores.slice(0, 5)) {
recentlyUsed.push(item.cmd)
}
// Create a set of recently used command IDs to avoid duplicates
const recentlyUsedIds = new Set(recentlyUsed.map(cmd => getCommandId(cmd)))
// Categorize remaining commands (excluding recently used)
const builtinCommands: Command[] = []
const userCommands: Command[] = []
const projectCommands: Command[] = []
const policyCommands: Command[] = []
const otherCommands: Command[] = []
visibleCommands.forEach(cmd => {
// Skip if already in recently used
if (recentlyUsedIds.has(getCommandId(cmd))) {
return
}
if (cmd.type === 'local' || cmd.type === 'local-jsx') {
builtinCommands.push(cmd)
} else if (
cmd.type === 'prompt' &&
(cmd.source === 'userSettings' || cmd.source === 'localSettings')
) {
userCommands.push(cmd)
} else if (cmd.type === 'prompt' && cmd.source === 'projectSettings') {
projectCommands.push(cmd)
} else if (cmd.type === 'prompt' && cmd.source === 'policySettings') {
policyCommands.push(cmd)
} else {
otherCommands.push(cmd)
}
})
// Sort each category alphabetically
const sortAlphabetically = (a: Command, b: Command) =>
getCommandName(a).localeCompare(getCommandName(b))
builtinCommands.sort(sortAlphabetically)
userCommands.sort(sortAlphabetically)
projectCommands.sort(sortAlphabetically)
policyCommands.sort(sortAlphabetically)
otherCommands.sort(sortAlphabetically)
// Combine with built-in commands prioritized after recently used,
// so they remain visible even when many skills are installed
return [
...recentlyUsed,
...builtinCommands,
...userCommands,
...projectCommands,
...policyCommands,
...otherCommands,
].map(cmd => createCommandSuggestionItem(cmd))
}
// The Fuse index filters isHidden at build time and is keyed on the
// (memoized) commands array identity, so a command that is hidden when Fuse
// first builds stays invisible to Fuse for the whole session. If the user
// types the exact name of a currently-hidden command, prepend it to the
// Fuse results so exact-name always wins over weak description fuzzy
// matches — but only when no visible command shares the name (that would
// be the user's explicit override and should win). Prepend rather than
// early-return so visible prefix siblings (e.g. /voice-memo) still appear
// below, and getBestCommandMatch can still find a non-empty suffix.
let hiddenExact = commands.find(
cmd => cmd.isHidden && getCommandName(cmd).toLowerCase() === query,
)
if (
hiddenExact &&
commands.some(
cmd => !cmd.isHidden && getCommandName(cmd).toLowerCase() === query,
)
) {
hiddenExact = undefined
}
const fuse = getCommandFuse(commands)
const searchResults = fuse.search(query)
// Sort results prioritizing exact/prefix command name matches over fuzzy description matches
// Priority order:
// 1. Exact name match (highest)
// 2. Exact alias match
// 3. Prefix name match
// 4. Prefix alias match
// 5. Fuzzy match (lowest)
// Precompute per-item values once to avoid O(n log n) recomputation in comparator
const withMeta = searchResults.map(r => {
const name = r.item.commandName.toLowerCase()
const aliases = r.item.aliasKey?.map(alias => alias.toLowerCase()) ?? []
const usage =
r.item.command.type === 'prompt'
? getSkillUsageScore(getCommandName(r.item.command))
: 0
return { r, name, aliases, usage }
})
const sortedResults = withMeta.sort((a, b) => {
const aName = a.name
const bName = b.name
const aAliases = a.aliases
const bAliases = b.aliases
// Check for exact name match (highest priority)
const aExactName = aName === query
const bExactName = bName === query
if (aExactName && !bExactName) return -1
if (bExactName && !aExactName) return 1
// Check for exact alias match
const aExactAlias = aAliases.some(alias => alias === query)
const bExactAlias = bAliases.some(alias => alias === query)
if (aExactAlias && !bExactAlias) return -1
if (bExactAlias && !aExactAlias) return 1
// Check for prefix name match
const aPrefixName = aName.startsWith(query)
const bPrefixName = bName.startsWith(query)
if (aPrefixName && !bPrefixName) return -1
if (bPrefixName && !aPrefixName) return 1
// Among prefix name matches, prefer the shorter name (closer to exact)
if (aPrefixName && bPrefixName && aName.length !== bName.length) {
return aName.length - bName.length
}
// Check for prefix alias match
const aPrefixAlias = aAliases.find(alias => alias.startsWith(query))
const bPrefixAlias = bAliases.find(alias => alias.startsWith(query))
if (aPrefixAlias && !bPrefixAlias) return -1
if (bPrefixAlias && !aPrefixAlias) return 1
// Among prefix alias matches, prefer the shorter alias
if (
aPrefixAlias &&
bPrefixAlias &&
aPrefixAlias.length !== bPrefixAlias.length
) {
return aPrefixAlias.length - bPrefixAlias.length
}
// For similar match types, use Fuse score with usage as tiebreaker
const scoreDiff = (a.r.score ?? 0) - (b.r.score ?? 0)
if (Math.abs(scoreDiff) > 0.1) {
return scoreDiff
}
// For similar Fuse scores, prefer more frequently used skills
return b.usage - a.usage
})
// Map search results to suggestion items
// Note: We intentionally don't deduplicate here because commands with the same name
// from different sources (e.g., projectSettings vs userSettings) may have different
// implementations and should both be available to the user
const fuseSuggestions = sortedResults.map(result => {
const cmd = result.r.item.command
// Only show alias in parentheses if the user typed an alias
const matchedAlias = findMatchedAlias(query, cmd.aliases)
return createCommandSuggestionItem(cmd, matchedAlias)
})
// Skip the prepend if hiddenExact is already in fuseSuggestions — this
// happens when isHidden flips false→true mid-session (OAuth expiry,
// GrowthBook kill-switch) and the stale Fuse index still holds the
// command. Fuse already sorts exact-name matches first, so no reorder
// is needed; we just don't want a duplicate id (duplicate React keys,
// both rows rendering as selected).
if (hiddenExact) {
const hiddenId = getCommandId(hiddenExact)
if (!fuseSuggestions.some(s => s.id === hiddenId)) {
return [createCommandSuggestionItem(hiddenExact), ...fuseSuggestions]
}
}
return fuseSuggestions
}
/**
* Apply selected command to input
*/
export function applyCommandSuggestion(
suggestion: string | SuggestionItem,
shouldExecute: boolean,
commands: Command[],
onInputChange: (value: string) => void,
setCursorOffset: (offset: number) => void,
onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void,
): void {
// Extract command name and object from string or SuggestionItem metadata
let commandName: string
let commandObj: Command | undefined
if (typeof suggestion === 'string') {
commandName = suggestion
commandObj = shouldExecute ? getCommand(commandName, commands) : undefined
} else {
if (!isCommandMetadata(suggestion.metadata)) {
return // Invalid suggestion, nothing to apply
}
commandName = getCommandName(suggestion.metadata)
commandObj = suggestion.metadata
}
// Format the command input with trailing space
const newInput = formatCommand(commandName)
onInputChange(newInput)
setCursorOffset(newInput.length)
// Execute command if requested and it takes no arguments
if (shouldExecute && commandObj) {
if (
commandObj.type !== 'prompt' ||
(commandObj.argNames ?? []).length === 0
) {
onSubmit(newInput, /* isSubmittingSlashCommand */ true)
}
}
}
// Helper function at bottom of file per CLAUDE.md
function cleanWord(word: string) {
return word.toLowerCase().replace(/[^a-z0-9]/g, '')
}
/**
* Find all /command patterns in text for highlighting.
* Returns array of {start, end} positions.
* Requires whitespace or start-of-string before the slash to avoid
* matching paths like /usr/bin.
*/
export function findSlashCommandPositions(
text: string,
): Array<{ start: number; end: number }> {
const positions: Array<{ start: number; end: number }> = []
// Match /command patterns preceded by whitespace or start-of-string
const regex = /(^|[\s])(\/[a-zA-Z][a-zA-Z0-9:\-_]*)/g
let match: RegExpExecArray | null = null
while ((match = regex.exec(text)) !== null) {
const precedingChar = match[1] ?? ''
const commandName = match[2] ?? ''
// Start position is after the whitespace (if any)
const start = match.index + precedingChar.length
positions.push({ start, end: start + commandName.length })
}
return positions
}
+263
View File
@@ -0,0 +1,263 @@
import { LRUCache } from 'lru-cache'
import { basename, dirname, join, sep } from 'path'
import type { SuggestionItem } from 'src/components/PromptInput/PromptInputFooterSuggestions.js'
import { getCwd } from 'src/utils/cwd.js'
import { getFsImplementation } from 'src/utils/fsOperations.js'
import { logError } from 'src/utils/log.js'
import { expandPath } from 'src/utils/path.js'
// Types
export type DirectoryEntry = {
name: string
path: string
type: 'directory'
}
export type PathEntry = {
name: string
path: string
type: 'directory' | 'file'
}
export type CompletionOptions = {
basePath?: string
maxResults?: number
}
export type PathCompletionOptions = CompletionOptions & {
includeFiles?: boolean
includeHidden?: boolean
}
type ParsedPath = {
directory: string
prefix: string
}
// Cache configuration
const CACHE_SIZE = 500
const CACHE_TTL = 5 * 60 * 1000 // 5 minutes
// Initialize LRU cache for directory scans
const directoryCache = new LRUCache<string, DirectoryEntry[]>({
max: CACHE_SIZE,
ttl: CACHE_TTL,
})
// Initialize LRU cache for path scans (files and directories)
const pathCache = new LRUCache<string, PathEntry[]>({
max: CACHE_SIZE,
ttl: CACHE_TTL,
})
/**
* Parses a partial path into directory and prefix components
*/
export function parsePartialPath(
partialPath: string,
basePath?: string,
): ParsedPath {
// Handle empty input
if (!partialPath) {
const directory = basePath || getCwd()
return { directory, prefix: '' }
}
const resolved = expandPath(partialPath, basePath)
// If path ends with separator, treat as directory with no prefix
// Handle both forward slash and platform-specific separator
if (partialPath.endsWith('/') || partialPath.endsWith(sep)) {
return { directory: resolved, prefix: '' }
}
// Split into directory and prefix
const directory = dirname(resolved)
const prefix = basename(partialPath)
return { directory, prefix }
}
/**
* Scans a directory and returns subdirectories
* Uses LRU cache to avoid repeated filesystem calls
*/
export async function scanDirectory(
dirPath: string,
): Promise<DirectoryEntry[]> {
// Check cache first
const cached = directoryCache.get(dirPath)
if (cached) {
return cached
}
try {
// Read directory contents
const fs = getFsImplementation()
const entries = await fs.readdir(dirPath)
// Filter for directories only, exclude hidden directories
const directories = entries
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
.map(entry => ({
name: entry.name,
path: join(dirPath, entry.name),
type: 'directory' as const,
}))
.slice(0, 100) // Limit results for MVP
// Cache the results
directoryCache.set(dirPath, directories)
return directories
} catch (error) {
logError(error)
return []
}
}
/**
* Main function to get directory completion suggestions
*/
export async function getDirectoryCompletions(
partialPath: string,
options: CompletionOptions = {},
): Promise<SuggestionItem[]> {
const { basePath = getCwd(), maxResults = 10 } = options
const { directory, prefix } = parsePartialPath(partialPath, basePath)
const entries = await scanDirectory(directory)
const prefixLower = prefix.toLowerCase()
const matches = entries
.filter(entry => entry.name.toLowerCase().startsWith(prefixLower))
.slice(0, maxResults)
return matches.map(entry => ({
id: entry.path,
displayText: entry.name + '/',
description: 'directory',
metadata: { type: 'directory' as const },
}))
}
/**
* Clears the directory cache
*/
export function clearDirectoryCache(): void {
directoryCache.clear()
}
/**
* Checks if a string looks like a path (starts with path-like prefixes)
*/
export function isPathLikeToken(token: string): boolean {
return (
token.startsWith('~/') ||
token.startsWith('/') ||
token.startsWith('./') ||
token.startsWith('../') ||
token === '~' ||
token === '.' ||
token === '..'
)
}
/**
* Scans a directory and returns both files and subdirectories
* Uses LRU cache to avoid repeated filesystem calls
*/
export async function scanDirectoryForPaths(
dirPath: string,
includeHidden = false,
): Promise<PathEntry[]> {
const cacheKey = `${dirPath}:${includeHidden}`
const cached = pathCache.get(cacheKey)
if (cached) {
return cached
}
try {
const fs = getFsImplementation()
const entries = await fs.readdir(dirPath)
const paths = entries
.filter(entry => includeHidden || !entry.name.startsWith('.'))
.map(entry => ({
name: entry.name,
path: join(dirPath, entry.name),
type: entry.isDirectory() ? ('directory' as const) : ('file' as const),
}))
.sort((a, b) => {
// Sort directories first, then alphabetically
if (a.type === 'directory' && b.type !== 'directory') return -1
if (a.type !== 'directory' && b.type === 'directory') return 1
return a.name.localeCompare(b.name)
})
.slice(0, 100)
pathCache.set(cacheKey, paths)
return paths
} catch (error) {
logError(error)
return []
}
}
/**
* Get path completion suggestions for files and directories
*/
export async function getPathCompletions(
partialPath: string,
options: PathCompletionOptions = {},
): Promise<SuggestionItem[]> {
const {
basePath = getCwd(),
maxResults = 10,
includeFiles = true,
includeHidden = false,
} = options
const { directory, prefix } = parsePartialPath(partialPath, basePath)
const entries = await scanDirectoryForPaths(directory, includeHidden)
const prefixLower = prefix.toLowerCase()
const matches = entries
.filter(entry => {
if (!includeFiles && entry.type === 'file') return false
return entry.name.toLowerCase().startsWith(prefixLower)
})
.slice(0, maxResults)
// Construct relative path based on original partialPath
// e.g., if partialPath is "src/c", directory portion is "src/"
// Strip leading "./" since it's just used for cwd search
// Handle both forward slash and platform separator for Windows compatibility
const hasSeparator = partialPath.includes('/') || partialPath.includes(sep)
let dirPortion = ''
if (hasSeparator) {
// Find the last separator (either / or platform-specific)
const lastSlash = partialPath.lastIndexOf('/')
const lastSep = partialPath.lastIndexOf(sep)
const lastSeparatorPos = Math.max(lastSlash, lastSep)
dirPortion = partialPath.substring(0, lastSeparatorPos + 1)
}
if (dirPortion.startsWith('./') || dirPortion.startsWith('.' + sep)) {
dirPortion = dirPortion.slice(2)
}
return matches.map(entry => {
const fullPath = dirPortion + entry.name
return {
id: fullPath,
displayText: entry.type === 'directory' ? fullPath + '/' : fullPath,
metadata: { type: entry.type },
}
})
}
/**
* Clears both directory and path caches
*/
export function clearPathCache(): void {
directoryCache.clear()
pathCache.clear()
}
+119
View File
@@ -0,0 +1,119 @@
import { getHistory } from '../../history.js'
import { logForDebugging } from '../debug.js'
/**
* Result of shell history completion lookup
*/
export type ShellHistoryMatch = {
/** The full command from history */
fullCommand: string
/** The suffix to display as ghost text (the part after user's input) */
suffix: string
}
// Cache for shell history commands to avoid repeated async reads
// History only changes when user submits a command, so a long TTL is fine
let shellHistoryCache: string[] | null = null
let shellHistoryCacheTimestamp = 0
const CACHE_TTL_MS = 60000 // 60 seconds - history won't change while typing
/**
* Get shell commands from history, with caching
*/
async function getShellHistoryCommands(): Promise<string[]> {
const now = Date.now()
// Return cached result if still fresh
if (shellHistoryCache && now - shellHistoryCacheTimestamp < CACHE_TTL_MS) {
return shellHistoryCache
}
const commands: string[] = []
const seen = new Set<string>()
try {
// Read history entries and filter for bash commands
for await (const entry of getHistory()) {
if (entry.display && entry.display.startsWith('!')) {
// Remove the '!' prefix to get the actual command
const command = entry.display.slice(1).trim()
if (command && !seen.has(command)) {
seen.add(command)
commands.push(command)
}
}
// Limit to 50 most recent unique commands
if (commands.length >= 50) {
break
}
}
} catch (error) {
logForDebugging(`Failed to read shell history: ${error}`)
}
shellHistoryCache = commands
shellHistoryCacheTimestamp = now
return commands
}
/**
* Clear the shell history cache (useful when history is updated)
*/
export function clearShellHistoryCache(): void {
shellHistoryCache = null
shellHistoryCacheTimestamp = 0
}
/**
* Add a command to the front of the shell history cache without
* flushing the entire cache. If the command already exists in the
* cache it is moved to the front (deduped). When the cache hasn't
* been populated yet this is a no-op the next lookup will read
* the full history which already includes the new command.
*/
export function prependToShellHistoryCache(command: string): void {
if (!shellHistoryCache) {
return
}
const idx = shellHistoryCache.indexOf(command)
if (idx !== -1) {
shellHistoryCache.splice(idx, 1)
}
shellHistoryCache.unshift(command)
}
/**
* Find the best matching shell command from history for the given input
*
* @param input The current user input (without '!' prefix)
* @returns The best match, or null if no match found
*/
export async function getShellHistoryCompletion(
input: string,
): Promise<ShellHistoryMatch | null> {
// Don't suggest for empty or very short input
if (!input || input.length < 2) {
return null
}
// Check the trimmed input to make sure there's actual content
const trimmedInput = input.trim()
if (!trimmedInput) {
return null
}
const commands = await getShellHistoryCommands()
// Find the first command that starts with the EXACT input (including spaces)
// This ensures "ls " matches "ls -lah" but "ls " (2 spaces) does not
for (const command of commands) {
if (command.startsWith(input) && command !== input) {
return {
fullCommand: command,
suffix: command.slice(input.length),
}
}
}
return null
}
+55
View File
@@ -0,0 +1,55 @@
import { getGlobalConfig, saveGlobalConfig } from '../config.js'
const SKILL_USAGE_DEBOUNCE_MS = 60_000
// Process-lifetime debounce cache — avoids lock + read + parse on debounced
// calls. Same pattern as lastConfigStatTime / globalConfigWriteCount in config.ts.
const lastWriteBySkill = new Map<string, number>()
/**
* Records a skill usage for ranking purposes.
* Updates both usage count and last used timestamp.
*/
export function recordSkillUsage(skillName: string): void {
const now = Date.now()
const lastWrite = lastWriteBySkill.get(skillName)
// The ranking algorithm uses a 7-day half-life, so sub-minute granularity
// is irrelevant. Bail out before saveGlobalConfig to avoid lock + file I/O.
if (lastWrite !== undefined && now - lastWrite < SKILL_USAGE_DEBOUNCE_MS) {
return
}
lastWriteBySkill.set(skillName, now)
saveGlobalConfig(current => {
const existing = current.skillUsage?.[skillName]
return {
...current,
skillUsage: {
...current.skillUsage,
[skillName]: {
usageCount: (existing?.usageCount ?? 0) + 1,
lastUsedAt: now,
},
},
}
})
}
/**
* Calculates a usage score for a skill based on frequency and recency.
* Higher scores indicate more frequently and recently used skills.
*
* The score uses exponential decay with a half-life of 7 days,
* meaning usage from 7 days ago is worth half as much as usage today.
*/
export function getSkillUsageScore(skillName: string): number {
const config = getGlobalConfig()
const usage = config.skillUsage?.[skillName]
if (!usage) return 0
// Recency decay: halve score every 7 days
const daysSinceUse = (Date.now() - usage.lastUsedAt) / (1000 * 60 * 60 * 24)
const recencyFactor = Math.pow(0.5, daysSinceUse / 7)
// Minimum recency factor of 0.1 to avoid completely dropping old but heavily used skills
return usage.usageCount * Math.max(recencyFactor, 0.1)
}
@@ -0,0 +1,209 @@
import { z } from 'zod'
import type { SuggestionItem } from '../../components/PromptInput/PromptInputFooterSuggestions.js'
import type { MCPServerConnection } from '../../services/mcp/types.js'
import { logForDebugging } from '../debug.js'
import { lazySchema } from '../lazySchema.js'
import { createSignal } from '../signal.js'
import { jsonParse } from '../slowOperations.js'
const SLACK_SEARCH_TOOL = 'slack_search_channels'
// Plain Map (not LRUCache) — findReusableCacheEntry needs to iterate all
// entries for prefix matching, which LRUCache doesn't expose cleanly.
const cache = new Map<string, string[]>()
// Flat set of every channel name ever returned by MCP — used to gate
// highlighting so only confirmed-real channels turn blue in the prompt.
const knownChannels = new Set<string>()
let knownChannelsVersion = 0
const knownChannelsChanged = createSignal()
export const subscribeKnownChannels = knownChannelsChanged.subscribe
let inflightQuery: string | null = null
let inflightPromise: Promise<string[]> | null = null
function findSlackClient(
clients: MCPServerConnection[],
): MCPServerConnection | undefined {
return clients.find(c => c.type === 'connected' && c.name.includes('slack'))
}
async function fetchChannels(
clients: MCPServerConnection[],
query: string,
): Promise<string[]> {
const slackClient = findSlackClient(clients)
if (!slackClient || slackClient.type !== 'connected') {
return []
}
try {
const result = await slackClient.client.callTool(
{
name: SLACK_SEARCH_TOOL,
arguments: {
query,
limit: 20,
channel_types: 'public_channel,private_channel',
},
},
undefined,
{ timeout: 5000 },
)
const content = result.content
if (!Array.isArray(content)) return []
const rawText = content
.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
.map(c => c.text)
.join('\n')
return parseChannels(unwrapResults(rawText))
} catch (error) {
logForDebugging(`Failed to fetch Slack channels: ${error}`)
return []
}
}
// The Slack MCP server wraps its markdown in a JSON envelope:
// {"results":"# Search Results...\nName: #chan\n..."}
const resultsEnvelopeSchema = lazySchema(() =>
z.object({ results: z.string() }),
)
function unwrapResults(text: string): string {
const trimmed = text.trim()
if (!trimmed.startsWith('{')) return text
try {
const parsed = resultsEnvelopeSchema().safeParse(jsonParse(trimmed))
if (parsed.success) return parsed.data.results
} catch {
// jsonParse threw — fall through
}
return text
}
// Parse channel names from slack_search_channels text output.
// The Slack MCP server returns markdown with "Name: #channel-name" lines.
function parseChannels(text: string): string[] {
const channels: string[] = []
const seen = new Set<string>()
for (const line of text.split('\n')) {
const m = line.match(/^Name:\s*#?([a-z0-9][a-z0-9_-]{0,79})\s*$/)
if (m && !seen.has(m[1]!)) {
seen.add(m[1]!)
channels.push(m[1]!)
}
}
return channels
}
export function hasSlackMcpServer(clients: MCPServerConnection[]): boolean {
return findSlackClient(clients) !== undefined
}
export function getKnownChannelsVersion(): number {
return knownChannelsVersion
}
export function findSlackChannelPositions(
text: string,
): Array<{ start: number; end: number }> {
const positions: Array<{ start: number; end: number }> = []
const re = /(^|\s)#([a-z0-9][a-z0-9_-]{0,79})(?=\s|$)/g
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
if (!knownChannels.has(m[2]!)) continue
const start = m.index + m[1]!.length
positions.push({ start, end: start + 1 + m[2]!.length })
}
return positions
}
// Slack's search tokenizes on hyphens and requires whole-word matches, so
// "claude-code-team-en" returns 0 results. Strip the trailing partial segment
// so the MCP query is "claude-code-team" (complete words only), then filter
// locally. This keeps the query maximally specific (avoiding the 20-result
// cap) while never sending a partial word that kills the search.
function mcpQueryFor(searchToken: string): string {
const lastSep = Math.max(
searchToken.lastIndexOf('-'),
searchToken.lastIndexOf('_'),
)
return lastSep > 0 ? searchToken.slice(0, lastSep) : searchToken
}
// Find a cached entry whose key is a prefix of mcpQuery and still has
// matches for searchToken. Lets typing "c"→"cl"→"cla" reuse the "c" cache
// instead of issuing a new MCP call per keystroke.
function findReusableCacheEntry(
mcpQuery: string,
searchToken: string,
): string[] | undefined {
let best: string[] | undefined
let bestLen = 0
for (const [key, channels] of cache) {
if (
mcpQuery.startsWith(key) &&
key.length > bestLen &&
channels.some(c => c.startsWith(searchToken))
) {
best = channels
bestLen = key.length
}
}
return best
}
export async function getSlackChannelSuggestions(
clients: MCPServerConnection[],
searchToken: string,
): Promise<SuggestionItem[]> {
if (!searchToken) return []
const mcpQuery = mcpQueryFor(searchToken)
const lower = searchToken.toLowerCase()
let channels = cache.get(mcpQuery) ?? findReusableCacheEntry(mcpQuery, lower)
if (!channels) {
if (inflightQuery === mcpQuery && inflightPromise) {
channels = await inflightPromise
} else {
inflightQuery = mcpQuery
inflightPromise = fetchChannels(clients, mcpQuery)
channels = await inflightPromise
cache.set(mcpQuery, channels)
const before = knownChannels.size
for (const c of channels) knownChannels.add(c)
if (knownChannels.size !== before) {
knownChannelsVersion++
knownChannelsChanged.emit()
}
if (cache.size > 50) {
cache.delete(cache.keys().next().value!)
}
if (inflightQuery === mcpQuery) {
inflightQuery = null
inflightPromise = null
}
}
}
return channels
.filter(c => c.startsWith(lower))
.sort()
.slice(0, 10)
.map(c => ({
id: `slack-channel-${c}`,
displayText: `#${c}`,
}))
}
export function clearSlackChannelCache(): void {
cache.clear()
knownChannels.clear()
knownChannelsVersion = 0
inflightQuery = null
inflightPromise = null
}