| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350 |
- import { getDirectConnectServerUrl, getSessionId } from '../bootstrap/state.js'
- import { stringWidth } from '../ink/stringWidth.js'
- import type { LogOption } from '../types/logs.js'
- import { getSubscriptionName, isClaudeAISubscriber } from './auth.js'
- import { getCwd } from './cwd.js'
- import { getDisplayPath } from './file.js'
- import {
- truncate,
- truncateToWidth,
- truncateToWidthNoEllipsis,
- } from './format.js'
- import { getStoredChangelogFromMemory, parseChangelog } from './releaseNotes.js'
- import { gt } from './semver.js'
- import { loadMessageLogs } from './sessionStorage.js'
- import { getInitialSettings } from './settings/settings.js'
- // Layout constants
- const MAX_LEFT_WIDTH = 50
- const MAX_USERNAME_LENGTH = 20
- const BORDER_PADDING = 4
- const DIVIDER_WIDTH = 1
- const CONTENT_PADDING = 2
- export type LayoutMode = 'horizontal' | 'compact'
- export type LayoutDimensions = {
- leftWidth: number
- rightWidth: number
- totalWidth: number
- }
- /**
- * Determines the layout mode based on terminal width
- */
- export function getLayoutMode(columns: number): LayoutMode {
- if (columns >= 70) return 'horizontal'
- return 'compact'
- }
- /**
- * Calculates layout dimensions for the LogoV2 component
- */
- export function calculateLayoutDimensions(
- columns: number,
- layoutMode: LayoutMode,
- optimalLeftWidth: number,
- ): LayoutDimensions {
- if (layoutMode === 'horizontal') {
- const leftWidth = optimalLeftWidth
- const usedSpace =
- BORDER_PADDING + CONTENT_PADDING + DIVIDER_WIDTH + leftWidth
- const availableForRight = columns - usedSpace
- let rightWidth = Math.max(30, availableForRight)
- const totalWidth = Math.min(
- leftWidth + rightWidth + DIVIDER_WIDTH + CONTENT_PADDING,
- columns - BORDER_PADDING,
- )
- // Recalculate right width if we had to cap the total
- if (totalWidth < leftWidth + rightWidth + DIVIDER_WIDTH + CONTENT_PADDING) {
- rightWidth = totalWidth - leftWidth - DIVIDER_WIDTH - CONTENT_PADDING
- }
- return { leftWidth, rightWidth, totalWidth }
- }
- // Vertical mode
- const totalWidth = Math.min(columns - BORDER_PADDING, MAX_LEFT_WIDTH + 20)
- return {
- leftWidth: totalWidth,
- rightWidth: totalWidth,
- totalWidth,
- }
- }
- /**
- * Calculates optimal left panel width based on content
- */
- export function calculateOptimalLeftWidth(
- welcomeMessage: string,
- truncatedCwd: string,
- modelLine: string,
- ): number {
- const contentWidth = Math.max(
- stringWidth(welcomeMessage),
- stringWidth(truncatedCwd),
- stringWidth(modelLine),
- 20, // Minimum for clawd art
- )
- return Math.min(contentWidth + 4, MAX_LEFT_WIDTH) // +4 for padding
- }
- /**
- * Formats the welcome message based on username
- */
- export function formatWelcomeMessage(username: string | null): string {
- if (!username || username.length > MAX_USERNAME_LENGTH) {
- return 'Welcome back!'
- }
- return `Welcome back ${username}!`
- }
- /**
- * Truncates a path in the middle if it's too long.
- * Width-aware: uses stringWidth() for correct CJK/emoji measurement.
- */
- export function truncatePath(path: string, maxLength: number): string {
- if (stringWidth(path) <= maxLength) return path
- const separator = '/'
- const ellipsis = '…'
- const ellipsisWidth = 1 // '…' is always 1 column
- const separatorWidth = 1
- const parts = path.split(separator)
- const first = parts[0] || ''
- const last = parts[parts.length - 1] || ''
- const firstWidth = stringWidth(first)
- const lastWidth = stringWidth(last)
- // Only one part, so show as much of it as we can
- if (parts.length === 1) {
- return truncateToWidth(path, maxLength)
- }
- // We don't have enough space to show the last part, so truncate it
- // But since firstPart is empty (unix) we don't want the extra ellipsis
- if (first === '' && ellipsisWidth + separatorWidth + lastWidth >= maxLength) {
- return `${separator}${truncateToWidth(last, Math.max(1, maxLength - separatorWidth))}`
- }
- // We have a first part so let's show the ellipsis and truncate last part
- if (
- first !== '' &&
- ellipsisWidth * 2 + separatorWidth + lastWidth >= maxLength
- ) {
- return `${ellipsis}${separator}${truncateToWidth(last, Math.max(1, maxLength - ellipsisWidth - separatorWidth))}`
- }
- // Truncate first and leave last
- if (parts.length === 2) {
- const availableForFirst =
- maxLength - ellipsisWidth - separatorWidth - lastWidth
- return `${truncateToWidthNoEllipsis(first, availableForFirst)}${ellipsis}${separator}${last}`
- }
- // Now we start removing middle parts
- let available =
- maxLength - firstWidth - lastWidth - ellipsisWidth - 2 * separatorWidth
- // Just the first and last are too long, so truncate first
- if (available <= 0) {
- const availableForFirst = Math.max(
- 0,
- maxLength - lastWidth - ellipsisWidth - 2 * separatorWidth,
- )
- const truncatedFirst = truncateToWidthNoEllipsis(first, availableForFirst)
- return `${truncatedFirst}${separator}${ellipsis}${separator}${last}`
- }
- // Try to keep as many middle parts as possible
- const middleParts = []
- for (let i = parts.length - 2; i > 0; i--) {
- const part = parts[i]
- if (part && stringWidth(part) + separatorWidth <= available) {
- middleParts.unshift(part)
- available -= stringWidth(part) + separatorWidth
- } else {
- break
- }
- }
- if (middleParts.length === 0) {
- return `${first}${separator}${ellipsis}${separator}${last}`
- }
- return `${first}${separator}${ellipsis}${separator}${middleParts.join(separator)}${separator}${last}`
- }
- // Simple cache for preloaded activity
- let cachedActivity: LogOption[] = []
- let cachePromise: Promise<LogOption[]> | null = null
- /**
- * Preloads recent conversations for display in Logo v2
- */
- export async function getRecentActivity(): Promise<LogOption[]> {
- // Return existing promise if already loading
- if (cachePromise) {
- return cachePromise
- }
- const currentSessionId = getSessionId()
- cachePromise = loadMessageLogs(10)
- .then(logs => {
- cachedActivity = logs
- .filter(log => {
- if (log.isSidechain) return false
- if (log.sessionId === currentSessionId) return false
- if (log.summary?.includes('I apologize')) return false
- // Filter out sessions where both summary and firstPrompt are "No prompt" or missing
- const hasSummary = log.summary && log.summary !== 'No prompt'
- const hasFirstPrompt =
- log.firstPrompt && log.firstPrompt !== 'No prompt'
- return hasSummary || hasFirstPrompt
- })
- .slice(0, 3)
- return cachedActivity
- })
- .catch(() => {
- cachedActivity = []
- return cachedActivity
- })
- return cachePromise
- }
- /**
- * Gets cached activity synchronously
- */
- export function getRecentActivitySync(): LogOption[] {
- return cachedActivity
- }
- /**
- * Formats release notes for display, with smart truncation
- */
- export function formatReleaseNoteForDisplay(
- note: string,
- maxWidth: number,
- ): string {
- // Simply truncate at the max width, same as Recent Activity descriptions
- return truncate(note, maxWidth)
- }
- /**
- * Gets the common logo display data used by both LogoV2 and CondensedLogo
- */
- export function getLogoDisplayData(): {
- version: string
- cwd: string
- billingType: string
- agentName: string | undefined
- } {
- const version = process.env.DEMO_VERSION ?? MACRO.VERSION
- const serverUrl = getDirectConnectServerUrl()
- const displayPath = process.env.DEMO_VERSION
- ? '/code/claude'
- : getDisplayPath(getCwd())
- const cwd = serverUrl
- ? `${displayPath} in ${serverUrl.replace(/^https?:\/\//, '')}`
- : displayPath
- const billingType = isClaudeAISubscriber()
- ? getSubscriptionName()
- : 'API Usage Billing'
- const agentName = getInitialSettings().agent
- return {
- version,
- cwd,
- billingType,
- agentName,
- }
- }
- /**
- * Determines how to display model and billing information based on available width
- */
- export function formatModelAndBilling(
- modelName: string,
- billingType: string,
- availableWidth: number,
- ): {
- shouldSplit: boolean
- truncatedModel: string
- truncatedBilling: string
- } {
- const separator = ' · '
- const combinedWidth =
- stringWidth(modelName) + separator.length + stringWidth(billingType)
- const shouldSplit = combinedWidth > availableWidth
- if (shouldSplit) {
- return {
- shouldSplit: true,
- truncatedModel: truncate(modelName, availableWidth),
- truncatedBilling: truncate(billingType, availableWidth),
- }
- }
- return {
- shouldSplit: false,
- truncatedModel: truncate(
- modelName,
- Math.max(
- availableWidth - stringWidth(billingType) - separator.length,
- 10,
- ),
- ),
- truncatedBilling: billingType,
- }
- }
- /**
- * Gets recent release notes for Logo v2 display
- * For ants, uses commits bundled at build time
- * For external users, uses public changelog
- */
- export function getRecentReleaseNotesSync(maxItems: number): string[] {
- // For ants, use bundled changelog
- if (process.env.USER_TYPE === 'ant') {
- const changelog = MACRO.VERSION_CHANGELOG
- if (changelog) {
- const commits = changelog.trim().split('\n').filter(Boolean)
- return commits.slice(0, maxItems)
- }
- return []
- }
- const changelog = getStoredChangelogFromMemory()
- if (!changelog) {
- return []
- }
- let parsed
- try {
- parsed = parseChangelog(changelog)
- } catch {
- return []
- }
- // Get notes from recent versions
- const allNotes: string[] = []
- const versions = Object.keys(parsed)
- .sort((a, b) => (gt(a, b) ? -1 : 1))
- .slice(0, 3) // Look at top 3 recent versions
- for (const version of versions) {
- const notes = parsed[version]
- if (notes) {
- allNotes.push(...notes)
- }
- }
- // Return raw notes without filtering or premature truncation
- return allNotes.slice(0, maxItems)
- }
|