/ utils / logoV2Utils.ts
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  }