logoV2Utils.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  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. // Layout constants
  17. const MAX_LEFT_WIDTH = 50
  18. const MAX_USERNAME_LENGTH = 20
  19. const BORDER_PADDING = 4
  20. const DIVIDER_WIDTH = 1
  21. const CONTENT_PADDING = 2
  22. export type LayoutMode = 'horizontal' | 'compact'
  23. export type LayoutDimensions = {
  24. leftWidth: number
  25. rightWidth: number
  26. totalWidth: number
  27. }
  28. /**
  29. * Determines the layout mode based on terminal width
  30. */
  31. export function getLayoutMode(columns: number): LayoutMode {
  32. if (columns >= 70) return 'horizontal'
  33. return 'compact'
  34. }
  35. /**
  36. * Calculates layout dimensions for the LogoV2 component
  37. */
  38. export function calculateLayoutDimensions(
  39. columns: number,
  40. layoutMode: LayoutMode,
  41. optimalLeftWidth: number,
  42. ): LayoutDimensions {
  43. if (layoutMode === 'horizontal') {
  44. const leftWidth = optimalLeftWidth
  45. const usedSpace =
  46. BORDER_PADDING + CONTENT_PADDING + DIVIDER_WIDTH + leftWidth
  47. const availableForRight = columns - usedSpace
  48. let rightWidth = Math.max(30, availableForRight)
  49. const totalWidth = Math.min(
  50. leftWidth + rightWidth + DIVIDER_WIDTH + CONTENT_PADDING,
  51. columns - BORDER_PADDING,
  52. )
  53. // Recalculate right width if we had to cap the total
  54. if (totalWidth < leftWidth + rightWidth + DIVIDER_WIDTH + CONTENT_PADDING) {
  55. rightWidth = totalWidth - leftWidth - DIVIDER_WIDTH - CONTENT_PADDING
  56. }
  57. return { leftWidth, rightWidth, totalWidth }
  58. }
  59. // Vertical mode
  60. const totalWidth = Math.min(columns - BORDER_PADDING, MAX_LEFT_WIDTH + 20)
  61. return {
  62. leftWidth: totalWidth,
  63. rightWidth: totalWidth,
  64. totalWidth,
  65. }
  66. }
  67. /**
  68. * Calculates optimal left panel width based on content
  69. */
  70. export function calculateOptimalLeftWidth(
  71. welcomeMessage: string,
  72. truncatedCwd: string,
  73. modelLine: string,
  74. ): number {
  75. const contentWidth = Math.max(
  76. stringWidth(welcomeMessage),
  77. stringWidth(truncatedCwd),
  78. stringWidth(modelLine),
  79. 20, // Minimum for clawd art
  80. )
  81. return Math.min(contentWidth + 4, MAX_LEFT_WIDTH) // +4 for padding
  82. }
  83. /**
  84. * Formats the welcome message based on username
  85. */
  86. export function formatWelcomeMessage(username: string | null): string {
  87. if (!username || username.length > MAX_USERNAME_LENGTH) {
  88. return 'Welcome back!'
  89. }
  90. return `Welcome back ${username}!`
  91. }
  92. /**
  93. * Truncates a path in the middle if it's too long.
  94. * Width-aware: uses stringWidth() for correct CJK/emoji measurement.
  95. */
  96. export function truncatePath(path: string, maxLength: number): string {
  97. if (stringWidth(path) <= maxLength) return path
  98. const separator = '/'
  99. const ellipsis = '…'
  100. const ellipsisWidth = 1 // '…' is always 1 column
  101. const separatorWidth = 1
  102. const parts = path.split(separator)
  103. const first = parts[0] || ''
  104. const last = parts[parts.length - 1] || ''
  105. const firstWidth = stringWidth(first)
  106. const lastWidth = stringWidth(last)
  107. // Only one part, so show as much of it as we can
  108. if (parts.length === 1) {
  109. return truncateToWidth(path, maxLength)
  110. }
  111. // We don't have enough space to show the last part, so truncate it
  112. // But since firstPart is empty (unix) we don't want the extra ellipsis
  113. if (first === '' && ellipsisWidth + separatorWidth + lastWidth >= maxLength) {
  114. return `${separator}${truncateToWidth(last, Math.max(1, maxLength - separatorWidth))}`
  115. }
  116. // We have a first part so let's show the ellipsis and truncate last part
  117. if (
  118. first !== '' &&
  119. ellipsisWidth * 2 + separatorWidth + lastWidth >= maxLength
  120. ) {
  121. return `${ellipsis}${separator}${truncateToWidth(last, Math.max(1, maxLength - ellipsisWidth - separatorWidth))}`
  122. }
  123. // Truncate first and leave last
  124. if (parts.length === 2) {
  125. const availableForFirst =
  126. maxLength - ellipsisWidth - separatorWidth - lastWidth
  127. return `${truncateToWidthNoEllipsis(first, availableForFirst)}${ellipsis}${separator}${last}`
  128. }
  129. // Now we start removing middle parts
  130. let available =
  131. maxLength - firstWidth - lastWidth - ellipsisWidth - 2 * separatorWidth
  132. // Just the first and last are too long, so truncate first
  133. if (available <= 0) {
  134. const availableForFirst = Math.max(
  135. 0,
  136. maxLength - lastWidth - ellipsisWidth - 2 * separatorWidth,
  137. )
  138. const truncatedFirst = truncateToWidthNoEllipsis(first, availableForFirst)
  139. return `${truncatedFirst}${separator}${ellipsis}${separator}${last}`
  140. }
  141. // Try to keep as many middle parts as possible
  142. const middleParts = []
  143. for (let i = parts.length - 2; i > 0; i--) {
  144. const part = parts[i]
  145. if (part && stringWidth(part) + separatorWidth <= available) {
  146. middleParts.unshift(part)
  147. available -= stringWidth(part) + separatorWidth
  148. } else {
  149. break
  150. }
  151. }
  152. if (middleParts.length === 0) {
  153. return `${first}${separator}${ellipsis}${separator}${last}`
  154. }
  155. return `${first}${separator}${ellipsis}${separator}${middleParts.join(separator)}${separator}${last}`
  156. }
  157. // Simple cache for preloaded activity
  158. let cachedActivity: LogOption[] = []
  159. let cachePromise: Promise<LogOption[]> | null = null
  160. /**
  161. * Preloads recent conversations for display in Logo v2
  162. */
  163. export async function getRecentActivity(): Promise<LogOption[]> {
  164. // Return existing promise if already loading
  165. if (cachePromise) {
  166. return cachePromise
  167. }
  168. const currentSessionId = getSessionId()
  169. cachePromise = loadMessageLogs(10)
  170. .then(logs => {
  171. cachedActivity = logs
  172. .filter(log => {
  173. if (log.isSidechain) return false
  174. if (log.sessionId === currentSessionId) return false
  175. if (log.summary?.includes('I apologize')) return false
  176. // Filter out sessions where both summary and firstPrompt are "No prompt" or missing
  177. const hasSummary = log.summary && log.summary !== 'No prompt'
  178. const hasFirstPrompt =
  179. log.firstPrompt && log.firstPrompt !== 'No prompt'
  180. return hasSummary || hasFirstPrompt
  181. })
  182. .slice(0, 3)
  183. return cachedActivity
  184. })
  185. .catch(() => {
  186. cachedActivity = []
  187. return cachedActivity
  188. })
  189. return cachePromise
  190. }
  191. /**
  192. * Gets cached activity synchronously
  193. */
  194. export function getRecentActivitySync(): LogOption[] {
  195. return cachedActivity
  196. }
  197. /**
  198. * Formats release notes for display, with smart truncation
  199. */
  200. export function formatReleaseNoteForDisplay(
  201. note: string,
  202. maxWidth: number,
  203. ): string {
  204. // Simply truncate at the max width, same as Recent Activity descriptions
  205. return truncate(note, maxWidth)
  206. }
  207. /**
  208. * Gets the common logo display data used by both LogoV2 and CondensedLogo
  209. */
  210. export function getLogoDisplayData(): {
  211. version: string
  212. cwd: string
  213. billingType: string
  214. agentName: string | undefined
  215. } {
  216. const version = process.env.DEMO_VERSION ?? MACRO.VERSION
  217. const serverUrl = getDirectConnectServerUrl()
  218. const displayPath = process.env.DEMO_VERSION
  219. ? '/code/claude'
  220. : getDisplayPath(getCwd())
  221. const cwd = serverUrl
  222. ? `${displayPath} in ${serverUrl.replace(/^https?:\/\//, '')}`
  223. : displayPath
  224. const billingType = isClaudeAISubscriber()
  225. ? getSubscriptionName()
  226. : 'API Usage Billing'
  227. const agentName = getInitialSettings().agent
  228. return {
  229. version,
  230. cwd,
  231. billingType,
  232. agentName,
  233. }
  234. }
  235. /**
  236. * Determines how to display model and billing information based on available width
  237. */
  238. export function formatModelAndBilling(
  239. modelName: string,
  240. billingType: string,
  241. availableWidth: number,
  242. ): {
  243. shouldSplit: boolean
  244. truncatedModel: string
  245. truncatedBilling: string
  246. } {
  247. const separator = ' · '
  248. const combinedWidth =
  249. stringWidth(modelName) + separator.length + stringWidth(billingType)
  250. const shouldSplit = combinedWidth > availableWidth
  251. if (shouldSplit) {
  252. return {
  253. shouldSplit: true,
  254. truncatedModel: truncate(modelName, availableWidth),
  255. truncatedBilling: truncate(billingType, availableWidth),
  256. }
  257. }
  258. return {
  259. shouldSplit: false,
  260. truncatedModel: truncate(
  261. modelName,
  262. Math.max(
  263. availableWidth - stringWidth(billingType) - separator.length,
  264. 10,
  265. ),
  266. ),
  267. truncatedBilling: billingType,
  268. }
  269. }
  270. /**
  271. * Gets recent release notes for Logo v2 display
  272. * For ants, uses commits bundled at build time
  273. * For external users, uses public changelog
  274. */
  275. export function getRecentReleaseNotesSync(maxItems: number): string[] {
  276. // For ants, use bundled changelog
  277. if (process.env.USER_TYPE === 'ant') {
  278. const changelog = MACRO.VERSION_CHANGELOG
  279. if (changelog) {
  280. const commits = changelog.trim().split('\n').filter(Boolean)
  281. return commits.slice(0, maxItems)
  282. }
  283. return []
  284. }
  285. const changelog = getStoredChangelogFromMemory()
  286. if (!changelog) {
  287. return []
  288. }
  289. let parsed
  290. try {
  291. parsed = parseChangelog(changelog)
  292. } catch {
  293. return []
  294. }
  295. // Get notes from recent versions
  296. const allNotes: string[] = []
  297. const versions = Object.keys(parsed)
  298. .sort((a, b) => (gt(a, b) ? -1 : 1))
  299. .slice(0, 3) // Look at top 3 recent versions
  300. for (const version of versions) {
  301. const notes = parsed[version]
  302. if (notes) {
  303. allNotes.push(...notes)
  304. }
  305. }
  306. // Return raw notes without filtering or premature truncation
  307. return allNotes.slice(0, maxItems)
  308. }