init claude-code
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user