@dreb/coding-agent 2.0.7 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"footer-data-provider.js","sourceRoot":"","sources":["../../src/core/footer-data-provider.ts"],"names":[],"mappings":"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","sourcesContent":["import { type ExecFileException, execFile, spawnSync } from \"child_process\";\nimport { existsSync, type FSWatcher, readFileSync, statSync, watch } from \"fs\";\nimport { dirname, join, resolve } from \"path\";\n\ntype GitPaths = {\n\trepoDir: string;\n\tcommonGitDir: string;\n\theadPath: string;\n};\n\n/**\n * Find git metadata paths by walking up from cwd.\n * Handles both regular git repos (.git is a directory) and worktrees (.git is a file).\n */\nfunction findGitPaths(): GitPaths | null {\n\tlet dir = process.cwd();\n\twhile (true) {\n\t\tconst gitPath = join(dir, \".git\");\n\t\tif (existsSync(gitPath)) {\n\t\t\ttry {\n\t\t\t\tconst stat = statSync(gitPath);\n\t\t\t\tif (stat.isFile()) {\n\t\t\t\t\tconst content = readFileSync(gitPath, \"utf8\").trim();\n\t\t\t\t\tif (content.startsWith(\"gitdir: \")) {\n\t\t\t\t\t\tconst gitDir = resolve(dir, content.slice(8).trim());\n\t\t\t\t\t\tconst headPath = join(gitDir, \"HEAD\");\n\t\t\t\t\t\tif (!existsSync(headPath)) return null;\n\t\t\t\t\t\tconst commonDirPath = join(gitDir, \"commondir\");\n\t\t\t\t\t\tconst commonGitDir = existsSync(commonDirPath)\n\t\t\t\t\t\t\t? resolve(gitDir, readFileSync(commonDirPath, \"utf8\").trim())\n\t\t\t\t\t\t\t: gitDir;\n\t\t\t\t\t\treturn { repoDir: dir, commonGitDir, headPath };\n\t\t\t\t\t}\n\t\t\t\t} else if (stat.isDirectory()) {\n\t\t\t\t\tconst headPath = join(gitPath, \"HEAD\");\n\t\t\t\t\tif (!existsSync(headPath)) return null;\n\t\t\t\t\treturn { repoDir: dir, commonGitDir: gitPath, headPath };\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}\n\t\tconst parent = dirname(dir);\n\t\tif (parent === dir) return null;\n\t\tdir = parent;\n\t}\n}\n\n/** Ask git for the current branch. Returns null on detached HEAD or if git is unavailable. */\nfunction resolveBranchWithGitSync(repoDir: string): string | null {\n\tconst result = spawnSync(\"git\", [\"--no-optional-locks\", \"symbolic-ref\", \"--quiet\", \"--short\", \"HEAD\"], {\n\t\tcwd: repoDir,\n\t\tencoding: \"utf8\",\n\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t});\n\tconst branch = result.status === 0 ? result.stdout.trim() : \"\";\n\treturn branch || null;\n}\n\n/** Ask git for the current branch asynchronously. Returns null on detached HEAD or if git is unavailable. */\nfunction resolveBranchWithGitAsync(repoDir: string): Promise<string | null> {\n\treturn new Promise((resolvePromise) => {\n\t\texecFile(\n\t\t\t\"git\",\n\t\t\t[\"--no-optional-locks\", \"symbolic-ref\", \"--quiet\", \"--short\", \"HEAD\"],\n\t\t\t{\n\t\t\t\tcwd: repoDir,\n\t\t\t\tencoding: \"utf8\",\n\t\t\t},\n\t\t\t(error: ExecFileException | null, stdout: string) => {\n\t\t\t\tif (error) {\n\t\t\t\t\tresolvePromise(null);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst branch = stdout.trim();\n\t\t\t\tresolvePromise(branch || null);\n\t\t\t},\n\t\t);\n\t});\n}\n\n/**\n * Provides git branch and extension statuses - data not otherwise accessible to extensions.\n * Token stats, model info available via ctx.sessionManager and ctx.model.\n */\nexport class FooterDataProvider {\n\tprivate static readonly WATCH_DEBOUNCE_MS = 500;\n\n\tprivate extensionStatuses = new Map<string, string>();\n\tprivate cachedBranch: string | null | undefined = undefined;\n\tprivate gitPaths: GitPaths | null | undefined = undefined;\n\tprivate headWatcher: FSWatcher | null = null;\n\tprivate reftableWatcher: FSWatcher | null = null;\n\tprivate branchChangeCallbacks = new Set<() => void>();\n\tprivate availableProviderCount = 0;\n\tprivate refreshTimer: ReturnType<typeof setTimeout> | null = null;\n\tprivate refreshInFlight = false;\n\tprivate refreshPending = false;\n\tprivate disposed = false;\n\n\tconstructor() {\n\t\tthis.gitPaths = findGitPaths();\n\t\tthis.setupGitWatcher();\n\t}\n\n\t/** Current git branch, null if not in repo, \"detached\" if detached HEAD */\n\tgetGitBranch(): string | null {\n\t\tif (this.cachedBranch === undefined) {\n\t\t\tthis.cachedBranch = this.resolveGitBranchSync();\n\t\t}\n\t\treturn this.cachedBranch;\n\t}\n\n\t/** Extension status texts set via ctx.ui.setStatus() */\n\tgetExtensionStatuses(): ReadonlyMap<string, string> {\n\t\treturn this.extensionStatuses;\n\t}\n\n\t/** Subscribe to git branch changes. Returns unsubscribe function. */\n\tonBranchChange(callback: () => void): () => void {\n\t\tthis.branchChangeCallbacks.add(callback);\n\t\treturn () => this.branchChangeCallbacks.delete(callback);\n\t}\n\n\t/** Internal: set extension status */\n\tsetExtensionStatus(key: string, text: string | undefined): void {\n\t\tif (text === undefined) {\n\t\t\tthis.extensionStatuses.delete(key);\n\t\t} else {\n\t\t\tthis.extensionStatuses.set(key, text);\n\t\t}\n\t}\n\n\t/** Internal: clear extension statuses */\n\tclearExtensionStatuses(): void {\n\t\tthis.extensionStatuses.clear();\n\t}\n\n\t/** Number of unique providers with available models (for footer display) */\n\tgetAvailableProviderCount(): number {\n\t\treturn this.availableProviderCount;\n\t}\n\n\t/** Internal: update available provider count */\n\tsetAvailableProviderCount(count: number): void {\n\t\tthis.availableProviderCount = count;\n\t}\n\n\t/** Internal: cleanup */\n\tdispose(): void {\n\t\tthis.disposed = true;\n\t\tif (this.refreshTimer) {\n\t\t\tclearTimeout(this.refreshTimer);\n\t\t\tthis.refreshTimer = null;\n\t\t}\n\t\tif (this.headWatcher) {\n\t\t\tthis.headWatcher.close();\n\t\t\tthis.headWatcher = null;\n\t\t}\n\t\tif (this.reftableWatcher) {\n\t\t\tthis.reftableWatcher.close();\n\t\t\tthis.reftableWatcher = null;\n\t\t}\n\t\tthis.branchChangeCallbacks.clear();\n\t}\n\n\tprivate notifyBranchChange(): void {\n\t\tfor (const cb of this.branchChangeCallbacks) cb();\n\t}\n\n\tprivate scheduleRefresh(): void {\n\t\tif (this.disposed) return;\n\t\tif (this.refreshTimer) {\n\t\t\tclearTimeout(this.refreshTimer);\n\t\t}\n\t\tthis.refreshTimer = setTimeout(() => {\n\t\t\tthis.refreshTimer = null;\n\t\t\tvoid this.refreshGitBranchAsync();\n\t\t}, FooterDataProvider.WATCH_DEBOUNCE_MS);\n\t}\n\n\tprivate async refreshGitBranchAsync(): Promise<void> {\n\t\tif (this.disposed) return;\n\t\tif (this.refreshInFlight) {\n\t\t\tthis.refreshPending = true;\n\t\t\treturn;\n\t\t}\n\n\t\tthis.refreshInFlight = true;\n\t\ttry {\n\t\t\tconst nextBranch = await this.resolveGitBranchAsync();\n\t\t\tif (this.disposed) return;\n\t\t\tif (this.cachedBranch !== undefined && this.cachedBranch !== nextBranch) {\n\t\t\t\tthis.cachedBranch = nextBranch;\n\t\t\t\tthis.notifyBranchChange();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.cachedBranch = nextBranch;\n\t\t} finally {\n\t\t\tthis.refreshInFlight = false;\n\t\t\tif (this.refreshPending && !this.disposed) {\n\t\t\t\tthis.refreshPending = false;\n\t\t\t\tthis.scheduleRefresh();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate resolveGitBranchSync(): string | null {\n\t\ttry {\n\t\t\tif (!this.gitPaths) return null;\n\t\t\tconst content = readFileSync(this.gitPaths.headPath, \"utf8\").trim();\n\t\t\tif (content.startsWith(\"ref: refs/heads/\")) {\n\t\t\t\tconst branch = content.slice(16);\n\t\t\t\treturn branch === \".invalid\" ? (resolveBranchWithGitSync(this.gitPaths.repoDir) ?? \"detached\") : branch;\n\t\t\t}\n\t\t\treturn \"detached\";\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tprivate async resolveGitBranchAsync(): Promise<string | null> {\n\t\ttry {\n\t\t\tif (!this.gitPaths) return null;\n\t\t\tconst content = readFileSync(this.gitPaths.headPath, \"utf8\").trim();\n\t\t\tif (content.startsWith(\"ref: refs/heads/\")) {\n\t\t\t\tconst branch = content.slice(16);\n\t\t\t\treturn branch === \".invalid\"\n\t\t\t\t\t? ((await resolveBranchWithGitAsync(this.gitPaths.repoDir)) ?? \"detached\")\n\t\t\t\t\t: branch;\n\t\t\t}\n\t\t\treturn \"detached\";\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tprivate setupGitWatcher(): void {\n\t\tif (!this.gitPaths) return;\n\n\t\t// Watch the directory containing HEAD, not HEAD itself.\n\t\t// Git uses atomic writes (write temp, rename over HEAD), which changes the inode.\n\t\t// fs.watch on a file stops working after the inode changes.\n\t\ttry {\n\t\t\tthis.headWatcher = watch(dirname(this.gitPaths.headPath), (_eventType, filename) => {\n\t\t\t\tif (!filename || filename.toString() === \"HEAD\") {\n\t\t\t\t\tthis.scheduleRefresh();\n\t\t\t\t}\n\t\t\t});\n\t\t} catch {\n\t\t\t// Silently fail if we can't watch\n\t\t}\n\n\t\t// In reftable repos, branch switches update files in the reftable directory\n\t\t// instead of HEAD. Watch it separately so the footer picks up those changes.\n\t\tconst reftableDir = join(this.gitPaths.commonGitDir, \"reftable\");\n\t\tif (existsSync(reftableDir)) {\n\t\t\ttry {\n\t\t\t\tthis.reftableWatcher = watch(reftableDir, () => {\n\t\t\t\t\tthis.scheduleRefresh();\n\t\t\t\t});\n\t\t\t} catch {\n\t\t\t\t// Silently fail if we can't watch\n\t\t\t}\n\t\t}\n\t}\n}\n\n/** Read-only view for extensions - excludes setExtensionStatus, setAvailableProviderCount and dispose */\nexport type ReadonlyFooterDataProvider = Pick<\n\tFooterDataProvider,\n\t\"getGitBranch\" | \"getExtensionStatuses\" | \"getAvailableProviderCount\" | \"onBranchChange\"\n>;\n"]}
1
+ {"version":3,"file":"footer-data-provider.js","sourceRoot":"","sources":["../../src/core/footer-data-provider.ts"],"names":[],"mappings":"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","sourcesContent":["import { type ExecFileException, execFile, spawnSync } from \"child_process\";\nimport { existsSync, type FSWatcher, readFileSync, statSync, watch } from \"fs\";\nimport { dirname, join, resolve } from \"path\";\nimport { DailyCostTracker } from \"./daily-cost-tracker.js\";\n\ntype GitPaths = {\n\trepoDir: string;\n\tcommonGitDir: string;\n\theadPath: string;\n};\n\n/**\n * Find git metadata paths by walking up from cwd.\n * Handles both regular git repos (.git is a directory) and worktrees (.git is a file).\n */\nfunction findGitPaths(): GitPaths | null {\n\tlet dir = process.cwd();\n\twhile (true) {\n\t\tconst gitPath = join(dir, \".git\");\n\t\tif (existsSync(gitPath)) {\n\t\t\ttry {\n\t\t\t\tconst stat = statSync(gitPath);\n\t\t\t\tif (stat.isFile()) {\n\t\t\t\t\tconst content = readFileSync(gitPath, \"utf8\").trim();\n\t\t\t\t\tif (content.startsWith(\"gitdir: \")) {\n\t\t\t\t\t\tconst gitDir = resolve(dir, content.slice(8).trim());\n\t\t\t\t\t\tconst headPath = join(gitDir, \"HEAD\");\n\t\t\t\t\t\tif (!existsSync(headPath)) return null;\n\t\t\t\t\t\tconst commonDirPath = join(gitDir, \"commondir\");\n\t\t\t\t\t\tconst commonGitDir = existsSync(commonDirPath)\n\t\t\t\t\t\t\t? resolve(gitDir, readFileSync(commonDirPath, \"utf8\").trim())\n\t\t\t\t\t\t\t: gitDir;\n\t\t\t\t\t\treturn { repoDir: dir, commonGitDir, headPath };\n\t\t\t\t\t}\n\t\t\t\t} else if (stat.isDirectory()) {\n\t\t\t\t\tconst headPath = join(gitPath, \"HEAD\");\n\t\t\t\t\tif (!existsSync(headPath)) return null;\n\t\t\t\t\treturn { repoDir: dir, commonGitDir: gitPath, headPath };\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}\n\t\tconst parent = dirname(dir);\n\t\tif (parent === dir) return null;\n\t\tdir = parent;\n\t}\n}\n\n/** Ask git for the current branch. Returns null on detached HEAD or if git is unavailable. */\nfunction resolveBranchWithGitSync(repoDir: string): string | null {\n\tconst result = spawnSync(\"git\", [\"--no-optional-locks\", \"symbolic-ref\", \"--quiet\", \"--short\", \"HEAD\"], {\n\t\tcwd: repoDir,\n\t\tencoding: \"utf8\",\n\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t});\n\tconst branch = result.status === 0 ? result.stdout.trim() : \"\";\n\treturn branch || null;\n}\n\n/** Ask git for the current branch asynchronously. Returns null on detached HEAD or if git is unavailable. */\nfunction resolveBranchWithGitAsync(repoDir: string): Promise<string | null> {\n\treturn new Promise((resolvePromise) => {\n\t\texecFile(\n\t\t\t\"git\",\n\t\t\t[\"--no-optional-locks\", \"symbolic-ref\", \"--quiet\", \"--short\", \"HEAD\"],\n\t\t\t{\n\t\t\t\tcwd: repoDir,\n\t\t\t\tencoding: \"utf8\",\n\t\t\t},\n\t\t\t(error: ExecFileException | null, stdout: string) => {\n\t\t\t\tif (error) {\n\t\t\t\t\tresolvePromise(null);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst branch = stdout.trim();\n\t\t\t\tresolvePromise(branch || null);\n\t\t\t},\n\t\t);\n\t});\n}\n\n/**\n * Provides git branch and extension statuses - data not otherwise accessible to extensions.\n * Token stats, model info available via ctx.sessionManager and ctx.model.\n */\nexport class FooterDataProvider {\n\tprivate static readonly WATCH_DEBOUNCE_MS = 500;\n\n\tprivate extensionStatuses = new Map<string, string>();\n\tprivate cachedBranch: string | null | undefined = undefined;\n\tprivate gitPaths: GitPaths | null | undefined = undefined;\n\tprivate headWatcher: FSWatcher | null = null;\n\tprivate reftableWatcher: FSWatcher | null = null;\n\tprivate branchChangeCallbacks = new Set<() => void>();\n\tprivate dailyCostTracker: DailyCostTracker;\n\tprivate availableProviderCount = 0;\n\tprivate refreshTimer: ReturnType<typeof setTimeout> | null = null;\n\tprivate refreshInFlight = false;\n\tprivate refreshPending = false;\n\tprivate disposed = false;\n\n\tconstructor() {\n\t\tthis.gitPaths = findGitPaths();\n\t\tthis.setupGitWatcher();\n\t\tthis.dailyCostTracker = new DailyCostTracker();\n\t}\n\n\t/** Current git branch, null if not in repo, \"detached\" if detached HEAD */\n\tgetGitBranch(): string | null {\n\t\tif (this.cachedBranch === undefined) {\n\t\t\tthis.cachedBranch = this.resolveGitBranchSync();\n\t\t}\n\t\treturn this.cachedBranch;\n\t}\n\n\t/** Extension status texts set via ctx.ui.setStatus() */\n\tgetExtensionStatuses(): ReadonlyMap<string, string> {\n\t\treturn this.extensionStatuses;\n\t}\n\n\t/** Subscribe to git branch changes. Returns unsubscribe function. */\n\tonBranchChange(callback: () => void): () => void {\n\t\tthis.branchChangeCallbacks.add(callback);\n\t\treturn () => this.branchChangeCallbacks.delete(callback);\n\t}\n\n\t/** Internal: set extension status */\n\tsetExtensionStatus(key: string, text: string | undefined): void {\n\t\tif (text === undefined) {\n\t\t\tthis.extensionStatuses.delete(key);\n\t\t} else {\n\t\t\tthis.extensionStatuses.set(key, text);\n\t\t}\n\t}\n\n\t/** Internal: clear extension statuses */\n\tclearExtensionStatuses(): void {\n\t\tthis.extensionStatuses.clear();\n\t}\n\n\t/** Cached daily cost total across all sessions. O(1). */\n\tgetDailyCost(): number {\n\t\treturn this.dailyCostTracker.getDailyCost();\n\t}\n\n\t/** Force refresh of the daily cost cache. */\n\tasync refreshDailyCost(): Promise<void> {\n\t\tawait this.dailyCostTracker.refresh();\n\t}\n\n\t/** Number of unique providers with available models (for footer display) */\n\tgetAvailableProviderCount(): number {\n\t\treturn this.availableProviderCount;\n\t}\n\n\t/** Internal: update available provider count */\n\tsetAvailableProviderCount(count: number): void {\n\t\tthis.availableProviderCount = count;\n\t}\n\n\t/** Internal: cleanup */\n\tdispose(): void {\n\t\tthis.disposed = true;\n\t\tif (this.refreshTimer) {\n\t\t\tclearTimeout(this.refreshTimer);\n\t\t\tthis.refreshTimer = null;\n\t\t}\n\t\tthis.dailyCostTracker.dispose();\n\t\tif (this.headWatcher) {\n\t\t\tthis.headWatcher.close();\n\t\t\tthis.headWatcher = null;\n\t\t}\n\t\tif (this.reftableWatcher) {\n\t\t\tthis.reftableWatcher.close();\n\t\t\tthis.reftableWatcher = null;\n\t\t}\n\t\tthis.branchChangeCallbacks.clear();\n\t}\n\n\tprivate notifyBranchChange(): void {\n\t\tfor (const cb of this.branchChangeCallbacks) cb();\n\t}\n\n\tprivate scheduleRefresh(): void {\n\t\tif (this.disposed) return;\n\t\tif (this.refreshTimer) {\n\t\t\tclearTimeout(this.refreshTimer);\n\t\t}\n\t\tthis.refreshTimer = setTimeout(() => {\n\t\t\tthis.refreshTimer = null;\n\t\t\tvoid this.refreshGitBranchAsync();\n\t\t}, FooterDataProvider.WATCH_DEBOUNCE_MS);\n\t}\n\n\tprivate async refreshGitBranchAsync(): Promise<void> {\n\t\tif (this.disposed) return;\n\t\tif (this.refreshInFlight) {\n\t\t\tthis.refreshPending = true;\n\t\t\treturn;\n\t\t}\n\n\t\tthis.refreshInFlight = true;\n\t\ttry {\n\t\t\tconst nextBranch = await this.resolveGitBranchAsync();\n\t\t\tif (this.disposed) return;\n\t\t\tif (this.cachedBranch !== undefined && this.cachedBranch !== nextBranch) {\n\t\t\t\tthis.cachedBranch = nextBranch;\n\t\t\t\tthis.notifyBranchChange();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.cachedBranch = nextBranch;\n\t\t} finally {\n\t\t\tthis.refreshInFlight = false;\n\t\t\tif (this.refreshPending && !this.disposed) {\n\t\t\t\tthis.refreshPending = false;\n\t\t\t\tthis.scheduleRefresh();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate resolveGitBranchSync(): string | null {\n\t\ttry {\n\t\t\tif (!this.gitPaths) return null;\n\t\t\tconst content = readFileSync(this.gitPaths.headPath, \"utf8\").trim();\n\t\t\tif (content.startsWith(\"ref: refs/heads/\")) {\n\t\t\t\tconst branch = content.slice(16);\n\t\t\t\treturn branch === \".invalid\" ? (resolveBranchWithGitSync(this.gitPaths.repoDir) ?? \"detached\") : branch;\n\t\t\t}\n\t\t\treturn \"detached\";\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tprivate async resolveGitBranchAsync(): Promise<string | null> {\n\t\ttry {\n\t\t\tif (!this.gitPaths) return null;\n\t\t\tconst content = readFileSync(this.gitPaths.headPath, \"utf8\").trim();\n\t\t\tif (content.startsWith(\"ref: refs/heads/\")) {\n\t\t\t\tconst branch = content.slice(16);\n\t\t\t\treturn branch === \".invalid\"\n\t\t\t\t\t? ((await resolveBranchWithGitAsync(this.gitPaths.repoDir)) ?? \"detached\")\n\t\t\t\t\t: branch;\n\t\t\t}\n\t\t\treturn \"detached\";\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tprivate setupGitWatcher(): void {\n\t\tif (!this.gitPaths) return;\n\n\t\t// Watch the directory containing HEAD, not HEAD itself.\n\t\t// Git uses atomic writes (write temp, rename over HEAD), which changes the inode.\n\t\t// fs.watch on a file stops working after the inode changes.\n\t\ttry {\n\t\t\tthis.headWatcher = watch(dirname(this.gitPaths.headPath), (_eventType, filename) => {\n\t\t\t\tif (!filename || filename.toString() === \"HEAD\") {\n\t\t\t\t\tthis.scheduleRefresh();\n\t\t\t\t}\n\t\t\t});\n\t\t} catch {\n\t\t\t// Silently fail if we can't watch\n\t\t}\n\n\t\t// In reftable repos, branch switches update files in the reftable directory\n\t\t// instead of HEAD. Watch it separately so the footer picks up those changes.\n\t\tconst reftableDir = join(this.gitPaths.commonGitDir, \"reftable\");\n\t\tif (existsSync(reftableDir)) {\n\t\t\ttry {\n\t\t\t\tthis.reftableWatcher = watch(reftableDir, () => {\n\t\t\t\t\tthis.scheduleRefresh();\n\t\t\t\t});\n\t\t\t} catch {\n\t\t\t\t// Silently fail if we can't watch\n\t\t\t}\n\t\t}\n\t}\n}\n\n/** Read-only view for extensions - excludes setExtensionStatus, setAvailableProviderCount and dispose */\nexport type ReadonlyFooterDataProvider = Pick<\n\tFooterDataProvider,\n\t\"getGitBranch\" | \"getExtensionStatuses\" | \"getAvailableProviderCount\" | \"onBranchChange\" | \"getDailyCost\"\n>;\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"buddy-component.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/buddy-component.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAuB,GAAG,EAAE,MAAM,WAAW,CAAC;AAIrE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oCAAoC,CAAC;AAUrE,qBAAa,cAAe,YAAW,SAAS;IAC/C,OAAO,CAAC,EAAE,CAAM;IAChB,OAAO,CAAC,KAAK,CAAa;IAC1B,OAAO,CAAC,QAAQ,CAA+C;IAG/D,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,WAAW,CAAK;IAGxB,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,aAAa,CAA8C;IAGnE,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,UAAU,CAA8C;IAChE,OAAO,CAAC,MAAM,CAAmE;IAGjF,OAAO,CAAC,aAAa,CAAuB;IAC5C,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,kBAAkB,CAAK;IAG/C,OAAO,CAAC,WAAW,CAAgB;IACnC,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,aAAa,CAAM;IAC3B,OAAO,CAAC,aAAa,CAAK;IAE1B,YAAY,EAAE,EAAE,GAAG,EAAE,KAAK,EAAE,UAAU,EAKrC;IAED,6CAA6C;IAC7C,WAAW,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAMnC;IAED,qCAAqC;IACrC,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAW7B;IAED,4BAA4B;IAC5B,GAAG,IAAI,IAAI,CAsBV;IAED,4DAA4D;IAC5D,YAAY,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAKjC;IAED,kCAAkC;IAClC,YAAY,IAAI,IAAI,CAKnB;IAED,UAAU,IAAI,IAAI,CAEjB;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAc9B;IAED,OAAO,IAAI,IAAI,CAWd;IAMD,OAAO,CAAC,UAAU;IAiElB,OAAO,CAAC,YAAY;IAgCpB,OAAO,CAAC,cAAc;IAuBtB,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,WAAW;IAQnB,OAAO,CAAC,OAAO;IAQf,OAAO,CAAC,WAAW;IAenB,OAAO,CAAC,kBAAkB;IAwC1B,6EAA6E;IAC7E,OAAO,CAAC,oBAAoB;IAY5B,qEAAqE;IACrE,OAAO,CAAC,kBAAkB;CA0B1B","sourcesContent":["/**\n * BuddyComponent — TUI component rendering the buddy companion.\n *\n * Renders below the editor with:\n * - 3-frame idle animation cycling at 500ms\n * - Speech bubbles for reactions and name-call responses\n * - Pet hearts animation (2.5s)\n * - Narrow terminal fallback (<100 cols)\n * - Stat display and rarity badge\n */\n\nimport type { Component, MarkdownTheme as MT, TUI } from \"@dreb/tui\";\nimport { truncateToWidth, visibleWidth } from \"@dreb/tui\";\nimport { marked } from \"marked\";\nimport { applyEyes, getSpeciesFrames, getSpeciesWidth } from \"../../../core/buddy/buddy-species.js\";\nimport type { BuddyState } from \"../../../core/buddy/buddy-types.js\";\nimport { Rarity, Stat } from \"../../../core/buddy/buddy-types.js\";\nimport { getMarkdownTheme, theme } from \"../theme/theme.js\";\n\nconst IDLE_INTERVAL_MS = 500;\nconst SPEECH_BUBBLE_DURATION_MS = 10000;\nconst PET_DURATION_MS = 2500;\nconst HEART_CHARS = [\"❤️\", \"💕\", \"💖\", \"💗\", \"✨\"];\nconst NARROW_THRESHOLD = 100;\n\nexport class BuddyComponent implements Component {\n\tprivate ui: TUI;\n\tprivate state: BuddyState;\n\tprivate interval: ReturnType<typeof setInterval> | null = null;\n\n\t// Animation state\n\tprivate currentFrame = 0;\n\tprivate totalFrames = 3;\n\n\t// Speech bubble\n\tprivate speechText: string | null = null;\n\tprivate speechTimeout: ReturnType<typeof setTimeout> | null = null;\n\n\t// Pet animation\n\tprivate isPetting = false;\n\tprivate petTimeout: ReturnType<typeof setTimeout> | null = null;\n\tprivate hearts: Array<{ x: number; y: number; char: string; life: number }> = [];\n\n\t// Thinking indicator\n\tprivate thinkingLabel: string | null = null;\n\tprivate thinkingDots = 0;\n\tprivate static readonly THINKING_DOT_COUNT = 4; // cycles 0,1,2,3 → \".\", \"..\", \"...\", \"....\"\n\n\t// Cached render\n\tprivate cachedLines: string[] = [];\n\tprivate cachedWidth = 0;\n\tprivate cachedVersion = -1;\n\tprivate renderVersion = 0;\n\n\tconstructor(ui: TUI, state: BuddyState) {\n\t\tthis.ui = ui;\n\t\tthis.state = state;\n\t\tthis.totalFrames = getSpeciesFrames(state.species).length;\n\t\tthis.startAnimation();\n\t}\n\n\t/** Update buddy state (e.g. after reroll) */\n\tupdateState(state: BuddyState): void {\n\t\tthis.state = state;\n\t\tthis.totalFrames = getSpeciesFrames(state.species).length;\n\t\tthis.currentFrame = 0;\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\t/** Show a speech bubble with text */\n\tshowSpeech(text: string): void {\n\t\tthis.speechText = text;\n\t\tif (this.speechTimeout) clearTimeout(this.speechTimeout);\n\t\tthis.speechTimeout = setTimeout(() => {\n\t\t\tthis.speechText = null;\n\t\t\tthis.speechTimeout = null;\n\t\t\tthis.bumpVersion();\n\t\t\tthis.ui.requestRender();\n\t\t}, SPEECH_BUBBLE_DURATION_MS);\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\t/** Trigger pet animation */\n\tpet(): void {\n\t\tthis.isPetting = true;\n\t\t// Spawn hearts\n\t\tconst spriteWidth = getSpeciesWidth(this.state.species);\n\t\tfor (let i = 0; i < 5; i++) {\n\t\t\tthis.hearts.push({\n\t\t\t\tx: Math.floor(Math.random() * spriteWidth),\n\t\t\t\ty: -1 - Math.floor(Math.random() * 3),\n\t\t\t\tchar: HEART_CHARS[Math.floor(Math.random() * HEART_CHARS.length)],\n\t\t\t\tlife: 5 + Math.floor(Math.random() * 5),\n\t\t\t});\n\t\t}\n\t\tif (this.petTimeout) clearTimeout(this.petTimeout);\n\t\tthis.petTimeout = setTimeout(() => {\n\t\t\tthis.isPetting = false;\n\t\t\tthis.hearts = [];\n\t\t\tthis.petTimeout = null;\n\t\t\tthis.bumpVersion();\n\t\t\tthis.ui.requestRender();\n\t\t}, PET_DURATION_MS);\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\t/** Show a pulsing thinking indicator with optional label */\n\tshowThinking(label?: string): void {\n\t\tthis.thinkingLabel = label ?? \"thinking\";\n\t\tthis.thinkingDots = 0;\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\t/** Hide the thinking indicator */\n\thideThinking(): void {\n\t\tthis.thinkingLabel = null;\n\t\tthis.thinkingDots = 0;\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\tinvalidate(): void {\n\t\tthis.cachedWidth = 0;\n\t}\n\n\trender(width: number): string[] {\n\t\tif (width === this.cachedWidth && this.cachedVersion === this.renderVersion) {\n\t\t\treturn this.cachedLines;\n\t\t}\n\n\t\tif (width < NARROW_THRESHOLD) {\n\t\t\tthis.cachedLines = this.renderNarrow(width);\n\t\t} else {\n\t\t\tthis.cachedLines = this.renderFull(width);\n\t\t}\n\n\t\tthis.cachedWidth = width;\n\t\tthis.cachedVersion = this.renderVersion;\n\t\treturn this.cachedLines;\n\t}\n\n\tdispose(): void {\n\t\tthis.stopAnimation();\n\t\tthis.thinkingLabel = null;\n\t\tif (this.speechTimeout) {\n\t\t\tclearTimeout(this.speechTimeout);\n\t\t\tthis.speechTimeout = null;\n\t\t}\n\t\tif (this.petTimeout) {\n\t\t\tclearTimeout(this.petTimeout);\n\t\t\tthis.petTimeout = null;\n\t\t}\n\t}\n\n\t// =============================================================================\n\t// Full rendering (wide terminal)\n\t// =============================================================================\n\n\tprivate renderFull(width: number): string[] {\n\t\tconst lines: string[] = [];\n\t\tconst frames = getSpeciesFrames(this.state.species);\n\t\tconst frame = frames[this.currentFrame % this.totalFrames];\n\t\tconst rendered = applyEyes(frame, this.state.eyeStyle);\n\n\t\t// Hat line (if present)\n\t\tif (this.state.hat) {\n\t\t\tconst hatPad = Math.max(0, Math.floor((getSpeciesWidth(this.state.species) - 1) / 2) - 1);\n\t\t\tlines.push(\" \".repeat(hatPad) + this.state.hat);\n\t\t}\n\n\t\t// Sprite lines\n\t\tconst spriteWidth = getSpeciesWidth(this.state.species);\n\t\tlines.push(...rendered);\n\n\t\t// Heart animation line above sprite\n\t\tif (this.isPetting && this.hearts.length > 0) {\n\t\t\tconst heartLine = \" \".repeat(spriteWidth);\n\t\t\tconst chars = heartLine.split(\"\");\n\t\t\tfor (const heart of this.hearts) {\n\t\t\t\tconst x = Math.min(heart.x, chars.length - 2);\n\t\t\t\tif (x >= 0 && heart.life > 0) {\n\t\t\t\t\tconst hChar = heart.char;\n\t\t\t\t\tchars.splice(x, hChar.length, ...hChar.split(\"\"));\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Insert heart line before sprite\n\t\t\tlines.splice(this.state.hat ? 1 : 0, 0, chars.join(\"\"));\n\t\t}\n\n\t\t// Name + rarity line\n\t\tconst shinyMark = this.state.shiny ? \" ✨\" : \"\";\n\t\tconst rarityColor = this.rarityColor(this.state.rarity);\n\t\tconst nameLine = ` ${theme.bold(this.state.name)}${shinyMark} ${theme.fg(rarityColor, `[${this.state.rarity}]`)} ${theme.fg(\"muted\", this.state.species)}`;\n\t\tlines.push(nameLine);\n\n\t\t// Stats line\n\t\tconst statParts = (Object.values(Stat) as Stat[]).map((s) => {\n\t\t\tconst val = this.state.stats[s];\n\t\t\tconst bar = this.statBar(val);\n\t\t\treturn `${theme.fg(\"muted\", s[0])}:${bar}`;\n\t\t});\n\t\tlines.push(` ${statParts.join(\" \")}`);\n\n\t\t// Thinking indicator\n\t\tif (this.thinkingLabel !== null) {\n\t\t\tconst dots = \".\".repeat((this.thinkingDots % BuddyComponent.THINKING_DOT_COUNT) + 1);\n\t\t\tconst label = this.thinkingLabel || \"thinking\";\n\t\t\tlines.push(` ${theme.fg(\"muted\", `💭 ${label}${dots}`)}`);\n\t\t}\n\n\t\t// Speech bubble (beside or below sprite)\n\t\tif (this.speechText) {\n\t\t\tconst bubbleLines = this.formatSpeechBubble(this.speechText, Math.min(width - 4, 60));\n\t\t\tlines.push(...bubbleLines);\n\t\t}\n\n\t\treturn lines;\n\t}\n\n\t// =============================================================================\n\t// Narrow rendering (< 100 cols)\n\t// =============================================================================\n\n\tprivate renderNarrow(width: number): string[] {\n\t\tconst lines: string[] = [];\n\n\t\t// Single-line face\n\t\tconst eyes = this.state.eyeStyle;\n\t\tconst mouth = this.isPetting ? \"♥\" : \">\";\n\t\tconst face = `${this.state.hat}${eyes}${mouth}${eyes}`;\n\n\t\t// Name + truncated quip\n\t\tconst shinyMark = this.state.shiny ? \"✨\" : \"\";\n\t\tconst name = `${theme.bold(this.state.name)}${shinyMark}`;\n\n\t\tif (this.speechText) {\n\t\t\tconst maxQuip = Math.max(10, width - face.length - this.state.name.length - 6);\n\t\t\tconst styledQuip = this.renderInlineMarkdown(this.speechText);\n\t\t\tconst quip = visibleWidth(styledQuip) > maxQuip ? `${truncateToWidth(styledQuip, maxQuip - 1)}…` : styledQuip;\n\t\t\tlines.push(` ${face} ${name}: ${theme.fg(\"accent\", quip)}`);\n\t\t} else if (this.thinkingLabel !== null) {\n\t\t\tconst dots = \".\".repeat((this.thinkingDots % BuddyComponent.THINKING_DOT_COUNT) + 1);\n\t\t\tconst label = this.thinkingLabel || \"thinking\";\n\t\t\tlines.push(` ${face} ${name} ${theme.fg(\"muted\", `💭 ${label}${dots}`)}`);\n\t\t} else {\n\t\t\tlines.push(` ${face} ${name}`);\n\t\t}\n\n\t\treturn lines;\n\t}\n\n\t// =============================================================================\n\t// Animation\n\t// =============================================================================\n\n\tprivate startAnimation(): void {\n\t\tthis.interval = setInterval(() => {\n\t\t\tthis.currentFrame = (this.currentFrame + 1) % this.totalFrames;\n\n\t\t\t// Tick thinking dots\n\t\t\tif (this.thinkingLabel !== null) {\n\t\t\t\tthis.thinkingDots = (this.thinkingDots + 1) % BuddyComponent.THINKING_DOT_COUNT;\n\t\t\t}\n\n\t\t\t// Tick hearts\n\t\t\tif (this.isPetting) {\n\t\t\t\tfor (const heart of this.hearts) {\n\t\t\t\t\theart.life--;\n\t\t\t\t\theart.y++;\n\t\t\t\t}\n\t\t\t\tthis.hearts = this.hearts.filter((h) => h.life > 0);\n\t\t\t}\n\n\t\t\tthis.bumpVersion();\n\t\t\tthis.ui.requestRender();\n\t\t}, IDLE_INTERVAL_MS);\n\t}\n\n\tprivate stopAnimation(): void {\n\t\tif (this.interval) {\n\t\t\tclearInterval(this.interval);\n\t\t\tthis.interval = null;\n\t\t}\n\t}\n\n\tprivate bumpVersion(): void {\n\t\tthis.renderVersion++;\n\t}\n\n\t// =============================================================================\n\t// Formatting helpers\n\t// =============================================================================\n\n\tprivate statBar(value: number): string {\n\t\tconst filled = Math.round(value / 10);\n\t\tconst empty = 10 - filled;\n\t\tconst bar = \"█\".repeat(filled) + \"░\".repeat(empty);\n\t\tconst color: \"success\" | \"warning\" | \"error\" = value >= 70 ? \"success\" : value >= 40 ? \"warning\" : \"error\";\n\t\treturn theme.fg(color, bar);\n\t}\n\n\tprivate rarityColor(rarity: Rarity): \"muted\" | \"success\" | \"accent\" | \"warning\" | \"error\" {\n\t\tswitch (rarity) {\n\t\t\tcase Rarity.COMMON:\n\t\t\t\treturn \"muted\";\n\t\t\tcase Rarity.UNCOMMON:\n\t\t\t\treturn \"success\";\n\t\t\tcase Rarity.RARE:\n\t\t\t\treturn \"accent\";\n\t\t\tcase Rarity.EPIC:\n\t\t\t\treturn \"warning\";\n\t\t\tcase Rarity.LEGENDARY:\n\t\t\t\treturn \"error\";\n\t\t}\n\t}\n\n\tprivate formatSpeechBubble(text: string, maxWidth: number): string[] {\n\t\tif (!text.trim()) return [];\n\n\t\t// Render inline markdown (bold, italic, code) to styled text with ANSI codes\n\t\tconst styledText = this.renderInlineMarkdown(text);\n\n\t\t// Word-wrap using visible width (ANSI-aware)\n\t\tconst lines: string[] = [];\n\t\tconst words = styledText.split(\" \");\n\t\tlet currentLine = \"\";\n\n\t\tfor (const word of words) {\n\t\t\tconst test = currentLine ? `${currentLine} ${word}` : word;\n\t\t\tif (visibleWidth(test) > maxWidth) {\n\t\t\t\tif (currentLine) lines.push(currentLine);\n\t\t\t\tcurrentLine = word;\n\t\t\t} else {\n\t\t\t\tcurrentLine = test;\n\t\t\t}\n\t\t}\n\t\tif (currentLine) lines.push(currentLine);\n\n\t\t// Wrap in bubble border using visible width for measurement\n\t\tconst maxLineWidth = Math.max(...lines.map((l) => visibleWidth(l)));\n\t\tconst bubbleWidth = Math.max(6, Math.min(maxLineWidth + 4, maxWidth + 4));\n\t\tconst top = `╭${\"─\".repeat(bubbleWidth - 2)}╮`;\n\t\tconst bottom = `╰${\"─\".repeat(bubbleWidth - 2)}╯`;\n\n\t\tconst result: string[] = [];\n\t\tresult.push(` ${theme.fg(\"accent\", top)}`);\n\t\tfor (const line of lines) {\n\t\t\tconst padding = Math.max(0, bubbleWidth - 4 - visibleWidth(line));\n\t\t\tconst padded = line + \" \".repeat(padding);\n\t\t\tresult.push(` ${theme.fg(\"accent\", \"│\")} ${padded} ${theme.fg(\"accent\", \"│\")}`);\n\t\t}\n\t\tresult.push(` ${theme.fg(\"accent\", bottom)}`);\n\n\t\treturn result;\n\t}\n\n\t/** Render inline markdown tokens (bold, italic, code) to ANSI-styled text */\n\tprivate renderInlineMarkdown(text: string): string {\n\t\tconst mdTheme = getMarkdownTheme();\n\t\tconst tokens = marked.lexer(text);\n\n\t\t// Flatten: we expect a paragraph containing inline tokens\n\t\tconst inlineTokens = tokens.flatMap((t: any) =>\n\t\t\tt.type === \"paragraph\" ? (t.tokens ?? []) : t.type === \"text\" ? t : [],\n\t\t);\n\n\t\treturn this.renderInlineTokens(inlineTokens, mdTheme);\n\t}\n\n\t/** Recursively render marked inline tokens to ANSI-styled strings */\n\tprivate renderInlineTokens(tokens: any[], mdTheme: MT): string {\n\t\tlet result = \"\";\n\t\tfor (const token of tokens) {\n\t\t\tswitch (token.type) {\n\t\t\t\tcase \"text\":\n\t\t\t\t\tresult += token.text ?? this.renderInlineTokens(token.tokens ?? [], mdTheme);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"strong\":\n\t\t\t\t\tresult += mdTheme.bold(this.renderInlineTokens(token.tokens ?? [], mdTheme));\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"em\":\n\t\t\t\t\tresult += mdTheme.italic(this.renderInlineTokens(token.tokens ?? [], mdTheme));\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"codespan\":\n\t\t\t\t\tresult += mdTheme.code(token.text);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"escape\":\n\t\t\t\t\tresult += token.text;\n\t\t\t\t\tbreak;\n\t\t\t\tdefault:\n\t\t\t\t\tresult += token.text ?? token.raw ?? \"\";\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\treturn result;\n\t}\n}\n"]}
1
+ {"version":3,"file":"buddy-component.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/buddy-component.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAuB,GAAG,EAAE,MAAM,WAAW,CAAC;AAIrE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oCAAoC,CAAC;AAYrE,qBAAa,cAAe,YAAW,SAAS;IAC/C,OAAO,CAAC,EAAE,CAAM;IAChB,OAAO,CAAC,KAAK,CAAa;IAC1B,OAAO,CAAC,QAAQ,CAA+C;IAG/D,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,WAAW,CAAK;IAGxB,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,aAAa,CAA8C;IAGnE,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,UAAU,CAA8C;IAChE,OAAO,CAAC,MAAM,CAAmE;IAGjF,OAAO,CAAC,aAAa,CAAuB;IAC5C,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,kBAAkB,CAAK;IAG/C,OAAO,CAAC,WAAW,CAAgB;IACnC,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,aAAa,CAAM;IAC3B,OAAO,CAAC,aAAa,CAAK;IAE1B,YAAY,EAAE,EAAE,GAAG,EAAE,KAAK,EAAE,UAAU,EAKrC;IAED,6CAA6C;IAC7C,WAAW,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAMnC;IAED,qCAAqC;IACrC,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAW7B;IAED,4BAA4B;IAC5B,GAAG,IAAI,IAAI,CAsBV;IAED,4DAA4D;IAC5D,YAAY,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAKjC;IAED,kCAAkC;IAClC,YAAY,IAAI,IAAI,CAKnB;IAED,UAAU,IAAI,IAAI,CAEjB;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAc9B;IAED,OAAO,IAAI,IAAI,CAWd;IAMD,OAAO,CAAC,UAAU;IA0ElB,OAAO,CAAC,YAAY;IAgCpB,OAAO,CAAC,cAAc;IAuBtB,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,WAAW;IAQnB,OAAO,CAAC,OAAO;IAQf,OAAO,CAAC,WAAW;IAenB,OAAO,CAAC,kBAAkB;IAsC1B,6EAA6E;IAC7E,OAAO,CAAC,oBAAoB;IAY5B,qEAAqE;IACrE,OAAO,CAAC,kBAAkB;CA0B1B","sourcesContent":["/**\n * BuddyComponent — TUI component rendering the buddy companion.\n *\n * Renders below the editor with:\n * - 3-frame idle animation cycling at 500ms\n * - Speech bubbles for reactions and name-call responses\n * - Pet hearts animation (2.5s)\n * - Narrow terminal fallback (<100 cols)\n * - Stat display and rarity badge\n */\n\nimport type { Component, MarkdownTheme as MT, TUI } from \"@dreb/tui\";\nimport { joinColumns, truncateToWidth, visibleWidth, wrapTextWithAnsi } from \"@dreb/tui\";\nimport { marked } from \"marked\";\nimport { applyEyes, getSpeciesFrames, getSpeciesWidth } from \"../../../core/buddy/buddy-species.js\";\nimport type { BuddyState } from \"../../../core/buddy/buddy-types.js\";\nimport { Rarity, Stat } from \"../../../core/buddy/buddy-types.js\";\nimport { getMarkdownTheme, theme } from \"../theme/theme.js\";\n\nconst IDLE_INTERVAL_MS = 500;\nconst SPEECH_BUBBLE_DURATION_MS = 10000;\nconst PET_DURATION_MS = 2500;\nconst HEART_CHARS = [\"❤️\", \"💕\", \"💖\", \"💗\", \"✨\"];\nconst NARROW_THRESHOLD = 100;\nconst SPEECH_MAX_CONTENT_LINES = 3;\nconst SIDE_PANEL_GAP = 2;\n\nexport class BuddyComponent implements Component {\n\tprivate ui: TUI;\n\tprivate state: BuddyState;\n\tprivate interval: ReturnType<typeof setInterval> | null = null;\n\n\t// Animation state\n\tprivate currentFrame = 0;\n\tprivate totalFrames = 3;\n\n\t// Speech bubble\n\tprivate speechText: string | null = null;\n\tprivate speechTimeout: ReturnType<typeof setTimeout> | null = null;\n\n\t// Pet animation\n\tprivate isPetting = false;\n\tprivate petTimeout: ReturnType<typeof setTimeout> | null = null;\n\tprivate hearts: Array<{ x: number; y: number; char: string; life: number }> = [];\n\n\t// Thinking indicator\n\tprivate thinkingLabel: string | null = null;\n\tprivate thinkingDots = 0;\n\tprivate static readonly THINKING_DOT_COUNT = 4; // cycles 0,1,2,3 → \".\", \"..\", \"...\", \"....\"\n\n\t// Cached render\n\tprivate cachedLines: string[] = [];\n\tprivate cachedWidth = 0;\n\tprivate cachedVersion = -1;\n\tprivate renderVersion = 0;\n\n\tconstructor(ui: TUI, state: BuddyState) {\n\t\tthis.ui = ui;\n\t\tthis.state = state;\n\t\tthis.totalFrames = getSpeciesFrames(state.species).length;\n\t\tthis.startAnimation();\n\t}\n\n\t/** Update buddy state (e.g. after reroll) */\n\tupdateState(state: BuddyState): void {\n\t\tthis.state = state;\n\t\tthis.totalFrames = getSpeciesFrames(state.species).length;\n\t\tthis.currentFrame = 0;\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\t/** Show a speech bubble with text */\n\tshowSpeech(text: string): void {\n\t\tthis.speechText = text;\n\t\tif (this.speechTimeout) clearTimeout(this.speechTimeout);\n\t\tthis.speechTimeout = setTimeout(() => {\n\t\t\tthis.speechText = null;\n\t\t\tthis.speechTimeout = null;\n\t\t\tthis.bumpVersion();\n\t\t\tthis.ui.requestRender();\n\t\t}, SPEECH_BUBBLE_DURATION_MS);\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\t/** Trigger pet animation */\n\tpet(): void {\n\t\tthis.isPetting = true;\n\t\t// Spawn hearts\n\t\tconst spriteWidth = getSpeciesWidth(this.state.species);\n\t\tfor (let i = 0; i < 5; i++) {\n\t\t\tthis.hearts.push({\n\t\t\t\tx: Math.floor(Math.random() * spriteWidth),\n\t\t\t\ty: -1 - Math.floor(Math.random() * 3),\n\t\t\t\tchar: HEART_CHARS[Math.floor(Math.random() * HEART_CHARS.length)],\n\t\t\t\tlife: 5 + Math.floor(Math.random() * 5),\n\t\t\t});\n\t\t}\n\t\tif (this.petTimeout) clearTimeout(this.petTimeout);\n\t\tthis.petTimeout = setTimeout(() => {\n\t\t\tthis.isPetting = false;\n\t\t\tthis.hearts = [];\n\t\t\tthis.petTimeout = null;\n\t\t\tthis.bumpVersion();\n\t\t\tthis.ui.requestRender();\n\t\t}, PET_DURATION_MS);\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\t/** Show a pulsing thinking indicator with optional label */\n\tshowThinking(label?: string): void {\n\t\tthis.thinkingLabel = label ?? \"thinking\";\n\t\tthis.thinkingDots = 0;\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\t/** Hide the thinking indicator */\n\thideThinking(): void {\n\t\tthis.thinkingLabel = null;\n\t\tthis.thinkingDots = 0;\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\tinvalidate(): void {\n\t\tthis.cachedWidth = 0;\n\t}\n\n\trender(width: number): string[] {\n\t\tif (width === this.cachedWidth && this.cachedVersion === this.renderVersion) {\n\t\t\treturn this.cachedLines;\n\t\t}\n\n\t\tif (width < NARROW_THRESHOLD) {\n\t\t\tthis.cachedLines = this.renderNarrow(width);\n\t\t} else {\n\t\t\tthis.cachedLines = this.renderFull(width);\n\t\t}\n\n\t\tthis.cachedWidth = width;\n\t\tthis.cachedVersion = this.renderVersion;\n\t\treturn this.cachedLines;\n\t}\n\n\tdispose(): void {\n\t\tthis.stopAnimation();\n\t\tthis.thinkingLabel = null;\n\t\tif (this.speechTimeout) {\n\t\t\tclearTimeout(this.speechTimeout);\n\t\t\tthis.speechTimeout = null;\n\t\t}\n\t\tif (this.petTimeout) {\n\t\t\tclearTimeout(this.petTimeout);\n\t\t\tthis.petTimeout = null;\n\t\t}\n\t}\n\n\t// =============================================================================\n\t// Full rendering (wide terminal)\n\t// =============================================================================\n\n\tprivate renderFull(width: number): string[] {\n\t\tconst lines: string[] = [];\n\t\tconst frames = getSpeciesFrames(this.state.species);\n\t\tconst frame = frames[this.currentFrame % this.totalFrames];\n\t\tconst rendered = applyEyes(frame, this.state.eyeStyle);\n\t\tconst spriteWidth = getSpeciesWidth(this.state.species);\n\n\t\t// Build LEFT block: hat + heart animation + sprite lines\n\t\tconst leftLines: string[] = [];\n\n\t\t// Hat line\n\t\tif (this.state.hat) {\n\t\t\tconst hatPad = Math.max(0, Math.floor((spriteWidth - 1) / 2) - 1);\n\t\t\tleftLines.push(\" \".repeat(hatPad) + this.state.hat);\n\t\t}\n\n\t\t// Heart animation line (above sprite, inserted at top)\n\t\tif (this.isPetting && this.hearts.length > 0) {\n\t\t\tconst heartLine = \" \".repeat(spriteWidth);\n\t\t\tconst chars = heartLine.split(\"\");\n\t\t\tfor (const heart of this.hearts) {\n\t\t\t\tconst x = Math.min(heart.x, chars.length - 2);\n\t\t\t\tif (x >= 0 && heart.life > 0) {\n\t\t\t\t\tconst hChar = heart.char;\n\t\t\t\t\tchars.splice(x, hChar.length, ...hChar.split(\"\"));\n\t\t\t\t}\n\t\t\t}\n\t\t\tleftLines.push(chars.join(\"\"));\n\t\t}\n\n\t\t// Sprite lines\n\t\tleftLines.push(...rendered);\n\n\t\t// Build RIGHT block: speech bubble or thinking indicator\n\t\tlet rightLines: string[] = [];\n\t\tif (this.speechText) {\n\t\t\tconst availableWidth = width - spriteWidth - SIDE_PANEL_GAP - 5; // 5 for leading space + bubble borders + padding\n\t\t\tconst bubbleMaxWidth = Math.max(20, availableWidth);\n\t\t\trightLines = this.formatSpeechBubble(this.speechText, bubbleMaxWidth);\n\t\t} else if (this.thinkingLabel !== null) {\n\t\t\tconst dots = \".\".repeat((this.thinkingDots % BuddyComponent.THINKING_DOT_COUNT) + 1);\n\t\t\tconst label = this.thinkingLabel || \"thinking\";\n\t\t\trightLines = [` ${theme.fg(\"muted\", `💭 ${label}${dots}`)}`];\n\t\t}\n\n\t\t// Merge left and right side-by-side\n\t\tif (rightLines.length > 0) {\n\t\t\tconst merged = joinColumns(leftLines, rightLines, SIDE_PANEL_GAP, width);\n\t\t\tlines.push(...merged);\n\t\t} else {\n\t\t\tlines.push(...leftLines);\n\t\t}\n\n\t\t// Name + rarity line (full width, below the sprite+panel area)\n\t\tconst shinyMark = this.state.shiny ? \" ✨\" : \"\";\n\t\tconst rarityColor = this.rarityColor(this.state.rarity);\n\t\tconst nameLine = ` ${theme.bold(this.state.name)}${shinyMark} ${theme.fg(rarityColor, `[${this.state.rarity}]`)} ${theme.fg(\"muted\", this.state.species)}`;\n\t\tlines.push(nameLine);\n\n\t\t// Stats line (full width)\n\t\tconst statParts = (Object.values(Stat) as Stat[]).map((s) => {\n\t\t\tconst val = this.state.stats[s];\n\t\t\tconst bar = this.statBar(val);\n\t\t\treturn `${theme.fg(\"muted\", s[0])}:${bar}`;\n\t\t});\n\t\tlines.push(` ${statParts.join(\" \")}`);\n\n\t\treturn lines;\n\t}\n\n\t// =============================================================================\n\t// Narrow rendering (< 100 cols)\n\t// =============================================================================\n\n\tprivate renderNarrow(width: number): string[] {\n\t\tconst lines: string[] = [];\n\n\t\t// Single-line face\n\t\tconst eyes = this.state.eyeStyle;\n\t\tconst mouth = this.isPetting ? \"♥\" : \">\";\n\t\tconst face = `${this.state.hat}${eyes}${mouth}${eyes}`;\n\n\t\t// Name + truncated quip\n\t\tconst shinyMark = this.state.shiny ? \"✨\" : \"\";\n\t\tconst name = `${theme.bold(this.state.name)}${shinyMark}`;\n\n\t\tif (this.speechText) {\n\t\t\tconst maxQuip = Math.max(10, width - face.length - this.state.name.length - 6);\n\t\t\tconst styledQuip = this.renderInlineMarkdown(this.speechText);\n\t\t\tconst quip = visibleWidth(styledQuip) > maxQuip ? `${truncateToWidth(styledQuip, maxQuip - 1)}…` : styledQuip;\n\t\t\tlines.push(` ${face} ${name}: ${theme.fg(\"accent\", quip)}`);\n\t\t} else if (this.thinkingLabel !== null) {\n\t\t\tconst dots = \".\".repeat((this.thinkingDots % BuddyComponent.THINKING_DOT_COUNT) + 1);\n\t\t\tconst label = this.thinkingLabel || \"thinking\";\n\t\t\tlines.push(` ${face} ${name} ${theme.fg(\"muted\", `💭 ${label}${dots}`)}`);\n\t\t} else {\n\t\t\tlines.push(` ${face} ${name}`);\n\t\t}\n\n\t\treturn lines;\n\t}\n\n\t// =============================================================================\n\t// Animation\n\t// =============================================================================\n\n\tprivate startAnimation(): void {\n\t\tthis.interval = setInterval(() => {\n\t\t\tthis.currentFrame = (this.currentFrame + 1) % this.totalFrames;\n\n\t\t\t// Tick thinking dots\n\t\t\tif (this.thinkingLabel !== null) {\n\t\t\t\tthis.thinkingDots = (this.thinkingDots + 1) % BuddyComponent.THINKING_DOT_COUNT;\n\t\t\t}\n\n\t\t\t// Tick hearts\n\t\t\tif (this.isPetting) {\n\t\t\t\tfor (const heart of this.hearts) {\n\t\t\t\t\theart.life--;\n\t\t\t\t\theart.y++;\n\t\t\t\t}\n\t\t\t\tthis.hearts = this.hearts.filter((h) => h.life > 0);\n\t\t\t}\n\n\t\t\tthis.bumpVersion();\n\t\t\tthis.ui.requestRender();\n\t\t}, IDLE_INTERVAL_MS);\n\t}\n\n\tprivate stopAnimation(): void {\n\t\tif (this.interval) {\n\t\t\tclearInterval(this.interval);\n\t\t\tthis.interval = null;\n\t\t}\n\t}\n\n\tprivate bumpVersion(): void {\n\t\tthis.renderVersion++;\n\t}\n\n\t// =============================================================================\n\t// Formatting helpers\n\t// =============================================================================\n\n\tprivate statBar(value: number): string {\n\t\tconst filled = Math.round(value / 10);\n\t\tconst empty = 10 - filled;\n\t\tconst bar = \"█\".repeat(filled) + \"░\".repeat(empty);\n\t\tconst color: \"success\" | \"warning\" | \"error\" = value >= 70 ? \"success\" : value >= 40 ? \"warning\" : \"error\";\n\t\treturn theme.fg(color, bar);\n\t}\n\n\tprivate rarityColor(rarity: Rarity): \"muted\" | \"success\" | \"accent\" | \"warning\" | \"error\" {\n\t\tswitch (rarity) {\n\t\t\tcase Rarity.COMMON:\n\t\t\t\treturn \"muted\";\n\t\t\tcase Rarity.UNCOMMON:\n\t\t\t\treturn \"success\";\n\t\t\tcase Rarity.RARE:\n\t\t\t\treturn \"accent\";\n\t\t\tcase Rarity.EPIC:\n\t\t\t\treturn \"warning\";\n\t\t\tcase Rarity.LEGENDARY:\n\t\t\t\treturn \"error\";\n\t\t}\n\t}\n\n\tprivate formatSpeechBubble(text: string, maxWidth: number): string[] {\n\t\tif (!text.trim()) return [];\n\n\t\t// Render inline markdown (bold, italic, code) to styled text with ANSI codes\n\t\tconst styledText = this.renderInlineMarkdown(text);\n\n\t\t// Word-wrap using visible width (ANSI-aware, handles long words)\n\t\tconst lines = wrapTextWithAnsi(styledText, maxWidth);\n\n\t\t// Enforce hard line cap\n\t\tif (lines.length > SPEECH_MAX_CONTENT_LINES) {\n\t\t\tconst kept = lines.slice(0, SPEECH_MAX_CONTENT_LINES - 1);\n\t\t\tconst lastLine = lines[SPEECH_MAX_CONTENT_LINES - 1];\n\t\t\t// Truncate the last kept line with ellipsis\n\t\t\tconst truncated = truncateToWidth(lastLine, maxWidth - 1, \"…\");\n\t\t\tkept.push(truncated);\n\t\t\tlines.length = 0;\n\t\t\tlines.push(...kept);\n\t\t}\n\n\t\t// Wrap in bubble border using visible width for measurement\n\t\tconst maxLineWidth = Math.max(...lines.map((l) => visibleWidth(l)));\n\t\tconst bubbleWidth = Math.max(6, Math.min(maxLineWidth + 4, maxWidth + 4));\n\t\tconst top = `╭${\"─\".repeat(bubbleWidth - 2)}╮`;\n\t\tconst bottom = `╰${\"─\".repeat(bubbleWidth - 2)}╯`;\n\n\t\tconst result: string[] = [];\n\t\tresult.push(` ${theme.fg(\"accent\", top)}`);\n\t\tfor (const line of lines) {\n\t\t\tconst padding = Math.max(0, bubbleWidth - 4 - visibleWidth(line));\n\t\t\tconst padded = line + \" \".repeat(padding);\n\t\t\tresult.push(` ${theme.fg(\"accent\", \"│\")} ${padded} ${theme.fg(\"accent\", \"│\")}`);\n\t\t}\n\t\tresult.push(` ${theme.fg(\"accent\", bottom)}`);\n\n\t\treturn result;\n\t}\n\n\t/** Render inline markdown tokens (bold, italic, code) to ANSI-styled text */\n\tprivate renderInlineMarkdown(text: string): string {\n\t\tconst mdTheme = getMarkdownTheme();\n\t\tconst tokens = marked.lexer(text);\n\n\t\t// Flatten: we expect a paragraph containing inline tokens\n\t\tconst inlineTokens = tokens.flatMap((t: any) =>\n\t\t\tt.type === \"paragraph\" ? (t.tokens ?? []) : t.type === \"text\" ? t : [],\n\t\t);\n\n\t\treturn this.renderInlineTokens(inlineTokens, mdTheme);\n\t}\n\n\t/** Recursively render marked inline tokens to ANSI-styled strings */\n\tprivate renderInlineTokens(tokens: any[], mdTheme: MT): string {\n\t\tlet result = \"\";\n\t\tfor (const token of tokens) {\n\t\t\tswitch (token.type) {\n\t\t\t\tcase \"text\":\n\t\t\t\t\tresult += token.text ?? this.renderInlineTokens(token.tokens ?? [], mdTheme);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"strong\":\n\t\t\t\t\tresult += mdTheme.bold(this.renderInlineTokens(token.tokens ?? [], mdTheme));\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"em\":\n\t\t\t\t\tresult += mdTheme.italic(this.renderInlineTokens(token.tokens ?? [], mdTheme));\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"codespan\":\n\t\t\t\t\tresult += mdTheme.code(token.text);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"escape\":\n\t\t\t\t\tresult += token.text;\n\t\t\t\t\tbreak;\n\t\t\t\tdefault:\n\t\t\t\t\tresult += token.text ?? token.raw ?? \"\";\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\treturn result;\n\t}\n}\n"]}
@@ -8,7 +8,7 @@
8
8
  * - Narrow terminal fallback (<100 cols)
