source dump of claude code
at main 350 lines 9.8 kB view raw
1import { getDirectConnectServerUrl, getSessionId } from '../bootstrap/state.js' 2import { stringWidth } from '../ink/stringWidth.js' 3import type { LogOption } from '../types/logs.js' 4import { getSubscriptionName, isClaudeAISubscriber } from './auth.js' 5import { getCwd } from './cwd.js' 6import { getDisplayPath } from './file.js' 7import { 8 truncate, 9 truncateToWidth, 10 truncateToWidthNoEllipsis, 11} from './format.js' 12import { getStoredChangelogFromMemory, parseChangelog } from './releaseNotes.js' 13import { gt } from './semver.js' 14import { loadMessageLogs } from './sessionStorage.js' 15import { getInitialSettings } from './settings/settings.js' 16 17// Layout constants 18const MAX_LEFT_WIDTH = 50 19const MAX_USERNAME_LENGTH = 20 20const BORDER_PADDING = 4 21const DIVIDER_WIDTH = 1 22const CONTENT_PADDING = 2 23 24export type LayoutMode = 'horizontal' | 'compact' 25 26export type LayoutDimensions = { 27 leftWidth: number 28 rightWidth: number 29 totalWidth: number 30} 31 32/** 33 * Determines the layout mode based on terminal width 34 */ 35export function getLayoutMode(columns: number): LayoutMode { 36 if (columns >= 70) return 'horizontal' 37 return 'compact' 38} 39 40/** 41 * Calculates layout dimensions for the LogoV2 component 42 */ 43export function calculateLayoutDimensions( 44 columns: number, 45 layoutMode: LayoutMode, 46 optimalLeftWidth: number, 47): LayoutDimensions { 48 if (layoutMode === 'horizontal') { 49 const leftWidth = optimalLeftWidth 50 const usedSpace = 51 BORDER_PADDING + CONTENT_PADDING + DIVIDER_WIDTH + leftWidth 52 const availableForRight = columns - usedSpace 53 54 let rightWidth = Math.max(30, availableForRight) 55 const totalWidth = Math.min( 56 leftWidth + rightWidth + DIVIDER_WIDTH + CONTENT_PADDING, 57 columns - BORDER_PADDING, 58 ) 59 60 // Recalculate right width if we had to cap the total 61 if (totalWidth < leftWidth + rightWidth + DIVIDER_WIDTH + CONTENT_PADDING) { 62 rightWidth = totalWidth - leftWidth - DIVIDER_WIDTH - CONTENT_PADDING 63 } 64 65 return { leftWidth, rightWidth, totalWidth } 66 } 67 68 // Vertical mode 69 const totalWidth = Math.min(columns - BORDER_PADDING, MAX_LEFT_WIDTH + 20) 70 return { 71 leftWidth: totalWidth, 72 rightWidth: totalWidth, 73 totalWidth, 74 } 75} 76 77/** 78 * Calculates optimal left panel width based on content 79 */ 80export function calculateOptimalLeftWidth( 81 welcomeMessage: string, 82 truncatedCwd: string, 83 modelLine: string, 84): number { 85 const contentWidth = Math.max( 86 stringWidth(welcomeMessage), 87 stringWidth(truncatedCwd), 88 stringWidth(modelLine), 89 20, // Minimum for clawd art 90 ) 91 return Math.min(contentWidth + 4, MAX_LEFT_WIDTH) // +4 for padding 92} 93 94/** 95 * Formats the welcome message based on username 96 */ 97export function formatWelcomeMessage(username: string | null): string { 98 if (!username || username.length > MAX_USERNAME_LENGTH) { 99 return 'Welcome back!' 100 } 101 return `Welcome back ${username}!` 102} 103 104/** 105 * Truncates a path in the middle if it's too long. 106 * Width-aware: uses stringWidth() for correct CJK/emoji measurement. 107 */ 108export function truncatePath(path: string, maxLength: number): string { 109 if (stringWidth(path) <= maxLength) return path 110 111 const separator = '/' 112 const ellipsis = '…' 113 const ellipsisWidth = 1 // '…' is always 1 column 114 const separatorWidth = 1 115 116 const parts = path.split(separator) 117 const first = parts[0] || '' 118 const last = parts[parts.length - 1] || '' 119 const firstWidth = stringWidth(first) 120 const lastWidth = stringWidth(last) 121 122 // Only one part, so show as much of it as we can 123 if (parts.length === 1) { 124 return truncateToWidth(path, maxLength) 125 } 126 127 // We don't have enough space to show the last part, so truncate it 128 // But since firstPart is empty (unix) we don't want the extra ellipsis 129 if (first === '' && ellipsisWidth + separatorWidth + lastWidth >= maxLength) { 130 return `${separator}${truncateToWidth(last, Math.max(1, maxLength - separatorWidth))}` 131 } 132 133 // We have a first part so let's show the ellipsis and truncate last part 134 if ( 135 first !== '' && 136 ellipsisWidth * 2 + separatorWidth + lastWidth >= maxLength 137 ) { 138 return `${ellipsis}${separator}${truncateToWidth(last, Math.max(1, maxLength - ellipsisWidth - separatorWidth))}` 139 } 140 141 // Truncate first and leave last 142 if (parts.length === 2) { 143 const availableForFirst = 144 maxLength - ellipsisWidth - separatorWidth - lastWidth 145 return `${truncateToWidthNoEllipsis(first, availableForFirst)}${ellipsis}${separator}${last}` 146 } 147 148 // Now we start removing middle parts 149 150 let available = 151 maxLength - firstWidth - lastWidth - ellipsisWidth - 2 * separatorWidth 152 153 // Just the first and last are too long, so truncate first 154 if (available <= 0) { 155 const availableForFirst = Math.max( 156 0, 157 maxLength - lastWidth - ellipsisWidth - 2 * separatorWidth, 158 ) 159 const truncatedFirst = truncateToWidthNoEllipsis(first, availableForFirst) 160 return `${truncatedFirst}${separator}${ellipsis}${separator}${last}` 161 } 162 163 // Try to keep as many middle parts as possible 164 const middleParts = [] 165 for (let i = parts.length - 2; i > 0; i--) { 166 const part = parts[i] 167 if (part && stringWidth(part) + separatorWidth <= available) { 168 middleParts.unshift(part) 169 available -= stringWidth(part) + separatorWidth 170 } else { 171 break 172 } 173 } 174 175 if (middleParts.length === 0) { 176 return `${first}${separator}${ellipsis}${separator}${last}` 177 } 178 179 return `${first}${separator}${ellipsis}${separator}${middleParts.join(separator)}${separator}${last}` 180} 181 182// Simple cache for preloaded activity 183let cachedActivity: LogOption[] = [] 184let cachePromise: Promise<LogOption[]> | null = null 185 186/** 187 * Preloads recent conversations for display in Logo v2 188 */ 189export async function getRecentActivity(): Promise<LogOption[]> { 190 // Return existing promise if already loading 191 if (cachePromise) { 192 return cachePromise 193 } 194 195 const currentSessionId = getSessionId() 196 cachePromise = loadMessageLogs(10) 197 .then(logs => { 198 cachedActivity = logs 199 .filter(log => { 200 if (log.isSidechain) return false 201 if (log.sessionId === currentSessionId) return false 202 if (log.summary?.includes('I apologize')) return false 203 204 // Filter out sessions where both summary and firstPrompt are "No prompt" or missing 205 const hasSummary = log.summary && log.summary !== 'No prompt' 206 const hasFirstPrompt = 207 log.firstPrompt && log.firstPrompt !== 'No prompt' 208 return hasSummary || hasFirstPrompt 209 }) 210 .slice(0, 3) 211 return cachedActivity 212 }) 213 .catch(() => { 214 cachedActivity = [] 215 return cachedActivity 216 }) 217 218 return cachePromise 219} 220 221/** 222 * Gets cached activity synchronously 223 */ 224export function getRecentActivitySync(): LogOption[] { 225 return cachedActivity 226} 227 228/** 229 * Formats release notes for display, with smart truncation 230 */ 231export function formatReleaseNoteForDisplay( 232 note: string, 233 maxWidth: number, 234): string { 235 // Simply truncate at the max width, same as Recent Activity descriptions 236 return truncate(note, maxWidth) 237} 238 239/** 240 * Gets the common logo display data used by both LogoV2 and CondensedLogo 241 */ 242export function getLogoDisplayData(): { 243 version: string 244 cwd: string 245 billingType: string 246 agentName: string | undefined 247} { 248 const version = process.env.DEMO_VERSION ?? MACRO.VERSION 249 const serverUrl = getDirectConnectServerUrl() 250 const displayPath = process.env.DEMO_VERSION 251 ? '/code/claude' 252 : getDisplayPath(getCwd()) 253 const cwd = serverUrl 254 ? `${displayPath} in ${serverUrl.replace(/^https?:\/\//, '')}` 255 : displayPath 256 const billingType = isClaudeAISubscriber() 257 ? getSubscriptionName() 258 : 'API Usage Billing' 259 const agentName = getInitialSettings().agent 260 261 return { 262 version, 263 cwd, 264 billingType, 265 agentName, 266 } 267} 268 269/** 270 * Determines how to display model and billing information based on available width 271 */ 272export function formatModelAndBilling( 273 modelName: string, 274 billingType: string, 275 availableWidth: number, 276): { 277 shouldSplit: boolean 278 truncatedModel: string 279 truncatedBilling: string 280} { 281 const separator = ' · ' 282 const combinedWidth = 283 stringWidth(modelName) + separator.length + stringWidth(billingType) 284 const shouldSplit = combinedWidth > availableWidth 285 286 if (shouldSplit) { 287 return { 288 shouldSplit: true, 289 truncatedModel: truncate(modelName, availableWidth), 290 truncatedBilling: truncate(billingType, availableWidth), 291 } 292 } 293 294 return { 295 shouldSplit: false, 296 truncatedModel: truncate( 297 modelName, 298 Math.max( 299 availableWidth - stringWidth(billingType) - separator.length, 300 10, 301 ), 302 ), 303 truncatedBilling: billingType, 304 } 305} 306 307/** 308 * Gets recent release notes for Logo v2 display 309 * For ants, uses commits bundled at build time 310 * For external users, uses public changelog 311 */ 312export function getRecentReleaseNotesSync(maxItems: number): string[] { 313 // For ants, use bundled changelog 314 if (process.env.USER_TYPE === 'ant') { 315 const changelog = MACRO.VERSION_CHANGELOG 316 if (changelog) { 317 const commits = changelog.trim().split('\n').filter(Boolean) 318 return commits.slice(0, maxItems) 319 } 320 return [] 321 } 322 323 const changelog = getStoredChangelogFromMemory() 324 if (!changelog) { 325 return [] 326 } 327 328 let parsed 329 try { 330 parsed = parseChangelog(changelog) 331 } catch { 332 return [] 333 } 334 335 // Get notes from recent versions 336 const allNotes: string[] = [] 337 const versions = Object.keys(parsed) 338 .sort((a, b) => (gt(a, b) ? -1 : 1)) 339 .slice(0, 3) // Look at top 3 recent versions 340 341 for (const version of versions) { 342 const notes = parsed[version] 343 if (notes) { 344 allNotes.push(...notes) 345 } 346 } 347 348 // Return raw notes without filtering or premature truncation 349 return allNotes.slice(0, maxItems) 350}