logoV2Utils.ts
1 import { getDirectConnectServerUrl, getSessionId } from '../bootstrap/state.js' 2 import { stringWidth } from '../ink/stringWidth.js' 3 import type { LogOption } from '../types/logs.js' 4 import { getSubscriptionName, isClaudeAISubscriber } from './auth.js' 5 import { getCwd } from './cwd.js' 6 import { getDisplayPath } from './file.js' 7 import { 8 truncate, 9 truncateToWidth, 10 truncateToWidthNoEllipsis, 11 } from './format.js' 12 import { getStoredChangelogFromMemory, parseChangelog } from './releaseNotes.js' 13 import { gt } from './semver.js' 14 import { loadMessageLogs } from './sessionStorage.js' 15 import { getInitialSettings } from './settings/settings.js' 16 17 // Layout constants 18 const MAX_LEFT_WIDTH = 50 19 const MAX_USERNAME_LENGTH = 20 20 const BORDER_PADDING = 4 21 const DIVIDER_WIDTH = 1 22 const CONTENT_PADDING = 2 23 24 export type LayoutMode = 'horizontal' | 'compact' 25 26 export type LayoutDimensions = { 27 leftWidth: number 28 rightWidth: number 29 totalWidth: number 30 } 31 32 /** 33 * Determines the layout mode based on terminal width 34 */ 35 export 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 */ 43 export 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 */ 80 export 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 */ 97 export 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 */ 108 export 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 183 let cachedActivity: LogOption[] = [] 184 let cachePromise: Promise<LogOption[]> | null = null 185 186 /** 187 * Preloads recent conversations for display in Logo v2 188 */ 189 export 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 */ 224 export function getRecentActivitySync(): LogOption[] { 225 return cachedActivity 226 } 227 228 /** 229 * Formats release notes for display, with smart truncation 230 */ 231 export 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 */ 242 export 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 */ 272 export 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 */ 312 export 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 }