9
9
  * - Stat display and rarity badge
10
10
  */
11
- import { truncateToWidth, visibleWidth } from "@dreb/tui";
11
+ import { joinColumns, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@dreb/tui";
12
12
  import { marked } from "marked";
13
13
  import { applyEyes, getSpeciesFrames, getSpeciesWidth } from "../../../core/buddy/buddy-species.js";
14
14
  import { Rarity, Stat } from "../../../core/buddy/buddy-types.js";
@@ -18,6 +18,8 @@ const SPEECH_BUBBLE_DURATION_MS = 10000;
18
18
  const PET_DURATION_MS = 2500;
19
19
  const HEART_CHARS = ["❤️", "💕", "💖", "💗", "✨"];
20
20
  const NARROW_THRESHOLD = 100;
21
+ const SPEECH_MAX_CONTENT_LINES = 3;
22
+ const SIDE_PANEL_GAP = 2;
21
23
  export class BuddyComponent {
22
24
  ui;
23
25
  state;
@@ -145,15 +147,15 @@ export class BuddyComponent {
145
147
  const frames = getSpeciesFrames(this.state.species);
146
148
  const frame = frames[this.currentFrame % this.totalFrames];
147
149
  const rendered = applyEyes(frame, this.state.eyeStyle);
148
- // Hat line (if present)
150
+ const spriteWidth = getSpeciesWidth(this.state.species);
151
+ // Build LEFT block: hat + heart animation + sprite lines
152
+ const leftLines = [];
153
+ // Hat line
149
154
  if (this.state.hat) {
150
- const hatPad = Math.max(0, Math.floor((getSpeciesWidth(this.state.species) - 1) / 2) - 1);
151
- lines.push(" ".repeat(hatPad) + this.state.hat);
155
+ const hatPad = Math.max(0, Math.floor((spriteWidth - 1) / 2) - 1);
156
+ leftLines.push(" ".repeat(hatPad) + this.state.hat);
152
157
  }
153
- // Sprite lines
154
- const spriteWidth = getSpeciesWidth(this.state.species);
155
- lines.push(...rendered);
156
- // Heart animation line above sprite
158
+ // Heart animation line (above sprite, inserted at top)
157
159
  if (this.isPetting && this.hearts.length > 0) {
158
160
  const heartLine = " ".repeat(spriteWidth);
159
161
  const chars = heartLine.split("");
@@ -164,32 +166,42 @@ export class BuddyComponent {
164
166
  chars.splice(x, hChar.length, ...hChar.split(""));
165
167
  }
166
168
  }
167
- // Insert heart line before sprite
168
- lines.splice(this.state.hat ? 1 : 0, 0, chars.join(""));
169
+ leftLines.push(chars.join(""));
170
+ }
171
+ // Sprite lines
172
+ leftLines.push(...rendered);
173
+ // Build RIGHT block: speech bubble or thinking indicator
174
+ let rightLines = [];
175
+ if (this.speechText) {
176
+ const availableWidth = width - spriteWidth - SIDE_PANEL_GAP - 5; // 5 for leading space + bubble borders + padding
177
+ const bubbleMaxWidth = Math.max(20, availableWidth);
178
+ rightLines = this.formatSpeechBubble(this.speechText, bubbleMaxWidth);
179
+ }
180
+ else if (this.thinkingLabel !== null) {
181
+ const dots = ".".repeat((this.thinkingDots % BuddyComponent.THINKING_DOT_COUNT) + 1);
182
+ const label = this.thinkingLabel || "thinking";
183
+ rightLines = [` ${theme.fg("muted", `💭 ${label}${dots}`)}`];
184
+ }
185
+ // Merge left and right side-by-side
186
+ if (rightLines.length > 0) {
187
+ const merged = joinColumns(leftLines, rightLines, SIDE_PANEL_GAP, width);
188
+ lines.push(...merged);
169
189
  }
170
- // Name + rarity line
190
+ else {
191
+ lines.push(...leftLines);
192
+ }
193
+ // Name + rarity line (full width, below the sprite+panel area)
171
194
  const shinyMark = this.state.shiny ? " ✨" : "";
172
195
  const rarityColor = this.rarityColor(this.state.rarity);
173
196
  const nameLine = ` ${theme.bold(this.state.name)}${shinyMark} ${theme.fg(rarityColor, `[${this.state.rarity}]`)} ${theme.fg("muted", this.state.species)}`;
174
197
  lines.push(nameLine);
175
- // Stats line
198
+ // Stats line (full width)
176
199
  const statParts = Object.values(Stat).map((s) => {
177
200
  const val = this.state.stats[s];
178
201
  const bar = this.statBar(val);
179
202
  return `${theme.fg("muted", s[0])}:${bar}`;
180
203
  });
181
204
  lines.push(` ${statParts.join(" ")}`);
182
- // Thinking indicator
183
- if (this.thinkingLabel !== null) {
184
- const dots = ".".repeat((this.thinkingDots % BuddyComponent.THINKING_DOT_COUNT) + 1);
185
- const label = this.thinkingLabel || "thinking";
186
- lines.push(` ${theme.fg("muted", `💭 ${label}${dots}`)}`);
187
- }
188
- // Speech bubble (beside or below sprite)
189
- if (this.speechText) {
190
- const bubbleLines = this.formatSpeechBubble(this.speechText, Math.min(width - 4, 60));
191
- lines.push(...bubbleLines);
192
- }
193
205
  return lines;
194
206
  }
195
207
  // =============================================================================
@@ -280,23 +292,18 @@ export class BuddyComponent {
280
292
  return [];
281
293
  // Render inline markdown (bold, italic, code) to styled text with ANSI codes
282
294
  const styledText = this.renderInlineMarkdown(text);
283
- // Word-wrap using visible width (ANSI-aware)
284
- const lines = [];
285
- const words = styledText.split(" ");
286
- let currentLine = "";
287
- for (const word of words) {
288
- const test = currentLine ? `${currentLine} ${word}` : word;
289
- if (visibleWidth(test) > maxWidth) {
290
- if (currentLine)
291
- lines.push(currentLine);
292
- currentLine = word;
293
- }
294
- else {
295
- currentLine = test;
296
- }
295
+ // Word-wrap using visible width (ANSI-aware, handles long words)
296
+ const lines = wrapTextWithAnsi(styledText, maxWidth);
297
+ // Enforce hard line cap
298
+ if (lines.length > SPEECH_MAX_CONTENT_LINES) {
299
+ const kept = lines.slice(0, SPEECH_MAX_CONTENT_LINES - 1);
300
+ const lastLine = lines[SPEECH_MAX_CONTENT_LINES - 1];
301
+ // Truncate the last kept line with ellipsis
302
+ const truncated = truncateToWidth(lastLine, maxWidth - 1, "…");
303
+ kept.push(truncated);
304
+ lines.length = 0;
305
+ lines.push(...kept);
297
306
  }
298
- if (currentLine)
299
- lines.push(currentLine);
300
307
  // Wrap in bubble border using visible width for measurement
301
308
  const maxLineWidth = Math.max(...lines.map((l) => visibleWidth(l)));
302
309
  const bubbleWidth = Math.max(6, Math.min(maxLineWidth + 4, maxWidth + 4));
@@ -1 +1 @@
1
- {"version":3,"file":"buddy-component.js","sourceRoot":"","sources":["../../../../src/modes/interactive/components/buddy-component.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * BuddyComponent — TUI component rendering the buddy companion.\n *\n * Renders below the editor with:\n * - 3-frame idle animation cycling at 500ms\n * - Speech bubbles for reactions and name-call responses\n * - Pet hearts animation (2.5s)\n * - Narrow terminal fallback (<100 cols)\n * - Stat display and rarity badge\n */\n\nimport type { Component, MarkdownTheme as MT, TUI } from \"@dreb/tui\";\nimport { truncateToWidth, visibleWidth } from \"@dreb/tui\";\nimport { marked } from \"marked\";\nimport { applyEyes, getSpeciesFrames, getSpeciesWidth } from \"../../../core/buddy/buddy-species.js\";\nimport type { BuddyState } from \"../../../core/buddy/buddy-types.js\";\nimport { Rarity, Stat } from \"../../../core/buddy/buddy-types.js\";\nimport { getMarkdownTheme, theme } from \"../theme/theme.js\";\n\nconst IDLE_INTERVAL_MS = 500;\nconst SPEECH_BUBBLE_DURATION_MS = 10000;\nconst PET_DURATION_MS = 2500;\nconst HEART_CHARS = [\"❤️\", \"💕\", \"💖\", \"💗\", \"✨\"];\nconst NARROW_THRESHOLD = 100;\n\nexport class BuddyComponent implements Component {\n\tprivate ui: TUI;\n\tprivate state: BuddyState;\n\tprivate interval: ReturnType<typeof setInterval> | null = null;\n\n\t// Animation state\n\tprivate currentFrame = 0;\n\tprivate totalFrames = 3;\n\n\t// Speech bubble\n\tprivate speechText: string | null = null;\n\tprivate speechTimeout: ReturnType<typeof setTimeout> | null = null;\n\n\t// Pet animation\n\tprivate isPetting = false;\n\tprivate petTimeout: ReturnType<typeof setTimeout> | null = null;\n\tprivate hearts: Array<{ x: number; y: number; char: string; life: number }> = [];\n\n\t// Thinking indicator\n\tprivate thinkingLabel: string | null = null;\n\tprivate thinkingDots = 0;\n\tprivate static readonly THINKING_DOT_COUNT = 4; // cycles 0,1,2,3 → \".\", \"..\", \"...\", \"....\"\n\n\t// Cached render\n\tprivate cachedLines: string[] = [];\n\tprivate cachedWidth = 0;\n\tprivate cachedVersion = -1;\n\tprivate renderVersion = 0;\n\n\tconstructor(ui: TUI, state: BuddyState) {\n\t\tthis.ui = ui;\n\t\tthis.state = state;\n\t\tthis.totalFrames = getSpeciesFrames(state.species).length;\n\t\tthis.startAnimation();\n\t}\n\n\t/** Update buddy state (e.g. after reroll) */\n\tupdateState(state: BuddyState): void {\n\t\tthis.state = state;\n\t\tthis.totalFrames = getSpeciesFrames(state.species).length;\n\t\tthis.currentFrame = 0;\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\t/** Show a speech bubble with text */\n\tshowSpeech(text: string): void {\n\t\tthis.speechText = text;\n\t\tif (this.speechTimeout) clearTimeout(this.speechTimeout);\n\t\tthis.speechTimeout = setTimeout(() => {\n\t\t\tthis.speechText = null;\n\t\t\tthis.speechTimeout = null;\n\t\t\tthis.bumpVersion();\n\t\t\tthis.ui.requestRender();\n\t\t}, SPEECH_BUBBLE_DURATION_MS);\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\t/** Trigger pet animation */\n\tpet(): void {\n\t\tthis.isPetting = true;\n\t\t// Spawn hearts\n\t\tconst spriteWidth = getSpeciesWidth(this.state.species);\n\t\tfor (let i = 0; i < 5; i++) {\n\t\t\tthis.hearts.push({\n\t\t\t\tx: Math.floor(Math.random() * spriteWidth),\n\t\t\t\ty: -1 - Math.floor(Math.random() * 3),\n\t\t\t\tchar: HEART_CHARS[Math.floor(Math.random() * HEART_CHARS.length)],\n\t\t\t\tlife: 5 + Math.floor(Math.random() * 5),\n\t\t\t});\n\t\t}\n\t\tif (this.petTimeout) clearTimeout(this.petTimeout);\n\t\tthis.petTimeout = setTimeout(() => {\n\t\t\tthis.isPetting = false;\n\t\t\tthis.hearts = [];\n\t\t\tthis.petTimeout = null;\n\t\t\tthis.bumpVersion();\n\t\t\tthis.ui.requestRender();\n\t\t}, PET_DURATION_MS);\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\t/** Show a pulsing thinking indicator with optional label */\n\tshowThinking(label?: string): void {\n\t\tthis.thinkingLabel = label ?? \"thinking\";\n\t\tthis.thinkingDots = 0;\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\t/** Hide the thinking indicator */\n\thideThinking(): void {\n\t\tthis.thinkingLabel = null;\n\t\tthis.thinkingDots = 0;\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\tinvalidate(): void {\n\t\tthis.cachedWidth = 0;\n\t}\n\n\trender(width: number): string[] {\n\t\tif (width === this.cachedWidth && this.cachedVersion === this.renderVersion) {\n\t\t\treturn this.cachedLines;\n\t\t}\n\n\t\tif (width < NARROW_THRESHOLD) {\n\t\t\tthis.cachedLines = this.renderNarrow(width);\n\t\t} else {\n\t\t\tthis.cachedLines = this.renderFull(width);\n\t\t}\n\n\t\tthis.cachedWidth = width;\n\t\tthis.cachedVersion = this.renderVersion;\n\t\treturn this.cachedLines;\n\t}\n\n\tdispose(): void {\n\t\tthis.stopAnimation();\n\t\tthis.thinkingLabel = null;\n\t\tif (this.speechTimeout) {\n\t\t\tclearTimeout(this.speechTimeout);\n\t\t\tthis.speechTimeout = null;\n\t\t}\n\t\tif (this.petTimeout) {\n\t\t\tclearTimeout(this.petTimeout);\n\t\t\tthis.petTimeout = null;\n\t\t}\n\t}\n\n\t// =============================================================================\n\t// Full rendering (wide terminal)\n\t// =============================================================================\n\n\tprivate renderFull(width: number): string[] {\n\t\tconst lines: string[] = [];\n\t\tconst frames = getSpeciesFrames(this.state.species);\n\t\tconst frame = frames[this.currentFrame % this.totalFrames];\n\t\tconst rendered = applyEyes(frame, this.state.eyeStyle);\n\n\t\t// Hat line (if present)\n\t\tif (this.state.hat) {\n\t\t\tconst hatPad = Math.max(0, Math.floor((getSpeciesWidth(this.state.species) - 1) / 2) - 1);\n\t\t\tlines.push(\" \".repeat(hatPad) + this.state.hat);\n\t\t}\n\n\t\t// Sprite lines\n\t\tconst spriteWidth = getSpeciesWidth(this.state.species);\n\t\tlines.push(...rendered);\n\n\t\t// Heart animation line above sprite\n\t\tif (this.isPetting && this.hearts.length > 0) {\n\t\t\tconst heartLine = \" \".repeat(spriteWidth);\n\t\t\tconst chars = heartLine.split(\"\");\n\t\t\tfor (const heart of this.hearts) {\n\t\t\t\tconst x = Math.min(heart.x, chars.length - 2);\n\t\t\t\tif (x >= 0 && heart.life > 0) {\n\t\t\t\t\tconst hChar = heart.char;\n\t\t\t\t\tchars.splice(x, hChar.length, ...hChar.split(\"\"));\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Insert heart line before sprite\n\t\t\tlines.splice(this.state.hat ? 1 : 0, 0, chars.join(\"\"));\n\t\t}\n\n\t\t// Name + rarity line\n\t\tconst shinyMark = this.state.shiny ? \" ✨\" : \"\";\n\t\tconst rarityColor = this.rarityColor(this.state.rarity);\n\t\tconst nameLine = ` ${theme.bold(this.state.name)}${shinyMark} ${theme.fg(rarityColor, `[${this.state.rarity}]`)} ${theme.fg(\"muted\", this.state.species)}`;\n\t\tlines.push(nameLine);\n\n\t\t// Stats line\n\t\tconst statParts = (Object.values(Stat) as Stat[]).map((s) => {\n\t\t\tconst val = this.state.stats[s];\n\t\t\tconst bar = this.statBar(val);\n\t\t\treturn `${theme.fg(\"muted\", s[0])}:${bar}`;\n\t\t});\n\t\tlines.push(` ${statParts.join(\" \")}`);\n\n\t\t// Thinking indicator\n\t\tif (this.thinkingLabel !== null) {\n\t\t\tconst dots = \".\".repeat((this.thinkingDots % BuddyComponent.THINKING_DOT_COUNT) + 1);\n\t\t\tconst label = this.thinkingLabel || \"thinking\";\n\t\t\tlines.push(` ${theme.fg(\"muted\", `💭 ${label}${dots}`)}`);\n\t\t}\n\n\t\t// Speech bubble (beside or below sprite)\n\t\tif (this.speechText) {\n\t\t\tconst bubbleLines = this.formatSpeechBubble(this.speechText, Math.min(width - 4, 60));\n\t\t\tlines.push(...bubbleLines);\n\t\t}\n\n\t\treturn lines;\n\t}\n\n\t// =============================================================================\n\t// Narrow rendering (< 100 cols)\n\t// =============================================================================\n\n\tprivate renderNarrow(width: number): string[] {\n\t\tconst lines: string[] = [];\n\n\t\t// Single-line face\n\t\tconst eyes = this.state.eyeStyle;\n\t\tconst mouth = this.isPetting ? \"♥\" : \">\";\n\t\tconst face = `${this.state.hat}${eyes}${mouth}${eyes}`;\n\n\t\t// Name + truncated quip\n\t\tconst shinyMark = this.state.shiny ? \"✨\" : \"\";\n\t\tconst name = `${theme.bold(this.state.name)}${shinyMark}`;\n\n\t\tif (this.speechText) {\n\t\t\tconst maxQuip = Math.max(10, width - face.length - this.state.name.length - 6);\n\t\t\tconst styledQuip = this.renderInlineMarkdown(this.speechText);\n\t\t\tconst quip = visibleWidth(styledQuip) > maxQuip ? `${truncateToWidth(styledQuip, maxQuip - 1)}…` : styledQuip;\n\t\t\tlines.push(` ${face} ${name}: ${theme.fg(\"accent\", quip)}`);\n\t\t} else if (this.thinkingLabel !== null) {\n\t\t\tconst dots = \".\".repeat((this.thinkingDots % BuddyComponent.THINKING_DOT_COUNT) + 1);\n\t\t\tconst label = this.thinkingLabel || \"thinking\";\n\t\t\tlines.push(` ${face} ${name} ${theme.fg(\"muted\", `💭 ${label}${dots}`)}`);\n\t\t} else {\n\t\t\tlines.push(` ${face} ${name}`);\n\t\t}\n\n\t\treturn lines;\n\t}\n\n\t// =============================================================================\n\t// Animation\n\t// =============================================================================\n\n\tprivate startAnimation(): void {\n\t\tthis.interval = setInterval(() => {\n\t\t\tthis.currentFrame = (this.currentFrame + 1) % this.totalFrames;\n\n\t\t\t// Tick thinking dots\n\t\t\tif (this.thinkingLabel !== null) {\n\t\t\t\tthis.thinkingDots = (this.thinkingDots + 1) % BuddyComponent.THINKING_DOT_COUNT;\n\t\t\t}\n\n\t\t\t// Tick hearts\n\t\t\tif (this.isPetting) {\n\t\t\t\tfor (const heart of this.hearts) {\n\t\t\t\t\theart.life--;\n\t\t\t\t\theart.y++;\n\t\t\t\t}\n\t\t\t\tthis.hearts = this.hearts.filter((h) => h.life > 0);\n\t\t\t}\n\n\t\t\tthis.bumpVersion();\n\t\t\tthis.ui.requestRender();\n\t\t}, IDLE_INTERVAL_MS);\n\t}\n\n\tprivate stopAnimation(): void {\n\t\tif (this.interval) {\n\t\t\tclearInterval(this.interval);\n\t\t\tthis.interval = null;\n\t\t}\n\t}\n\n\tprivate bumpVersion(): void {\n\t\tthis.renderVersion++;\n\t}\n\n\t// =============================================================================\n\t// Formatting helpers\n\t// =============================================================================\n\n\tprivate statBar(value: number): string {\n\t\tconst filled = Math.round(value / 10);\n\t\tconst empty = 10 - filled;\n\t\tconst bar = \"█\".repeat(filled) + \"░\".repeat(empty);\n\t\tconst color: \"success\" | \"warning\" | \"error\" = value >= 70 ? \"success\" : value >= 40 ? \"warning\" : \"error\";\n\t\treturn theme.fg(color, bar);\n\t}\n\n\tprivate rarityColor(rarity: Rarity): \"muted\" | \"success\" | \"accent\" | \"warning\" | \"error\" {\n\t\tswitch (rarity) {\n\t\t\tcase Rarity.COMMON:\n\t\t\t\treturn \"muted\";\n\t\t\tcase Rarity.UNCOMMON:\n\t\t\t\treturn \"success\";\n\t\t\tcase Rarity.RARE:\n\t\t\t\treturn \"accent\";\n\t\t\tcase Rarity.EPIC:\n\t\t\t\treturn \"warning\";\n\t\t\tcase Rarity.LEGENDARY:\n\t\t\t\treturn \"error\";\n\t\t}\n\t}\n\n\tprivate formatSpeechBubble(text: string, maxWidth: number): string[] {\n\t\tif (!text.trim()) return [];\n\n\t\t// Render inline markdown (bold, italic, code) to styled text with ANSI codes\n\t\tconst styledText = this.renderInlineMarkdown(text);\n\n\t\t// Word-wrap using visible width (ANSI-aware)\n\t\tconst lines: string[] = [];\n\t\tconst words = styledText.split(\" \");\n\t\tlet currentLine = \"\";\n\n\t\tfor (const word of words) {\n\t\t\tconst test = currentLine ? `${currentLine} ${word}` : word;\n\t\t\tif (visibleWidth(test) > maxWidth) {\n\t\t\t\tif (currentLine) lines.push(currentLine);\n\t\t\t\tcurrentLine = word;\n\t\t\t} else {\n\t\t\t\tcurrentLine = test;\n\t\t\t}\n\t\t}\n\t\tif (currentLine) lines.push(currentLine);\n\n\t\t// Wrap in bubble border using visible width for measurement\n\t\tconst maxLineWidth = Math.max(...lines.map((l) => visibleWidth(l)));\n\t\tconst bubbleWidth = Math.max(6, Math.min(maxLineWidth + 4, maxWidth + 4));\n\t\tconst top = `╭${\"─\".repeat(bubbleWidth - 2)}╮`;\n\t\tconst bottom = `╰${\"─\".repeat(bubbleWidth - 2)}╯`;\n\n\t\tconst result: string[] = [];\n\t\tresult.push(` ${theme.fg(\"accent\", top)}`);\n\t\tfor (const line of lines) {\n\t\t\tconst padding = Math.max(0, bubbleWidth - 4 - visibleWidth(line));\n\t\t\tconst padded = line + \" \".repeat(padding);\n\t\t\tresult.push(` ${theme.fg(\"accent\", \"│\")} ${padded} ${theme.fg(\"accent\", \"│\")}`);\n\t\t}\n\t\tresult.push(` ${theme.fg(\"accent\", bottom)}`);\n\n\t\treturn result;\n\t}\n\n\t/** Render inline markdown tokens (bold, italic, code) to ANSI-styled text */\n\tprivate renderInlineMarkdown(text: string): string {\n\t\tconst mdTheme = getMarkdownTheme();\n\t\tconst tokens = marked.lexer(text);\n\n\t\t// Flatten: we expect a paragraph containing inline tokens\n\t\tconst inlineTokens = tokens.flatMap((t: any) =>\n\t\t\tt.type === \"paragraph\" ? (t.tokens ?? []) : t.type === \"text\" ? t : [],\n\t\t);\n\n\t\treturn this.renderInlineTokens(inlineTokens, mdTheme);\n\t}\n\n\t/** Recursively render marked inline tokens to ANSI-styled strings */\n\tprivate renderInlineTokens(tokens: any[], mdTheme: MT): string {\n\t\tlet result = \"\";\n\t\tfor (const token of tokens) {\n\t\t\tswitch (token.type) {\n\t\t\t\tcase \"text\":\n\t\t\t\t\tresult += token.text ?? this.renderInlineTokens(token.tokens ?? [], mdTheme);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"strong\":\n\t\t\t\t\tresult += mdTheme.bold(this.renderInlineTokens(token.tokens ?? [], mdTheme));\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"em\":\n\t\t\t\t\tresult += mdTheme.italic(this.renderInlineTokens(token.tokens ?? [], mdTheme));\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"codespan\":\n\t\t\t\t\tresult += mdTheme.code(token.text);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"escape\":\n\t\t\t\t\tresult += token.text;\n\t\t\t\t\tbreak;\n\t\t\t\tdefault:\n\t\t\t\t\tresult += token.text ?? token.raw ?? \"\";\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\treturn result;\n\t}\n}\n"]}
1
+ {"version":3,"file":"buddy-component.js","sourceRoot":"","sources":["../../../../src/modes/interactive/components/buddy-component.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * BuddyComponent — TUI component rendering the buddy companion.\n *\n * Renders below the editor with:\n * - 3-frame idle animation cycling at 500ms\n * - Speech bubbles for reactions and name-call responses\n * - Pet hearts animation (2.5s)\n * - Narrow terminal fallback (<100 cols)\n * - Stat display and rarity badge\n */\n\nimport type { Component, MarkdownTheme as MT, TUI } from \"@dreb/tui\";\nimport { joinColumns, truncateToWidth, visibleWidth, wrapTextWithAnsi } from \"@dreb/tui\";\nimport { marked } from \"marked\";\nimport { applyEyes, getSpeciesFrames, getSpeciesWidth } from \"../../../core/buddy/buddy-species.js\";\nimport type { BuddyState } from \"../../../core/buddy/buddy-types.js\";\nimport { Rarity, Stat } from \"../../../core/buddy/buddy-types.js\";\nimport { getMarkdownTheme, theme } from \"../theme/theme.js\";\n\nconst IDLE_INTERVAL_MS = 500;\nconst SPEECH_BUBBLE_DURATION_MS = 10000;\nconst PET_DURATION_MS = 2500;\nconst HEART_CHARS = [\"❤️\", \"💕\", \"💖\", \"💗\", \"✨\"];\nconst NARROW_THRESHOLD = 100;\nconst SPEECH_MAX_CONTENT_LINES = 3;\nconst SIDE_PANEL_GAP = 2;\n\nexport class BuddyComponent implements Component {\n\tprivate ui: TUI;\n\tprivate state: BuddyState;\n\tprivate interval: ReturnType<typeof setInterval> | null = null;\n\n\t// Animation state\n\tprivate currentFrame = 0;\n\tprivate totalFrames = 3;\n\n\t// Speech bubble\n\tprivate speechText: string | null = null;\n\tprivate speechTimeout: ReturnType<typeof setTimeout> | null = null;\n\n\t// Pet animation\n\tprivate isPetting = false;\n\tprivate petTimeout: ReturnType<typeof setTimeout> | null = null;\n\tprivate hearts: Array<{ x: number; y: number; char: string; life: number }> = [];\n\n\t// Thinking indicator\n\tprivate thinkingLabel: string | null = null;\n\tprivate thinkingDots = 0;\n\tprivate static readonly THINKING_DOT_COUNT = 4; // cycles 0,1,2,3 → \".\", \"..\", \"...\", \"....\"\n\n\t// Cached render\n\tprivate cachedLines: string[] = [];\n\tprivate cachedWidth = 0;\n\tprivate cachedVersion = -1;\n\tprivate renderVersion = 0;\n\n\tconstructor(ui: TUI, state: BuddyState) {\n\t\tthis.ui = ui;\n\t\tthis.state = state;\n\t\tthis.totalFrames = getSpeciesFrames(state.species).length;\n\t\tthis.startAnimation();\n\t}\n\n\t/** Update buddy state (e.g. after reroll) */\n\tupdateState(state: BuddyState): void {\n\t\tthis.state = state;\n\t\tthis.totalFrames = getSpeciesFrames(state.species).length;\n\t\tthis.currentFrame = 0;\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\t/** Show a speech bubble with text */\n\tshowSpeech(text: string): void {\n\t\tthis.speechText = text;\n\t\tif (this.speechTimeout) clearTimeout(this.speechTimeout);\n\t\tthis.speechTimeout = setTimeout(() => {\n\t\t\tthis.speechText = null;\n\t\t\tthis.speechTimeout = null;\n\t\t\tthis.bumpVersion();\n\t\t\tthis.ui.requestRender();\n\t\t}, SPEECH_BUBBLE_DURATION_MS);\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\t/** Trigger pet animation */\n\tpet(): void {\n\t\tthis.isPetting = true;\n\t\t// Spawn hearts\n\t\tconst spriteWidth = getSpeciesWidth(this.state.species);\n\t\tfor (let i = 0; i < 5; i++) {\n\t\t\tthis.hearts.push({\n\t\t\t\tx: Math.floor(Math.random() * spriteWidth),\n\t\t\t\ty: -1 - Math.floor(Math.random() * 3),\n\t\t\t\tchar: HEART_CHARS[Math.floor(Math.random() * HEART_CHARS.length)],\n\t\t\t\tlife: 5 + Math.floor(Math.random() * 5),\n\t\t\t});\n\t\t}\n\t\tif (this.petTimeout) clearTimeout(this.petTimeout);\n\t\tthis.petTimeout = setTimeout(() => {\n\t\t\tthis.isPetting = false;\n\t\t\tthis.hearts = [];\n\t\t\tthis.petTimeout = null;\n\t\t\tthis.bumpVersion();\n\t\t\tthis.ui.requestRender();\n\t\t}, PET_DURATION_MS);\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\t/** Show a pulsing thinking indicator with optional label */\n\tshowThinking(label?: string): void {\n\t\tthis.thinkingLabel = label ?? \"thinking\";\n\t\tthis.thinkingDots = 0;\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\t/** Hide the thinking indicator */\n\thideThinking(): void {\n\t\tthis.thinkingLabel = null;\n\t\tthis.thinkingDots = 0;\n\t\tthis.bumpVersion();\n\t\tthis.ui.requestRender();\n\t}\n\n\tinvalidate(): void {\n\t\tthis.cachedWidth = 0;\n\t}\n\n\trender(width: number): string[] {\n\t\tif (width === this.cachedWidth && this.cachedVersion === this.renderVersion) {\n\t\t\treturn this.cachedLines;\n\t\t}\n\n\t\tif (width < NARROW_THRESHOLD) {\n\t\t\tthis.cachedLines = this.renderNarrow(width);\n\t\t} else {\n\t\t\tthis.cachedLines = this.renderFull(width);\n\t\t}\n\n\t\tthis.cachedWidth = width;\n\t\tthis.cachedVersion = this.renderVersion;\n\t\treturn this.cachedLines;\n\t}\n\n\tdispose(): void {\n\t\tthis.stopAnimation();\n\t\tthis.thinkingLabel = null;\n\t\tif (this.speechTimeout) {\n\t\t\tclearTimeout(this.speechTimeout);\n\t\t\tthis.speechTimeout = null;\n\t\t}\n\t\tif (this.petTimeout) {\n\t\t\tclearTimeout(this.petTimeout);\n\t\t\tthis.petTimeout = null;\n\t\t}\n\t}\n\n\t// =============================================================================\n\t// Full rendering (wide terminal)\n\t// =============================================================================\n\n\tprivate renderFull(width: number): string[] {\n\t\tconst lines: string[] = [];\n\t\tconst frames = getSpeciesFrames(this.state.species);\n\t\tconst frame = frames[this.currentFrame % this.totalFrames];\n\t\tconst rendered = applyEyes(frame, this.state.eyeStyle);\n\t\tconst spriteWidth = getSpeciesWidth(this.state.species);\n\n\t\t// Build LEFT block: hat + heart animation + sprite lines\n\t\tconst leftLines: string[] = [];\n\n\t\t// Hat line\n\t\tif (this.state.hat) {\n\t\t\tconst hatPad = Math.max(0, Math.floor((spriteWidth - 1) / 2) - 1);\n\t\t\tleftLines.push(\" \".repeat(hatPad) + this.state.hat);\n\t\t}\n\n\t\t// Heart animation line (above sprite, inserted at top)\n\t\tif (this.isPetting && this.hearts.length > 0) {\n\t\t\tconst heartLine = \" \".repeat(spriteWidth);\n\t\t\tconst chars = heartLine.split(\"\");\n\t\t\tfor (const heart of this.hearts) {\n\t\t\t\tconst x = Math.min(heart.x, chars.length - 2);\n\t\t\t\tif (x >= 0 && heart.life > 0) {\n\t\t\t\t\tconst hChar = heart.char;\n\t\t\t\t\tchars.splice(x, hChar.length, ...hChar.split(\"\"));\n\t\t\t\t}\n\t\t\t}\n\t\t\tleftLines.push(chars.join(\"\"));\n\t\t}\n\n\t\t// Sprite lines\n\t\tleftLines.push(...rendered);\n\n\t\t// Build RIGHT block: speech bubble or thinking indicator\n\t\tlet rightLines: string[] = [];\n\t\tif (this.speechText) {\n\t\t\tconst availableWidth = width - spriteWidth - SIDE_PANEL_GAP - 5; // 5 for leading space + bubble borders + padding\n\t\t\tconst bubbleMaxWidth = Math.max(20, availableWidth);\n\t\t\trightLines = this.formatSpeechBubble(this.speechText, bubbleMaxWidth);\n\t\t} else if (this.thinkingLabel !== null) {\n\t\t\tconst dots = \".\".repeat((this.thinkingDots % BuddyComponent.THINKING_DOT_COUNT) + 1);\n\t\t\tconst label = this.thinkingLabel || \"thinking\";\n\t\t\trightLines = [` ${theme.fg(\"muted\", `💭 ${label}${dots}`)}`];\n\t\t}\n\n\t\t// Merge left and right side-by-side\n\t\tif (rightLines.length > 0) {\n\t\t\tconst merged = joinColumns(leftLines, rightLines, SIDE_PANEL_GAP, width);\n\t\t\tlines.push(...merged);\n\t\t} else {\n\t\t\tlines.push(...leftLines);\n\t\t}\n\n\t\t// Name + rarity line (full width, below the sprite+panel area)\n\t\tconst shinyMark = this.state.shiny ? \" ✨\" : \"\";\n\t\tconst rarityColor = this.rarityColor(this.state.rarity);\n\t\tconst nameLine = ` ${theme.bold(this.state.name)}${shinyMark} ${theme.fg(rarityColor, `[${this.state.rarity}]`)} ${theme.fg(\"muted\", this.state.species)}`;\n\t\tlines.push(nameLine);\n\n\t\t// Stats line (full width)\n\t\tconst statParts = (Object.values(Stat) as Stat[]).map((s) => {\n\t\t\tconst val = this.state.stats[s];\n\t\t\tconst bar = this.statBar(val);\n\t\t\treturn `${theme.fg(\"muted\", s[0])}:${bar}`;\n\t\t});\n\t\tlines.push(` ${statParts.join(\" \")}`);\n\n\t\treturn lines;\n\t}\n\n\t// =============================================================================\n\t// Narrow rendering (< 100 cols)\n\t// =============================================================================\n\n\tprivate renderNarrow(width: number): string[] {\n\t\tconst lines: string[] = [];\n\n\t\t// Single-line face\n\t\tconst eyes = this.state.eyeStyle;\n\t\tconst mouth = this.isPetting ? \"♥\" : \">\";\n\t\tconst face = `${this.state.hat}${eyes}${mouth}${eyes}`;\n\n\t\t// Name + truncated quip\n\t\tconst shinyMark = this.state.shiny ? \"✨\" : \"\";\n\t\tconst name = `${theme.bold(this.state.name)}${shinyMark}`;\n\n\t\tif (this.speechText) {\n\t\t\tconst maxQuip = Math.max(10, width - face.length - this.state.name.length - 6);\n\t\t\tconst styledQuip = this.renderInlineMarkdown(this.speechText);\n\t\t\tconst quip = visibleWidth(styledQuip) > maxQuip ? `${truncateToWidth(styledQuip, maxQuip - 1)}…` : styledQuip;\n\t\t\tlines.push(` ${face} ${name}: ${theme.fg(\"accent\", quip)}`);\n\t\t} else if (this.thinkingLabel !== null) {\n\t\t\tconst dots = \".\".repeat((this.thinkingDots % BuddyComponent.THINKING_DOT_COUNT) + 1);\n\t\t\tconst label = this.thinkingLabel || \"thinking\";\n\t\t\tlines.push(` ${face} ${name} ${theme.fg(\"muted\", `💭 ${label}${dots}`)}`);\n\t\t} else {\n\t\t\tlines.push(` ${face} ${name}`);\n\t\t}\n\n\t\treturn lines;\n\t}\n\n\t// =============================================================================\n\t// Animation\n\t// =============================================================================\n\n\tprivate startAnimation(): void {\n\t\tthis.interval = setInterval(() => {\n\t\t\tthis.currentFrame = (this.currentFrame + 1) % this.totalFrames;\n\n\t\t\t// Tick thinking dots\n\t\t\tif (this.thinkingLabel !== null) {\n\t\t\t\tthis.thinkingDots = (this.thinkingDots + 1) % BuddyComponent.THINKING_DOT_COUNT;\n\t\t\t}\n\n\t\t\t// Tick hearts\n\t\t\tif (this.isPetting) {\n\t\t\t\tfor (const heart of this.hearts) {\n\t\t\t\t\theart.life--;\n\t\t\t\t\theart.y++;\n\t\t\t\t}\n\t\t\t\tthis.hearts = this.hearts.filter((h) => h.life > 0);\n\t\t\t}\n\n\t\t\tthis.bumpVersion();\n\t\t\tthis.ui.requestRender();\n\t\t}, IDLE_INTERVAL_MS);\n\t}\n\n\tprivate stopAnimation(): void {\n\t\tif (this.interval) {\n\t\t\tclearInterval(this.interval);\n\t\t\tthis.interval = null;\n\t\t}\n\t}\n\n\tprivate bumpVersion(): void {\n\t\tthis.renderVersion++;\n\t}\n\n\t// =============================================================================\n\t// Formatting helpers\n\t// =============================================================================\n\n\tprivate statBar(value: number): string {\n\t\tconst filled = Math.round(value / 10);\n\t\tconst empty = 10 - filled;\n\t\tconst bar = \"█\".repeat(filled) + \"░\".repeat(empty);\n\t\tconst color: \"success\" | \"warning\" | \"error\" = value >= 70 ? \"success\" : value >= 40 ? \"warning\" : \"error\";\n\t\treturn theme.fg(color, bar);\n\t}\n\n\tprivate rarityColor(rarity: Rarity): \"muted\" | \"success\" | \"accent\" | \"warning\" | \"error\" {\n\t\tswitch (rarity) {\n\t\t\tcase Rarity.COMMON:\n\t\t\t\treturn \"muted\";\n\t\t\tcase Rarity.UNCOMMON:\n\t\t\t\treturn \"success\";\n\t\t\tcase Rarity.RARE:\n\t\t\t\treturn \"accent\";\n\t\t\tcase Rarity.EPIC:\n\t\t\t\treturn \"warning\";\n\t\t\tcase Rarity.LEGENDARY:\n\t\t\t\treturn \"error\";\n\t\t}\n\t}\n\n\tprivate formatSpeechBubble(text: string, maxWidth: number): string[] {\n\t\tif (!text.trim()) return [];\n\n\t\t// Render inline markdown (bold, italic, code) to styled text with ANSI codes\n\t\tconst styledText = this.renderInlineMarkdown(text);\n\n\t\t// Word-wrap using visible width (ANSI-aware, handles long words)\n\t\tconst lines = wrapTextWithAnsi(styledText, maxWidth);\n\n\t\t// Enforce hard line cap\n\t\tif (lines.length > SPEECH_MAX_CONTENT_LINES) {\n\t\t\tconst kept = lines.slice(0, SPEECH_MAX_CONTENT_LINES - 1);\n\t\t\tconst lastLine = lines[SPEECH_MAX_CONTENT_LINES - 1];\n\t\t\t// Truncate the last kept line with ellipsis\n\t\t\tconst truncated = truncateToWidth(lastLine, maxWidth - 1, \"…\");\n\t\t\tkept.push(truncated);\n\t\t\tlines.length = 0;\n\t\t\tlines.push(...kept);\n\t\t}\n\n\t\t// Wrap in bubble border using visible width for measurement\n\t\tconst maxLineWidth = Math.max(...lines.map((l) => visibleWidth(l)));\n\t\tconst bubbleWidth = Math.max(6, Math.min(maxLineWidth + 4, maxWidth + 4));\n\t\tconst top = `╭${\"─\".repeat(bubbleWidth - 2)}╮`;\n\t\tconst bottom = `╰${\"─\".repeat(bubbleWidth - 2)}╯`;\n\n\t\tconst result: string[] = [];\n\t\tresult.push(` ${theme.fg(\"accent\", top)}`);\n\t\tfor (const line of lines) {\n\t\t\tconst padding = Math.max(0, bubbleWidth - 4 - visibleWidth(line));\n\t\t\tconst padded = line + \" \".repeat(padding);\n\t\t\tresult.push(` ${theme.fg(\"accent\", \"│\")} ${padded} ${theme.fg(\"accent\", \"│\")}`);\n\t\t}\n\t\tresult.push(` ${theme.fg(\"accent\", bottom)}`);\n\n\t\treturn result;\n\t}\n\n\t/** Render inline markdown tokens (bold, italic, code) to ANSI-styled text */\n\tprivate renderInlineMarkdown(text: string): string {\n\t\tconst mdTheme = getMarkdownTheme();\n\t\tconst tokens = marked.lexer(text);\n\n\t\t// Flatten: we expect a paragraph containing inline tokens\n\t\tconst inlineTokens = tokens.flatMap((t: any) =>\n\t\t\tt.type === \"paragraph\" ? (t.tokens ?? []) : t.type === \"text\" ? t : [],\n\t\t);\n\n\t\treturn this.renderInlineTokens(inlineTokens, mdTheme);\n\t}\n\n\t/** Recursively render marked inline tokens to ANSI-styled strings */\n\tprivate renderInlineTokens(tokens: any[], mdTheme: MT): string {\n\t\tlet result = \"\";\n\t\tfor (const token of tokens) {\n\t\t\tswitch (token.type) {\n\t\t\t\tcase \"text\":\n\t\t\t\t\tresult += token.text ?? this.renderInlineTokens(token.tokens ?? [], mdTheme);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"strong\":\n\t\t\t\t\tresult += mdTheme.bold(this.renderInlineTokens(token.tokens ?? [], mdTheme));\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"em\":\n\t\t\t\t\tresult += mdTheme.italic(this.renderInlineTokens(token.tokens ?? [], mdTheme));\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"codespan\":\n\t\t\t\t\tresult += mdTheme.code(token.text);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"escape\":\n\t\t\t\t\tresult += token.text;\n\t\t\t\t\tbreak;\n\t\t\t\tdefault:\n\t\t\t\t\tresult += token.text ?? token.raw ?? \"\";\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\treturn result;\n\t}\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"footer.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/footer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAiC,MAAM,WAAW,CAAC;AAC1E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,uCAAuC,CAAC;AA0BxF;;;GAGG;AACH,qBAAa,eAAgB,YAAW,SAAS;IAI/C,OAAO,CAAC,OAAO;IACf,OAAO,CAAC,UAAU;IAJnB,OAAO,CAAC,kBAAkB,CAAQ;IAElC,YACS,OAAO,EAAE,YAAY,EACrB,UAAU,EAAE,0BAA0B,EAC3C;IAEJ,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAE5C;IAED;;;OAGG;IACH,UAAU,IAAI,IAAI,CAEjB;IAED;;;OAGG;IACH,OAAO,IAAI,IAAI,CAEd;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CA0J9B;CACD","sourcesContent":["import { type Component, truncateToWidth, visibleWidth } from \"@dreb/tui\";\nimport type { AgentSession } from \"../../../core/agent-session.js\";\nimport type { ReadonlyFooterDataProvider } from \"../../../core/footer-data-provider.js\";\nimport { theme } from \"../theme/theme.js\";\n\n/**\n * Sanitize text for display in a single-line status.\n * Removes newlines, tabs, carriage returns, and other control characters.\n */\nfunction sanitizeStatusText(text: string): string {\n\t// Replace newlines, tabs, carriage returns with space, then collapse multiple spaces\n\treturn text\n\t\t.replace(/[\\r\\n\\t]/g, \" \")\n\t\t.replace(/ +/g, \" \")\n\t\t.trim();\n}\n\n/**\n * Format token counts (similar to web-ui)\n */\nfunction formatTokens(count: number): string {\n\tif (count < 1000) return count.toString();\n\tif (count < 10000) return `${(count / 1000).toFixed(1)}k`;\n\tif (count < 1000000) return `${Math.round(count / 1000)}k`;\n\tif (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;\n\treturn `${Math.round(count / 1000000)}M`;\n}\n\n/**\n * Footer component that shows pwd, token stats, and context usage.\n * Computes token/context stats from session, gets git branch and extension statuses from provider.\n */\nexport class FooterComponent implements Component {\n\tprivate autoCompactEnabled = true;\n\n\tconstructor(\n\t\tprivate session: AgentSession,\n\t\tprivate footerData: ReadonlyFooterDataProvider,\n\t) {}\n\n\tsetAutoCompactEnabled(enabled: boolean): void {\n\t\tthis.autoCompactEnabled = enabled;\n\t}\n\n\t/**\n\t * No-op: git branch caching now handled by provider.\n\t * Kept for compatibility with existing call sites in interactive-mode.\n\t */\n\tinvalidate(): void {\n\t\t// No-op: git branch is cached/invalidated by provider\n\t}\n\n\t/**\n\t * Clean up resources.\n\t * Git watcher cleanup now handled by provider.\n\t */\n\tdispose(): void {\n\t\t// Git watcher cleanup handled by provider\n\t}\n\n\trender(width: number): string[] {\n\t\tconst state = this.session.state;\n\n\t\t// Calculate cumulative usage from ALL session entries (not just post-compaction messages)\n\t\tlet totalInput = 0;\n\t\tlet totalOutput = 0;\n\t\tlet totalCacheRead = 0;\n\t\tlet totalCacheWrite = 0;\n\t\tlet totalCost = 0;\n\n\t\tfor (const entry of this.session.sessionManager.getEntries()) {\n\t\t\tif (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\t\ttotalInput += entry.message.usage.input;\n\t\t\t\ttotalOutput += entry.message.usage.output;\n\t\t\t\ttotalCacheRead += entry.message.usage.cacheRead;\n\t\t\t\ttotalCacheWrite += entry.message.usage.cacheWrite;\n\t\t\t\ttotalCost += entry.message.usage.cost.total;\n\t\t\t}\n\t\t}\n\n\t\t// Calculate context usage from session (handles compaction correctly).\n\t\t// After compaction, tokens are unknown until the next LLM response.\n\t\tconst contextUsage = this.session.getContextUsage();\n\t\tconst contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;\n\t\tconst contextPercentValue = contextUsage?.percent ?? 0;\n\t\tconst contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : \"?\";\n\n\t\t// Replace home directory with ~\n\t\tlet pwd = process.cwd();\n\t\tconst home = process.env.HOME || process.env.USERPROFILE;\n\t\tif (home && pwd.startsWith(home)) {\n\t\t\tpwd = `~${pwd.slice(home.length)}`;\n\t\t}\n\n\t\t// Add git branch if available\n\t\tconst branch = this.footerData.getGitBranch();\n\t\tif (branch) {\n\t\t\tpwd = `${pwd} (${branch})`;\n\t\t}\n\n\t\t// Add session name if set\n\t\tconst sessionName = this.session.sessionManager.getSessionName();\n\t\tif (sessionName) {\n\t\t\tpwd = `${pwd} • ${sessionName}`;\n\t\t}\n\n\t\t// Build stats line\n\t\tconst statsParts = [];\n\t\tif (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`);\n\t\tif (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`);\n\t\tif (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);\n\t\tif (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);\n\n\t\t// Show cost with \"(sub)\" indicator if using OAuth subscription\n\t\tconst usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;\n\t\tif (totalCost || usingSubscription) {\n\t\t\tconst costStr = `$${totalCost.toFixed(3)}${usingSubscription ? \" (sub)\" : \"\"}`;\n\t\t\tstatsParts.push(costStr);\n\t\t}\n\n\t\t// Colorize context percentage based on usage\n\t\tlet contextPercentStr: string;\n\t\tconst autoIndicator = this.autoCompactEnabled ? \" (auto)\" : \"\";\n\t\tconst contextPercentDisplay =\n\t\t\tcontextPercent === \"?\"\n\t\t\t\t? `?/${formatTokens(contextWindow)}${autoIndicator}`\n\t\t\t\t: `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;\n\t\tif (contextPercentValue > 90) {\n\t\t\tcontextPercentStr = theme.fg(\"error\", contextPercentDisplay);\n\t\t} else if (contextPercentValue > 70) {\n\t\t\tcontextPercentStr = theme.fg(\"warning\", contextPercentDisplay);\n\t\t} else {\n\t\t\tcontextPercentStr = contextPercentDisplay;\n\t\t}\n\t\tstatsParts.push(contextPercentStr);\n\n\t\tlet statsLeft = statsParts.join(\" \");\n\n\t\t// Add model name on the right side, plus thinking level if model supports it\n\t\tconst modelName = state.model?.id || \"no-model\";\n\n\t\tlet statsLeftWidth = visibleWidth(statsLeft);\n\n\t\t// If statsLeft is too wide, truncate it\n\t\tif (statsLeftWidth > width) {\n\t\t\tstatsLeft = truncateToWidth(statsLeft, width, \"...\");\n\t\t\tstatsLeftWidth = visibleWidth(statsLeft);\n\t\t}\n\n\t\t// Calculate available space for padding (minimum 2 spaces between stats and model)\n\t\tconst minPadding = 2;\n\n\t\t// Add thinking level indicator if model supports reasoning\n\t\tlet rightSideWithoutProvider = modelName;\n\t\tif (state.model?.reasoning) {\n\t\t\tconst thinkingLevel = state.thinkingLevel || \"off\";\n\t\t\trightSideWithoutProvider =\n\t\t\t\tthinkingLevel === \"off\" ? `${modelName} • thinking off` : `${modelName} • ${thinkingLevel}`;\n\t\t}\n\n\t\t// Prepend the provider in parentheses if there are multiple providers and there's enough room\n\t\tlet rightSide = rightSideWithoutProvider;\n\t\tif (this.footerData.getAvailableProviderCount() > 1 && state.model) {\n\t\t\trightSide = `(${state.model!.provider}) ${rightSideWithoutProvider}`;\n\t\t\tif (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) {\n\t\t\t\t// Too wide, fall back\n\t\t\t\trightSide = rightSideWithoutProvider;\n\t\t\t}\n\t\t}\n\n\t\tconst rightSideWidth = visibleWidth(rightSide);\n\t\tconst totalNeeded = statsLeftWidth + minPadding + rightSideWidth;\n\n\t\tlet statsLine: string;\n\t\tif (totalNeeded <= width) {\n\t\t\t// Both fit - add padding to right-align model\n\t\t\tconst padding = \" \".repeat(width - statsLeftWidth - rightSideWidth);\n\t\t\tstatsLine = statsLeft + padding + rightSide;\n\t\t} else {\n\t\t\t// Need to truncate right side\n\t\t\tconst availableForRight = width - statsLeftWidth - minPadding;\n\t\t\tif (availableForRight > 0) {\n\t\t\t\tconst truncatedRight = truncateToWidth(rightSide, availableForRight, \"\");\n\t\t\t\tconst truncatedRightWidth = visibleWidth(truncatedRight);\n\t\t\t\tconst padding = \" \".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth));\n\t\t\t\tstatsLine = statsLeft + padding + truncatedRight;\n\t\t\t} else {\n\t\t\t\t// Not enough space for right side at all\n\t\t\t\tstatsLine = statsLeft;\n\t\t\t}\n\t\t}\n\n\t\t// Apply dim to each part separately. statsLeft may contain color codes (for context %)\n\t\t// that end with a reset, which would clear an outer dim wrapper. So we dim the parts\n\t\t// before and after the colored section independently.\n\t\tconst dimStatsLeft = theme.fg(\"dim\", statsLeft);\n\t\tconst remainder = statsLine.slice(statsLeft.length); // padding + rightSide\n\t\tconst dimRemainder = theme.fg(\"dim\", remainder);\n\n\t\tconst pwdLine = truncateToWidth(theme.fg(\"dim\", pwd), width, theme.fg(\"dim\", \"...\"));\n\t\tconst lines = [pwdLine, dimStatsLeft + dimRemainder];\n\n\t\t// Add extension statuses on a single line, sorted by key alphabetically\n\t\tconst extensionStatuses = this.footerData.getExtensionStatuses();\n\t\tif (extensionStatuses.size > 0) {\n\t\t\tconst sortedStatuses = Array.from(extensionStatuses.entries())\n\t\t\t\t.sort(([a], [b]) => a.localeCompare(b))\n\t\t\t\t.map(([, text]) => sanitizeStatusText(text));\n\t\t\tconst statusLine = sortedStatuses.join(\" \");\n\t\t\t// Truncate to terminal width with dim ellipsis for consistency with footer style\n\t\t\tlines.push(truncateToWidth(statusLine, width, theme.fg(\"dim\", \"...\")));\n\t\t}\n\n\t\treturn lines;\n\t}\n}\n"]}
1
+ {"version":3,"file":"footer.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/footer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAiC,MAAM,WAAW,CAAC;AAC1E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,uCAAuC,CAAC;AA0BxF;;;GAGG;AACH,qBAAa,eAAgB,YAAW,SAAS;IAI/C,OAAO,CAAC,OAAO;IACf,OAAO,CAAC,UAAU;IAJnB,OAAO,CAAC,kBAAkB,CAAQ;IAElC,YACS,OAAO,EAAE,YAAY,EACrB,UAAU,EAAE,0BAA0B,EAC3C;IAEJ,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAE5C;IAED;;;OAGG;IACH,UAAU,IAAI,IAAI,CAEjB;IAED;;;OAGG;IACH,OAAO,IAAI,IAAI,CAEd;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAoK9B;CACD","sourcesContent":["import { type Component, truncateToWidth, visibleWidth } from \"@dreb/tui\";\nimport type { AgentSession } from \"../../../core/agent-session.js\";\nimport type { ReadonlyFooterDataProvider } from \"../../../core/footer-data-provider.js\";\nimport { theme } from \"../theme/theme.js\";\n\n/**\n * Sanitize text for display in a single-line status.\n * Removes newlines, tabs, carriage returns, and other control characters.\n */\nfunction sanitizeStatusText(text: string): string {\n\t// Replace newlines, tabs, carriage returns with space, then collapse multiple spaces\n\treturn text\n\t\t.replace(/[\\r\\n\\t]/g, \" \")\n\t\t.replace(/ +/g, \" \")\n\t\t.trim();\n}\n\n/**\n * Format token counts (similar to web-ui)\n */\nfunction formatTokens(count: number): string {\n\tif (count < 1000) return count.toString();\n\tif (count < 10000) return `${(count / 1000).toFixed(1)}k`;\n\tif (count < 1000000) return `${Math.round(count / 1000)}k`;\n\tif (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;\n\treturn `${Math.round(count / 1000000)}M`;\n}\n\n/**\n * Footer component that shows pwd, token stats, and context usage.\n * Computes token/context stats from session, gets git branch and extension statuses from provider.\n */\nexport class FooterComponent implements Component {\n\tprivate autoCompactEnabled = true;\n\n\tconstructor(\n\t\tprivate session: AgentSession,\n\t\tprivate footerData: ReadonlyFooterDataProvider,\n\t) {}\n\n\tsetAutoCompactEnabled(enabled: boolean): void {\n\t\tthis.autoCompactEnabled = enabled;\n\t}\n\n\t/**\n\t * No-op: git branch caching now handled by provider.\n\t * Kept for compatibility with existing call sites in interactive-mode.\n\t */\n\tinvalidate(): void {\n\t\t// No-op: git branch is cached/invalidated by provider\n\t}\n\n\t/**\n\t * Clean up resources.\n\t * Git watcher cleanup now handled by provider.\n\t */\n\tdispose(): void {\n\t\t// Git watcher cleanup handled by provider\n\t}\n\n\trender(width: number): string[] {\n\t\tconst state = this.session.state;\n\n\t\t// Calculate cumulative usage from ALL session entries (not just post-compaction messages)\n\t\tlet totalInput = 0;\n\t\tlet totalOutput = 0;\n\t\tlet totalCacheRead = 0;\n\t\tlet totalCacheWrite = 0;\n\t\tlet totalCost = 0;\n\n\t\tfor (const entry of this.session.sessionManager.getEntries()) {\n\t\t\tif (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\t\ttotalInput += entry.message.usage.input;\n\t\t\t\ttotalOutput += entry.message.usage.output;\n\t\t\t\ttotalCacheRead += entry.message.usage.cacheRead;\n\t\t\t\ttotalCacheWrite += entry.message.usage.cacheWrite;\n\t\t\t\ttotalCost += entry.message.usage.cost.total;\n\t\t\t}\n\t\t}\n\n\t\t// Calculate context usage from session (handles compaction correctly).\n\t\t// After compaction, tokens are unknown until the next LLM response.\n\t\tconst contextUsage = this.session.getContextUsage();\n\t\tconst contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;\n\t\tconst contextPercentValue = contextUsage?.percent ?? 0;\n\t\tconst contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : \"?\";\n\n\t\t// Replace home directory with ~\n\t\tlet pwd = process.cwd();\n\t\tconst home = process.env.HOME || process.env.USERPROFILE;\n\t\tif (home && pwd.startsWith(home)) {\n\t\t\tpwd = `~${pwd.slice(home.length)}`;\n\t\t}\n\n\t\t// Add git branch if available\n\t\tconst branch = this.footerData.getGitBranch();\n\t\tif (branch) {\n\t\t\tpwd = `${pwd} (${branch})`;\n\t\t}\n\n\t\t// Add session name if set\n\t\tconst sessionName = this.session.sessionManager.getSessionName();\n\t\tif (sessionName) {\n\t\t\tpwd = `${pwd} • ${sessionName}`;\n\t\t}\n\n\t\t// Build stats line as sections separated by ·\n\t\tconst tokenParts = [];\n\t\tif (totalInput) tokenParts.push(`↑${formatTokens(totalInput)}`);\n\t\tif (totalOutput) tokenParts.push(`↓${formatTokens(totalOutput)}`);\n\t\tif (totalCacheRead) tokenParts.push(`R${formatTokens(totalCacheRead)}`);\n\t\tif (totalCacheWrite) tokenParts.push(`W${formatTokens(totalCacheWrite)}`);\n\n\t\t// Show cost with \"(sub)\" indicator if using OAuth subscription\n\t\tlet costStr = \"\";\n\t\tconst usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;\n\t\tif (totalCost || usingSubscription) {\n\t\t\tcostStr = `$${totalCost.toFixed(3)}${usingSubscription ? \" (sub)\" : \"\"}`;\n\t\t\t// Append daily total when there's cross-session spend\n\t\t\tconst dailyCost = this.footerData.getDailyCost();\n\t\t\tif (dailyCost > totalCost) {\n\t\t\t\tcostStr += `, today: $${dailyCost.toFixed(2)}`;\n\t\t\t}\n\t\t}\n\n\t\t// Colorize context percentage based on usage\n\t\tlet contextPercentStr: string;\n\t\tconst autoIndicator = this.autoCompactEnabled ? \" (auto)\" : \"\";\n\t\tconst contextPercentDisplay =\n\t\t\tcontextPercent === \"?\"\n\t\t\t\t? `?/${formatTokens(contextWindow)}${autoIndicator}`\n\t\t\t\t: `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;\n\t\tif (contextPercentValue > 90) {\n\t\t\tcontextPercentStr = theme.fg(\"error\", contextPercentDisplay);\n\t\t} else if (contextPercentValue > 70) {\n\t\t\tcontextPercentStr = theme.fg(\"warning\", contextPercentDisplay);\n\t\t} else {\n\t\t\tcontextPercentStr = contextPercentDisplay;\n\t\t}\n\n\t\t// Join sections with · separator\n\t\tconst sections: string[] = [];\n\t\tif (tokenParts.length > 0) sections.push(tokenParts.join(\" \"));\n\t\tif (costStr) sections.push(costStr);\n\t\tsections.push(contextPercentStr);\n\n\t\tlet statsLeft = sections.join(\" · \");\n\n\t\t// Add model name on the right side, plus thinking level if model supports it\n\t\tconst modelName = state.model?.id || \"no-model\";\n\n\t\tlet statsLeftWidth = visibleWidth(statsLeft);\n\n\t\t// If statsLeft is too wide, truncate it\n\t\tif (statsLeftWidth > width) {\n\t\t\tstatsLeft = truncateToWidth(statsLeft, width, \"...\");\n\t\t\tstatsLeftWidth = visibleWidth(statsLeft);\n\t\t}\n\n\t\t// Calculate available space for padding (minimum 2 spaces between stats and model)\n\t\tconst minPadding = 2;\n\n\t\t// Add thinking level indicator if model supports reasoning\n\t\tlet rightSideWithoutProvider = modelName;\n\t\tif (state.model?.reasoning) {\n\t\t\tconst thinkingLevel = state.thinkingLevel || \"off\";\n\t\t\trightSideWithoutProvider =\n\t\t\t\tthinkingLevel === \"off\" ? `${modelName} • thinking off` : `${modelName} • ${thinkingLevel}`;\n\t\t}\n\n\t\t// Prepend the provider in parentheses if there are multiple providers and there's enough room\n\t\tlet rightSide = rightSideWithoutProvider;\n\t\tif (this.footerData.getAvailableProviderCount() > 1 && state.model) {\n\t\t\trightSide = `(${state.model!.provider}) ${rightSideWithoutProvider}`;\n\t\t\tif (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) {\n\t\t\t\t// Too wide, fall back\n\t\t\t\trightSide = rightSideWithoutProvider;\n\t\t\t}\n\t\t}\n\n\t\tconst rightSideWidth = visibleWidth(rightSide);\n\t\tconst totalNeeded = statsLeftWidth + minPadding + rightSideWidth;\n\n\t\tlet statsLine: string;\n\t\tif (totalNeeded <= width) {\n\t\t\t// Both fit - add padding to right-align model\n\t\t\tconst padding = \" \".repeat(width - statsLeftWidth - rightSideWidth);\n\t\t\tstatsLine = statsLeft + padding + rightSide;\n\t\t} else {\n\t\t\t// Need to truncate right side\n\t\t\tconst availableForRight = width - statsLeftWidth - minPadding;\n\t\t\tif (availableForRight > 0) {\n\t\t\t\tconst truncatedRight = truncateToWidth(rightSide, availableForRight, \"\");\n\t\t\t\tconst truncatedRightWidth = visibleWidth(truncatedRight);\n\t\t\t\tconst padding = \" \".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth));\n\t\t\t\tstatsLine = statsLeft + padding + truncatedRight;\n\t\t\t} else {\n\t\t\t\t// Not enough space for right side at all\n\t\t\t\tstatsLine = statsLeft;\n\t\t\t}\n\t\t}\n\n\t\t// Apply dim to each part separately. statsLeft may contain color codes (for context %)\n\t\t// that end with a reset, which would clear an outer dim wrapper. So we dim the parts\n\t\t// before and after the colored section independently.\n\t\tconst dimStatsLeft = theme.fg(\"dim\", statsLeft);\n\t\tconst remainder = statsLine.slice(statsLeft.length); // padding + rightSide\n\t\tconst dimRemainder = theme.fg(\"dim\", remainder);\n\n\t\tconst pwdLine = truncateToWidth(theme.fg(\"dim\", pwd), width, theme.fg(\"dim\", \"...\"));\n\t\tconst lines = [pwdLine, dimStatsLeft + dimRemainder];\n\n\t\t// Add extension statuses on a single line, sorted by key alphabetically\n\t\tconst extensionStatuses = this.footerData.getExtensionStatuses();\n\t\tif (extensionStatuses.size > 0) {\n\t\t\tconst sortedStatuses = Array.from(extensionStatuses.entries())\n\t\t\t\t.sort(([a], [b]) => a.localeCompare(b))\n\t\t\t\t.map(([, text]) => sanitizeStatusText(text));\n\t\t\tconst statusLine = sortedStatuses.join(\" \");\n\t\t\t// Truncate to terminal width with dim ellipsis for consistency with footer style\n\t\t\tlines.push(truncateToWidth(statusLine, width, theme.fg(\"dim\", \"...\")));\n\t\t}\n\n\t\treturn lines;\n\t}\n}\n"]}
@@ -93,21 +93,26 @@ export class FooterComponent {
93
93
  if (sessionName) {
94
94
  pwd = `${pwd} • ${sessionName}`;
95
95
  }
96
- // Build stats line
97
- const statsParts = [];
96
+ // Build stats line as sections separated by ·
97
+ const tokenParts = [];
98
98
  if (totalInput)
99
- statsParts.push(`↑${formatTokens(totalInput)}`);
99
+ tokenParts.push(`↑${formatTokens(totalInput)}`);
100
100
  if (totalOutput)
101
- statsParts.push(`↓${formatTokens(totalOutput)}`);
101
+ tokenParts.push(`↓${formatTokens(totalOutput)}`);
102
102
  if (totalCacheRead)
103
- statsParts.push(`R${formatTokens(totalCacheRead)}`);
103
+ tokenParts.push(`R${formatTokens(totalCacheRead)}`);
104
104
  if (totalCacheWrite)
105
- statsParts.push(`W${formatTokens(totalCacheWrite)}`);
105
+ tokenParts.push(`W${formatTokens(totalCacheWrite)}`);
106
106
  // Show cost with "(sub)" indicator if using OAuth subscription
107
+ let costStr = "";
107
108
  const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;
108
109
  if (totalCost || usingSubscription) {
109
- const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
110
- statsParts.push(costStr);
110
+ costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
111
+ // Append daily total when there's cross-session spend
112
+ const dailyCost = this.footerData.getDailyCost();
113
+ if (dailyCost > totalCost) {
114
+ costStr += `, today: $${dailyCost.toFixed(2)}`;
115
+ }
111
116
  }
112
117
  // Colorize context percentage based on usage
113
118
  let contextPercentStr;
@@ -124,8 +129,14 @@ export class FooterComponent {
124
129
  else {
125
130
  contextPercentStr = contextPercentDisplay;
126
131
  }
127
- statsParts.push(contextPercentStr);
128
- let statsLeft = statsParts.join(" ");
132
+ // Join sections with · separator
133
+ const sections = [];
134
+ if (tokenParts.length > 0)
135
+ sections.push(tokenParts.join(" "));
136
+ if (costStr)
137
+ sections.push(costStr);
138
+ sections.push(contextPercentStr);
139
+ let statsLeft = sections.join(" · ");
129
140
  // Add model name on the right side, plus thinking level if model supports it
130
141
  const modelName = state.model?.id || "no-model";
131
142
  let statsLeftWidth = visibleWidth(statsLeft);