diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b73cb..8ae6866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [Unreleased] + +- **Agent Experience** + - Bkper CLI Agent has a new look: the startup screen shows the Bkper logo in its account type colors, and new Bkper light and dark themes replace Pi's default colors, following your terminal's light or dark background. Custom themes you selected are kept + - The startup screen no longer breaks in narrow terminals + - Updated Pi to 0.99.2 + ## [5.2.0] - 2026-10-01 - **Agent Experience** diff --git a/assets/bkper-agent-cli.png b/assets/bkper-agent-cli.png index 2692e68..33df033 100644 Binary files a/assets/bkper-agent-cli.png and b/assets/bkper-agent-cli.png differ diff --git a/bun.lock b/bun.lock index 8f6c0ba..bae1d3b 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,8 @@ "": { "name": "bkper", "dependencies": { - "@earendil-works/pi-coding-agent": "0.87.1", - "@earendil-works/pi-tui": "0.87.1", + "@earendil-works/pi-coding-agent": "0.99.2", + "@earendil-works/pi-tui": "0.99.2", "bkper-js": "^2.42.0", "commander": "^13.1.0", "dotenv": "^8.2.0", @@ -114,17 +114,21 @@ "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], - "@earendil-works/chord": ["@earendil-works/chord@0.87.1", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-bg7IkJGFcEaMqqYgOGUiq5Ky9RghpRfrlZ8I/v/1b4bBZ02A7t3E+6uhPRbadwWb/kWsnVFbZsqOKRN4a3LLCg=="], + "@earendil-works/chord": ["@earendil-works/chord@0.99.2", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-2dJWiNsOJm/ioBl1fhpJmw0hIjFUhM0CAteEnVkiVG0Kg4LimR7Asq8PfjclEvrbCPsDntzy7rGKhTZObI9TZw=="], - "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.87.1", "", { "dependencies": { "@earendil-works/chord": "^0.87.1", "@earendil-works/pi-ai": "^0.87.1", "@earendil-works/pi-telemetry": "^0.87.1", "diff": "8.0.4", "ignore": "7.0.8", "typebox": "1.3.27", "yaml": "2.9.0" } }, "sha512-Zev3B0HK7YS5A4EZQ2XnEqiJuirx6QBiltJ+LpmjV5a/+2IU0cfKtIfnkNkORK707XOvKBY2WRtk7cAwHpbh2Q=="], + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.99.2", "", { "dependencies": { "@earendil-works/chord": "^0.99.2", "@earendil-works/pi-ai": "^0.99.2", "@earendil-works/pi-telemetry": "^0.99.2", "diff": "8.0.4", "ignore": "7.0.8", "typebox": "1.3.27", "yaml": "2.9.0" } }, "sha512-VX0QMcg8HKBsv1bYP2NwmutBYa44OrwmtqR6Dq7QC3Ln8X/IlpHhUxB5L1JgnXNUUkTm8Wg/HZhzS797NBDMCQ=="], - "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.87.1", "", { "dependencies": { "@anthropic-ai/sdk": "0.124.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "^0.87.1", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-X/3PfQBnnoeVdO9Cv8zHghUMglzlgNZYGNzoPnbRoGnHl3Rw3TlA2UKSUB7BRHUOxMryHXYa8dnjWZlbRheDZA=="], + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.99.2", "", { "dependencies": { "@anthropic-ai/sdk": "0.124.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "^0.99.2", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-9RFOEdY+ZTJ1AI+UuAFs4RM0tF2Tje/R/CEv9gWTJHt0iTu8XHZs64U/EIZxNSllFmec/4HfwsOxYj1qQek9bg=="], - "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.87.1", "", { "dependencies": { "@earendil-works/chord": "^0.87.1", "@earendil-works/pi-agent-core": "^0.87.1", "@earendil-works/pi-ai": "^0.87.1", "@earendil-works/pi-tui": "^0.87.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "6.0.0", "cross-spawn": "7.0.6", "diff": "8.0.4", "grok-mermaid": "0.2.3", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.8", "jiti": "2.7.0", "minimatch": "10.2.6", "proper-lockfile": "4.1.2", "semver": "7.8.5", "typebox": "1.3.27", "undici": "8.10.2", "yaml": "2.9.0" }, "bin": { "pi": "dist/bundle/cli.js" } }, "sha512-m8ArJUtVcQMSe1lLE/Ei7vX/JV7O39sWmWBsXV2NOU70F0qCp8GubA24pT3LnwTmM6LL2xV80/h6sQg85n69ew=="], + "@earendil-works/pi-codemode": ["@earendil-works/pi-codemode@0.99.2", "", { "dependencies": { "quickjs-wasi": "3.6.2" } }, "sha512-PVxNGu4l7LI92MvwUbaY3trjznU1Mlp2OMGwHwCWzLsjG7/lDa8xf6ei+Gws7VCt+Ri1tO+nFVl9KV2EeX7HWg=="], - "@earendil-works/pi-telemetry": ["@earendil-works/pi-telemetry@0.87.1", "", {}, "sha512-MC6TRQH5lgMXpcN+Vku2WMI2T8BsiUPzMQHGo81uqFZD3/9O79WWJAysEDGuzduP6R4tvtgwMLwmqIxynM10JQ=="], + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.99.2", "", { "dependencies": { "@earendil-works/chord": "^0.99.2", "@earendil-works/pi-agent-core": "^0.99.2", "@earendil-works/pi-ai": "^0.99.2", "@earendil-works/pi-codemode": "^0.99.2", "@earendil-works/pi-mcp": "^0.99.2", "@earendil-works/pi-tui": "^0.99.2", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "6.0.0", "cross-spawn": "7.0.6", "diff": "8.0.4", "grok-mermaid": "0.2.3", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.8", "jiti": "2.7.0", "minimatch": "10.2.6", "proper-lockfile": "4.1.2", "quickjs-wasi": "3.6.2", "semver": "7.8.5", "typebox": "1.3.27", "undici": "8.10.2", "yaml": "2.9.0" }, "bin": { "pi": "dist/bundle/cli.js" } }, "sha512-6R1BZ2N77CrVcGf3eC2KovTz1Q4RYiAeydvVWQT546N2fi1nBc81aURlbOZCgruWoW9VY/UrLzDynF4YTolpoA=="], - "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.87.1", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.11" } }, "sha512-YEH2vRyOeiO7hhN6j6AE6YwKSq2Kz2f3XR8bj1TbR+aGE/JsnY1hLPMI2pvaZfRM1n9Y00tejxFQ4zbzvF7nkQ=="], + "@earendil-works/pi-mcp": ["@earendil-works/pi-mcp@0.99.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-ISk6OIsiUzmULF+sUilLnENGKo9/gVDxZG2CTEq8f4dbp70AXOr/hMiwOjtcrlX6xBd4UMUsIz4EPTOlU6U+HA=="], + + "@earendil-works/pi-telemetry": ["@earendil-works/pi-telemetry@0.99.2", "", {}, "sha512-WThYU4XM6jjjzH4FzLdreN7QAPS9SDdokL0W0Ldheg1ssCIJkfpK7PgebFm7/PoQub7OiXx+SRF6B/oF7z7lXA=="], + + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.99.2", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.11" } }, "sha512-IOcNnd390NCIwBEKeOiBK/bvaR7Z+vEsvB5TtKsFdsY/vAZE6qRHbQqt6jpTefTiKPfQlwG8eS1BU42DeG2v4w=="], "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], @@ -908,7 +912,7 @@ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - "openai": ["openai@6.40.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"] }, "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA=="], + "openai": ["openai@7.19.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "undici": ">=5 <9", "ws": "^8.21.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "undici", "ws", "zod"] }, "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ=="], "openapi-fetch": ["openapi-fetch@0.15.2", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-rdYTzUmSsJevmNqg7fwUVGuKc2Gfb9h6ph74EVPkPfIGJaZTfqdIbJahtbJ3qg1LKinln30hqZniLnKpH0RJBg=="], @@ -980,6 +984,8 @@ "quick-lru": ["quick-lru@4.0.1", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="], + "quickjs-wasi": ["quickjs-wasi@3.6.2", "", {}, "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg=="], + "read-pkg": ["read-pkg@3.0.0", "", { "dependencies": { "load-json-file": "^4.0.0", "normalize-package-data": "^2.3.2", "path-type": "^3.0.0" } }, "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA=="], "read-pkg-up": ["read-pkg-up@7.0.1", "", { "dependencies": { "find-up": "^4.1.0", "read-pkg": "^5.2.0", "type-fest": "^0.8.1" } }, "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg=="], @@ -1276,6 +1282,8 @@ "@earendil-works/pi-coding-agent/undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="], + "@earendil-works/pi-mcp/cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "@eslint/eslintrc/ignore": ["ignore@4.0.6", "", {}, "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg=="], "@eslint/eslintrc/js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="], @@ -1422,6 +1430,12 @@ "@earendil-works/pi-coding-agent/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "@earendil-works/pi-mcp/cross-spawn/path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "@earendil-works/pi-mcp/cross-spawn/shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "@earendil-works/pi-mcp/cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "@eslint/eslintrc/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], @@ -1490,6 +1504,8 @@ "@earendil-works/pi-coding-agent/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@earendil-works/pi-mcp/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "eslint/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], diff --git a/package.json b/package.json index 0b39a4c..9238c10 100644 --- a/package.json +++ b/package.json @@ -37,10 +37,11 @@ "clean": "rm -rf ./lib & rm -rf ./node_modules & wait", "sync:docs": "bun ./scripts/sync-docs.ts", "prebuild": "bun install && bun sync:docs", - "build": "run-s build:clean build:compile build:copy-docs", + "build": "run-s build:clean build:compile build:copy-docs build:copy-themes", "build:clean": "gts clean && rimraf temp", "build:compile": "tsc", "build:copy-docs": "mkdir -p lib && rm -rf lib/docs && cp -R skill/references lib/docs", + "build:copy-themes": "mkdir -p lib/agent && rm -rf lib/agent/themes && cp -R src/agent/themes lib/agent/themes", "skill:package": "bun ./scripts/generate-skill-distribution.ts", "api:platform": "openapi-typescript https://platform.bkper.app/openapi.json -o src/platform/types.d.ts", "api:platform:local": "openapi-typescript http://localhost:8790/openapi.json -o src/platform/types.d.ts", @@ -62,8 +63,8 @@ "upgrade:api": "bun update @bkper/bkper-api-types --latest && bun update bkper-js --latest" }, "dependencies": { - "@earendil-works/pi-coding-agent": "0.87.1", - "@earendil-works/pi-tui": "0.87.1", + "@earendil-works/pi-coding-agent": "0.99.2", + "@earendil-works/pi-tui": "0.99.2", "bkper-js": "^2.42.0", "commander": "^13.1.0", "dotenv": "^8.2.0", diff --git a/src/agent/extensions/bkper-ai-provider.ts b/src/agent/extensions/bkper-ai-provider.ts index c5d97fe..804504b 100644 --- a/src/agent/extensions/bkper-ai-provider.ts +++ b/src/agent/extensions/bkper-ai-provider.ts @@ -19,7 +19,10 @@ export interface BkperAiModelMetadata { bkperDefaultThinkingLevel?: BkperAiThinkingLevel; } -interface BkperAiModelConfig extends ProviderModelConfig { +/** The chat member of Pi's provider model union; the package root exports only the union. */ +type ProviderChatModelConfig = Extract; + +interface BkperAiModelConfig extends ProviderChatModelConfig { bkperDefault: boolean; bkperDefaultThinkingLevel?: BkperAiThinkingLevel; } @@ -99,7 +102,7 @@ function getDefaultThinkingLevel(model: BkperAiCatalogModel): BkperAiThinkingLev function getThinkingLevelMap( levels: string[] -): NonNullable { +): NonNullable { const supports = (level: string): string | null => (levels.includes(level) ? level : null); return { off: levels.includes('none') ? 'none' : null, diff --git a/src/agent/extensions/builtins.ts b/src/agent/extensions/builtins.ts index e76c442..f50a5dc 100644 --- a/src/agent/extensions/builtins.ts +++ b/src/agent/extensions/builtins.ts @@ -55,6 +55,8 @@ function normalizeBkperAgentExtension(extension: Extension): Extension { return { ...extension, + // Built-ins are part of the agent, not user extensions to list at startup. + hidden: true, path: BKPER_AGENT_BUILTINS_EXTENSION_PATH, resolvedPath: BKPER_AGENT_BUILTINS_EXTENSION_PATH, sourceInfo: { diff --git a/src/agent/extensions/startup-logo.ts b/src/agent/extensions/startup-logo.ts new file mode 100644 index 0000000..76c2b00 --- /dev/null +++ b/src/agent/extensions/startup-logo.ts @@ -0,0 +1,111 @@ +import { + backgroundAnsi, + foregroundAnsi, + rgbColor, + type Color, + type TerminalColorMode, +} from '@earendil-works/pi-tui'; + +const RESET = '\x1b[0m'; + +/** Official logo colors; the four mark colors are the four Bkper account type colors. */ +const BKPER_LOGO_COLORS: Record = { + B: rgbColor(34, 133, 192), // #2285c0 Asset blue + D: rgbColor(30, 119, 173), // #1e77ad stem shade under the bar + R: rgbColor(209, 72, 54), // #d14836 Outgoing red + Y: rgbColor(247, 179, 0), // #f7b300 Liability yellow + G: rgbColor(58, 170, 87), // #3aaa57 Incoming green + W: rgbColor(153, 153, 153), // #999999 arrow and wordmark gray +}; + +/** + * The Bkper logo as square pixels, two pixel rows per terminal line. The mark is the official + * icon geometry sampled at 2 pixels per logo unit; the wordmark sits on the mark's baseline. + * '.' is transparent. + */ +const BKPER_LOGO_PIXELS = [ + '.......BB.............................................', + '......BBBB............................................', + '......BBBB............................................', + '......BBBB............................................', + '......BBBB............................................', + '......BBBB............................................', + '......BBBB............................................', + '......BBBB............................................', + '......BRRRRRRYYYY.....................................', + '......RRRRRRYYYYYY....................................', + '......RRRRRRYYYYYYY...................................', + '......BRRRRRRYYYYYYY..................................', + '......BBDD.....YYYYY..................................', + '......BBDD......YYYY..W......W........................', + '......BBBD......YYYY..W......W........................', + 'W.....BBBB.....YYYYY..W.WW...W..W..W.WW....WWW...W.WW.', + 'WW....BBBBGGGGGYYYYY..WW..W..W.W...WW..W..W...W..WW...', + 'WWW...BBBBGGGGGGYYY...W...W..WW....W...W..WWWWW..W....', + 'WW....BBBBGGGGGGYY....WW..W..W.W...WW..W..W......W....', + 'W......BBBGGGGGGY.....W.WW...W..W..W.WW....WWW...W....', + '...................................W..................', + '...................................W..................', +]; + +/** Width of the arrow and mark, without the wordmark. */ +const BKPER_MARK_WIDTH = 20; + +/** + * Renders a pixel grid with half blocks: each terminal line shows two pixel rows, the upper one + * as the foreground of '▀' and the lower one as its background. + */ +export function renderPixelArt( + pixels: readonly string[], + colors: Readonly>, + mode: TerminalColorMode +): string[] { + const lines: string[] = []; + for (let row = 0; row < pixels.length; row += 2) { + const upper = pixels[row]; + const lower = pixels[row + 1] ?? ''; + const width = Math.max(upper.length, lower.length); + let line = ''; + for (let column = 0; column < width; column++) { + const top = colors[upper[column]]; + const bottom = colors[lower[column]]; + if (top && bottom && top === bottom) { + line += `${foregroundAnsi(top, mode)}█${RESET}`; + } else if (top && bottom) { + line += `${foregroundAnsi(top, mode)}${backgroundAnsi(bottom, mode)}▀${RESET}`; + } else if (top) { + line += `${foregroundAnsi(top, mode)}▀${RESET}`; + } else if (bottom) { + line += `${foregroundAnsi(bottom, mode)}▄${RESET}`; + } else { + line += ' '; + } + } + lines.push(line); + } + return lines; +} + +function cropPixels(pixels: readonly string[], width: number): string[] { + const cropped = pixels.map(row => row.slice(0, width)); + while (cropped.length > 0 && /^\.*$/.test(cropped[cropped.length - 1])) { + cropped.pop(); + } + return cropped; +} + +/** The logo with wordmark when it fits, the mark alone when only that fits, otherwise nothing. */ +export function getBkperLogoLines(maxWidth: number, mode: TerminalColorMode): string[] { + const fullWidth = Math.max(...BKPER_LOGO_PIXELS.map(row => row.length)); + if (maxWidth >= fullWidth) { + return renderPixelArt(cropPixels(BKPER_LOGO_PIXELS, fullWidth), BKPER_LOGO_COLORS, mode); + } + if (maxWidth >= BKPER_MARK_WIDTH) { + return renderPixelArt( + cropPixels(BKPER_LOGO_PIXELS, BKPER_MARK_WIDTH), + BKPER_LOGO_COLORS, + mode + ); + } + return []; +} diff --git a/src/agent/extensions/startup.ts b/src/agent/extensions/startup.ts index fdd6ef4..ecbc3ce 100644 --- a/src/agent/extensions/startup.ts +++ b/src/agent/extensions/startup.ts @@ -1,4 +1,4 @@ -import {getKeybindings} from '@earendil-works/pi-tui'; +import {getKeybindings, truncateToWidth, visibleWidth} from '@earendil-works/pi-tui'; import { getShellConfig, keyText, @@ -13,6 +13,7 @@ import { } from '../interactive/session-keybindings.js'; import {runStartupMaintenance} from '../startup-maintenance.js'; import {getBkperHandoffShortcut} from './handoff.js'; +import {getBkperLogoLines} from './startup-logo.js'; type StartupHeaderComponent = { render: (width: number) => string[]; @@ -27,7 +28,10 @@ type ModelRegistryLike = { getAvailable(): unknown[]; }; +type StartupHint = {key: string; description: string}; + const STARTUP_LEFT_PADDING = ' '; +const HINT_COLUMN_GAP = ' '; const NO_MODELS_STARTUP_HINT = 'No AI model provider configured. Use /login for Bkper AI or /connect for another ' + 'model provider.'; @@ -82,17 +86,32 @@ function wrapStartupHeaderLine(line: string, width: number): string[] { return wrappedLines; } -const BKPER_BANNER = [ - '██████╗ ██╗ ██╗██████╗ ███████╗██████╗ ', - '██╔══██╗██║ ██╔╝██╔══██╗██╔════╝██╔══██╗', - '██████╔╝█████╔╝ ██████╔╝█████╗ ██████╔╝', - '██╔══██╗██╔═██╗ ██╔═══╝ ██╔══╝ ██╔══██╗', - '██████╔╝██║ ██╗██║ ███████╗██║ ██║', - '╚═════╝ ╚═╝ ╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝', -]; - -function formatStartupHint(theme: Theme, key: string, description: string): string { - return theme.fg('dim', key) + theme.fg('muted', ` ${description}`); +function formatStartupHint(theme: Theme, hint: StartupHint): string { + return theme.fg('accent', hint.key) + theme.fg('muted', ` ${hint.description}`); +} + +/** + * Lays hints out in two columns when both fit in the width, filling the left column first so + * related hints stay together; otherwise one column. + */ +function layoutStartupHints(theme: Theme, hints: StartupHint[], width: number): string[] { + const cells = hints.map(hint => formatStartupHint(theme, hint)); + const leftCount = Math.ceil(cells.length / 2); + const left = cells.slice(0, leftCount); + const right = cells.slice(leftCount); + const leftWidth = Math.max(0, ...left.map(visibleWidth)); + const rightWidth = Math.max(0, ...right.map(visibleWidth)); + + if (leftWidth + HINT_COLUMN_GAP.length + rightWidth > width) { + return cells; + } + + return left.map((cell, index) => { + const rightCell = right[index]; + return rightCell === undefined + ? cell + : cell + ' '.repeat(leftWidth - visibleWidth(cell)) + HINT_COLUMN_GAP + rightCell; + }); } function isBashAvailable(shellPath?: string): boolean { @@ -109,71 +128,69 @@ function formatHandoffStartupCommand(): string { return shortcut ? `/handoff (${shortcut})` : '/handoff'; } +function getStartupHints(showBashShortcut: boolean): StartupHint[] { + const showPromptHistory = !isShortcutClaimedByUserBinding( + getKeybindings().getUserBindings(), + undefined, + PROMPT_HISTORY_SHORTCUT + ); + + return [ + {key: keyText('app.interrupt'), description: 'to interrupt'}, + {key: keyText('app.clear'), description: 'to clear'}, + {key: `${keyText('app.clear')} twice`, description: 'to exit'}, + ...(showPromptHistory + ? [{key: PROMPT_HISTORY_SHORTCUT, description: 'to search prompt history'}] + : []), + {key: '/', description: 'for commands'}, + ...(showBashShortcut ? [{key: '!', description: 'to run bash'}] : []), + {key: '/new', description: 'to start new session'}, + { + key: formatBkperSessionCommandShortcut('/resume', 'app.session.resume'), + description: 'to resume a session', + }, + {key: '/clone', description: 'to duplicate session'}, + { + key: formatBkperSessionCommandShortcut('/fork', 'app.session.fork'), + description: 'to branch from a message', + }, + { + key: formatBkperSessionCommandShortcut('/tree', 'app.session.tree'), + description: 'for session tree', + }, + {key: formatHandoffStartupCommand(), description: 'to continue in a focused session'}, + ]; +} + function buildStartupHeaderLines( theme: Theme, modelRegistry: ModelRegistryLike, width: number, showBashShortcut: boolean ): string[] { + const contentWidth = Math.max(1, width - STARTUP_LEFT_PADDING.length); + const logo = getBkperLogoLines(contentWidth, theme.getColorMode()); const lines = [ - ...BKPER_BANNER.map(line => theme.bold(theme.fg('accent', line))), - theme.fg('muted', `powered by `) + theme.fg('dim', `pi v${PI_VERSION}`), + ...logo, + ...(logo.length > 0 ? [''] : []), + theme.fg('muted', 'powered by ') + theme.fg('dim', `pi v${PI_VERSION}`), '', - formatStartupHint(theme, keyText('app.interrupt'), 'to interrupt'), - formatStartupHint(theme, keyText('app.clear'), 'to clear'), - formatStartupHint(theme, `${keyText('app.clear')} twice`, 'to exit'), - ...(!isShortcutClaimedByUserBinding( - getKeybindings().getUserBindings(), - undefined, - PROMPT_HISTORY_SHORTCUT - ) - ? [ - formatStartupHint( - theme, - PROMPT_HISTORY_SHORTCUT, - 'to search prompt history' - ), - ] - : []), - formatStartupHint(theme, '/', 'for commands'), - formatStartupHint(theme, '/new', 'to start new session'), - formatStartupHint( - theme, - formatBkperSessionCommandShortcut('/resume', 'app.session.resume'), - 'to resume a session' - ), - formatStartupHint(theme, '/clone', 'to duplicate session'), - formatStartupHint( - theme, - formatBkperSessionCommandShortcut('/fork', 'app.session.fork'), - 'to branch from a message' - ), - formatStartupHint( - theme, - formatBkperSessionCommandShortcut('/tree', 'app.session.tree'), - 'for session tree' - ), - formatStartupHint( - theme, - formatHandoffStartupCommand(), - 'to continue in a focused session' - ), + ...layoutStartupHints(theme, getStartupHints(showBashShortcut), contentWidth), ]; - if (showBashShortcut) { - lines.push(formatStartupHint(theme, '!', 'to run bash')); - } - if (modelRegistry.getAvailable().length === 0) { lines.push( '', - ...wrapStartupHeaderLine(NO_MODELS_STARTUP_HINT, width).map(line => + ...wrapStartupHeaderLine(NO_MODELS_STARTUP_HINT, contentWidth).map(line => theme.fg('warning', line) ) ); } - return lines.map(line => (line.length > 0 ? STARTUP_LEFT_PADDING + line : line)); + // The TUI rejects lines wider than the terminal, so narrow terminals truncate hints. + return lines.map(line => + line.length > 0 ? STARTUP_LEFT_PADDING + truncateToWidth(line, contentWidth) : line + ); } function createStartupHeaderFactory( diff --git a/src/agent/interactive/run-agent-mode.ts b/src/agent/interactive/run-agent-mode.ts index 810b989..9e8be1f 100644 --- a/src/agent/interactive/run-agent-mode.ts +++ b/src/agent/interactive/run-agent-mode.ts @@ -29,7 +29,9 @@ import { applyBkperAgentToolSelection, collectSettingsDiagnostics, createStartupSessionManager, + overrideDefaultTools, } from './settings.js'; +import {getBkperThemesDir, resolveBkperInitialThemeSetting} from './themes.js'; export interface AgentModeDependencies { createRuntime: () => Promise<{ @@ -103,6 +105,7 @@ export function createAgentModeDependencies( settingsManager, modelRuntime, resourceLoaderOptions: { + additionalThemePaths: [getBkperThemesDir()], systemPromptOverride: () => getBkperAgentSystemPrompt(selectedTools), extensionFactories: [ @@ -124,7 +127,7 @@ export function createAgentModeDependencies( }, }); applyBkperAgentSettingsDefaults(settingsManager); - settingsManager.applyOverrides({defaultTools: selectedTools}); + overrideDefaultTools(settingsManager, selectedTools); const restoredSessionOptions = restorePersistedSessionOptions( settingsManager, { @@ -187,6 +190,9 @@ export function createAgentModeDependencies( createInteractiveMode: (runtime, modelFallbackMessage) => new BkperInteractiveMode(runtime, { modelFallbackMessage, + initialThemeSetting: resolveBkperInitialThemeSetting( + runtime.services.settingsManager + ), }), }; } diff --git a/src/agent/interactive/settings.ts b/src/agent/interactive/settings.ts index 7e9dd5d..086838f 100644 --- a/src/agent/interactive/settings.ts +++ b/src/agent/interactive/settings.ts @@ -62,12 +62,32 @@ export function resolveBkperAgentTools( }; } -type BkperAgentToolSettingsManager = { +type DefaultToolsSettingsManager = { getDefaultTools(): string[] | undefined; - getShellPath(): string | undefined; applyOverrides(overrides: {defaultTools: string[]}): void; }; +type BkperAgentToolSettingsManager = DefaultToolsSettingsManager & { + getShellPath(): string | undefined; +}; + +/** + * Replaces the effective `defaultTools` selection. Pi appends an override without plain tool + * names, including an empty list, to the inherited selection, so an empty selection is + * expressed as removal of every currently selected tool. + */ +export function overrideDefaultTools( + settingsManager: DefaultToolsSettingsManager, + tools: string[] +): void { + settingsManager.applyOverrides({ + defaultTools: + tools.length > 0 + ? tools + : (settingsManager.getDefaultTools() ?? []).map(tool => `-${tool}`), + }); +} + function canResolveShell(resolve: () => unknown): boolean { try { resolve(); @@ -95,7 +115,7 @@ export function applyBkperAgentToolSelection( availability ); - settingsManager.applyOverrides({defaultTools: resolved.tools}); + overrideDefaultTools(settingsManager, resolved.tools); return resolved.warning ? [{type: 'warning', message: resolved.warning}] : []; } diff --git a/src/agent/interactive/themes.ts b/src/agent/interactive/themes.ts new file mode 100644 index 0000000..80dc219 --- /dev/null +++ b/src/agent/interactive/themes.ts @@ -0,0 +1,43 @@ +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +/** Pi theme setting that picks the Bkper light or dark theme from the terminal's appearance. */ +export const BKPER_THEME_SETTING = 'bkper-light/bkper-dark'; + +/** Directory of the bundled Bkper themes; `lib/agent/themes` in builds, `src/agent/themes` in sources. */ +export function getBkperThemesDir( + moduleDir: string = path.dirname(fileURLToPath(import.meta.url)) +): string { + return path.resolve(moduleDir, '..', 'themes'); +} + +const [BKPER_LIGHT_THEME, BKPER_DARK_THEME] = BKPER_THEME_SETTING.split('/'); + +/** Bkper's counterpart of a Pi built-in theme in a light or dark slot, or the theme itself. */ +function toBkperTheme(theme: string, appearance: 'light' | 'dark'): string { + if (theme === 'light') return BKPER_LIGHT_THEME; + if (theme === 'dark') return BKPER_DARK_THEME; + if (theme === 'system') return appearance === 'light' ? BKPER_LIGHT_THEME : BKPER_DARK_THEME; + return theme; +} + +/** + * The theme setting for this invocation. Pi saves one of its built-in themes (system, dark, or + * light) at first run, so those are replaced by their Bkper counterparts; custom themes are kept. + * Nothing is saved, because Bkper shares its settings file with Pi, which does not load these themes. + */ +export function resolveBkperInitialThemeSetting(settingsManager: { + getThemeSetting(): string | undefined; +}): string | undefined { + const setting = settingsManager.getThemeSetting(); + if (setting === undefined || setting === 'system') { + return BKPER_THEME_SETTING; + } + + const slots = setting.split('/'); + const resolved = + slots.length === 2 + ? `${toBkperTheme(slots[0], 'light')}/${toBkperTheme(slots[1], 'dark')}` + : toBkperTheme(setting, 'dark'); + return resolved === setting ? undefined : resolved; +} diff --git a/src/agent/themes/bkper-dark.json b/src/agent/themes/bkper-dark.json new file mode 100644 index 0000000..dd3be5b --- /dev/null +++ b/src/agent/themes/bkper-dark.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "bkper-dark", + "appearance": "dark", + "vars": { + "text": "okhsl(241 4% 89%)", + "muted": "okhsl(241 7% 67%)", + "blue": "okhsl(241 80% 68%)", + "sky": "okhsl(215 70% 76%)", + "teal": "okhsl(195 65% 74%)", + "green": "okhsl(149 68% 68%)", + "red": "okhsl(30 82% 67%)", + "yellow": "okhsl(81 96% 76%)", + "orange": "okhsl(55 92% 72%)", + "blueBg": "okhsl(241 45% 23%)" + }, + "colors": { + "accent": "blue", + "border": "okhsl(241 60% 58%)", + "borderAccent": "okhsl(241 80% 66%)", + "borderMuted": "okhsl(241 8% 50%)", + "success": "green", + "error": "red", + "warning": "yellow", + "muted": "muted", + "dim": "okhsl(241 7% 55%)", + "text": "text", + "thinkingText": "okhsl(241 7% 65%)", + "selectedBg": "blueBg", + "scrollbarTrack": "okhsl(241 7% 33%)", + "scrollbarThumb": "okhsl(241 7% 65%)", + "searchMatchBg": "okhsl(81 55% 26%)", + "searchMatchText": "muted", + "userMessageBg": "okhsl(241 38% 21%)", + "userMessageText": "text", + "customMessageBg": "okhsl(81 35% 20%)", + "customMessageText": "muted", + "customMessageLabel": "yellow", + "toolPendingBg": "okhsl(241 6% 21%)", + "toolSuccessBg": "okhsl(149 30% 19%)", + "toolErrorBg": "okhsl(30 45% 21%)", + "toolTitle": "text", + "toolOutput": "muted", + "mdHeading": "yellow", + "mdLink": "blue", + "mdLinkUrl": "muted", + "mdCode": "teal", + "mdCodeBlock": "green", + "mdCodeBlockBorder": "muted", + "mdQuote": "muted", + "mdQuoteBorder": "muted", + "mdHr": "muted", + "mdListBullet": "blue", + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "muted", + "syntaxComment": "muted", + "syntaxKeyword": "blue", + "syntaxFunction": "yellow", + "syntaxVariable": "sky", + "syntaxString": "orange", + "syntaxNumber": "green", + "syntaxType": "red", + "syntaxOperator": "muted", + "syntaxPunctuation": "muted", + "thinkingOff": "okhsl(241 6% 42%)", + "thinkingMinimal": "okhsl(241 18% 48%)", + "thinkingLow": "okhsl(241 35% 52%)", + "thinkingMedium": "okhsl(241 55% 57%)", + "thinkingHigh": "okhsl(241 72% 62%)", + "thinkingXhigh": "okhsl(236 92% 67%)", + "thinkingMax": "okhsl(30 92% 62%)", + "bashMode": "okhsl(149 70% 62%)" + }, + "export": { + "pageBg": "okhsl(241 14% 15%)", + "cardBg": "okhsl(241 13% 18%)", + "infoBg": "okhsl(81 45% 22%)" + } +} diff --git a/src/agent/themes/bkper-light.json b/src/agent/themes/bkper-light.json new file mode 100644 index 0000000..295d4bf --- /dev/null +++ b/src/agent/themes/bkper-light.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "bkper-light", + "appearance": "light", + "vars": { + "text": "okhsl(241 6% 25%)", + "muted": "okhsl(241 8% 46%)", + "blue": "okhsl(241 85% 45%)", + "sky": "okhsl(215 80% 44%)", + "teal": "okhsl(195 90% 38%)", + "green": "okhsl(149 85% 42%)", + "red": "okhsl(30 90% 46%)", + "yellow": "okhsl(78 100% 47%)", + "orange": "okhsl(55 95% 48%)", + "blueBg": "okhsl(241 40% 91%)" + }, + "colors": { + "accent": "blue", + "border": "okhsl(241 70% 55%)", + "borderAccent": "okhsl(241 85% 50%)", + "borderMuted": "okhsl(241 8% 70%)", + "success": "green", + "error": "red", + "warning": "yellow", + "muted": "muted", + "dim": "okhsl(241 7% 58%)", + "text": "text", + "thinkingText": "okhsl(241 8% 55%)", + "selectedBg": "blueBg", + "scrollbarTrack": "okhsl(241 4% 90%)", + "scrollbarThumb": "okhsl(241 7% 65%)", + "searchMatchBg": "okhsl(81 45% 88%)", + "searchMatchText": "muted", + "userMessageBg": "okhsl(241 45% 93%)", + "userMessageText": "text", + "customMessageBg": "okhsl(81 60% 92%)", + "customMessageText": "muted", + "customMessageLabel": "orange", + "toolPendingBg": "okhsl(241 5% 93%)", + "toolSuccessBg": "okhsl(149 35% 93%)", + "toolErrorBg": "okhsl(30 45% 93%)", + "toolTitle": "text", + "toolOutput": "muted", + "mdHeading": "yellow", + "mdLink": "blue", + "mdLinkUrl": "muted", + "mdCode": "teal", + "mdCodeBlock": "green", + "mdCodeBlockBorder": "muted", + "mdQuote": "muted", + "mdQuoteBorder": "muted", + "mdHr": "muted", + "mdListBullet": "blue", + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "muted", + "syntaxComment": "muted", + "syntaxKeyword": "blue", + "syntaxFunction": "yellow", + "syntaxVariable": "sky", + "syntaxString": "orange", + "syntaxNumber": "green", + "syntaxType": "red", + "syntaxOperator": "muted", + "syntaxPunctuation": "muted", + "thinkingOff": "okhsl(241 5% 80%)", + "thinkingMinimal": "okhsl(241 15% 74%)", + "thinkingLow": "okhsl(241 35% 66%)", + "thinkingMedium": "okhsl(241 55% 58%)", + "thinkingHigh": "okhsl(241 75% 52%)", + "thinkingXhigh": "okhsl(236 92% 48%)", + "thinkingMax": "okhsl(30 92% 52%)", + "bashMode": "okhsl(149 75% 50%)" + }, + "export": { + "pageBg": "okhsl(241 5% 95%)", + "cardBg": "okhsl(241 6% 98%)", + "infoBg": "okhsl(81 50% 92%)" + } +} diff --git a/test/unit/agent/extensions/bkper-ai-provider.test.ts b/test/unit/agent/extensions/bkper-ai-provider.test.ts index eb64da0..462cb63 100644 --- a/test/unit/agent/extensions/bkper-ai-provider.test.ts +++ b/test/unit/agent/extensions/bkper-ai-provider.test.ts @@ -1,5 +1,5 @@ import {expect} from '../../helpers/test-setup.js'; -import type {ProviderConfig} from '@earendil-works/pi-coding-agent'; +import type {ProviderConfig, ProviderModelConfig} from '@earendil-works/pi-coding-agent'; import sinon from 'sinon'; import { findDefaultBkperAiModel, @@ -17,6 +17,20 @@ function createRefreshContext(): Parameters< }; } +type ProviderChatModelConfig = Extract; + +function isChatModel(model: ProviderModelConfig): model is ProviderChatModelConfig { + return model.type === undefined || model.type === 'chat'; +} + +/** Refreshes the catalog and asserts the provider only offers chat models. */ +async function refreshChatModels(config: ProviderConfig): Promise { + const models = (await config.refreshModels?.(createRefreshContext())) ?? []; + const chatModels = models.filter(isChatModel); + expect(chatModels).to.have.length(models.length); + return chatModels; +} + describe('agent/bkper-ai-provider', function () { it('provides models dynamically through Pi refreshModels', async function () { const fetchModels = sinon.stub().resolves( @@ -68,7 +82,7 @@ describe('agent/bkper-ai-provider', function () { ); expect(config.models).to.deep.equal([]); - const models = await config.refreshModels?.(createRefreshContext()); + const models = await refreshChatModels(config); expect(fetchModels.calledOnce).to.equal(true); expect(fetchModels.firstCall.args[0]).to.equal('https://ai-dev.bkper.app/v1/models'); @@ -161,6 +175,49 @@ describe('agent/bkper-ai-provider', function () { expect(await config.refreshModels?.(createRefreshContext())).to.deep.equal([]); }); + it('skips decision models listed in the catalog', async function () { + const config = getBkperAiProviderConfig({}, sinon.stub().resolves( + new Response(JSON.stringify({ + default_model: 'vision', + data: [ + { + id: 'jev', + object: 'model', + type: 'decision', + input_modalities: ['text'], + pricing: { + inputNanoUsdPerToken: 0, + cachedInputNanoUsdPerToken: 0, + cacheWriteNanoUsdPerToken: 0, + outputNanoUsdPerToken: 0, + }, + context_window: 128000, + max_state_question_tokens: 8192, + question_types: ['noul', 'choice', 'score'], + }, + { + id: 'vision', + object: 'model', + input_modalities: ['text', 'image'], + pricing: { + inputNanoUsdPerToken: 0, + cachedInputNanoUsdPerToken: 0, + cacheWriteNanoUsdPerToken: 0, + outputNanoUsdPerToken: 0, + }, + context_window: 128000, + max_output_tokens: 8192, + thinking_levels: ['low'], + }, + ], + })) + )); + + const models = await refreshChatModels(config); + + expect(models.map(model => model.id)).to.deep.equal(['vision']); + }); + it('enables cache warming only for models with a catalog prompt cache lifetime', async function () { const catalogModel = (id: string, promptCacheTtlSeconds?: unknown) => ({ id, @@ -188,9 +245,9 @@ describe('agent/bkper-ai-provider', function () { })) )); - const models = await config.refreshModels?.(createRefreshContext()); + const models = await refreshChatModels(config); - expect(models?.map(model => [model.id, model.promptCache])).to.deep.equal([ + expect(models.map(model => [model.id, model.promptCache])).to.deep.equal([ ['documented', {short: 300}], ['undocumented', undefined], ['invalid', undefined], diff --git a/test/unit/agent/extensions/builtins.test.ts b/test/unit/agent/extensions/builtins.test.ts index 849d9fc..f2042d5 100644 --- a/test/unit/agent/extensions/builtins.test.ts +++ b/test/unit/agent/extensions/builtins.test.ts @@ -165,6 +165,25 @@ describe('Bkper agent built-in extensions', function () { ); }); + it('keeps built-ins out of the loaded extensions listing but lists user extensions', function () { + const normalized = normalizeBkperAgentExtensions( + { + extensions: [ + createLoadedExtension(''), + createLoadedExtension('/tmp/user-extension.ts'), + ], + errors: [], + runtime: createExtensionRuntime(), + }, + {verbose: false} + ); + + expect(normalized.extensions.map(extension => extension.hidden === true)).to.deep.equal([ + true, + false, + ]); + }); + it('shows the canonical built-in extension name in diagnostics', function () { const normalized = normalizeBkperAgentExtensionErrors( [ diff --git a/test/unit/agent/extensions/startup-logo.test.ts b/test/unit/agent/extensions/startup-logo.test.ts new file mode 100644 index 0000000..1071a1a --- /dev/null +++ b/test/unit/agent/extensions/startup-logo.test.ts @@ -0,0 +1,35 @@ +import {rgbColor, stripTerminalSequences, visibleWidth} from '@earendil-works/pi-tui'; +import {expect} from '../../helpers/test-setup.js'; +import { + getBkperLogoLines, + renderPixelArt, +} from '../../../../src/agent/extensions/startup-logo.js'; + +const RED = rgbColor(255, 0, 0); +const BLUE = rgbColor(0, 0, 255); + +describe('Bkper startup logo', function () { + it('packs two pixel rows into each terminal line with half blocks', function () { + const lines = renderPixelArt(['RR.', 'RB.', '.B.'], {R: RED, B: BLUE}, 'truecolor'); + + expect(lines).to.have.length(2); + expect(lines.map(stripTerminalSequences)).to.deep.equal(['█▀ ', ' ▀ ']); + expect(lines[0]).to.include('\x1b[38;2;255;0;0m'); // red over red, red over blue + expect(lines[0]).to.include('\x1b[48;2;0;0;255m'); + expect(lines[1]).to.include('\x1b[38;2;0;0;255m'); // blue over the transparent padding row + }); + + it('fits the logo to the available width', function () { + const full = getBkperLogoLines(80, 'truecolor'); + const markOnly = getBkperLogoLines(30, 'truecolor'); + + expect(full.length).to.be.greaterThan(0); + expect(Math.max(...full.map(visibleWidth))).to.be.at.most(80); + expect(markOnly.length).to.be.greaterThan(0); + expect(Math.max(...markOnly.map(visibleWidth))).to.be.at.most(30); + expect(Math.max(...markOnly.map(visibleWidth))).to.be.lessThan( + Math.max(...full.map(visibleWidth)) + ); + expect(getBkperLogoLines(10, 'truecolor')).to.deep.equal([]); + }); +}); diff --git a/test/unit/agent/extensions/startup.test.ts b/test/unit/agent/extensions/startup.test.ts index 4a39903..b340ff7 100644 --- a/test/unit/agent/extensions/startup.test.ts +++ b/test/unit/agent/extensions/startup.test.ts @@ -6,6 +6,7 @@ import { getKeybindings, KeybindingsManager, setKeybindings, + visibleWidth, type KeybindingsConfig, } from '@earendil-works/pi-tui'; import sinon from 'sinon'; @@ -17,6 +18,7 @@ type NotificationType = 'info' | 'warning' | 'error'; type StartupTheme = { bold: (text: string) => string; fg: (color: string, text: string) => string; + getColorMode: () => 'truecolor'; }; type StartupHeaderFactory = ( @@ -46,6 +48,7 @@ function createThemeStub(): StartupTheme { return { bold: (text: string) => text, fg: (_color: string, text: string) => text, + getColorMode: () => 'truecolor', }; } @@ -58,20 +61,28 @@ const STARTUP_TEST_KEYBINDINGS = { 'app.session.tree': {defaultKeys: 'ctrl+alt+r'}, } as const; -function renderStartupHeaderWithKeybindings( +function renderStartupHeaderLinesWithKeybindings( factory: StartupHeaderFactory, - userBindings: KeybindingsConfig = {} -): string { + userBindings: KeybindingsConfig = {}, + width = 120 +): string[] { const previousKeybindings = getKeybindings(); setKeybindings(new KeybindingsManager(STARTUP_TEST_KEYBINDINGS, userBindings)); try { - return factory(undefined, createThemeStub()).render(120).join('\n'); + return factory(undefined, createThemeStub()).render(width); } finally { setKeybindings(previousKeybindings); } } +function renderStartupHeaderWithKeybindings( + factory: StartupHeaderFactory, + userBindings: KeybindingsConfig = {} +): string { + return renderStartupHeaderLinesWithKeybindings(factory, userBindings).join('\n'); +} + function registerStartupExtension( startupMaintenance = sinon.stub().resolves(), settingsManager?: { @@ -272,7 +283,6 @@ describe('Bkper agent startup extension', function () { const headerText = startupHeaderFactory ? renderStartupHeaderWithKeybindings(startupHeaderFactory) : ''; - expect(headerText).to.include('██████╗'); expect(headerText).to.include(`pi v${PI_VERSION}`); expect(headerText).to.include('to interrupt'); expect(headerText).to.include('for session tree'); @@ -291,4 +301,32 @@ describe('Bkper agent startup extension', function () { expect(headerText).to.include('/connect for another model provider'); expect(notify.called).to.be.false; }); + + it('keeps every header line within the terminal width', async function () { + let startupHeaderFactory: StartupHeaderFactory | undefined; + const {sessionStartHandler} = registerStartupExtension(); + + await sessionStartHandler( + {}, + { + ui: { + notify: sinon.stub(), + setHeader: factory => { + startupHeaderFactory = factory; + }, + }, + modelRegistry: {getAvailable: () => []}, + } + ); + + for (const width of [24, 60, 80, 120, 200]) { + const lines = startupHeaderFactory + ? renderStartupHeaderLinesWithKeybindings(startupHeaderFactory, {}, width) + : []; + expect(lines.join('\n')).to.include(`pi v${PI_VERSION}`); + for (const line of lines) { + expect(visibleWidth(line), `width ${width}: ${line}`).to.be.at.most(width); + } + } + }); }); diff --git a/test/unit/agent/interactive/settings.test.ts b/test/unit/agent/interactive/settings.test.ts index e1eebf8..d16d6fc 100644 --- a/test/unit/agent/interactive/settings.test.ts +++ b/test/unit/agent/interactive/settings.test.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import sinon from 'sinon'; +import {SettingsManager} from '@earendil-works/pi-coding-agent'; import {expect} from '../../helpers/test-setup.js'; import { applyBkperAgentSettingsDefaults, @@ -94,6 +95,23 @@ describe('interactive agent settings', function () { }); }); + it('disables configured shells even when no configured tool remains', function () { + const settingsManager = SettingsManager.inMemory({ + defaultTools: ['bash'], + shellPath: path.join(REPO_ROOT, 'missing-shell'), + }); + + const diagnostics = applyBkperAgentToolSelection(settingsManager, 'linux'); + + expect(settingsManager.getDefaultTools()).to.deep.equal([]); + expect(diagnostics).to.deep.equal([ + { + type: 'warning', + message: 'Unavailable configured shell tools were disabled: bash.', + }, + ]); + }); + it('persists Bkper agent defaults when no user settings are present', function () { const setShowCacheMissNotices = sinon.stub(); const setTuiMode = sinon.stub(); diff --git a/test/unit/agent/interactive/themes.test.ts b/test/unit/agent/interactive/themes.test.ts new file mode 100644 index 0000000..a643e79 --- /dev/null +++ b/test/unit/agent/interactive/themes.test.ts @@ -0,0 +1,95 @@ +import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {DefaultResourceLoader, SettingsManager} from '@earendil-works/pi-coding-agent'; +import {expect} from '../../helpers/test-setup.js'; +import { + BKPER_THEME_SETTING, + getBkperThemesDir, + resolveBkperInitialThemeSetting, +} from '../../../../src/agent/interactive/themes.js'; + +type ThemeFile = { + name: string; + vars?: Record; + colors: Record; +}; + +type ThemeSchema = { + properties: {colors: {required: string[]; properties: Record}}; +}; + +const COLOR_LITERAL = /^(#|okhsl\(|oklch\()/; + +function readJson(filePath: string): T { + return JSON.parse(readFileSync(filePath, 'utf8')) as T; +} + +// Pi validates theme files against this schema only in its own CLI, not for SDK hosts like Bkper. +function readPiThemeSchema(): ThemeSchema { + const piEntry = fileURLToPath(import.meta.resolve('@earendil-works/pi-coding-agent')); + return readJson( + path.join(path.dirname(piEntry), 'modes', 'interactive', 'theme', 'theme-schema.json') + ); +} + +describe('Bkper agent themes', function () { + it('define every color token Pi requires, and only known tokens and variables', function () { + const schema = readPiThemeSchema().properties.colors; + + for (const name of BKPER_THEME_SETTING.split('/')) { + const theme = readJson(path.join(getBkperThemesDir(), `${name}.json`)); + expect(theme.name).to.equal(name); + expect(Object.keys(theme.colors)).to.include.members(schema.required); + expect(Object.keys(schema.properties)).to.include.members(Object.keys(theme.colors)); + for (const [token, value] of Object.entries(theme.colors)) { + if (typeof value === 'string' && value !== '' && !COLOR_LITERAL.test(value)) { + expect(theme.vars, `${name}.${token}`).to.have.property(value); + } + } + } + }); + + it('loads the bundled light and dark themes in Pi without diagnostics', async function () { + const agentDir = mkdtempSync(path.join(tmpdir(), 'bkper-themes-')); + try { + const loader = new DefaultResourceLoader({ + cwd: agentDir, + agentDir, + settingsManager: SettingsManager.inMemory(), + additionalThemePaths: [getBkperThemesDir()], + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noContextFiles: true, + }); + await loader.reload(); + + const {themes, diagnostics} = loader.getThemes(); + const names = themes.map(theme => theme.name); + expect(diagnostics).to.deep.equal([]); + for (const name of BKPER_THEME_SETTING.split('/')) { + expect(names).to.include(name); + } + } finally { + rmSync(agentDir, {recursive: true, force: true}); + } + }); + + it('uses Bkper themes in place of Pi built-in themes but keeps custom themes', function () { + const resolve = (theme?: string) => + resolveBkperInitialThemeSetting( + SettingsManager.inMemory(theme === undefined ? {} : {theme}) + ); + + expect(resolve()).to.equal(BKPER_THEME_SETTING); + expect(resolve('system')).to.equal(BKPER_THEME_SETTING); + expect(resolve('dark')).to.equal('bkper-dark'); + expect(resolve('light')).to.equal('bkper-light'); + expect(resolve('light/dark')).to.equal(BKPER_THEME_SETTING); + expect(resolve('light/my-dark')).to.equal('bkper-light/my-dark'); + expect(resolve('my-theme')).to.equal(undefined); + expect(resolve('bkper-dark')).to.equal(undefined); + }); +});