{"version":3,"file":"footer.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/footer.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,KAAK,SAAS,EAAiC,MAAM,wBAAwB,CAAC;AACvF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,uCAAuC,CAAC;AA+HxF,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,UAAU,IAAI,IAAI,CAAG;IAErB,OAAO,IAAI,IAAI,CAAG;IAElB,OAAO,CAAC,gBAAgB;IAgBxB,OAAO,CAAC,sBAAsB;IAM9B,OAAO,CAAC,wBAAwB;IAMhC,OAAO,CAAC,sBAAsB;IAQ9B,OAAO,CAAC,qBAAqB;IAU7B,OAAO,CAAC,yBAAyB;IAgBjC,OAAO,CAAC,iBAAiB;IAMzB,OAAO,CAAC,cAAc;IAetB,OAAO,CAAC,WAAW;IAuBnB,OAAO,CAAC,mBAAmB;IAgB3B;;;;OAIG;IACH,OAAO,CAAC,mBAAmB;IA2B3B,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CA6C9B;CACD","sourcesContent":["import * as os from \"node:os\";\nimport {\n\ttype Api,\n\tcalculateCost,\n\tgetModel,\n\tgetProviders,\n\ttype KnownProvider,\n\ttype Model,\n\ttype ToolResultMessage,\n} from \"@apholdings/jensen-ai\";\nimport { type Component, truncateToWidth, visibleWidth } from \"@apholdings/jensen-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\nconst KNOWN_PROVIDERS = new Set<string>(getProviders());\n\ntype CostSummary = {\n\ttotal: number;\n\thasUsage: boolean;\n\thasUnknown: boolean;\n};\n\nfunction summarizeAssistantCosts(\n\tmessages: AgentSession[\"state\"][\"messages\"],\n\tcurrentModel: Model<Api> | null | undefined,\n): CostSummary {\n\tlet total = 0;\n\tlet hasUsage = false;\n\tlet hasUnknown = false;\n\n\tfor (const message of messages) {\n\t\tif (message.role !== \"assistant\") continue;\n\t\thasUsage = true;\n\n\t\tconst pricingModel = resolvePricingModel(message.provider, message.model, currentModel);\n\t\tif (!pricingModel) {\n\t\t\thasUnknown = true;\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst cost = calculateCost(pricingModel, buildUsageSnapshot(message.usage));\n\t\ttotal += cost.total;\n\t}\n\n\treturn { total, hasUsage, hasUnknown };\n}\n\nfunction findCurrentTurnStartIndex(messages: AgentSession[\"state\"][\"messages\"]): number | null {\n\tfor (let i = messages.length - 1; i >= 0; i--) {\n\t\tconst message = messages[i];\n\t\tif (message.role === \"user\" || message.role === \"bashExecution\") {\n\t\t\treturn i;\n\t\t}\n\t}\n\n\treturn null;\n}\n\nfunction sumDelegatedCosts(messages: AgentSession[\"state\"][\"messages\"]): number {\n\tlet total = 0;\n\tfor (const message of messages) {\n\t\tif (message.role !== \"toolResult\") continue;\n\t\tconst toolResult = message as ToolResultMessage;\n\t\tif (toolResult.toolName !== \"subagent\") continue;\n\t\tconst details = toolResult.details as { results?: Array<{ usage?: { cost?: number } }> } | undefined;\n\t\tif (!details || !Array.isArray(details.results)) continue;\n\t\tfor (const result of details.results) {\n\t\t\tif (typeof result?.usage?.cost === \"number\") {\n\t\t\t\ttotal += result.usage.cost;\n\t\t\t}\n\t\t}\n\t}\n\treturn total;\n}\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\treturn text\n\t\t.replace(/[\\r\\n\\t]/g, \" \")\n\t\t.replace(/ +/g, \" \")\n\t\t.trim();\n}\n\nfunction formatTokenCount(value: number): string {\n\tif (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;\n\tif (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;\n\treturn String(value);\n}\n\nfunction formatCost(value: number): string {\n\tif (value >= 100) return `$${value.toFixed(2)}`;\n\tif (value >= 1) return `$${value.toFixed(3)}`;\n\tif (value >= 0.01) return `$${value.toFixed(4)}`;\n\treturn `$${value.toFixed(6)}`;\n}\n\nfunction resolvePricingModel(\n\tprovider: string,\n\tmodelId: string,\n\tcurrentModel: Model<Api> | null | undefined,\n): Model<Api> | undefined {\n\tif (currentModel && currentModel.provider === provider && currentModel.id === modelId) {\n\t\treturn currentModel;\n\t}\n\n\tif (!KNOWN_PROVIDERS.has(provider)) {\n\t\treturn undefined;\n\t}\n\n\treturn getModel(provider as KnownProvider, modelId as never) as Model<Api> | undefined;\n}\n\nfunction buildUsageSnapshot(messageUsage: {\n\tinput?: number;\n\toutput?: number;\n\tcacheRead?: number;\n\tcacheWrite?: number;\n\ttotalTokens?: number;\n}) {\n\treturn {\n\t\tinput: typeof messageUsage.input === \"number\" ? messageUsage.input : 0,\n\t\toutput: typeof messageUsage.output === \"number\" ? messageUsage.output : 0,\n\t\tcacheRead: typeof messageUsage.cacheRead === \"number\" ? messageUsage.cacheRead : 0,\n\t\tcacheWrite: typeof messageUsage.cacheWrite === \"number\" ? messageUsage.cacheWrite : 0,\n\t\ttotalTokens: typeof messageUsage.totalTokens === \"number\" ? messageUsage.totalTokens : 0,\n\t\tcost: {\n\t\t\tinput: 0,\n\t\t\toutput: 0,\n\t\t\tcacheRead: 0,\n\t\t\tcacheWrite: 0,\n\t\t\ttotal: 0,\n\t\t},\n\t};\n}\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\tinvalidate(): void {}\n\n\tdispose(): void {}\n\n\tprivate getContextTokens(): string {\n\t\tconst usage = this.session.getContextUsage();\n\t\tif (!usage || usage.tokens == null) return \"--\";\n\n\t\tconst contextWindow =\n\t\t\ttypeof usage.contextWindow === \"number\"\n\t\t\t\t? usage.contextWindow\n\t\t\t\t: (this.session.state.model?.contextWindow ?? null);\n\n\t\tif (contextWindow == null) {\n\t\t\treturn formatTokenCount(usage.tokens);\n\t\t}\n\n\t\treturn `${formatTokenCount(usage.tokens)}/${formatTokenCount(contextWindow)}`;\n\t}\n\n\tprivate getContextPercentValue(): number | null {\n\t\tconst usage = this.session.getContextUsage();\n\t\tif (!usage || usage.percent == null) return null;\n\t\treturn usage.percent;\n\t}\n\n\tprivate getContextPercentDisplay(): string {\n\t\tconst percent = this.getContextPercentValue();\n\t\tif (percent == null) return \"--\";\n\t\treturn `${percent.toFixed(1)}%${this.autoCompactEnabled ? \" (auto)\" : \"\"}`;\n\t}\n\n\tprivate getContextPercentColor(): \"success\" | \"warning\" | \"error\" {\n\t\tconst percent = this.getContextPercentValue();\n\t\tif (percent == null) return \"success\";\n\t\tif (percent > 90) return \"error\";\n\t\tif (percent > 70) return \"warning\";\n\t\treturn \"success\";\n\t}\n\n\tprivate getSessionCostSummary(): CostSummary {\n\t\tconst summary = summarizeAssistantCosts(this.session.state.messages, this.session.state.model);\n\t\tconst delegated = sumDelegatedCosts(this.session.state.messages);\n\t\treturn {\n\t\t\ttotal: summary.total + delegated,\n\t\t\thasUsage: summary.hasUsage || delegated > 0,\n\t\t\thasUnknown: summary.hasUnknown,\n\t\t};\n\t}\n\n\tprivate getCurrentTurnCostSummary(): CostSummary | null {\n\t\tconst turnStartIndex = findCurrentTurnStartIndex(this.session.state.messages);\n\t\tif (turnStartIndex == null) {\n\t\t\treturn null;\n\t\t}\n\n\t\tconst slice = this.session.state.messages.slice(turnStartIndex);\n\t\tconst summary = summarizeAssistantCosts(slice, this.session.state.model);\n\t\tconst delegated = sumDelegatedCosts(slice);\n\t\treturn {\n\t\t\ttotal: summary.total + delegated,\n\t\t\thasUsage: summary.hasUsage || delegated > 0,\n\t\t\thasUnknown: summary.hasUnknown,\n\t\t};\n\t}\n\n\tprivate formatCostSummary(summary: CostSummary): string {\n\t\tif (!summary.hasUsage) return \"--\";\n\t\tif (summary.hasUnknown && summary.total <= 0) return \"?\";\n\t\treturn `${formatCost(summary.total)}${summary.hasUnknown ? \"+\" : \"\"}`;\n\t}\n\n\tprivate getCostDisplay(): string {\n\t\tconst sessionSummary = this.getSessionCostSummary();\n\t\tconst currentTurnSummary = this.getCurrentTurnCostSummary();\n\n\t\tif (!sessionSummary.hasUsage) {\n\t\t\treturn \"session --\";\n\t\t}\n\n\t\tif (!currentTurnSummary || !currentTurnSummary.hasUsage) {\n\t\t\treturn `session ${this.formatCostSummary(sessionSummary)}`;\n\t\t}\n\n\t\treturn `turn ${this.formatCostSummary(currentTurnSummary)} · session ${this.formatCostSummary(sessionSummary)}`;\n\t}\n\n\tprivate compactPath(input: string, maxWidth = 44): string {\n\t\tconst home = os.homedir();\n\t\tlet normalized = input;\n\t\tif (normalized.startsWith(home)) {\n\t\t\tnormalized = `~${normalized.slice(home.length)}`;\n\t\t}\n\t\tnormalized = normalized.replaceAll(\"/\", process.platform === \"win32\" ? \"\\\\\" : \"/\");\n\t\tif (normalized.length <= maxWidth) return normalized;\n\n\t\tconst separator = normalized.includes(\"\\\\\") ? \"\\\\\" : \"/\";\n\t\tconst parts = normalized.split(separator).filter(Boolean);\n\t\tif (parts.length <= 2) return normalized;\n\n\t\tconst first = normalized.startsWith(separator) ? separator : \"\";\n\t\tconst isHome = parts[0] === \"~\";\n\t\tconst driveMatch = parts[0]?.match(/^[A-Za-z]:$/);\n\n\t\tconst head = isHome ? \"~\" : driveMatch ? `${parts[0]}${separator}` : first + parts[0];\n\t\tconst tail = parts.slice(-2).join(separator);\n\n\t\treturn `${head}${separator}…${separator}${tail}`;\n\t}\n\n\tprivate getLeftContentParts(): string[] {\n\t\tconst cwd = process.cwd();\n\t\tconst repo = this.footerData.getGitRepoName();\n\t\tconst branch = this.footerData.getGitBranch();\n\n\t\tconst parts: string[] = [];\n\t\tparts.push(`${theme.fg(\"accent\", \"cwd\")} ${theme.fg(\"text\", this.compactPath(cwd))}`);\n\t\tif (repo) {\n\t\t\tparts.push(`${theme.fg(\"accent\", \"repo\")} ${theme.fg(\"text\", repo)}`);\n\t\t}\n\t\tif (branch) {\n\t\t\tparts.push(`${theme.fg(\"accent\", \"branch\")} ${theme.fg(\"text\", branch)}`);\n\t\t}\n\t\treturn parts;\n\t}\n\n\t/**\n\t * Progressive truncation of left content parts to fit within available width.\n\t * Parts are removed from right to left (least important first), preserving\n\t * the most important content like cwd and path components.\n\t */\n\tprivate truncateLeftContent(parts: string[], availableWidth: number, separator: string, ellipsis: string): string {\n\t\tif (parts.length === 0) return \"\";\n\n\t\t// Start with all parts\n\t\tconst currentParts = [...parts];\n\t\tlet result = currentParts.join(separator);\n\t\tlet currentWidth = visibleWidth(result);\n\n\t\t// If everything fits, return as-is\n\t\tif (currentWidth <= availableWidth) {\n\t\t\treturn truncateToWidth(result, availableWidth, ellipsis);\n\t\t}\n\n\t\t// Progressive truncation: remove parts from right to left\n\t\twhile (currentParts.length > 1 && currentWidth > availableWidth) {\n\t\t\t// Remove the last part (least important)\n\t\t\tcurrentParts.pop();\n\n\t\t\t// Reconstruct with separator\n\t\t\tresult = currentParts.join(separator);\n\t\t\tcurrentWidth = visibleWidth(result);\n\t\t}\n\n\t\t// Now we have at most one part (the cwd), truncate it to fit\n\t\treturn truncateToWidth(result, availableWidth, ellipsis);\n\t}\n\n\trender(width: number): string[] {\n\t\tif (width <= 0) return [\"\"];\n\n\t\tconst separator = theme.fg(\"dim\", \" · \");\n\t\tconst ellipsis = theme.fg(\"dim\", \"...\");\n\n\t\tconst leftParts = this.getLeftContentParts();\n\t\tconst right = [\n\t\t\ttheme.fg(\"accent\", \"tok \") + theme.fg(\"text\", this.getContextTokens()),\n\t\t\ttheme.fg(\"accent\", \"ctx \") + theme.fg(this.getContextPercentColor(), this.getContextPercentDisplay()),\n\t\t\ttheme.fg(\"accent\", \"cost \") + theme.fg(\"text\", this.getCostDisplay()),\n\t\t].join(separator);\n\n\t\tconst rightWidth = visibleWidth(right);\n\n\t\t// Calculate space needed for left content (all parts joined)\n\t\tconst fullLeftWidth = visibleWidth(leftParts.join(separator));\n\t\tconst gap = width - fullLeftWidth - rightWidth;\n\n\t\tlet footerLine: string;\n\t\tif (gap >= 0) {\n\t\t\t// Enough space: left aligned, right aligned\n\t\t\tfooterLine = leftParts.join(separator) + \" \".repeat(gap) + right;\n\t\t} else {\n\t\t\t// Not enough space, progressively truncate left parts\n\t\t\tconst availableForLeft = width - rightWidth - 1; // -1 for space between\n\t\t\tconst truncatedLeft = this.truncateLeftContent(leftParts, availableForLeft, separator, ellipsis);\n\t\t\tfooterLine = `${truncatedLeft} ${right}`;\n\t\t}\n\n\t\t// Ensure we don't exceed width due to rounding errors\n\t\tfooterLine = truncateToWidth(footerLine, width, ellipsis);\n\t\tconst lines = [footerLine];\n\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\n\t\t\tconst statusLine = sortedStatuses.join(\" \");\n\t\t\tlines.push(truncateToWidth(statusLine, width, ellipsis));\n\t\t}\n\n\t\treturn lines;\n\t}\n}\n"]}