source dump of claude code
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 209 lines 6.4 kB view raw
1import { z } from 'zod' 2import type { SuggestionItem } from '../../components/PromptInput/PromptInputFooterSuggestions.js' 3import type { MCPServerConnection } from '../../services/mcp/types.js' 4import { logForDebugging } from '../debug.js' 5import { lazySchema } from '../lazySchema.js' 6import { createSignal } from '../signal.js' 7import { jsonParse } from '../slowOperations.js' 8 9const SLACK_SEARCH_TOOL = 'slack_search_channels' 10 11// Plain Map (not LRUCache) — findReusableCacheEntry needs to iterate all 12// entries for prefix matching, which LRUCache doesn't expose cleanly. 13const cache = new Map<string, string[]>() 14// Flat set of every channel name ever returned by MCP — used to gate 15// highlighting so only confirmed-real channels turn blue in the prompt. 16const knownChannels = new Set<string>() 17let knownChannelsVersion = 0 18const knownChannelsChanged = createSignal() 19export const subscribeKnownChannels = knownChannelsChanged.subscribe 20let inflightQuery: string | null = null 21let inflightPromise: Promise<string[]> | null = null 22 23function findSlackClient( 24 clients: MCPServerConnection[], 25): MCPServerConnection | undefined { 26 return clients.find(c => c.type === 'connected' && c.name.includes('slack')) 27} 28 29async function fetchChannels( 30 clients: MCPServerConnection[], 31 query: string, 32): Promise<string[]> { 33 const slackClient = findSlackClient(clients) 34 if (!slackClient || slackClient.type !== 'connected') { 35 return [] 36 } 37 38 try { 39 const result = await slackClient.client.callTool( 40 { 41 name: SLACK_SEARCH_TOOL, 42 arguments: { 43 query, 44 limit: 20, 45 channel_types: 'public_channel,private_channel', 46 }, 47 }, 48 undefined, 49 { timeout: 5000 }, 50 ) 51 52 const content = result.content 53 if (!Array.isArray(content)) return [] 54 55 const rawText = content 56 .filter((c): c is { type: 'text'; text: string } => c.type === 'text') 57 .map(c => c.text) 58 .join('\n') 59 60 return parseChannels(unwrapResults(rawText)) 61 } catch (error) { 62 logForDebugging(`Failed to fetch Slack channels: ${error}`) 63 return [] 64 } 65} 66 67// The Slack MCP server wraps its markdown in a JSON envelope: 68// {"results":"# Search Results...\nName: #chan\n..."} 69const resultsEnvelopeSchema = lazySchema(() => 70 z.object({ results: z.string() }), 71) 72 73function unwrapResults(text: string): string { 74 const trimmed = text.trim() 75 if (!trimmed.startsWith('{')) return text 76 try { 77 const parsed = resultsEnvelopeSchema().safeParse(jsonParse(trimmed)) 78 if (parsed.success) return parsed.data.results 79 } catch { 80 // jsonParse threw — fall through 81 } 82 return text 83} 84 85// Parse channel names from slack_search_channels text output. 86// The Slack MCP server returns markdown with "Name: #channel-name" lines. 87function parseChannels(text: string): string[] { 88 const channels: string[] = [] 89 const seen = new Set<string>() 90 91 for (const line of text.split('\n')) { 92 const m = line.match(/^Name:\s*#?([a-z0-9][a-z0-9_-]{0,79})\s*$/) 93 if (m && !seen.has(m[1]!)) { 94 seen.add(m[1]!) 95 channels.push(m[1]!) 96 } 97 } 98 99 return channels 100} 101 102export function hasSlackMcpServer(clients: MCPServerConnection[]): boolean { 103 return findSlackClient(clients) !== undefined 104} 105 106export function getKnownChannelsVersion(): number { 107 return knownChannelsVersion 108} 109 110export function findSlackChannelPositions( 111 text: string, 112): Array<{ start: number; end: number }> { 113 const positions: Array<{ start: number; end: number }> = [] 114 const re = /(^|\s)#([a-z0-9][a-z0-9_-]{0,79})(?=\s|$)/g 115 let m: RegExpExecArray | null 116 while ((m = re.exec(text)) !== null) { 117 if (!knownChannels.has(m[2]!)) continue 118 const start = m.index + m[1]!.length 119 positions.push({ start, end: start + 1 + m[2]!.length }) 120 } 121 return positions 122} 123 124// Slack's search tokenizes on hyphens and requires whole-word matches, so 125// "claude-code-team-en" returns 0 results. Strip the trailing partial segment 126// so the MCP query is "claude-code-team" (complete words only), then filter 127// locally. This keeps the query maximally specific (avoiding the 20-result 128// cap) while never sending a partial word that kills the search. 129function mcpQueryFor(searchToken: string): string { 130 const lastSep = Math.max( 131 searchToken.lastIndexOf('-'), 132 searchToken.lastIndexOf('_'), 133 ) 134 return lastSep > 0 ? searchToken.slice(0, lastSep) : searchToken 135} 136 137// Find a cached entry whose key is a prefix of mcpQuery and still has 138// matches for searchToken. Lets typing "c"→"cl"→"cla" reuse the "c" cache 139// instead of issuing a new MCP call per keystroke. 140function findReusableCacheEntry( 141 mcpQuery: string, 142 searchToken: string, 143): string[] | undefined { 144 let best: string[] | undefined 145 let bestLen = 0 146 for (const [key, channels] of cache) { 147 if ( 148 mcpQuery.startsWith(key) && 149 key.length > bestLen && 150 channels.some(c => c.startsWith(searchToken)) 151 ) { 152 best = channels 153 bestLen = key.length 154 } 155 } 156 return best 157} 158 159export async function getSlackChannelSuggestions( 160 clients: MCPServerConnection[], 161 searchToken: string, 162): Promise<SuggestionItem[]> { 163 if (!searchToken) return [] 164 165 const mcpQuery = mcpQueryFor(searchToken) 166 const lower = searchToken.toLowerCase() 167 168 let channels = cache.get(mcpQuery) ?? findReusableCacheEntry(mcpQuery, lower) 169 if (!channels) { 170 if (inflightQuery === mcpQuery && inflightPromise) { 171 channels = await inflightPromise 172 } else { 173 inflightQuery = mcpQuery 174 inflightPromise = fetchChannels(clients, mcpQuery) 175 channels = await inflightPromise 176 cache.set(mcpQuery, channels) 177 const before = knownChannels.size 178 for (const c of channels) knownChannels.add(c) 179 if (knownChannels.size !== before) { 180 knownChannelsVersion++ 181 knownChannelsChanged.emit() 182 } 183 if (cache.size > 50) { 184 cache.delete(cache.keys().next().value!) 185 } 186 if (inflightQuery === mcpQuery) { 187 inflightQuery = null 188 inflightPromise = null 189 } 190 } 191 } 192 193 return channels 194 .filter(c => c.startsWith(lower)) 195 .sort() 196 .slice(0, 10) 197 .map(c => ({ 198 id: `slack-channel-${c}`, 199 displayText: `#${c}`, 200 })) 201} 202 203export function clearSlackChannelCache(): void { 204 cache.clear() 205 knownChannels.clear() 206 knownChannelsVersion = 0 207 inflightQuery = null 208 inflightPromise = null 209}