@kolisachint/hoocode-agent 0.5.6 → 0.5.7
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.
- package/CHANGELOG.md +22 -0
- package/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/dist/modes/interactive/components/footer.js +9 -2
- package/dist/modes/interactive/components/footer.js.map +1 -1
- package/dist/modes/interactive/theme/theme-schema.json +16 -8
- package/dist/modes/interactive/theme/theme.d.ts +6 -2
- package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
- package/dist/modes/interactive/theme/theme.js +15 -0
- package/dist/modes/interactive/theme/theme.js.map +1 -1
- package/dist/modes/interactive/theme/vox-dark.json +97 -0
- package/dist/modes/interactive/theme/vox-light.json +101 -0
- package/docs/themes.md +65 -2
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.7] - 2026-08-12
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`vox-dark` and `vox-light` themes** — explanatory-journalism styling: one
|
|
8
|
+
loud yellow used sparingly against a newsprint-quiet palette. The yellow is
|
|
9
|
+
reserved for four roles (`accent`, `mdHeading`, `selectedBg`, `borderAccent`)
|
|
10
|
+
and never carries a status, so `warning` is orange rather than the usual
|
|
11
|
+
yellow — a yellow `◐` or context gauge stops reading as a signal when yellow
|
|
12
|
+
is already the accent. Both are AA, not AAA, and are not covered by
|
|
13
|
+
`test/theme-contrast.test.ts`, but they are held to the same standards the
|
|
14
|
+
default `light` theme gained in 0.5.6: every text token clears 4.5:1 on every
|
|
15
|
+
surface it paints, rules and inactive chrome clear 2.8:1, and no two tokens
|
|
16
|
+
that mean different things sit closer than ΔE 11. Rules stay neutral rather
|
|
17
|
+
than saturated — a separator carries no meaning through hue — which is the one
|
|
18
|
+
deliberate departure, documented in `docs/themes.md`.
|
|
19
|
+
- Optional `brandBg` / `brandText` theme tokens, honored only as a pair, paint
|
|
20
|
+
the footer's brand mark as a filled chip instead of accent-colored text.
|
|
21
|
+
Themes that omit them are unchanged. Added for light themes, where a brand
|
|
22
|
+
hue vivid enough to be recognizable is rarely legible as text on a light
|
|
23
|
+
canvas. `vox-light` is the only built-in that sets them.
|
|
24
|
+
|
|
3
25
|
## [0.5.6] - 2026-08-12
|
|
4
26
|
|
|
5
27
|
### Changed
|
|
@@ -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,0BAA0B,CAAC;AACzF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAEnE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,uCAAuC,CAAC;AA2FxF;;;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,UAAU,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAEtC;IAED,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,CA+I9B;CACD","sourcesContent":["import { type Component, truncateToWidth, visibleWidth } from \"@kolisachint/hoocode-tui\";\nimport type { AgentSession } from \"../../../core/agent-session.js\";\nimport { sumAssistantUsage } from \"../../../core/agent-session-stats.js\";\nimport type { ReadonlyFooterDataProvider } from \"../../../core/footer-data-provider.js\";\nimport { formatTokens } from \"../../../core/format-tokens.js\";\nimport { type StartupProgress, startupProgress } from \"../../../core/startup-progress.js\";\nimport { taskStore } from \"../../../core/task-store.js\";\nimport { BRAND_MARK, GIT_BRANCH_GLYPH } from \"../brand.js\";\nimport { theme } from \"../theme/theme.js\";\n\n/**\n * Assemble one footer line: `left` flush left, `right` flush right when it fits\n * (≥2 cols between), padded to the full width. When it doesn't fit, drop `right`\n * and pad; when even `left` overflows, truncate it. Width math runs on the plain\n * strings; the styled strings carry the colour. Every returned line is exactly\n * `width` cells or fewer — the invariant the footer-width tests hold us to.\n */\nfunction assembleLine(width: number, leftPlain: string, leftStyled: string, rightPlain = \"\", rightStyled = \"\"): string {\n\tconst lw = visibleWidth(leftPlain);\n\tif (rightPlain && lw + 2 + visibleWidth(rightPlain) <= width) {\n\t\treturn leftStyled + \" \".repeat(width - lw - visibleWidth(rightPlain)) + rightStyled;\n\t}\n\tif (lw <= width) return leftStyled + \" \".repeat(width - lw);\n\treturn truncateToWidth(leftStyled, width, theme.fg(\"dim\", \"…\"));\n}\n\n/** A compact context-fill gauge, coloured by proximity to the auto-compact trip point. */\nfunction contextGauge(percent: number, errorLevel: number, warnLevel: number): { plain: string; styled: string } {\n\tconst CELLS = 8;\n\tconst filled = Math.max(0, Math.min(CELLS, Math.round((percent / 100) * CELLS)));\n\tconst fill = \"▰\".repeat(filled);\n\tconst track = \"▱\".repeat(CELLS - filled);\n\tconst color = percent >= errorLevel ? \"error\" : percent >= warnLevel ? \"warning\" : \"accent\";\n\treturn { plain: fill + track, styled: theme.fg(color, fill) + theme.fg(\"dim\", track) };\n}\n\n/** Count subagent runs currently in flight, for the footer's live delegation cue. */\nfunction activeSubagentCount(): number {\n\treturn taskStore.list().filter((t) => t.source === \"subagent\" && t.status === \"in_progress\").length;\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\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/** Cells in a startup-progress bar; compact so several tools fit the footer. */\nconst STARTUP_BAR_CELLS = 12;\n\nfunction formatMb(bytes: number): string {\n\treturn `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\n/**\n * One footer line for a transient startup-progress entry (tool download or\n * index build), styled like the voice download bar: a `·` fill over a dim\n * track with percent and a `received / total` (or `done/total`) detail. An\n * indeterminate download (no Content-Length) drops the bar for a running byte\n * count; an error entry renders as a dim message. Returns a styled string; the\n * caller width-clamps it.\n */\nfunction renderStartupLine(entry: StartupProgress): string {\n\tif (entry.kind === \"error\") {\n\t\treturn theme.fg(\"dim\", `${entry.label}: ${entry.message}`);\n\t}\n\tconst label = theme.fg(\"text\", entry.label);\n\tif (entry.kind === \"download\") {\n\t\tif (entry.totalBytes === null || entry.totalBytes <= 0) {\n\t\t\treturn `${label} ${theme.fg(\"dim\", `${formatMb(entry.receivedBytes)}…`)}`;\n\t\t}\n\t\tconst detail = `${formatMb(entry.receivedBytes)} / ${formatMb(entry.totalBytes)}`;\n\t\treturn `${label} ${determinateBar(entry.receivedBytes / entry.totalBytes, detail)}`;\n\t}\n\tconst detail = `${entry.done}/${entry.total} ${entry.unit}`;\n\tconst ratio = entry.total > 0 ? entry.done / entry.total : 0;\n\treturn `${label} ${determinateBar(ratio, detail)}`;\n}\n\n/** `·`-fill bar + percent + trailing detail, matching the voice download bar. */\nfunction determinateBar(ratio: number, detail: string): string {\n\tconst clamped = Math.max(0, Math.min(1, ratio));\n\tconst filled = Math.round(clamped * STARTUP_BAR_CELLS);\n\tconst bar = theme.fg(\"accent\", \"·\".repeat(filled)) + theme.fg(\"dim\", \"·\".repeat(STARTUP_BAR_CELLS - filled));\n\tconst pct = `${Math.round(clamped * 100)}%`;\n\treturn `${bar} ${theme.fg(\"muted\", pct)} ${theme.fg(\"dim\", `· ${detail}`)}`;\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\tsetSession(session: AgentSession): void {\n\t\tthis.session = session;\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// Cumulative usage across ALL session entries (not just post-compaction\n\t\t// messages). Shares sumAssistantUsage with the per-request cost line the\n\t\t// transcript prints at agent_end, so the footer total and that line are always\n\t\t// derived from the same accounting.\n\t\tconst {\n\t\t\tinput: totalInput,\n\t\t\toutput: totalOutput,\n\t\t\tcacheRead: totalCacheRead,\n\t\t\tcacheWrite: totalCacheWrite,\n\t\t\tcost: totalCost,\n\t\t} = sumAssistantUsage(this.session.sessionManager.getEntries());\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 = this.session.sessionManager.getCwd();\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\t\tconst branch = this.footerData.getGitBranch();\n\t\tconst sessionName = this.session.sessionManager.getSessionName();\n\t\tconst modeLabel = this.footerData.getActiveMode();\n\n\t\t// ── Line 1 — identity & location ────────────────────────────────────────\n\t\t// Lead with the brand mark + MODE (the agent's guardrail: Ask/Plan/Build/\n\t\t// Debug) in bold accent so it is the first thing the eye lands on, then the\n\t\t// path, git branch, and session name in descending emphasis. The live\n\t\t// subagent count sits flush right — present only while work is delegated.\n\t\tconst modeUp = modeLabel.toUpperCase();\n\t\tconst brand = `${BRAND_MARK} ${modeUp}`;\n\t\tlet l1Plain = `${brand} ${pwd}`;\n\t\tlet l1Styled = `${theme.bold(theme.fg(\"accent\", brand))} ${theme.fg(\"muted\", pwd)}`;\n\t\tif (branch) {\n\t\t\tl1Plain += ` ${GIT_BRANCH_GLYPH} ${branch}`;\n\t\t\tl1Styled += ` ${theme.fg(\"dim\", GIT_BRANCH_GLYPH)} ${theme.fg(\"muted\", branch)}`;\n\t\t}\n\t\tif (sessionName) {\n\t\t\tl1Plain += ` • ${sessionName}`;\n\t\t\tl1Styled += theme.fg(\"dim\", ` • ${sessionName}`);\n\t\t}\n\t\tconst nSub = activeSubagentCount();\n\t\tconst l1RightPlain = nSub > 0 ? `◇${nSub} running` : \"\";\n\t\tconst l1RightStyled = nSub > 0 ? theme.fg(\"accent\", `◇${nSub}`) + theme.fg(\"dim\", \" running\") : \"\";\n\t\tconst line1 = assembleLine(width, l1Plain, l1Styled, l1RightPlain, l1RightStyled);\n\n\t\t// ── Line 2 — session vitals ─────────────────────────────────────────────\n\t\t// A context-fill gauge (coloured by proximity to the auto-compact trip\n\t\t// point) leads, then token/cost deltas, with the model + thinking level\n\t\t// flush right. Numbers read in muted, labels/arrows in dim — a legible\n\t\t// hierarchy in place of the old uniform grey.\n\t\tlet thresholdPercent: number | undefined;\n\t\tif (this.autoCompactEnabled && contextWindow > 0) {\n\t\t\tconst reserveTokens = this.session.settingsManager.getCompactionSettings().reserveTokens;\n\t\t\tconst effective = contextWindow - reserveTokens;\n\t\t\tif (effective > 0) thresholdPercent = (effective / contextWindow) * 100;\n\t\t}\n\t\tconst errorLevel = thresholdPercent !== undefined ? thresholdPercent - 3 : 90;\n\t\tconst warnLevel = thresholdPercent !== undefined ? thresholdPercent - 10 : 70;\n\t\tconst autoIndicator =\n\t\t\tthresholdPercent !== undefined\n\t\t\t\t? ` auto@${thresholdPercent.toFixed(0)}%`\n\t\t\t\t: this.autoCompactEnabled\n\t\t\t\t\t? \" auto\"\n\t\t\t\t\t: \"\";\n\n\t\tconst gauge = contextGauge(contextPercentValue, errorLevel, warnLevel);\n\t\tconst pctText = contextPercent === \"?\" ? \"?\" : `${contextPercent}%`;\n\t\tconst pctColor =\n\t\t\tcontextPercentValue >= errorLevel ? \"error\" : contextPercentValue >= warnLevel ? \"warning\" : \"muted\";\n\t\tconst winText = `${formatTokens(contextWindow)}${autoIndicator}`;\n\n\t\tconst segs: Array<{ plain: string; styled: string }> = [\n\t\t\t{\n\t\t\t\tplain: `${gauge.plain} ${pctText} ${winText}`,\n\t\t\t\tstyled: `${gauge.styled} ${theme.fg(pctColor, pctText)} ${theme.fg(\"dim\", winText)}`,\n\t\t\t},\n\t\t];\n\t\tconst arrow = (a: string, n: number) => ({\n\t\t\tplain: `${a}${formatTokens(n)}`,\n\t\t\tstyled: theme.fg(\"dim\", a) + theme.fg(\"muted\", formatTokens(n)),\n\t\t});\n\t\tif (totalInput) segs.push(arrow(\"↑\", totalInput));\n\t\tif (totalOutput) segs.push(arrow(\"↓\", totalOutput));\n\t\tif (totalCacheRead) segs.push(arrow(\"R\", totalCacheRead));\n\t\tif (totalCacheWrite) segs.push(arrow(\"W\", totalCacheWrite));\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\tsegs.push({ plain: costStr, styled: theme.fg(\"muted\", costStr) });\n\t\t}\n\t\tconst l2Plain = segs.map((s) => s.plain).join(\" \");\n\t\tconst l2Styled = segs.map((s) => s.styled).join(\" \");\n\n\t\t// Right: model, thinking level, and provider (when several are configured).\n\t\tconst modelName = state.model?.id || \"no-model\";\n\t\tlet r2Plain = modelName;\n\t\tlet r2Styled = theme.fg(\"muted\", modelName);\n\t\tif (state.model?.reasoning) {\n\t\t\tconst tl = state.thinkingLevel || \"off\";\n\t\t\tconst tstr = tl === \"off\" ? \"thinking off\" : tl;\n\t\t\tr2Plain += ` • ${tstr}`;\n\t\t\tr2Styled += theme.fg(\"dim\", ` • ${tstr}`);\n\t\t}\n\t\tif (this.footerData.getAvailableProviderCount() > 1 && state.model) {\n\t\t\t// Prepend the provider only when the whole right cluster still fits.\n\t\t\tconst withProv = `(${state.model.provider}) ${r2Plain}`;\n\t\t\tif (visibleWidth(l2Plain) + 2 + visibleWidth(withProv) <= width) {\n\t\t\t\tr2Plain = withProv;\n\t\t\t\tr2Styled = theme.fg(\"dim\", `(${state.model.provider}) `) + r2Styled;\n\t\t\t}\n\t\t}\n\t\tconst line2 = assembleLine(width, l2Plain, l2Styled, r2Plain, r2Styled);\n\n\t\tconst lines = [line1, line2];\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\t// Transient startup progress (first-run tool downloads, index build): one\n\t\t// determinate bar per entry, cleared as each settles. Width-clamped like the\n\t\t// status line so the footer never overflows.\n\t\tfor (const entry of startupProgress.list()) {\n\t\t\tlines.push(truncateToWidth(renderStartupLine(entry), 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,0BAA0B,CAAC;AACzF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAEnE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,uCAAuC,CAAC;AA2FxF;;;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,UAAU,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAEtC;IAED,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,CAsJ9B;CACD","sourcesContent":["import { type Component, truncateToWidth, visibleWidth } from \"@kolisachint/hoocode-tui\";\nimport type { AgentSession } from \"../../../core/agent-session.js\";\nimport { sumAssistantUsage } from \"../../../core/agent-session-stats.js\";\nimport type { ReadonlyFooterDataProvider } from \"../../../core/footer-data-provider.js\";\nimport { formatTokens } from \"../../../core/format-tokens.js\";\nimport { type StartupProgress, startupProgress } from \"../../../core/startup-progress.js\";\nimport { taskStore } from \"../../../core/task-store.js\";\nimport { BRAND_MARK, GIT_BRANCH_GLYPH } from \"../brand.js\";\nimport { theme } from \"../theme/theme.js\";\n\n/**\n * Assemble one footer line: `left` flush left, `right` flush right when it fits\n * (≥2 cols between), padded to the full width. When it doesn't fit, drop `right`\n * and pad; when even `left` overflows, truncate it. Width math runs on the plain\n * strings; the styled strings carry the colour. Every returned line is exactly\n * `width` cells or fewer — the invariant the footer-width tests hold us to.\n */\nfunction assembleLine(width: number, leftPlain: string, leftStyled: string, rightPlain = \"\", rightStyled = \"\"): string {\n\tconst lw = visibleWidth(leftPlain);\n\tif (rightPlain && lw + 2 + visibleWidth(rightPlain) <= width) {\n\t\treturn leftStyled + \" \".repeat(width - lw - visibleWidth(rightPlain)) + rightStyled;\n\t}\n\tif (lw <= width) return leftStyled + \" \".repeat(width - lw);\n\treturn truncateToWidth(leftStyled, width, theme.fg(\"dim\", \"…\"));\n}\n\n/** A compact context-fill gauge, coloured by proximity to the auto-compact trip point. */\nfunction contextGauge(percent: number, errorLevel: number, warnLevel: number): { plain: string; styled: string } {\n\tconst CELLS = 8;\n\tconst filled = Math.max(0, Math.min(CELLS, Math.round((percent / 100) * CELLS)));\n\tconst fill = \"▰\".repeat(filled);\n\tconst track = \"▱\".repeat(CELLS - filled);\n\tconst color = percent >= errorLevel ? \"error\" : percent >= warnLevel ? \"warning\" : \"accent\";\n\treturn { plain: fill + track, styled: theme.fg(color, fill) + theme.fg(\"dim\", track) };\n}\n\n/** Count subagent runs currently in flight, for the footer's live delegation cue. */\nfunction activeSubagentCount(): number {\n\treturn taskStore.list().filter((t) => t.source === \"subagent\" && t.status === \"in_progress\").length;\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\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/** Cells in a startup-progress bar; compact so several tools fit the footer. */\nconst STARTUP_BAR_CELLS = 12;\n\nfunction formatMb(bytes: number): string {\n\treturn `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\n/**\n * One footer line for a transient startup-progress entry (tool download or\n * index build), styled like the voice download bar: a `·` fill over a dim\n * track with percent and a `received / total` (or `done/total`) detail. An\n * indeterminate download (no Content-Length) drops the bar for a running byte\n * count; an error entry renders as a dim message. Returns a styled string; the\n * caller width-clamps it.\n */\nfunction renderStartupLine(entry: StartupProgress): string {\n\tif (entry.kind === \"error\") {\n\t\treturn theme.fg(\"dim\", `${entry.label}: ${entry.message}`);\n\t}\n\tconst label = theme.fg(\"text\", entry.label);\n\tif (entry.kind === \"download\") {\n\t\tif (entry.totalBytes === null || entry.totalBytes <= 0) {\n\t\t\treturn `${label} ${theme.fg(\"dim\", `${formatMb(entry.receivedBytes)}…`)}`;\n\t\t}\n\t\tconst detail = `${formatMb(entry.receivedBytes)} / ${formatMb(entry.totalBytes)}`;\n\t\treturn `${label} ${determinateBar(entry.receivedBytes / entry.totalBytes, detail)}`;\n\t}\n\tconst detail = `${entry.done}/${entry.total} ${entry.unit}`;\n\tconst ratio = entry.total > 0 ? entry.done / entry.total : 0;\n\treturn `${label} ${determinateBar(ratio, detail)}`;\n}\n\n/** `·`-fill bar + percent + trailing detail, matching the voice download bar. */\nfunction determinateBar(ratio: number, detail: string): string {\n\tconst clamped = Math.max(0, Math.min(1, ratio));\n\tconst filled = Math.round(clamped * STARTUP_BAR_CELLS);\n\tconst bar = theme.fg(\"accent\", \"·\".repeat(filled)) + theme.fg(\"dim\", \"·\".repeat(STARTUP_BAR_CELLS - filled));\n\tconst pct = `${Math.round(clamped * 100)}%`;\n\treturn `${bar} ${theme.fg(\"muted\", pct)} ${theme.fg(\"dim\", `· ${detail}`)}`;\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\tsetSession(session: AgentSession): void {\n\t\tthis.session = session;\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// Cumulative usage across ALL session entries (not just post-compaction\n\t\t// messages). Shares sumAssistantUsage with the per-request cost line the\n\t\t// transcript prints at agent_end, so the footer total and that line are always\n\t\t// derived from the same accounting.\n\t\tconst {\n\t\t\tinput: totalInput,\n\t\t\toutput: totalOutput,\n\t\t\tcacheRead: totalCacheRead,\n\t\t\tcacheWrite: totalCacheWrite,\n\t\t\tcost: totalCost,\n\t\t} = sumAssistantUsage(this.session.sessionManager.getEntries());\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 = this.session.sessionManager.getCwd();\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\t\tconst branch = this.footerData.getGitBranch();\n\t\tconst sessionName = this.session.sessionManager.getSessionName();\n\t\tconst modeLabel = this.footerData.getActiveMode();\n\n\t\t// ── Line 1 — identity & location ────────────────────────────────────────\n\t\t// Lead with the brand mark + MODE (the agent's guardrail: Ask/Plan/Build/\n\t\t// Debug) in bold accent so it is the first thing the eye lands on, then the\n\t\t// path, git branch, and session name in descending emphasis. The live\n\t\t// subagent count sits flush right — present only while work is delegated.\n\t\tconst modeUp = modeLabel.toUpperCase();\n\t\t// Themes that define the brandBg/brandText pair paint the mark as a filled\n\t\t// chip; the padding lives in the plain string too so width math and\n\t\t// truncation stay honest. Themes without the pair keep accent-coloured text.\n\t\tconst chip = theme.hasBg(\"brandBg\") && theme.has(\"brandText\");\n\t\tconst brand = chip ? ` ${BRAND_MARK} ${modeUp} ` : `${BRAND_MARK} ${modeUp}`;\n\t\tlet l1Plain = `${brand} ${pwd}`;\n\t\tconst brandStyled = chip\n\t\t\t? theme.bg(\"brandBg\", theme.bold(theme.fg(\"brandText\", brand)))\n\t\t\t: theme.bold(theme.fg(\"accent\", brand));\n\t\tlet l1Styled = `${brandStyled} ${theme.fg(\"muted\", pwd)}`;\n\t\tif (branch) {\n\t\t\tl1Plain += ` ${GIT_BRANCH_GLYPH} ${branch}`;\n\t\t\tl1Styled += ` ${theme.fg(\"dim\", GIT_BRANCH_GLYPH)} ${theme.fg(\"muted\", branch)}`;\n\t\t}\n\t\tif (sessionName) {\n\t\t\tl1Plain += ` • ${sessionName}`;\n\t\t\tl1Styled += theme.fg(\"dim\", ` • ${sessionName}`);\n\t\t}\n\t\tconst nSub = activeSubagentCount();\n\t\tconst l1RightPlain = nSub > 0 ? `◇${nSub} running` : \"\";\n\t\tconst l1RightStyled = nSub > 0 ? theme.fg(\"accent\", `◇${nSub}`) + theme.fg(\"dim\", \" running\") : \"\";\n\t\tconst line1 = assembleLine(width, l1Plain, l1Styled, l1RightPlain, l1RightStyled);\n\n\t\t// ── Line 2 — session vitals ─────────────────────────────────────────────\n\t\t// A context-fill gauge (coloured by proximity to the auto-compact trip\n\t\t// point) leads, then token/cost deltas, with the model + thinking level\n\t\t// flush right. Numbers read in muted, labels/arrows in dim — a legible\n\t\t// hierarchy in place of the old uniform grey.\n\t\tlet thresholdPercent: number | undefined;\n\t\tif (this.autoCompactEnabled && contextWindow > 0) {\n\t\t\tconst reserveTokens = this.session.settingsManager.getCompactionSettings().reserveTokens;\n\t\t\tconst effective = contextWindow - reserveTokens;\n\t\t\tif (effective > 0) thresholdPercent = (effective / contextWindow) * 100;\n\t\t}\n\t\tconst errorLevel = thresholdPercent !== undefined ? thresholdPercent - 3 : 90;\n\t\tconst warnLevel = thresholdPercent !== undefined ? thresholdPercent - 10 : 70;\n\t\tconst autoIndicator =\n\t\t\tthresholdPercent !== undefined\n\t\t\t\t? ` auto@${thresholdPercent.toFixed(0)}%`\n\t\t\t\t: this.autoCompactEnabled\n\t\t\t\t\t? \" auto\"\n\t\t\t\t\t: \"\";\n\n\t\tconst gauge = contextGauge(contextPercentValue, errorLevel, warnLevel);\n\t\tconst pctText = contextPercent === \"?\" ? \"?\" : `${contextPercent}%`;\n\t\tconst pctColor =\n\t\t\tcontextPercentValue >= errorLevel ? \"error\" : contextPercentValue >= warnLevel ? \"warning\" : \"muted\";\n\t\tconst winText = `${formatTokens(contextWindow)}${autoIndicator}`;\n\n\t\tconst segs: Array<{ plain: string; styled: string }> = [\n\t\t\t{\n\t\t\t\tplain: `${gauge.plain} ${pctText} ${winText}`,\n\t\t\t\tstyled: `${gauge.styled} ${theme.fg(pctColor, pctText)} ${theme.fg(\"dim\", winText)}`,\n\t\t\t},\n\t\t];\n\t\tconst arrow = (a: string, n: number) => ({\n\t\t\tplain: `${a}${formatTokens(n)}`,\n\t\t\tstyled: theme.fg(\"dim\", a) + theme.fg(\"muted\", formatTokens(n)),\n\t\t});\n\t\tif (totalInput) segs.push(arrow(\"↑\", totalInput));\n\t\tif (totalOutput) segs.push(arrow(\"↓\", totalOutput));\n\t\tif (totalCacheRead) segs.push(arrow(\"R\", totalCacheRead));\n\t\tif (totalCacheWrite) segs.push(arrow(\"W\", totalCacheWrite));\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\tsegs.push({ plain: costStr, styled: theme.fg(\"muted\", costStr) });\n\t\t}\n\t\tconst l2Plain = segs.map((s) => s.plain).join(\" \");\n\t\tconst l2Styled = segs.map((s) => s.styled).join(\" \");\n\n\t\t// Right: model, thinking level, and provider (when several are configured).\n\t\tconst modelName = state.model?.id || \"no-model\";\n\t\tlet r2Plain = modelName;\n\t\tlet r2Styled = theme.fg(\"muted\", modelName);\n\t\tif (state.model?.reasoning) {\n\t\t\tconst tl = state.thinkingLevel || \"off\";\n\t\t\tconst tstr = tl === \"off\" ? \"thinking off\" : tl;\n\t\t\tr2Plain += ` • ${tstr}`;\n\t\t\tr2Styled += theme.fg(\"dim\", ` • ${tstr}`);\n\t\t}\n\t\tif (this.footerData.getAvailableProviderCount() > 1 && state.model) {\n\t\t\t// Prepend the provider only when the whole right cluster still fits.\n\t\t\tconst withProv = `(${state.model.provider}) ${r2Plain}`;\n\t\t\tif (visibleWidth(l2Plain) + 2 + visibleWidth(withProv) <= width) {\n\t\t\t\tr2Plain = withProv;\n\t\t\t\tr2Styled = theme.fg(\"dim\", `(${state.model.provider}) `) + r2Styled;\n\t\t\t}\n\t\t}\n\t\tconst line2 = assembleLine(width, l2Plain, l2Styled, r2Plain, r2Styled);\n\n\t\tconst lines = [line1, line2];\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\t// Transient startup progress (first-run tool downloads, index build): one\n\t\t// determinate bar per entry, cleared as each settles. Width-clamped like the\n\t\t// status line so the footer never overflows.\n\t\tfor (const entry of startupProgress.list()) {\n\t\t\tlines.push(truncateToWidth(renderStartupLine(entry), width, theme.fg(\"dim\", \"…\")));\n\t\t}\n\n\t\treturn lines;\n\t}\n}\n"]}
|
|
@@ -142,9 +142,16 @@ export class FooterComponent {
|
|
|
142
142
|
// path, git branch, and session name in descending emphasis. The live
|
|
143
143
|
// subagent count sits flush right — present only while work is delegated.
|
|
144
144
|
const modeUp = modeLabel.toUpperCase();
|
|
145
|
-
|
|
145
|
+
// Themes that define the brandBg/brandText pair paint the mark as a filled
|
|
146
|
+
// chip; the padding lives in the plain string too so width math and
|
|
147
|
+
// truncation stay honest. Themes without the pair keep accent-coloured text.
|
|
148
|
+
const chip = theme.hasBg("brandBg") && theme.has("brandText");
|
|
149
|
+
const brand = chip ? ` ${BRAND_MARK} ${modeUp} ` : `${BRAND_MARK} ${modeUp}`;
|
|
146
150
|
let l1Plain = `${brand} ${pwd}`;
|
|
147
|
-
|
|
151
|
+
const brandStyled = chip
|
|
152
|
+
? theme.bg("brandBg", theme.bold(theme.fg("brandText", brand)))
|
|
153
|
+
: theme.bold(theme.fg("accent", brand));
|
|
154
|
+
let l1Styled = `${brandStyled} ${theme.fg("muted", pwd)}`;
|
|
148
155
|
if (branch) {
|
|
149
156
|
l1Plain += ` ${GIT_BRANCH_GLYPH} ${branch}`;
|
|
150
157
|
l1Styled += ` ${theme.fg("dim", GIT_BRANCH_GLYPH)} ${theme.fg("muted", branch)}`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"footer.js","sourceRoot":"","sources":["../../../../src/modes/interactive/components/footer.ts"],"names":[],"mappings":"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","sourcesContent":["import { type Component, truncateToWidth, visibleWidth } from \"@kolisachint/hoocode-tui\";\nimport type { AgentSession } from \"../../../core/agent-session.js\";\nimport { sumAssistantUsage } from \"../../../core/agent-session-stats.js\";\nimport type { ReadonlyFooterDataProvider } from \"../../../core/footer-data-provider.js\";\nimport { formatTokens } from \"../../../core/format-tokens.js\";\nimport { type StartupProgress, startupProgress } from \"../../../core/startup-progress.js\";\nimport { taskStore } from \"../../../core/task-store.js\";\nimport { BRAND_MARK, GIT_BRANCH_GLYPH } from \"../brand.js\";\nimport { theme } from \"../theme/theme.js\";\n\n/**\n * Assemble one footer line: `left` flush left, `right` flush right when it fits\n * (≥2 cols between), padded to the full width. When it doesn't fit, drop `right`\n * and pad; when even `left` overflows, truncate it. Width math runs on the plain\n * strings; the styled strings carry the colour. Every returned line is exactly\n * `width` cells or fewer — the invariant the footer-width tests hold us to.\n */\nfunction assembleLine(width: number, leftPlain: string, leftStyled: string, rightPlain = \"\", rightStyled = \"\"): string {\n\tconst lw = visibleWidth(leftPlain);\n\tif (rightPlain && lw + 2 + visibleWidth(rightPlain) <= width) {\n\t\treturn leftStyled + \" \".repeat(width - lw - visibleWidth(rightPlain)) + rightStyled;\n\t}\n\tif (lw <= width) return leftStyled + \" \".repeat(width - lw);\n\treturn truncateToWidth(leftStyled, width, theme.fg(\"dim\", \"…\"));\n}\n\n/** A compact context-fill gauge, coloured by proximity to the auto-compact trip point. */\nfunction contextGauge(percent: number, errorLevel: number, warnLevel: number): { plain: string; styled: string } {\n\tconst CELLS = 8;\n\tconst filled = Math.max(0, Math.min(CELLS, Math.round((percent / 100) * CELLS)));\n\tconst fill = \"▰\".repeat(filled);\n\tconst track = \"▱\".repeat(CELLS - filled);\n\tconst color = percent >= errorLevel ? \"error\" : percent >= warnLevel ? \"warning\" : \"accent\";\n\treturn { plain: fill + track, styled: theme.fg(color, fill) + theme.fg(\"dim\", track) };\n}\n\n/** Count subagent runs currently in flight, for the footer's live delegation cue. */\nfunction activeSubagentCount(): number {\n\treturn taskStore.list().filter((t) => t.source === \"subagent\" && t.status === \"in_progress\").length;\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\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/** Cells in a startup-progress bar; compact so several tools fit the footer. */\nconst STARTUP_BAR_CELLS = 12;\n\nfunction formatMb(bytes: number): string {\n\treturn `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\n/**\n * One footer line for a transient startup-progress entry (tool download or\n * index build), styled like the voice download bar: a `·` fill over a dim\n * track with percent and a `received / total` (or `done/total`) detail. An\n * indeterminate download (no Content-Length) drops the bar for a running byte\n * count; an error entry renders as a dim message. Returns a styled string; the\n * caller width-clamps it.\n */\nfunction renderStartupLine(entry: StartupProgress): string {\n\tif (entry.kind === \"error\") {\n\t\treturn theme.fg(\"dim\", `${entry.label}: ${entry.message}`);\n\t}\n\tconst label = theme.fg(\"text\", entry.label);\n\tif (entry.kind === \"download\") {\n\t\tif (entry.totalBytes === null || entry.totalBytes <= 0) {\n\t\t\treturn `${label} ${theme.fg(\"dim\", `${formatMb(entry.receivedBytes)}…`)}`;\n\t\t}\n\t\tconst detail = `${formatMb(entry.receivedBytes)} / ${formatMb(entry.totalBytes)}`;\n\t\treturn `${label} ${determinateBar(entry.receivedBytes / entry.totalBytes, detail)}`;\n\t}\n\tconst detail = `${entry.done}/${entry.total} ${entry.unit}`;\n\tconst ratio = entry.total > 0 ? entry.done / entry.total : 0;\n\treturn `${label} ${determinateBar(ratio, detail)}`;\n}\n\n/** `·`-fill bar + percent + trailing detail, matching the voice download bar. */\nfunction determinateBar(ratio: number, detail: string): string {\n\tconst clamped = Math.max(0, Math.min(1, ratio));\n\tconst filled = Math.round(clamped * STARTUP_BAR_CELLS);\n\tconst bar = theme.fg(\"accent\", \"·\".repeat(filled)) + theme.fg(\"dim\", \"·\".repeat(STARTUP_BAR_CELLS - filled));\n\tconst pct = `${Math.round(clamped * 100)}%`;\n\treturn `${bar} ${theme.fg(\"muted\", pct)} ${theme.fg(\"dim\", `· ${detail}`)}`;\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\tsetSession(session: AgentSession): void {\n\t\tthis.session = session;\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// Cumulative usage across ALL session entries (not just post-compaction\n\t\t// messages). Shares sumAssistantUsage with the per-request cost line the\n\t\t// transcript prints at agent_end, so the footer total and that line are always\n\t\t// derived from the same accounting.\n\t\tconst {\n\t\t\tinput: totalInput,\n\t\t\toutput: totalOutput,\n\t\t\tcacheRead: totalCacheRead,\n\t\t\tcacheWrite: totalCacheWrite,\n\t\t\tcost: totalCost,\n\t\t} = sumAssistantUsage(this.session.sessionManager.getEntries());\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 = this.session.sessionManager.getCwd();\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\t\tconst branch = this.footerData.getGitBranch();\n\t\tconst sessionName = this.session.sessionManager.getSessionName();\n\t\tconst modeLabel = this.footerData.getActiveMode();\n\n\t\t// ── Line 1 — identity & location ────────────────────────────────────────\n\t\t// Lead with the brand mark + MODE (the agent's guardrail: Ask/Plan/Build/\n\t\t// Debug) in bold accent so it is the first thing the eye lands on, then the\n\t\t// path, git branch, and session name in descending emphasis. The live\n\t\t// subagent count sits flush right — present only while work is delegated.\n\t\tconst modeUp = modeLabel.toUpperCase();\n\t\tconst brand = `${BRAND_MARK} ${modeUp}`;\n\t\tlet l1Plain = `${brand} ${pwd}`;\n\t\tlet l1Styled = `${theme.bold(theme.fg(\"accent\", brand))} ${theme.fg(\"muted\", pwd)}`;\n\t\tif (branch) {\n\t\t\tl1Plain += ` ${GIT_BRANCH_GLYPH} ${branch}`;\n\t\t\tl1Styled += ` ${theme.fg(\"dim\", GIT_BRANCH_GLYPH)} ${theme.fg(\"muted\", branch)}`;\n\t\t}\n\t\tif (sessionName) {\n\t\t\tl1Plain += ` • ${sessionName}`;\n\t\t\tl1Styled += theme.fg(\"dim\", ` • ${sessionName}`);\n\t\t}\n\t\tconst nSub = activeSubagentCount();\n\t\tconst l1RightPlain = nSub > 0 ? `◇${nSub} running` : \"\";\n\t\tconst l1RightStyled = nSub > 0 ? theme.fg(\"accent\", `◇${nSub}`) + theme.fg(\"dim\", \" running\") : \"\";\n\t\tconst line1 = assembleLine(width, l1Plain, l1Styled, l1RightPlain, l1RightStyled);\n\n\t\t// ── Line 2 — session vitals ─────────────────────────────────────────────\n\t\t// A context-fill gauge (coloured by proximity to the auto-compact trip\n\t\t// point) leads, then token/cost deltas, with the model + thinking level\n\t\t// flush right. Numbers read in muted, labels/arrows in dim — a legible\n\t\t// hierarchy in place of the old uniform grey.\n\t\tlet thresholdPercent: number | undefined;\n\t\tif (this.autoCompactEnabled && contextWindow > 0) {\n\t\t\tconst reserveTokens = this.session.settingsManager.getCompactionSettings().reserveTokens;\n\t\t\tconst effective = contextWindow - reserveTokens;\n\t\t\tif (effective > 0) thresholdPercent = (effective / contextWindow) * 100;\n\t\t}\n\t\tconst errorLevel = thresholdPercent !== undefined ? thresholdPercent - 3 : 90;\n\t\tconst warnLevel = thresholdPercent !== undefined ? thresholdPercent - 10 : 70;\n\t\tconst autoIndicator =\n\t\t\tthresholdPercent !== undefined\n\t\t\t\t? ` auto@${thresholdPercent.toFixed(0)}%`\n\t\t\t\t: this.autoCompactEnabled\n\t\t\t\t\t? \" auto\"\n\t\t\t\t\t: \"\";\n\n\t\tconst gauge = contextGauge(contextPercentValue, errorLevel, warnLevel);\n\t\tconst pctText = contextPercent === \"?\" ? \"?\" : `${contextPercent}%`;\n\t\tconst pctColor =\n\t\t\tcontextPercentValue >= errorLevel ? \"error\" : contextPercentValue >= warnLevel ? \"warning\" : \"muted\";\n\t\tconst winText = `${formatTokens(contextWindow)}${autoIndicator}`;\n\n\t\tconst segs: Array<{ plain: string; styled: string }> = [\n\t\t\t{\n\t\t\t\tplain: `${gauge.plain} ${pctText} ${winText}`,\n\t\t\t\tstyled: `${gauge.styled} ${theme.fg(pctColor, pctText)} ${theme.fg(\"dim\", winText)}`,\n\t\t\t},\n\t\t];\n\t\tconst arrow = (a: string, n: number) => ({\n\t\t\tplain: `${a}${formatTokens(n)}`,\n\t\t\tstyled: theme.fg(\"dim\", a) + theme.fg(\"muted\", formatTokens(n)),\n\t\t});\n\t\tif (totalInput) segs.push(arrow(\"↑\", totalInput));\n\t\tif (totalOutput) segs.push(arrow(\"↓\", totalOutput));\n\t\tif (totalCacheRead) segs.push(arrow(\"R\", totalCacheRead));\n\t\tif (totalCacheWrite) segs.push(arrow(\"W\", totalCacheWrite));\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\tsegs.push({ plain: costStr, styled: theme.fg(\"muted\", costStr) });\n\t\t}\n\t\tconst l2Plain = segs.map((s) => s.plain).join(\" \");\n\t\tconst l2Styled = segs.map((s) => s.styled).join(\" \");\n\n\t\t// Right: model, thinking level, and provider (when several are configured).\n\t\tconst modelName = state.model?.id || \"no-model\";\n\t\tlet r2Plain = modelName;\n\t\tlet r2Styled = theme.fg(\"muted\", modelName);\n\t\tif (state.model?.reasoning) {\n\t\t\tconst tl = state.thinkingLevel || \"off\";\n\t\t\tconst tstr = tl === \"off\" ? \"thinking off\" : tl;\n\t\t\tr2Plain += ` • ${tstr}`;\n\t\t\tr2Styled += theme.fg(\"dim\", ` • ${tstr}`);\n\t\t}\n\t\tif (this.footerData.getAvailableProviderCount() > 1 && state.model) {\n\t\t\t// Prepend the provider only when the whole right cluster still fits.\n\t\t\tconst withProv = `(${state.model.provider}) ${r2Plain}`;\n\t\t\tif (visibleWidth(l2Plain) + 2 + visibleWidth(withProv) <= width) {\n\t\t\t\tr2Plain = withProv;\n\t\t\t\tr2Styled = theme.fg(\"dim\", `(${state.model.provider}) `) + r2Styled;\n\t\t\t}\n\t\t}\n\t\tconst line2 = assembleLine(width, l2Plain, l2Styled, r2Plain, r2Styled);\n\n\t\tconst lines = [line1, line2];\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\t// Transient startup progress (first-run tool downloads, index build): one\n\t\t// determinate bar per entry, cleared as each settles. Width-clamped like the\n\t\t// status line so the footer never overflows.\n\t\tfor (const entry of startupProgress.list()) {\n\t\t\tlines.push(truncateToWidth(renderStartupLine(entry), width, theme.fg(\"dim\", \"…\")));\n\t\t}\n\n\t\treturn lines;\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"footer.js","sourceRoot":"","sources":["../../../../src/modes/interactive/components/footer.ts"],"names":[],"mappings":"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","sourcesContent":["import { type Component, truncateToWidth, visibleWidth } from \"@kolisachint/hoocode-tui\";\nimport type { AgentSession } from \"../../../core/agent-session.js\";\nimport { sumAssistantUsage } from \"../../../core/agent-session-stats.js\";\nimport type { ReadonlyFooterDataProvider } from \"../../../core/footer-data-provider.js\";\nimport { formatTokens } from \"../../../core/format-tokens.js\";\nimport { type StartupProgress, startupProgress } from \"../../../core/startup-progress.js\";\nimport { taskStore } from \"../../../core/task-store.js\";\nimport { BRAND_MARK, GIT_BRANCH_GLYPH } from \"../brand.js\";\nimport { theme } from \"../theme/theme.js\";\n\n/**\n * Assemble one footer line: `left` flush left, `right` flush right when it fits\n * (≥2 cols between), padded to the full width. When it doesn't fit, drop `right`\n * and pad; when even `left` overflows, truncate it. Width math runs on the plain\n * strings; the styled strings carry the colour. Every returned line is exactly\n * `width` cells or fewer — the invariant the footer-width tests hold us to.\n */\nfunction assembleLine(width: number, leftPlain: string, leftStyled: string, rightPlain = \"\", rightStyled = \"\"): string {\n\tconst lw = visibleWidth(leftPlain);\n\tif (rightPlain && lw + 2 + visibleWidth(rightPlain) <= width) {\n\t\treturn leftStyled + \" \".repeat(width - lw - visibleWidth(rightPlain)) + rightStyled;\n\t}\n\tif (lw <= width) return leftStyled + \" \".repeat(width - lw);\n\treturn truncateToWidth(leftStyled, width, theme.fg(\"dim\", \"…\"));\n}\n\n/** A compact context-fill gauge, coloured by proximity to the auto-compact trip point. */\nfunction contextGauge(percent: number, errorLevel: number, warnLevel: number): { plain: string; styled: string } {\n\tconst CELLS = 8;\n\tconst filled = Math.max(0, Math.min(CELLS, Math.round((percent / 100) * CELLS)));\n\tconst fill = \"▰\".repeat(filled);\n\tconst track = \"▱\".repeat(CELLS - filled);\n\tconst color = percent >= errorLevel ? \"error\" : percent >= warnLevel ? \"warning\" : \"accent\";\n\treturn { plain: fill + track, styled: theme.fg(color, fill) + theme.fg(\"dim\", track) };\n}\n\n/** Count subagent runs currently in flight, for the footer's live delegation cue. */\nfunction activeSubagentCount(): number {\n\treturn taskStore.list().filter((t) => t.source === \"subagent\" && t.status === \"in_progress\").length;\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\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/** Cells in a startup-progress bar; compact so several tools fit the footer. */\nconst STARTUP_BAR_CELLS = 12;\n\nfunction formatMb(bytes: number): string {\n\treturn `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\n/**\n * One footer line for a transient startup-progress entry (tool download or\n * index build), styled like the voice download bar: a `·` fill over a dim\n * track with percent and a `received / total` (or `done/total`) detail. An\n * indeterminate download (no Content-Length) drops the bar for a running byte\n * count; an error entry renders as a dim message. Returns a styled string; the\n * caller width-clamps it.\n */\nfunction renderStartupLine(entry: StartupProgress): string {\n\tif (entry.kind === \"error\") {\n\t\treturn theme.fg(\"dim\", `${entry.label}: ${entry.message}`);\n\t}\n\tconst label = theme.fg(\"text\", entry.label);\n\tif (entry.kind === \"download\") {\n\t\tif (entry.totalBytes === null || entry.totalBytes <= 0) {\n\t\t\treturn `${label} ${theme.fg(\"dim\", `${formatMb(entry.receivedBytes)}…`)}`;\n\t\t}\n\t\tconst detail = `${formatMb(entry.receivedBytes)} / ${formatMb(entry.totalBytes)}`;\n\t\treturn `${label} ${determinateBar(entry.receivedBytes / entry.totalBytes, detail)}`;\n\t}\n\tconst detail = `${entry.done}/${entry.total} ${entry.unit}`;\n\tconst ratio = entry.total > 0 ? entry.done / entry.total : 0;\n\treturn `${label} ${determinateBar(ratio, detail)}`;\n}\n\n/** `·`-fill bar + percent + trailing detail, matching the voice download bar. */\nfunction determinateBar(ratio: number, detail: string): string {\n\tconst clamped = Math.max(0, Math.min(1, ratio));\n\tconst filled = Math.round(clamped * STARTUP_BAR_CELLS);\n\tconst bar = theme.fg(\"accent\", \"·\".repeat(filled)) + theme.fg(\"dim\", \"·\".repeat(STARTUP_BAR_CELLS - filled));\n\tconst pct = `${Math.round(clamped * 100)}%`;\n\treturn `${bar} ${theme.fg(\"muted\", pct)} ${theme.fg(\"dim\", `· ${detail}`)}`;\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\tsetSession(session: AgentSession): void {\n\t\tthis.session = session;\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// Cumulative usage across ALL session entries (not just post-compaction\n\t\t// messages). Shares sumAssistantUsage with the per-request cost line the\n\t\t// transcript prints at agent_end, so the footer total and that line are always\n\t\t// derived from the same accounting.\n\t\tconst {\n\t\t\tinput: totalInput,\n\t\t\toutput: totalOutput,\n\t\t\tcacheRead: totalCacheRead,\n\t\t\tcacheWrite: totalCacheWrite,\n\t\t\tcost: totalCost,\n\t\t} = sumAssistantUsage(this.session.sessionManager.getEntries());\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 = this.session.sessionManager.getCwd();\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\t\tconst branch = this.footerData.getGitBranch();\n\t\tconst sessionName = this.session.sessionManager.getSessionName();\n\t\tconst modeLabel = this.footerData.getActiveMode();\n\n\t\t// ── Line 1 — identity & location ────────────────────────────────────────\n\t\t// Lead with the brand mark + MODE (the agent's guardrail: Ask/Plan/Build/\n\t\t// Debug) in bold accent so it is the first thing the eye lands on, then the\n\t\t// path, git branch, and session name in descending emphasis. The live\n\t\t// subagent count sits flush right — present only while work is delegated.\n\t\tconst modeUp = modeLabel.toUpperCase();\n\t\t// Themes that define the brandBg/brandText pair paint the mark as a filled\n\t\t// chip; the padding lives in the plain string too so width math and\n\t\t// truncation stay honest. Themes without the pair keep accent-coloured text.\n\t\tconst chip = theme.hasBg(\"brandBg\") && theme.has(\"brandText\");\n\t\tconst brand = chip ? ` ${BRAND_MARK} ${modeUp} ` : `${BRAND_MARK} ${modeUp}`;\n\t\tlet l1Plain = `${brand} ${pwd}`;\n\t\tconst brandStyled = chip\n\t\t\t? theme.bg(\"brandBg\", theme.bold(theme.fg(\"brandText\", brand)))\n\t\t\t: theme.bold(theme.fg(\"accent\", brand));\n\t\tlet l1Styled = `${brandStyled} ${theme.fg(\"muted\", pwd)}`;\n\t\tif (branch) {\n\t\t\tl1Plain += ` ${GIT_BRANCH_GLYPH} ${branch}`;\n\t\t\tl1Styled += ` ${theme.fg(\"dim\", GIT_BRANCH_GLYPH)} ${theme.fg(\"muted\", branch)}`;\n\t\t}\n\t\tif (sessionName) {\n\t\t\tl1Plain += ` • ${sessionName}`;\n\t\t\tl1Styled += theme.fg(\"dim\", ` • ${sessionName}`);\n\t\t}\n\t\tconst nSub = activeSubagentCount();\n\t\tconst l1RightPlain = nSub > 0 ? `◇${nSub} running` : \"\";\n\t\tconst l1RightStyled = nSub > 0 ? theme.fg(\"accent\", `◇${nSub}`) + theme.fg(\"dim\", \" running\") : \"\";\n\t\tconst line1 = assembleLine(width, l1Plain, l1Styled, l1RightPlain, l1RightStyled);\n\n\t\t// ── Line 2 — session vitals ─────────────────────────────────────────────\n\t\t// A context-fill gauge (coloured by proximity to the auto-compact trip\n\t\t// point) leads, then token/cost deltas, with the model + thinking level\n\t\t// flush right. Numbers read in muted, labels/arrows in dim — a legible\n\t\t// hierarchy in place of the old uniform grey.\n\t\tlet thresholdPercent: number | undefined;\n\t\tif (this.autoCompactEnabled && contextWindow > 0) {\n\t\t\tconst reserveTokens = this.session.settingsManager.getCompactionSettings().reserveTokens;\n\t\t\tconst effective = contextWindow - reserveTokens;\n\t\t\tif (effective > 0) thresholdPercent = (effective / contextWindow) * 100;\n\t\t}\n\t\tconst errorLevel = thresholdPercent !== undefined ? thresholdPercent - 3 : 90;\n\t\tconst warnLevel = thresholdPercent !== undefined ? thresholdPercent - 10 : 70;\n\t\tconst autoIndicator =\n\t\t\tthresholdPercent !== undefined\n\t\t\t\t? ` auto@${thresholdPercent.toFixed(0)}%`\n\t\t\t\t: this.autoCompactEnabled\n\t\t\t\t\t? \" auto\"\n\t\t\t\t\t: \"\";\n\n\t\tconst gauge = contextGauge(contextPercentValue, errorLevel, warnLevel);\n\t\tconst pctText = contextPercent === \"?\" ? \"?\" : `${contextPercent}%`;\n\t\tconst pctColor =\n\t\t\tcontextPercentValue >= errorLevel ? \"error\" : contextPercentValue >= warnLevel ? \"warning\" : \"muted\";\n\t\tconst winText = `${formatTokens(contextWindow)}${autoIndicator}`;\n\n\t\tconst segs: Array<{ plain: string; styled: string }> = [\n\t\t\t{\n\t\t\t\tplain: `${gauge.plain} ${pctText} ${winText}`,\n\t\t\t\tstyled: `${gauge.styled} ${theme.fg(pctColor, pctText)} ${theme.fg(\"dim\", winText)}`,\n\t\t\t},\n\t\t];\n\t\tconst arrow = (a: string, n: number) => ({\n\t\t\tplain: `${a}${formatTokens(n)}`,\n\t\t\tstyled: theme.fg(\"dim\", a) + theme.fg(\"muted\", formatTokens(n)),\n\t\t});\n\t\tif (totalInput) segs.push(arrow(\"↑\", totalInput));\n\t\tif (totalOutput) segs.push(arrow(\"↓\", totalOutput));\n\t\tif (totalCacheRead) segs.push(arrow(\"R\", totalCacheRead));\n\t\tif (totalCacheWrite) segs.push(arrow(\"W\", totalCacheWrite));\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\tsegs.push({ plain: costStr, styled: theme.fg(\"muted\", costStr) });\n\t\t}\n\t\tconst l2Plain = segs.map((s) => s.plain).join(\" \");\n\t\tconst l2Styled = segs.map((s) => s.styled).join(\" \");\n\n\t\t// Right: model, thinking level, and provider (when several are configured).\n\t\tconst modelName = state.model?.id || \"no-model\";\n\t\tlet r2Plain = modelName;\n\t\tlet r2Styled = theme.fg(\"muted\", modelName);\n\t\tif (state.model?.reasoning) {\n\t\t\tconst tl = state.thinkingLevel || \"off\";\n\t\t\tconst tstr = tl === \"off\" ? \"thinking off\" : tl;\n\t\t\tr2Plain += ` • ${tstr}`;\n\t\t\tr2Styled += theme.fg(\"dim\", ` • ${tstr}`);\n\t\t}\n\t\tif (this.footerData.getAvailableProviderCount() > 1 && state.model) {\n\t\t\t// Prepend the provider only when the whole right cluster still fits.\n\t\t\tconst withProv = `(${state.model.provider}) ${r2Plain}`;\n\t\t\tif (visibleWidth(l2Plain) + 2 + visibleWidth(withProv) <= width) {\n\t\t\t\tr2Plain = withProv;\n\t\t\t\tr2Styled = theme.fg(\"dim\", `(${state.model.provider}) `) + r2Styled;\n\t\t\t}\n\t\t}\n\t\tconst line2 = assembleLine(width, l2Plain, l2Styled, r2Plain, r2Styled);\n\n\t\tconst lines = [line1, line2];\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\t// Transient startup progress (first-run tool downloads, index build): one\n\t\t// determinate bar per entry, cleared as each settles. Width-clamped like the\n\t\t// status line so the footer never overflows.\n\t\tfor (const entry of startupProgress.list()) {\n\t\t\tlines.push(truncateToWidth(renderStartupLine(entry), width, theme.fg(\"dim\", \"…\")));\n\t\t}\n\n\t\treturn lines;\n\t}\n}\n"]}
|
|
@@ -319,15 +319,23 @@
|
|
|
319
319
|
"$ref": "#/$defs/colorValue",
|
|
320
320
|
"description": "Agent identity palette slot 5 (optional; subagent types hash into agent1-agent6, falls back to accent)"
|
|
321
321
|
},
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
322
|
+
"agent6": {
|
|
323
|
+
"$ref": "#/$defs/colorValue",
|
|
324
|
+
"description": "Agent identity palette slot 6 (optional; subagent types hash into agent1-agent6, falls back to accent)"
|
|
325
|
+
},
|
|
326
|
+
"mcp": {
|
|
327
|
+
"$ref": "#/$defs/colorValue",
|
|
328
|
+
"description": "MCP server identity color"
|
|
329
|
+
},
|
|
330
|
+
"brandBg": {
|
|
331
|
+
"$ref": "#/$defs/colorValue",
|
|
332
|
+
"description": "Brand chip background (optional; set with brandText to paint the footer mark as a filled chip instead of accent text)"
|
|
333
|
+
},
|
|
334
|
+
"brandText": {
|
|
335
|
+
"$ref": "#/$defs/colorValue",
|
|
336
|
+
"description": "Brand chip text (optional; only honoured when brandBg is also set)"
|
|
337
|
+
}
|
|
325
338
|
},
|
|
326
|
-
"mcp": {
|
|
327
|
-
"$ref": "#/$defs/colorValue",
|
|
328
|
-
"description": "MCP server identity color"
|
|
329
|
-
}
|
|
330
|
-
},
|
|
331
339
|
"additionalProperties": false
|
|
332
340
|
},
|
|
333
341
|
"export": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { EditorTheme, MarkdownTheme, SelectListTheme } from "@kolisachint/hoocode-tui";
|
|
2
2
|
import type { SourceInfo } from "../../../core/source-info.js";
|
|
3
|
-
export type ThemeColor = "accent" | "border" | "borderAccent" | "borderMuted" | "success" | "error" | "warning" | "muted" | "dim" | "text" | "thinkingText" | "userMessageText" | "customMessageText" | "customMessageLabel" | "toolTitle" | "toolOutput" | "mdHeading" | "mdLink" | "mdLinkUrl" | "mdCode" | "mdCodeBlock" | "mdCodeBlockBorder" | "mdQuote" | "mdQuoteBorder" | "mdHr" | "mdListBullet" | "toolDiffAdded" | "toolDiffRemoved" | "toolDiffContext" | "syntaxComment" | "syntaxKeyword" | "syntaxFunction" | "syntaxVariable" | "syntaxString" | "syntaxNumber" | "syntaxType" | "syntaxOperator" | "syntaxPunctuation" | "thinkingOff" | "thinkingMinimal" | "thinkingLow" | "thinkingMedium" | "thinkingHigh" | "thinkingXhigh" | "bashMode" | "agent1" | "agent2" | "agent3" | "agent4" | "agent5" | "agent6" | "mcp";
|
|
3
|
+
export type ThemeColor = "accent" | "border" | "borderAccent" | "borderMuted" | "success" | "error" | "warning" | "muted" | "dim" | "text" | "thinkingText" | "userMessageText" | "customMessageText" | "customMessageLabel" | "toolTitle" | "toolOutput" | "mdHeading" | "mdLink" | "mdLinkUrl" | "mdCode" | "mdCodeBlock" | "mdCodeBlockBorder" | "mdQuote" | "mdQuoteBorder" | "mdHr" | "mdListBullet" | "toolDiffAdded" | "toolDiffRemoved" | "toolDiffContext" | "syntaxComment" | "syntaxKeyword" | "syntaxFunction" | "syntaxVariable" | "syntaxString" | "syntaxNumber" | "syntaxType" | "syntaxOperator" | "syntaxPunctuation" | "thinkingOff" | "thinkingMinimal" | "thinkingLow" | "thinkingMedium" | "thinkingHigh" | "thinkingXhigh" | "bashMode" | "agent1" | "agent2" | "agent3" | "agent4" | "agent5" | "agent6" | "mcp" | "brandText";
|
|
4
4
|
/** The agent identity palette, in hash order. */
|
|
5
5
|
export declare const AGENT_COLOR_TOKENS: readonly ["agent1", "agent2", "agent3", "agent4", "agent5", "agent6"];
|
|
6
6
|
/**
|
|
@@ -12,7 +12,7 @@ export declare const AGENT_COLOR_TOKENS: readonly ["agent1", "agent2", "agent3",
|
|
|
12
12
|
* primary identifier, color is the at-a-glance cue.
|
|
13
13
|
*/
|
|
14
14
|
export declare function agentColorFor(agentType: string): ThemeColor;
|
|
15
|
-
export type ThemeBg = "selectedBg" | "userMessageBg" | "customMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
|
|
15
|
+
export type ThemeBg = "selectedBg" | "userMessageBg" | "customMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg" | "brandBg";
|
|
16
16
|
type ColorMode = "truecolor" | "256color";
|
|
17
17
|
export declare class Theme {
|
|
18
18
|
readonly name?: string;
|
|
@@ -28,6 +28,10 @@ export declare class Theme {
|
|
|
28
28
|
});
|
|
29
29
|
fg(color: ThemeColor, text: string): string;
|
|
30
30
|
bg(color: ThemeBg, text: string): string;
|
|
31
|
+
/** Whether the theme defines an optional token, so callers can pick a
|
|
32
|
+
* fallback rendering instead of letting fg()/bg() throw. */
|
|
33
|
+
has(color: ThemeColor): boolean;
|
|
34
|
+
hasBg(color: ThemeBg): boolean;
|
|
31
35
|
bold(text: string): string;
|
|
32
36
|
blink(text: string): string;
|
|
33
37
|
italic(text: string): string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/theme/theme.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport type { EditorTheme, MarkdownTheme, SelectListTheme } from \"@kolisachint/hoocode-tui\";\nimport chalk from \"chalk\";\nimport { highlight, supportsLanguage } from \"cli-highlight\";\nimport { type Static, Type } from \"typebox\";\nimport { Compile } from \"typebox/compile\";\nimport { getCustomThemesDir, getThemesDir } from \"../../../config.js\";\nimport type { SourceInfo } from \"../../../core/source-info.js\";\nimport { closeWatcher, watchWithErrorHandler } from \"../../../utils/fs-watch.js\";\n\n// ============================================================================\n// Types & Schema\n// ============================================================================\n\nconst ColorValueSchema = Type.Union([\n\tType.String(), // hex \"#ff0000\", var ref \"primary\", or empty \"\"\n\tType.Integer({ minimum: 0, maximum: 255 }), // 256-color index\n]);\n\ntype ColorValue = Static<typeof ColorValueSchema>;\n\nconst ThemeJsonSchema = Type.Object({\n\t$schema: Type.Optional(Type.String()),\n\tname: Type.String(),\n\tdescription: Type.Optional(Type.String()),\n\tvars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)),\n\tcolors: Type.Object({\n\t\t// Core UI (10 colors)\n\t\taccent: ColorValueSchema,\n\t\tborder: ColorValueSchema,\n\t\tborderAccent: ColorValueSchema,\n\t\tborderMuted: ColorValueSchema,\n\t\tsuccess: ColorValueSchema,\n\t\terror: ColorValueSchema,\n\t\twarning: ColorValueSchema,\n\t\tmuted: ColorValueSchema,\n\t\tdim: ColorValueSchema,\n\t\ttext: ColorValueSchema,\n\t\tthinkingText: ColorValueSchema,\n\t\t// Backgrounds & Content Text (11 colors)\n\t\tselectedBg: ColorValueSchema,\n\t\tuserMessageBg: ColorValueSchema,\n\t\tuserMessageText: ColorValueSchema,\n\t\tcustomMessageBg: ColorValueSchema,\n\t\tcustomMessageText: ColorValueSchema,\n\t\tcustomMessageLabel: ColorValueSchema,\n\t\ttoolPendingBg: ColorValueSchema,\n\t\ttoolSuccessBg: ColorValueSchema,\n\t\ttoolErrorBg: ColorValueSchema,\n\t\ttoolTitle: ColorValueSchema,\n\t\ttoolOutput: ColorValueSchema,\n\t\t// Markdown (10 colors)\n\t\tmdHeading: ColorValueSchema,\n\t\tmdLink: ColorValueSchema,\n\t\tmdLinkUrl: ColorValueSchema,\n\t\tmdCode: ColorValueSchema,\n\t\tmdCodeBlock: ColorValueSchema,\n\t\tmdCodeBlockBorder: ColorValueSchema,\n\t\tmdQuote: ColorValueSchema,\n\t\tmdQuoteBorder: ColorValueSchema,\n\t\tmdHr: ColorValueSchema,\n\t\tmdListBullet: ColorValueSchema,\n\t\t// Tool Diffs (3 colors)\n\t\ttoolDiffAdded: ColorValueSchema,\n\t\ttoolDiffRemoved: ColorValueSchema,\n\t\ttoolDiffContext: ColorValueSchema,\n\t\t// Syntax Highlighting (9 colors)\n\t\tsyntaxComment: ColorValueSchema,\n\t\tsyntaxKeyword: ColorValueSchema,\n\t\tsyntaxFunction: ColorValueSchema,\n\t\tsyntaxVariable: ColorValueSchema,\n\t\tsyntaxString: ColorValueSchema,\n\t\tsyntaxNumber: ColorValueSchema,\n\t\tsyntaxType: ColorValueSchema,\n\t\tsyntaxOperator: ColorValueSchema,\n\t\tsyntaxPunctuation: ColorValueSchema,\n\t\t// Thinking Level Borders (6 colors)\n\t\tthinkingOff: ColorValueSchema,\n\t\tthinkingMinimal: ColorValueSchema,\n\t\tthinkingLow: ColorValueSchema,\n\t\tthinkingMedium: ColorValueSchema,\n\t\tthinkingHigh: ColorValueSchema,\n\t\tthinkingXhigh: ColorValueSchema,\n\t\t// Bash Mode (1 color)\n\t\tbashMode: ColorValueSchema,\n\t\t// Agent identity palette (6 colors). OPTIONAL so existing custom themes\n\t\t// keep validating: any missing entry falls back to `accent` (see\n\t\t// createTheme). Subagent types hash into this palette so the same agent\n\t\t// reads as the same color across the chat, task panel, and TaskOutput.\n\t\tagent1: Type.Optional(ColorValueSchema),\n\t\tagent2: Type.Optional(ColorValueSchema),\n\t\tagent3: Type.Optional(ColorValueSchema),\n\t\tagent4: Type.Optional(ColorValueSchema),\n\t\tagent5: Type.Optional(ColorValueSchema),\n\t\tagent6: Type.Optional(ColorValueSchema),\n\t}),\n\texport: Type.Optional(\n\t\tType.Object({\n\t\t\tpageBg: Type.Optional(ColorValueSchema),\n\t\t\tcardBg: Type.Optional(ColorValueSchema),\n\t\t\tinfoBg: Type.Optional(ColorValueSchema),\n\t\t}),\n\t),\n});\n\ntype ThemeJson = Static<typeof ThemeJsonSchema>;\n\nconst validateThemeJson = Compile(ThemeJsonSchema);\n\nexport type ThemeColor =\n\t| \"accent\"\n\t| \"border\"\n\t| \"borderAccent\"\n\t| \"borderMuted\"\n\t| \"success\"\n\t| \"error\"\n\t| \"warning\"\n\t| \"muted\"\n\t| \"dim\"\n\t| \"text\"\n\t| \"thinkingText\"\n\t| \"userMessageText\"\n\t| \"customMessageText\"\n\t| \"customMessageLabel\"\n\t| \"toolTitle\"\n\t| \"toolOutput\"\n\t| \"mdHeading\"\n\t| \"mdLink\"\n\t| \"mdLinkUrl\"\n\t| \"mdCode\"\n\t| \"mdCodeBlock\"\n\t| \"mdCodeBlockBorder\"\n\t| \"mdQuote\"\n\t| \"mdQuoteBorder\"\n\t| \"mdHr\"\n\t| \"mdListBullet\"\n\t| \"toolDiffAdded\"\n\t| \"toolDiffRemoved\"\n\t| \"toolDiffContext\"\n\t| \"syntaxComment\"\n\t| \"syntaxKeyword\"\n\t| \"syntaxFunction\"\n\t| \"syntaxVariable\"\n\t| \"syntaxString\"\n\t| \"syntaxNumber\"\n\t| \"syntaxType\"\n\t| \"syntaxOperator\"\n\t| \"syntaxPunctuation\"\n\t| \"thinkingOff\"\n\t| \"thinkingMinimal\"\n\t| \"thinkingLow\"\n\t| \"thinkingMedium\"\n\t| \"thinkingHigh\"\n\t| \"thinkingXhigh\"\n\t| \"bashMode\"\n\t| \"agent1\"\n\t| \"agent2\"\n\t| \"agent3\"\n\t| \"agent4\"\n\t| \"agent5\"\n\t| \"agent6\"\n\t| \"mcp\";\n\n/** The agent identity palette, in hash order. */\nexport const AGENT_COLOR_TOKENS = [\n\t\"agent1\",\n\t\"agent2\",\n\t\"agent3\",\n\t\"agent4\",\n\t\"agent5\",\n\t\"agent6\",\n] as const satisfies readonly ThemeColor[];\n\n/**\n * Stable color token for an agent type (\"explore\", \"plan\", a custom agent…).\n * The name hashes into the agent palette, so the same type renders in the same\n * color everywhere it appears — chat tool line, task panel rows/roster, and\n * TaskOutput — for the whole session and across sessions. Distinct types spread\n * over 6 hues; collisions are possible but the `[name]`/`#N` labels stay the\n * primary identifier, color is the at-a-glance cue.\n */\nexport function agentColorFor(agentType: string): ThemeColor {\n\t// djb2: tiny, stable, good spread for short identifier-like strings.\n\tlet hash = 5381;\n\tfor (let i = 0; i < agentType.length; i++) {\n\t\thash = ((hash << 5) + hash + agentType.charCodeAt(i)) >>> 0;\n\t}\n\treturn AGENT_COLOR_TOKENS[hash % AGENT_COLOR_TOKENS.length] as ThemeColor;\n}\n\nexport type ThemeBg =\n\t| \"selectedBg\"\n\t| \"userMessageBg\"\n\t| \"customMessageBg\"\n\t| \"toolPendingBg\"\n\t| \"toolSuccessBg\"\n\t| \"toolErrorBg\";\n\ntype ColorMode = \"truecolor\" | \"256color\";\n\n// ============================================================================\n// Color Utilities\n// ============================================================================\n\nfunction detectColorMode(): ColorMode {\n\tconst colorterm = process.env.COLORTERM;\n\tif (colorterm === \"truecolor\" || colorterm === \"24bit\") {\n\t\treturn \"truecolor\";\n\t}\n\t// Windows Terminal supports truecolor\n\tif (process.env.WT_SESSION) {\n\t\treturn \"truecolor\";\n\t}\n\tconst term = process.env.TERM || \"\";\n\t// Fall back to 256color for truly limited terminals\n\tif (term === \"dumb\" || term === \"\" || term === \"linux\") {\n\t\treturn \"256color\";\n\t}\n\t// Terminal.app also doesn't support truecolor\n\tif (process.env.TERM_PROGRAM === \"Apple_Terminal\") {\n\t\treturn \"256color\";\n\t}\n\t// GNU screen doesn't support truecolor unless explicitly opted in via COLORTERM=truecolor.\n\t// TERM under screen is typically \"screen\", \"screen-256color\", or \"screen.xterm-256color\".\n\tif (term === \"screen\" || term.startsWith(\"screen-\") || term.startsWith(\"screen.\")) {\n\t\treturn \"256color\";\n\t}\n\t// Assume truecolor for everything else - virtually all modern terminals support it\n\treturn \"truecolor\";\n}\n\nfunction hexToRgb(hex: string): { r: number; g: number; b: number } {\n\tconst cleaned = hex.replace(\"#\", \"\");\n\tif (cleaned.length !== 6) {\n\t\tthrow new Error(`Invalid hex color: ${hex}`);\n\t}\n\tconst r = parseInt(cleaned.substring(0, 2), 16);\n\tconst g = parseInt(cleaned.substring(2, 4), 16);\n\tconst b = parseInt(cleaned.substring(4, 6), 16);\n\tif (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {\n\t\tthrow new Error(`Invalid hex color: ${hex}`);\n\t}\n\treturn { r, g, b };\n}\n\n// The 6x6x6 color cube channel values (indices 0-5)\nconst CUBE_VALUES = [0, 95, 135, 175, 215, 255];\n\n// Grayscale ramp values (indices 232-255, 24 grays from 8 to 238)\nconst GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10);\n\nfunction findClosestCubeIndex(value: number): number {\n\tlet minDist = Infinity;\n\tlet minIdx = 0;\n\tfor (let i = 0; i < CUBE_VALUES.length; i++) {\n\t\tconst dist = Math.abs(value - CUBE_VALUES[i]);\n\t\tif (dist < minDist) {\n\t\t\tminDist = dist;\n\t\t\tminIdx = i;\n\t\t}\n\t}\n\treturn minIdx;\n}\n\nfunction findClosestGrayIndex(gray: number): number {\n\tlet minDist = Infinity;\n\tlet minIdx = 0;\n\tfor (let i = 0; i < GRAY_VALUES.length; i++) {\n\t\tconst dist = Math.abs(gray - GRAY_VALUES[i]);\n\t\tif (dist < minDist) {\n\t\t\tminDist = dist;\n\t\t\tminIdx = i;\n\t\t}\n\t}\n\treturn minIdx;\n}\n\nfunction colorDistance(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number {\n\t// Weighted Euclidean distance (human eye is more sensitive to green)\n\tconst dr = r1 - r2;\n\tconst dg = g1 - g2;\n\tconst db = b1 - b2;\n\treturn dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114;\n}\n\nfunction rgbTo256(r: number, g: number, b: number): number {\n\t// Find closest color in the 6x6x6 cube\n\tconst rIdx = findClosestCubeIndex(r);\n\tconst gIdx = findClosestCubeIndex(g);\n\tconst bIdx = findClosestCubeIndex(b);\n\tconst cubeR = CUBE_VALUES[rIdx];\n\tconst cubeG = CUBE_VALUES[gIdx];\n\tconst cubeB = CUBE_VALUES[bIdx];\n\tconst cubeIndex = 16 + 36 * rIdx + 6 * gIdx + bIdx;\n\tconst cubeDist = colorDistance(r, g, b, cubeR, cubeG, cubeB);\n\n\t// Find closest grayscale\n\tconst gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b);\n\tconst grayIdx = findClosestGrayIndex(gray);\n\tconst grayValue = GRAY_VALUES[grayIdx];\n\tconst grayIndex = 232 + grayIdx;\n\tconst grayDist = colorDistance(r, g, b, grayValue, grayValue, grayValue);\n\n\t// Check if color has noticeable saturation (hue matters)\n\t// If max-min spread is significant, prefer cube to preserve tint\n\tconst maxC = Math.max(r, g, b);\n\tconst minC = Math.min(r, g, b);\n\tconst spread = maxC - minC;\n\n\t// Only consider grayscale if color is nearly neutral (spread < 10)\n\t// AND grayscale is actually closer\n\tif (spread < 10 && grayDist < cubeDist) {\n\t\treturn grayIndex;\n\t}\n\n\treturn cubeIndex;\n}\n\nfunction hexTo256(hex: string): number {\n\tconst { r, g, b } = hexToRgb(hex);\n\treturn rgbTo256(r, g, b);\n}\n\nfunction fgAnsi(color: string | number, mode: ColorMode): string {\n\tif (color === \"\") return \"\\x1b[39m\";\n\tif (typeof color === \"number\") return `\\x1b[38;5;${color}m`;\n\tif (color.startsWith(\"#\")) {\n\t\tif (mode === \"truecolor\") {\n\t\t\tconst { r, g, b } = hexToRgb(color);\n\t\t\treturn `\\x1b[38;2;${r};${g};${b}m`;\n\t\t} else {\n\t\t\tconst index = hexTo256(color);\n\t\t\treturn `\\x1b[38;5;${index}m`;\n\t\t}\n\t}\n\tthrow new Error(`Invalid color value: ${color}`);\n}\n\nfunction bgAnsi(color: string | number, mode: ColorMode): string {\n\tif (color === \"\") return \"\\x1b[49m\";\n\tif (typeof color === \"number\") return `\\x1b[48;5;${color}m`;\n\tif (color.startsWith(\"#\")) {\n\t\tif (mode === \"truecolor\") {\n\t\t\tconst { r, g, b } = hexToRgb(color);\n\t\t\treturn `\\x1b[48;2;${r};${g};${b}m`;\n\t\t} else {\n\t\t\tconst index = hexTo256(color);\n\t\t\treturn `\\x1b[48;5;${index}m`;\n\t\t}\n\t}\n\tthrow new Error(`Invalid color value: ${color}`);\n}\n\nfunction resolveVarRefs(\n\tvalue: ColorValue,\n\tvars: Record<string, ColorValue>,\n\tvisited = new Set<string>(),\n): string | number {\n\tif (typeof value === \"number\" || value === \"\" || value.startsWith(\"#\")) {\n\t\treturn value;\n\t}\n\tif (visited.has(value)) {\n\t\tthrow new Error(`Circular variable reference detected: ${value}`);\n\t}\n\tif (!(value in vars)) {\n\t\tthrow new Error(`Variable reference not found: ${value}`);\n\t}\n\tvisited.add(value);\n\treturn resolveVarRefs(vars[value], vars, visited);\n}\n\nfunction resolveThemeColors<T extends Record<string, ColorValue>>(\n\tcolors: T,\n\tvars: Record<string, ColorValue> = {},\n): Record<keyof T, string | number> {\n\tconst resolved: Record<string, string | number> = {};\n\tfor (const [key, value] of Object.entries(colors)) {\n\t\tresolved[key] = resolveVarRefs(value, vars);\n\t}\n\treturn resolved as Record<keyof T, string | number>;\n}\n\n// ============================================================================\n// Theme Class\n// ============================================================================\n\nexport class Theme {\n\treadonly name?: string;\n\treadonly sourcePath?: string;\n\tsourceInfo?: SourceInfo;\n\tprivate fgColors: Map<ThemeColor, string>;\n\tprivate bgColors: Map<ThemeBg, string>;\n\tprivate mode: ColorMode;\n\n\tconstructor(\n\t\tfgColors: Record<ThemeColor, string | number>,\n\t\tbgColors: Record<ThemeBg, string | number>,\n\t\tmode: ColorMode,\n\t\toptions: { name?: string; sourcePath?: string; sourceInfo?: SourceInfo } = {},\n\t) {\n\t\tthis.name = options.name;\n\t\tthis.sourcePath = options.sourcePath;\n\t\tthis.sourceInfo = options.sourceInfo;\n\t\tthis.mode = mode;\n\t\tthis.fgColors = new Map();\n\t\tfor (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {\n\t\t\tthis.fgColors.set(key, fgAnsi(value, mode));\n\t\t}\n\t\t// Agent palette fallback for themes built before it existed (including\n\t\t// Theme instances constructed directly by extensions): default to accent.\n\t\tconst accentAnsi = this.fgColors.get(\"accent\");\n\t\tif (accentAnsi !== undefined) {\n\t\t\tfor (const token of AGENT_COLOR_TOKENS) {\n\t\t\t\tif (!this.fgColors.has(token)) {\n\t\t\t\t\tthis.fgColors.set(token, accentAnsi);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tthis.bgColors = new Map();\n\t\tfor (const [key, value] of Object.entries(bgColors) as [ThemeBg, string | number][]) {\n\t\t\tthis.bgColors.set(key, bgAnsi(value, mode));\n\t\t}\n\t}\n\n\tfg(color: ThemeColor, text: string): string {\n\t\tconst ansi = this.fgColors.get(color);\n\t\tif (!ansi) throw new Error(`Unknown theme color: ${color}`);\n\t\treturn `${ansi}${text}\\x1b[39m`; // Reset only foreground color\n\t}\n\n\tbg(color: ThemeBg, text: string): string {\n\t\tconst ansi = this.bgColors.get(color);\n\t\tif (!ansi) throw new Error(`Unknown theme background color: ${color}`);\n\t\treturn `${ansi}${text}\\x1b[49m`; // Reset only background color\n\t}\n\n\tbold(text: string): string {\n\t\treturn chalk.bold(text);\n\t}\n\n\tblink(text: string): string {\n\t\t// Terminal-native blink (SGR 5). Zero overhead; terminals that support\n\t\t// blink will animate, others render it as a steady glyph.\n\t\treturn `\\x1b[5m${text}\\x1b[25m`;\n\t}\n\n\titalic(text: string): string {\n\t\treturn chalk.italic(text);\n\t}\n\n\tunderline(text: string): string {\n\t\treturn chalk.underline(text);\n\t}\n\n\tinverse(text: string): string {\n\t\treturn chalk.inverse(text);\n\t}\n\n\tstrikethrough(text: string): string {\n\t\treturn chalk.strikethrough(text);\n\t}\n\n\tgetFgAnsi(color: ThemeColor): string {\n\t\tconst ansi = this.fgColors.get(color);\n\t\tif (!ansi) throw new Error(`Unknown theme color: ${color}`);\n\t\treturn ansi;\n\t}\n\n\tgetBgAnsi(color: ThemeBg): string {\n\t\tconst ansi = this.bgColors.get(color);\n\t\tif (!ansi) throw new Error(`Unknown theme background color: ${color}`);\n\t\treturn ansi;\n\t}\n\n\tgetColorMode(): ColorMode {\n\t\treturn this.mode;\n\t}\n\n\tgetThinkingBorderColor(level: \"off\" | \"minimal\" | \"low\" | \"medium\" | \"high\" | \"xhigh\"): (str: string) => string {\n\t\t// Map thinking levels to dedicated theme colors\n\t\tswitch (level) {\n\t\t\tcase \"off\":\n\t\t\t\treturn (str: string) => this.fg(\"thinkingOff\", str);\n\t\t\tcase \"minimal\":\n\t\t\t\treturn (str: string) => this.fg(\"thinkingMinimal\", str);\n\t\t\tcase \"low\":\n\t\t\t\treturn (str: string) => this.fg(\"thinkingLow\", str);\n\t\t\tcase \"medium\":\n\t\t\t\treturn (str: string) => this.fg(\"thinkingMedium\", str);\n\t\t\tcase \"high\":\n\t\t\t\treturn (str: string) => this.fg(\"thinkingHigh\", str);\n\t\t\tcase \"xhigh\":\n\t\t\t\treturn (str: string) => this.fg(\"thinkingXhigh\", str);\n\t\t\tdefault:\n\t\t\t\treturn (str: string) => this.fg(\"thinkingOff\", str);\n\t\t}\n\t}\n\n\tgetBashModeBorderColor(): (str: string) => string {\n\t\treturn (str: string) => this.fg(\"bashMode\", str);\n\t}\n}\n\n// ============================================================================\n// Theme Loading\n// ============================================================================\n\nlet BUILTIN_THEMES: Record<string, ThemeJson> | undefined;\n\n/** Files in the built-in theme directory that are not themes. */\nconst NON_THEME_FILES = new Set([\"theme-schema.json\"]);\n\nfunction getBuiltinThemes(): Record<string, ThemeJson> {\n\tif (!BUILTIN_THEMES) {\n\t\t// Every *.json in the shipped theme directory is a built-in, so adding a\n\t\t// theme is a matter of dropping the file in (both build steps copy the\n\t\t// whole directory). `dark` and `light` are still the detection defaults.\n\t\tconst themesDir = getThemesDir();\n\t\tconst themes: Record<string, ThemeJson> = {};\n\t\tfor (const file of fs.readdirSync(themesDir).sort()) {\n\t\t\tif (!file.endsWith(\".json\") || NON_THEME_FILES.has(file)) continue;\n\t\t\tconst name = file.slice(0, -5);\n\t\t\tthemes[name] = parseThemeJsonContent(file, fs.readFileSync(path.join(themesDir, file), \"utf-8\"));\n\t\t}\n\t\tBUILTIN_THEMES = themes;\n\t}\n\treturn BUILTIN_THEMES;\n}\n\n/** True for themes shipped with the package, which live outside the custom themes dir. */\nfunction isBuiltinTheme(name: string): boolean {\n\treturn name in getBuiltinThemes();\n}\n\nexport function getAvailableThemes(): string[] {\n\tconst themes = new Set<string>(Object.keys(getBuiltinThemes()));\n\tconst customThemesDir = getCustomThemesDir();\n\tif (fs.existsSync(customThemesDir)) {\n\t\tconst files = fs.readdirSync(customThemesDir);\n\t\tfor (const file of files) {\n\t\t\tif (file.endsWith(\".json\")) {\n\t\t\t\tthemes.add(file.slice(0, -5));\n\t\t\t}\n\t\t}\n\t}\n\tfor (const name of registeredThemes.keys()) {\n\t\tthemes.add(name);\n\t}\n\treturn Array.from(themes).sort();\n}\n\n/**\n * One-line description from a theme file, shown next to the name in the theme\n * picker. Undefined for themes that don't declare one, and for themes\n * registered as instances by extensions.\n */\nexport function getThemeDescription(name: string): string | undefined {\n\ttry {\n\t\treturn loadThemeJson(name).description;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nexport interface ThemeInfo {\n\tname: string;\n\tpath: string | undefined;\n}\n\nexport function getAvailableThemesWithPaths(): ThemeInfo[] {\n\tconst themesDir = getThemesDir();\n\tconst customThemesDir = getCustomThemesDir();\n\tconst result: ThemeInfo[] = [];\n\n\t// Built-in themes\n\tfor (const name of Object.keys(getBuiltinThemes())) {\n\t\tresult.push({ name, path: path.join(themesDir, `${name}.json`) });\n\t}\n\n\t// Custom themes\n\tif (fs.existsSync(customThemesDir)) {\n\t\tfor (const file of fs.readdirSync(customThemesDir)) {\n\t\t\tif (file.endsWith(\".json\")) {\n\t\t\t\tconst name = file.slice(0, -5);\n\t\t\t\tif (!result.some((t) => t.name === name)) {\n\t\t\t\t\tresult.push({ name, path: path.join(customThemesDir, file) });\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tfor (const [name, theme] of registeredThemes.entries()) {\n\t\tif (!result.some((t) => t.name === name)) {\n\t\t\tresult.push({ name, path: theme.sourcePath });\n\t\t}\n\t}\n\n\treturn result.sort((a, b) => a.name.localeCompare(b.name));\n}\n\nfunction parseThemeJson(label: string, json: unknown): ThemeJson {\n\tif (!validateThemeJson.Check(json)) {\n\t\tconst errors = Array.from(validateThemeJson.Errors(json));\n\t\tconst missingColors = new Set<string>();\n\t\tconst otherErrors: string[] = [];\n\n\t\tfor (const error of errors) {\n\t\t\tif (error.keyword === \"required\" && error.instancePath === \"/colors\") {\n\t\t\t\tconst requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties;\n\t\t\t\tfor (const requiredProperty of requiredProperties ?? []) {\n\t\t\t\t\tmissingColors.add(requiredProperty);\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst path = error.instancePath || \"/\";\n\t\t\totherErrors.push(` - ${path}: ${error.message}`);\n\t\t}\n\n\t\tlet errorMessage = `Invalid theme \"${label}\":\\n`;\n\t\tif (missingColors.size > 0) {\n\t\t\terrorMessage += \"\\nMissing required color tokens:\\n\";\n\t\t\terrorMessage += Array.from(missingColors)\n\t\t\t\t.sort()\n\t\t\t\t.map((color) => ` - ${color}`)\n\t\t\t\t.join(\"\\n\");\n\t\t\terrorMessage += '\\n\\nPlease add these colors to your theme\\'s \"colors\" object.';\n\t\t\terrorMessage += \"\\nSee the built-in themes (dark.json, light.json) for reference values.\";\n\t\t}\n\t\tif (otherErrors.length > 0) {\n\t\t\terrorMessage += `\\n\\nOther errors:\\n${otherErrors.join(\"\\n\")}`;\n\t\t}\n\n\t\tthrow new Error(errorMessage);\n\t}\n\n\treturn json as ThemeJson;\n}\n\nfunction parseThemeJsonContent(label: string, content: string): ThemeJson {\n\tlet json: unknown;\n\ttry {\n\t\tjson = JSON.parse(content);\n\t} catch (error) {\n\t\tthrow new Error(`Failed to parse theme ${label}: ${error}`);\n\t}\n\treturn parseThemeJson(label, json);\n}\n\nfunction loadThemeJson(name: string): ThemeJson {\n\tconst builtinThemes = getBuiltinThemes();\n\tif (name in builtinThemes) {\n\t\treturn builtinThemes[name];\n\t}\n\tconst registeredTheme = registeredThemes.get(name);\n\tif (registeredTheme?.sourcePath) {\n\t\tconst content = fs.readFileSync(registeredTheme.sourcePath, \"utf-8\");\n\t\treturn parseThemeJsonContent(registeredTheme.sourcePath, content);\n\t}\n\tif (registeredTheme) {\n\t\tthrow new Error(`Theme \"${name}\" does not have a source path for export`);\n\t}\n\tconst customThemesDir = getCustomThemesDir();\n\tconst themePath = path.join(customThemesDir, `${name}.json`);\n\tif (!fs.existsSync(themePath)) {\n\t\tthrow new Error(`Theme not found: ${name}`);\n\t}\n\tconst content = fs.readFileSync(themePath, \"utf-8\");\n\treturn parseThemeJsonContent(name, content);\n}\n\nfunction createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string): Theme {\n\tconst colorMode = mode ?? detectColorMode();\n\tconst resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);\n\tconst fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;\n\tconst bgColors: Record<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;\n\tconst bgColorKeys: Set<string> = new Set([\n\t\t\"selectedBg\",\n\t\t\"userMessageBg\",\n\t\t\"customMessageBg\",\n\t\t\"toolPendingBg\",\n\t\t\"toolSuccessBg\",\n\t\t\"toolErrorBg\",\n\t]);\n\tfor (const [key, value] of Object.entries(resolvedColors)) {\n\t\tif (bgColorKeys.has(key)) {\n\t\t\tbgColors[key as ThemeBg] = value;\n\t\t} else {\n\t\t\tfgColors[key as ThemeColor] = value;\n\t\t}\n\t}\n\t// The agent palette is optional in theme JSON (older custom themes predate\n\t// it); missing entries fall back to accent so theme.fg never throws.\n\tfor (const token of AGENT_COLOR_TOKENS) {\n\t\tif (fgColors[token] === undefined) {\n\t\t\tfgColors[token] = fgColors.accent;\n\t\t}\n\t}\n\treturn new Theme(fgColors, bgColors, colorMode, {\n\t\tname: themeJson.name,\n\t\tsourcePath,\n\t});\n}\n\nexport function loadThemeFromPath(themePath: string, mode?: ColorMode): Theme {\n\tconst content = fs.readFileSync(themePath, \"utf-8\");\n\tconst themeJson = parseThemeJsonContent(themePath, content);\n\treturn createTheme(themeJson, mode, themePath);\n}\n\nfunction loadTheme(name: string, mode?: ColorMode): Theme {\n\tconst registeredTheme = registeredThemes.get(name);\n\tif (registeredTheme) {\n\t\treturn registeredTheme;\n\t}\n\tconst themeJson = loadThemeJson(name);\n\treturn createTheme(themeJson, mode);\n}\n\nexport function getThemeByName(name: string): Theme | undefined {\n\ttry {\n\t\treturn loadTheme(name);\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nfunction detectTerminalBackground(): \"dark\" | \"light\" {\n\tconst colorfgbg = process.env.COLORFGBG || \"\";\n\tif (colorfgbg) {\n\t\tconst parts = colorfgbg.split(\";\");\n\t\tif (parts.length >= 2) {\n\t\t\tconst bg = parseInt(parts[1], 10);\n\t\t\tif (!Number.isNaN(bg)) {\n\t\t\t\tconst result = bg < 8 ? \"dark\" : \"light\";\n\t\t\t\treturn result;\n\t\t\t}\n\t\t}\n\t}\n\treturn \"dark\";\n}\n\nfunction getDefaultTheme(): string {\n\treturn detectTerminalBackground();\n}\n\n// ============================================================================\n// Global Theme Instance\n// ============================================================================\n\n// Use globalThis to share theme across module loaders (tsx + jiti in dev mode)\nconst THEME_KEY = Symbol.for(\"@kolisachint/hoocode-agent:theme\");\n\n// Export theme as a getter that reads from globalThis\n// This ensures all module instances (tsx, jiti) see the same theme\nexport const theme: Theme = new Proxy({} as Theme, {\n\tget(_target, prop) {\n\t\tconst t = (globalThis as Record<symbol, Theme>)[THEME_KEY];\n\t\tif (!t) throw new Error(\"Theme not initialized. Call initTheme() first.\");\n\t\treturn (t as unknown as Record<string | symbol, unknown>)[prop];\n\t},\n});\n\nfunction setGlobalTheme(t: Theme): void {\n\t(globalThis as Record<symbol, Theme>)[THEME_KEY] = t;\n}\n\nlet currentThemeName: string | undefined;\nlet themeWatcher: fs.FSWatcher | undefined;\nlet themeReloadTimer: NodeJS.Timeout | undefined;\nlet onThemeChangeCallback: (() => void) | undefined;\nconst registeredThemes = new Map<string, Theme>();\n\nexport function setRegisteredThemes(themes: Theme[]): void {\n\tregisteredThemes.clear();\n\tfor (const theme of themes) {\n\t\tif (theme.name) {\n\t\t\tregisteredThemes.set(theme.name, theme);\n\t\t}\n\t}\n}\n\nexport function initTheme(themeName?: string, enableWatcher: boolean = false): void {\n\tconst name = themeName ?? getDefaultTheme();\n\tcurrentThemeName = name;\n\ttry {\n\t\tsetGlobalTheme(loadTheme(name));\n\t\tif (enableWatcher) {\n\t\t\tstartThemeWatcher();\n\t\t}\n\t} catch (_error) {\n\t\t// Theme is invalid - fall back to dark theme silently\n\t\tcurrentThemeName = \"dark\";\n\t\tsetGlobalTheme(loadTheme(\"dark\"));\n\t\t// Don't start watcher for fallback theme\n\t}\n}\n\nexport function setTheme(name: string, enableWatcher: boolean = false): { success: boolean; error?: string } {\n\tcurrentThemeName = name;\n\ttry {\n\t\tsetGlobalTheme(loadTheme(name));\n\t\tif (enableWatcher) {\n\t\t\tstartThemeWatcher();\n\t\t}\n\t\tif (onThemeChangeCallback) {\n\t\t\tonThemeChangeCallback();\n\t\t}\n\t\treturn { success: true };\n\t} catch (error) {\n\t\t// Theme is invalid - fall back to dark theme\n\t\tcurrentThemeName = \"dark\";\n\t\tsetGlobalTheme(loadTheme(\"dark\"));\n\t\t// Don't start watcher for fallback theme\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: error instanceof Error ? error.message : String(error),\n\t\t};\n\t}\n}\n\nexport function setThemeInstance(themeInstance: Theme): void {\n\tsetGlobalTheme(themeInstance);\n\tcurrentThemeName = \"<in-memory>\";\n\tstopThemeWatcher(); // Can't watch a direct instance\n\tif (onThemeChangeCallback) {\n\t\tonThemeChangeCallback();\n\t}\n}\n\nexport function onThemeChange(callback: () => void): void {\n\tonThemeChangeCallback = callback;\n}\n\nfunction startThemeWatcher(): void {\n\tstopThemeWatcher();\n\n\t// Only watch if it's a custom theme (not built-in)\n\tif (!currentThemeName || isBuiltinTheme(currentThemeName)) {\n\t\treturn;\n\t}\n\n\tconst customThemesDir = getCustomThemesDir();\n\tconst watchedThemeName = currentThemeName;\n\tconst watchedFileName = `${watchedThemeName}.json`;\n\tconst themeFile = path.join(customThemesDir, watchedFileName);\n\n\t// Only watch if the file exists\n\tif (!fs.existsSync(themeFile)) {\n\t\treturn;\n\t}\n\n\tconst scheduleReload = () => {\n\t\tif (themeReloadTimer) {\n\t\t\tclearTimeout(themeReloadTimer);\n\t\t}\n\t\tthemeReloadTimer = setTimeout(() => {\n\t\t\tthemeReloadTimer = undefined;\n\n\t\t\t// Ignore stale timers after switching themes or stopping the watcher\n\t\t\tif (currentThemeName !== watchedThemeName) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Keep the last successfully loaded theme active if the file is temporarily missing\n\t\t\tif (!fs.existsSync(themeFile)) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\t// Reload the theme from disk and refresh the registry cache\n\t\t\t\tconst reloadedTheme = loadThemeFromPath(themeFile);\n\t\t\t\tregisteredThemes.set(watchedThemeName, reloadedTheme);\n\t\t\t\tsetGlobalTheme(reloadedTheme);\n\t\t\t\t// Notify callback (to invalidate UI)\n\t\t\t\tif (onThemeChangeCallback) {\n\t\t\t\t\tonThemeChangeCallback();\n\t\t\t\t}\n\t\t\t} catch (_error) {\n\t\t\t\t// Ignore errors (file might be in invalid state while being edited)\n\t\t\t}\n\t\t}, 100);\n\t};\n\n\tthemeWatcher =\n\t\twatchWithErrorHandler(\n\t\t\tcustomThemesDir,\n\t\t\t(_eventType, filename) => {\n\t\t\t\tif (currentThemeName !== watchedThemeName) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (!filename) {\n\t\t\t\t\tscheduleReload();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (filename !== watchedFileName) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tscheduleReload();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\tcloseWatcher(themeWatcher);\n\t\t\t\tthemeWatcher = undefined;\n\t\t\t},\n\t\t) ?? undefined;\n}\n\nexport function stopThemeWatcher(): void {\n\tif (themeReloadTimer) {\n\t\tclearTimeout(themeReloadTimer);\n\t\tthemeReloadTimer = undefined;\n\t}\n\tcloseWatcher(themeWatcher);\n\tthemeWatcher = undefined;\n}\n\n// ============================================================================\n// HTML Export Helpers\n// ============================================================================\n\n/**\n * Convert a 256-color index to hex string.\n * Indices 0-15: basic colors (approximate)\n * Indices 16-231: 6x6x6 color cube\n * Indices 232-255: grayscale ramp\n */\nfunction ansi256ToHex(index: number): string {\n\t// Basic colors (0-15) - approximate common terminal values\n\tconst basicColors = [\n\t\t\"#000000\",\n\t\t\"#800000\",\n\t\t\"#008000\",\n\t\t\"#808000\",\n\t\t\"#000080\",\n\t\t\"#800080\",\n\t\t\"#008080\",\n\t\t\"#c0c0c0\",\n\t\t\"#808080\",\n\t\t\"#ff0000\",\n\t\t\"#00ff00\",\n\t\t\"#ffff00\",\n\t\t\"#0000ff\",\n\t\t\"#ff00ff\",\n\t\t\"#00ffff\",\n\t\t\"#ffffff\",\n\t];\n\tif (index < 16) {\n\t\treturn basicColors[index];\n\t}\n\n\t// Color cube (16-231): 6x6x6 = 216 colors\n\tif (index < 232) {\n\t\tconst cubeIndex = index - 16;\n\t\tconst r = Math.floor(cubeIndex / 36);\n\t\tconst g = Math.floor((cubeIndex % 36) / 6);\n\t\tconst b = cubeIndex % 6;\n\t\tconst toHex = (n: number) => (n === 0 ? 0 : 55 + n * 40).toString(16).padStart(2, \"0\");\n\t\treturn `#${toHex(r)}${toHex(g)}${toHex(b)}`;\n\t}\n\n\t// Grayscale (232-255): 24 shades\n\tconst gray = 8 + (index - 232) * 10;\n\tconst grayHex = gray.toString(16).padStart(2, \"0\");\n\treturn `#${grayHex}${grayHex}${grayHex}`;\n}\n\n/** WCAG relative luminance (0-1) of a resolved color, or undefined if unparseable. */\nfunction relativeLuminance(color: string | number): number | undefined {\n\tconst hex = typeof color === \"number\" ? ansi256ToHex(color) : color;\n\tif (!hex.startsWith(\"#\")) return undefined;\n\tlet rgb: { r: number; g: number; b: number };\n\ttry {\n\t\trgb = hexToRgb(hex);\n\t} catch {\n\t\treturn undefined;\n\t}\n\tconst toLinear = (c: number) => {\n\t\tconst s = c / 255;\n\t\treturn s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;\n\t};\n\treturn 0.2126 * toLinear(rgb.r) + 0.7152 * toLinear(rgb.g) + 0.0722 * toLinear(rgb.b);\n}\n\n/**\n * Whether a theme sits on a light backdrop, judged by its own colors rather\n * than its name — `light` is no longer the only light-background theme.\n */\nfunction hasLightBackdrop(themeJson: ThemeJson, resolved: Record<string, string | number>): boolean {\n\tconst pageBg = themeJson.export?.pageBg;\n\tconst candidates = [\n\t\tpageBg === undefined ? undefined : resolveVarRefs(pageBg, themeJson.vars ?? {}),\n\t\tresolved.userMessageBg,\n\t];\n\tfor (const candidate of candidates) {\n\t\tif (candidate === undefined || candidate === \"\") continue;\n\t\tconst luminance = relativeLuminance(candidate);\n\t\tif (luminance !== undefined) return luminance > 0.5;\n\t}\n\treturn false;\n}\n\n/**\n * Get resolved theme colors as CSS-compatible hex strings.\n * Used by HTML export to generate CSS custom properties.\n */\nexport function getResolvedThemeColors(themeName?: string): Record<string, string> {\n\tconst name = themeName ?? currentThemeName ?? getDefaultTheme();\n\tconst themeJson = loadThemeJson(name);\n\tconst resolved = resolveThemeColors(themeJson.colors, themeJson.vars);\n\tconst isLight = hasLightBackdrop(themeJson, resolved);\n\n\t// Default text color for empty values (terminal uses default fg color)\n\tconst defaultText = isLight ? \"#000000\" : \"#e5e5e7\";\n\n\tconst cssColors: Record<string, string> = {};\n\tfor (const [key, value] of Object.entries(resolved)) {\n\t\tif (typeof value === \"number\") {\n\t\t\tcssColors[key] = ansi256ToHex(value);\n\t\t} else if (value === \"\") {\n\t\t\t// Empty means default terminal color - use sensible fallback for HTML\n\t\t\tcssColors[key] = defaultText;\n\t\t} else {\n\t\t\tcssColors[key] = value;\n\t\t}\n\t}\n\treturn cssColors;\n}\n\n/**\n * Get explicit export colors from theme JSON, if specified.\n * Returns undefined for each color that isn't explicitly set.\n */\nexport function getThemeExportColors(themeName?: string): {\n\tpageBg?: string;\n\tcardBg?: string;\n\tinfoBg?: string;\n} {\n\tconst name = themeName ?? currentThemeName ?? getDefaultTheme();\n\ttry {\n\t\tconst themeJson = loadThemeJson(name);\n\t\tconst exportSection = themeJson.export;\n\t\tif (!exportSection) return {};\n\n\t\tconst vars = themeJson.vars ?? {};\n\t\tconst resolve = (value: ColorValue | undefined): string | undefined => {\n\t\t\tif (value === undefined) return undefined;\n\t\t\tconst resolved = resolveVarRefs(value, vars);\n\t\t\tif (typeof resolved === \"number\") return ansi256ToHex(resolved);\n\t\t\tif (resolved === \"\") return undefined;\n\t\t\treturn resolved;\n\t\t};\n\n\t\treturn {\n\t\t\tpageBg: resolve(exportSection.pageBg),\n\t\t\tcardBg: resolve(exportSection.cardBg),\n\t\t\tinfoBg: resolve(exportSection.infoBg),\n\t\t};\n\t} catch {\n\t\treturn {};\n\t}\n}\n\n// ============================================================================\n// TUI Helpers\n// ============================================================================\n\ntype CliHighlightTheme = Record<string, (s: string) => string>;\n\nlet cachedHighlightThemeFor: Theme | undefined;\nlet cachedCliHighlightTheme: CliHighlightTheme | undefined;\n\nfunction buildCliHighlightTheme(t: Theme): CliHighlightTheme {\n\treturn {\n\t\tkeyword: (s: string) => t.fg(\"syntaxKeyword\", s),\n\t\tbuilt_in: (s: string) => t.fg(\"syntaxType\", s),\n\t\tliteral: (s: string) => t.fg(\"syntaxNumber\", s),\n\t\tnumber: (s: string) => t.fg(\"syntaxNumber\", s),\n\t\tstring: (s: string) => t.fg(\"syntaxString\", s),\n\t\tcomment: (s: string) => t.fg(\"syntaxComment\", s),\n\t\tfunction: (s: string) => t.fg(\"syntaxFunction\", s),\n\t\ttitle: (s: string) => t.fg(\"syntaxFunction\", s),\n\t\tclass: (s: string) => t.fg(\"syntaxType\", s),\n\t\ttype: (s: string) => t.fg(\"syntaxType\", s),\n\t\tattr: (s: string) => t.fg(\"syntaxVariable\", s),\n\t\tvariable: (s: string) => t.fg(\"syntaxVariable\", s),\n\t\tparams: (s: string) => t.fg(\"syntaxVariable\", s),\n\t\toperator: (s: string) => t.fg(\"syntaxOperator\", s),\n\t\tpunctuation: (s: string) => t.fg(\"syntaxPunctuation\", s),\n\t};\n}\n\nfunction getCliHighlightTheme(t: Theme): CliHighlightTheme {\n\tif (cachedHighlightThemeFor !== t || !cachedCliHighlightTheme) {\n\t\tcachedHighlightThemeFor = t;\n\t\tcachedCliHighlightTheme = buildCliHighlightTheme(t);\n\t}\n\treturn cachedCliHighlightTheme;\n}\n\n/**\n * Highlight code with syntax coloring based on file extension or language.\n * Returns array of highlighted lines.\n */\nexport function highlightCode(code: string, lang?: string): string[] {\n\t// Validate language before highlighting to avoid stderr spam from cli-highlight\n\tconst validLang = lang && supportsLanguage(lang) ? lang : undefined;\n\t// Skip highlighting when no valid language is specified. cli-highlight's\n\t// auto-detection is unreliable and can misidentify prose as AppleScript,\n\t// LiveCodeServer, etc., coloring random English words as keywords.\n\tif (!validLang) {\n\t\treturn code.split(\"\\n\").map((line) => theme.fg(\"mdCodeBlock\", line));\n\t}\n\tconst opts = {\n\t\tlanguage: validLang,\n\t\tignoreIllegals: true,\n\t\ttheme: getCliHighlightTheme(theme),\n\t};\n\ttry {\n\t\treturn highlight(code, opts).split(\"\\n\");\n\t} catch {\n\t\treturn code.split(\"\\n\");\n\t}\n}\n\n/**\n * Get language identifier from file path extension.\n */\nexport function getLanguageFromPath(filePath: string): string | undefined {\n\tconst ext = filePath.split(\".\").pop()?.toLowerCase();\n\tif (!ext) return undefined;\n\n\tconst extToLang: Record<string, string> = {\n\t\tts: \"typescript\",\n\t\ttsx: \"typescript\",\n\t\tjs: \"javascript\",\n\t\tjsx: \"javascript\",\n\t\tmjs: \"javascript\",\n\t\tcjs: \"javascript\",\n\t\tpy: \"python\",\n\t\trb: \"ruby\",\n\t\trs: \"rust\",\n\t\tgo: \"go\",\n\t\tjava: \"java\",\n\t\tkt: \"kotlin\",\n\t\tswift: \"swift\",\n\t\tc: \"c\",\n\t\th: \"c\",\n\t\tcpp: \"cpp\",\n\t\tcc: \"cpp\",\n\t\tcxx: \"cpp\",\n\t\thpp: \"cpp\",\n\t\tcs: \"csharp\",\n\t\tphp: \"php\",\n\t\tsh: \"bash\",\n\t\tbash: \"bash\",\n\t\tzsh: \"bash\",\n\t\tfish: \"fish\",\n\t\tps1: \"powershell\",\n\t\tsql: \"sql\",\n\t\thtml: \"html\",\n\t\thtm: \"html\",\n\t\tcss: \"css\",\n\t\tscss: \"scss\",\n\t\tsass: \"sass\",\n\t\tless: \"less\",\n\t\tjson: \"json\",\n\t\tyaml: \"yaml\",\n\t\tyml: \"yaml\",\n\t\ttoml: \"toml\",\n\t\txml: \"xml\",\n\t\tmd: \"markdown\",\n\t\tmarkdown: \"markdown\",\n\t\tdockerfile: \"dockerfile\",\n\t\tmakefile: \"makefile\",\n\t\tcmake: \"cmake\",\n\t\tlua: \"lua\",\n\t\tperl: \"perl\",\n\t\tr: \"r\",\n\t\tscala: \"scala\",\n\t\tclj: \"clojure\",\n\t\tex: \"elixir\",\n\t\texs: \"elixir\",\n\t\terl: \"erlang\",\n\t\ths: \"haskell\",\n\t\tml: \"ocaml\",\n\t\tvim: \"vim\",\n\t\tgraphql: \"graphql\",\n\t\tproto: \"protobuf\",\n\t\ttf: \"hcl\",\n\t\thcl: \"hcl\",\n\t};\n\n\treturn extToLang[ext];\n}\n\nexport function getMarkdownTheme(): MarkdownTheme {\n\treturn {\n\t\theading: (text: string) => theme.fg(\"mdHeading\", text),\n\t\tlink: (text: string) => theme.fg(\"mdLink\", text),\n\t\tlinkUrl: (text: string) => theme.fg(\"mdLinkUrl\", text),\n\t\tcode: (text: string) => theme.fg(\"mdCode\", text),\n\t\tcodeBlock: (text: string) => theme.fg(\"mdCodeBlock\", text),\n\t\tcodeBlockBorder: (text: string) => theme.fg(\"mdCodeBlockBorder\", text),\n\t\tquote: (text: string) => theme.fg(\"mdQuote\", text),\n\t\tquoteBorder: (text: string) => theme.fg(\"mdQuoteBorder\", text),\n\t\thr: (text: string) => theme.fg(\"mdHr\", text),\n\t\tlistBullet: (text: string) => theme.fg(\"mdListBullet\", text),\n\t\tbold: (text: string) => theme.bold(text),\n\t\titalic: (text: string) => theme.italic(text),\n\t\tunderline: (text: string) => theme.underline(text),\n\t\tstrikethrough: (text: string) => chalk.strikethrough(text),\n\t\thighlightCode: (code: string, lang?: string): string[] => {\n\t\t\t// Validate language before highlighting to avoid stderr spam from cli-highlight\n\t\t\tconst validLang = lang && supportsLanguage(lang) ? lang : undefined;\n\t\t\t// Skip highlighting when no valid language is specified. cli-highlight's\n\t\t\t// auto-detection is unreliable and can misidentify prose as AppleScript,\n\t\t\t// LiveCodeServer, etc., coloring random English words as keywords.\n\t\t\tif (!validLang) {\n\t\t\t\treturn code.split(\"\\n\").map((line) => theme.fg(\"mdCodeBlock\", line));\n\t\t\t}\n\t\t\tconst opts = {\n\t\t\t\tlanguage: validLang,\n\t\t\t\tignoreIllegals: true,\n\t\t\t\ttheme: getCliHighlightTheme(theme),\n\t\t\t};\n\t\t\ttry {\n\t\t\t\treturn highlight(code, opts).split(\"\\n\");\n\t\t\t} catch {\n\t\t\t\treturn code.split(\"\\n\").map((line) => theme.fg(\"mdCodeBlock\", line));\n\t\t\t}\n\t\t},\n\t};\n}\n\nexport function getSelectListTheme(): SelectListTheme {\n\treturn {\n\t\tselectedPrefix: (text: string) => theme.fg(\"accent\", text),\n\t\tselectedText: (text: string) => theme.fg(\"accent\", text),\n\t\tdescription: (text: string) => theme.fg(\"muted\", text),\n\t\tscrollInfo: (text: string) => theme.fg(\"muted\", text),\n\t\tnoMatch: (text: string) => theme.fg(\"muted\", text),\n\t};\n}\n\nexport function getEditorTheme(): EditorTheme {\n\treturn {\n\t\tborderColor: (text: string) => theme.fg(\"borderMuted\", text),\n\t\tselectList: getSelectListTheme(),\n\t};\n}\n\nexport function getSettingsListTheme(): import(\"@kolisachint/hoocode-tui\").SettingsListTheme {\n\treturn {\n\t\tlabel: (text: string, selected: boolean) => (selected ? theme.fg(\"accent\", text) : text),\n\t\tvalue: (text: string, selected: boolean) => (selected ? theme.fg(\"accent\", text) : theme.fg(\"muted\", text)),\n\t\tdescription: (text: string) => theme.fg(\"dim\", text),\n\t\tcursor: theme.fg(\"accent\", \"→ \"),\n\t\thint: (text: string) => theme.fg(\"dim\", text),\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/theme/theme.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport type { EditorTheme, MarkdownTheme, SelectListTheme } from \"@kolisachint/hoocode-tui\";\nimport chalk from \"chalk\";\nimport { highlight, supportsLanguage } from \"cli-highlight\";\nimport { type Static, Type } from \"typebox\";\nimport { Compile } from \"typebox/compile\";\nimport { getCustomThemesDir, getThemesDir } from \"../../../config.js\";\nimport type { SourceInfo } from \"../../../core/source-info.js\";\nimport { closeWatcher, watchWithErrorHandler } from \"../../../utils/fs-watch.js\";\n\n// ============================================================================\n// Types & Schema\n// ============================================================================\n\nconst ColorValueSchema = Type.Union([\n\tType.String(), // hex \"#ff0000\", var ref \"primary\", or empty \"\"\n\tType.Integer({ minimum: 0, maximum: 255 }), // 256-color index\n]);\n\ntype ColorValue = Static<typeof ColorValueSchema>;\n\nconst ThemeJsonSchema = Type.Object({\n\t$schema: Type.Optional(Type.String()),\n\tname: Type.String(),\n\tdescription: Type.Optional(Type.String()),\n\tvars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)),\n\tcolors: Type.Object({\n\t\t// Core UI (10 colors)\n\t\taccent: ColorValueSchema,\n\t\tborder: ColorValueSchema,\n\t\tborderAccent: ColorValueSchema,\n\t\tborderMuted: ColorValueSchema,\n\t\tsuccess: ColorValueSchema,\n\t\terror: ColorValueSchema,\n\t\twarning: ColorValueSchema,\n\t\tmuted: ColorValueSchema,\n\t\tdim: ColorValueSchema,\n\t\ttext: ColorValueSchema,\n\t\tthinkingText: ColorValueSchema,\n\t\t// Backgrounds & Content Text (11 colors)\n\t\tselectedBg: ColorValueSchema,\n\t\tuserMessageBg: ColorValueSchema,\n\t\tuserMessageText: ColorValueSchema,\n\t\tcustomMessageBg: ColorValueSchema,\n\t\tcustomMessageText: ColorValueSchema,\n\t\tcustomMessageLabel: ColorValueSchema,\n\t\ttoolPendingBg: ColorValueSchema,\n\t\ttoolSuccessBg: ColorValueSchema,\n\t\ttoolErrorBg: ColorValueSchema,\n\t\ttoolTitle: ColorValueSchema,\n\t\ttoolOutput: ColorValueSchema,\n\t\t// Markdown (10 colors)\n\t\tmdHeading: ColorValueSchema,\n\t\tmdLink: ColorValueSchema,\n\t\tmdLinkUrl: ColorValueSchema,\n\t\tmdCode: ColorValueSchema,\n\t\tmdCodeBlock: ColorValueSchema,\n\t\tmdCodeBlockBorder: ColorValueSchema,\n\t\tmdQuote: ColorValueSchema,\n\t\tmdQuoteBorder: ColorValueSchema,\n\t\tmdHr: ColorValueSchema,\n\t\tmdListBullet: ColorValueSchema,\n\t\t// Tool Diffs (3 colors)\n\t\ttoolDiffAdded: ColorValueSchema,\n\t\ttoolDiffRemoved: ColorValueSchema,\n\t\ttoolDiffContext: ColorValueSchema,\n\t\t// Syntax Highlighting (9 colors)\n\t\tsyntaxComment: ColorValueSchema,\n\t\tsyntaxKeyword: ColorValueSchema,\n\t\tsyntaxFunction: ColorValueSchema,\n\t\tsyntaxVariable: ColorValueSchema,\n\t\tsyntaxString: ColorValueSchema,\n\t\tsyntaxNumber: ColorValueSchema,\n\t\tsyntaxType: ColorValueSchema,\n\t\tsyntaxOperator: ColorValueSchema,\n\t\tsyntaxPunctuation: ColorValueSchema,\n\t\t// Thinking Level Borders (6 colors)\n\t\tthinkingOff: ColorValueSchema,\n\t\tthinkingMinimal: ColorValueSchema,\n\t\tthinkingLow: ColorValueSchema,\n\t\tthinkingMedium: ColorValueSchema,\n\t\tthinkingHigh: ColorValueSchema,\n\t\tthinkingXhigh: ColorValueSchema,\n\t\t// Bash Mode (1 color)\n\t\tbashMode: ColorValueSchema,\n\t\t// Agent identity palette (6 colors). OPTIONAL so existing custom themes\n\t\t// keep validating: any missing entry falls back to `accent` (see\n\t\t// createTheme). Subagent types hash into this palette so the same agent\n\t\t// reads as the same color across the chat, task panel, and TaskOutput.\n\t\tagent1: Type.Optional(ColorValueSchema),\n\t\tagent2: Type.Optional(ColorValueSchema),\n\t\tagent3: Type.Optional(ColorValueSchema),\n\t\tagent4: Type.Optional(ColorValueSchema),\n\t\tagent5: Type.Optional(ColorValueSchema),\n\t\tagent6: Type.Optional(ColorValueSchema),\n\t\t// Brand chip (2 colors). OPTIONAL, and only honoured as a pair: a theme\n\t\t// that sets both paints the footer's brand mark as a filled chip instead\n\t\t// of accent-coloured text. Light themes need this — a brand hue bright\n\t\t// enough to read as a chip is rarely legible as text on a light canvas.\n\t\tbrandBg: Type.Optional(ColorValueSchema),\n\t\tbrandText: Type.Optional(ColorValueSchema),\n\t}),\n\texport: Type.Optional(\n\t\tType.Object({\n\t\t\tpageBg: Type.Optional(ColorValueSchema),\n\t\t\tcardBg: Type.Optional(ColorValueSchema),\n\t\t\tinfoBg: Type.Optional(ColorValueSchema),\n\t\t}),\n\t),\n});\n\ntype ThemeJson = Static<typeof ThemeJsonSchema>;\n\nconst validateThemeJson = Compile(ThemeJsonSchema);\n\nexport type ThemeColor =\n\t| \"accent\"\n\t| \"border\"\n\t| \"borderAccent\"\n\t| \"borderMuted\"\n\t| \"success\"\n\t| \"error\"\n\t| \"warning\"\n\t| \"muted\"\n\t| \"dim\"\n\t| \"text\"\n\t| \"thinkingText\"\n\t| \"userMessageText\"\n\t| \"customMessageText\"\n\t| \"customMessageLabel\"\n\t| \"toolTitle\"\n\t| \"toolOutput\"\n\t| \"mdHeading\"\n\t| \"mdLink\"\n\t| \"mdLinkUrl\"\n\t| \"mdCode\"\n\t| \"mdCodeBlock\"\n\t| \"mdCodeBlockBorder\"\n\t| \"mdQuote\"\n\t| \"mdQuoteBorder\"\n\t| \"mdHr\"\n\t| \"mdListBullet\"\n\t| \"toolDiffAdded\"\n\t| \"toolDiffRemoved\"\n\t| \"toolDiffContext\"\n\t| \"syntaxComment\"\n\t| \"syntaxKeyword\"\n\t| \"syntaxFunction\"\n\t| \"syntaxVariable\"\n\t| \"syntaxString\"\n\t| \"syntaxNumber\"\n\t| \"syntaxType\"\n\t| \"syntaxOperator\"\n\t| \"syntaxPunctuation\"\n\t| \"thinkingOff\"\n\t| \"thinkingMinimal\"\n\t| \"thinkingLow\"\n\t| \"thinkingMedium\"\n\t| \"thinkingHigh\"\n\t| \"thinkingXhigh\"\n\t| \"bashMode\"\n\t| \"agent1\"\n\t| \"agent2\"\n\t| \"agent3\"\n\t| \"agent4\"\n\t| \"agent5\"\n\t| \"agent6\"\n\t| \"mcp\"\n\t| \"brandText\";\n\n/** The agent identity palette, in hash order. */\nexport const AGENT_COLOR_TOKENS = [\n\t\"agent1\",\n\t\"agent2\",\n\t\"agent3\",\n\t\"agent4\",\n\t\"agent5\",\n\t\"agent6\",\n] as const satisfies readonly ThemeColor[];\n\n/**\n * Stable color token for an agent type (\"explore\", \"plan\", a custom agent…).\n * The name hashes into the agent palette, so the same type renders in the same\n * color everywhere it appears — chat tool line, task panel rows/roster, and\n * TaskOutput — for the whole session and across sessions. Distinct types spread\n * over 6 hues; collisions are possible but the `[name]`/`#N` labels stay the\n * primary identifier, color is the at-a-glance cue.\n */\nexport function agentColorFor(agentType: string): ThemeColor {\n\t// djb2: tiny, stable, good spread for short identifier-like strings.\n\tlet hash = 5381;\n\tfor (let i = 0; i < agentType.length; i++) {\n\t\thash = ((hash << 5) + hash + agentType.charCodeAt(i)) >>> 0;\n\t}\n\treturn AGENT_COLOR_TOKENS[hash % AGENT_COLOR_TOKENS.length] as ThemeColor;\n}\n\nexport type ThemeBg =\n\t| \"selectedBg\"\n\t| \"userMessageBg\"\n\t| \"customMessageBg\"\n\t| \"toolPendingBg\"\n\t| \"toolSuccessBg\"\n\t| \"toolErrorBg\"\n\t| \"brandBg\";\n\ntype ColorMode = \"truecolor\" | \"256color\";\n\n// ============================================================================\n// Color Utilities\n// ============================================================================\n\nfunction detectColorMode(): ColorMode {\n\tconst colorterm = process.env.COLORTERM;\n\tif (colorterm === \"truecolor\" || colorterm === \"24bit\") {\n\t\treturn \"truecolor\";\n\t}\n\t// Windows Terminal supports truecolor\n\tif (process.env.WT_SESSION) {\n\t\treturn \"truecolor\";\n\t}\n\tconst term = process.env.TERM || \"\";\n\t// Fall back to 256color for truly limited terminals\n\tif (term === \"dumb\" || term === \"\" || term === \"linux\") {\n\t\treturn \"256color\";\n\t}\n\t// Terminal.app also doesn't support truecolor\n\tif (process.env.TERM_PROGRAM === \"Apple_Terminal\") {\n\t\treturn \"256color\";\n\t}\n\t// GNU screen doesn't support truecolor unless explicitly opted in via COLORTERM=truecolor.\n\t// TERM under screen is typically \"screen\", \"screen-256color\", or \"screen.xterm-256color\".\n\tif (term === \"screen\" || term.startsWith(\"screen-\") || term.startsWith(\"screen.\")) {\n\t\treturn \"256color\";\n\t}\n\t// Assume truecolor for everything else - virtually all modern terminals support it\n\treturn \"truecolor\";\n}\n\nfunction hexToRgb(hex: string): { r: number; g: number; b: number } {\n\tconst cleaned = hex.replace(\"#\", \"\");\n\tif (cleaned.length !== 6) {\n\t\tthrow new Error(`Invalid hex color: ${hex}`);\n\t}\n\tconst r = parseInt(cleaned.substring(0, 2), 16);\n\tconst g = parseInt(cleaned.substring(2, 4), 16);\n\tconst b = parseInt(cleaned.substring(4, 6), 16);\n\tif (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {\n\t\tthrow new Error(`Invalid hex color: ${hex}`);\n\t}\n\treturn { r, g, b };\n}\n\n// The 6x6x6 color cube channel values (indices 0-5)\nconst CUBE_VALUES = [0, 95, 135, 175, 215, 255];\n\n// Grayscale ramp values (indices 232-255, 24 grays from 8 to 238)\nconst GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10);\n\nfunction findClosestCubeIndex(value: number): number {\n\tlet minDist = Infinity;\n\tlet minIdx = 0;\n\tfor (let i = 0; i < CUBE_VALUES.length; i++) {\n\t\tconst dist = Math.abs(value - CUBE_VALUES[i]);\n\t\tif (dist < minDist) {\n\t\t\tminDist = dist;\n\t\t\tminIdx = i;\n\t\t}\n\t}\n\treturn minIdx;\n}\n\nfunction findClosestGrayIndex(gray: number): number {\n\tlet minDist = Infinity;\n\tlet minIdx = 0;\n\tfor (let i = 0; i < GRAY_VALUES.length; i++) {\n\t\tconst dist = Math.abs(gray - GRAY_VALUES[i]);\n\t\tif (dist < minDist) {\n\t\t\tminDist = dist;\n\t\t\tminIdx = i;\n\t\t}\n\t}\n\treturn minIdx;\n}\n\nfunction colorDistance(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number {\n\t// Weighted Euclidean distance (human eye is more sensitive to green)\n\tconst dr = r1 - r2;\n\tconst dg = g1 - g2;\n\tconst db = b1 - b2;\n\treturn dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114;\n}\n\nfunction rgbTo256(r: number, g: number, b: number): number {\n\t// Find closest color in the 6x6x6 cube\n\tconst rIdx = findClosestCubeIndex(r);\n\tconst gIdx = findClosestCubeIndex(g);\n\tconst bIdx = findClosestCubeIndex(b);\n\tconst cubeR = CUBE_VALUES[rIdx];\n\tconst cubeG = CUBE_VALUES[gIdx];\n\tconst cubeB = CUBE_VALUES[bIdx];\n\tconst cubeIndex = 16 + 36 * rIdx + 6 * gIdx + bIdx;\n\tconst cubeDist = colorDistance(r, g, b, cubeR, cubeG, cubeB);\n\n\t// Find closest grayscale\n\tconst gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b);\n\tconst grayIdx = findClosestGrayIndex(gray);\n\tconst grayValue = GRAY_VALUES[grayIdx];\n\tconst grayIndex = 232 + grayIdx;\n\tconst grayDist = colorDistance(r, g, b, grayValue, grayValue, grayValue);\n\n\t// Check if color has noticeable saturation (hue matters)\n\t// If max-min spread is significant, prefer cube to preserve tint\n\tconst maxC = Math.max(r, g, b);\n\tconst minC = Math.min(r, g, b);\n\tconst spread = maxC - minC;\n\n\t// Only consider grayscale if color is nearly neutral (spread < 10)\n\t// AND grayscale is actually closer\n\tif (spread < 10 && grayDist < cubeDist) {\n\t\treturn grayIndex;\n\t}\n\n\treturn cubeIndex;\n}\n\nfunction hexTo256(hex: string): number {\n\tconst { r, g, b } = hexToRgb(hex);\n\treturn rgbTo256(r, g, b);\n}\n\nfunction fgAnsi(color: string | number, mode: ColorMode): string {\n\tif (color === \"\") return \"\\x1b[39m\";\n\tif (typeof color === \"number\") return `\\x1b[38;5;${color}m`;\n\tif (color.startsWith(\"#\")) {\n\t\tif (mode === \"truecolor\") {\n\t\t\tconst { r, g, b } = hexToRgb(color);\n\t\t\treturn `\\x1b[38;2;${r};${g};${b}m`;\n\t\t} else {\n\t\t\tconst index = hexTo256(color);\n\t\t\treturn `\\x1b[38;5;${index}m`;\n\t\t}\n\t}\n\tthrow new Error(`Invalid color value: ${color}`);\n}\n\nfunction bgAnsi(color: string | number, mode: ColorMode): string {\n\tif (color === \"\") return \"\\x1b[49m\";\n\tif (typeof color === \"number\") return `\\x1b[48;5;${color}m`;\n\tif (color.startsWith(\"#\")) {\n\t\tif (mode === \"truecolor\") {\n\t\t\tconst { r, g, b } = hexToRgb(color);\n\t\t\treturn `\\x1b[48;2;${r};${g};${b}m`;\n\t\t} else {\n\t\t\tconst index = hexTo256(color);\n\t\t\treturn `\\x1b[48;5;${index}m`;\n\t\t}\n\t}\n\tthrow new Error(`Invalid color value: ${color}`);\n}\n\nfunction resolveVarRefs(\n\tvalue: ColorValue,\n\tvars: Record<string, ColorValue>,\n\tvisited = new Set<string>(),\n): string | number {\n\tif (typeof value === \"number\" || value === \"\" || value.startsWith(\"#\")) {\n\t\treturn value;\n\t}\n\tif (visited.has(value)) {\n\t\tthrow new Error(`Circular variable reference detected: ${value}`);\n\t}\n\tif (!(value in vars)) {\n\t\tthrow new Error(`Variable reference not found: ${value}`);\n\t}\n\tvisited.add(value);\n\treturn resolveVarRefs(vars[value], vars, visited);\n}\n\nfunction resolveThemeColors<T extends Record<string, ColorValue>>(\n\tcolors: T,\n\tvars: Record<string, ColorValue> = {},\n): Record<keyof T, string | number> {\n\tconst resolved: Record<string, string | number> = {};\n\tfor (const [key, value] of Object.entries(colors)) {\n\t\tresolved[key] = resolveVarRefs(value, vars);\n\t}\n\treturn resolved as Record<keyof T, string | number>;\n}\n\n// ============================================================================\n// Theme Class\n// ============================================================================\n\nexport class Theme {\n\treadonly name?: string;\n\treadonly sourcePath?: string;\n\tsourceInfo?: SourceInfo;\n\tprivate fgColors: Map<ThemeColor, string>;\n\tprivate bgColors: Map<ThemeBg, string>;\n\tprivate mode: ColorMode;\n\n\tconstructor(\n\t\tfgColors: Record<ThemeColor, string | number>,\n\t\tbgColors: Record<ThemeBg, string | number>,\n\t\tmode: ColorMode,\n\t\toptions: { name?: string; sourcePath?: string; sourceInfo?: SourceInfo } = {},\n\t) {\n\t\tthis.name = options.name;\n\t\tthis.sourcePath = options.sourcePath;\n\t\tthis.sourceInfo = options.sourceInfo;\n\t\tthis.mode = mode;\n\t\tthis.fgColors = new Map();\n\t\tfor (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {\n\t\t\tthis.fgColors.set(key, fgAnsi(value, mode));\n\t\t}\n\t\t// Agent palette fallback for themes built before it existed (including\n\t\t// Theme instances constructed directly by extensions): default to accent.\n\t\tconst accentAnsi = this.fgColors.get(\"accent\");\n\t\tif (accentAnsi !== undefined) {\n\t\t\tfor (const token of AGENT_COLOR_TOKENS) {\n\t\t\t\tif (!this.fgColors.has(token)) {\n\t\t\t\t\tthis.fgColors.set(token, accentAnsi);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tthis.bgColors = new Map();\n\t\tfor (const [key, value] of Object.entries(bgColors) as [ThemeBg, string | number][]) {\n\t\t\tthis.bgColors.set(key, bgAnsi(value, mode));\n\t\t}\n\t}\n\n\tfg(color: ThemeColor, text: string): string {\n\t\tconst ansi = this.fgColors.get(color);\n\t\tif (!ansi) throw new Error(`Unknown theme color: ${color}`);\n\t\treturn `${ansi}${text}\\x1b[39m`; // Reset only foreground color\n\t}\n\n\tbg(color: ThemeBg, text: string): string {\n\t\tconst ansi = this.bgColors.get(color);\n\t\tif (!ansi) throw new Error(`Unknown theme background color: ${color}`);\n\t\treturn `${ansi}${text}\\x1b[49m`; // Reset only background color\n\t}\n\n\t/** Whether the theme defines an optional token, so callers can pick a\n\t * fallback rendering instead of letting fg()/bg() throw. */\n\thas(color: ThemeColor): boolean {\n\t\treturn this.fgColors.has(color);\n\t}\n\n\thasBg(color: ThemeBg): boolean {\n\t\treturn this.bgColors.has(color);\n\t}\n\n\tbold(text: string): string {\n\t\treturn chalk.bold(text);\n\t}\n\n\tblink(text: string): string {\n\t\t// Terminal-native blink (SGR 5). Zero overhead; terminals that support\n\t\t// blink will animate, others render it as a steady glyph.\n\t\treturn `\\x1b[5m${text}\\x1b[25m`;\n\t}\n\n\titalic(text: string): string {\n\t\treturn chalk.italic(text);\n\t}\n\n\tunderline(text: string): string {\n\t\treturn chalk.underline(text);\n\t}\n\n\tinverse(text: string): string {\n\t\treturn chalk.inverse(text);\n\t}\n\n\tstrikethrough(text: string): string {\n\t\treturn chalk.strikethrough(text);\n\t}\n\n\tgetFgAnsi(color: ThemeColor): string {\n\t\tconst ansi = this.fgColors.get(color);\n\t\tif (!ansi) throw new Error(`Unknown theme color: ${color}`);\n\t\treturn ansi;\n\t}\n\n\tgetBgAnsi(color: ThemeBg): string {\n\t\tconst ansi = this.bgColors.get(color);\n\t\tif (!ansi) throw new Error(`Unknown theme background color: ${color}`);\n\t\treturn ansi;\n\t}\n\n\tgetColorMode(): ColorMode {\n\t\treturn this.mode;\n\t}\n\n\tgetThinkingBorderColor(level: \"off\" | \"minimal\" | \"low\" | \"medium\" | \"high\" | \"xhigh\"): (str: string) => string {\n\t\t// Map thinking levels to dedicated theme colors\n\t\tswitch (level) {\n\t\t\tcase \"off\":\n\t\t\t\treturn (str: string) => this.fg(\"thinkingOff\", str);\n\t\t\tcase \"minimal\":\n\t\t\t\treturn (str: string) => this.fg(\"thinkingMinimal\", str);\n\t\t\tcase \"low\":\n\t\t\t\treturn (str: string) => this.fg(\"thinkingLow\", str);\n\t\t\tcase \"medium\":\n\t\t\t\treturn (str: string) => this.fg(\"thinkingMedium\", str);\n\t\t\tcase \"high\":\n\t\t\t\treturn (str: string) => this.fg(\"thinkingHigh\", str);\n\t\t\tcase \"xhigh\":\n\t\t\t\treturn (str: string) => this.fg(\"thinkingXhigh\", str);\n\t\t\tdefault:\n\t\t\t\treturn (str: string) => this.fg(\"thinkingOff\", str);\n\t\t}\n\t}\n\n\tgetBashModeBorderColor(): (str: string) => string {\n\t\treturn (str: string) => this.fg(\"bashMode\", str);\n\t}\n}\n\n// ============================================================================\n// Theme Loading\n// ============================================================================\n\nlet BUILTIN_THEMES: Record<string, ThemeJson> | undefined;\n\n/** Files in the built-in theme directory that are not themes. */\nconst NON_THEME_FILES = new Set([\"theme-schema.json\"]);\n\nfunction getBuiltinThemes(): Record<string, ThemeJson> {\n\tif (!BUILTIN_THEMES) {\n\t\t// Every *.json in the shipped theme directory is a built-in, so adding a\n\t\t// theme is a matter of dropping the file in (both build steps copy the\n\t\t// whole directory). `dark` and `light` are still the detection defaults.\n\t\tconst themesDir = getThemesDir();\n\t\tconst themes: Record<string, ThemeJson> = {};\n\t\tfor (const file of fs.readdirSync(themesDir).sort()) {\n\t\t\tif (!file.endsWith(\".json\") || NON_THEME_FILES.has(file)) continue;\n\t\t\tconst name = file.slice(0, -5);\n\t\t\tthemes[name] = parseThemeJsonContent(file, fs.readFileSync(path.join(themesDir, file), \"utf-8\"));\n\t\t}\n\t\tBUILTIN_THEMES = themes;\n\t}\n\treturn BUILTIN_THEMES;\n}\n\n/** True for themes shipped with the package, which live outside the custom themes dir. */\nfunction isBuiltinTheme(name: string): boolean {\n\treturn name in getBuiltinThemes();\n}\n\nexport function getAvailableThemes(): string[] {\n\tconst themes = new Set<string>(Object.keys(getBuiltinThemes()));\n\tconst customThemesDir = getCustomThemesDir();\n\tif (fs.existsSync(customThemesDir)) {\n\t\tconst files = fs.readdirSync(customThemesDir);\n\t\tfor (const file of files) {\n\t\t\tif (file.endsWith(\".json\")) {\n\t\t\t\tthemes.add(file.slice(0, -5));\n\t\t\t}\n\t\t}\n\t}\n\tfor (const name of registeredThemes.keys()) {\n\t\tthemes.add(name);\n\t}\n\treturn Array.from(themes).sort();\n}\n\n/**\n * One-line description from a theme file, shown next to the name in the theme\n * picker. Undefined for themes that don't declare one, and for themes\n * registered as instances by extensions.\n */\nexport function getThemeDescription(name: string): string | undefined {\n\ttry {\n\t\treturn loadThemeJson(name).description;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nexport interface ThemeInfo {\n\tname: string;\n\tpath: string | undefined;\n}\n\nexport function getAvailableThemesWithPaths(): ThemeInfo[] {\n\tconst themesDir = getThemesDir();\n\tconst customThemesDir = getCustomThemesDir();\n\tconst result: ThemeInfo[] = [];\n\n\t// Built-in themes\n\tfor (const name of Object.keys(getBuiltinThemes())) {\n\t\tresult.push({ name, path: path.join(themesDir, `${name}.json`) });\n\t}\n\n\t// Custom themes\n\tif (fs.existsSync(customThemesDir)) {\n\t\tfor (const file of fs.readdirSync(customThemesDir)) {\n\t\t\tif (file.endsWith(\".json\")) {\n\t\t\t\tconst name = file.slice(0, -5);\n\t\t\t\tif (!result.some((t) => t.name === name)) {\n\t\t\t\t\tresult.push({ name, path: path.join(customThemesDir, file) });\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tfor (const [name, theme] of registeredThemes.entries()) {\n\t\tif (!result.some((t) => t.name === name)) {\n\t\t\tresult.push({ name, path: theme.sourcePath });\n\t\t}\n\t}\n\n\treturn result.sort((a, b) => a.name.localeCompare(b.name));\n}\n\nfunction parseThemeJson(label: string, json: unknown): ThemeJson {\n\tif (!validateThemeJson.Check(json)) {\n\t\tconst errors = Array.from(validateThemeJson.Errors(json));\n\t\tconst missingColors = new Set<string>();\n\t\tconst otherErrors: string[] = [];\n\n\t\tfor (const error of errors) {\n\t\t\tif (error.keyword === \"required\" && error.instancePath === \"/colors\") {\n\t\t\t\tconst requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties;\n\t\t\t\tfor (const requiredProperty of requiredProperties ?? []) {\n\t\t\t\t\tmissingColors.add(requiredProperty);\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst path = error.instancePath || \"/\";\n\t\t\totherErrors.push(` - ${path}: ${error.message}`);\n\t\t}\n\n\t\tlet errorMessage = `Invalid theme \"${label}\":\\n`;\n\t\tif (missingColors.size > 0) {\n\t\t\terrorMessage += \"\\nMissing required color tokens:\\n\";\n\t\t\terrorMessage += Array.from(missingColors)\n\t\t\t\t.sort()\n\t\t\t\t.map((color) => ` - ${color}`)\n\t\t\t\t.join(\"\\n\");\n\t\t\terrorMessage += '\\n\\nPlease add these colors to your theme\\'s \"colors\" object.';\n\t\t\terrorMessage += \"\\nSee the built-in themes (dark.json, light.json) for reference values.\";\n\t\t}\n\t\tif (otherErrors.length > 0) {\n\t\t\terrorMessage += `\\n\\nOther errors:\\n${otherErrors.join(\"\\n\")}`;\n\t\t}\n\n\t\tthrow new Error(errorMessage);\n\t}\n\n\treturn json as ThemeJson;\n}\n\nfunction parseThemeJsonContent(label: string, content: string): ThemeJson {\n\tlet json: unknown;\n\ttry {\n\t\tjson = JSON.parse(content);\n\t} catch (error) {\n\t\tthrow new Error(`Failed to parse theme ${label}: ${error}`);\n\t}\n\treturn parseThemeJson(label, json);\n}\n\nfunction loadThemeJson(name: string): ThemeJson {\n\tconst builtinThemes = getBuiltinThemes();\n\tif (name in builtinThemes) {\n\t\treturn builtinThemes[name];\n\t}\n\tconst registeredTheme = registeredThemes.get(name);\n\tif (registeredTheme?.sourcePath) {\n\t\tconst content = fs.readFileSync(registeredTheme.sourcePath, \"utf-8\");\n\t\treturn parseThemeJsonContent(registeredTheme.sourcePath, content);\n\t}\n\tif (registeredTheme) {\n\t\tthrow new Error(`Theme \"${name}\" does not have a source path for export`);\n\t}\n\tconst customThemesDir = getCustomThemesDir();\n\tconst themePath = path.join(customThemesDir, `${name}.json`);\n\tif (!fs.existsSync(themePath)) {\n\t\tthrow new Error(`Theme not found: ${name}`);\n\t}\n\tconst content = fs.readFileSync(themePath, \"utf-8\");\n\treturn parseThemeJsonContent(name, content);\n}\n\nfunction createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string): Theme {\n\tconst colorMode = mode ?? detectColorMode();\n\tconst resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);\n\tconst fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;\n\tconst bgColors: Record<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;\n\tconst bgColorKeys: Set<string> = new Set([\n\t\t\"selectedBg\",\n\t\t\"userMessageBg\",\n\t\t\"customMessageBg\",\n\t\t\"toolPendingBg\",\n\t\t\"toolSuccessBg\",\n\t\t\"toolErrorBg\",\n\t\t\"brandBg\",\n\t]);\n\tfor (const [key, value] of Object.entries(resolvedColors)) {\n\t\tif (bgColorKeys.has(key)) {\n\t\t\tbgColors[key as ThemeBg] = value;\n\t\t} else {\n\t\t\tfgColors[key as ThemeColor] = value;\n\t\t}\n\t}\n\t// The agent palette is optional in theme JSON (older custom themes predate\n\t// it); missing entries fall back to accent so theme.fg never throws.\n\tfor (const token of AGENT_COLOR_TOKENS) {\n\t\tif (fgColors[token] === undefined) {\n\t\t\tfgColors[token] = fgColors.accent;\n\t\t}\n\t}\n\treturn new Theme(fgColors, bgColors, colorMode, {\n\t\tname: themeJson.name,\n\t\tsourcePath,\n\t});\n}\n\nexport function loadThemeFromPath(themePath: string, mode?: ColorMode): Theme {\n\tconst content = fs.readFileSync(themePath, \"utf-8\");\n\tconst themeJson = parseThemeJsonContent(themePath, content);\n\treturn createTheme(themeJson, mode, themePath);\n}\n\nfunction loadTheme(name: string, mode?: ColorMode): Theme {\n\tconst registeredTheme = registeredThemes.get(name);\n\tif (registeredTheme) {\n\t\treturn registeredTheme;\n\t}\n\tconst themeJson = loadThemeJson(name);\n\treturn createTheme(themeJson, mode);\n}\n\nexport function getThemeByName(name: string): Theme | undefined {\n\ttry {\n\t\treturn loadTheme(name);\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nfunction detectTerminalBackground(): \"dark\" | \"light\" {\n\tconst colorfgbg = process.env.COLORFGBG || \"\";\n\tif (colorfgbg) {\n\t\tconst parts = colorfgbg.split(\";\");\n\t\tif (parts.length >= 2) {\n\t\t\tconst bg = parseInt(parts[1], 10);\n\t\t\tif (!Number.isNaN(bg)) {\n\t\t\t\tconst result = bg < 8 ? \"dark\" : \"light\";\n\t\t\t\treturn result;\n\t\t\t}\n\t\t}\n\t}\n\treturn \"dark\";\n}\n\nfunction getDefaultTheme(): string {\n\treturn detectTerminalBackground();\n}\n\n// ============================================================================\n// Global Theme Instance\n// ============================================================================\n\n// Use globalThis to share theme across module loaders (tsx + jiti in dev mode)\nconst THEME_KEY = Symbol.for(\"@kolisachint/hoocode-agent:theme\");\n\n// Export theme as a getter that reads from globalThis\n// This ensures all module instances (tsx, jiti) see the same theme\nexport const theme: Theme = new Proxy({} as Theme, {\n\tget(_target, prop) {\n\t\tconst t = (globalThis as Record<symbol, Theme>)[THEME_KEY];\n\t\tif (!t) throw new Error(\"Theme not initialized. Call initTheme() first.\");\n\t\treturn (t as unknown as Record<string | symbol, unknown>)[prop];\n\t},\n});\n\nfunction setGlobalTheme(t: Theme): void {\n\t(globalThis as Record<symbol, Theme>)[THEME_KEY] = t;\n}\n\nlet currentThemeName: string | undefined;\nlet themeWatcher: fs.FSWatcher | undefined;\nlet themeReloadTimer: NodeJS.Timeout | undefined;\nlet onThemeChangeCallback: (() => void) | undefined;\nconst registeredThemes = new Map<string, Theme>();\n\nexport function setRegisteredThemes(themes: Theme[]): void {\n\tregisteredThemes.clear();\n\tfor (const theme of themes) {\n\t\tif (theme.name) {\n\t\t\tregisteredThemes.set(theme.name, theme);\n\t\t}\n\t}\n}\n\nexport function initTheme(themeName?: string, enableWatcher: boolean = false): void {\n\tconst name = themeName ?? getDefaultTheme();\n\tcurrentThemeName = name;\n\ttry {\n\t\tsetGlobalTheme(loadTheme(name));\n\t\tif (enableWatcher) {\n\t\t\tstartThemeWatcher();\n\t\t}\n\t} catch (_error) {\n\t\t// Theme is invalid - fall back to dark theme silently\n\t\tcurrentThemeName = \"dark\";\n\t\tsetGlobalTheme(loadTheme(\"dark\"));\n\t\t// Don't start watcher for fallback theme\n\t}\n}\n\nexport function setTheme(name: string, enableWatcher: boolean = false): { success: boolean; error?: string } {\n\tcurrentThemeName = name;\n\ttry {\n\t\tsetGlobalTheme(loadTheme(name));\n\t\tif (enableWatcher) {\n\t\t\tstartThemeWatcher();\n\t\t}\n\t\tif (onThemeChangeCallback) {\n\t\t\tonThemeChangeCallback();\n\t\t}\n\t\treturn { success: true };\n\t} catch (error) {\n\t\t// Theme is invalid - fall back to dark theme\n\t\tcurrentThemeName = \"dark\";\n\t\tsetGlobalTheme(loadTheme(\"dark\"));\n\t\t// Don't start watcher for fallback theme\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: error instanceof Error ? error.message : String(error),\n\t\t};\n\t}\n}\n\nexport function setThemeInstance(themeInstance: Theme): void {\n\tsetGlobalTheme(themeInstance);\n\tcurrentThemeName = \"<in-memory>\";\n\tstopThemeWatcher(); // Can't watch a direct instance\n\tif (onThemeChangeCallback) {\n\t\tonThemeChangeCallback();\n\t}\n}\n\nexport function onThemeChange(callback: () => void): void {\n\tonThemeChangeCallback = callback;\n}\n\nfunction startThemeWatcher(): void {\n\tstopThemeWatcher();\n\n\t// Only watch if it's a custom theme (not built-in)\n\tif (!currentThemeName || isBuiltinTheme(currentThemeName)) {\n\t\treturn;\n\t}\n\n\tconst customThemesDir = getCustomThemesDir();\n\tconst watchedThemeName = currentThemeName;\n\tconst watchedFileName = `${watchedThemeName}.json`;\n\tconst themeFile = path.join(customThemesDir, watchedFileName);\n\n\t// Only watch if the file exists\n\tif (!fs.existsSync(themeFile)) {\n\t\treturn;\n\t}\n\n\tconst scheduleReload = () => {\n\t\tif (themeReloadTimer) {\n\t\t\tclearTimeout(themeReloadTimer);\n\t\t}\n\t\tthemeReloadTimer = setTimeout(() => {\n\t\t\tthemeReloadTimer = undefined;\n\n\t\t\t// Ignore stale timers after switching themes or stopping the watcher\n\t\t\tif (currentThemeName !== watchedThemeName) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Keep the last successfully loaded theme active if the file is temporarily missing\n\t\t\tif (!fs.existsSync(themeFile)) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\t// Reload the theme from disk and refresh the registry cache\n\t\t\t\tconst reloadedTheme = loadThemeFromPath(themeFile);\n\t\t\t\tregisteredThemes.set(watchedThemeName, reloadedTheme);\n\t\t\t\tsetGlobalTheme(reloadedTheme);\n\t\t\t\t// Notify callback (to invalidate UI)\n\t\t\t\tif (onThemeChangeCallback) {\n\t\t\t\t\tonThemeChangeCallback();\n\t\t\t\t}\n\t\t\t} catch (_error) {\n\t\t\t\t// Ignore errors (file might be in invalid state while being edited)\n\t\t\t}\n\t\t}, 100);\n\t};\n\n\tthemeWatcher =\n\t\twatchWithErrorHandler(\n\t\t\tcustomThemesDir,\n\t\t\t(_eventType, filename) => {\n\t\t\t\tif (currentThemeName !== watchedThemeName) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (!filename) {\n\t\t\t\t\tscheduleReload();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (filename !== watchedFileName) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tscheduleReload();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\tcloseWatcher(themeWatcher);\n\t\t\t\tthemeWatcher = undefined;\n\t\t\t},\n\t\t) ?? undefined;\n}\n\nexport function stopThemeWatcher(): void {\n\tif (themeReloadTimer) {\n\t\tclearTimeout(themeReloadTimer);\n\t\tthemeReloadTimer = undefined;\n\t}\n\tcloseWatcher(themeWatcher);\n\tthemeWatcher = undefined;\n}\n\n// ============================================================================\n// HTML Export Helpers\n// ============================================================================\n\n/**\n * Convert a 256-color index to hex string.\n * Indices 0-15: basic colors (approximate)\n * Indices 16-231: 6x6x6 color cube\n * Indices 232-255: grayscale ramp\n */\nfunction ansi256ToHex(index: number): string {\n\t// Basic colors (0-15) - approximate common terminal values\n\tconst basicColors = [\n\t\t\"#000000\",\n\t\t\"#800000\",\n\t\t\"#008000\",\n\t\t\"#808000\",\n\t\t\"#000080\",\n\t\t\"#800080\",\n\t\t\"#008080\",\n\t\t\"#c0c0c0\",\n\t\t\"#808080\",\n\t\t\"#ff0000\",\n\t\t\"#00ff00\",\n\t\t\"#ffff00\",\n\t\t\"#0000ff\",\n\t\t\"#ff00ff\",\n\t\t\"#00ffff\",\n\t\t\"#ffffff\",\n\t];\n\tif (index < 16) {\n\t\treturn basicColors[index];\n\t}\n\n\t// Color cube (16-231): 6x6x6 = 216 colors\n\tif (index < 232) {\n\t\tconst cubeIndex = index - 16;\n\t\tconst r = Math.floor(cubeIndex / 36);\n\t\tconst g = Math.floor((cubeIndex % 36) / 6);\n\t\tconst b = cubeIndex % 6;\n\t\tconst toHex = (n: number) => (n === 0 ? 0 : 55 + n * 40).toString(16).padStart(2, \"0\");\n\t\treturn `#${toHex(r)}${toHex(g)}${toHex(b)}`;\n\t}\n\n\t// Grayscale (232-255): 24 shades\n\tconst gray = 8 + (index - 232) * 10;\n\tconst grayHex = gray.toString(16).padStart(2, \"0\");\n\treturn `#${grayHex}${grayHex}${grayHex}`;\n}\n\n/** WCAG relative luminance (0-1) of a resolved color, or undefined if unparseable. */\nfunction relativeLuminance(color: string | number): number | undefined {\n\tconst hex = typeof color === \"number\" ? ansi256ToHex(color) : color;\n\tif (!hex.startsWith(\"#\")) return undefined;\n\tlet rgb: { r: number; g: number; b: number };\n\ttry {\n\t\trgb = hexToRgb(hex);\n\t} catch {\n\t\treturn undefined;\n\t}\n\tconst toLinear = (c: number) => {\n\t\tconst s = c / 255;\n\t\treturn s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;\n\t};\n\treturn 0.2126 * toLinear(rgb.r) + 0.7152 * toLinear(rgb.g) + 0.0722 * toLinear(rgb.b);\n}\n\n/**\n * Whether a theme sits on a light backdrop, judged by its own colors rather\n * than its name — `light` is no longer the only light-background theme.\n */\nfunction hasLightBackdrop(themeJson: ThemeJson, resolved: Record<string, string | number>): boolean {\n\tconst pageBg = themeJson.export?.pageBg;\n\tconst candidates = [\n\t\tpageBg === undefined ? undefined : resolveVarRefs(pageBg, themeJson.vars ?? {}),\n\t\tresolved.userMessageBg,\n\t];\n\tfor (const candidate of candidates) {\n\t\tif (candidate === undefined || candidate === \"\") continue;\n\t\tconst luminance = relativeLuminance(candidate);\n\t\tif (luminance !== undefined) return luminance > 0.5;\n\t}\n\treturn false;\n}\n\n/**\n * Get resolved theme colors as CSS-compatible hex strings.\n * Used by HTML export to generate CSS custom properties.\n */\nexport function getResolvedThemeColors(themeName?: string): Record<string, string> {\n\tconst name = themeName ?? currentThemeName ?? getDefaultTheme();\n\tconst themeJson = loadThemeJson(name);\n\tconst resolved = resolveThemeColors(themeJson.colors, themeJson.vars);\n\tconst isLight = hasLightBackdrop(themeJson, resolved);\n\n\t// Default text color for empty values (terminal uses default fg color)\n\tconst defaultText = isLight ? \"#000000\" : \"#e5e5e7\";\n\n\tconst cssColors: Record<string, string> = {};\n\tfor (const [key, value] of Object.entries(resolved)) {\n\t\tif (typeof value === \"number\") {\n\t\t\tcssColors[key] = ansi256ToHex(value);\n\t\t} else if (value === \"\") {\n\t\t\t// Empty means default terminal color - use sensible fallback for HTML\n\t\t\tcssColors[key] = defaultText;\n\t\t} else {\n\t\t\tcssColors[key] = value;\n\t\t}\n\t}\n\treturn cssColors;\n}\n\n/**\n * Get explicit export colors from theme JSON, if specified.\n * Returns undefined for each color that isn't explicitly set.\n */\nexport function getThemeExportColors(themeName?: string): {\n\tpageBg?: string;\n\tcardBg?: string;\n\tinfoBg?: string;\n} {\n\tconst name = themeName ?? currentThemeName ?? getDefaultTheme();\n\ttry {\n\t\tconst themeJson = loadThemeJson(name);\n\t\tconst exportSection = themeJson.export;\n\t\tif (!exportSection) return {};\n\n\t\tconst vars = themeJson.vars ?? {};\n\t\tconst resolve = (value: ColorValue | undefined): string | undefined => {\n\t\t\tif (value === undefined) return undefined;\n\t\t\tconst resolved = resolveVarRefs(value, vars);\n\t\t\tif (typeof resolved === \"number\") return ansi256ToHex(resolved);\n\t\t\tif (resolved === \"\") return undefined;\n\t\t\treturn resolved;\n\t\t};\n\n\t\treturn {\n\t\t\tpageBg: resolve(exportSection.pageBg),\n\t\t\tcardBg: resolve(exportSection.cardBg),\n\t\t\tinfoBg: resolve(exportSection.infoBg),\n\t\t};\n\t} catch {\n\t\treturn {};\n\t}\n}\n\n// ============================================================================\n// TUI Helpers\n// ============================================================================\n\ntype CliHighlightTheme = Record<string, (s: string) => string>;\n\nlet cachedHighlightThemeFor: Theme | undefined;\nlet cachedCliHighlightTheme: CliHighlightTheme | undefined;\n\nfunction buildCliHighlightTheme(t: Theme): CliHighlightTheme {\n\treturn {\n\t\tkeyword: (s: string) => t.fg(\"syntaxKeyword\", s),\n\t\tbuilt_in: (s: string) => t.fg(\"syntaxType\", s),\n\t\tliteral: (s: string) => t.fg(\"syntaxNumber\", s),\n\t\tnumber: (s: string) => t.fg(\"syntaxNumber\", s),\n\t\tstring: (s: string) => t.fg(\"syntaxString\", s),\n\t\tcomment: (s: string) => t.fg(\"syntaxComment\", s),\n\t\tfunction: (s: string) => t.fg(\"syntaxFunction\", s),\n\t\ttitle: (s: string) => t.fg(\"syntaxFunction\", s),\n\t\tclass: (s: string) => t.fg(\"syntaxType\", s),\n\t\ttype: (s: string) => t.fg(\"syntaxType\", s),\n\t\tattr: (s: string) => t.fg(\"syntaxVariable\", s),\n\t\tvariable: (s: string) => t.fg(\"syntaxVariable\", s),\n\t\tparams: (s: string) => t.fg(\"syntaxVariable\", s),\n\t\toperator: (s: string) => t.fg(\"syntaxOperator\", s),\n\t\tpunctuation: (s: string) => t.fg(\"syntaxPunctuation\", s),\n\t};\n}\n\nfunction getCliHighlightTheme(t: Theme): CliHighlightTheme {\n\tif (cachedHighlightThemeFor !== t || !cachedCliHighlightTheme) {\n\t\tcachedHighlightThemeFor = t;\n\t\tcachedCliHighlightTheme = buildCliHighlightTheme(t);\n\t}\n\treturn cachedCliHighlightTheme;\n}\n\n/**\n * Highlight code with syntax coloring based on file extension or language.\n * Returns array of highlighted lines.\n */\nexport function highlightCode(code: string, lang?: string): string[] {\n\t// Validate language before highlighting to avoid stderr spam from cli-highlight\n\tconst validLang = lang && supportsLanguage(lang) ? lang : undefined;\n\t// Skip highlighting when no valid language is specified. cli-highlight's\n\t// auto-detection is unreliable and can misidentify prose as AppleScript,\n\t// LiveCodeServer, etc., coloring random English words as keywords.\n\tif (!validLang) {\n\t\treturn code.split(\"\\n\").map((line) => theme.fg(\"mdCodeBlock\", line));\n\t}\n\tconst opts = {\n\t\tlanguage: validLang,\n\t\tignoreIllegals: true,\n\t\ttheme: getCliHighlightTheme(theme),\n\t};\n\ttry {\n\t\treturn highlight(code, opts).split(\"\\n\");\n\t} catch {\n\t\treturn code.split(\"\\n\");\n\t}\n}\n\n/**\n * Get language identifier from file path extension.\n */\nexport function getLanguageFromPath(filePath: string): string | undefined {\n\tconst ext = filePath.split(\".\").pop()?.toLowerCase();\n\tif (!ext) return undefined;\n\n\tconst extToLang: Record<string, string> = {\n\t\tts: \"typescript\",\n\t\ttsx: \"typescript\",\n\t\tjs: \"javascript\",\n\t\tjsx: \"javascript\",\n\t\tmjs: \"javascript\",\n\t\tcjs: \"javascript\",\n\t\tpy: \"python\",\n\t\trb: \"ruby\",\n\t\trs: \"rust\",\n\t\tgo: \"go\",\n\t\tjava: \"java\",\n\t\tkt: \"kotlin\",\n\t\tswift: \"swift\",\n\t\tc: \"c\",\n\t\th: \"c\",\n\t\tcpp: \"cpp\",\n\t\tcc: \"cpp\",\n\t\tcxx: \"cpp\",\n\t\thpp: \"cpp\",\n\t\tcs: \"csharp\",\n\t\tphp: \"php\",\n\t\tsh: \"bash\",\n\t\tbash: \"bash\",\n\t\tzsh: \"bash\",\n\t\tfish: \"fish\",\n\t\tps1: \"powershell\",\n\t\tsql: \"sql\",\n\t\thtml: \"html\",\n\t\thtm: \"html\",\n\t\tcss: \"css\",\n\t\tscss: \"scss\",\n\t\tsass: \"sass\",\n\t\tless: \"less\",\n\t\tjson: \"json\",\n\t\tyaml: \"yaml\",\n\t\tyml: \"yaml\",\n\t\ttoml: \"toml\",\n\t\txml: \"xml\",\n\t\tmd: \"markdown\",\n\t\tmarkdown: \"markdown\",\n\t\tdockerfile: \"dockerfile\",\n\t\tmakefile: \"makefile\",\n\t\tcmake: \"cmake\",\n\t\tlua: \"lua\",\n\t\tperl: \"perl\",\n\t\tr: \"r\",\n\t\tscala: \"scala\",\n\t\tclj: \"clojure\",\n\t\tex: \"elixir\",\n\t\texs: \"elixir\",\n\t\terl: \"erlang\",\n\t\ths: \"haskell\",\n\t\tml: \"ocaml\",\n\t\tvim: \"vim\",\n\t\tgraphql: \"graphql\",\n\t\tproto: \"protobuf\",\n\t\ttf: \"hcl\",\n\t\thcl: \"hcl\",\n\t};\n\n\treturn extToLang[ext];\n}\n\nexport function getMarkdownTheme(): MarkdownTheme {\n\treturn {\n\t\theading: (text: string) => theme.fg(\"mdHeading\", text),\n\t\tlink: (text: string) => theme.fg(\"mdLink\", text),\n\t\tlinkUrl: (text: string) => theme.fg(\"mdLinkUrl\", text),\n\t\tcode: (text: string) => theme.fg(\"mdCode\", text),\n\t\tcodeBlock: (text: string) => theme.fg(\"mdCodeBlock\", text),\n\t\tcodeBlockBorder: (text: string) => theme.fg(\"mdCodeBlockBorder\", text),\n\t\tquote: (text: string) => theme.fg(\"mdQuote\", text),\n\t\tquoteBorder: (text: string) => theme.fg(\"mdQuoteBorder\", text),\n\t\thr: (text: string) => theme.fg(\"mdHr\", text),\n\t\tlistBullet: (text: string) => theme.fg(\"mdListBullet\", text),\n\t\tbold: (text: string) => theme.bold(text),\n\t\titalic: (text: string) => theme.italic(text),\n\t\tunderline: (text: string) => theme.underline(text),\n\t\tstrikethrough: (text: string) => chalk.strikethrough(text),\n\t\thighlightCode: (code: string, lang?: string): string[] => {\n\t\t\t// Validate language before highlighting to avoid stderr spam from cli-highlight\n\t\t\tconst validLang = lang && supportsLanguage(lang) ? lang : undefined;\n\t\t\t// Skip highlighting when no valid language is specified. cli-highlight's\n\t\t\t// auto-detection is unreliable and can misidentify prose as AppleScript,\n\t\t\t// LiveCodeServer, etc., coloring random English words as keywords.\n\t\t\tif (!validLang) {\n\t\t\t\treturn code.split(\"\\n\").map((line) => theme.fg(\"mdCodeBlock\", line));\n\t\t\t}\n\t\t\tconst opts = {\n\t\t\t\tlanguage: validLang,\n\t\t\t\tignoreIllegals: true,\n\t\t\t\ttheme: getCliHighlightTheme(theme),\n\t\t\t};\n\t\t\ttry {\n\t\t\t\treturn highlight(code, opts).split(\"\\n\");\n\t\t\t} catch {\n\t\t\t\treturn code.split(\"\\n\").map((line) => theme.fg(\"mdCodeBlock\", line));\n\t\t\t}\n\t\t},\n\t};\n}\n\nexport function getSelectListTheme(): SelectListTheme {\n\treturn {\n\t\tselectedPrefix: (text: string) => theme.fg(\"accent\", text),\n\t\tselectedText: (text: string) => theme.fg(\"accent\", text),\n\t\tdescription: (text: string) => theme.fg(\"muted\", text),\n\t\tscrollInfo: (text: string) => theme.fg(\"muted\", text),\n\t\tnoMatch: (text: string) => theme.fg(\"muted\", text),\n\t};\n}\n\nexport function getEditorTheme(): EditorTheme {\n\treturn {\n\t\tborderColor: (text: string) => theme.fg(\"borderMuted\", text),\n\t\tselectList: getSelectListTheme(),\n\t};\n}\n\nexport function getSettingsListTheme(): import(\"@kolisachint/hoocode-tui\").SettingsListTheme {\n\treturn {\n\t\tlabel: (text: string, selected: boolean) => (selected ? theme.fg(\"accent\", text) : text),\n\t\tvalue: (text: string, selected: boolean) => (selected ? theme.fg(\"accent\", text) : theme.fg(\"muted\", text)),\n\t\tdescription: (text: string) => theme.fg(\"dim\", text),\n\t\tcursor: theme.fg(\"accent\", \"→ \"),\n\t\thint: (text: string) => theme.fg(\"dim\", text),\n\t};\n}\n"]}
|
|
@@ -87,6 +87,12 @@ const ThemeJsonSchema = Type.Object({
|
|
|
87
87
|
agent4: Type.Optional(ColorValueSchema),
|
|
88
88
|
agent5: Type.Optional(ColorValueSchema),
|
|
89
89
|
agent6: Type.Optional(ColorValueSchema),
|
|
90
|
+
// Brand chip (2 colors). OPTIONAL, and only honoured as a pair: a theme
|
|
91
|
+
// that sets both paints the footer's brand mark as a filled chip instead
|
|
92
|
+
// of accent-coloured text. Light themes need this — a brand hue bright
|
|
93
|
+
// enough to read as a chip is rarely legible as text on a light canvas.
|
|
94
|
+
brandBg: Type.Optional(ColorValueSchema),
|
|
95
|
+
brandText: Type.Optional(ColorValueSchema),
|
|
90
96
|
}),
|
|
91
97
|
export: Type.Optional(Type.Object({
|
|
92
98
|
pageBg: Type.Optional(ColorValueSchema),
|
|
@@ -329,6 +335,14 @@ export class Theme {
|
|
|
329
335
|
throw new Error(`Unknown theme background color: ${color}`);
|
|
330
336
|
return `${ansi}${text}\x1b[49m`; // Reset only background color
|
|
331
337
|
}
|
|
338
|
+
/** Whether the theme defines an optional token, so callers can pick a
|
|
339
|
+
* fallback rendering instead of letting fg()/bg() throw. */
|
|
340
|
+
has(color) {
|
|
341
|
+
return this.fgColors.has(color);
|
|
342
|
+
}
|
|
343
|
+
hasBg(color) {
|
|
344
|
+
return this.bgColors.has(color);
|
|
345
|
+
}
|
|
332
346
|
bold(text) {
|
|
333
347
|
return chalk.bold(text);
|
|
334
348
|
}
|
|
@@ -545,6 +559,7 @@ function createTheme(themeJson, mode, sourcePath) {
|
|
|
545
559
|
"toolPendingBg",
|
|
546
560
|
"toolSuccessBg",
|
|
547
561
|
"toolErrorBg",
|
|
562
|
+
"brandBg",
|
|
548
563
|
]);
|
|
549
564
|
for (const [key, value] of Object.entries(resolvedColors)) {
|
|
550
565
|
if (bgColorKeys.has(key)) {
|