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": { "packages/agent": {
"name": "@mariozechner/pi-agent", "name": "@mariozechner/pi-agent",
"version": "0.7.29", "version": "0.8.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@mariozechner/pi-ai": "^0.7.29", "@mariozechner/pi-ai": "^0.7.29",
@ -5428,6 +5428,42 @@
"node": ">=20.0.0" "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": { "packages/agent/node_modules/@types/node": {
"version": "24.10.1", "version": "24.10.1",
"dev": true, "dev": true,
@ -5436,6 +5472,73 @@
"undici-types": "~7.16.0" "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": { "packages/agent/node_modules/undici-types": {
"version": "7.16.0", "version": "7.16.0",
"dev": true, "dev": true,
@ -5443,7 +5546,7 @@
}, },
"packages/ai": { "packages/ai": {
"name": "@mariozechner/pi-ai", "name": "@mariozechner/pi-ai",
"version": "0.7.29", "version": "0.8.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.61.0", "@anthropic-ai/sdk": "^0.61.0",
@ -5490,7 +5593,7 @@
}, },
"packages/coding-agent": { "packages/coding-agent": {
"name": "@mariozechner/pi-coding-agent", "name": "@mariozechner/pi-coding-agent",
"version": "0.7.29", "version": "0.8.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@mariozechner/pi-agent": "^0.7.29", "@mariozechner/pi-agent": "^0.7.29",
@ -5512,6 +5615,55 @@
"node": ">=20.0.0" "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": { "packages/coding-agent/node_modules/@types/node": {
"version": "24.10.1", "version": "24.10.1",
"dev": true, "dev": true,
@ -5520,6 +5672,18 @@
"undici-types": "~7.16.0" "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": { "packages/coding-agent/node_modules/chalk": {
"version": "5.6.2", "version": "5.6.2",
"license": "MIT", "license": "MIT",
@ -5530,6 +5694,49 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "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": { "packages/coding-agent/node_modules/undici-types": {
"version": "7.16.0", "version": "7.16.0",
"dev": true, "dev": true,
@ -5537,7 +5744,7 @@
}, },
"packages/pods": { "packages/pods": {
"name": "@mariozechner/pi", "name": "@mariozechner/pi",
"version": "0.7.29", "version": "0.8.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@mariozechner/pi-agent": "^0.7.29", "@mariozechner/pi-agent": "^0.7.29",
@ -5551,6 +5758,67 @@
"node": ">=20.0.0" "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": { "packages/pods/node_modules/chalk": {
"version": "5.6.2", "version": "5.6.2",
"license": "MIT", "license": "MIT",
@ -5561,9 +5829,52 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "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": { "packages/proxy": {
"name": "@mariozechner/pi-proxy", "name": "@mariozechner/pi-proxy",
"version": "0.7.29", "version": "0.8.0",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.14.0", "@hono/node-server": "^1.14.0",
"hono": "^4.6.16" "hono": "^4.6.16"
@ -5579,7 +5890,7 @@
}, },
"packages/tui": { "packages/tui": {
"name": "@mariozechner/pi-tui", "name": "@mariozechner/pi-tui",
"version": "0.7.29", "version": "0.8.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
@ -5661,7 +5972,7 @@
}, },
"packages/web-ui": { "packages/web-ui": {
"name": "@mariozechner/pi-web-ui", "name": "@mariozechner/pi-web-ui",
"version": "0.7.29", "version": "0.8.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@lmstudio/sdk": "^1.5.0", "@lmstudio/sdk": "^1.5.0",
@ -5684,6 +5995,109 @@
"@mariozechner/mini-lit": "^0.2.0", "@mariozechner/mini-lit": "^0.2.0",
"lit": "^3.3.1" "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", "name": "@mariozechner/pi-agent",
"version": "0.7.29", "version": "0.8.0",
"description": "General-purpose agent with transport abstraction, state management, and attachment support", "description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@ -18,8 +18,8 @@
"prepublishOnly": "npm run clean && npm run build" "prepublishOnly": "npm run clean && npm run build"
}, },
"dependencies": { "dependencies": {
"@mariozechner/pi-ai": "^0.7.29", "@mariozechner/pi-ai": "^0.8.0",
"@mariozechner/pi-tui": "^0.7.29" "@mariozechner/pi-tui": "^0.8.0"
}, },
"keywords": [ "keywords": [
"ai", "ai",

View file

@ -1,6 +1,6 @@
{ {
"name": "@mariozechner/pi-ai", "name": "@mariozechner/pi-ai",
"version": "0.7.29", "version": "0.8.0",
"description": "Unified LLM API with automatic model discovery and provider configuration", "description": "Unified LLM API with automatic model discovery and provider configuration",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",

View file

@ -2,6 +2,12 @@
## [Unreleased] ## [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 ## [0.7.29] - 2025-11-20
### Improved ### 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) - [API Keys](#api-keys)
- [OAuth Authentication (Optional)](#oauth-authentication-optional) - [OAuth Authentication (Optional)](#oauth-authentication-optional)
- [Custom Models and Providers](#custom-models-and-providers) - [Custom Models and Providers](#custom-models-and-providers)
- [Themes](#themes)
- [Slash Commands](#slash-commands) - [Slash Commands](#slash-commands)
- [Editor Features](#editor-features) - [Editor Features](#editor-features)
- [Project Context Files](#project-context-files) - [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. 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 ## Slash Commands
The CLI supports several commands to control its behavior: 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. Every theme must define all color tokens. There are no optional colors.
### Core UI (9 colors) ### Core UI (10 colors)
| Token | Purpose | Examples | | 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 | | `error` | Error states | Error messages, diff deletions |
| `warning` | Warning states | Warning messages | | `warning` | Warning states | Warning messages |
| `muted` | Secondary/dimmed text | Metadata, descriptions, output | | `muted` | Secondary/dimmed text | Metadata, descriptions, output |
| `dim` | Very dimmed text | Less important info, placeholders |
| `text` | Default text color | Main content (usually `""`) | | `text` | Default text color | Main content (usually `""`) |
### Backgrounds & Content Text (6 colors) ### Backgrounds & Content Text (7 colors)
| Token | Purpose | | Token | Purpose |
|-------|---------| |-------|---------|
@ -29,14 +30,16 @@ Every theme must define all color tokens. There are no optional colors.
| `toolPendingBg` | Tool execution box (pending state) | | `toolPendingBg` | Tool execution box (pending state) |
| `toolSuccessBg` | Tool execution box (success state) | | `toolSuccessBg` | Tool execution box (success state) |
| `toolErrorBg` | Tool execution box (error 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 | | Token | Purpose |
|-------|---------| |-------|---------|
| `mdHeading` | Heading text (`#`, `##`, etc) | | `mdHeading` | Heading text (`#`, `##`, etc) |
| `mdLink` | Link text and URLs | | `mdLink` | Link text |
| `mdLinkUrl` | Link URL (in parentheses) |
| `mdCode` | Inline code (backticks) | | `mdCode` | Inline code (backticks) |
| `mdCodeBlock` | Code block content | | `mdCodeBlock` | Code block content |
| `mdCodeBlockBorder` | Code block fences (```) | | `mdCodeBlockBorder` | Code block fences (```) |
@ -71,7 +74,21 @@ Future-proofing for syntax highlighting support:
| `syntaxOperator` | Operators (`+`, `-`, etc) | | `syntaxOperator` | Operators (`+`, `-`, etc) |
| `syntaxPunctuation` | Punctuation (`;`, `,`, 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 ## Theme Format
@ -241,7 +258,13 @@ Custom themes are loaded from `~/.pi/agent/themes/*.json`.
"syntaxNumber": "#ff00ff", "syntaxNumber": "#ff00ff",
"syntaxType": "#00aaff", "syntaxType": "#00aaff",
"syntaxOperator": "primary", "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", "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", "description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module", "type": "module",
"bin": { "bin": {
@ -22,8 +22,8 @@
"prepublishOnly": "npm run clean && npm run build" "prepublishOnly": "npm run clean && npm run build"
}, },
"dependencies": { "dependencies": {
"@mariozechner/pi-agent": "^0.7.29", "@mariozechner/pi-agent": "^0.8.0",
"@mariozechner/pi-ai": "^0.7.29", "@mariozechner/pi-ai": "^0.8.0",
"chalk": "^5.5.0", "chalk": "^5.5.0",
"diff": "^8.0.2", "diff": "^8.0.2",
"glob": "^11.0.3" "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 - When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did
Documentation: Documentation:
- Your own documentation (including custom model setup) is at: ${readmePath} - 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.`; - 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 // Append project context files
const contextFiles = loadProjectContextFiles(); const contextFiles = loadProjectContextFiles();

View file

@ -3,19 +3,21 @@
"name": "dark", "name": "dark",
"vars": { "vars": {
"cyan": "#00d7ff", "cyan": "#00d7ff",
"blue": "#0087ff", "blue": "#5f87ff",
"green": "#00ff00", "green": "#b5bd68",
"red": "#ff0000", "red": "#cc6666",
"yellow": "#ffff00", "yellow": "#ffff00",
"gray": 242, "gray": "#808080",
"darkGray": 238, "dimGray": "#666666",
"darkGray": "#303030",
"accent": "#8abeb7",
"userMsgBg": "#343541", "userMsgBg": "#343541",
"toolPendingBg": "#282832", "toolPendingBg": "#282832",
"toolSuccessBg": "#283228", "toolSuccessBg": "#283228",
"toolErrorBg": "#3c2828" "toolErrorBg": "#3c2828"
}, },
"colors": { "colors": {
"accent": "cyan", "accent": "accent",
"border": "blue", "border": "blue",
"borderAccent": "cyan", "borderAccent": "cyan",
"borderMuted": "darkGray", "borderMuted": "darkGray",
@ -23,6 +25,7 @@
"error": "red", "error": "red",
"warning": "yellow", "warning": "yellow",
"muted": "gray", "muted": "gray",
"dim": "dimGray",
"text": "", "text": "",
"userMessageBg": "userMsgBg", "userMessageBg": "userMsgBg",
@ -30,17 +33,19 @@
"toolPendingBg": "toolPendingBg", "toolPendingBg": "toolPendingBg",
"toolSuccessBg": "toolSuccessBg", "toolSuccessBg": "toolSuccessBg",
"toolErrorBg": "toolErrorBg", "toolErrorBg": "toolErrorBg",
"toolText": "", "toolTitle": "",
"toolOutput": "gray",
"mdHeading": "cyan", "mdHeading": "#f0c674",
"mdLink": "blue", "mdLink": "#81a2be",
"mdCode": "cyan", "mdLinkUrl": "dimGray",
"mdCodeBlock": "", "mdCode": "accent",
"mdCodeBlock": "green",
"mdCodeBlockBorder": "gray", "mdCodeBlockBorder": "gray",
"mdQuote": "gray", "mdQuote": "gray",
"mdQuoteBorder": "gray", "mdQuoteBorder": "gray",
"mdHr": "gray", "mdHr": "gray",
"mdListBullet": "cyan", "mdListBullet": "accent",
"toolDiffAdded": "green", "toolDiffAdded": "green",
"toolDiffRemoved": "red", "toolDiffRemoved": "red",
@ -54,6 +59,12 @@
"syntaxNumber": "yellow", "syntaxNumber": "yellow",
"syntaxType": "cyan", "syntaxType": "cyan",
"syntaxOperator": "", "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", "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
"name": "light", "name": "light",
"vars": { "vars": {
"darkCyan": "#008899", "teal": "#5f8787",
"darkBlue": "#0066cc", "blue": "#5f87af",
"darkGreen": "#008800", "green": "#87af87",
"darkRed": "#cc0000", "red": "#af5f5f",
"darkYellow": "#aa8800", "yellow": "#d7af5f",
"mediumGray": 242, "mediumGray": "#6c6c6c",
"lightGray": 250, "dimGray": "#8a8a8a",
"lightGray": "#b0b0b0",
"userMsgBg": "#e8e8e8", "userMsgBg": "#e8e8e8",
"toolPendingBg": "#e8e8f0", "toolPendingBg": "#e8e8f0",
"toolSuccessBg": "#e8f0e8", "toolSuccessBg": "#e8f0e8",
"toolErrorBg": "#f0e8e8" "toolErrorBg": "#f0e8e8"
}, },
"colors": { "colors": {
"accent": "darkCyan", "accent": "teal",
"border": "darkBlue", "border": "blue",
"borderAccent": "darkCyan", "borderAccent": "teal",
"borderMuted": "lightGray", "borderMuted": "lightGray",
"success": "darkGreen", "success": "green",
"error": "darkRed", "error": "red",
"warning": "darkYellow", "warning": "yellow",
"muted": "mediumGray", "muted": "mediumGray",
"dim": "dimGray",
"text": "", "text": "",
"userMessageBg": "userMsgBg", "userMessageBg": "userMsgBg",
@ -30,30 +32,38 @@
"toolPendingBg": "toolPendingBg", "toolPendingBg": "toolPendingBg",
"toolSuccessBg": "toolSuccessBg", "toolSuccessBg": "toolSuccessBg",
"toolErrorBg": "toolErrorBg", "toolErrorBg": "toolErrorBg",
"toolText": "", "toolTitle": "",
"toolOutput": "mediumGray",
"mdHeading": "darkCyan", "mdHeading": "yellow",
"mdLink": "darkBlue", "mdLink": "blue",
"mdCode": "darkCyan", "mdLinkUrl": "dimGray",
"mdCodeBlock": "", "mdCode": "teal",
"mdCodeBlock": "green",
"mdCodeBlockBorder": "mediumGray", "mdCodeBlockBorder": "mediumGray",
"mdQuote": "mediumGray", "mdQuote": "mediumGray",
"mdQuoteBorder": "mediumGray", "mdQuoteBorder": "mediumGray",
"mdHr": "mediumGray", "mdHr": "mediumGray",
"mdListBullet": "darkCyan", "mdListBullet": "green",
"toolDiffAdded": "darkGreen", "toolDiffAdded": "green",
"toolDiffRemoved": "darkRed", "toolDiffRemoved": "red",
"toolDiffContext": "mediumGray", "toolDiffContext": "mediumGray",
"syntaxComment": "mediumGray", "syntaxComment": "mediumGray",
"syntaxKeyword": "darkCyan", "syntaxKeyword": "teal",
"syntaxFunction": "darkBlue", "syntaxFunction": "blue",
"syntaxVariable": "", "syntaxVariable": "",
"syntaxString": "darkGreen", "syntaxString": "green",
"syntaxNumber": "darkYellow", "syntaxNumber": "yellow",
"syntaxType": "darkCyan", "syntaxType": "teal",
"syntaxOperator": "", "syntaxOperator": "",
"syntaxPunctuation": "mediumGray" "syntaxPunctuation": "mediumGray",
"thinkingOff": "lightGray",
"thinkingMinimal": "#9e9e9e",
"thinkingLow": "#5f87af",
"thinkingMedium": "#5f8787",
"thinkingHigh": "#875f87"
} }
} }

View file

@ -43,6 +43,7 @@
"error", "error",
"warning", "warning",
"muted", "muted",
"dim",
"text", "text",
"userMessageBg", "userMessageBg",
"userMessageText", "userMessageText",
@ -105,6 +106,10 @@
"$ref": "#/$defs/colorValue", "$ref": "#/$defs/colorValue",
"description": "Secondary/dimmed text" "description": "Secondary/dimmed text"
}, },
"dim": {
"$ref": "#/$defs/colorValue",
"description": "Very dimmed text (more subtle than muted)"
},
"text": { "text": {
"$ref": "#/$defs/colorValue", "$ref": "#/$defs/colorValue",
"description": "Default text color (usually empty string)" "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 os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { fileURLToPath } from "node:url"; 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 { type Static, Type } from "@sinclair/typebox";
import { TypeCompiler } from "@sinclair/typebox/compiler"; import { TypeCompiler } from "@sinclair/typebox/compiler";
import chalk from "chalk"; import chalk from "chalk";
@ -25,7 +25,7 @@ const ThemeJsonSchema = Type.Object({
name: Type.String(), name: Type.String(),
vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)), vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)),
colors: Type.Object({ colors: Type.Object({
// Core UI (9 colors) // Core UI (10 colors)
accent: ColorValueSchema, accent: ColorValueSchema,
border: ColorValueSchema, border: ColorValueSchema,
borderAccent: ColorValueSchema, borderAccent: ColorValueSchema,
@ -34,17 +34,20 @@ const ThemeJsonSchema = Type.Object({
error: ColorValueSchema, error: ColorValueSchema,
warning: ColorValueSchema, warning: ColorValueSchema,
muted: ColorValueSchema, muted: ColorValueSchema,
dim: ColorValueSchema,
text: ColorValueSchema, text: ColorValueSchema,
// Backgrounds & Content Text (6 colors) // Backgrounds & Content Text (7 colors)
userMessageBg: ColorValueSchema, userMessageBg: ColorValueSchema,
userMessageText: ColorValueSchema, userMessageText: ColorValueSchema,
toolPendingBg: ColorValueSchema, toolPendingBg: ColorValueSchema,
toolSuccessBg: ColorValueSchema, toolSuccessBg: ColorValueSchema,
toolErrorBg: ColorValueSchema, toolErrorBg: ColorValueSchema,
toolText: ColorValueSchema, toolTitle: ColorValueSchema,
// Markdown (9 colors) toolOutput: ColorValueSchema,
// Markdown (10 colors)
mdHeading: ColorValueSchema, mdHeading: ColorValueSchema,
mdLink: ColorValueSchema, mdLink: ColorValueSchema,
mdLinkUrl: ColorValueSchema,
mdCode: ColorValueSchema, mdCode: ColorValueSchema,
mdCodeBlock: ColorValueSchema, mdCodeBlock: ColorValueSchema,
mdCodeBlockBorder: ColorValueSchema, mdCodeBlockBorder: ColorValueSchema,
@ -66,6 +69,12 @@ const ThemeJsonSchema = Type.Object({
syntaxType: ColorValueSchema, syntaxType: ColorValueSchema,
syntaxOperator: ColorValueSchema, syntaxOperator: ColorValueSchema,
syntaxPunctuation: 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" | "error"
| "warning" | "warning"
| "muted" | "muted"
| "dim"
| "text" | "text"
| "userMessageText" | "userMessageText"
| "toolText" | "toolTitle"
| "toolOutput"
| "mdHeading" | "mdHeading"
| "mdLink" | "mdLink"
| "mdLinkUrl"
| "mdCode" | "mdCode"
| "mdCodeBlock" | "mdCodeBlock"
| "mdCodeBlockBorder" | "mdCodeBlockBorder"
@ -105,7 +117,12 @@ export type ThemeColor =
| "syntaxNumber" | "syntaxNumber"
| "syntaxType" | "syntaxType"
| "syntaxOperator" | "syntaxOperator"
| "syntaxPunctuation"; | "syntaxPunctuation"
| "thinkingOff"
| "thinkingMinimal"
| "thinkingLow"
| "thinkingMedium"
| "thinkingHigh";
export type ThemeBg = "userMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg"; export type ThemeBg = "userMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
@ -216,8 +233,6 @@ function resolveThemeColors<T extends Record<string, ColorValue>>(
// Theme Class // Theme Class
// ============================================================================ // ============================================================================
const RESET = "\x1b[0m";
export class Theme { export class Theme {
private fgColors: Map<ThemeColor, string>; private fgColors: Map<ThemeColor, string>;
private bgColors: Map<ThemeBg, string>; private bgColors: Map<ThemeBg, string>;
@ -242,13 +257,13 @@ export class Theme {
fg(color: ThemeColor, text: string): string { fg(color: ThemeColor, text: string): string {
const ansi = this.fgColors.get(color); const ansi = this.fgColors.get(color);
if (!ansi) throw new Error(`Unknown theme color: ${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 { bg(color: ThemeBg, text: string): string {
const ansi = this.bgColors.get(color); const ansi = this.bgColors.get(color);
if (!ansi) throw new Error(`Unknown theme background color: ${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 { bold(text: string): string {
@ -278,6 +293,24 @@ export class Theme {
getColorMode(): ColorMode { getColorMode(): ColorMode {
return this.mode; 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) { if (parts.length >= 2) {
const bg = parseInt(parts[1], 10); const bg = parseInt(parts[1], 10);
if (!Number.isNaN(bg)) { 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; export let theme: Theme;
let currentThemeName: string | undefined;
let themeWatcher: fs.FSWatcher | undefined;
let onThemeChangeCallback: (() => void) | undefined;
export function initTheme(themeName?: string): void { export function initTheme(themeName?: string): void {
const name = themeName ?? getDefaultTheme(); 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 { export function setTheme(name: string): { success: boolean; error?: string } {
theme = loadTheme(name); 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 { return {
heading: (text: string) => theme.fg("mdHeading", text), heading: (text: string) => theme.fg("mdHeading", text),
link: (text: string) => theme.fg("mdLink", text), link: (text: string) => theme.fg("mdLink", text),
linkUrl: (text: string) => theme.fg("mdLinkUrl", text),
code: (text: string) => theme.fg("mdCode", text), code: (text: string) => theme.fg("mdCode", text),
codeBlock: (text: string) => theme.fg("mdCodeBlock", text), codeBlock: (text: string) => theme.fg("mdCodeBlock", text),
codeBlockBorder: (text: string) => theme.fg("mdCodeBlockBorder", text), codeBlockBorder: (text: string) => theme.fg("mdCodeBlockBorder", text),
@ -410,5 +540,26 @@ export function getMarkdownTheme(): MarkdownTheme {
quoteBorder: (text: string) => theme.fg("mdQuoteBorder", text), quoteBorder: (text: string) => theme.fg("mdQuoteBorder", text),
hr: (text: string) => theme.fg("mdHr", text), hr: (text: string) => theme.fg("mdHr", text),
listBullet: (text: string) => theme.fg("mdListBullet", 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 type { AssistantMessage } from "@mariozechner/pi-ai";
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; 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 * Component that renders a complete assistant message
@ -38,13 +38,13 @@ export class AssistantMessageComponent extends Container {
if (content.type === "text" && content.text.trim()) { if (content.type === "text" && content.text.trim()) {
// Assistant text messages with no background - trim the text // Assistant text messages with no background - trim the text
// Set paddingY=0 to avoid extra spacing before tool executions // 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()) { } 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 // Use Markdown component with default text style for consistent styling
this.contentContainer.addChild( this.contentContainer.addChild(
new Markdown(content.thinking.trim(), 1, 0, { new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), {
color: "gray", color: (text: string) => theme.fg("muted", text),
italic: true, italic: true,
}), }),
); );
@ -57,11 +57,11 @@ export class AssistantMessageComponent extends Container {
const hasToolCalls = message.content.some((c) => c.type === "toolCall"); const hasToolCalls = message.content.some((c) => c.type === "toolCall");
if (!hasToolCalls) { if (!hasToolCalls) {
if (message.stopReason === "aborted") { 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") { } else if (message.stopReason === "error") {
const errorMsg = message.errorMessage || "Unknown error"; const errorMsg = message.errorMessage || "Unknown error";
this.contentContainer.addChild(new Spacer(1)); 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 type { Component } from "@mariozechner/pi-tui";
import chalk from "chalk"; import { theme } from "../theme/theme.js";
/** /**
* Dynamic border component that adjusts to viewport width * Dynamic border component that adjusts to viewport width
@ -7,10 +7,14 @@ import chalk from "chalk";
export class DynamicBorder implements Component { export class DynamicBorder implements Component {
private color: (str: string) => string; 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; this.color = color;
} }
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] { render(width: number): string[] {
return [this.color("─".repeat(Math.max(1, width)))]; return [this.color("─".repeat(Math.max(1, width)))];
} }

View file

@ -1,12 +1,12 @@
import type { AgentState } from "@mariozechner/pi-agent"; import type { AgentState } from "@mariozechner/pi-agent";
import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { AssistantMessage } from "@mariozechner/pi-ai";
import { visibleWidth } from "@mariozechner/pi-tui"; import { type Component, visibleWidth } from "@mariozechner/pi-tui";
import chalk from "chalk"; import { theme } from "../theme/theme.js";
/** /**
* Footer component that shows pwd, token stats, and context usage * Footer component that shows pwd, token stats, and context usage
*/ */
export class FooterComponent { export class FooterComponent implements Component {
private state: AgentState; private state: AgentState;
constructor(state: AgentState) { constructor(state: AgentState) {
@ -17,6 +17,10 @@ export class FooterComponent {
this.state = state; this.state = state;
} }
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] { render(width: number): string[] {
// Calculate cumulative usage from all assistant messages // Calculate cumulative usage from all assistant messages
let totalInput = 0; let totalInput = 0;
@ -50,7 +54,8 @@ export class FooterComponent {
lastAssistantMessage.usage.cacheWrite lastAssistantMessage.usage.cacheWrite
: 0; : 0;
const contextWindow = this.state.model?.contextWindow || 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) // Format token counts (similar to web-ui)
const formatTokens = (count: number): string => { const formatTokens = (count: number): string => {
@ -80,8 +85,18 @@ export class FooterComponent {
if (totalOutput) statsParts.push(`${formatTokens(totalOutput)}`); if (totalOutput) statsParts.push(`${formatTokens(totalOutput)}`);
if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`); if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);
if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`); if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);
if (totalCost) statsParts.push(`$${totalCost.toFixed(3)}`); if (totalCost) statsParts.push(`${totalCost.toFixed(3)}`);
statsParts.push(`${contextPercent}%`);
// 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(" "); const statsLeft = statsParts.join(" ");
@ -126,6 +141,6 @@ export class FooterComponent {
} }
// Return two lines: pwd and stats // 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 type { Model } from "@mariozechner/pi-ai";
import { Container, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; import { Container, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import chalk from "chalk";
import { getAvailableModels } from "../model-config.js"; import { getAvailableModels } from "../model-config.js";
import type { SettingsManager } from "../settings-manager.js"; import type { SettingsManager } from "../settings-manager.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
interface ModelItem { interface ModelItem {
provider: string; provider: string;
@ -42,12 +43,12 @@ export class ModelSelectorComponent extends Container {
this.onCancelCallback = onCancel; this.onCancelCallback = onCancel;
// Add top border // Add top border
this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0)); this.addChild(new DynamicBorder());
this.addChild(new Spacer(1)); this.addChild(new Spacer(1));
// Add hint about API key filtering // Add hint about API key filtering
this.addChild( 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)); this.addChild(new Spacer(1));
@ -70,7 +71,7 @@ export class ModelSelectorComponent extends Container {
this.addChild(new Spacer(1)); this.addChild(new Spacer(1));
// Add bottom border // Add bottom border
this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0)); this.addChild(new DynamicBorder());
// Load models and do initial render // Load models and do initial render
this.loadModels().then(() => { this.loadModels().then(() => {
@ -150,15 +151,15 @@ export class ModelSelectorComponent extends Container {
let line = ""; let line = "";
if (isSelected) { if (isSelected) {
const prefix = chalk.blue("→ "); const prefix = theme.fg("accent", "→ ");
const modelText = `${item.id}`; const modelText = `${item.id}`;
const providerBadge = chalk.gray(`[${item.provider}]`); const providerBadge = theme.fg("muted", `[${item.provider}]`);
const checkmark = isCurrent ? chalk.green(" ✓") : ""; const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
line = prefix + chalk.blue(modelText) + " " + providerBadge + checkmark; line = prefix + theme.fg("accent", modelText) + " " + providerBadge + checkmark;
} else { } else {
const modelText = ` ${item.id}`; const modelText = ` ${item.id}`;
const providerBadge = chalk.gray(`[${item.provider}]`); const providerBadge = theme.fg("muted", `[${item.provider}]`);
const checkmark = isCurrent ? chalk.green(" ✓") : ""; const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
line = modelText + " " + providerBadge + checkmark; line = modelText + " " + providerBadge + checkmark;
} }
@ -167,7 +168,7 @@ export class ModelSelectorComponent extends Container {
// Add scroll indicator if needed // Add scroll indicator if needed
if (startIndex > 0 || endIndex < this.filteredModels.length) { 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)); this.listContainer.addChild(new Text(scrollInfo, 0, 0));
} }
@ -176,10 +177,10 @@ export class ModelSelectorComponent extends Container {
// Show error in red // Show error in red
const errorLines = this.errorMessage.split("\n"); const errorLines = this.errorMessage.split("\n");
for (const line of errorLines) { 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) { } 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 { Container, Spacer, Text } from "@mariozechner/pi-tui";
import chalk from "chalk";
import { getOAuthProviders, type OAuthProviderInfo } from "../oauth/index.js"; 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 * Component that renders an OAuth provider selector
@ -24,12 +25,12 @@ export class OAuthSelectorComponent extends Container {
this.loadProviders(); this.loadProviders();
// Add top border // Add top border
this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0)); this.addChild(new DynamicBorder());
this.addChild(new Spacer(1)); this.addChild(new Spacer(1));
// Add title // Add title
const title = mode === "login" ? "Select provider to login:" : "Select provider to logout:"; 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)); this.addChild(new Spacer(1));
// Create list container // Create list container
@ -39,7 +40,7 @@ export class OAuthSelectorComponent extends Container {
this.addChild(new Spacer(1)); this.addChild(new Spacer(1));
// Add bottom border // Add bottom border
this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0)); this.addChild(new DynamicBorder());
// Initial render // Initial render
this.updateList(); this.updateList();
@ -62,11 +63,11 @@ export class OAuthSelectorComponent extends Container {
let line = ""; let line = "";
if (isSelected) { if (isSelected) {
const prefix = chalk.blue("→ "); const prefix = theme.fg("accent", "→ ");
const text = isAvailable ? chalk.blue(provider.name) : chalk.dim(provider.name); const text = isAvailable ? theme.fg("accent", provider.name) : theme.fg("dim", provider.name);
line = prefix + text; line = prefix + text;
} else { } else {
const text = isAvailable ? ` ${provider.name}` : chalk.dim(` ${provider.name}`); const text = isAvailable ? ` ${provider.name}` : theme.fg("dim", ` ${provider.name}`);
line = text; line = text;
} }
@ -77,7 +78,7 @@ export class OAuthSelectorComponent extends Container {
if (this.allProviders.length === 0) { if (this.allProviders.length === 0) {
const message = const message =
this.mode === "login" ? "No OAuth providers available" : "No OAuth providers logged in. Use /login first."; 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 { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui";
import chalk from "chalk"; import { getSelectListTheme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.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)))];
}
}
/** /**
* Component that renders a queue mode selector with borders * Component that renders a queue mode selector with borders
@ -36,7 +28,7 @@ export class QueueModeSelectorComponent extends Container {
this.addChild(new DynamicBorder()); this.addChild(new DynamicBorder());
// Create selector // Create selector
this.selectList = new SelectList(queueModes, 2); this.selectList = new SelectList(queueModes, 2, getSelectListTheme());
// Preselect current mode // Preselect current mode
const currentIndex = queueModes.findIndex((item) => item.value === currentMode); 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 { type Component, Container, Input, Spacer, Text } from "@mariozechner/pi-tui";
import chalk from "chalk";
import type { SessionManager } from "../session-manager.js"; import type { SessionManager } from "../session-manager.js";
import { theme } from "../theme/theme.js";
/** import { DynamicBorder } from "./dynamic-border.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)))];
}
}
interface SessionItem { interface SessionItem {
path: string; path: string;
@ -67,6 +59,10 @@ class SessionList implements Component {
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1)); 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[] { render(width: number): string[] {
const lines: string[] = []; const lines: string[] = [];
@ -75,7 +71,7 @@ class SessionList implements Component {
lines.push(""); // Blank line after search lines.push(""); // Blank line after search
if (this.filteredSessions.length === 0) { if (this.filteredSessions.length === 0) {
lines.push(chalk.gray(" No sessions found")); lines.push(theme.fg("muted", " No sessions found"));
return lines; return lines;
} }
@ -112,16 +108,16 @@ class SessionList implements Component {
const normalizedMessage = session.firstMessage.replace(/\n/g, " ").trim(); const normalizedMessage = session.firstMessage.replace(/\n/g, " ").trim();
// First line: cursor + message // First line: cursor + message
const cursor = isSelected ? chalk.blue(" ") : " "; const cursor = isSelected ? theme.fg("accent", " ") : " ";
const maxMsgWidth = width - 2; // Account for cursor const maxMsgWidth = width - 2; // Account for cursor
const truncatedMsg = normalizedMessage.substring(0, maxMsgWidth); 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) // Second line: metadata (dimmed)
const modified = formatDate(session.modified); const modified = formatDate(session.modified);
const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`; const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`;
const metadata = ` ${modified} · ${msgCount}`; const metadata = ` ${modified} · ${msgCount}`;
const metadataLine = chalk.dim(metadata); const metadataLine = theme.fg("dim", metadata);
lines.push(messageLine); lines.push(messageLine);
lines.push(metadataLine); lines.push(metadataLine);
@ -130,7 +126,7 @@ class SessionList implements Component {
// Add scroll indicator if needed // Add scroll indicator if needed
if (startIndex > 0 || endIndex < this.filteredSessions.length) { 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); lines.push(scrollInfo);
} }
@ -185,7 +181,7 @@ export class SessionSelectorComponent extends Container {
// Add header // Add header
this.addChild(new Spacer(1)); 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 Spacer(1));
this.addChild(new DynamicBorder()); this.addChild(new DynamicBorder());
this.addChild(new Spacer(1)); this.addChild(new Spacer(1));

View file

@ -1,5 +1,5 @@
import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui"; 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"; import { DynamicBorder } from "./dynamic-border.js";
/** /**
@ -7,9 +7,16 @@ import { DynamicBorder } from "./dynamic-border.js";
*/ */
export class ThemeSelectorComponent extends Container { export class ThemeSelectorComponent extends Container {
private selectList: SelectList; 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(); super();
this.onPreview = onPreview;
// Get available themes and create select items // Get available themes and create select items
const themes = getAvailableThemes(); const themes = getAvailableThemes();
@ -20,10 +27,10 @@ export class ThemeSelectorComponent extends Container {
})); }));
// Add top border // Add top border
this.addChild(new DynamicBorder((text) => theme.fg("border", text))); this.addChild(new DynamicBorder());
// Create selector // Create selector
this.selectList = new SelectList(themeItems, 10); this.selectList = new SelectList(themeItems, 10, getSelectListTheme());
// Preselect current theme // Preselect current theme
const currentIndex = themes.indexOf(currentTheme); const currentIndex = themes.indexOf(currentTheme);
@ -39,10 +46,14 @@ export class ThemeSelectorComponent extends Container {
onCancel(); onCancel();
}; };
this.selectList.onSelectionChange = (item) => {
this.onPreview(item.value);
};
this.addChild(this.selectList); this.addChild(this.selectList);
// Add bottom border // Add bottom border
this.addChild(new DynamicBorder((text) => theme.fg("border", text))); this.addChild(new DynamicBorder());
} }
getSelectList(): SelectList { getSelectList(): SelectList {

View file

@ -1,15 +1,7 @@
import type { ThinkingLevel } from "@mariozechner/pi-agent"; import type { ThinkingLevel } from "@mariozechner/pi-agent";
import { type Component, Container, type SelectItem, SelectList } from "@mariozechner/pi-tui"; import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui";
import chalk from "chalk"; import { getSelectListTheme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.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)))];
}
}
/** /**
* Component that renders a thinking level selector with borders * Component that renders a thinking level selector with borders
@ -32,7 +24,7 @@ export class ThinkingSelectorComponent extends Container {
this.addChild(new DynamicBorder()); this.addChild(new DynamicBorder());
// Create selector // Create selector
this.selectList = new SelectList(thinkingLevels, 5); this.selectList = new SelectList(thinkingLevels, 5, getSelectListTheme());
// Preselect current level // Preselect current level
const currentIndex = thinkingLevels.findIndex((item) => item.value === currentLevel); const currentIndex = thinkingLevels.findIndex((item) => item.value === currentLevel);

View file

@ -1,8 +1,7 @@
import * as os from "node:os"; import * as os from "node:os";
import { Container, Spacer, Text } from "@mariozechner/pi-tui"; import { Container, Spacer, Text } from "@mariozechner/pi-tui";
import chalk from "chalk";
import * as Diff from "diff";
import stripAnsi from "strip-ansi"; import stripAnsi from "strip-ansi";
import { theme } from "../theme/theme.js";
/** /**
* Convert absolute path to tilde notation if it's in home directory * 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, " "); 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) * Component that renders a tool call with its result (updateable)
*/ */
@ -140,7 +41,7 @@ export class ToolExecutionComponent extends Container {
this.args = args; this.args = args;
this.addChild(new Spacer(1)); this.addChild(new Spacer(1));
// Content with colored background and padding // 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.addChild(this.contentText);
this.updateDisplay(); this.updateDisplay();
} }
@ -165,13 +66,13 @@ export class ToolExecutionComponent extends Container {
} }
private updateDisplay(): void { private updateDisplay(): void {
const bgColor = this.result const bgFn = this.result
? this.result.isError ? this.result.isError
? { r: 60, g: 40, b: 40 } ? (text: string) => theme.bg("toolErrorBg", text)
: { r: 40, g: 50, b: 40 } : (text: string) => theme.bg("toolSuccessBg", text)
: { r: 40, g: 40, b: 50 }; : (text: string) => theme.bg("toolPendingBg", text);
this.contentText.setCustomBgRgb(bgColor); this.contentText.setCustomBgFn(bgFn);
this.contentText.setText(this.formatToolExecution()); this.contentText.setText(this.formatToolExecution());
} }
@ -200,7 +101,7 @@ export class ToolExecutionComponent extends Container {
// Format based on tool type // Format based on tool type
if (this.toolName === "bash") { if (this.toolName === "bash") {
const command = this.args?.command || ""; const command = this.args?.command || "";
text = chalk.bold(`$ ${command || chalk.dim("...")}`); text = theme.fg("toolTitle", theme.bold(`$ ${command || theme.fg("toolOutput", "...")}`));
if (this.result) { if (this.result) {
// Show output without code fences - more minimal // Show output without code fences - more minimal
@ -211,9 +112,9 @@ export class ToolExecutionComponent extends Container {
const displayLines = lines.slice(0, maxLines); const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - 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) { 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; const limit = this.args?.limit;
// Build path display with offset/limit suffix // 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) { if (offset !== undefined) {
const endLine = limit !== undefined ? offset + limit : ""; 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) { if (this.result) {
const output = this.getTextOutput(); const output = this.getTextOutput();
@ -238,9 +139,9 @@ export class ToolExecutionComponent extends Container {
const displayLines = lines.slice(0, maxLines); const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - 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) { if (remaining > 0) {
text += chalk.dim(`\n... (${remaining} more lines)`); text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
} }
} }
} else if (this.toolName === "write") { } else if (this.toolName === "write") {
@ -249,7 +150,10 @@ export class ToolExecutionComponent extends Container {
const lines = fileContent ? fileContent.split("\n") : []; const lines = fileContent ? fileContent.split("\n") : [];
const totalLines = lines.length; 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) { if (totalLines > 10) {
text += ` (${totalLines} lines)`; text += ` (${totalLines} lines)`;
} }
@ -260,32 +164,35 @@ export class ToolExecutionComponent extends Container {
const displayLines = lines.slice(0, maxLines); const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - 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) { if (remaining > 0) {
text += chalk.dim(`\n... (${remaining} more lines)`); text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
} }
} }
} else if (this.toolName === "edit") { } else if (this.toolName === "edit") {
const path = shortenPath(this.args?.file_path || this.args?.path || ""); 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) { if (this.result) {
// Show error message if it's an error // Show error message if it's an error
if (this.result.isError) { if (this.result.isError) {
const errorText = this.getTextOutput(); const errorText = this.getTextOutput();
if (errorText) { if (errorText) {
text += "\n\n" + chalk.red(errorText); text += "\n\n" + theme.fg("error", errorText);
} }
} else if (this.result.details?.diff) { } else if (this.result.details?.diff) {
// Show diff if available // Show diff if available
const diffLines = this.result.details.diff.split("\n"); const diffLines = this.result.details.diff.split("\n");
const coloredLines = diffLines.map((line: string) => { const coloredLines = diffLines.map((line: string) => {
if (line.startsWith("+")) { if (line.startsWith("+")) {
return chalk.green(line); return theme.fg("toolDiffAdded", line);
} else if (line.startsWith("-")) { } else if (line.startsWith("-")) {
return chalk.red(line); return theme.fg("toolDiffRemoved", line);
} else { } else {
return chalk.dim(line); return theme.fg("toolDiffContext", line);
} }
}); });
text += "\n\n" + coloredLines.join("\n"); text += "\n\n" + coloredLines.join("\n");
@ -293,7 +200,7 @@ export class ToolExecutionComponent extends Container {
} }
} else { } else {
// Generic tool // Generic tool
text = chalk.bold(this.toolName); text = theme.fg("toolTitle", theme.bold(this.toolName));
const content = JSON.stringify(this.args, null, 2); const content = JSON.stringify(this.args, null, 2);
text += "\n\n" + content; text += "\n\n" + content;

View file

@ -13,7 +13,7 @@ import {
TruncatedText, TruncatedText,
TUI, TUI,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
import chalk from "chalk";
import { exec } from "child_process"; import { exec } from "child_process";
import { getChangelogPath, parseChangelog } from "../changelog.js"; import { getChangelogPath, parseChangelog } from "../changelog.js";
import { exportSessionToHtml } from "../export-html.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 { listOAuthProviders, login, logout } from "../oauth/index.js";
import type { SessionManager } from "../session-manager.js"; import type { SessionManager } from "../session-manager.js";
import type { SettingsManager } from "../settings-manager.js"; import type { SettingsManager } from "../settings-manager.js";
import { setTheme } from "../theme/theme.js"; import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
import { AssistantMessageComponent } from "./assistant-message.js"; import { AssistantMessageComponent } from "./assistant-message.js";
import { CustomEditor } from "./custom-editor.js"; import { CustomEditor } from "./custom-editor.js";
import { DynamicBorder } from "./dynamic-border.js"; import { DynamicBorder } from "./dynamic-border.js";
@ -114,7 +114,7 @@ export class TuiRenderer {
this.chatContainer = new Container(); this.chatContainer = new Container();
this.pendingMessagesContainer = new Container(); this.pendingMessagesContainer = new Container();
this.statusContainer = 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 = new Container(); // Container to hold editor or selector
this.editorContainer.addChild(this.editor); // Start with editor this.editorContainer.addChild(this.editor); // Start with editor
this.footer = new FooterComponent(agent.state); this.footer = new FooterComponent(agent.state);
@ -193,34 +193,34 @@ export class TuiRenderer {
if (this.isInitialized) return; if (this.isInitialized) return;
// Add header with logo and instructions // 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 = const instructions =
chalk.dim("esc") + theme.fg("dim", "esc") +
chalk.gray(" to interrupt") + theme.fg("muted", " to interrupt") +
"\n" + "\n" +
chalk.dim("ctrl+c") + theme.fg("dim", "ctrl+c") +
chalk.gray(" to clear") + theme.fg("muted", " to clear") +
"\n" + "\n" +
chalk.dim("ctrl+c twice") + theme.fg("dim", "ctrl+c twice") +
chalk.gray(" to exit") + theme.fg("muted", " to exit") +
"\n" + "\n" +
chalk.dim("ctrl+k") + theme.fg("dim", "ctrl+k") +
chalk.gray(" to delete line") + theme.fg("muted", " to delete line") +
"\n" + "\n" +
chalk.dim("shift+tab") + theme.fg("dim", "shift+tab") +
chalk.gray(" to cycle thinking") + theme.fg("muted", " to cycle thinking") +
"\n" + "\n" +
chalk.dim("ctrl+p") + theme.fg("dim", "ctrl+p") +
chalk.gray(" to cycle models") + theme.fg("muted", " to cycle models") +
"\n" + "\n" +
chalk.dim("ctrl+o") + theme.fg("dim", "ctrl+o") +
chalk.gray(" to expand tools") + theme.fg("muted", " to expand tools") +
"\n" + "\n" +
chalk.dim("/") + theme.fg("dim", "/") +
chalk.gray(" for commands") + theme.fg("muted", " for commands") +
"\n" + "\n" +
chalk.dim("drop files") + theme.fg("dim", "drop files") +
chalk.gray(" to attach"); theme.fg("muted", " to attach");
const header = new Text(logo + "\n" + instructions, 1, 0); const header = new Text(logo + "\n" + instructions, 1, 0);
// Setup UI layout // Setup UI layout
@ -230,28 +230,28 @@ export class TuiRenderer {
// Add new version notification if available // Add new version notification if available
if (this.newVersion) { if (this.newVersion) {
this.ui.addChild(new DynamicBorder(chalk.yellow)); this.ui.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.ui.addChild( this.ui.addChild(
new Text( new Text(
chalk.bold.yellow("Update Available") + theme.bold(theme.fg("warning", "Update Available")) +
"\n" + "\n" +
chalk.gray(`New version ${this.newVersion} is available. Run: `) + theme.fg("muted", `New version ${this.newVersion} is available. Run: `) +
chalk.cyan("npm install -g @mariozechner/pi-coding-agent"), theme.fg("accent", "npm install -g @mariozechner/pi-coding-agent"),
1, 1,
0, 0,
), ),
); );
this.ui.addChild(new DynamicBorder(chalk.yellow)); this.ui.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
} }
// Add changelog if provided // Add changelog if provided
if (this.changelogMarkdown) { if (this.changelogMarkdown) {
this.ui.addChild(new DynamicBorder(chalk.cyan)); this.ui.addChild(new DynamicBorder());
this.ui.addChild(new Text(chalk.bold.cyan("What's New"), 1, 0)); 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 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 Spacer(1));
this.ui.addChild(new DynamicBorder(chalk.cyan)); this.ui.addChild(new DynamicBorder());
} }
this.ui.addChild(this.chatContainer); this.ui.addChild(this.chatContainer);
@ -435,6 +435,13 @@ export class TuiRenderer {
// Start the UI // Start the UI
this.ui.start(); this.ui.start();
this.isInitialized = true; 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> { async handleEvent(event: AgentEvent, state: AgentState): Promise<void> {
@ -454,7 +461,12 @@ export class TuiRenderer {
this.loadingAnimation.stop(); this.loadingAnimation.stop();
} }
this.statusContainer.clear(); 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.statusContainer.addChild(this.loadingAnimation);
this.ui.requestRender(); this.ui.requestRender();
break; 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 { private updateEditorBorderColor(): void {
const level = this.agent.state.thinkingLevel || "off"; const level = this.agent.state.thinkingLevel || "off";
const color = this.getThinkingBorderColor(level); this.editor.borderColor = theme.getThinkingBorderColor(level);
this.editor.borderColor = color;
this.ui.requestRender(); this.ui.requestRender();
} }
@ -747,7 +740,7 @@ export class TuiRenderer {
// Only cycle if model supports thinking // Only cycle if model supports thinking
if (!this.agent.state.model?.reasoning) { if (!this.agent.state.model?.reasoning) {
this.chatContainer.addChild(new Spacer(1)); 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(); this.ui.requestRender();
return; return;
} }
@ -769,7 +762,7 @@ export class TuiRenderer {
// Show brief notification // Show brief notification
this.chatContainer.addChild(new Spacer(1)); 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(); this.ui.requestRender();
} }
@ -794,7 +787,7 @@ export class TuiRenderer {
if (modelsToUse.length === 1) { if (modelsToUse.length === 1) {
this.chatContainer.addChild(new Spacer(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(); this.ui.requestRender();
return; return;
} }
@ -824,7 +817,7 @@ export class TuiRenderer {
// Show notification // Show notification
this.chatContainer.addChild(new Spacer(1)); 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(); this.ui.requestRender();
} }
@ -849,14 +842,14 @@ export class TuiRenderer {
showError(errorMessage: string): void { showError(errorMessage: string): void {
// Show error message in the chat // Show error message in the chat
this.chatContainer.addChild(new Spacer(1)); 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(); this.ui.requestRender();
} }
showWarning(warningMessage: string): void { showWarning(warningMessage: string): void {
// Show warning message in the chat // Show warning message in the chat
this.chatContainer.addChild(new Spacer(1)); 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(); this.ui.requestRender();
} }
@ -876,7 +869,7 @@ export class TuiRenderer {
// Show confirmation message with proper spacing // Show confirmation message with proper spacing
this.chatContainer.addChild(new Spacer(1)); 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); this.chatContainer.addChild(confirmText);
// Hide selector and show editor again // Hide selector and show editor again
@ -918,7 +911,7 @@ export class TuiRenderer {
// Show confirmation message with proper spacing // Show confirmation message with proper spacing
this.chatContainer.addChild(new Spacer(1)); 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); this.chatContainer.addChild(confirmText);
// Hide selector and show editor again // Hide selector and show editor again
@ -956,15 +949,27 @@ export class TuiRenderer {
currentTheme, currentTheme,
(themeName) => { (themeName) => {
// Apply the selected theme // Apply the selected theme
setTheme(themeName); const result = setTheme(themeName);
// Save theme to settings // Save theme to settings
this.settingsManager.setTheme(themeName); 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)); this.chatContainer.addChild(new Spacer(1));
const confirmText = new Text(chalk.dim(`Theme: ${themeName}`), 1, 0); if (result.success) {
this.chatContainer.addChild(confirmText); 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 // Hide selector and show editor again
this.hideThemeSelector(); this.hideThemeSelector();
@ -975,6 +980,15 @@ export class TuiRenderer {
this.hideThemeSelector(); this.hideThemeSelector();
this.ui.requestRender(); 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 // Replace editor with selector
@ -1007,7 +1021,7 @@ export class TuiRenderer {
// Show confirmation message with proper spacing // Show confirmation message with proper spacing
this.chatContainer.addChild(new Spacer(1)); 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); this.chatContainer.addChild(confirmText);
// Hide selector and show editor again // 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 // Don't show selector if there are no messages or only one message
if (userMessages.length <= 1) { if (userMessages.length <= 1) {
this.chatContainer.addChild(new Spacer(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(); this.ui.requestRender();
return; return;
} }
@ -1088,7 +1102,7 @@ export class TuiRenderer {
// Show confirmation message // Show confirmation message
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild( 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 // Put the selected message in the editor
@ -1127,7 +1141,9 @@ export class TuiRenderer {
const loggedInProviders = listOAuthProviders(); const loggedInProviders = listOAuthProviders();
if (loggedInProviders.length === 0) { if (loggedInProviders.length === 0) {
this.chatContainer.addChild(new Spacer(1)); 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(); this.ui.requestRender();
return; return;
} }
@ -1144,7 +1160,7 @@ export class TuiRenderer {
if (mode === "login") { if (mode === "login") {
// Handle login // Handle login
this.chatContainer.addChild(new Spacer(1)); 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(); this.ui.requestRender();
try { try {
@ -1153,11 +1169,11 @@ export class TuiRenderer {
(url: string) => { (url: string) => {
// Show auth URL to user // Show auth URL to user
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.cyan("Opening browser to:"), 1, 0)); this.chatContainer.addChild(new Text(theme.fg("accent", "Opening browser to:"), 1, 0));
this.chatContainer.addChild(new Text(chalk.cyan(url), 1, 0)); this.chatContainer.addChild(new Text(theme.fg("accent", url), 1, 0));
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild( 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(); this.ui.requestRender();
@ -1189,8 +1205,12 @@ export class TuiRenderer {
// Success // Success
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.green(`✓ Successfully logged in to ${providerId}`), 1, 0)); this.chatContainer.addChild(
this.chatContainer.addChild(new Text(chalk.dim(`Tokens saved to ~/.pi/agent/oauth.json`), 1, 0)); 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(); this.ui.requestRender();
} catch (error: any) { } catch (error: any) {
this.showError(`Login failed: ${error.message}`); this.showError(`Login failed: ${error.message}`);
@ -1202,10 +1222,10 @@ export class TuiRenderer {
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild( 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( 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(); this.ui.requestRender();
} catch (error: any) { } catch (error: any) {
@ -1246,13 +1266,13 @@ export class TuiRenderer {
// Show success message in chat - matching thinking level style // Show success message in chat - matching thinking level style
this.chatContainer.addChild(new Spacer(1)); 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(); this.ui.requestRender();
} catch (error: any) { } catch (error: any) {
// Show error message in chat // Show error message in chat
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild( 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(); this.ui.requestRender();
} }
@ -1299,29 +1319,29 @@ export class TuiRenderer {
const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite; const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite;
// Build info text // Build info text
let info = `${chalk.bold("Session Info")}\n\n`; let info = `${theme.bold("Session Info")}\n\n`;
info += `${chalk.dim("File:")} ${sessionFile}\n`; info += `${theme.fg("dim", "File:")} ${sessionFile}\n`;
info += `${chalk.dim("ID:")} ${this.sessionManager.getSessionId()}\n\n`; info += `${theme.fg("dim", "ID:")} ${this.sessionManager.getSessionId()}\n\n`;
info += `${chalk.bold("Messages")}\n`; info += `${theme.bold("Messages")}\n`;
info += `${chalk.dim("User:")} ${userMessages}\n`; info += `${theme.fg("dim", "User:")} ${userMessages}\n`;
info += `${chalk.dim("Assistant:")} ${assistantMessages}\n`; info += `${theme.fg("dim", "Assistant:")} ${assistantMessages}\n`;
info += `${chalk.dim("Tool Calls:")} ${toolCalls}\n`; info += `${theme.fg("dim", "Tool Calls:")} ${toolCalls}\n`;
info += `${chalk.dim("Tool Results:")} ${toolResults}\n`; info += `${theme.fg("dim", "Tool Results:")} ${toolResults}\n`;
info += `${chalk.dim("Total:")} ${totalMessages}\n\n`; info += `${theme.fg("dim", "Total:")} ${totalMessages}\n\n`;
info += `${chalk.bold("Tokens")}\n`; info += `${theme.bold("Tokens")}\n`;
info += `${chalk.dim("Input:")} ${totalInput.toLocaleString()}\n`; info += `${theme.fg("dim", "Input:")} ${totalInput.toLocaleString()}\n`;
info += `${chalk.dim("Output:")} ${totalOutput.toLocaleString()}\n`; info += `${theme.fg("dim", "Output:")} ${totalOutput.toLocaleString()}\n`;
if (totalCacheRead > 0) { if (totalCacheRead > 0) {
info += `${chalk.dim("Cache Read:")} ${totalCacheRead.toLocaleString()}\n`; info += `${theme.fg("dim", "Cache Read:")} ${totalCacheRead.toLocaleString()}\n`;
} }
if (totalCacheWrite > 0) { 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) { if (totalCost > 0) {
info += `\n${chalk.bold("Cost")}\n`; info += `\n${theme.bold("Cost")}\n`;
info += `${chalk.dim("Total:")} ${totalCost.toFixed(4)}`; info += `${theme.fg("dim", "Total:")} ${totalCost.toFixed(4)}`;
} }
// Show info in chat // Show info in chat
@ -1345,11 +1365,11 @@ export class TuiRenderer {
// Display in chat // Display in chat
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new DynamicBorder(chalk.cyan)); this.chatContainer.addChild(new DynamicBorder());
this.ui.addChild(new Text(chalk.bold.cyan("What's New"), 1, 0)); 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 Spacer(1));
this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1)); this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, getMarkdownTheme()));
this.chatContainer.addChild(new DynamicBorder(chalk.cyan)); this.chatContainer.addChild(new DynamicBorder());
this.ui.requestRender(); this.ui.requestRender();
} }
@ -1360,7 +1380,7 @@ export class TuiRenderer {
this.pendingMessagesContainer.addChild(new Spacer(1)); this.pendingMessagesContainer.addChild(new Spacer(1));
for (const message of this.queuedMessages) { 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)); 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 { type Component, Container, Spacer, Text } from "@mariozechner/pi-tui";
import chalk from "chalk"; import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
/**
* 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)))];
}
}
interface UserMessageItem { interface UserMessageItem {
index: number; // Index in the full messages array index: number; // Index in the full messages array
@ -39,11 +25,15 @@ class UserMessageList implements Component {
this.selectedIndex = Math.max(0, messages.length - 1); this.selectedIndex = Math.max(0, messages.length - 1);
} }
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] { render(width: number): string[] {
const lines: string[] = []; const lines: string[] = [];
if (this.messages.length === 0) { if (this.messages.length === 0) {
lines.push(chalk.gray(" No user messages found")); lines.push(theme.fg("muted", " No user messages found"));
return lines; return lines;
} }
@ -63,24 +53,24 @@ class UserMessageList implements Component {
const normalizedMessage = message.text.replace(/\n/g, " ").trim(); const normalizedMessage = message.text.replace(/\n/g, " ").trim();
// First line: cursor + message // First line: cursor + message
const cursor = isSelected ? chalk.blue(" ") : " "; const cursor = isSelected ? theme.fg("accent", " ") : " ";
const maxMsgWidth = width - 2; // Account for cursor const maxMsgWidth = width - 2; // Account for cursor
const truncatedMsg = normalizedMessage.substring(0, maxMsgWidth); 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); lines.push(messageLine);
// Second line: metadata (position in history) // Second line: metadata (position in history)
const position = i + 1; const position = i + 1;
const metadata = ` Message ${position} of ${this.messages.length}`; const metadata = ` Message ${position} of ${this.messages.length}`;
const metadataLine = chalk.dim(metadata); const metadataLine = theme.fg("muted", metadata);
lines.push(metadataLine); lines.push(metadataLine);
lines.push(""); // Blank line between messages lines.push(""); // Blank line between messages
} }
// Add scroll indicator if needed // Add scroll indicator if needed
if (startIndex > 0 || endIndex < this.messages.length) { 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); lines.push(scrollInfo);
} }
@ -129,8 +119,8 @@ export class UserMessageSelectorComponent extends Container {
// Add header // Add header
this.addChild(new Spacer(1)); this.addChild(new Spacer(1));
this.addChild(new Text(chalk.bold("Branch from Message"), 1, 0)); this.addChild(new Text(theme.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.fg("muted", "Select a message to create a new branch from that point"), 1, 0));
this.addChild(new Spacer(1)); this.addChild(new Spacer(1));
this.addChild(new DynamicBorder()); this.addChild(new DynamicBorder());
this.addChild(new Spacer(1)); this.addChild(new Spacer(1));

View file

@ -1,4 +1,5 @@
import { Container, Markdown, Spacer } from "@mariozechner/pi-tui"; import { Container, Markdown, Spacer } from "@mariozechner/pi-tui";
import { getMarkdownTheme, theme } from "../theme/theme.js";
/** /**
* Component that renders a user message * Component that renders a user message
@ -11,6 +12,11 @@ export class UserMessageComponent extends Container {
if (!isFirst) { if (!isFirst) {
this.addChild(new Spacer(1)); 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", "name": "@mariozechner/pi",
"version": "0.7.29", "version": "0.8.0",
"description": "CLI tool for managing vLLM deployments on GPU pods", "description": "CLI tool for managing vLLM deployments on GPU pods",
"type": "module", "type": "module",
"bin": { "bin": {
@ -34,7 +34,7 @@
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
"dependencies": { "dependencies": {
"@mariozechner/pi-agent": "^0.7.29", "@mariozechner/pi-agent": "^0.8.0",
"chalk": "^5.5.0" "chalk": "^5.5.0"
}, },
"devDependencies": {} "devDependencies": {}

View file

@ -1,6 +1,6 @@
{ {
"name": "@mariozechner/pi-proxy", "name": "@mariozechner/pi-proxy",
"version": "0.7.29", "version": "0.8.0",
"type": "module", "type": "module",
"description": "CORS and authentication proxy for pi-ai", "description": "CORS and authentication proxy for pi-ai",
"main": "dist/index.js", "main": "dist/index.js",

View file

@ -1,6 +1,6 @@
{ {
"name": "@mariozechner/pi-tui", "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", "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View file

@ -1,7 +1,6 @@
import chalk from "chalk";
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
import type { Component } from "../tui.js"; import type { Component } from "../tui.js";
import { SelectList } from "./select-list.js"; import { SelectList, type SelectListTheme } from "./select-list.js";
interface EditorState { interface EditorState {
lines: string[]; lines: string[];
@ -15,8 +14,9 @@ interface LayoutLine {
cursorPos?: number; cursorPos?: number;
} }
export interface TextEditorConfig { export interface EditorTheme {
// Configuration options for text editor (none currently) borderColor: (str: string) => string;
selectList: SelectListTheme;
} }
export class Editor implements Component { export class Editor implements Component {
@ -26,10 +26,10 @@ export class Editor implements Component {
cursorCol: 0, cursorCol: 0,
}; };
private config: TextEditorConfig = {}; private theme: EditorTheme;
// Border color (can be changed dynamically) // Border color (can be changed dynamically)
public borderColor: (str: string) => string = chalk.gray; public borderColor: (str: string) => string;
// Autocomplete support // Autocomplete support
private autocompleteProvider?: AutocompleteProvider; private autocompleteProvider?: AutocompleteProvider;
@ -49,20 +49,19 @@ export class Editor implements Component {
public onChange?: (text: string) => void; public onChange?: (text: string) => void;
public disableSubmit: boolean = false; public disableSubmit: boolean = false;
constructor(config?: TextEditorConfig) { constructor(theme: EditorTheme) {
if (config) { this.theme = theme;
this.config = { ...this.config, ...config }; this.borderColor = theme.borderColor;
}
}
configure(config: Partial<TextEditorConfig>): void {
this.config = { ...this.config, ...config };
} }
setAutocompleteProvider(provider: AutocompleteProvider): void { setAutocompleteProvider(provider: AutocompleteProvider): void {
this.autocompleteProvider = provider; this.autocompleteProvider = provider;
} }
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] { render(width: number): string[] {
const horizontal = this.borderColor("─"); const horizontal = this.borderColor("─");
@ -806,7 +805,7 @@ export class Editor implements Component {
if (suggestions && suggestions.items.length > 0) { if (suggestions && suggestions.items.length > 0) {
this.autocompletePrefix = suggestions.prefix; this.autocompletePrefix = suggestions.prefix;
this.autocompleteList = new SelectList(suggestions.items, 5); this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
this.isAutocompleting = true; this.isAutocompleting = true;
} else { } else {
this.cancelAutocomplete(); this.cancelAutocomplete();
@ -851,7 +850,7 @@ export class Editor implements Component {
if (suggestions && suggestions.items.length > 0) { if (suggestions && suggestions.items.length > 0) {
this.autocompletePrefix = suggestions.prefix; this.autocompletePrefix = suggestions.prefix;
this.autocompleteList = new SelectList(suggestions.items, 5); this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
this.isAutocompleting = true; this.isAutocompleting = true;
} else { } else {
this.cancelAutocomplete(); this.cancelAutocomplete();
@ -881,7 +880,7 @@ export class Editor implements Component {
this.autocompletePrefix = suggestions.prefix; this.autocompletePrefix = suggestions.prefix;
if (this.autocompleteList) { if (this.autocompleteList) {
// Update the existing list with new items // 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 { } else {
// No more matches, cancel autocomplete // No more matches, cancel autocomplete

View file

@ -129,6 +129,10 @@ export class Input implements Component {
this.cursor += cleanText.length; this.cursor += cleanText.length;
} }
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] { render(width: number): string[] {
// Calculate visible window // Calculate visible window
const prompt = "> "; const prompt = "> ";

View file

@ -1,4 +1,3 @@
import chalk from "chalk";
import type { TUI } from "../tui.js"; import type { TUI } from "../tui.js";
import { Text } from "./text.js"; import { Text } from "./text.js";
@ -13,6 +12,8 @@ export class Loader extends Text {
constructor( constructor(
ui: TUI, ui: TUI,
private spinnerColorFn: (str: string) => string,
private messageColorFn: (str: string) => string,
private message: string = "Loading...", private message: string = "Loading...",
) { ) {
super("", 1, 0); super("", 1, 0);
@ -46,7 +47,7 @@ export class Loader extends Text {
private updateDisplay() { private updateDisplay() {
const frame = this.frames[this.currentFrame]; 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) { if (this.ui) {
this.ui.requestRender(); this.ui.requestRender();
} }

View file

@ -1,20 +1,16 @@
import { Chalk } from "chalk";
import { marked, type Token } from "marked"; import { marked, type Token } from "marked";
import type { Component } from "../tui.js"; import type { Component } from "../tui.js";
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.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. * Default text styling for markdown content.
* Applied to all text unless overridden by markdown formatting. * Applied to all text unless overridden by markdown formatting.
*/ */
export interface DefaultTextStyle { export interface DefaultTextStyle {
/** Foreground color - named color or hex string like "#ff0000" */ /** Foreground color function */
color?: string; color?: (text: string) => string;
/** Background color - named color or hex string like "#ff0000" */ /** Background color function */
bgColor?: string; bgColor?: (text: string) => string;
/** Bold text */ /** Bold text */
bold?: boolean; bold?: boolean;
/** Italic text */ /** Italic text */
@ -32,6 +28,7 @@ export interface DefaultTextStyle {
export interface MarkdownTheme { export interface MarkdownTheme {
heading: (text: string) => string; heading: (text: string) => string;
link: (text: string) => string; link: (text: string) => string;
linkUrl: (text: string) => string;
code: (text: string) => string; code: (text: string) => string;
codeBlock: (text: string) => string; codeBlock: (text: string) => string;
codeBlockBorder: (text: string) => string; codeBlockBorder: (text: string) => string;
@ -39,6 +36,10 @@ export interface MarkdownTheme {
quoteBorder: (text: string) => string; quoteBorder: (text: string) => string;
hr: (text: string) => string; hr: (text: string) => string;
listBullet: (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 { export class Markdown implements Component {
@ -46,7 +47,7 @@ export class Markdown implements Component {
private paddingX: number; // Left/right padding private paddingX: number; // Left/right padding
private paddingY: number; // Top/bottom padding private paddingY: number; // Top/bottom padding
private defaultTextStyle?: DefaultTextStyle; private defaultTextStyle?: DefaultTextStyle;
private theme?: MarkdownTheme; private theme: MarkdownTheme;
// Cache for rendered output // Cache for rendered output
private cachedText?: string; private cachedText?: string;
@ -54,22 +55,25 @@ export class Markdown implements Component {
private cachedLines?: string[]; private cachedLines?: string[];
constructor( constructor(
text: string = "", text: string,
paddingX: number = 1, paddingX: number,
paddingY: number = 1, paddingY: number,
theme: MarkdownTheme,
defaultTextStyle?: DefaultTextStyle, defaultTextStyle?: DefaultTextStyle,
theme?: MarkdownTheme,
) { ) {
this.text = text; this.text = text;
this.paddingX = paddingX; this.paddingX = paddingX;
this.paddingY = paddingY; this.paddingY = paddingY;
this.defaultTextStyle = defaultTextStyle;
this.theme = theme; this.theme = theme;
this.defaultTextStyle = defaultTextStyle;
} }
setText(text: string): void { setText(text: string): void {
this.text = text; this.text = text;
// Invalidate cache when text changes this.invalidate();
}
invalidate(): void {
this.cachedText = undefined; this.cachedText = undefined;
this.cachedWidth = undefined; this.cachedWidth = undefined;
this.cachedLines = undefined; this.cachedLines = undefined;
@ -119,14 +123,14 @@ export class Markdown implements Component {
// Add margins and background to each wrapped line // Add margins and background to each wrapped line
const leftMargin = " ".repeat(this.paddingX); const leftMargin = " ".repeat(this.paddingX);
const rightMargin = " ".repeat(this.paddingX); const rightMargin = " ".repeat(this.paddingX);
const bgRgb = this.defaultTextStyle?.bgColor ? this.parseBgColor() : undefined; const bgFn = this.defaultTextStyle?.bgColor;
const contentLines: string[] = []; const contentLines: string[] = [];
for (const line of wrappedLines) { for (const line of wrappedLines) {
const lineWithMargins = leftMargin + line + rightMargin; const lineWithMargins = leftMargin + line + rightMargin;
if (bgRgb) { if (bgFn) {
contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgRgb)); contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));
} else { } else {
// No background - just pad to width // No background - just pad to width
const visibleLen = visibleWidth(lineWithMargins); const visibleLen = visibleWidth(lineWithMargins);
@ -139,7 +143,7 @@ export class Markdown implements Component {
const emptyLine = " ".repeat(width); const emptyLine = " ".repeat(width);
const emptyLines: string[] = []; const emptyLines: string[] = [];
for (let i = 0; i < this.paddingY; i++) { 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); emptyLines.push(line);
} }
@ -154,39 +158,6 @@ export class Markdown implements Component {
return result.length > 0 ? result : [""]; 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. * Apply default text style to a string.
* This is the base styling applied to all text content. * 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) // Apply foreground color (NOT background - that's applied at padding stage)
if (this.defaultTextStyle.color) { if (this.defaultTextStyle.color) {
if (this.defaultTextStyle.color.startsWith("#")) { styled = this.defaultTextStyle.color(styled);
// 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);
}
} }
// Apply text decorations // Apply text decorations using this.theme
if (this.defaultTextStyle.bold) { if (this.defaultTextStyle.bold) {
styled = colorChalk.bold(styled); styled = this.theme.bold(styled);
} }
if (this.defaultTextStyle.italic) { if (this.defaultTextStyle.italic) {
styled = colorChalk.italic(styled); styled = this.theme.italic(styled);
} }
if (this.defaultTextStyle.strikethrough) { if (this.defaultTextStyle.strikethrough) {
styled = colorChalk.strikethrough(styled); styled = this.theme.strikethrough(styled);
} }
if (this.defaultTextStyle.underline) { if (this.defaultTextStyle.underline) {
styled = colorChalk.underline(styled); styled = this.theme.underline(styled);
} }
return styled; return styled;
@ -240,13 +201,15 @@ export class Markdown implements Component {
const headingLevel = token.depth; const headingLevel = token.depth;
const headingPrefix = "#".repeat(headingLevel) + " "; const headingPrefix = "#".repeat(headingLevel) + " ";
const headingText = this.renderInlineTokens(token.tokens || []); const headingText = this.renderInlineTokens(token.tokens || []);
let styledHeading: string;
if (headingLevel === 1) { 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) { } else if (headingLevel === 2) {
lines.push(colorChalk.bold.yellow(headingText)); styledHeading = this.theme.heading(this.theme.bold(headingText));
} else { } 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 lines.push(""); // Add spacing after headings
break; break;
} }
@ -262,13 +225,13 @@ export class Markdown implements Component {
} }
case "code": { case "code": {
lines.push(colorChalk.gray("```" + (token.lang || ""))); lines.push(this.theme.codeBlockBorder("```" + (token.lang || "")));
// Split code by newlines and style each line // Split code by newlines and style each line
const codeLines = token.text.split("\n"); const codeLines = token.text.split("\n");
for (const codeLine of codeLines) { 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 lines.push(""); // Add spacing after code blocks
break; break;
} }
@ -291,14 +254,14 @@ export class Markdown implements Component {
const quoteText = this.renderInlineTokens(token.tokens || []); const quoteText = this.renderInlineTokens(token.tokens || []);
const quoteLines = quoteText.split("\n"); const quoteLines = quoteText.split("\n");
for (const quoteLine of quoteLines) { 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 lines.push(""); // Add spacing after blockquotes
break; break;
} }
case "hr": 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 lines.push(""); // Add spacing after horizontal rules
break; break;
@ -339,31 +302,31 @@ export class Markdown implements Component {
case "strong": { case "strong": {
// Apply bold, then reapply default style after // Apply bold, then reapply default style after
const boldContent = this.renderInlineTokens(token.tokens || []); const boldContent = this.renderInlineTokens(token.tokens || []);
result += colorChalk.bold(boldContent) + this.applyDefaultStyle(""); result += this.theme.bold(boldContent) + this.applyDefaultStyle("");
break; break;
} }
case "em": { case "em": {
// Apply italic, then reapply default style after // Apply italic, then reapply default style after
const italicContent = this.renderInlineTokens(token.tokens || []); const italicContent = this.renderInlineTokens(token.tokens || []);
result += colorChalk.italic(italicContent) + this.applyDefaultStyle(""); result += this.theme.italic(italicContent) + this.applyDefaultStyle("");
break; break;
} }
case "codespan": case "codespan":
// Apply code styling without backticks // Apply code styling without backticks
result += colorChalk.cyan(token.text) + this.applyDefaultStyle(""); result += this.theme.code(token.text) + this.applyDefaultStyle("");
break; break;
case "link": { case "link": {
const linkText = this.renderInlineTokens(token.tokens || []); const linkText = this.renderInlineTokens(token.tokens || []);
// If link text matches href, only show the link once // If link text matches href, only show the link once
if (linkText === token.href) { if (linkText === token.href) {
result += colorChalk.underline.blue(linkText) + this.applyDefaultStyle(""); result += this.theme.link(this.theme.underline(linkText)) + this.applyDefaultStyle("");
} else { } else {
result += result +=
colorChalk.underline.blue(linkText) + this.theme.link(this.theme.underline(linkText)) +
colorChalk.gray(` (${token.href})`) + this.theme.linkUrl(` (${token.href})`) +
this.applyDefaultStyle(""); this.applyDefaultStyle("");
} }
break; break;
@ -375,7 +338,7 @@ export class Markdown implements Component {
case "del": { case "del": {
const delContent = this.renderInlineTokens(token.tokens || []); const delContent = this.renderInlineTokens(token.tokens || []);
result += colorChalk.strikethrough(delContent) + this.applyDefaultStyle(""); result += this.theme.strikethrough(delContent) + this.applyDefaultStyle("");
break; break;
} }
@ -415,7 +378,7 @@ export class Markdown implements Component {
lines.push(firstLine); lines.push(firstLine);
} else { } else {
// Regular text content - add indent and bullet // 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 // Rest of the lines
@ -432,7 +395,7 @@ export class Markdown implements Component {
} }
} }
} else { } 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); lines.push(text);
} else if (token.type === "code") { } else if (token.type === "code") {
// Code block in list item // Code block in list item
lines.push(colorChalk.gray("```" + (token.lang || ""))); lines.push(this.theme.codeBlockBorder("```" + (token.lang || "")));
const codeLines = token.text.split("\n"); const codeLines = token.text.split("\n");
for (const codeLine of codeLines) { 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 { } else {
// Other token types - try to render as inline // Other token types - try to render as inline
const text = this.renderInlineTokens([token]); const text = this.renderInlineTokens([token]);
@ -515,7 +478,7 @@ export class Markdown implements Component {
// Render header // Render header
const headerCells = token.header.map((cell, i) => { const headerCells = token.header.map((cell, i) => {
const text = this.renderInlineTokens(cell.tokens || []); 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(" │ ") + " │"); lines.push("│ " + headerCells.join(" │ ") + " │");

View file

@ -1,4 +1,3 @@
import chalk from "chalk";
import type { Component } from "../tui.js"; import type { Component } from "../tui.js";
export interface SelectItem { export interface SelectItem {
@ -7,19 +6,30 @@ export interface SelectItem {
description?: string; 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 { export class SelectList implements Component {
private items: SelectItem[] = []; private items: SelectItem[] = [];
private filteredItems: SelectItem[] = []; private filteredItems: SelectItem[] = [];
private selectedIndex: number = 0; private selectedIndex: number = 0;
private maxVisible: number = 5; private maxVisible: number = 5;
private theme: SelectListTheme;
public onSelect?: (item: SelectItem) => void; public onSelect?: (item: SelectItem) => void;
public onCancel?: () => 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.items = items;
this.filteredItems = items; this.filteredItems = items;
this.maxVisible = maxVisible; this.maxVisible = maxVisible;
this.theme = theme;
} }
setFilter(filter: string): void { 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)); this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
} }
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] { render(width: number): string[] {
const lines: string[] = []; const lines: string[] = [];
// If no items match filter, show message // If no items match filter, show message
if (this.filteredItems.length === 0) { if (this.filteredItems.length === 0) {
lines.push(chalk.gray(" No matching commands")); lines.push(this.theme.noMatch(" No matching commands"));
return lines; return lines;
} }
@ -58,7 +72,7 @@ export class SelectList implements Component {
let line = ""; let line = "";
if (isSelected) { if (isSelected) {
// Use arrow indicator for selection // Use arrow indicator for selection
const prefix = chalk.blue("→ "); const prefix = this.theme.selectedPrefix("→ ");
const prefixWidth = 2; // "→ " is 2 characters visually const prefixWidth = 2; // "→ " is 2 characters visually
const displayValue = item.label || item.value; const displayValue = item.label || item.value;
@ -74,16 +88,20 @@ export class SelectList implements Component {
if (remainingWidth > 10) { if (remainingWidth > 10) {
const truncatedDesc = item.description.substring(0, remainingWidth); 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 { } else {
// Not enough space for description // Not enough space for description
const maxWidth = width - prefixWidth - 2; 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 { } else {
// No description or not enough width // No description or not enough width
const maxWidth = width - prefixWidth - 2; 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 { } else {
const displayValue = item.label || item.value; const displayValue = item.label || item.value;
@ -101,7 +119,8 @@ export class SelectList implements Component {
if (remainingWidth > 10) { if (remainingWidth > 10) {
const truncatedDesc = item.description.substring(0, remainingWidth); 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 { } else {
// Not enough space for description // Not enough space for description
const maxWidth = width - prefix.length - 2; const maxWidth = width - prefix.length - 2;
@ -123,8 +142,7 @@ export class SelectList implements Component {
// Truncate if too long for terminal // Truncate if too long for terminal
const maxWidth = width - 2; const maxWidth = width - 2;
const truncated = scrollText.substring(0, maxWidth); const truncated = scrollText.substring(0, maxWidth);
const scrollInfo = chalk.gray(truncated); lines.push(this.theme.scrollInfo(truncated));
lines.push(scrollInfo);
} }
return lines; return lines;
@ -134,10 +152,12 @@ export class SelectList implements Component {
// Up arrow // Up arrow
if (keyData === "\x1b[A") { if (keyData === "\x1b[A") {
this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.notifySelectionChange();
} }
// Down arrow // Down arrow
else if (keyData === "\x1b[B") { else if (keyData === "\x1b[B") {
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1); this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
this.notifySelectionChange();
} }
// Enter // Enter
else if (keyData === "\r") { 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 { getSelectedItem(): SelectItem | null {
const item = this.filteredItems[this.selectedIndex]; const item = this.filteredItems[this.selectedIndex];
return item || null; return item || null;

View file

@ -14,6 +14,10 @@ export class Spacer implements Component {
this.lines = lines; this.lines = lines;
} }
invalidate(): void {
// No cached state to invalidate currently
}
render(_width: number): string[] { render(_width: number): string[] {
const result: string[] = []; const result: string[] = [];
for (let i = 0; i < this.lines; i++) { 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 type { Component } from "../tui.js";
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js"; import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";
const colorChalk = new Chalk({ level: 3 });
/** /**
* Text component - displays multi-line text with word wrapping * Text component - displays multi-line text with word wrapping
*/ */
@ -11,23 +8,18 @@ export class Text implements Component {
private text: string; private text: string;
private paddingX: number; // Left/right padding private paddingX: number; // Left/right padding
private paddingY: number; // Top/bottom padding private paddingY: number; // Top/bottom padding
private customBgRgb?: { r: number; g: number; b: number }; private customBgFn?: (text: string) => string;
// Cache for rendered output // Cache for rendered output
private cachedText?: string; private cachedText?: string;
private cachedWidth?: number; private cachedWidth?: number;
private cachedLines?: string[]; private cachedLines?: string[];
constructor( constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) {
text: string = "",
paddingX: number = 1,
paddingY: number = 1,
customBgRgb?: { r: number; g: number; b: number },
) {
this.text = text; this.text = text;
this.paddingX = paddingX; this.paddingX = paddingX;
this.paddingY = paddingY; this.paddingY = paddingY;
this.customBgRgb = customBgRgb; this.customBgFn = customBgFn;
} }
setText(text: string): void { setText(text: string): void {
@ -37,8 +29,14 @@ export class Text implements Component {
this.cachedLines = undefined; this.cachedLines = undefined;
} }
setCustomBgRgb(customBgRgb?: { r: number; g: number; b: number }): void { setCustomBgFn(customBgFn?: (text: string) => string): void {
this.customBgRgb = customBgRgb; this.customBgFn = customBgFn;
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
invalidate(): void {
this.cachedText = undefined; this.cachedText = undefined;
this.cachedWidth = undefined; this.cachedWidth = undefined;
this.cachedLines = undefined; this.cachedLines = undefined;
@ -78,8 +76,8 @@ export class Text implements Component {
const lineWithMargins = leftMargin + line + rightMargin; const lineWithMargins = leftMargin + line + rightMargin;
// Apply background if specified (this also pads to full width) // Apply background if specified (this also pads to full width)
if (this.customBgRgb) { if (this.customBgFn) {
contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.customBgRgb)); contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.customBgFn));
} else { } else {
// No background - just pad to width with spaces // No background - just pad to width with spaces
const visibleLen = visibleWidth(lineWithMargins); const visibleLen = visibleWidth(lineWithMargins);
@ -92,7 +90,7 @@ export class Text implements Component {
const emptyLine = " ".repeat(width); const emptyLine = " ".repeat(width);
const emptyLines: string[] = []; const emptyLines: string[] = [];
for (let i = 0; i < this.paddingY; i++) { 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); emptyLines.push(line);
} }

View file

@ -15,20 +15,34 @@ export class TruncatedText implements Component {
this.paddingY = paddingY; this.paddingY = paddingY;
} }
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] { render(width: number): string[] {
const result: string[] = []; const result: string[] = [];
// Empty line padded to width
const emptyLine = " ".repeat(width);
// Add vertical padding above // Add vertical padding above
for (let i = 0; i < this.paddingY; i++) { for (let i = 0; i < this.paddingY; i++) {
result.push(""); result.push(emptyLine);
} }
// Calculate available width after horizontal padding // Calculate available width after horizontal padding
const availableWidth = Math.max(1, width - this.paddingX * 2); 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) // Truncate text if needed (accounting for ANSI codes)
let displayText = this.text; let displayText = singleLineText;
const textVisibleWidth = visibleWidth(this.text); const textVisibleWidth = visibleWidth(singleLineText);
if (textVisibleWidth > availableWidth) { if (textVisibleWidth > availableWidth) {
// Need to truncate - walk through the string character by character // Need to truncate - walk through the string character by character
@ -38,18 +52,21 @@ export class TruncatedText implements Component {
const ellipsisWidth = 3; const ellipsisWidth = 3;
const targetWidth = availableWidth - ellipsisWidth; const targetWidth = availableWidth - ellipsisWidth;
while (i < this.text.length && currentWidth < targetWidth) { while (i < singleLineText.length && currentWidth < targetWidth) {
// Skip ANSI escape sequences // Skip ANSI escape sequences (include them in output but don't count width)
if (this.text[i] === "\x1b" && this.text[i + 1] === "[") { if (singleLineText[i] === "\x1b" && singleLineText[i + 1] === "[") {
let j = i + 2; 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++; j++;
} }
i = j + 1; // Include the final letter of the escape sequence
j++;
truncateAt = j;
i = j;
continue; continue;
} }
const char = this.text[i]; const char = singleLineText[i];
const charWidth = visibleWidth(char); const charWidth = visibleWidth(char);
if (currentWidth + charWidth > targetWidth) { if (currentWidth + charWidth > targetWidth) {
@ -61,16 +78,25 @@ export class TruncatedText implements Component {
i++; 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 // Add horizontal padding
const paddingStr = " ".repeat(this.paddingX); const leftPadding = " ".repeat(this.paddingX);
result.push(paddingStr + displayText); 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 // Add vertical padding below
for (let i = 0; i < this.paddingY; i++) { for (let i = 0; i < this.paddingY; i++) {
result.push(""); result.push(emptyLine);
} }
return result; return result;

View file

@ -8,11 +8,11 @@ export {
type SlashCommand, type SlashCommand,
} from "./autocomplete.js"; } from "./autocomplete.js";
// Components // Components
export { Editor, type TextEditorConfig } from "./components/editor.js"; export { Editor, type EditorTheme } from "./components/editor.js";
export { Input } from "./components/input.js"; export { Input } from "./components/input.js";
export { Loader } from "./components/loader.js"; export { Loader } from "./components/loader.js";
export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js"; export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js";
export { type SelectItem, SelectList } from "./components/select-list.js"; export { type SelectItem, SelectList, type SelectListTheme } from "./components/select-list.js";
export { Spacer } from "./components/spacer.js"; export { Spacer } from "./components/spacer.js";
export { Text } from "./components/text.js"; export { Text } from "./components/text.js";
export { TruncatedText } from "./components/truncated-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 * Optional handler for keyboard input when component has focus
*/ */
handleInput?(data: string): void; 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 }; export { visibleWidth };
@ -45,6 +51,12 @@ export class Container implements Component {
this.children = []; this.children = [];
} }
invalidate(): void {
for (const child of this.children) {
child.invalidate?.();
}
}
render(width: number): string[] { render(width: number): string[] {
const lines: string[] = []; const lines: string[] = [];
for (const child of this.children) { for (const child of this.children) {

View file

@ -1,8 +1,5 @@
import { Chalk } from "chalk";
import stringWidth from "string-width"; import stringWidth from "string-width";
const colorChalk = new Chalk({ level: 3 });
/** /**
* Calculate the visible width of a string in terminal columns. * 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. * 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 line - Line of text (may contain ANSI codes)
* @param width - Total width to pad to * @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 * @returns Line with background applied and padded to width
*/ */
export function applyBackgroundToLine(line: string, width: number, bgRgb: { r: number; g: number; b: number }): string { export function applyBackgroundToLine(line: string, width: number, bgFn: (text: string) => string): string {
const bgStart = `\x1b[48;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
const bgEnd = "\x1b[49m";
// Calculate padding needed // Calculate padding needed
const visibleLen = visibleWidth(line); const visibleLen = visibleWidth(line);
const paddingNeeded = Math.max(0, width - visibleLen); const paddingNeeded = Math.max(0, width - visibleLen);
const padding = " ".repeat(paddingNeeded); 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 withPadding = line + padding;
const withBg = bgStart + withPadding + bgEnd; return bgFn(withPadding);
// 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;
} }

View file

@ -2,6 +2,7 @@
* Simple chat interface demo using tui.ts * Simple chat interface demo using tui.ts
*/ */
import chalk from "chalk";
import { CombinedAutocompleteProvider } from "../src/autocomplete.js"; import { CombinedAutocompleteProvider } from "../src/autocomplete.js";
import { Editor } from "../src/components/editor.js"; import { Editor } from "../src/components/editor.js";
import { Loader } from "../src/components/loader.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 { Text } from "../src/components/text.js";
import { ProcessTerminal } from "../src/terminal.js"; import { ProcessTerminal } from "../src/terminal.js";
import { TUI } from "../src/tui.js"; import { TUI } from "../src/tui.js";
import { defaultEditorTheme, defaultMarkdownTheme } from "./test-themes.js";
// Create terminal // Create terminal
const terminal = new ProcessTerminal(); const terminal = new ProcessTerminal();
@ -22,7 +24,7 @@ tui.addChild(
); );
// Create editor with autocomplete // Create editor with autocomplete
const editor = new Editor(); const editor = new Editor(defaultEditorTheme);
// Set up autocomplete provider with slash commands and file completion // Set up autocomplete provider with slash commands and file completion
const autocompleteProvider = new CombinedAutocompleteProvider( const autocompleteProvider = new CombinedAutocompleteProvider(
@ -78,12 +80,17 @@ editor.onSubmit = (value: string) => {
isResponding = true; isResponding = true;
editor.disableSubmit = 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; const children = tui.children;
children.splice(children.length - 1, 0, userMessage); 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); children.splice(children.length - 1, 0, loader);
tui.requestRender(); tui.requestRender();
@ -105,7 +112,7 @@ editor.onSubmit = (value: string) => {
const randomResponse = responses[Math.floor(Math.random() * responses.length)]; const randomResponse = responses[Math.floor(Math.random() * responses.length)];
// Add assistant message with no background (transparent) // 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); children.splice(children.length - 1, 0, botMessage);
// Re-enable submit // Re-enable submit

View file

@ -1,11 +1,12 @@
import assert from "node:assert"; import assert from "node:assert";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { Editor } from "../src/components/editor.js"; import { Editor } from "../src/components/editor.js";
import { defaultEditorTheme } from "./test-themes.js";
describe("Editor component", () => { describe("Editor component", () => {
describe("Unicode text editing behavior", () => { describe("Unicode text editing behavior", () => {
it("inserts mixed ASCII, umlauts, and emojis as literal text", () => { it("inserts mixed ASCII, umlauts, and emojis as literal text", () => {
const editor = new Editor(); const editor = new Editor(defaultEditorTheme);
editor.handleInput("H"); editor.handleInput("H");
editor.handleInput("e"); editor.handleInput("e");
@ -24,7 +25,7 @@ describe("Editor component", () => {
}); });
it("deletes single-code-unit unicode characters (umlauts) with Backspace", () => { it("deletes single-code-unit unicode characters (umlauts) with Backspace", () => {
const editor = new Editor(); const editor = new Editor(defaultEditorTheme);
editor.handleInput("ä"); editor.handleInput("ä");
editor.handleInput("ö"); editor.handleInput("ö");
@ -38,7 +39,7 @@ describe("Editor component", () => {
}); });
it("deletes multi-code-unit emojis with repeated Backspace", () => { it("deletes multi-code-unit emojis with repeated Backspace", () => {
const editor = new Editor(); const editor = new Editor(defaultEditorTheme);
editor.handleInput("😀"); editor.handleInput("😀");
editor.handleInput("👍"); editor.handleInput("👍");
@ -52,7 +53,7 @@ describe("Editor component", () => {
}); });
it("inserts characters at the correct position after cursor movement over umlauts", () => { 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("ä");
editor.handleInput("ö"); editor.handleInput("ö");
@ -70,7 +71,7 @@ describe("Editor component", () => {
}); });
it("moves cursor in code units across multi-code-unit emojis before insertion", () => { 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("😀");
editor.handleInput("👍"); editor.handleInput("👍");
@ -92,7 +93,7 @@ describe("Editor component", () => {
}); });
it("preserves umlauts across line breaks", () => { it("preserves umlauts across line breaks", () => {
const editor = new Editor(); const editor = new Editor(defaultEditorTheme);
editor.handleInput("ä"); editor.handleInput("ä");
editor.handleInput("ö"); editor.handleInput("ö");
@ -107,7 +108,7 @@ describe("Editor component", () => {
}); });
it("replaces the entire document with unicode text via setText (paste simulation)", () => { 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 // Simulate bracketed paste / programmatic replacement
editor.setText("Hällö Wörld! 😀 äöüÄÖÜß"); 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", () => { 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("a");
editor.handleInput("b"); editor.handleInput("b");

View file

@ -40,6 +40,10 @@ class KeyLogger implements Component {
this.tui.requestRender(); this.tui.requestRender();
} }
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] { render(width: number): string[] {
const lines: string[] = []; const lines: string[] = [];

View file

@ -1,6 +1,8 @@
import assert from "node:assert"; import assert from "node:assert";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import chalk from "chalk";
import { Markdown } from "../src/components/markdown.js"; import { Markdown } from "../src/components/markdown.js";
import { defaultMarkdownTheme } from "./test-themes.js";
describe("Markdown component", () => { describe("Markdown component", () => {
describe("Nested lists", () => { describe("Nested lists", () => {
@ -12,6 +14,7 @@ describe("Markdown component", () => {
- Item 2`, - Item 2`,
0, 0,
0, 0,
defaultMarkdownTheme,
); );
const lines = markdown.render(80); const lines = markdown.render(80);
@ -37,6 +40,7 @@ describe("Markdown component", () => {
- Level 4`, - Level 4`,
0, 0,
0, 0,
defaultMarkdownTheme,
); );
const lines = markdown.render(80); const lines = markdown.render(80);
@ -57,6 +61,7 @@ describe("Markdown component", () => {
2. Second`, 2. Second`,
0, 0,
0, 0,
defaultMarkdownTheme,
); );
const lines = markdown.render(80); const lines = markdown.render(80);
@ -77,6 +82,7 @@ describe("Markdown component", () => {
- More nested`, - More nested`,
0, 0,
0, 0,
defaultMarkdownTheme,
); );
const lines = markdown.render(80); const lines = markdown.render(80);
@ -97,6 +103,7 @@ describe("Markdown component", () => {
| Bob | 25 |`, | Bob | 25 |`,
0, 0,
0, 0,
defaultMarkdownTheme,
); );
const lines = markdown.render(80); const lines = markdown.render(80);
@ -120,6 +127,7 @@ describe("Markdown component", () => {
| Long text | Middle | End |`, | Long text | Middle | End |`,
0, 0,
0, 0,
defaultMarkdownTheme,
); );
const lines = markdown.render(80); const lines = markdown.render(80);
@ -141,6 +149,7 @@ describe("Markdown component", () => {
| B | Short |`, | B | Short |`,
0, 0,
0, 0,
defaultMarkdownTheme,
); );
const lines = markdown.render(80); const lines = markdown.render(80);
@ -168,6 +177,7 @@ describe("Markdown component", () => {
| A | B |`, | A | B |`,
0, 0,
0, 0,
defaultMarkdownTheme,
); );
const lines = markdown.render(80); const lines = markdown.render(80);
@ -187,10 +197,16 @@ describe("Markdown component", () => {
describe("Pre-styled text (thinking traces)", () => { describe("Pre-styled text (thinking traces)", () => {
it("should preserve gray italic styling after inline code", () => { it("should preserve gray italic styling after inline code", () => {
// This replicates how thinking content is rendered in assistant-message.ts // 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, { const markdown = new Markdown(
color: "gray", "This is thinking with `inline code` and more text after",
italic: true, 1,
}); 0,
defaultMarkdownTheme,
{
color: (text) => chalk.gray(text),
italic: true,
},
);
const lines = markdown.render(80); const lines = markdown.render(80);
const joinedOutput = lines.join("\n"); const joinedOutput = lines.join("\n");
@ -208,10 +224,16 @@ describe("Markdown component", () => {
}); });
it("should preserve gray italic styling after bold text", () => { it("should preserve gray italic styling after bold text", () => {
const markdown = new Markdown("This is thinking with **bold text** and more after", 1, 0, { const markdown = new Markdown(
color: "gray", "This is thinking with **bold text** and more after",
italic: true, 1,
}); 0,
defaultMarkdownTheme,
{
color: (text) => chalk.gray(text),
italic: true,
},
);
const lines = markdown.render(80); const lines = markdown.render(80);
const joinedOutput = lines.join("\n"); const joinedOutput = lines.join("\n");
@ -236,6 +258,7 @@ describe("Markdown component", () => {
"This is text with <thinking>hidden content</thinking> that should be visible", "This is text with <thinking>hidden content</thinking> that should be visible",
0, 0,
0, 0,
defaultMarkdownTheme,
); );
const lines = markdown.render(80); const lines = markdown.render(80);
@ -250,7 +273,7 @@ describe("Markdown component", () => {
}); });
it("should render HTML tags in code blocks correctly", () => { 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 lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); 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", () => { describe("applyBackgroundToLine", () => {
const greenBg = (text: string) => chalk.bgGreen(text);
it("applies background to plain text and pads to width", () => { it("applies background to plain text and pads to width", () => {
const line = "hello"; 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 // Should be exactly 20 visible chars
const stripped = result.replace(/\x1b\[[0-9;]*m/g, ""); const stripped = result.replace(/\x1b\[[0-9;]*m/g, "");
assert.strictEqual(stripped.length, 20); assert.strictEqual(stripped.length, 20);
// Should have background codes // 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")); assert.ok(result.includes("\x1b[49m"));
}); });
it("handles text with ANSI codes and resets", () => { it("handles text with ANSI codes and resets", () => {
const line = chalk.bold("hello") + " world"; 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 // Should be exactly 20 visible chars
const stripped = result.replace(/\x1b\[[0-9;]*m/g, ""); const stripped = result.replace(/\x1b\[[0-9;]*m/g, "");
@ -90,13 +92,13 @@ describe("applyBackgroundToLine", () => {
assert.ok(result.includes("\x1b[1m")); assert.ok(result.includes("\x1b[1m"));
// Should have background throughout (even after resets) // 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", () => { it("handles text with 0m resets by reapplying background", () => {
// Simulate: bold text + reset + normal text // Simulate: bold text + reset + normal text
const line = "\x1b[1mhello\x1b[0m world"; 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) // Should NOT have black cells (spaces without background)
// Pattern we DON'T want: 49m or 0m followed by spaces before bg reapplied // 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", "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", "description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@ -18,8 +18,8 @@
}, },
"dependencies": { "dependencies": {
"@lmstudio/sdk": "^1.5.0", "@lmstudio/sdk": "^1.5.0",
"@mariozechner/pi-ai": "^0.7.29", "@mariozechner/pi-ai": "^0.8.0",
"@mariozechner/pi-tui": "^0.7.29", "@mariozechner/pi-tui": "^0.8.0",
"docx-preview": "^0.3.7", "docx-preview": "^0.3.7",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide": "^0.544.0", "lucide": "^0.544.0",