mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 06:04:51 +00:00
WIP: Add theming system with /theme command
- Consolidated theme system into single src/theme/ directory - Created Theme class with fg(), bg(), bold(), italic(), underline() - Added dark and light built-in themes with 36 color tokens - Support for custom themes in ~/.pi/agent/themes/*.json - JSON schema for theme validation - Theme selector UI with /theme command - Save theme preference to settings - Uses chalk for text formatting to preserve colors TODO: - Replace hardcoded colors throughout TUI components - Apply markdown theming to Markdown components - Add theme support to all TUI elements
This commit is contained in:
parent
93a60b7969
commit
cc88095140
13 changed files with 937 additions and 11 deletions
|
|
@ -10,4 +10,5 @@
|
||||||
- Always run `npm run check` in the project's root directory after making code changes.
|
- Always run `npm run check` in the project's root directory after making code changes.
|
||||||
- You must NEVER run `npm run dev` yourself. Doing is means you failed the user hard.
|
- You must NEVER run `npm run dev` yourself. Doing is means you failed the user hard.
|
||||||
- Do NOT commit unless asked to by the user
|
- Do NOT commit unless asked to by the user
|
||||||
- Keep you answers short and concise and to the point.
|
- Keep you answers short and concise and to the point.
|
||||||
|
- Do NOT use inline imports ala `await import("./theme/theme.js");`
|
||||||
|
|
@ -79,7 +79,7 @@ Themes are defined in JSON files with the following structure:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"$schema": "https://pi.mariozechner.at/theme-schema.json",
|
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
|
||||||
"name": "my-theme",
|
"name": "my-theme",
|
||||||
"vars": {
|
"vars": {
|
||||||
"blue": "#0066cc",
|
"blue": "#0066cc",
|
||||||
|
|
@ -194,7 +194,7 @@ Custom themes are loaded from `~/.pi/agent/themes/*.json`.
|
||||||
3. **Define all colors:**
|
3. **Define all colors:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"$schema": "https://pi.mariozechner.at/theme-schema.json",
|
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
|
||||||
"name": "my-theme",
|
"name": "my-theme",
|
||||||
"vars": {
|
"vars": {
|
||||||
"primary": "#00aaff",
|
"primary": "#00aaff",
|
||||||
|
|
@ -370,13 +370,13 @@ Error loading theme 'my-theme':
|
||||||
|
|
||||||
For editor support, the JSON schema is available at:
|
For editor support, the JSON schema is available at:
|
||||||
```
|
```
|
||||||
https://pi.mariozechner.at/theme-schema.json
|
https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Add to your theme file for auto-completion and validation:
|
Add to your theme file for auto-completion and validation:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"$schema": "https://pi.mariozechner.at/theme-schema.json",
|
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"build": "tsgo -p tsconfig.build.json && chmod +x dist/cli.js",
|
"build": "tsgo -p tsconfig.build.json && chmod +x dist/cli.js && npm run copy-theme-assets",
|
||||||
|
"copy-theme-assets": "cp src/theme/*.json dist/theme/",
|
||||||
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
|
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
|
||||||
"check": "tsgo --noEmit",
|
"check": "tsgo --noEmit",
|
||||||
"test": "vitest --run",
|
"test": "vitest --run",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js"
|
||||||
import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js";
|
import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js";
|
||||||
import { SessionManager } from "./session-manager.js";
|
import { SessionManager } from "./session-manager.js";
|
||||||
import { SettingsManager } from "./settings-manager.js";
|
import { SettingsManager } from "./settings-manager.js";
|
||||||
|
import { initTheme } from "./theme/theme.js";
|
||||||
import { codingTools } from "./tools/index.js";
|
import { codingTools } from "./tools/index.js";
|
||||||
import { SessionSelectorComponent } from "./tui/session-selector.js";
|
import { SessionSelectorComponent } from "./tui/session-selector.js";
|
||||||
import { TuiRenderer } from "./tui/tui-renderer.js";
|
import { TuiRenderer } from "./tui/tui-renderer.js";
|
||||||
|
|
@ -563,6 +564,11 @@ export async function main(args: string[]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize theme (before any TUI rendering)
|
||||||
|
const settingsManager = new SettingsManager();
|
||||||
|
const themeName = settingsManager.getTheme();
|
||||||
|
initTheme(themeName);
|
||||||
|
|
||||||
// Setup session manager
|
// Setup session manager
|
||||||
const sessionManager = new SessionManager(parsed.continue && !parsed.resume, parsed.session);
|
const sessionManager = new SessionManager(parsed.continue && !parsed.resume, parsed.session);
|
||||||
|
|
||||||
|
|
@ -582,9 +588,6 @@ export async function main(args: string[]) {
|
||||||
sessionManager.setSessionFile(selectedSession);
|
sessionManager.setSessionFile(selectedSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings manager
|
|
||||||
const settingsManager = new SettingsManager();
|
|
||||||
|
|
||||||
// Determine initial model using priority system:
|
// Determine initial model using priority system:
|
||||||
// 1. CLI args (--provider and --model)
|
// 1. CLI args (--provider and --model)
|
||||||
// 2. Restored from session (if --continue or --resume)
|
// 2. Restored from session (if --continue or --resume)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export interface Settings {
|
||||||
defaultProvider?: string;
|
defaultProvider?: string;
|
||||||
defaultModel?: string;
|
defaultModel?: string;
|
||||||
queueMode?: "all" | "one-at-a-time";
|
queueMode?: "all" | "one-at-a-time";
|
||||||
|
theme?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SettingsManager {
|
export class SettingsManager {
|
||||||
|
|
@ -88,4 +89,13 @@ export class SettingsManager {
|
||||||
this.settings.queueMode = mode;
|
this.settings.queueMode = mode;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getTheme(): string | undefined {
|
||||||
|
return this.settings.theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(theme: string): void {
|
||||||
|
this.settings.theme = theme;
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
59
packages/coding-agent/src/theme/dark.json
Normal file
59
packages/coding-agent/src/theme/dark.json
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
|
||||||
|
"name": "dark",
|
||||||
|
"vars": {
|
||||||
|
"cyan": "#00d7ff",
|
||||||
|
"blue": "#0087ff",
|
||||||
|
"green": "#00ff00",
|
||||||
|
"red": "#ff0000",
|
||||||
|
"yellow": "#ffff00",
|
||||||
|
"gray": 242,
|
||||||
|
"darkGray": 238,
|
||||||
|
"userMsgBg": "#343541",
|
||||||
|
"toolPendingBg": "#282832",
|
||||||
|
"toolSuccessBg": "#283228",
|
||||||
|
"toolErrorBg": "#3c2828"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"accent": "cyan",
|
||||||
|
"border": "blue",
|
||||||
|
"borderAccent": "cyan",
|
||||||
|
"borderMuted": "darkGray",
|
||||||
|
"success": "green",
|
||||||
|
"error": "red",
|
||||||
|
"warning": "yellow",
|
||||||
|
"muted": "gray",
|
||||||
|
"text": "",
|
||||||
|
|
||||||
|
"userMessageBg": "userMsgBg",
|
||||||
|
"userMessageText": "",
|
||||||
|
"toolPendingBg": "toolPendingBg",
|
||||||
|
"toolSuccessBg": "toolSuccessBg",
|
||||||
|
"toolErrorBg": "toolErrorBg",
|
||||||
|
"toolText": "",
|
||||||
|
|
||||||
|
"mdHeading": "cyan",
|
||||||
|
"mdLink": "blue",
|
||||||
|
"mdCode": "cyan",
|
||||||
|
"mdCodeBlock": "",
|
||||||
|
"mdCodeBlockBorder": "gray",
|
||||||
|
"mdQuote": "gray",
|
||||||
|
"mdQuoteBorder": "gray",
|
||||||
|
"mdHr": "gray",
|
||||||
|
"mdListBullet": "cyan",
|
||||||
|
|
||||||
|
"toolDiffAdded": "green",
|
||||||
|
"toolDiffRemoved": "red",
|
||||||
|
"toolDiffContext": "gray",
|
||||||
|
|
||||||
|
"syntaxComment": "gray",
|
||||||
|
"syntaxKeyword": "cyan",
|
||||||
|
"syntaxFunction": "blue",
|
||||||
|
"syntaxVariable": "",
|
||||||
|
"syntaxString": "green",
|
||||||
|
"syntaxNumber": "yellow",
|
||||||
|
"syntaxType": "cyan",
|
||||||
|
"syntaxOperator": "",
|
||||||
|
"syntaxPunctuation": "gray"
|
||||||
|
}
|
||||||
|
}
|
||||||
59
packages/coding-agent/src/theme/light.json
Normal file
59
packages/coding-agent/src/theme/light.json
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
|
||||||
|
"name": "light",
|
||||||
|
"vars": {
|
||||||
|
"darkCyan": "#008899",
|
||||||
|
"darkBlue": "#0066cc",
|
||||||
|
"darkGreen": "#008800",
|
||||||
|
"darkRed": "#cc0000",
|
||||||
|
"darkYellow": "#aa8800",
|
||||||
|
"mediumGray": 242,
|
||||||
|
"lightGray": 250,
|
||||||
|
"userMsgBg": "#e8e8e8",
|
||||||
|
"toolPendingBg": "#e8e8f0",
|
||||||
|
"toolSuccessBg": "#e8f0e8",
|
||||||
|
"toolErrorBg": "#f0e8e8"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"accent": "darkCyan",
|
||||||
|
"border": "darkBlue",
|
||||||
|
"borderAccent": "darkCyan",
|
||||||
|
"borderMuted": "lightGray",
|
||||||
|
"success": "darkGreen",
|
||||||
|
"error": "darkRed",
|
||||||
|
"warning": "darkYellow",
|
||||||
|
"muted": "mediumGray",
|
||||||
|
"text": "",
|
||||||
|
|
||||||
|
"userMessageBg": "userMsgBg",
|
||||||
|
"userMessageText": "",
|
||||||
|
"toolPendingBg": "toolPendingBg",
|
||||||
|
"toolSuccessBg": "toolSuccessBg",
|
||||||
|
"toolErrorBg": "toolErrorBg",
|
||||||
|
"toolText": "",
|
||||||
|
|
||||||
|
"mdHeading": "darkCyan",
|
||||||
|
"mdLink": "darkBlue",
|
||||||
|
"mdCode": "darkCyan",
|
||||||
|
"mdCodeBlock": "",
|
||||||
|
"mdCodeBlockBorder": "mediumGray",
|
||||||
|
"mdQuote": "mediumGray",
|
||||||
|
"mdQuoteBorder": "mediumGray",
|
||||||
|
"mdHr": "mediumGray",
|
||||||
|
"mdListBullet": "darkCyan",
|
||||||
|
|
||||||
|
"toolDiffAdded": "darkGreen",
|
||||||
|
"toolDiffRemoved": "darkRed",
|
||||||
|
"toolDiffContext": "mediumGray",
|
||||||
|
|
||||||
|
"syntaxComment": "mediumGray",
|
||||||
|
"syntaxKeyword": "darkCyan",
|
||||||
|
"syntaxFunction": "darkBlue",
|
||||||
|
"syntaxVariable": "",
|
||||||
|
"syntaxString": "darkGreen",
|
||||||
|
"syntaxNumber": "darkYellow",
|
||||||
|
"syntaxType": "darkCyan",
|
||||||
|
"syntaxOperator": "",
|
||||||
|
"syntaxPunctuation": "mediumGray"
|
||||||
|
}
|
||||||
|
}
|
||||||
241
packages/coding-agent/src/theme/theme-schema.json
Normal file
241
packages/coding-agent/src/theme/theme-schema.json
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "Pi Coding Agent Theme",
|
||||||
|
"description": "Theme schema for Pi coding agent",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name", "colors"],
|
||||||
|
"properties": {
|
||||||
|
"$schema": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "JSON schema reference"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Theme name"
|
||||||
|
},
|
||||||
|
"vars": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Reusable color variables",
|
||||||
|
"additionalProperties": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Hex color (#RRGGBB), variable reference, or empty string for terminal default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 255,
|
||||||
|
"description": "256-color palette index (0-255)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Theme color definitions (all required)",
|
||||||
|
"required": [
|
||||||
|
"accent",
|
||||||
|
"border",
|
||||||
|
"borderAccent",
|
||||||
|
"borderMuted",
|
||||||
|
"success",
|
||||||
|
"error",
|
||||||
|
"warning",
|
||||||
|
"muted",
|
||||||
|
"text",
|
||||||
|
"userMessageBg",
|
||||||
|
"userMessageText",
|
||||||
|
"toolPendingBg",
|
||||||
|
"toolSuccessBg",
|
||||||
|
"toolErrorBg",
|
||||||
|
"toolText",
|
||||||
|
"mdHeading",
|
||||||
|
"mdLink",
|
||||||
|
"mdCode",
|
||||||
|
"mdCodeBlock",
|
||||||
|
"mdCodeBlockBorder",
|
||||||
|
"mdQuote",
|
||||||
|
"mdQuoteBorder",
|
||||||
|
"mdHr",
|
||||||
|
"mdListBullet",
|
||||||
|
"toolDiffAdded",
|
||||||
|
"toolDiffRemoved",
|
||||||
|
"toolDiffContext",
|
||||||
|
"syntaxComment",
|
||||||
|
"syntaxKeyword",
|
||||||
|
"syntaxFunction",
|
||||||
|
"syntaxVariable",
|
||||||
|
"syntaxString",
|
||||||
|
"syntaxNumber",
|
||||||
|
"syntaxType",
|
||||||
|
"syntaxOperator",
|
||||||
|
"syntaxPunctuation"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"accent": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Primary accent color (logo, selected items, cursor)"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Normal borders"
|
||||||
|
},
|
||||||
|
"borderAccent": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Highlighted borders"
|
||||||
|
},
|
||||||
|
"borderMuted": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Subtle borders"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Success states"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Error states"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Warning states"
|
||||||
|
},
|
||||||
|
"muted": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Secondary/dimmed text"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Default text color (usually empty string)"
|
||||||
|
},
|
||||||
|
"userMessageBg": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "User message background"
|
||||||
|
},
|
||||||
|
"userMessageText": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "User message text color"
|
||||||
|
},
|
||||||
|
"toolPendingBg": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Tool execution box (pending state)"
|
||||||
|
},
|
||||||
|
"toolSuccessBg": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Tool execution box (success state)"
|
||||||
|
},
|
||||||
|
"toolErrorBg": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Tool execution box (error state)"
|
||||||
|
},
|
||||||
|
"toolText": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Tool execution box text color"
|
||||||
|
},
|
||||||
|
"mdHeading": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Markdown heading text"
|
||||||
|
},
|
||||||
|
"mdLink": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Markdown link text"
|
||||||
|
},
|
||||||
|
"mdCode": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Markdown inline code"
|
||||||
|
},
|
||||||
|
"mdCodeBlock": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Markdown code block content"
|
||||||
|
},
|
||||||
|
"mdCodeBlockBorder": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Markdown code block fences"
|
||||||
|
},
|
||||||
|
"mdQuote": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Markdown blockquote text"
|
||||||
|
},
|
||||||
|
"mdQuoteBorder": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Markdown blockquote border"
|
||||||
|
},
|
||||||
|
"mdHr": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Markdown horizontal rule"
|
||||||
|
},
|
||||||
|
"mdListBullet": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Markdown list bullets/numbers"
|
||||||
|
},
|
||||||
|
"toolDiffAdded": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Added lines in tool diffs"
|
||||||
|
},
|
||||||
|
"toolDiffRemoved": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Removed lines in tool diffs"
|
||||||
|
},
|
||||||
|
"toolDiffContext": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Context lines in tool diffs"
|
||||||
|
},
|
||||||
|
"syntaxComment": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Syntax highlighting: comments"
|
||||||
|
},
|
||||||
|
"syntaxKeyword": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Syntax highlighting: keywords"
|
||||||
|
},
|
||||||
|
"syntaxFunction": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Syntax highlighting: function names"
|
||||||
|
},
|
||||||
|
"syntaxVariable": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Syntax highlighting: variable names"
|
||||||
|
},
|
||||||
|
"syntaxString": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Syntax highlighting: string literals"
|
||||||
|
},
|
||||||
|
"syntaxNumber": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Syntax highlighting: number literals"
|
||||||
|
},
|
||||||
|
"syntaxType": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Syntax highlighting: type names"
|
||||||
|
},
|
||||||
|
"syntaxOperator": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Syntax highlighting: operators"
|
||||||
|
},
|
||||||
|
"syntaxPunctuation": {
|
||||||
|
"$ref": "#/$defs/colorValue",
|
||||||
|
"description": "Syntax highlighting: punctuation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"$defs": {
|
||||||
|
"colorValue": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Hex color (#RRGGBB), variable reference, or empty string for terminal default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 255,
|
||||||
|
"description": "256-color palette index (0-255)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
414
packages/coding-agent/src/theme/theme.ts
Normal file
414
packages/coding-agent/src/theme/theme.ts
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { MarkdownTheme } from "@mariozechner/pi-tui";
|
||||||
|
import { type Static, Type } from "@sinclair/typebox";
|
||||||
|
import { TypeCompiler } from "@sinclair/typebox/compiler";
|
||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types & Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const ColorValueSchema = Type.Union([
|
||||||
|
Type.String(), // hex "#ff0000", var ref "primary", or empty ""
|
||||||
|
Type.Integer({ minimum: 0, maximum: 255 }), // 256-color index
|
||||||
|
]);
|
||||||
|
|
||||||
|
type ColorValue = Static<typeof ColorValueSchema>;
|
||||||
|
|
||||||
|
const ThemeJsonSchema = Type.Object({
|
||||||
|
$schema: Type.Optional(Type.String()),
|
||||||
|
name: Type.String(),
|
||||||
|
vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)),
|
||||||
|
colors: Type.Object({
|
||||||
|
// Core UI (9 colors)
|
||||||
|
accent: ColorValueSchema,
|
||||||
|
border: ColorValueSchema,
|
||||||
|
borderAccent: ColorValueSchema,
|
||||||
|
borderMuted: ColorValueSchema,
|
||||||
|
success: ColorValueSchema,
|
||||||
|
error: ColorValueSchema,
|
||||||
|
warning: ColorValueSchema,
|
||||||
|
muted: ColorValueSchema,
|
||||||
|
text: ColorValueSchema,
|
||||||
|
// Backgrounds & Content Text (6 colors)
|
||||||
|
userMessageBg: ColorValueSchema,
|
||||||
|
userMessageText: ColorValueSchema,
|
||||||
|
toolPendingBg: ColorValueSchema,
|
||||||
|
toolSuccessBg: ColorValueSchema,
|
||||||
|
toolErrorBg: ColorValueSchema,
|
||||||
|
toolText: ColorValueSchema,
|
||||||
|
// Markdown (9 colors)
|
||||||
|
mdHeading: ColorValueSchema,
|
||||||
|
mdLink: ColorValueSchema,
|
||||||
|
mdCode: ColorValueSchema,
|
||||||
|
mdCodeBlock: ColorValueSchema,
|
||||||
|
mdCodeBlockBorder: ColorValueSchema,
|
||||||
|
mdQuote: ColorValueSchema,
|
||||||
|
mdQuoteBorder: ColorValueSchema,
|
||||||
|
mdHr: ColorValueSchema,
|
||||||
|
mdListBullet: ColorValueSchema,
|
||||||
|
// Tool Diffs (3 colors)
|
||||||
|
toolDiffAdded: ColorValueSchema,
|
||||||
|
toolDiffRemoved: ColorValueSchema,
|
||||||
|
toolDiffContext: ColorValueSchema,
|
||||||
|
// Syntax Highlighting (9 colors)
|
||||||
|
syntaxComment: ColorValueSchema,
|
||||||
|
syntaxKeyword: ColorValueSchema,
|
||||||
|
syntaxFunction: ColorValueSchema,
|
||||||
|
syntaxVariable: ColorValueSchema,
|
||||||
|
syntaxString: ColorValueSchema,
|
||||||
|
syntaxNumber: ColorValueSchema,
|
||||||
|
syntaxType: ColorValueSchema,
|
||||||
|
syntaxOperator: ColorValueSchema,
|
||||||
|
syntaxPunctuation: ColorValueSchema,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ThemeJson = Static<typeof ThemeJsonSchema>;
|
||||||
|
|
||||||
|
const validateThemeJson = TypeCompiler.Compile(ThemeJsonSchema);
|
||||||
|
|
||||||
|
export type ThemeColor =
|
||||||
|
| "accent"
|
||||||
|
| "border"
|
||||||
|
| "borderAccent"
|
||||||
|
| "borderMuted"
|
||||||
|
| "success"
|
||||||
|
| "error"
|
||||||
|
| "warning"
|
||||||
|
| "muted"
|
||||||
|
| "text"
|
||||||
|
| "userMessageText"
|
||||||
|
| "toolText"
|
||||||
|
| "mdHeading"
|
||||||
|
| "mdLink"
|
||||||
|
| "mdCode"
|
||||||
|
| "mdCodeBlock"
|
||||||
|
| "mdCodeBlockBorder"
|
||||||
|
| "mdQuote"
|
||||||
|
| "mdQuoteBorder"
|
||||||
|
| "mdHr"
|
||||||
|
| "mdListBullet"
|
||||||
|
| "toolDiffAdded"
|
||||||
|
| "toolDiffRemoved"
|
||||||
|
| "toolDiffContext"
|
||||||
|
| "syntaxComment"
|
||||||
|
| "syntaxKeyword"
|
||||||
|
| "syntaxFunction"
|
||||||
|
| "syntaxVariable"
|
||||||
|
| "syntaxString"
|
||||||
|
| "syntaxNumber"
|
||||||
|
| "syntaxType"
|
||||||
|
| "syntaxOperator"
|
||||||
|
| "syntaxPunctuation";
|
||||||
|
|
||||||
|
export type ThemeBg = "userMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
|
||||||
|
|
||||||
|
type ColorMode = "truecolor" | "256color";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Color Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function detectColorMode(): ColorMode {
|
||||||
|
const colorterm = process.env.COLORTERM;
|
||||||
|
if (colorterm === "truecolor" || colorterm === "24bit") {
|
||||||
|
return "truecolor";
|
||||||
|
}
|
||||||
|
const term = process.env.TERM || "";
|
||||||
|
if (term.includes("256color")) {
|
||||||
|
return "256color";
|
||||||
|
}
|
||||||
|
return "256color";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||||
|
const cleaned = hex.replace("#", "");
|
||||||
|
if (cleaned.length !== 6) {
|
||||||
|
throw new Error(`Invalid hex color: ${hex}`);
|
||||||
|
}
|
||||||
|
const r = parseInt(cleaned.substring(0, 2), 16);
|
||||||
|
const g = parseInt(cleaned.substring(2, 4), 16);
|
||||||
|
const b = parseInt(cleaned.substring(4, 6), 16);
|
||||||
|
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
|
||||||
|
throw new Error(`Invalid hex color: ${hex}`);
|
||||||
|
}
|
||||||
|
return { r, g, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbTo256(r: number, g: number, b: number): number {
|
||||||
|
const rIndex = Math.round((r / 255) * 5);
|
||||||
|
const gIndex = Math.round((g / 255) * 5);
|
||||||
|
const bIndex = Math.round((b / 255) * 5);
|
||||||
|
return 16 + 36 * rIndex + 6 * gIndex + bIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexTo256(hex: string): number {
|
||||||
|
const { r, g, b } = hexToRgb(hex);
|
||||||
|
return rgbTo256(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fgAnsi(color: string | number, mode: ColorMode): string {
|
||||||
|
if (color === "") return "\x1b[39m";
|
||||||
|
if (typeof color === "number") return `\x1b[38;5;${color}m`;
|
||||||
|
if (color.startsWith("#")) {
|
||||||
|
if (mode === "truecolor") {
|
||||||
|
const { r, g, b } = hexToRgb(color);
|
||||||
|
return `\x1b[38;2;${r};${g};${b}m`;
|
||||||
|
} else {
|
||||||
|
const index = hexTo256(color);
|
||||||
|
return `\x1b[38;5;${index}m`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Invalid color value: ${color}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bgAnsi(color: string | number, mode: ColorMode): string {
|
||||||
|
if (color === "") return "\x1b[49m";
|
||||||
|
if (typeof color === "number") return `\x1b[48;5;${color}m`;
|
||||||
|
if (color.startsWith("#")) {
|
||||||
|
if (mode === "truecolor") {
|
||||||
|
const { r, g, b } = hexToRgb(color);
|
||||||
|
return `\x1b[48;2;${r};${g};${b}m`;
|
||||||
|
} else {
|
||||||
|
const index = hexTo256(color);
|
||||||
|
return `\x1b[48;5;${index}m`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Invalid color value: ${color}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVarRefs(
|
||||||
|
value: ColorValue,
|
||||||
|
vars: Record<string, ColorValue>,
|
||||||
|
visited = new Set<string>(),
|
||||||
|
): string | number {
|
||||||
|
if (typeof value === "number" || value === "" || value.startsWith("#")) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (visited.has(value)) {
|
||||||
|
throw new Error(`Circular variable reference detected: ${value}`);
|
||||||
|
}
|
||||||
|
if (!(value in vars)) {
|
||||||
|
throw new Error(`Variable reference not found: ${value}`);
|
||||||
|
}
|
||||||
|
visited.add(value);
|
||||||
|
return resolveVarRefs(vars[value], vars, visited);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveThemeColors<T extends Record<string, ColorValue>>(
|
||||||
|
colors: T,
|
||||||
|
vars: Record<string, ColorValue> = {},
|
||||||
|
): Record<keyof T, string | number> {
|
||||||
|
const resolved: Record<string, string | number> = {};
|
||||||
|
for (const [key, value] of Object.entries(colors)) {
|
||||||
|
resolved[key] = resolveVarRefs(value, vars);
|
||||||
|
}
|
||||||
|
return resolved as Record<keyof T, string | number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Theme Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const RESET = "\x1b[0m";
|
||||||
|
|
||||||
|
export class Theme {
|
||||||
|
private fgColors: Map<ThemeColor, string>;
|
||||||
|
private bgColors: Map<ThemeBg, string>;
|
||||||
|
private mode: ColorMode;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
fgColors: Record<ThemeColor, string | number>,
|
||||||
|
bgColors: Record<ThemeBg, string | number>,
|
||||||
|
mode: ColorMode,
|
||||||
|
) {
|
||||||
|
this.mode = mode;
|
||||||
|
this.fgColors = new Map();
|
||||||
|
for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
|
||||||
|
this.fgColors.set(key, fgAnsi(value, mode));
|
||||||
|
}
|
||||||
|
this.bgColors = new Map();
|
||||||
|
for (const [key, value] of Object.entries(bgColors) as [ThemeBg, string | number][]) {
|
||||||
|
this.bgColors.set(key, bgAnsi(value, mode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fg(color: ThemeColor, text: string): string {
|
||||||
|
const ansi = this.fgColors.get(color);
|
||||||
|
if (!ansi) throw new Error(`Unknown theme color: ${color}`);
|
||||||
|
return `${ansi}${text}${RESET}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
bg(color: ThemeBg, text: string): string {
|
||||||
|
const ansi = this.bgColors.get(color);
|
||||||
|
if (!ansi) throw new Error(`Unknown theme background color: ${color}`);
|
||||||
|
return `${ansi}${text}${RESET}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
bold(text: string): string {
|
||||||
|
return chalk.bold(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
italic(text: string): string {
|
||||||
|
return chalk.italic(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
underline(text: string): string {
|
||||||
|
return chalk.underline(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFgAnsi(color: ThemeColor): string {
|
||||||
|
const ansi = this.fgColors.get(color);
|
||||||
|
if (!ansi) throw new Error(`Unknown theme color: ${color}`);
|
||||||
|
return ansi;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBgAnsi(color: ThemeBg): string {
|
||||||
|
const ansi = this.bgColors.get(color);
|
||||||
|
if (!ansi) throw new Error(`Unknown theme background color: ${color}`);
|
||||||
|
return ansi;
|
||||||
|
}
|
||||||
|
|
||||||
|
getColorMode(): ColorMode {
|
||||||
|
return this.mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Theme Loading
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let BUILTIN_THEMES: Record<string, ThemeJson> | undefined;
|
||||||
|
|
||||||
|
function getBuiltinThemes(): Record<string, ThemeJson> {
|
||||||
|
if (!BUILTIN_THEMES) {
|
||||||
|
const darkPath = path.join(__dirname, "dark.json");
|
||||||
|
const lightPath = path.join(__dirname, "light.json");
|
||||||
|
BUILTIN_THEMES = {
|
||||||
|
dark: JSON.parse(fs.readFileSync(darkPath, "utf-8")) as ThemeJson,
|
||||||
|
light: JSON.parse(fs.readFileSync(lightPath, "utf-8")) as ThemeJson,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return BUILTIN_THEMES;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThemesDir(): string {
|
||||||
|
return path.join(os.homedir(), ".pi", "agent", "themes");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableThemes(): string[] {
|
||||||
|
const themes = new Set<string>(Object.keys(getBuiltinThemes()));
|
||||||
|
const themesDir = getThemesDir();
|
||||||
|
if (fs.existsSync(themesDir)) {
|
||||||
|
const files = fs.readdirSync(themesDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith(".json")) {
|
||||||
|
themes.add(file.slice(0, -5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(themes).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadThemeJson(name: string): ThemeJson {
|
||||||
|
const builtinThemes = getBuiltinThemes();
|
||||||
|
if (name in builtinThemes) {
|
||||||
|
return builtinThemes[name];
|
||||||
|
}
|
||||||
|
const themesDir = getThemesDir();
|
||||||
|
const themePath = path.join(themesDir, `${name}.json`);
|
||||||
|
if (!fs.existsSync(themePath)) {
|
||||||
|
throw new Error(`Theme not found: ${name}`);
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(themePath, "utf-8");
|
||||||
|
let json: unknown;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(content);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to parse theme ${name}: ${error}`);
|
||||||
|
}
|
||||||
|
if (!validateThemeJson.Check(json)) {
|
||||||
|
const errors = Array.from(validateThemeJson.Errors(json));
|
||||||
|
const errorMessages = errors.map((e) => ` - ${e.path}: ${e.message}`).join("\n");
|
||||||
|
throw new Error(`Invalid theme ${name}:\n${errorMessages}`);
|
||||||
|
}
|
||||||
|
return json as ThemeJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme {
|
||||||
|
const colorMode = mode ?? detectColorMode();
|
||||||
|
const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
|
||||||
|
const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
|
||||||
|
const bgColors: Record<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;
|
||||||
|
const bgColorKeys: Set<string> = new Set(["userMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg"]);
|
||||||
|
for (const [key, value] of Object.entries(resolvedColors)) {
|
||||||
|
if (bgColorKeys.has(key)) {
|
||||||
|
bgColors[key as ThemeBg] = value;
|
||||||
|
} else {
|
||||||
|
fgColors[key as ThemeColor] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Theme(fgColors, bgColors, colorMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTheme(name: string, mode?: ColorMode): Theme {
|
||||||
|
const themeJson = loadThemeJson(name);
|
||||||
|
return createTheme(themeJson, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectTerminalBackground(): "dark" | "light" {
|
||||||
|
const colorfgbg = process.env.COLORFGBG || "";
|
||||||
|
if (colorfgbg) {
|
||||||
|
const parts = colorfgbg.split(";");
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const bg = parseInt(parts[1], 10);
|
||||||
|
if (!Number.isNaN(bg)) {
|
||||||
|
return bg < 8 ? "dark" : "light";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultTheme(): string {
|
||||||
|
return detectTerminalBackground();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Global Theme Instance
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export let theme: Theme;
|
||||||
|
|
||||||
|
export function initTheme(themeName?: string): void {
|
||||||
|
const name = themeName ?? getDefaultTheme();
|
||||||
|
theme = loadTheme(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTheme(name: string): void {
|
||||||
|
theme = loadTheme(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TUI Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function getMarkdownTheme(): MarkdownTheme {
|
||||||
|
return {
|
||||||
|
heading: (text: string) => theme.fg("mdHeading", text),
|
||||||
|
link: (text: string) => theme.fg("mdLink", text),
|
||||||
|
code: (text: string) => theme.fg("mdCode", text),
|
||||||
|
codeBlock: (text: string) => theme.fg("mdCodeBlock", text),
|
||||||
|
codeBlockBorder: (text: string) => theme.fg("mdCodeBlockBorder", text),
|
||||||
|
quote: (text: string) => theme.fg("mdQuote", text),
|
||||||
|
quoteBorder: (text: string) => theme.fg("mdQuoteBorder", text),
|
||||||
|
hr: (text: string) => theme.fg("mdHr", text),
|
||||||
|
listBullet: (text: string) => theme.fg("mdListBullet", text),
|
||||||
|
};
|
||||||
|
}
|
||||||
51
packages/coding-agent/src/tui/theme-selector.ts
Normal file
51
packages/coding-agent/src/tui/theme-selector.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui";
|
||||||
|
import { getAvailableThemes, theme } from "../theme/theme.js";
|
||||||
|
import { DynamicBorder } from "./dynamic-border.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that renders a theme selector
|
||||||
|
*/
|
||||||
|
export class ThemeSelectorComponent extends Container {
|
||||||
|
private selectList: SelectList;
|
||||||
|
|
||||||
|
constructor(currentTheme: string, onSelect: (themeName: string) => void, onCancel: () => void) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
// Get available themes and create select items
|
||||||
|
const themes = getAvailableThemes();
|
||||||
|
const themeItems: SelectItem[] = themes.map((name) => ({
|
||||||
|
value: name,
|
||||||
|
label: name,
|
||||||
|
description: name === currentTheme ? "(current)" : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add top border
|
||||||
|
this.addChild(new DynamicBorder((text) => theme.fg("border", text)));
|
||||||
|
|
||||||
|
// Create selector
|
||||||
|
this.selectList = new SelectList(themeItems, 10);
|
||||||
|
|
||||||
|
// Preselect current theme
|
||||||
|
const currentIndex = themes.indexOf(currentTheme);
|
||||||
|
if (currentIndex !== -1) {
|
||||||
|
this.selectList.setSelectedIndex(currentIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectList.onSelect = (item) => {
|
||||||
|
onSelect(item.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.selectList.onCancel = () => {
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.addChild(this.selectList);
|
||||||
|
|
||||||
|
// Add bottom border
|
||||||
|
this.addChild(new DynamicBorder((text) => theme.fg("border", text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectList(): SelectList {
|
||||||
|
return this.selectList;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ import { getApiKeyForModel, getAvailableModels } from "../model-config.js";
|
||||||
import { listOAuthProviders, login, logout } from "../oauth/index.js";
|
import { listOAuthProviders, login, logout } from "../oauth/index.js";
|
||||||
import type { SessionManager } from "../session-manager.js";
|
import type { SessionManager } from "../session-manager.js";
|
||||||
import type { SettingsManager } from "../settings-manager.js";
|
import type { SettingsManager } from "../settings-manager.js";
|
||||||
|
import { setTheme } from "../theme/theme.js";
|
||||||
import { AssistantMessageComponent } from "./assistant-message.js";
|
import { AssistantMessageComponent } from "./assistant-message.js";
|
||||||
import { CustomEditor } from "./custom-editor.js";
|
import { CustomEditor } from "./custom-editor.js";
|
||||||
import { DynamicBorder } from "./dynamic-border.js";
|
import { DynamicBorder } from "./dynamic-border.js";
|
||||||
|
|
@ -28,6 +29,7 @@ import { FooterComponent } from "./footer.js";
|
||||||
import { ModelSelectorComponent } from "./model-selector.js";
|
import { ModelSelectorComponent } from "./model-selector.js";
|
||||||
import { OAuthSelectorComponent } from "./oauth-selector.js";
|
import { OAuthSelectorComponent } from "./oauth-selector.js";
|
||||||
import { QueueModeSelectorComponent } from "./queue-mode-selector.js";
|
import { QueueModeSelectorComponent } from "./queue-mode-selector.js";
|
||||||
|
import { ThemeSelectorComponent } from "./theme-selector.js";
|
||||||
import { ThinkingSelectorComponent } from "./thinking-selector.js";
|
import { ThinkingSelectorComponent } from "./thinking-selector.js";
|
||||||
import { ToolExecutionComponent } from "./tool-execution.js";
|
import { ToolExecutionComponent } from "./tool-execution.js";
|
||||||
import { UserMessageComponent } from "./user-message.js";
|
import { UserMessageComponent } from "./user-message.js";
|
||||||
|
|
@ -71,6 +73,9 @@ export class TuiRenderer {
|
||||||
// Queue mode selector
|
// Queue mode selector
|
||||||
private queueModeSelector: QueueModeSelectorComponent | null = null;
|
private queueModeSelector: QueueModeSelectorComponent | null = null;
|
||||||
|
|
||||||
|
// Theme selector
|
||||||
|
private themeSelector: ThemeSelectorComponent | null = null;
|
||||||
|
|
||||||
// Model selector
|
// Model selector
|
||||||
private modelSelector: ModelSelectorComponent | null = null;
|
private modelSelector: ModelSelectorComponent | null = null;
|
||||||
|
|
||||||
|
|
@ -160,11 +165,17 @@ export class TuiRenderer {
|
||||||
description: "Select message queue mode (opens selector UI)",
|
description: "Select message queue mode (opens selector UI)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const themeCommand: SlashCommand = {
|
||||||
|
name: "theme",
|
||||||
|
description: "Select color theme (opens selector UI)",
|
||||||
|
};
|
||||||
|
|
||||||
// Setup autocomplete for file paths and slash commands
|
// Setup autocomplete for file paths and slash commands
|
||||||
const autocompleteProvider = new CombinedAutocompleteProvider(
|
const autocompleteProvider = new CombinedAutocompleteProvider(
|
||||||
[
|
[
|
||||||
thinkingCommand,
|
thinkingCommand,
|
||||||
modelCommand,
|
modelCommand,
|
||||||
|
themeCommand,
|
||||||
exportCommand,
|
exportCommand,
|
||||||
sessionCommand,
|
sessionCommand,
|
||||||
changelogCommand,
|
changelogCommand,
|
||||||
|
|
@ -365,6 +376,13 @@ export class TuiRenderer {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for /theme command
|
||||||
|
if (text === "/theme") {
|
||||||
|
this.showThemeSelector();
|
||||||
|
this.editor.setText("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Normal message submission - validate model and API key first
|
// Normal message submission - validate model and API key first
|
||||||
const currentModel = this.agent.state.model;
|
const currentModel = this.agent.state.model;
|
||||||
if (!currentModel) {
|
if (!currentModel) {
|
||||||
|
|
@ -929,6 +947,51 @@ export class TuiRenderer {
|
||||||
this.ui.setFocus(this.editor);
|
this.ui.setFocus(this.editor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private showThemeSelector(): void {
|
||||||
|
// Get current theme from settings
|
||||||
|
const currentTheme = this.settingsManager.getTheme() || "dark";
|
||||||
|
|
||||||
|
// Create theme selector
|
||||||
|
this.themeSelector = new ThemeSelectorComponent(
|
||||||
|
currentTheme,
|
||||||
|
(themeName) => {
|
||||||
|
// Apply the selected theme
|
||||||
|
setTheme(themeName);
|
||||||
|
|
||||||
|
// Save theme to settings
|
||||||
|
this.settingsManager.setTheme(themeName);
|
||||||
|
|
||||||
|
// Show confirmation message with proper spacing
|
||||||
|
this.chatContainer.addChild(new Spacer(1));
|
||||||
|
const confirmText = new Text(chalk.dim(`Theme: ${themeName}`), 1, 0);
|
||||||
|
this.chatContainer.addChild(confirmText);
|
||||||
|
|
||||||
|
// Hide selector and show editor again
|
||||||
|
this.hideThemeSelector();
|
||||||
|
this.ui.requestRender();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Just hide the selector
|
||||||
|
this.hideThemeSelector();
|
||||||
|
this.ui.requestRender();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Replace editor with selector
|
||||||
|
this.editorContainer.clear();
|
||||||
|
this.editorContainer.addChild(this.themeSelector);
|
||||||
|
this.ui.setFocus(this.themeSelector.getSelectList());
|
||||||
|
this.ui.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideThemeSelector(): void {
|
||||||
|
// Replace selector with editor in the container
|
||||||
|
this.editorContainer.clear();
|
||||||
|
this.editorContainer.addChild(this.editor);
|
||||||
|
this.themeSelector = null;
|
||||||
|
this.ui.setFocus(this.editor);
|
||||||
|
}
|
||||||
|
|
||||||
private showModelSelector(): void {
|
private showModelSelector(): void {
|
||||||
// Create model selector with current model
|
// Create model selector with current model
|
||||||
this.modelSelector = new ModelSelectorComponent(
|
this.modelSelector = new ModelSelectorComponent(
|
||||||
|
|
|
||||||
|
|
@ -25,22 +25,46 @@ export interface DefaultTextStyle {
|
||||||
underline?: boolean;
|
underline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme functions for markdown elements.
|
||||||
|
* Each function takes text and returns styled text with ANSI codes.
|
||||||
|
*/
|
||||||
|
export interface MarkdownTheme {
|
||||||
|
heading: (text: string) => string;
|
||||||
|
link: (text: string) => string;
|
||||||
|
code: (text: string) => string;
|
||||||
|
codeBlock: (text: string) => string;
|
||||||
|
codeBlockBorder: (text: string) => string;
|
||||||
|
quote: (text: string) => string;
|
||||||
|
quoteBorder: (text: string) => string;
|
||||||
|
hr: (text: string) => string;
|
||||||
|
listBullet: (text: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
export class Markdown implements Component {
|
export class Markdown implements Component {
|
||||||
private text: string;
|
private text: string;
|
||||||
private paddingX: number; // Left/right padding
|
private paddingX: number; // Left/right padding
|
||||||
private paddingY: number; // Top/bottom padding
|
private paddingY: number; // Top/bottom padding
|
||||||
private defaultTextStyle?: DefaultTextStyle;
|
private defaultTextStyle?: DefaultTextStyle;
|
||||||
|
private theme?: MarkdownTheme;
|
||||||
|
|
||||||
// Cache for rendered output
|
// Cache for rendered output
|
||||||
private cachedText?: string;
|
private cachedText?: string;
|
||||||
private cachedWidth?: number;
|
private cachedWidth?: number;
|
||||||
private cachedLines?: string[];
|
private cachedLines?: string[];
|
||||||
|
|
||||||
constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, defaultTextStyle?: DefaultTextStyle) {
|
constructor(
|
||||||
|
text: string = "",
|
||||||
|
paddingX: number = 1,
|
||||||
|
paddingY: number = 1,
|
||||||
|
defaultTextStyle?: DefaultTextStyle,
|
||||||
|
theme?: MarkdownTheme,
|
||||||
|
) {
|
||||||
this.text = text;
|
this.text = text;
|
||||||
this.paddingX = paddingX;
|
this.paddingX = paddingX;
|
||||||
this.paddingY = paddingY;
|
this.paddingY = paddingY;
|
||||||
this.defaultTextStyle = defaultTextStyle;
|
this.defaultTextStyle = defaultTextStyle;
|
||||||
|
this.theme = theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
setText(text: string): void {
|
setText(text: string): void {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export {
|
||||||
export { Editor, type TextEditorConfig } from "./components/editor.js";
|
export { Editor, type TextEditorConfig } from "./components/editor.js";
|
||||||
export { Input } from "./components/input.js";
|
export { Input } from "./components/input.js";
|
||||||
export { Loader } from "./components/loader.js";
|
export { Loader } from "./components/loader.js";
|
||||||
export { Markdown } from "./components/markdown.js";
|
export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js";
|
||||||
export { type SelectItem, SelectList } from "./components/select-list.js";
|
export { type SelectItem, SelectList } from "./components/select-list.js";
|
||||||
export { Spacer } from "./components/spacer.js";
|
export { Spacer } from "./components/spacer.js";
|
||||||
export { Text } from "./components/text.js";
|
export { Text } from "./components/text.js";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue