351 lines
9.6 KiB
TypeScript
351 lines
9.6 KiB
TypeScript
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)
|
|
}
|