Release v0.8.0

This commit is contained in:
Mario Zechner 2025-11-21 03:12:42 +01:00
parent cc88095140
commit 85adcf22bf
48 changed files with 1530 additions and 608 deletions

428
package-lock.json generated
View file

@ -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"
}
}
}
}

View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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:

View file

@ -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"
}
}
```

View file

@ -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"

View file

@ -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();

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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)"

View file

@ -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<T extends Record<string, ColorValue>>(
// Theme Class
// ============================================================================
const RESET = "\x1b[0m";
export class Theme {
private fgColors: Map<ThemeColor, string>;
private bgColors: Map<ThemeBg, string>;
@ -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(),
};
}

View file

@ -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));
}
}
}

View file

@ -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)))];
}

View file

@ -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)];
}
}

View file

@ -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));
}
}

View file

@ -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));
}
}

View file

@ -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);

View file

@ -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));

View file

@ -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 {

View file

@ -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);

View file

@ -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;

View file

@ -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<void> {
@ -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));
}
}

View file

@ -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));

View file

@ -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),
}),
);
}
}

View file

@ -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");

View file

@ -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": {}

View file

@ -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",

View file

@ -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",

View file

@ -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<TextEditorConfig>): 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

View file

@ -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 = "> ";

View file

@ -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();
}

View file

@ -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<string, { r: number; g: number; b: number }> = {
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(" │ ") + " │");

View file

@ -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;

View file

@ -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++) {

View file

@ -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);
}

View file

@ -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;

View file

@ -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";

View file

@ -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) {

View file

@ -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);
}

View file

@ -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

View file

@ -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");

View file

@ -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[] = [];

View file

@ -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 <thinking>hidden content</thinking> 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<div>Some HTML</div>\n```", 0, 0);
const markdown = new Markdown("```html\n<div>Some HTML</div>\n```", 0, 0, defaultMarkdownTheme);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));

View file

@ -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,
};

View file

@ -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"));
});
});

View file

@ -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

View file

@ -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",