diff --git a/package-lock.json b/package-lock.json index b477fca2..2b4ba9cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5413,7 +5413,7 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent", - "version": "0.7.29", + "version": "0.8.0", "license": "MIT", "dependencies": { "@mariozechner/pi-ai": "^0.7.29", @@ -5428,6 +5428,42 @@ "node": ">=20.0.0" } }, + "packages/agent/node_modules/@mariozechner/pi-ai": { + "version": "0.7.29", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.7.29.tgz", + "integrity": "sha512-CtYAyQikG4S2j22+8OBclCBASTMzmyfca32K9SMLIV2W88/1JdJL3B9TfZOdPrp/6WMFRe+TdprXh0UK/I/Ikw==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.61.0", + "@google/genai": "^1.30.0", + "@sinclair/typebox": "^0.34.41", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "chalk": "^5.6.2", + "openai": "5.21.0", + "partial-json": "^0.1.7", + "zod-to-json-schema": "^3.24.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "packages/agent/node_modules/@mariozechner/pi-tui": { + "version": "0.7.29", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.7.29.tgz", + "integrity": "sha512-vqrjc2NynNj+4y5WVabPQZSBkWw2NIYIz4VSROtqkGQD5ilaalZuHd+oIsAByClFlJv0joaa+Qtwbcvfl1nTiA==", + "license": "MIT", + "dependencies": { + "@types/mime-types": "^2.1.4", + "chalk": "^5.5.0", + "marked": "^15.0.12", + "mime-types": "^3.0.1", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "packages/agent/node_modules/@types/node": { "version": "24.10.1", "dev": true, @@ -5436,6 +5472,73 @@ "undici-types": "~7.16.0" } }, + "packages/agent/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "packages/agent/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "packages/agent/node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "packages/agent/node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/agent/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "packages/agent/node_modules/undici-types": { "version": "7.16.0", "dev": true, @@ -5443,7 +5546,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.7.29", + "version": "0.8.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.61.0", @@ -5490,7 +5593,7 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.7.29", + "version": "0.8.0", "license": "MIT", "dependencies": { "@mariozechner/pi-agent": "^0.7.29", @@ -5512,6 +5615,55 @@ "node": ">=20.0.0" } }, + "packages/coding-agent/node_modules/@mariozechner/pi-agent": { + "version": "0.7.29", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent/-/pi-agent-0.7.29.tgz", + "integrity": "sha512-zmgg0Ob6xzrQP8AcxwziH1YR+RmIb/kNWTltXTr9kTpXZPhXh/kFIc56ZhWxkMl5BsY9AOvSRgWv+EPCoJFglQ==", + "license": "MIT", + "dependencies": { + "@mariozechner/pi-ai": "^0.7.29", + "@mariozechner/pi-tui": "^0.7.29" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "packages/coding-agent/node_modules/@mariozechner/pi-ai": { + "version": "0.7.29", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.7.29.tgz", + "integrity": "sha512-CtYAyQikG4S2j22+8OBclCBASTMzmyfca32K9SMLIV2W88/1JdJL3B9TfZOdPrp/6WMFRe+TdprXh0UK/I/Ikw==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.61.0", + "@google/genai": "^1.30.0", + "@sinclair/typebox": "^0.34.41", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "chalk": "^5.6.2", + "openai": "5.21.0", + "partial-json": "^0.1.7", + "zod-to-json-schema": "^3.24.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "packages/coding-agent/node_modules/@mariozechner/pi-tui": { + "version": "0.7.29", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.7.29.tgz", + "integrity": "sha512-vqrjc2NynNj+4y5WVabPQZSBkWw2NIYIz4VSROtqkGQD5ilaalZuHd+oIsAByClFlJv0joaa+Qtwbcvfl1nTiA==", + "license": "MIT", + "dependencies": { + "@types/mime-types": "^2.1.4", + "chalk": "^5.5.0", + "marked": "^15.0.12", + "mime-types": "^3.0.1", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "packages/coding-agent/node_modules/@types/node": { "version": "24.10.1", "dev": true, @@ -5520,6 +5672,18 @@ "undici-types": "~7.16.0" } }, + "packages/coding-agent/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "packages/coding-agent/node_modules/chalk": { "version": "5.6.2", "license": "MIT", @@ -5530,6 +5694,49 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "packages/coding-agent/node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "packages/coding-agent/node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/coding-agent/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "packages/coding-agent/node_modules/undici-types": { "version": "7.16.0", "dev": true, @@ -5537,7 +5744,7 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.7.29", + "version": "0.8.0", "license": "MIT", "dependencies": { "@mariozechner/pi-agent": "^0.7.29", @@ -5551,6 +5758,67 @@ "node": ">=20.0.0" } }, + "packages/pods/node_modules/@mariozechner/pi-agent": { + "version": "0.7.29", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent/-/pi-agent-0.7.29.tgz", + "integrity": "sha512-zmgg0Ob6xzrQP8AcxwziH1YR+RmIb/kNWTltXTr9kTpXZPhXh/kFIc56ZhWxkMl5BsY9AOvSRgWv+EPCoJFglQ==", + "license": "MIT", + "dependencies": { + "@mariozechner/pi-ai": "^0.7.29", + "@mariozechner/pi-tui": "^0.7.29" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "packages/pods/node_modules/@mariozechner/pi-ai": { + "version": "0.7.29", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.7.29.tgz", + "integrity": "sha512-CtYAyQikG4S2j22+8OBclCBASTMzmyfca32K9SMLIV2W88/1JdJL3B9TfZOdPrp/6WMFRe+TdprXh0UK/I/Ikw==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.61.0", + "@google/genai": "^1.30.0", + "@sinclair/typebox": "^0.34.41", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "chalk": "^5.6.2", + "openai": "5.21.0", + "partial-json": "^0.1.7", + "zod-to-json-schema": "^3.24.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "packages/pods/node_modules/@mariozechner/pi-tui": { + "version": "0.7.29", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.7.29.tgz", + "integrity": "sha512-vqrjc2NynNj+4y5WVabPQZSBkWw2NIYIz4VSROtqkGQD5ilaalZuHd+oIsAByClFlJv0joaa+Qtwbcvfl1nTiA==", + "license": "MIT", + "dependencies": { + "@types/mime-types": "^2.1.4", + "chalk": "^5.5.0", + "marked": "^15.0.12", + "mime-types": "^3.0.1", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "packages/pods/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "packages/pods/node_modules/chalk": { "version": "5.6.2", "license": "MIT", @@ -5561,9 +5829,52 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "packages/pods/node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "packages/pods/node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/pods/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "packages/proxy": { "name": "@mariozechner/pi-proxy", - "version": "0.7.29", + "version": "0.8.0", "dependencies": { "@hono/node-server": "^1.14.0", "hono": "^4.6.16" @@ -5579,7 +5890,7 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.7.29", + "version": "0.8.0", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -5661,7 +5972,7 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.7.29", + "version": "0.8.0", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", @@ -5684,6 +5995,109 @@ "@mariozechner/mini-lit": "^0.2.0", "lit": "^3.3.1" } + }, + "packages/web-ui/node_modules/@mariozechner/pi-ai": { + "version": "0.7.29", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.7.29.tgz", + "integrity": "sha512-CtYAyQikG4S2j22+8OBclCBASTMzmyfca32K9SMLIV2W88/1JdJL3B9TfZOdPrp/6WMFRe+TdprXh0UK/I/Ikw==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.61.0", + "@google/genai": "^1.30.0", + "@sinclair/typebox": "^0.34.41", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "chalk": "^5.6.2", + "openai": "5.21.0", + "partial-json": "^0.1.7", + "zod-to-json-schema": "^3.24.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "packages/web-ui/node_modules/@mariozechner/pi-tui": { + "version": "0.7.29", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.7.29.tgz", + "integrity": "sha512-vqrjc2NynNj+4y5WVabPQZSBkWw2NIYIz4VSROtqkGQD5ilaalZuHd+oIsAByClFlJv0joaa+Qtwbcvfl1nTiA==", + "license": "MIT", + "dependencies": { + "@types/mime-types": "^2.1.4", + "chalk": "^5.5.0", + "marked": "^15.0.12", + "mime-types": "^3.0.1", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "packages/web-ui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "packages/web-ui/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "packages/web-ui/node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "packages/web-ui/node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/web-ui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } } } } diff --git a/packages/agent/package.json b/packages/agent/package.json index ae5ea721..0e570486 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-agent", - "version": "0.7.29", + "version": "0.8.0", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -18,8 +18,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-ai": "^0.7.29", - "@mariozechner/pi-tui": "^0.7.29" + "@mariozechner/pi-ai": "^0.8.0", + "@mariozechner/pi-tui": "^0.8.0" }, "keywords": [ "ai", diff --git a/packages/ai/package.json b/packages/ai/package.json index fda3f8fa..a0283013 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-ai", - "version": "0.7.29", + "version": "0.8.0", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index adcfbaa2..21964ea0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [0.8.0] - 2025-11-21 + +### Added + +- **Theme System**: Full theming support with 44 customizable color tokens. Two built-in themes (`dark`, `light`) with auto-detection based on terminal background. Use `/theme` command to select themes interactively. Custom themes in `~/.pi/agent/themes/*.json` support live editing - changes apply immediately when the file is saved. Themes use RGB hex values for consistent rendering across terminals. VS Code users: set `terminal.integrated.minimumContrastRatio` to `1` for proper color rendering. See [Theme Documentation](docs/theme.md) for details. + ## [0.7.29] - 2025-11-20 ### Improved diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index dad3b097..af280036 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -11,6 +11,7 @@ Works on Linux, macOS, and Windows (barely tested, needs Git Bash running in the - [API Keys](#api-keys) - [OAuth Authentication (Optional)](#oauth-authentication-optional) - [Custom Models and Providers](#custom-models-and-providers) +- [Themes](#themes) - [Slash Commands](#slash-commands) - [Editor Features](#editor-features) - [Project Context Files](#project-context-files) @@ -284,6 +285,79 @@ If the file contains errors (JSON syntax, schema violations, missing fields), th See the configuration structure above. Create `~/.pi/agent/models.json` with your Ollama setup, then use `/model` to select your local models. The agent can also help you write this file if you point it to this README. +## Themes + +Pi supports customizable color themes for the TUI. Two built-in themes are available: `dark` (default) and `light`. + +### Selecting a Theme + +Use the `/theme` command to interactively select a theme, or edit your settings file: + +```bash +# Interactive selector +pi +/theme + +# Or edit ~/.pi/agent/settings.json +{ + "theme": "dark" # or "light" +} +``` + +On first run, Pi auto-detects your terminal background (dark/light) and selects an appropriate theme. + +### Custom Themes + +Create custom themes in `~/.pi/agent/themes/*.json`. Custom themes support **live editing** - when you select a custom theme, Pi watches the file and automatically reloads when you save changes. + +**Workflow for creating themes:** +1. Copy a built-in theme as a starting point: + ```bash + mkdir -p ~/.pi/agent/themes + # Copy dark theme + cp $(npm root -g)/@mariozechner/pi-coding-agent/dist/theme/dark.json ~/.pi/agent/themes/my-theme.json + # Or copy light theme + cp $(npm root -g)/@mariozechner/pi-coding-agent/dist/theme/light.json ~/.pi/agent/themes/my-theme.json + ``` +2. Use `/theme` to select "my-theme" +3. Edit `~/.pi/agent/themes/my-theme.json` - changes apply immediately on save +4. Iterate until satisfied (no need to re-select the theme) + +See [Theme Documentation](docs/theme.md) for: +- Complete list of 44 color tokens +- Theme format and examples +- Color value formats (hex, RGB, terminal default) + +Example custom theme: + +```json +{ + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json", + "name": "my-theme", + "vars": { + "accent": "#00aaff", + "muted": "#6c6c6c" + }, + "colors": { + "accent": "accent", + "muted": "muted", + ... + } +} +``` + +### VS Code Terminal Color Issue + +**Important:** VS Code's integrated terminal has a known issue with rendering truecolor (24-bit RGB) values. By default, it applies a "minimum contrast ratio" adjustment that can make colors look washed out or identical. + +To fix this, set the contrast ratio to 1 in VS Code settings: + +1. Open Settings (Cmd/Ctrl + ,) +2. Search for: `terminal.integrated.minimumContrastRatio` +3. Set to: `1` + +This ensures VS Code renders the exact RGB colors defined in your theme. + ## Slash Commands The CLI supports several commands to control its behavior: diff --git a/packages/coding-agent/docs/theme.md b/packages/coding-agent/docs/theme.md index d9b9c470..2bf01781 100644 --- a/packages/coding-agent/docs/theme.md +++ b/packages/coding-agent/docs/theme.md @@ -6,7 +6,7 @@ Themes allow you to customize the colors used throughout the coding agent TUI. Every theme must define all color tokens. There are no optional colors. -### Core UI (9 colors) +### Core UI (10 colors) | Token | Purpose | Examples | |-------|---------|----------| @@ -18,9 +18,10 @@ Every theme must define all color tokens. There are no optional colors. | `error` | Error states | Error messages, diff deletions | | `warning` | Warning states | Warning messages | | `muted` | Secondary/dimmed text | Metadata, descriptions, output | +| `dim` | Very dimmed text | Less important info, placeholders | | `text` | Default text color | Main content (usually `""`) | -### Backgrounds & Content Text (6 colors) +### Backgrounds & Content Text (7 colors) | Token | Purpose | |-------|---------| @@ -29,14 +30,16 @@ Every theme must define all color tokens. There are no optional colors. | `toolPendingBg` | Tool execution box (pending state) | | `toolSuccessBg` | Tool execution box (success state) | | `toolErrorBg` | Tool execution box (error state) | -| `toolText` | Tool execution box text color (all states) | +| `toolTitle` | Tool execution title/heading (e.g., `$ command`, `read file.txt`) | +| `toolOutput` | Tool execution output text | -### Markdown (9 colors) +### Markdown (10 colors) | Token | Purpose | |-------|---------| | `mdHeading` | Heading text (`#`, `##`, etc) | -| `mdLink` | Link text and URLs | +| `mdLink` | Link text | +| `mdLinkUrl` | Link URL (in parentheses) | | `mdCode` | Inline code (backticks) | | `mdCodeBlock` | Code block content | | `mdCodeBlockBorder` | Code block fences (```) | @@ -71,7 +74,21 @@ Future-proofing for syntax highlighting support: | `syntaxOperator` | Operators (`+`, `-`, etc) | | `syntaxPunctuation` | Punctuation (`;`, `,`, etc) | -**Total: 36 color tokens** (all required) +### Thinking Level Borders (5 colors) + +Editor border colors that indicate the current thinking/reasoning level: + +| Token | Purpose | +|-------|---------| +| `thinkingOff` | Border when thinking is off (most subtle) | +| `thinkingMinimal` | Border for minimal thinking | +| `thinkingLow` | Border for low thinking | +| `thinkingMedium` | Border for medium thinking | +| `thinkingHigh` | Border for high thinking (most prominent) | + +These create a visual hierarchy: off → minimal → low → medium → high + +**Total: 44 color tokens** (all required) ## Theme Format @@ -241,7 +258,13 @@ Custom themes are loaded from `~/.pi/agent/themes/*.json`. "syntaxNumber": "#ff00ff", "syntaxType": "#00aaff", "syntaxOperator": "primary", - "syntaxPunctuation": "secondary" + "syntaxPunctuation": "secondary", + + "thinkingOff": "secondary", + "thinkingMinimal": "primary", + "thinkingLow": "#00aaff", + "thinkingMedium": "#00ffff", + "thinkingHigh": "#ff00ff" } } ``` diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 6fc3e6c5..4e5c6470 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-coding-agent", - "version": "0.7.29", + "version": "0.8.0", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "bin": { @@ -22,8 +22,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-agent": "^0.7.29", - "@mariozechner/pi-ai": "^0.7.29", + "@mariozechner/pi-agent": "^0.8.0", + "@mariozechner/pi-ai": "^0.8.0", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 61587b7e..6d827f9d 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -236,8 +236,8 @@ Guidelines: - When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did Documentation: -- Your own documentation (including custom model setup) is at: ${readmePath} -- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider.`; +- Your own documentation (including custom model setup and theme creation) is at: ${readmePath} +- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, or create a custom theme.`; // Append project context files const contextFiles = loadProjectContextFiles(); diff --git a/packages/coding-agent/src/theme/dark.json b/packages/coding-agent/src/theme/dark.json index 716bd1f8..aa52d1fc 100644 --- a/packages/coding-agent/src/theme/dark.json +++ b/packages/coding-agent/src/theme/dark.json @@ -3,19 +3,21 @@ "name": "dark", "vars": { "cyan": "#00d7ff", - "blue": "#0087ff", - "green": "#00ff00", - "red": "#ff0000", + "blue": "#5f87ff", + "green": "#b5bd68", + "red": "#cc6666", "yellow": "#ffff00", - "gray": 242, - "darkGray": 238, + "gray": "#808080", + "dimGray": "#666666", + "darkGray": "#303030", + "accent": "#8abeb7", "userMsgBg": "#343541", "toolPendingBg": "#282832", "toolSuccessBg": "#283228", "toolErrorBg": "#3c2828" }, "colors": { - "accent": "cyan", + "accent": "accent", "border": "blue", "borderAccent": "cyan", "borderMuted": "darkGray", @@ -23,6 +25,7 @@ "error": "red", "warning": "yellow", "muted": "gray", + "dim": "dimGray", "text": "", "userMessageBg": "userMsgBg", @@ -30,17 +33,19 @@ "toolPendingBg": "toolPendingBg", "toolSuccessBg": "toolSuccessBg", "toolErrorBg": "toolErrorBg", - "toolText": "", + "toolTitle": "", + "toolOutput": "gray", - "mdHeading": "cyan", - "mdLink": "blue", - "mdCode": "cyan", - "mdCodeBlock": "", + "mdHeading": "#f0c674", + "mdLink": "#81a2be", + "mdLinkUrl": "dimGray", + "mdCode": "accent", + "mdCodeBlock": "green", "mdCodeBlockBorder": "gray", "mdQuote": "gray", "mdQuoteBorder": "gray", "mdHr": "gray", - "mdListBullet": "cyan", + "mdListBullet": "accent", "toolDiffAdded": "green", "toolDiffRemoved": "red", @@ -54,6 +59,12 @@ "syntaxNumber": "yellow", "syntaxType": "cyan", "syntaxOperator": "", - "syntaxPunctuation": "gray" + "syntaxPunctuation": "gray", + + "thinkingOff": "darkGray", + "thinkingMinimal": "#4e4e4e", + "thinkingLow": "#5f87af", + "thinkingMedium": "#81a2be", + "thinkingHigh": "#b294bb" } } diff --git a/packages/coding-agent/src/theme/light.json b/packages/coding-agent/src/theme/light.json index 321e3921..25482376 100644 --- a/packages/coding-agent/src/theme/light.json +++ b/packages/coding-agent/src/theme/light.json @@ -2,27 +2,29 @@ "$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, + "teal": "#5f8787", + "blue": "#5f87af", + "green": "#87af87", + "red": "#af5f5f", + "yellow": "#d7af5f", + "mediumGray": "#6c6c6c", + "dimGray": "#8a8a8a", + "lightGray": "#b0b0b0", "userMsgBg": "#e8e8e8", "toolPendingBg": "#e8e8f0", "toolSuccessBg": "#e8f0e8", "toolErrorBg": "#f0e8e8" }, "colors": { - "accent": "darkCyan", - "border": "darkBlue", - "borderAccent": "darkCyan", + "accent": "teal", + "border": "blue", + "borderAccent": "teal", "borderMuted": "lightGray", - "success": "darkGreen", - "error": "darkRed", - "warning": "darkYellow", + "success": "green", + "error": "red", + "warning": "yellow", "muted": "mediumGray", + "dim": "dimGray", "text": "", "userMessageBg": "userMsgBg", @@ -30,30 +32,38 @@ "toolPendingBg": "toolPendingBg", "toolSuccessBg": "toolSuccessBg", "toolErrorBg": "toolErrorBg", - "toolText": "", + "toolTitle": "", + "toolOutput": "mediumGray", - "mdHeading": "darkCyan", - "mdLink": "darkBlue", - "mdCode": "darkCyan", - "mdCodeBlock": "", + "mdHeading": "yellow", + "mdLink": "blue", + "mdLinkUrl": "dimGray", + "mdCode": "teal", + "mdCodeBlock": "green", "mdCodeBlockBorder": "mediumGray", "mdQuote": "mediumGray", "mdQuoteBorder": "mediumGray", "mdHr": "mediumGray", - "mdListBullet": "darkCyan", + "mdListBullet": "green", - "toolDiffAdded": "darkGreen", - "toolDiffRemoved": "darkRed", + "toolDiffAdded": "green", + "toolDiffRemoved": "red", "toolDiffContext": "mediumGray", "syntaxComment": "mediumGray", - "syntaxKeyword": "darkCyan", - "syntaxFunction": "darkBlue", + "syntaxKeyword": "teal", + "syntaxFunction": "blue", "syntaxVariable": "", - "syntaxString": "darkGreen", - "syntaxNumber": "darkYellow", - "syntaxType": "darkCyan", + "syntaxString": "green", + "syntaxNumber": "yellow", + "syntaxType": "teal", "syntaxOperator": "", - "syntaxPunctuation": "mediumGray" + "syntaxPunctuation": "mediumGray", + + "thinkingOff": "lightGray", + "thinkingMinimal": "#9e9e9e", + "thinkingLow": "#5f87af", + "thinkingMedium": "#5f8787", + "thinkingHigh": "#875f87" } } diff --git a/packages/coding-agent/src/theme/theme-schema.json b/packages/coding-agent/src/theme/theme-schema.json index d9cc47b2..8507a94c 100644 --- a/packages/coding-agent/src/theme/theme-schema.json +++ b/packages/coding-agent/src/theme/theme-schema.json @@ -43,6 +43,7 @@ "error", "warning", "muted", + "dim", "text", "userMessageBg", "userMessageText", @@ -105,6 +106,10 @@ "$ref": "#/$defs/colorValue", "description": "Secondary/dimmed text" }, + "dim": { + "$ref": "#/$defs/colorValue", + "description": "Very dimmed text (more subtle than muted)" + }, "text": { "$ref": "#/$defs/colorValue", "description": "Default text color (usually empty string)" diff --git a/packages/coding-agent/src/theme/theme.ts b/packages/coding-agent/src/theme/theme.ts index 9c9bfb02..85a5f2e2 100644 --- a/packages/coding-agent/src/theme/theme.ts +++ b/packages/coding-agent/src/theme/theme.ts @@ -2,7 +2,7 @@ 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 { EditorTheme, MarkdownTheme, SelectListTheme } from "@mariozechner/pi-tui"; import { type Static, Type } from "@sinclair/typebox"; import { TypeCompiler } from "@sinclair/typebox/compiler"; import chalk from "chalk"; @@ -25,7 +25,7 @@ const ThemeJsonSchema = Type.Object({ name: Type.String(), vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)), colors: Type.Object({ - // Core UI (9 colors) + // Core UI (10 colors) accent: ColorValueSchema, border: ColorValueSchema, borderAccent: ColorValueSchema, @@ -34,17 +34,20 @@ const ThemeJsonSchema = Type.Object({ error: ColorValueSchema, warning: ColorValueSchema, muted: ColorValueSchema, + dim: ColorValueSchema, text: ColorValueSchema, - // Backgrounds & Content Text (6 colors) + // Backgrounds & Content Text (7 colors) userMessageBg: ColorValueSchema, userMessageText: ColorValueSchema, toolPendingBg: ColorValueSchema, toolSuccessBg: ColorValueSchema, toolErrorBg: ColorValueSchema, - toolText: ColorValueSchema, - // Markdown (9 colors) + toolTitle: ColorValueSchema, + toolOutput: ColorValueSchema, + // Markdown (10 colors) mdHeading: ColorValueSchema, mdLink: ColorValueSchema, + mdLinkUrl: ColorValueSchema, mdCode: ColorValueSchema, mdCodeBlock: ColorValueSchema, mdCodeBlockBorder: ColorValueSchema, @@ -66,6 +69,12 @@ const ThemeJsonSchema = Type.Object({ syntaxType: ColorValueSchema, syntaxOperator: ColorValueSchema, syntaxPunctuation: ColorValueSchema, + // Thinking Level Borders (5 colors) + thinkingOff: ColorValueSchema, + thinkingMinimal: ColorValueSchema, + thinkingLow: ColorValueSchema, + thinkingMedium: ColorValueSchema, + thinkingHigh: ColorValueSchema, }), }); @@ -82,11 +91,14 @@ export type ThemeColor = | "error" | "warning" | "muted" + | "dim" | "text" | "userMessageText" - | "toolText" + | "toolTitle" + | "toolOutput" | "mdHeading" | "mdLink" + | "mdLinkUrl" | "mdCode" | "mdCodeBlock" | "mdCodeBlockBorder" @@ -105,7 +117,12 @@ export type ThemeColor = | "syntaxNumber" | "syntaxType" | "syntaxOperator" - | "syntaxPunctuation"; + | "syntaxPunctuation" + | "thinkingOff" + | "thinkingMinimal" + | "thinkingLow" + | "thinkingMedium" + | "thinkingHigh"; export type ThemeBg = "userMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg"; @@ -216,8 +233,6 @@ function resolveThemeColors>( // Theme Class // ============================================================================ -const RESET = "\x1b[0m"; - export class Theme { private fgColors: Map; private bgColors: Map; @@ -242,13 +257,13 @@ export class Theme { 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}`; + return `${ansi}${text}\x1b[39m`; // Reset only foreground color } 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}`; + return `${ansi}${text}\x1b[49m`; // Reset only background color } bold(text: string): string { @@ -278,6 +293,24 @@ export class Theme { getColorMode(): ColorMode { return this.mode; } + + getThinkingBorderColor(level: "off" | "minimal" | "low" | "medium" | "high"): (str: string) => string { + // Map thinking levels to dedicated theme colors + switch (level) { + case "off": + return (str: string) => this.fg("thinkingOff", str); + case "minimal": + return (str: string) => this.fg("thinkingMinimal", str); + case "low": + return (str: string) => this.fg("thinkingLow", str); + case "medium": + return (str: string) => this.fg("thinkingMedium", str); + case "high": + return (str: string) => this.fg("thinkingHigh", str); + default: + return (str: string) => this.fg("thinkingOff", str); + } + } } // ============================================================================ @@ -369,7 +402,8 @@ function detectTerminalBackground(): "dark" | "light" { if (parts.length >= 2) { const bg = parseInt(parts[1], 10); if (!Number.isNaN(bg)) { - return bg < 8 ? "dark" : "light"; + const result = bg < 8 ? "dark" : "light"; + return result; } } } @@ -385,14 +419,109 @@ function getDefaultTheme(): string { // ============================================================================ export let theme: Theme; +let currentThemeName: string | undefined; +let themeWatcher: fs.FSWatcher | undefined; +let onThemeChangeCallback: (() => void) | undefined; export function initTheme(themeName?: string): void { const name = themeName ?? getDefaultTheme(); - theme = loadTheme(name); + currentThemeName = name; + try { + theme = loadTheme(name); + startThemeWatcher(); + } catch (error) { + // Theme is invalid - fall back to dark theme silently + currentThemeName = "dark"; + theme = loadTheme("dark"); + // Don't start watcher for fallback theme + } } -export function setTheme(name: string): void { - theme = loadTheme(name); +export function setTheme(name: string): { success: boolean; error?: string } { + currentThemeName = name; + try { + theme = loadTheme(name); + startThemeWatcher(); + return { success: true }; + } catch (error) { + // Theme is invalid - fall back to dark theme + currentThemeName = "dark"; + theme = loadTheme("dark"); + // Don't start watcher for fallback theme + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export function onThemeChange(callback: () => void): void { + onThemeChangeCallback = callback; +} + +function startThemeWatcher(): void { + // Stop existing watcher if any + if (themeWatcher) { + themeWatcher.close(); + themeWatcher = undefined; + } + + // Only watch if it's a custom theme (not built-in) + if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") { + return; + } + + const themesDir = getThemesDir(); + const themeFile = path.join(themesDir, `${currentThemeName}.json`); + + // Only watch if the file exists + if (!fs.existsSync(themeFile)) { + return; + } + + try { + themeWatcher = fs.watch(themeFile, (eventType) => { + if (eventType === "change") { + // Debounce rapid changes + setTimeout(() => { + try { + // Reload the theme + theme = loadTheme(currentThemeName!); + // Notify callback (to invalidate UI) + if (onThemeChangeCallback) { + onThemeChangeCallback(); + } + } catch (error) { + // Ignore errors (file might be in invalid state while being edited) + } + }, 100); + } else if (eventType === "rename") { + // File was deleted or renamed - fall back to default theme + setTimeout(() => { + if (!fs.existsSync(themeFile)) { + currentThemeName = "dark"; + theme = loadTheme("dark"); + if (themeWatcher) { + themeWatcher.close(); + themeWatcher = undefined; + } + if (onThemeChangeCallback) { + onThemeChangeCallback(); + } + } + }, 100); + } + }); + } catch (error) { + // Ignore errors starting watcher + } +} + +export function stopThemeWatcher(): void { + if (themeWatcher) { + themeWatcher.close(); + themeWatcher = undefined; + } } // ============================================================================ @@ -403,6 +532,7 @@ export function getMarkdownTheme(): MarkdownTheme { return { heading: (text: string) => theme.fg("mdHeading", text), link: (text: string) => theme.fg("mdLink", text), + linkUrl: (text: string) => theme.fg("mdLinkUrl", text), code: (text: string) => theme.fg("mdCode", text), codeBlock: (text: string) => theme.fg("mdCodeBlock", text), codeBlockBorder: (text: string) => theme.fg("mdCodeBlockBorder", text), @@ -410,5 +540,26 @@ export function getMarkdownTheme(): MarkdownTheme { quoteBorder: (text: string) => theme.fg("mdQuoteBorder", text), hr: (text: string) => theme.fg("mdHr", text), listBullet: (text: string) => theme.fg("mdListBullet", text), + bold: (text: string) => theme.bold(text), + italic: (text: string) => theme.italic(text), + underline: (text: string) => theme.underline(text), + strikethrough: (text: string) => chalk.strikethrough(text), + }; +} + +export function getSelectListTheme(): SelectListTheme { + return { + selectedPrefix: (text: string) => theme.fg("accent", text), + selectedText: (text: string) => theme.fg("accent", text), + description: (text: string) => theme.fg("muted", text), + scrollInfo: (text: string) => theme.fg("muted", text), + noMatch: (text: string) => theme.fg("muted", text), + }; +} + +export function getEditorTheme(): EditorTheme { + return { + borderColor: (text: string) => theme.fg("borderMuted", text), + selectList: getSelectListTheme(), }; } diff --git a/packages/coding-agent/src/tui/assistant-message.ts b/packages/coding-agent/src/tui/assistant-message.ts index 49f8e1d4..e1a33fba 100644 --- a/packages/coding-agent/src/tui/assistant-message.ts +++ b/packages/coding-agent/src/tui/assistant-message.ts @@ -1,6 +1,6 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; -import chalk from "chalk"; +import { getMarkdownTheme, theme } from "../theme/theme.js"; /** * Component that renders a complete assistant message @@ -38,13 +38,13 @@ export class AssistantMessageComponent extends Container { if (content.type === "text" && content.text.trim()) { // Assistant text messages with no background - trim the text // Set paddingY=0 to avoid extra spacing before tool executions - this.contentContainer.addChild(new Markdown(content.text.trim(), 1, 0)); + this.contentContainer.addChild(new Markdown(content.text.trim(), 1, 0, getMarkdownTheme())); } else if (content.type === "thinking" && content.thinking.trim()) { - // Thinking traces in dark gray italic + // Thinking traces in muted color, italic // Use Markdown component with default text style for consistent styling this.contentContainer.addChild( - new Markdown(content.thinking.trim(), 1, 0, { - color: "gray", + new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), { + color: (text: string) => theme.fg("muted", text), italic: true, }), ); @@ -57,11 +57,11 @@ export class AssistantMessageComponent extends Container { const hasToolCalls = message.content.some((c) => c.type === "toolCall"); if (!hasToolCalls) { if (message.stopReason === "aborted") { - this.contentContainer.addChild(new Text(chalk.red("\nAborted"), 1, 0)); + this.contentContainer.addChild(new Text(theme.fg("error", "\nAborted"), 1, 0)); } else if (message.stopReason === "error") { const errorMsg = message.errorMessage || "Unknown error"; this.contentContainer.addChild(new Spacer(1)); - this.contentContainer.addChild(new Text(chalk.red(`Error: ${errorMsg}`), 1, 0)); + this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0)); } } } diff --git a/packages/coding-agent/src/tui/dynamic-border.ts b/packages/coding-agent/src/tui/dynamic-border.ts index 8f6a4bdd..b835ae0a 100644 --- a/packages/coding-agent/src/tui/dynamic-border.ts +++ b/packages/coding-agent/src/tui/dynamic-border.ts @@ -1,5 +1,5 @@ import type { Component } from "@mariozechner/pi-tui"; -import chalk from "chalk"; +import { theme } from "../theme/theme.js"; /** * Dynamic border component that adjusts to viewport width @@ -7,10 +7,14 @@ import chalk from "chalk"; export class DynamicBorder implements Component { private color: (str: string) => string; - constructor(color: (str: string) => string = chalk.blue) { + constructor(color: (str: string) => string = (str) => theme.fg("border", str)) { this.color = color; } + invalidate(): void { + // No cached state to invalidate currently + } + render(width: number): string[] { return [this.color("─".repeat(Math.max(1, width)))]; } diff --git a/packages/coding-agent/src/tui/footer.ts b/packages/coding-agent/src/tui/footer.ts index 5bf05a07..4015586e 100644 --- a/packages/coding-agent/src/tui/footer.ts +++ b/packages/coding-agent/src/tui/footer.ts @@ -1,12 +1,12 @@ import type { AgentState } from "@mariozechner/pi-agent"; import type { AssistantMessage } from "@mariozechner/pi-ai"; -import { visibleWidth } from "@mariozechner/pi-tui"; -import chalk from "chalk"; +import { type Component, visibleWidth } from "@mariozechner/pi-tui"; +import { theme } from "../theme/theme.js"; /** * Footer component that shows pwd, token stats, and context usage */ -export class FooterComponent { +export class FooterComponent implements Component { private state: AgentState; constructor(state: AgentState) { @@ -17,6 +17,10 @@ export class FooterComponent { this.state = state; } + invalidate(): void { + // No cached state to invalidate currently + } + render(width: number): string[] { // Calculate cumulative usage from all assistant messages let totalInput = 0; @@ -50,7 +54,8 @@ export class FooterComponent { lastAssistantMessage.usage.cacheWrite : 0; const contextWindow = this.state.model?.contextWindow || 0; - const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : "0.0"; + const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0; + const contextPercent = contextPercentValue.toFixed(1); // Format token counts (similar to web-ui) const formatTokens = (count: number): string => { @@ -80,8 +85,18 @@ export class FooterComponent { if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`); if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`); if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`); - if (totalCost) statsParts.push(`$${totalCost.toFixed(3)}`); - statsParts.push(`${contextPercent}%`); + if (totalCost) statsParts.push(`${totalCost.toFixed(3)}`); + + // Colorize context percentage based on usage + let contextPercentStr: string; + if (contextPercentValue > 90) { + contextPercentStr = theme.fg("error", `${contextPercent}%`); + } else if (contextPercentValue > 70) { + contextPercentStr = theme.fg("warning", `${contextPercent}%`); + } else { + contextPercentStr = `${contextPercent}%`; + } + statsParts.push(contextPercentStr); const statsLeft = statsParts.join(" "); @@ -126,6 +141,6 @@ export class FooterComponent { } // Return two lines: pwd and stats - return [chalk.gray(pwd), chalk.gray(statsLine)]; + return [theme.fg("dim", pwd), theme.fg("dim", statsLine)]; } } diff --git a/packages/coding-agent/src/tui/model-selector.ts b/packages/coding-agent/src/tui/model-selector.ts index 3f5b79f4..7c0e4164 100644 --- a/packages/coding-agent/src/tui/model-selector.ts +++ b/packages/coding-agent/src/tui/model-selector.ts @@ -1,8 +1,9 @@ import type { Model } from "@mariozechner/pi-ai"; import { Container, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; -import chalk from "chalk"; import { getAvailableModels } from "../model-config.js"; import type { SettingsManager } from "../settings-manager.js"; +import { theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; interface ModelItem { provider: string; @@ -42,12 +43,12 @@ export class ModelSelectorComponent extends Container { this.onCancelCallback = onCancel; // Add top border - this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0)); + this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); // Add hint about API key filtering this.addChild( - new Text(chalk.yellow("Only showing models with configured API keys (see README for details)"), 0, 0), + new Text(theme.fg("warning", "Only showing models with configured API keys (see README for details)"), 0, 0), ); this.addChild(new Spacer(1)); @@ -70,7 +71,7 @@ export class ModelSelectorComponent extends Container { this.addChild(new Spacer(1)); // Add bottom border - this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0)); + this.addChild(new DynamicBorder()); // Load models and do initial render this.loadModels().then(() => { @@ -150,15 +151,15 @@ export class ModelSelectorComponent extends Container { let line = ""; if (isSelected) { - const prefix = chalk.blue("→ "); + const prefix = theme.fg("accent", "→ "); const modelText = `${item.id}`; - const providerBadge = chalk.gray(`[${item.provider}]`); - const checkmark = isCurrent ? chalk.green(" ✓") : ""; - line = prefix + chalk.blue(modelText) + " " + providerBadge + checkmark; + const providerBadge = theme.fg("muted", `[${item.provider}]`); + const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; + line = prefix + theme.fg("accent", modelText) + " " + providerBadge + checkmark; } else { const modelText = ` ${item.id}`; - const providerBadge = chalk.gray(`[${item.provider}]`); - const checkmark = isCurrent ? chalk.green(" ✓") : ""; + const providerBadge = theme.fg("muted", `[${item.provider}]`); + const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; line = modelText + " " + providerBadge + checkmark; } @@ -167,7 +168,7 @@ export class ModelSelectorComponent extends Container { // Add scroll indicator if needed if (startIndex > 0 || endIndex < this.filteredModels.length) { - const scrollInfo = chalk.gray(` (${this.selectedIndex + 1}/${this.filteredModels.length})`); + const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredModels.length})`); this.listContainer.addChild(new Text(scrollInfo, 0, 0)); } @@ -176,10 +177,10 @@ export class ModelSelectorComponent extends Container { // Show error in red const errorLines = this.errorMessage.split("\n"); for (const line of errorLines) { - this.listContainer.addChild(new Text(chalk.red(line), 0, 0)); + this.listContainer.addChild(new Text(theme.fg("error", line), 0, 0)); } } else if (this.filteredModels.length === 0) { - this.listContainer.addChild(new Text(chalk.gray(" No matching models"), 0, 0)); + this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0)); } } diff --git a/packages/coding-agent/src/tui/oauth-selector.ts b/packages/coding-agent/src/tui/oauth-selector.ts index d66d29d8..4063b3b1 100644 --- a/packages/coding-agent/src/tui/oauth-selector.ts +++ b/packages/coding-agent/src/tui/oauth-selector.ts @@ -1,6 +1,7 @@ import { Container, Spacer, Text } from "@mariozechner/pi-tui"; -import chalk from "chalk"; import { getOAuthProviders, type OAuthProviderInfo } from "../oauth/index.js"; +import { theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; /** * Component that renders an OAuth provider selector @@ -24,12 +25,12 @@ export class OAuthSelectorComponent extends Container { this.loadProviders(); // Add top border - this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0)); + this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); // Add title const title = mode === "login" ? "Select provider to login:" : "Select provider to logout:"; - this.addChild(new Text(chalk.bold(title), 0, 0)); + this.addChild(new Text(theme.bold(title), 0, 0)); this.addChild(new Spacer(1)); // Create list container @@ -39,7 +40,7 @@ export class OAuthSelectorComponent extends Container { this.addChild(new Spacer(1)); // Add bottom border - this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0)); + this.addChild(new DynamicBorder()); // Initial render this.updateList(); @@ -62,11 +63,11 @@ export class OAuthSelectorComponent extends Container { let line = ""; if (isSelected) { - const prefix = chalk.blue("→ "); - const text = isAvailable ? chalk.blue(provider.name) : chalk.dim(provider.name); + const prefix = theme.fg("accent", "→ "); + const text = isAvailable ? theme.fg("accent", provider.name) : theme.fg("dim", provider.name); line = prefix + text; } else { - const text = isAvailable ? ` ${provider.name}` : chalk.dim(` ${provider.name}`); + const text = isAvailable ? ` ${provider.name}` : theme.fg("dim", ` ${provider.name}`); line = text; } @@ -77,7 +78,7 @@ export class OAuthSelectorComponent extends Container { if (this.allProviders.length === 0) { const message = this.mode === "login" ? "No OAuth providers available" : "No OAuth providers logged in. Use /login first."; - this.listContainer.addChild(new Text(chalk.gray(` ${message}`), 0, 0)); + this.listContainer.addChild(new Text(theme.fg("muted", ` ${message}`), 0, 0)); } } diff --git a/packages/coding-agent/src/tui/queue-mode-selector.ts b/packages/coding-agent/src/tui/queue-mode-selector.ts index 79935f7c..cebd1e5b 100644 --- a/packages/coding-agent/src/tui/queue-mode-selector.ts +++ b/packages/coding-agent/src/tui/queue-mode-selector.ts @@ -1,14 +1,6 @@ -import { type Component, Container, type SelectItem, SelectList } from "@mariozechner/pi-tui"; -import chalk from "chalk"; - -/** - * Dynamic border component that adjusts to viewport width - */ -class DynamicBorder implements Component { - render(width: number): string[] { - return [chalk.blue("─".repeat(Math.max(1, width)))]; - } -} +import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui"; +import { getSelectListTheme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; /** * Component that renders a queue mode selector with borders @@ -36,7 +28,7 @@ export class QueueModeSelectorComponent extends Container { this.addChild(new DynamicBorder()); // Create selector - this.selectList = new SelectList(queueModes, 2); + this.selectList = new SelectList(queueModes, 2, getSelectListTheme()); // Preselect current mode const currentIndex = queueModes.findIndex((item) => item.value === currentMode); diff --git a/packages/coding-agent/src/tui/session-selector.ts b/packages/coding-agent/src/tui/session-selector.ts index 3f15426d..64c94efd 100644 --- a/packages/coding-agent/src/tui/session-selector.ts +++ b/packages/coding-agent/src/tui/session-selector.ts @@ -1,15 +1,7 @@ import { type Component, Container, Input, Spacer, Text } from "@mariozechner/pi-tui"; -import chalk from "chalk"; import type { SessionManager } from "../session-manager.js"; - -/** - * Dynamic border component that adjusts to viewport width - */ -class DynamicBorder implements Component { - render(width: number): string[] { - return [chalk.blue("─".repeat(Math.max(1, width)))]; - } -} +import { theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; interface SessionItem { path: string; @@ -67,6 +59,10 @@ class SessionList implements Component { this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1)); } + invalidate(): void { + // No cached state to invalidate currently + } + render(width: number): string[] { const lines: string[] = []; @@ -75,7 +71,7 @@ class SessionList implements Component { lines.push(""); // Blank line after search if (this.filteredSessions.length === 0) { - lines.push(chalk.gray(" No sessions found")); + lines.push(theme.fg("muted", " No sessions found")); return lines; } @@ -112,16 +108,16 @@ class SessionList implements Component { const normalizedMessage = session.firstMessage.replace(/\n/g, " ").trim(); // First line: cursor + message - const cursor = isSelected ? chalk.blue("› ") : " "; + const cursor = isSelected ? theme.fg("accent", "› ") : " "; const maxMsgWidth = width - 2; // Account for cursor const truncatedMsg = normalizedMessage.substring(0, maxMsgWidth); - const messageLine = cursor + (isSelected ? chalk.bold(truncatedMsg) : truncatedMsg); + const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg); // Second line: metadata (dimmed) const modified = formatDate(session.modified); const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`; const metadata = ` ${modified} · ${msgCount}`; - const metadataLine = chalk.dim(metadata); + const metadataLine = theme.fg("dim", metadata); lines.push(messageLine); lines.push(metadataLine); @@ -130,7 +126,7 @@ class SessionList implements Component { // Add scroll indicator if needed if (startIndex > 0 || endIndex < this.filteredSessions.length) { - const scrollInfo = chalk.gray(` (${this.selectedIndex + 1}/${this.filteredSessions.length})`); + const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredSessions.length})`); lines.push(scrollInfo); } @@ -185,7 +181,7 @@ export class SessionSelectorComponent extends Container { // Add header this.addChild(new Spacer(1)); - this.addChild(new Text(chalk.bold("Resume Session"), 1, 0)); + this.addChild(new Text(theme.bold("Resume Session"), 1, 0)); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); diff --git a/packages/coding-agent/src/tui/theme-selector.ts b/packages/coding-agent/src/tui/theme-selector.ts index 862e55f0..f7ffc017 100644 --- a/packages/coding-agent/src/tui/theme-selector.ts +++ b/packages/coding-agent/src/tui/theme-selector.ts @@ -1,5 +1,5 @@ import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui"; -import { getAvailableThemes, theme } from "../theme/theme.js"; +import { getAvailableThemes, getSelectListTheme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; /** @@ -7,9 +7,16 @@ import { DynamicBorder } from "./dynamic-border.js"; */ export class ThemeSelectorComponent extends Container { private selectList: SelectList; + private onPreview: (themeName: string) => void; - constructor(currentTheme: string, onSelect: (themeName: string) => void, onCancel: () => void) { + constructor( + currentTheme: string, + onSelect: (themeName: string) => void, + onCancel: () => void, + onPreview: (themeName: string) => void, + ) { super(); + this.onPreview = onPreview; // Get available themes and create select items const themes = getAvailableThemes(); @@ -20,10 +27,10 @@ export class ThemeSelectorComponent extends Container { })); // Add top border - this.addChild(new DynamicBorder((text) => theme.fg("border", text))); + this.addChild(new DynamicBorder()); // Create selector - this.selectList = new SelectList(themeItems, 10); + this.selectList = new SelectList(themeItems, 10, getSelectListTheme()); // Preselect current theme const currentIndex = themes.indexOf(currentTheme); @@ -39,10 +46,14 @@ export class ThemeSelectorComponent extends Container { onCancel(); }; + this.selectList.onSelectionChange = (item) => { + this.onPreview(item.value); + }; + this.addChild(this.selectList); // Add bottom border - this.addChild(new DynamicBorder((text) => theme.fg("border", text))); + this.addChild(new DynamicBorder()); } getSelectList(): SelectList { diff --git a/packages/coding-agent/src/tui/thinking-selector.ts b/packages/coding-agent/src/tui/thinking-selector.ts index effe203e..e636b561 100644 --- a/packages/coding-agent/src/tui/thinking-selector.ts +++ b/packages/coding-agent/src/tui/thinking-selector.ts @@ -1,15 +1,7 @@ import type { ThinkingLevel } from "@mariozechner/pi-agent"; -import { type Component, Container, type SelectItem, SelectList } from "@mariozechner/pi-tui"; -import chalk from "chalk"; - -/** - * Dynamic border component that adjusts to viewport width - */ -class DynamicBorder implements Component { - render(width: number): string[] { - return [chalk.blue("─".repeat(Math.max(1, width)))]; - } -} +import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui"; +import { getSelectListTheme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; /** * Component that renders a thinking level selector with borders @@ -32,7 +24,7 @@ export class ThinkingSelectorComponent extends Container { this.addChild(new DynamicBorder()); // Create selector - this.selectList = new SelectList(thinkingLevels, 5); + this.selectList = new SelectList(thinkingLevels, 5, getSelectListTheme()); // Preselect current level const currentIndex = thinkingLevels.findIndex((item) => item.value === currentLevel); diff --git a/packages/coding-agent/src/tui/tool-execution.ts b/packages/coding-agent/src/tui/tool-execution.ts index e785fff0..419f6b84 100644 --- a/packages/coding-agent/src/tui/tool-execution.ts +++ b/packages/coding-agent/src/tui/tool-execution.ts @@ -1,8 +1,7 @@ import * as os from "node:os"; import { Container, Spacer, Text } from "@mariozechner/pi-tui"; -import chalk from "chalk"; -import * as Diff from "diff"; import stripAnsi from "strip-ansi"; +import { theme } from "../theme/theme.js"; /** * Convert absolute path to tilde notation if it's in home directory @@ -22,104 +21,6 @@ function replaceTabs(text: string): string { return text.replace(/\t/g, " "); } -/** - * Generate a unified diff with line numbers and context - */ -function generateDiff(oldStr: string, newStr: string): string { - const parts = Diff.diffLines(oldStr, newStr); - const output: string[] = []; - - // Calculate max line number for padding - const oldLines = oldStr.split("\n"); - const newLines = newStr.split("\n"); - const maxLineNum = Math.max(oldLines.length, newLines.length); - const lineNumWidth = String(maxLineNum).length; - - const CONTEXT_LINES = 2; // Show 2 lines of context around changes - - let oldLineNum = 1; - let newLineNum = 1; - let lastWasChange = false; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const raw = part.value.split("\n"); - if (raw[raw.length - 1] === "") { - raw.pop(); - } - - if (part.added || part.removed) { - // Show the change - for (const line of raw) { - if (part.added) { - const lineNum = String(newLineNum).padStart(lineNumWidth, " "); - output.push(chalk.green(`${lineNum} ${line}`)); - newLineNum++; - } else { - // removed - const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); - output.push(chalk.red(`${lineNum} ${line}`)); - oldLineNum++; - } - } - lastWasChange = true; - } else { - // Context lines - only show a few before/after changes - const isFirstPart = i === 0; - const isLastPart = i === parts.length - 1; - const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed); - - if (lastWasChange || nextPartIsChange || isFirstPart || isLastPart) { - // Show context - let linesToShow = raw; - let skipStart = 0; - let skipEnd = 0; - - if (!isFirstPart && !lastWasChange) { - // Show only last N lines as leading context - skipStart = Math.max(0, raw.length - CONTEXT_LINES); - linesToShow = raw.slice(skipStart); - } - - if (!isLastPart && !nextPartIsChange && linesToShow.length > CONTEXT_LINES) { - // Show only first N lines as trailing context - skipEnd = linesToShow.length - CONTEXT_LINES; - linesToShow = linesToShow.slice(0, CONTEXT_LINES); - } - - // Add ellipsis if we skipped lines at start - if (skipStart > 0) { - output.push(chalk.dim(`${"".padStart(lineNumWidth, " ")} ...`)); - } - - for (const line of linesToShow) { - const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); - output.push(chalk.dim(`${lineNum} ${line}`)); - oldLineNum++; - newLineNum++; - } - - // Add ellipsis if we skipped lines at end - if (skipEnd > 0) { - output.push(chalk.dim(`${"".padStart(lineNumWidth, " ")} ...`)); - } - - // Update line numbers for skipped lines - oldLineNum += skipStart + skipEnd; - newLineNum += skipStart + skipEnd; - } else { - // Skip these context lines entirely - oldLineNum += raw.length; - newLineNum += raw.length; - } - - lastWasChange = false; - } - } - - return output.join("\n"); -} - /** * Component that renders a tool call with its result (updateable) */ @@ -140,7 +41,7 @@ export class ToolExecutionComponent extends Container { this.args = args; this.addChild(new Spacer(1)); // Content with colored background and padding - this.contentText = new Text("", 1, 1, { r: 40, g: 40, b: 50 }); + this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text)); this.addChild(this.contentText); this.updateDisplay(); } @@ -165,13 +66,13 @@ export class ToolExecutionComponent extends Container { } private updateDisplay(): void { - const bgColor = this.result + const bgFn = this.result ? this.result.isError - ? { r: 60, g: 40, b: 40 } - : { r: 40, g: 50, b: 40 } - : { r: 40, g: 40, b: 50 }; + ? (text: string) => theme.bg("toolErrorBg", text) + : (text: string) => theme.bg("toolSuccessBg", text) + : (text: string) => theme.bg("toolPendingBg", text); - this.contentText.setCustomBgRgb(bgColor); + this.contentText.setCustomBgFn(bgFn); this.contentText.setText(this.formatToolExecution()); } @@ -200,7 +101,7 @@ export class ToolExecutionComponent extends Container { // Format based on tool type if (this.toolName === "bash") { const command = this.args?.command || ""; - text = chalk.bold(`$ ${command || chalk.dim("...")}`); + text = theme.fg("toolTitle", theme.bold(`$ ${command || theme.fg("toolOutput", "...")}`)); if (this.result) { // Show output without code fences - more minimal @@ -211,9 +112,9 @@ export class ToolExecutionComponent extends Container { const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; - text += "\n\n" + displayLines.map((line: string) => chalk.dim(line)).join("\n"); + text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n"); if (remaining > 0) { - text += chalk.dim(`\n... (${remaining} more lines)`); + text += theme.fg("toolOutput", `\n... (${remaining} more lines)`); } } } @@ -223,13 +124,13 @@ export class ToolExecutionComponent extends Container { const limit = this.args?.limit; // Build path display with offset/limit suffix - let pathDisplay = path ? chalk.cyan(path) : chalk.dim("..."); + let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); if (offset !== undefined) { const endLine = limit !== undefined ? offset + limit : ""; - pathDisplay += chalk.dim(`:${offset}${endLine ? `-${endLine}` : ""}`); + pathDisplay += theme.fg("toolOutput", `:${offset}${endLine ? `-${endLine}` : ""}`); } - text = chalk.bold("read") + " " + pathDisplay; + text = theme.fg("toolTitle", theme.bold("read")) + " " + pathDisplay; if (this.result) { const output = this.getTextOutput(); @@ -238,9 +139,9 @@ export class ToolExecutionComponent extends Container { const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; - text += "\n\n" + displayLines.map((line: string) => chalk.dim(replaceTabs(line))).join("\n"); + text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", replaceTabs(line))).join("\n"); if (remaining > 0) { - text += chalk.dim(`\n... (${remaining} more lines)`); + text += theme.fg("toolOutput", `\n... (${remaining} more lines)`); } } } else if (this.toolName === "write") { @@ -249,7 +150,10 @@ export class ToolExecutionComponent extends Container { const lines = fileContent ? fileContent.split("\n") : []; const totalLines = lines.length; - text = chalk.bold("write") + " " + (path ? chalk.cyan(path) : chalk.dim("...")); + text = + theme.fg("toolTitle", theme.bold("write")) + + " " + + (path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")); if (totalLines > 10) { text += ` (${totalLines} lines)`; } @@ -260,32 +164,35 @@ export class ToolExecutionComponent extends Container { const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; - text += "\n\n" + displayLines.map((line: string) => chalk.dim(replaceTabs(line))).join("\n"); + text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", replaceTabs(line))).join("\n"); if (remaining > 0) { - text += chalk.dim(`\n... (${remaining} more lines)`); + text += theme.fg("toolOutput", `\n... (${remaining} more lines)`); } } } else if (this.toolName === "edit") { const path = shortenPath(this.args?.file_path || this.args?.path || ""); - text = chalk.bold("edit") + " " + (path ? chalk.cyan(path) : chalk.dim("...")); + text = + theme.fg("toolTitle", theme.bold("edit")) + + " " + + (path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")); if (this.result) { // Show error message if it's an error if (this.result.isError) { const errorText = this.getTextOutput(); if (errorText) { - text += "\n\n" + chalk.red(errorText); + text += "\n\n" + theme.fg("error", errorText); } } else if (this.result.details?.diff) { // Show diff if available const diffLines = this.result.details.diff.split("\n"); const coloredLines = diffLines.map((line: string) => { if (line.startsWith("+")) { - return chalk.green(line); + return theme.fg("toolDiffAdded", line); } else if (line.startsWith("-")) { - return chalk.red(line); + return theme.fg("toolDiffRemoved", line); } else { - return chalk.dim(line); + return theme.fg("toolDiffContext", line); } }); text += "\n\n" + coloredLines.join("\n"); @@ -293,7 +200,7 @@ export class ToolExecutionComponent extends Container { } } else { // Generic tool - text = chalk.bold(this.toolName); + text = theme.fg("toolTitle", theme.bold(this.toolName)); const content = JSON.stringify(this.args, null, 2); text += "\n\n" + content; diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 644efcf2..c3de2e36 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -13,7 +13,7 @@ import { TruncatedText, TUI, } from "@mariozechner/pi-tui"; -import chalk from "chalk"; + import { exec } from "child_process"; import { getChangelogPath, parseChangelog } from "../changelog.js"; import { exportSessionToHtml } from "../export-html.js"; @@ -21,7 +21,7 @@ import { getApiKeyForModel, getAvailableModels } from "../model-config.js"; import { listOAuthProviders, login, logout } from "../oauth/index.js"; import type { SessionManager } from "../session-manager.js"; import type { SettingsManager } from "../settings-manager.js"; -import { setTheme } from "../theme/theme.js"; +import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js"; import { AssistantMessageComponent } from "./assistant-message.js"; import { CustomEditor } from "./custom-editor.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -114,7 +114,7 @@ export class TuiRenderer { this.chatContainer = new Container(); this.pendingMessagesContainer = new Container(); this.statusContainer = new Container(); - this.editor = new CustomEditor(); + this.editor = new CustomEditor(getEditorTheme()); this.editorContainer = new Container(); // Container to hold editor or selector this.editorContainer.addChild(this.editor); // Start with editor this.footer = new FooterComponent(agent.state); @@ -193,34 +193,34 @@ export class TuiRenderer { if (this.isInitialized) return; // Add header with logo and instructions - const logo = chalk.bold.cyan("pi") + chalk.dim(` v${this.version}`); + const logo = theme.bold(theme.fg("accent", "pi")) + theme.fg("dim", ` v${this.version}`); const instructions = - chalk.dim("esc") + - chalk.gray(" to interrupt") + + theme.fg("dim", "esc") + + theme.fg("muted", " to interrupt") + "\n" + - chalk.dim("ctrl+c") + - chalk.gray(" to clear") + + theme.fg("dim", "ctrl+c") + + theme.fg("muted", " to clear") + "\n" + - chalk.dim("ctrl+c twice") + - chalk.gray(" to exit") + + theme.fg("dim", "ctrl+c twice") + + theme.fg("muted", " to exit") + "\n" + - chalk.dim("ctrl+k") + - chalk.gray(" to delete line") + + theme.fg("dim", "ctrl+k") + + theme.fg("muted", " to delete line") + "\n" + - chalk.dim("shift+tab") + - chalk.gray(" to cycle thinking") + + theme.fg("dim", "shift+tab") + + theme.fg("muted", " to cycle thinking") + "\n" + - chalk.dim("ctrl+p") + - chalk.gray(" to cycle models") + + theme.fg("dim", "ctrl+p") + + theme.fg("muted", " to cycle models") + "\n" + - chalk.dim("ctrl+o") + - chalk.gray(" to expand tools") + + theme.fg("dim", "ctrl+o") + + theme.fg("muted", " to expand tools") + "\n" + - chalk.dim("/") + - chalk.gray(" for commands") + + theme.fg("dim", "/") + + theme.fg("muted", " for commands") + "\n" + - chalk.dim("drop files") + - chalk.gray(" to attach"); + theme.fg("dim", "drop files") + + theme.fg("muted", " to attach"); const header = new Text(logo + "\n" + instructions, 1, 0); // Setup UI layout @@ -230,28 +230,28 @@ export class TuiRenderer { // Add new version notification if available if (this.newVersion) { - this.ui.addChild(new DynamicBorder(chalk.yellow)); + this.ui.addChild(new DynamicBorder((text) => theme.fg("warning", text))); this.ui.addChild( new Text( - chalk.bold.yellow("Update Available") + + theme.bold(theme.fg("warning", "Update Available")) + "\n" + - chalk.gray(`New version ${this.newVersion} is available. Run: `) + - chalk.cyan("npm install -g @mariozechner/pi-coding-agent"), + theme.fg("muted", `New version ${this.newVersion} is available. Run: `) + + theme.fg("accent", "npm install -g @mariozechner/pi-coding-agent"), 1, 0, ), ); - this.ui.addChild(new DynamicBorder(chalk.yellow)); + this.ui.addChild(new DynamicBorder((text) => theme.fg("warning", text))); } // Add changelog if provided if (this.changelogMarkdown) { - this.ui.addChild(new DynamicBorder(chalk.cyan)); - this.ui.addChild(new Text(chalk.bold.cyan("What's New"), 1, 0)); + this.ui.addChild(new DynamicBorder()); + this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0)); this.ui.addChild(new Spacer(1)); - this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0)); + this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme())); this.ui.addChild(new Spacer(1)); - this.ui.addChild(new DynamicBorder(chalk.cyan)); + this.ui.addChild(new DynamicBorder()); } this.ui.addChild(this.chatContainer); @@ -435,6 +435,13 @@ export class TuiRenderer { // Start the UI this.ui.start(); this.isInitialized = true; + + // Set up theme file watcher for live reload + onThemeChange(() => { + this.ui.invalidate(); + this.updateEditorBorderColor(); + this.ui.requestRender(); + }); } async handleEvent(event: AgentEvent, state: AgentState): Promise { @@ -454,7 +461,12 @@ export class TuiRenderer { this.loadingAnimation.stop(); } this.statusContainer.clear(); - this.loadingAnimation = new Loader(this.ui, "Working... (esc to interrupt)"); + this.loadingAnimation = new Loader( + this.ui, + (spinner) => theme.fg("accent", spinner), + (text) => theme.fg("muted", text), + "Working... (esc to interrupt)", + ); this.statusContainer.addChild(this.loadingAnimation); this.ui.requestRender(); break; @@ -718,28 +730,9 @@ export class TuiRenderer { } } - private getThinkingBorderColor(level: ThinkingLevel): (str: string) => string { - // More thinking = more color (gray → dim colors → bright colors) - switch (level) { - case "off": - return chalk.gray; - case "minimal": - return chalk.dim.blue; - case "low": - return chalk.blue; - case "medium": - return chalk.cyan; - case "high": - return chalk.magenta; - default: - return chalk.gray; - } - } - private updateEditorBorderColor(): void { const level = this.agent.state.thinkingLevel || "off"; - const color = this.getThinkingBorderColor(level); - this.editor.borderColor = color; + this.editor.borderColor = theme.getThinkingBorderColor(level); this.ui.requestRender(); } @@ -747,7 +740,7 @@ export class TuiRenderer { // Only cycle if model supports thinking if (!this.agent.state.model?.reasoning) { this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(chalk.dim("Current model does not support thinking"), 1, 0)); + this.chatContainer.addChild(new Text(theme.fg("dim", "Current model does not support thinking"), 1, 0)); this.ui.requestRender(); return; } @@ -769,7 +762,7 @@ export class TuiRenderer { // Show brief notification this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(chalk.dim(`Thinking level: ${nextLevel}`), 1, 0)); + this.chatContainer.addChild(new Text(theme.fg("dim", `Thinking level: ${nextLevel}`), 1, 0)); this.ui.requestRender(); } @@ -794,7 +787,7 @@ export class TuiRenderer { if (modelsToUse.length === 1) { this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(chalk.dim("Only one model in scope"), 1, 0)); + this.chatContainer.addChild(new Text(theme.fg("dim", "Only one model in scope"), 1, 0)); this.ui.requestRender(); return; } @@ -824,7 +817,7 @@ export class TuiRenderer { // Show notification this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(chalk.dim(`Switched to ${nextModel.name || nextModel.id}`), 1, 0)); + this.chatContainer.addChild(new Text(theme.fg("dim", `Switched to ${nextModel.name || nextModel.id}`), 1, 0)); this.ui.requestRender(); } @@ -849,14 +842,14 @@ export class TuiRenderer { showError(errorMessage: string): void { // Show error message in the chat this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(chalk.red(`Error: ${errorMessage}`), 1, 0)); + this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0)); this.ui.requestRender(); } showWarning(warningMessage: string): void { // Show warning message in the chat this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(chalk.yellow(`Warning: ${warningMessage}`), 1, 0)); + this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0)); this.ui.requestRender(); } @@ -876,7 +869,7 @@ export class TuiRenderer { // Show confirmation message with proper spacing this.chatContainer.addChild(new Spacer(1)); - const confirmText = new Text(chalk.dim(`Thinking level: ${level}`), 1, 0); + const confirmText = new Text(theme.fg("dim", `Thinking level: ${level}`), 1, 0); this.chatContainer.addChild(confirmText); // Hide selector and show editor again @@ -918,7 +911,7 @@ export class TuiRenderer { // Show confirmation message with proper spacing this.chatContainer.addChild(new Spacer(1)); - const confirmText = new Text(chalk.dim(`Queue mode: ${mode}`), 1, 0); + const confirmText = new Text(theme.fg("dim", `Queue mode: ${mode}`), 1, 0); this.chatContainer.addChild(confirmText); // Hide selector and show editor again @@ -956,15 +949,27 @@ export class TuiRenderer { currentTheme, (themeName) => { // Apply the selected theme - setTheme(themeName); + const result = setTheme(themeName); // Save theme to settings this.settingsManager.setTheme(themeName); - // Show confirmation message with proper spacing + // Invalidate all components to clear cached rendering + this.ui.invalidate(); + + // Show confirmation or error message this.chatContainer.addChild(new Spacer(1)); - const confirmText = new Text(chalk.dim(`Theme: ${themeName}`), 1, 0); - this.chatContainer.addChild(confirmText); + if (result.success) { + const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0); + this.chatContainer.addChild(confirmText); + } else { + const errorText = new Text( + theme.fg("error", `Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`), + 1, + 0, + ); + this.chatContainer.addChild(errorText); + } // Hide selector and show editor again this.hideThemeSelector(); @@ -975,6 +980,15 @@ export class TuiRenderer { this.hideThemeSelector(); this.ui.requestRender(); }, + (themeName) => { + // Preview theme on selection change + const result = setTheme(themeName); + if (result.success) { + this.ui.invalidate(); + this.ui.requestRender(); + } + // If failed, theme already fell back to dark, just don't re-render + }, ); // Replace editor with selector @@ -1007,7 +1021,7 @@ export class TuiRenderer { // Show confirmation message with proper spacing this.chatContainer.addChild(new Spacer(1)); - const confirmText = new Text(chalk.dim(`Model: ${model.id}`), 1, 0); + const confirmText = new Text(theme.fg("dim", `Model: ${model.id}`), 1, 0); this.chatContainer.addChild(confirmText); // Hide selector and show editor again @@ -1055,7 +1069,7 @@ export class TuiRenderer { // Don't show selector if there are no messages or only one message if (userMessages.length <= 1) { this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(chalk.dim("No messages to branch from"), 1, 0)); + this.chatContainer.addChild(new Text(theme.fg("dim", "No messages to branch from"), 1, 0)); this.ui.requestRender(); return; } @@ -1088,7 +1102,7 @@ export class TuiRenderer { // Show confirmation message this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( - new Text(chalk.dim(`Branched to new session from message ${messageIndex}`), 1, 0), + new Text(theme.fg("dim", `Branched to new session from message ${messageIndex}`), 1, 0), ); // Put the selected message in the editor @@ -1127,7 +1141,9 @@ export class TuiRenderer { const loggedInProviders = listOAuthProviders(); if (loggedInProviders.length === 0) { this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(chalk.dim("No OAuth providers logged in. Use /login first."), 1, 0)); + this.chatContainer.addChild( + new Text(theme.fg("dim", "No OAuth providers logged in. Use /login first."), 1, 0), + ); this.ui.requestRender(); return; } @@ -1144,7 +1160,7 @@ export class TuiRenderer { if (mode === "login") { // Handle login this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(chalk.dim(`Logging in to ${providerId}...`), 1, 0)); + this.chatContainer.addChild(new Text(theme.fg("dim", `Logging in to ${providerId}...`), 1, 0)); this.ui.requestRender(); try { @@ -1153,11 +1169,11 @@ export class TuiRenderer { (url: string) => { // Show auth URL to user this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(chalk.cyan("Opening browser to:"), 1, 0)); - this.chatContainer.addChild(new Text(chalk.cyan(url), 1, 0)); + this.chatContainer.addChild(new Text(theme.fg("accent", "Opening browser to:"), 1, 0)); + this.chatContainer.addChild(new Text(theme.fg("accent", url), 1, 0)); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( - new Text(chalk.yellow("Paste the authorization code below:"), 1, 0), + new Text(theme.fg("warning", "Paste the authorization code below:"), 1, 0), ); this.ui.requestRender(); @@ -1189,8 +1205,12 @@ export class TuiRenderer { // Success this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(chalk.green(`✓ Successfully logged in to ${providerId}`), 1, 0)); - this.chatContainer.addChild(new Text(chalk.dim(`Tokens saved to ~/.pi/agent/oauth.json`), 1, 0)); + this.chatContainer.addChild( + new Text(theme.fg("success", `✓ Successfully logged in to ${providerId}`), 1, 0), + ); + this.chatContainer.addChild( + new Text(theme.fg("dim", `Tokens saved to ~/.pi/agent/oauth.json`), 1, 0), + ); this.ui.requestRender(); } catch (error: any) { this.showError(`Login failed: ${error.message}`); @@ -1202,10 +1222,10 @@ export class TuiRenderer { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( - new Text(chalk.green(`✓ Successfully logged out of ${providerId}`), 1, 0), + new Text(theme.fg("success", `✓ Successfully logged out of ${providerId}`), 1, 0), ); this.chatContainer.addChild( - new Text(chalk.dim(`Credentials removed from ~/.pi/agent/oauth.json`), 1, 0), + new Text(theme.fg("dim", `Credentials removed from ~/.pi/agent/oauth.json`), 1, 0), ); this.ui.requestRender(); } catch (error: any) { @@ -1246,13 +1266,13 @@ export class TuiRenderer { // Show success message in chat - matching thinking level style this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(chalk.dim(`Session exported to: ${filePath}`), 1, 0)); + this.chatContainer.addChild(new Text(theme.fg("dim", `Session exported to: ${filePath}`), 1, 0)); this.ui.requestRender(); } catch (error: any) { // Show error message in chat this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( - new Text(chalk.red(`Failed to export session: ${error.message || "Unknown error"}`), 1, 0), + new Text(theme.fg("error", `Failed to export session: ${error.message || "Unknown error"}`), 1, 0), ); this.ui.requestRender(); } @@ -1299,29 +1319,29 @@ export class TuiRenderer { const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite; // Build info text - let info = `${chalk.bold("Session Info")}\n\n`; - info += `${chalk.dim("File:")} ${sessionFile}\n`; - info += `${chalk.dim("ID:")} ${this.sessionManager.getSessionId()}\n\n`; - info += `${chalk.bold("Messages")}\n`; - info += `${chalk.dim("User:")} ${userMessages}\n`; - info += `${chalk.dim("Assistant:")} ${assistantMessages}\n`; - info += `${chalk.dim("Tool Calls:")} ${toolCalls}\n`; - info += `${chalk.dim("Tool Results:")} ${toolResults}\n`; - info += `${chalk.dim("Total:")} ${totalMessages}\n\n`; - info += `${chalk.bold("Tokens")}\n`; - info += `${chalk.dim("Input:")} ${totalInput.toLocaleString()}\n`; - info += `${chalk.dim("Output:")} ${totalOutput.toLocaleString()}\n`; + let info = `${theme.bold("Session Info")}\n\n`; + info += `${theme.fg("dim", "File:")} ${sessionFile}\n`; + info += `${theme.fg("dim", "ID:")} ${this.sessionManager.getSessionId()}\n\n`; + info += `${theme.bold("Messages")}\n`; + info += `${theme.fg("dim", "User:")} ${userMessages}\n`; + info += `${theme.fg("dim", "Assistant:")} ${assistantMessages}\n`; + info += `${theme.fg("dim", "Tool Calls:")} ${toolCalls}\n`; + info += `${theme.fg("dim", "Tool Results:")} ${toolResults}\n`; + info += `${theme.fg("dim", "Total:")} ${totalMessages}\n\n`; + info += `${theme.bold("Tokens")}\n`; + info += `${theme.fg("dim", "Input:")} ${totalInput.toLocaleString()}\n`; + info += `${theme.fg("dim", "Output:")} ${totalOutput.toLocaleString()}\n`; if (totalCacheRead > 0) { - info += `${chalk.dim("Cache Read:")} ${totalCacheRead.toLocaleString()}\n`; + info += `${theme.fg("dim", "Cache Read:")} ${totalCacheRead.toLocaleString()}\n`; } if (totalCacheWrite > 0) { - info += `${chalk.dim("Cache Write:")} ${totalCacheWrite.toLocaleString()}\n`; + info += `${theme.fg("dim", "Cache Write:")} ${totalCacheWrite.toLocaleString()}\n`; } - info += `${chalk.dim("Total:")} ${totalTokens.toLocaleString()}\n`; + info += `${theme.fg("dim", "Total:")} ${totalTokens.toLocaleString()}\n`; if (totalCost > 0) { - info += `\n${chalk.bold("Cost")}\n`; - info += `${chalk.dim("Total:")} ${totalCost.toFixed(4)}`; + info += `\n${theme.bold("Cost")}\n`; + info += `${theme.fg("dim", "Total:")} ${totalCost.toFixed(4)}`; } // Show info in chat @@ -1345,11 +1365,11 @@ export class TuiRenderer { // Display in chat this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new DynamicBorder(chalk.cyan)); - this.ui.addChild(new Text(chalk.bold.cyan("What's New"), 1, 0)); + this.chatContainer.addChild(new DynamicBorder()); + this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0)); this.ui.addChild(new Spacer(1)); - this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1)); - this.chatContainer.addChild(new DynamicBorder(chalk.cyan)); + this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, getMarkdownTheme())); + this.chatContainer.addChild(new DynamicBorder()); this.ui.requestRender(); } @@ -1360,7 +1380,7 @@ export class TuiRenderer { this.pendingMessagesContainer.addChild(new Spacer(1)); for (const message of this.queuedMessages) { - const queuedText = chalk.dim("Queued: " + message); + const queuedText = theme.fg("dim", "Queued: " + message); this.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0)); } } diff --git a/packages/coding-agent/src/tui/user-message-selector.ts b/packages/coding-agent/src/tui/user-message-selector.ts index 1482984a..ff1f3596 100644 --- a/packages/coding-agent/src/tui/user-message-selector.ts +++ b/packages/coding-agent/src/tui/user-message-selector.ts @@ -1,20 +1,6 @@ import { type Component, Container, Spacer, Text } from "@mariozechner/pi-tui"; -import chalk from "chalk"; - -/** - * Dynamic border component that adjusts to viewport width - */ -class DynamicBorder implements Component { - private colorFn: (text: string) => string; - - constructor(colorFn: (text: string) => string = chalk.blue) { - this.colorFn = colorFn; - } - - render(width: number): string[] { - return [this.colorFn("─".repeat(Math.max(1, width)))]; - } -} +import { theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; interface UserMessageItem { index: number; // Index in the full messages array @@ -39,11 +25,15 @@ class UserMessageList implements Component { this.selectedIndex = Math.max(0, messages.length - 1); } + invalidate(): void { + // No cached state to invalidate currently + } + render(width: number): string[] { const lines: string[] = []; if (this.messages.length === 0) { - lines.push(chalk.gray(" No user messages found")); + lines.push(theme.fg("muted", " No user messages found")); return lines; } @@ -63,24 +53,24 @@ class UserMessageList implements Component { const normalizedMessage = message.text.replace(/\n/g, " ").trim(); // First line: cursor + message - const cursor = isSelected ? chalk.blue("› ") : " "; + const cursor = isSelected ? theme.fg("accent", "› ") : " "; const maxMsgWidth = width - 2; // Account for cursor const truncatedMsg = normalizedMessage.substring(0, maxMsgWidth); - const messageLine = cursor + (isSelected ? chalk.bold(truncatedMsg) : truncatedMsg); + const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg); lines.push(messageLine); // Second line: metadata (position in history) const position = i + 1; const metadata = ` Message ${position} of ${this.messages.length}`; - const metadataLine = chalk.dim(metadata); + const metadataLine = theme.fg("muted", metadata); lines.push(metadataLine); lines.push(""); // Blank line between messages } // Add scroll indicator if needed if (startIndex > 0 || endIndex < this.messages.length) { - const scrollInfo = chalk.gray(` (${this.selectedIndex + 1}/${this.messages.length})`); + const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.messages.length})`); lines.push(scrollInfo); } @@ -129,8 +119,8 @@ export class UserMessageSelectorComponent extends Container { // Add header this.addChild(new Spacer(1)); - this.addChild(new Text(chalk.bold("Branch from Message"), 1, 0)); - this.addChild(new Text(chalk.dim("Select a message to create a new branch from that point"), 1, 0)); + this.addChild(new Text(theme.bold("Branch from Message"), 1, 0)); + this.addChild(new Text(theme.fg("muted", "Select a message to create a new branch from that point"), 1, 0)); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); diff --git a/packages/coding-agent/src/tui/user-message.ts b/packages/coding-agent/src/tui/user-message.ts index 25833f41..dfeee875 100644 --- a/packages/coding-agent/src/tui/user-message.ts +++ b/packages/coding-agent/src/tui/user-message.ts @@ -1,4 +1,5 @@ import { Container, Markdown, Spacer } from "@mariozechner/pi-tui"; +import { getMarkdownTheme, theme } from "../theme/theme.js"; /** * Component that renders a user message @@ -11,6 +12,11 @@ export class UserMessageComponent extends Container { if (!isFirst) { this.addChild(new Spacer(1)); } - this.addChild(new Markdown(text, 1, 1, { bgColor: "#343541" })); + this.addChild( + new Markdown(text, 1, 1, getMarkdownTheme(), { + bgColor: (text: string) => theme.bg("userMessageBg", text), + color: (text: string) => theme.fg("userMessageText", text), + }), + ); } } diff --git a/packages/coding-agent/test/test-theme-colors.ts b/packages/coding-agent/test/test-theme-colors.ts new file mode 100644 index 00000000..12beb253 --- /dev/null +++ b/packages/coding-agent/test/test-theme-colors.ts @@ -0,0 +1,75 @@ +import { initTheme, theme } from "../src/theme/theme.js"; + +// Initialize with dark theme explicitly +process.env.COLORTERM = "truecolor"; +initTheme("dark"); + +console.log("\n=== Foreground Colors ===\n"); + +// Core UI colors +console.log("accent:", theme.fg("accent", "Sample text")); +console.log("border:", theme.fg("border", "Sample text")); +console.log("borderAccent:", theme.fg("borderAccent", "Sample text")); +console.log("borderMuted:", theme.fg("borderMuted", "Sample text")); +console.log("success:", theme.fg("success", "Sample text")); +console.log("error:", theme.fg("error", "Sample text")); +console.log("warning:", theme.fg("warning", "Sample text")); +console.log("muted:", theme.fg("muted", "Sample text")); +console.log("dim:", theme.fg("dim", "Sample text")); +console.log("text:", theme.fg("text", "Sample text")); + +console.log("\n=== Message Text Colors ===\n"); +console.log("userMessageText:", theme.fg("userMessageText", "Sample text")); +console.log("toolTitle:", theme.fg("toolTitle", "Sample text")); +console.log("toolOutput:", theme.fg("toolOutput", "Sample text")); + +console.log("\n=== Markdown Colors ===\n"); +console.log("mdHeading:", theme.fg("mdHeading", "Sample text")); +console.log("mdLink:", theme.fg("mdLink", "Sample text")); +console.log("mdCode:", theme.fg("mdCode", "Sample text")); +console.log("mdCodeBlock:", theme.fg("mdCodeBlock", "Sample text")); +console.log("mdCodeBlockBorder:", theme.fg("mdCodeBlockBorder", "Sample text")); +console.log("mdQuote:", theme.fg("mdQuote", "Sample text")); +console.log("mdQuoteBorder:", theme.fg("mdQuoteBorder", "Sample text")); +console.log("mdHr:", theme.fg("mdHr", "Sample text")); +console.log("mdListBullet:", theme.fg("mdListBullet", "Sample text")); + +console.log("\n=== Tool Diff Colors ===\n"); +console.log("toolDiffAdded:", theme.fg("toolDiffAdded", "Sample text")); +console.log("toolDiffRemoved:", theme.fg("toolDiffRemoved", "Sample text")); +console.log("toolDiffContext:", theme.fg("toolDiffContext", "Sample text")); + +console.log("\n=== Thinking Border Colors ===\n"); +console.log("thinkingOff:", theme.fg("thinkingOff", "Sample text")); +console.log("thinkingMinimal:", theme.fg("thinkingMinimal", "Sample text")); +console.log("thinkingLow:", theme.fg("thinkingLow", "Sample text")); +console.log("thinkingMedium:", theme.fg("thinkingMedium", "Sample text")); +console.log("thinkingHigh:", theme.fg("thinkingHigh", "Sample text")); + +console.log("\n=== Background Colors ===\n"); +console.log("userMessageBg:", theme.bg("userMessageBg", " Sample background text ")); +console.log("toolPendingBg:", theme.bg("toolPendingBg", " Sample background text ")); +console.log("toolSuccessBg:", theme.bg("toolSuccessBg", " Sample background text ")); +console.log("toolErrorBg:", theme.bg("toolErrorBg", " Sample background text ")); + +console.log("\n=== Raw ANSI Codes ===\n"); +console.log("thinkingMedium ANSI:", JSON.stringify(theme.getFgAnsi("thinkingMedium"))); +console.log("accent ANSI:", JSON.stringify(theme.getFgAnsi("accent"))); +console.log("muted ANSI:", JSON.stringify(theme.getFgAnsi("muted"))); +console.log("dim ANSI:", JSON.stringify(theme.getFgAnsi("dim"))); + +console.log("\n=== Direct RGB Test ===\n"); +console.log("Gray #6c6c6c: \x1b[38;2;108;108;108mSample text\x1b[0m"); +console.log("Gray #444444: \x1b[38;2;68;68;68mSample text\x1b[0m"); +console.log("Gray #303030: \x1b[38;2;48;48;48mSample text\x1b[0m"); + +console.log("\n=== Hex Color Test ===\n"); +console.log("Direct #00d7ff test: \x1b[38;2;0;215;255mBRIGHT CYAN\x1b[0m"); +console.log("Theme cyan (should match above):", theme.fg("accent", "BRIGHT CYAN")); + +console.log("\n=== Environment ===\n"); +console.log("TERM:", process.env.TERM); +console.log("COLORTERM:", process.env.COLORTERM); +console.log("Color mode:", theme.getColorMode()); + +console.log("\n"); diff --git a/packages/pods/package.json b/packages/pods/package.json index 1ec4c806..4e853887 100644 --- a/packages/pods/package.json +++ b/packages/pods/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi", - "version": "0.7.29", + "version": "0.8.0", "description": "CLI tool for managing vLLM deployments on GPU pods", "type": "module", "bin": { @@ -34,7 +34,7 @@ "node": ">=20.0.0" }, "dependencies": { - "@mariozechner/pi-agent": "^0.7.29", + "@mariozechner/pi-agent": "^0.8.0", "chalk": "^5.5.0" }, "devDependencies": {} diff --git a/packages/proxy/package.json b/packages/proxy/package.json index d40d19b4..af68b005 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-proxy", - "version": "0.7.29", + "version": "0.8.0", "type": "module", "description": "CORS and authentication proxy for pi-ai", "main": "dist/index.js", diff --git a/packages/tui/package.json b/packages/tui/package.json index a0cc06d5..594411f5 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-tui", - "version": "0.7.29", + "version": "0.8.0", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 053704aa..78424fd5 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,7 +1,6 @@ -import chalk from "chalk"; import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; import type { Component } from "../tui.js"; -import { SelectList } from "./select-list.js"; +import { SelectList, type SelectListTheme } from "./select-list.js"; interface EditorState { lines: string[]; @@ -15,8 +14,9 @@ interface LayoutLine { cursorPos?: number; } -export interface TextEditorConfig { - // Configuration options for text editor (none currently) +export interface EditorTheme { + borderColor: (str: string) => string; + selectList: SelectListTheme; } export class Editor implements Component { @@ -26,10 +26,10 @@ export class Editor implements Component { cursorCol: 0, }; - private config: TextEditorConfig = {}; + private theme: EditorTheme; // Border color (can be changed dynamically) - public borderColor: (str: string) => string = chalk.gray; + public borderColor: (str: string) => string; // Autocomplete support private autocompleteProvider?: AutocompleteProvider; @@ -49,20 +49,19 @@ export class Editor implements Component { public onChange?: (text: string) => void; public disableSubmit: boolean = false; - constructor(config?: TextEditorConfig) { - if (config) { - this.config = { ...this.config, ...config }; - } - } - - configure(config: Partial): void { - this.config = { ...this.config, ...config }; + constructor(theme: EditorTheme) { + this.theme = theme; + this.borderColor = theme.borderColor; } setAutocompleteProvider(provider: AutocompleteProvider): void { this.autocompleteProvider = provider; } + invalidate(): void { + // No cached state to invalidate currently + } + render(width: number): string[] { const horizontal = this.borderColor("─"); @@ -806,7 +805,7 @@ export class Editor implements Component { if (suggestions && suggestions.items.length > 0) { this.autocompletePrefix = suggestions.prefix; - this.autocompleteList = new SelectList(suggestions.items, 5); + this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList); this.isAutocompleting = true; } else { this.cancelAutocomplete(); @@ -851,7 +850,7 @@ export class Editor implements Component { if (suggestions && suggestions.items.length > 0) { this.autocompletePrefix = suggestions.prefix; - this.autocompleteList = new SelectList(suggestions.items, 5); + this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList); this.isAutocompleting = true; } else { this.cancelAutocomplete(); @@ -881,7 +880,7 @@ export class Editor implements Component { this.autocompletePrefix = suggestions.prefix; if (this.autocompleteList) { // Update the existing list with new items - this.autocompleteList = new SelectList(suggestions.items, 5); + this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList); } } else { // No more matches, cancel autocomplete diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index a0e72cca..f4f11f6a 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -129,6 +129,10 @@ export class Input implements Component { this.cursor += cleanText.length; } + invalidate(): void { + // No cached state to invalidate currently + } + render(width: number): string[] { // Calculate visible window const prompt = "> "; diff --git a/packages/tui/src/components/loader.ts b/packages/tui/src/components/loader.ts index 6ea4b84c..b071e8ee 100644 --- a/packages/tui/src/components/loader.ts +++ b/packages/tui/src/components/loader.ts @@ -1,4 +1,3 @@ -import chalk from "chalk"; import type { TUI } from "../tui.js"; import { Text } from "./text.js"; @@ -13,6 +12,8 @@ export class Loader extends Text { constructor( ui: TUI, + private spinnerColorFn: (str: string) => string, + private messageColorFn: (str: string) => string, private message: string = "Loading...", ) { super("", 1, 0); @@ -46,7 +47,7 @@ export class Loader extends Text { private updateDisplay() { const frame = this.frames[this.currentFrame]; - this.setText(`${chalk.cyan(frame)} ${chalk.dim(this.message)}`); + this.setText(`${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`); if (this.ui) { this.ui.requestRender(); } diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index bef6e253..79b03ddb 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -1,20 +1,16 @@ -import { Chalk } from "chalk"; import { marked, type Token } from "marked"; import type { Component } from "../tui.js"; import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js"; -// Use a chalk instance with color level 3 for consistent ANSI output -const colorChalk = new Chalk({ level: 3 }); - /** * Default text styling for markdown content. * Applied to all text unless overridden by markdown formatting. */ export interface DefaultTextStyle { - /** Foreground color - named color or hex string like "#ff0000" */ - color?: string; - /** Background color - named color or hex string like "#ff0000" */ - bgColor?: string; + /** Foreground color function */ + color?: (text: string) => string; + /** Background color function */ + bgColor?: (text: string) => string; /** Bold text */ bold?: boolean; /** Italic text */ @@ -32,6 +28,7 @@ export interface DefaultTextStyle { export interface MarkdownTheme { heading: (text: string) => string; link: (text: string) => string; + linkUrl: (text: string) => string; code: (text: string) => string; codeBlock: (text: string) => string; codeBlockBorder: (text: string) => string; @@ -39,6 +36,10 @@ export interface MarkdownTheme { quoteBorder: (text: string) => string; hr: (text: string) => string; listBullet: (text: string) => string; + bold: (text: string) => string; + italic: (text: string) => string; + strikethrough: (text: string) => string; + underline: (text: string) => string; } export class Markdown implements Component { @@ -46,7 +47,7 @@ export class Markdown implements Component { private paddingX: number; // Left/right padding private paddingY: number; // Top/bottom padding private defaultTextStyle?: DefaultTextStyle; - private theme?: MarkdownTheme; + private theme: MarkdownTheme; // Cache for rendered output private cachedText?: string; @@ -54,22 +55,25 @@ export class Markdown implements Component { private cachedLines?: string[]; constructor( - text: string = "", - paddingX: number = 1, - paddingY: number = 1, + text: string, + paddingX: number, + paddingY: number, + theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, - theme?: MarkdownTheme, ) { this.text = text; this.paddingX = paddingX; this.paddingY = paddingY; - this.defaultTextStyle = defaultTextStyle; this.theme = theme; + this.defaultTextStyle = defaultTextStyle; } setText(text: string): void { this.text = text; - // Invalidate cache when text changes + this.invalidate(); + } + + invalidate(): void { this.cachedText = undefined; this.cachedWidth = undefined; this.cachedLines = undefined; @@ -119,14 +123,14 @@ export class Markdown implements Component { // Add margins and background to each wrapped line const leftMargin = " ".repeat(this.paddingX); const rightMargin = " ".repeat(this.paddingX); - const bgRgb = this.defaultTextStyle?.bgColor ? this.parseBgColor() : undefined; + const bgFn = this.defaultTextStyle?.bgColor; const contentLines: string[] = []; for (const line of wrappedLines) { const lineWithMargins = leftMargin + line + rightMargin; - if (bgRgb) { - contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgRgb)); + if (bgFn) { + contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn)); } else { // No background - just pad to width const visibleLen = visibleWidth(lineWithMargins); @@ -139,7 +143,7 @@ export class Markdown implements Component { const emptyLine = " ".repeat(width); const emptyLines: string[] = []; for (let i = 0; i < this.paddingY; i++) { - const line = bgRgb ? applyBackgroundToLine(emptyLine, width, bgRgb) : emptyLine; + const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine; emptyLines.push(line); } @@ -154,39 +158,6 @@ export class Markdown implements Component { return result.length > 0 ? result : [""]; } - /** - * Parse background color from defaultTextStyle to RGB values - */ - private parseBgColor(): { r: number; g: number; b: number } | undefined { - if (!this.defaultTextStyle?.bgColor) { - return undefined; - } - - if (this.defaultTextStyle.bgColor.startsWith("#")) { - // Hex color - const hex = this.defaultTextStyle.bgColor.substring(1); - return { - r: Number.parseInt(hex.substring(0, 2), 16), - g: Number.parseInt(hex.substring(2, 4), 16), - b: Number.parseInt(hex.substring(4, 6), 16), - }; - } - - // Named colors - map to RGB (common terminal colors) - const colorMap: Record = { - bgBlack: { r: 0, g: 0, b: 0 }, - bgRed: { r: 255, g: 0, b: 0 }, - bgGreen: { r: 0, g: 255, b: 0 }, - bgYellow: { r: 255, g: 255, b: 0 }, - bgBlue: { r: 0, g: 0, b: 255 }, - bgMagenta: { r: 255, g: 0, b: 255 }, - bgCyan: { r: 0, g: 255, b: 255 }, - bgWhite: { r: 255, g: 255, b: 255 }, - }; - - return colorMap[this.defaultTextStyle.bgColor]; - } - /** * Apply default text style to a string. * This is the base styling applied to all text content. @@ -202,31 +173,21 @@ export class Markdown implements Component { // Apply foreground color (NOT background - that's applied at padding stage) if (this.defaultTextStyle.color) { - if (this.defaultTextStyle.color.startsWith("#")) { - // Hex color - const hex = this.defaultTextStyle.color.substring(1); - const r = Number.parseInt(hex.substring(0, 2), 16); - const g = Number.parseInt(hex.substring(2, 4), 16); - const b = Number.parseInt(hex.substring(4, 6), 16); - styled = colorChalk.rgb(r, g, b)(styled); - } else { - // Named color - styled = (colorChalk as any)[this.defaultTextStyle.color](styled); - } + styled = this.defaultTextStyle.color(styled); } - // Apply text decorations + // Apply text decorations using this.theme if (this.defaultTextStyle.bold) { - styled = colorChalk.bold(styled); + styled = this.theme.bold(styled); } if (this.defaultTextStyle.italic) { - styled = colorChalk.italic(styled); + styled = this.theme.italic(styled); } if (this.defaultTextStyle.strikethrough) { - styled = colorChalk.strikethrough(styled); + styled = this.theme.strikethrough(styled); } if (this.defaultTextStyle.underline) { - styled = colorChalk.underline(styled); + styled = this.theme.underline(styled); } return styled; @@ -240,13 +201,15 @@ export class Markdown implements Component { const headingLevel = token.depth; const headingPrefix = "#".repeat(headingLevel) + " "; const headingText = this.renderInlineTokens(token.tokens || []); + let styledHeading: string; if (headingLevel === 1) { - lines.push(colorChalk.bold.underline.yellow(headingText)); + styledHeading = this.theme.heading(this.theme.bold(this.theme.underline(headingText))); } else if (headingLevel === 2) { - lines.push(colorChalk.bold.yellow(headingText)); + styledHeading = this.theme.heading(this.theme.bold(headingText)); } else { - lines.push(colorChalk.bold(headingPrefix + headingText)); + styledHeading = this.theme.heading(this.theme.bold(headingPrefix + headingText)); } + lines.push(styledHeading); lines.push(""); // Add spacing after headings break; } @@ -262,13 +225,13 @@ export class Markdown implements Component { } case "code": { - lines.push(colorChalk.gray("```" + (token.lang || ""))); + lines.push(this.theme.codeBlockBorder("```" + (token.lang || ""))); // Split code by newlines and style each line const codeLines = token.text.split("\n"); for (const codeLine of codeLines) { - lines.push(colorChalk.dim(" ") + colorChalk.green(codeLine)); + lines.push(" " + this.theme.codeBlock(codeLine)); } - lines.push(colorChalk.gray("```")); + lines.push(this.theme.codeBlockBorder("```")); lines.push(""); // Add spacing after code blocks break; } @@ -291,14 +254,14 @@ export class Markdown implements Component { const quoteText = this.renderInlineTokens(token.tokens || []); const quoteLines = quoteText.split("\n"); for (const quoteLine of quoteLines) { - lines.push(colorChalk.gray("│ ") + colorChalk.italic(quoteLine)); + lines.push(this.theme.quoteBorder("│ ") + this.theme.quote(this.theme.italic(quoteLine))); } lines.push(""); // Add spacing after blockquotes break; } case "hr": - lines.push(colorChalk.gray("─".repeat(Math.min(width, 80)))); + lines.push(this.theme.hr("─".repeat(Math.min(width, 80)))); lines.push(""); // Add spacing after horizontal rules break; @@ -339,31 +302,31 @@ export class Markdown implements Component { case "strong": { // Apply bold, then reapply default style after const boldContent = this.renderInlineTokens(token.tokens || []); - result += colorChalk.bold(boldContent) + this.applyDefaultStyle(""); + result += this.theme.bold(boldContent) + this.applyDefaultStyle(""); break; } case "em": { // Apply italic, then reapply default style after const italicContent = this.renderInlineTokens(token.tokens || []); - result += colorChalk.italic(italicContent) + this.applyDefaultStyle(""); + result += this.theme.italic(italicContent) + this.applyDefaultStyle(""); break; } case "codespan": // Apply code styling without backticks - result += colorChalk.cyan(token.text) + this.applyDefaultStyle(""); + result += this.theme.code(token.text) + this.applyDefaultStyle(""); break; case "link": { const linkText = this.renderInlineTokens(token.tokens || []); // If link text matches href, only show the link once if (linkText === token.href) { - result += colorChalk.underline.blue(linkText) + this.applyDefaultStyle(""); + result += this.theme.link(this.theme.underline(linkText)) + this.applyDefaultStyle(""); } else { result += - colorChalk.underline.blue(linkText) + - colorChalk.gray(` (${token.href})`) + + this.theme.link(this.theme.underline(linkText)) + + this.theme.linkUrl(` (${token.href})`) + this.applyDefaultStyle(""); } break; @@ -375,7 +338,7 @@ export class Markdown implements Component { case "del": { const delContent = this.renderInlineTokens(token.tokens || []); - result += colorChalk.strikethrough(delContent) + this.applyDefaultStyle(""); + result += this.theme.strikethrough(delContent) + this.applyDefaultStyle(""); break; } @@ -415,7 +378,7 @@ export class Markdown implements Component { lines.push(firstLine); } else { // Regular text content - add indent and bullet - lines.push(indent + colorChalk.cyan(bullet) + firstLine); + lines.push(indent + this.theme.listBullet(bullet) + firstLine); } // Rest of the lines @@ -432,7 +395,7 @@ export class Markdown implements Component { } } } else { - lines.push(indent + colorChalk.cyan(bullet)); + lines.push(indent + this.theme.listBullet(bullet)); } } @@ -463,12 +426,12 @@ export class Markdown implements Component { lines.push(text); } else if (token.type === "code") { // Code block in list item - lines.push(colorChalk.gray("```" + (token.lang || ""))); + lines.push(this.theme.codeBlockBorder("```" + (token.lang || ""))); const codeLines = token.text.split("\n"); for (const codeLine of codeLines) { - lines.push(colorChalk.dim(" ") + colorChalk.green(codeLine)); + lines.push(" " + this.theme.codeBlock(codeLine)); } - lines.push(colorChalk.gray("```")); + lines.push(this.theme.codeBlockBorder("```")); } else { // Other token types - try to render as inline const text = this.renderInlineTokens([token]); @@ -515,7 +478,7 @@ export class Markdown implements Component { // Render header const headerCells = token.header.map((cell, i) => { const text = this.renderInlineTokens(cell.tokens || []); - return colorChalk.bold(text.padEnd(columnWidths[i])); + return this.theme.bold(text.padEnd(columnWidths[i])); }); lines.push("│ " + headerCells.join(" │ ") + " │"); diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index b68b7557..e6b0b58b 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -1,4 +1,3 @@ -import chalk from "chalk"; import type { Component } from "../tui.js"; export interface SelectItem { @@ -7,19 +6,30 @@ export interface SelectItem { description?: string; } +export interface SelectListTheme { + selectedPrefix: (text: string) => string; + selectedText: (text: string) => string; + description: (text: string) => string; + scrollInfo: (text: string) => string; + noMatch: (text: string) => string; +} + export class SelectList implements Component { private items: SelectItem[] = []; private filteredItems: SelectItem[] = []; private selectedIndex: number = 0; private maxVisible: number = 5; + private theme: SelectListTheme; public onSelect?: (item: SelectItem) => void; public onCancel?: () => void; + public onSelectionChange?: (item: SelectItem) => void; - constructor(items: SelectItem[], maxVisible: number = 5) { + constructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme) { this.items = items; this.filteredItems = items; this.maxVisible = maxVisible; + this.theme = theme; } setFilter(filter: string): void { @@ -32,12 +42,16 @@ export class SelectList implements Component { this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1)); } + invalidate(): void { + // No cached state to invalidate currently + } + render(width: number): string[] { const lines: string[] = []; // If no items match filter, show message if (this.filteredItems.length === 0) { - lines.push(chalk.gray(" No matching commands")); + lines.push(this.theme.noMatch(" No matching commands")); return lines; } @@ -58,7 +72,7 @@ export class SelectList implements Component { let line = ""; if (isSelected) { // Use arrow indicator for selection - const prefix = chalk.blue("→ "); + const prefix = this.theme.selectedPrefix("→ "); const prefixWidth = 2; // "→ " is 2 characters visually const displayValue = item.label || item.value; @@ -74,16 +88,20 @@ export class SelectList implements Component { if (remainingWidth > 10) { const truncatedDesc = item.description.substring(0, remainingWidth); - line = prefix + chalk.blue(truncatedValue) + chalk.gray(spacing + truncatedDesc); + const selectedText = this.theme.selectedText(truncatedValue); + const descText = this.theme.description(spacing + truncatedDesc); + line = prefix + selectedText + descText; } else { // Not enough space for description const maxWidth = width - prefixWidth - 2; - line = prefix + chalk.blue(displayValue.substring(0, maxWidth)); + const selectedText = this.theme.selectedText(displayValue.substring(0, maxWidth)); + line = prefix + selectedText; } } else { // No description or not enough width const maxWidth = width - prefixWidth - 2; - line = prefix + chalk.blue(displayValue.substring(0, maxWidth)); + const selectedText = this.theme.selectedText(displayValue.substring(0, maxWidth)); + line = prefix + selectedText; } } else { const displayValue = item.label || item.value; @@ -101,7 +119,8 @@ export class SelectList implements Component { if (remainingWidth > 10) { const truncatedDesc = item.description.substring(0, remainingWidth); - line = prefix + truncatedValue + chalk.gray(spacing + truncatedDesc); + const descText = this.theme.description(spacing + truncatedDesc); + line = prefix + truncatedValue + descText; } else { // Not enough space for description const maxWidth = width - prefix.length - 2; @@ -123,8 +142,7 @@ export class SelectList implements Component { // Truncate if too long for terminal const maxWidth = width - 2; const truncated = scrollText.substring(0, maxWidth); - const scrollInfo = chalk.gray(truncated); - lines.push(scrollInfo); + lines.push(this.theme.scrollInfo(truncated)); } return lines; @@ -134,10 +152,12 @@ export class SelectList implements Component { // Up arrow if (keyData === "\x1b[A") { this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.notifySelectionChange(); } // Down arrow else if (keyData === "\x1b[B") { this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1); + this.notifySelectionChange(); } // Enter else if (keyData === "\r") { @@ -154,6 +174,13 @@ export class SelectList implements Component { } } + private notifySelectionChange(): void { + const selectedItem = this.filteredItems[this.selectedIndex]; + if (selectedItem && this.onSelectionChange) { + this.onSelectionChange(selectedItem); + } + } + getSelectedItem(): SelectItem | null { const item = this.filteredItems[this.selectedIndex]; return item || null; diff --git a/packages/tui/src/components/spacer.ts b/packages/tui/src/components/spacer.ts index 868ea617..8c63d3c2 100644 --- a/packages/tui/src/components/spacer.ts +++ b/packages/tui/src/components/spacer.ts @@ -14,6 +14,10 @@ export class Spacer implements Component { this.lines = lines; } + invalidate(): void { + // No cached state to invalidate currently + } + render(_width: number): string[] { const result: string[] = []; for (let i = 0; i < this.lines; i++) { diff --git a/packages/tui/src/components/text.ts b/packages/tui/src/components/text.ts index 23513eb6..efcf25b4 100644 --- a/packages/tui/src/components/text.ts +++ b/packages/tui/src/components/text.ts @@ -1,9 +1,6 @@ -import { Chalk } from "chalk"; import type { Component } from "../tui.js"; import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js"; -const colorChalk = new Chalk({ level: 3 }); - /** * Text component - displays multi-line text with word wrapping */ @@ -11,23 +8,18 @@ export class Text implements Component { private text: string; private paddingX: number; // Left/right padding private paddingY: number; // Top/bottom padding - private customBgRgb?: { r: number; g: number; b: number }; + private customBgFn?: (text: string) => string; // Cache for rendered output private cachedText?: string; private cachedWidth?: number; private cachedLines?: string[]; - constructor( - text: string = "", - paddingX: number = 1, - paddingY: number = 1, - customBgRgb?: { r: number; g: number; b: number }, - ) { + constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) { this.text = text; this.paddingX = paddingX; this.paddingY = paddingY; - this.customBgRgb = customBgRgb; + this.customBgFn = customBgFn; } setText(text: string): void { @@ -37,8 +29,14 @@ export class Text implements Component { this.cachedLines = undefined; } - setCustomBgRgb(customBgRgb?: { r: number; g: number; b: number }): void { - this.customBgRgb = customBgRgb; + setCustomBgFn(customBgFn?: (text: string) => string): void { + this.customBgFn = customBgFn; + this.cachedText = undefined; + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + invalidate(): void { this.cachedText = undefined; this.cachedWidth = undefined; this.cachedLines = undefined; @@ -78,8 +76,8 @@ export class Text implements Component { const lineWithMargins = leftMargin + line + rightMargin; // Apply background if specified (this also pads to full width) - if (this.customBgRgb) { - contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.customBgRgb)); + if (this.customBgFn) { + contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.customBgFn)); } else { // No background - just pad to width with spaces const visibleLen = visibleWidth(lineWithMargins); @@ -92,7 +90,7 @@ export class Text implements Component { const emptyLine = " ".repeat(width); const emptyLines: string[] = []; for (let i = 0; i < this.paddingY; i++) { - const line = this.customBgRgb ? applyBackgroundToLine(emptyLine, width, this.customBgRgb) : emptyLine; + const line = this.customBgFn ? applyBackgroundToLine(emptyLine, width, this.customBgFn) : emptyLine; emptyLines.push(line); } diff --git a/packages/tui/src/components/truncated-text.ts b/packages/tui/src/components/truncated-text.ts index 104227f5..be90b5c5 100644 --- a/packages/tui/src/components/truncated-text.ts +++ b/packages/tui/src/components/truncated-text.ts @@ -15,20 +15,34 @@ export class TruncatedText implements Component { this.paddingY = paddingY; } + invalidate(): void { + // No cached state to invalidate currently + } + render(width: number): string[] { const result: string[] = []; + // Empty line padded to width + const emptyLine = " ".repeat(width); + // Add vertical padding above for (let i = 0; i < this.paddingY; i++) { - result.push(""); + result.push(emptyLine); } // Calculate available width after horizontal padding const availableWidth = Math.max(1, width - this.paddingX * 2); + // Take only the first line (stop at newline) + let singleLineText = this.text; + const newlineIndex = this.text.indexOf("\n"); + if (newlineIndex !== -1) { + singleLineText = this.text.substring(0, newlineIndex); + } + // Truncate text if needed (accounting for ANSI codes) - let displayText = this.text; - const textVisibleWidth = visibleWidth(this.text); + let displayText = singleLineText; + const textVisibleWidth = visibleWidth(singleLineText); if (textVisibleWidth > availableWidth) { // Need to truncate - walk through the string character by character @@ -38,18 +52,21 @@ export class TruncatedText implements Component { const ellipsisWidth = 3; const targetWidth = availableWidth - ellipsisWidth; - while (i < this.text.length && currentWidth < targetWidth) { - // Skip ANSI escape sequences - if (this.text[i] === "\x1b" && this.text[i + 1] === "[") { + while (i < singleLineText.length && currentWidth < targetWidth) { + // Skip ANSI escape sequences (include them in output but don't count width) + if (singleLineText[i] === "\x1b" && singleLineText[i + 1] === "[") { let j = i + 2; - while (j < this.text.length && !/[a-zA-Z]/.test(this.text[j])) { + while (j < singleLineText.length && !/[a-zA-Z]/.test(singleLineText[j])) { j++; } - i = j + 1; + // Include the final letter of the escape sequence + j++; + truncateAt = j; + i = j; continue; } - const char = this.text[i]; + const char = singleLineText[i]; const charWidth = visibleWidth(char); if (currentWidth + charWidth > targetWidth) { @@ -61,16 +78,25 @@ export class TruncatedText implements Component { i++; } - displayText = this.text.substring(0, truncateAt) + "..."; + // Add reset code before ellipsis to prevent styling leaking into it + displayText = singleLineText.substring(0, truncateAt) + "\x1b[0m..."; } // Add horizontal padding - const paddingStr = " ".repeat(this.paddingX); - result.push(paddingStr + displayText); + const leftPadding = " ".repeat(this.paddingX); + const rightPadding = " ".repeat(this.paddingX); + const lineWithPadding = leftPadding + displayText + rightPadding; + + // Pad line to exactly width characters + const lineVisibleWidth = visibleWidth(lineWithPadding); + const paddingNeeded = Math.max(0, width - lineVisibleWidth); + const finalLine = lineWithPadding + " ".repeat(paddingNeeded); + + result.push(finalLine); // Add vertical padding below for (let i = 0; i < this.paddingY; i++) { - result.push(""); + result.push(emptyLine); } return result; diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 4ed15ca9..9e1e0927 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -8,11 +8,11 @@ export { type SlashCommand, } from "./autocomplete.js"; // Components -export { Editor, type TextEditorConfig } from "./components/editor.js"; +export { Editor, type EditorTheme } from "./components/editor.js"; export { Input } from "./components/input.js"; export { Loader } from "./components/loader.js"; export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js"; -export { type SelectItem, SelectList } from "./components/select-list.js"; +export { type SelectItem, SelectList, type SelectListTheme } from "./components/select-list.js"; export { Spacer } from "./components/spacer.js"; export { Text } from "./components/text.js"; export { TruncatedText } from "./components/truncated-text.js"; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 43fdbca2..567c1469 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -20,6 +20,12 @@ export interface Component { * Optional handler for keyboard input when component has focus */ handleInput?(data: string): void; + + /** + * Invalidate any cached rendering state. + * Called when theme changes or when component needs to re-render from scratch. + */ + invalidate(): void; } export { visibleWidth }; @@ -45,6 +51,12 @@ export class Container implements Component { this.children = []; } + invalidate(): void { + for (const child of this.children) { + child.invalidate?.(); + } + } + render(width: number): string[] { const lines: string[] = []; for (const child of this.children) { diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 0ab0e5c6..b80c620d 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -1,8 +1,5 @@ -import { Chalk } from "chalk"; import stringWidth from "string-width"; -const colorChalk = new Chalk({ level: 3 }); - /** * Calculate the visible width of a string in terminal columns. */ @@ -245,30 +242,18 @@ function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): s /** * Apply background color to a line, padding to full width. * - * Handles the tricky case where content contains \x1b[0m resets that would - * kill the background color. We reapply the background after any reset. - * * @param line - Line of text (may contain ANSI codes) * @param width - Total width to pad to - * @param bgRgb - Background RGB color + * @param bgFn - Background color function * @returns Line with background applied and padded to width */ -export function applyBackgroundToLine(line: string, width: number, bgRgb: { r: number; g: number; b: number }): string { - const bgStart = `\x1b[48;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`; - const bgEnd = "\x1b[49m"; - +export function applyBackgroundToLine(line: string, width: number, bgFn: (text: string) => string): string { // Calculate padding needed const visibleLen = visibleWidth(line); const paddingNeeded = Math.max(0, width - visibleLen); const padding = " ".repeat(paddingNeeded); - // Strategy: wrap content + padding in background, then fix any 0m resets + // Apply background to content + padding const withPadding = line + padding; - const withBg = bgStart + withPadding + bgEnd; - - // Find all \x1b[0m or \x1b[49m that would kill background - // Replace with reset + background reapplication - const fixedBg = withBg.replace(/\x1b\[0m/g, `\x1b[0m${bgStart}`); - - return fixedBg; + return bgFn(withPadding); } diff --git a/packages/tui/test/chat-simple.ts b/packages/tui/test/chat-simple.ts index 31baaeee..5f5fd5cf 100644 --- a/packages/tui/test/chat-simple.ts +++ b/packages/tui/test/chat-simple.ts @@ -2,6 +2,7 @@ * Simple chat interface demo using tui.ts */ +import chalk from "chalk"; import { CombinedAutocompleteProvider } from "../src/autocomplete.js"; import { Editor } from "../src/components/editor.js"; import { Loader } from "../src/components/loader.js"; @@ -9,6 +10,7 @@ import { Markdown } from "../src/components/markdown.js"; import { Text } from "../src/components/text.js"; import { ProcessTerminal } from "../src/terminal.js"; import { TUI } from "../src/tui.js"; +import { defaultEditorTheme, defaultMarkdownTheme } from "./test-themes.js"; // Create terminal const terminal = new ProcessTerminal(); @@ -22,7 +24,7 @@ tui.addChild( ); // Create editor with autocomplete -const editor = new Editor(); +const editor = new Editor(defaultEditorTheme); // Set up autocomplete provider with slash commands and file completion const autocompleteProvider = new CombinedAutocompleteProvider( @@ -78,12 +80,17 @@ editor.onSubmit = (value: string) => { isResponding = true; editor.disableSubmit = true; - const userMessage = new Markdown(value, 1, 1, { bgColor: "#343541" }); + const userMessage = new Markdown(value, 1, 1, defaultMarkdownTheme); const children = tui.children; children.splice(children.length - 1, 0, userMessage); - const loader = new Loader(tui, "Thinking..."); + const loader = new Loader( + tui, + (s) => chalk.cyan(s), + (s) => chalk.dim(s), + "Thinking...", + ); children.splice(children.length - 1, 0, loader); tui.requestRender(); @@ -105,7 +112,7 @@ editor.onSubmit = (value: string) => { const randomResponse = responses[Math.floor(Math.random() * responses.length)]; // Add assistant message with no background (transparent) - const botMessage = new Markdown(randomResponse); + const botMessage = new Markdown(randomResponse, 1, 1, defaultMarkdownTheme); children.splice(children.length - 1, 0, botMessage); // Re-enable submit diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index ab9b3af2..9928568f 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -1,11 +1,12 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import { Editor } from "../src/components/editor.js"; +import { defaultEditorTheme } from "./test-themes.js"; describe("Editor component", () => { describe("Unicode text editing behavior", () => { it("inserts mixed ASCII, umlauts, and emojis as literal text", () => { - const editor = new Editor(); + const editor = new Editor(defaultEditorTheme); editor.handleInput("H"); editor.handleInput("e"); @@ -24,7 +25,7 @@ describe("Editor component", () => { }); it("deletes single-code-unit unicode characters (umlauts) with Backspace", () => { - const editor = new Editor(); + const editor = new Editor(defaultEditorTheme); editor.handleInput("ä"); editor.handleInput("ö"); @@ -38,7 +39,7 @@ describe("Editor component", () => { }); it("deletes multi-code-unit emojis with repeated Backspace", () => { - const editor = new Editor(); + const editor = new Editor(defaultEditorTheme); editor.handleInput("😀"); editor.handleInput("👍"); @@ -52,7 +53,7 @@ describe("Editor component", () => { }); it("inserts characters at the correct position after cursor movement over umlauts", () => { - const editor = new Editor(); + const editor = new Editor(defaultEditorTheme); editor.handleInput("ä"); editor.handleInput("ö"); @@ -70,7 +71,7 @@ describe("Editor component", () => { }); it("moves cursor in code units across multi-code-unit emojis before insertion", () => { - const editor = new Editor(); + const editor = new Editor(defaultEditorTheme); editor.handleInput("😀"); editor.handleInput("👍"); @@ -92,7 +93,7 @@ describe("Editor component", () => { }); it("preserves umlauts across line breaks", () => { - const editor = new Editor(); + const editor = new Editor(defaultEditorTheme); editor.handleInput("ä"); editor.handleInput("ö"); @@ -107,7 +108,7 @@ describe("Editor component", () => { }); it("replaces the entire document with unicode text via setText (paste simulation)", () => { - const editor = new Editor(); + const editor = new Editor(defaultEditorTheme); // Simulate bracketed paste / programmatic replacement editor.setText("Hällö Wörld! 😀 äöüÄÖÜß"); @@ -117,7 +118,7 @@ describe("Editor component", () => { }); it("moves cursor to document start on Ctrl+A and inserts at the beginning", () => { - const editor = new Editor(); + const editor = new Editor(defaultEditorTheme); editor.handleInput("a"); editor.handleInput("b"); diff --git a/packages/tui/test/key-tester.ts b/packages/tui/test/key-tester.ts index fa3692f4..b39e4da8 100755 --- a/packages/tui/test/key-tester.ts +++ b/packages/tui/test/key-tester.ts @@ -40,6 +40,10 @@ class KeyLogger implements Component { this.tui.requestRender(); } + invalidate(): void { + // No cached state to invalidate currently + } + render(width: number): string[] { const lines: string[] = []; diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 9ecd71c2..56144dad 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert"; import { describe, it } from "node:test"; +import chalk from "chalk"; import { Markdown } from "../src/components/markdown.js"; +import { defaultMarkdownTheme } from "./test-themes.js"; describe("Markdown component", () => { describe("Nested lists", () => { @@ -12,6 +14,7 @@ describe("Markdown component", () => { - Item 2`, 0, 0, + defaultMarkdownTheme, ); const lines = markdown.render(80); @@ -37,6 +40,7 @@ describe("Markdown component", () => { - Level 4`, 0, 0, + defaultMarkdownTheme, ); const lines = markdown.render(80); @@ -57,6 +61,7 @@ describe("Markdown component", () => { 2. Second`, 0, 0, + defaultMarkdownTheme, ); const lines = markdown.render(80); @@ -77,6 +82,7 @@ describe("Markdown component", () => { - More nested`, 0, 0, + defaultMarkdownTheme, ); const lines = markdown.render(80); @@ -97,6 +103,7 @@ describe("Markdown component", () => { | Bob | 25 |`, 0, 0, + defaultMarkdownTheme, ); const lines = markdown.render(80); @@ -120,6 +127,7 @@ describe("Markdown component", () => { | Long text | Middle | End |`, 0, 0, + defaultMarkdownTheme, ); const lines = markdown.render(80); @@ -141,6 +149,7 @@ describe("Markdown component", () => { | B | Short |`, 0, 0, + defaultMarkdownTheme, ); const lines = markdown.render(80); @@ -168,6 +177,7 @@ describe("Markdown component", () => { | A | B |`, 0, 0, + defaultMarkdownTheme, ); const lines = markdown.render(80); @@ -187,10 +197,16 @@ describe("Markdown component", () => { describe("Pre-styled text (thinking traces)", () => { it("should preserve gray italic styling after inline code", () => { // This replicates how thinking content is rendered in assistant-message.ts - const markdown = new Markdown("This is thinking with `inline code` and more text after", 1, 0, { - color: "gray", - italic: true, - }); + const markdown = new Markdown( + "This is thinking with `inline code` and more text after", + 1, + 0, + defaultMarkdownTheme, + { + color: (text) => chalk.gray(text), + italic: true, + }, + ); const lines = markdown.render(80); const joinedOutput = lines.join("\n"); @@ -208,10 +224,16 @@ describe("Markdown component", () => { }); it("should preserve gray italic styling after bold text", () => { - const markdown = new Markdown("This is thinking with **bold text** and more after", 1, 0, { - color: "gray", - italic: true, - }); + const markdown = new Markdown( + "This is thinking with **bold text** and more after", + 1, + 0, + defaultMarkdownTheme, + { + color: (text) => chalk.gray(text), + italic: true, + }, + ); const lines = markdown.render(80); const joinedOutput = lines.join("\n"); @@ -236,6 +258,7 @@ describe("Markdown component", () => { "This is text with hidden content that should be visible", 0, 0, + defaultMarkdownTheme, ); const lines = markdown.render(80); @@ -250,7 +273,7 @@ describe("Markdown component", () => { }); it("should render HTML tags in code blocks correctly", () => { - const markdown = new Markdown("```html\n
Some HTML
\n```", 0, 0); + const markdown = new Markdown("```html\n
Some HTML
\n```", 0, 0, defaultMarkdownTheme); const lines = markdown.render(80); const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); diff --git a/packages/tui/test/test-themes.ts b/packages/tui/test/test-themes.ts new file mode 100644 index 00000000..9ef55042 --- /dev/null +++ b/packages/tui/test/test-themes.ts @@ -0,0 +1,36 @@ +/** + * Default themes for TUI tests using chalk + */ + +import chalk from "chalk"; +import type { EditorTheme, MarkdownTheme, SelectListTheme } from "../src/index.js"; + +export const defaultSelectListTheme: SelectListTheme = { + selectedPrefix: (text: string) => chalk.blue(text), + selectedText: (text: string) => chalk.bold(text), + description: (text: string) => chalk.dim(text), + scrollInfo: (text: string) => chalk.dim(text), + noMatch: (text: string) => chalk.dim(text), +}; + +export const defaultMarkdownTheme: MarkdownTheme = { + heading: (text: string) => chalk.bold.cyan(text), + link: (text: string) => chalk.blue(text), + linkUrl: (text: string) => chalk.dim(text), + code: (text: string) => chalk.yellow(text), + codeBlock: (text: string) => chalk.green(text), + codeBlockBorder: (text: string) => chalk.dim(text), + quote: (text: string) => chalk.italic(text), + quoteBorder: (text: string) => chalk.dim(text), + hr: (text: string) => chalk.dim(text), + listBullet: (text: string) => chalk.cyan(text), + bold: (text: string) => chalk.bold(text), + italic: (text: string) => chalk.italic(text), + strikethrough: (text: string) => chalk.strikethrough(text), + underline: (text: string) => chalk.underline(text), +}; + +export const defaultEditorTheme: EditorTheme = { + borderColor: (text: string) => chalk.dim(text), + selectList: defaultSelectListTheme, +}; diff --git a/packages/tui/test/truncated-text.test.ts b/packages/tui/test/truncated-text.test.ts new file mode 100644 index 00000000..ef24f368 --- /dev/null +++ b/packages/tui/test/truncated-text.test.ts @@ -0,0 +1,126 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import chalk from "chalk"; +import { TruncatedText } from "../src/components/truncated-text.js"; +import { visibleWidth } from "../src/utils.js"; + +describe("TruncatedText component", () => { + it("pads output lines to exactly match width", () => { + const text = new TruncatedText("Hello world", 1, 0); + const lines = text.render(50); + + // Should have exactly one content line (no vertical padding) + assert.strictEqual(lines.length, 1); + + // Line should be exactly 50 visible characters + const visibleLen = visibleWidth(lines[0]); + assert.strictEqual(visibleLen, 50); + }); + + it("pads output with vertical padding lines to width", () => { + const text = new TruncatedText("Hello", 0, 2); + const lines = text.render(40); + + // Should have 2 padding lines + 1 content line + 2 padding lines = 5 total + assert.strictEqual(lines.length, 5); + + // All lines should be exactly 40 characters + for (const line of lines) { + assert.strictEqual(visibleWidth(line), 40); + } + }); + + it("truncates long text and pads to width", () => { + const longText = "This is a very long piece of text that will definitely exceed the available width"; + const text = new TruncatedText(longText, 1, 0); + const lines = text.render(30); + + assert.strictEqual(lines.length, 1); + + // Should be exactly 30 characters + assert.strictEqual(visibleWidth(lines[0]), 30); + + // Should contain ellipsis + const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, ""); + assert.ok(stripped.includes("...")); + }); + + it("preserves ANSI codes in output and pads correctly", () => { + const styledText = chalk.red("Hello") + " " + chalk.blue("world"); + const text = new TruncatedText(styledText, 1, 0); + const lines = text.render(40); + + assert.strictEqual(lines.length, 1); + + // Should be exactly 40 visible characters (ANSI codes don't count) + assert.strictEqual(visibleWidth(lines[0]), 40); + + // Should preserve the color codes + assert.ok(lines[0].includes("\x1b[")); + }); + + it("truncates styled text and adds reset code before ellipsis", () => { + const longStyledText = chalk.red("This is a very long red text that will be truncated"); + const text = new TruncatedText(longStyledText, 1, 0); + const lines = text.render(20); + + assert.strictEqual(lines.length, 1); + + // Should be exactly 20 visible characters + assert.strictEqual(visibleWidth(lines[0]), 20); + + // Should contain reset code before ellipsis + assert.ok(lines[0].includes("\x1b[0m...")); + }); + + it("handles text that fits exactly", () => { + // With paddingX=1, available width is 30-2=28 + // "Hello world" is 11 chars, fits comfortably + const text = new TruncatedText("Hello world", 1, 0); + const lines = text.render(30); + + assert.strictEqual(lines.length, 1); + assert.strictEqual(visibleWidth(lines[0]), 30); + + // Should NOT contain ellipsis + const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, ""); + assert.ok(!stripped.includes("...")); + }); + + it("handles empty text", () => { + const text = new TruncatedText("", 1, 0); + const lines = text.render(30); + + assert.strictEqual(lines.length, 1); + assert.strictEqual(visibleWidth(lines[0]), 30); + }); + + it("stops at newline and only shows first line", () => { + const multilineText = "First line\nSecond line\nThird line"; + const text = new TruncatedText(multilineText, 1, 0); + const lines = text.render(40); + + assert.strictEqual(lines.length, 1); + assert.strictEqual(visibleWidth(lines[0]), 40); + + // Should only contain "First line" + const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "").trim(); + assert.ok(stripped.includes("First line")); + assert.ok(!stripped.includes("Second line")); + assert.ok(!stripped.includes("Third line")); + }); + + it("truncates first line even with newlines in text", () => { + const longMultilineText = "This is a very long first line that needs truncation\nSecond line"; + const text = new TruncatedText(longMultilineText, 1, 0); + const lines = text.render(25); + + assert.strictEqual(lines.length, 1); + assert.strictEqual(visibleWidth(lines[0]), 25); + + // Should contain ellipsis and not second line + const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, ""); + assert.ok(stripped.includes("...")); + assert.ok(!stripped.includes("Second line")); + }); +}); diff --git a/packages/tui/test/wrap-ansi.test.ts b/packages/tui/test/wrap-ansi.test.ts index a704ad57..7e5af229 100644 --- a/packages/tui/test/wrap-ansi.test.ts +++ b/packages/tui/test/wrap-ansi.test.ts @@ -65,22 +65,24 @@ describe("wrapTextWithAnsi", () => { }); describe("applyBackgroundToLine", () => { + const greenBg = (text: string) => chalk.bgGreen(text); + it("applies background to plain text and pads to width", () => { const line = "hello"; - const result = applyBackgroundToLine(line, 20, { r: 0, g: 255, b: 0 }); + const result = applyBackgroundToLine(line, 20, greenBg); // Should be exactly 20 visible chars const stripped = result.replace(/\x1b\[[0-9;]*m/g, ""); assert.strictEqual(stripped.length, 20); // Should have background codes - assert.ok(result.includes("\x1b[48;2;0;255;0m")); + assert.ok(result.includes("\x1b[48") || result.includes("\x1b[42m")); assert.ok(result.includes("\x1b[49m")); }); it("handles text with ANSI codes and resets", () => { const line = chalk.bold("hello") + " world"; - const result = applyBackgroundToLine(line, 20, { r: 0, g: 255, b: 0 }); + const result = applyBackgroundToLine(line, 20, greenBg); // Should be exactly 20 visible chars const stripped = result.replace(/\x1b\[[0-9;]*m/g, ""); @@ -90,13 +92,13 @@ describe("applyBackgroundToLine", () => { assert.ok(result.includes("\x1b[1m")); // Should have background throughout (even after resets) - assert.ok(result.includes("\x1b[48;2;0;255;0m")); + assert.ok(result.includes("\x1b[48") || result.includes("\x1b[42m")); }); it("handles text with 0m resets by reapplying background", () => { // Simulate: bold text + reset + normal text const line = "\x1b[1mhello\x1b[0m world"; - const result = applyBackgroundToLine(line, 20, { r: 0, g: 255, b: 0 }); + const result = applyBackgroundToLine(line, 20, greenBg); // Should NOT have black cells (spaces without background) // Pattern we DON'T want: 49m or 0m followed by spaces before bg reapplied diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index c375e2cc..ae291dbe 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-web-ui", - "version": "0.7.29", + "version": "0.8.0", "description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai", "type": "module", "main": "dist/index.js", @@ -18,8 +18,8 @@ }, "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.7.29", - "@mariozechner/pi-tui": "^0.7.29", + "@mariozechner/pi-ai": "^0.8.0", + "@mariozechner/pi-tui": "^0.8.0", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0",