mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 22:03:45 +00:00
Merge branch 'main' into fix/chutes-ai-provider-400-error
This commit is contained in:
commit
b76f7a0f88
63 changed files with 4781 additions and 3540 deletions
|
|
@ -1,5 +1,4 @@
|
|||
- When receiving the first user message, ask the user which module(s) they want to work on. Then you MUST read the corresponding README.md files in full, in parallel:
|
||||
- README.md
|
||||
- When receiving the first user message, you MUST read README.md in full. Then ask the user which module(s) they want to work on. Then you MUST read the corresponding README.md files in full, in parallel:
|
||||
- packages/ai/README.md
|
||||
- packages/tui/README.md
|
||||
- packages/agent/README.md
|
||||
|
|
|
|||
36
README.md
36
README.md
|
|
@ -56,9 +56,39 @@ These commands:
|
|||
|
||||
### Publishing
|
||||
|
||||
```bash
|
||||
npm run publish # Publish all packages to npm
|
||||
```
|
||||
Complete release process:
|
||||
|
||||
1. **Update CHANGELOG.md** (if changes affect coding-agent):
|
||||
```bash
|
||||
# Add your changes to the [Unreleased] section in packages/coding-agent/CHANGELOG.md
|
||||
```
|
||||
|
||||
2. **Bump version** (all packages):
|
||||
```bash
|
||||
npm run version:patch # For bug fixes
|
||||
npm run version:minor # For new features
|
||||
npm run version:major # For breaking changes
|
||||
```
|
||||
|
||||
3. **Update CHANGELOG.md version** (if changes affect coding-agent):
|
||||
```bash
|
||||
# Move the [Unreleased] section to the new version number with today's date
|
||||
# e.g., ## [0.7.16] - 2025-11-17
|
||||
```
|
||||
|
||||
4. **Commit and tag**:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Release v0.7.16"
|
||||
git tag v0.7.16
|
||||
git push origin main
|
||||
git push origin v0.7.16
|
||||
```
|
||||
|
||||
5. **Publish to npm**:
|
||||
```bash
|
||||
npm run publish # Publish all packages to npm
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
36
package-lock.json
generated
36
package-lock.json
generated
|
|
@ -86,7 +86,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@google/genai": {
|
||||
"version": "1.29.0",
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.30.0.tgz",
|
||||
"integrity": "sha512-3MRcgczBFbUat1wIlZoLJ0vCCfXgm7Qxjh59cZi2X08RgWLtm9hKOspzp7TOg1TV2e26/MLxR2GR5yD5GmBV2w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"google-auth-library": "^10.3.0",
|
||||
|
|
@ -3193,11 +3195,11 @@
|
|||
},
|
||||
"packages/agent": {
|
||||
"name": "@mariozechner/pi-agent",
|
||||
"version": "0.7.10",
|
||||
"version": "0.7.25",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-ai": "^0.7.9",
|
||||
"@mariozechner/pi-tui": "^0.7.9"
|
||||
"@mariozechner/pi-ai": "^0.7.24",
|
||||
"@mariozechner/pi-tui": "^0.7.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
|
|
@ -3223,11 +3225,11 @@
|
|||
},
|
||||
"packages/ai": {
|
||||
"name": "@mariozechner/pi-ai",
|
||||
"version": "0.7.10",
|
||||
"version": "0.7.25",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.61.0",
|
||||
"@google/genai": "^1.17.0",
|
||||
"@google/genai": "^1.30.0",
|
||||
"@sinclair/typebox": "^0.34.41",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
|
|
@ -3270,11 +3272,11 @@
|
|||
},
|
||||
"packages/coding-agent": {
|
||||
"name": "@mariozechner/pi-coding-agent",
|
||||
"version": "0.7.10",
|
||||
"version": "0.7.25",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-agent": "^0.7.9",
|
||||
"@mariozechner/pi-ai": "^0.7.9",
|
||||
"@mariozechner/pi-agent": "^0.7.24",
|
||||
"@mariozechner/pi-ai": "^0.7.24",
|
||||
"chalk": "^5.5.0",
|
||||
"diff": "^8.0.2",
|
||||
"glob": "^11.0.3"
|
||||
|
|
@ -3317,10 +3319,10 @@
|
|||
},
|
||||
"packages/pods": {
|
||||
"name": "@mariozechner/pi",
|
||||
"version": "0.7.10",
|
||||
"version": "0.7.25",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-agent": "^0.7.9",
|
||||
"@mariozechner/pi-agent": "^0.7.24",
|
||||
"chalk": "^5.5.0"
|
||||
},
|
||||
"bin": {
|
||||
|
|
@ -3343,7 +3345,7 @@
|
|||
},
|
||||
"packages/proxy": {
|
||||
"name": "@mariozechner/pi-proxy",
|
||||
"version": "0.7.10",
|
||||
"version": "0.7.25",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.14.0",
|
||||
"hono": "^4.6.16"
|
||||
|
|
@ -3359,7 +3361,7 @@
|
|||
},
|
||||
"packages/tui": {
|
||||
"name": "@mariozechner/pi-tui",
|
||||
"version": "0.7.10",
|
||||
"version": "0.7.25",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mime-types": "^2.1.4",
|
||||
|
|
@ -3398,17 +3400,17 @@
|
|||
},
|
||||
"packages/web-ui": {
|
||||
"name": "@mariozechner/pi-web-ui",
|
||||
"version": "0.7.10",
|
||||
"version": "0.7.25",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lmstudio/sdk": "^1.5.0",
|
||||
"@mariozechner/pi-ai": "^0.7.9",
|
||||
"@mariozechner/pi-tui": "^0.7.9",
|
||||
"@mariozechner/pi-ai": "^0.7.24",
|
||||
"@mariozechner/pi-tui": "^0.7.24",
|
||||
"docx-preview": "^0.3.7",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide": "^0.544.0",
|
||||
"ollama": "^0.6.0",
|
||||
"pdfjs-dist": "^5.4.296",
|
||||
"pdfjs-dist": "5.4.394",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@mariozechner/pi-agent",
|
||||
"version": "0.7.10",
|
||||
"version": "0.7.25",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -18,8 +18,8 @@
|
|||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-ai": "^0.7.10",
|
||||
"@mariozechner/pi-tui": "^0.7.10"
|
||||
"@mariozechner/pi-ai": "^0.7.25",
|
||||
"@mariozechner/pi-tui": "^0.7.25"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@mariozechner/pi-ai",
|
||||
"version": "0.7.10",
|
||||
"version": "0.7.25",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.61.0",
|
||||
"@google/genai": "^1.17.0",
|
||||
"@google/genai": "^1.30.0",
|
||||
"@sinclair/typebox": "^0.34.41",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ async function generateModels() {
|
|||
// Combine models (models.dev has priority)
|
||||
const allModels = [...modelsDevModels, ...openRouterModels];
|
||||
|
||||
// Add missing gpt models (can't use tools)
|
||||
// Add missing gpt models
|
||||
if (!allModels.some(m => m.provider === "openai" && m.id === "gpt-5-chat-latest")) {
|
||||
allModels.push({
|
||||
id: "gpt-5-chat-latest",
|
||||
|
|
@ -316,6 +316,26 @@ async function generateModels() {
|
|||
});
|
||||
}
|
||||
|
||||
if (!allModels.some(m => m.provider === "openai" && m.id === "gpt-5.1-codex")) {
|
||||
allModels.push({
|
||||
id: "gpt-5.1-codex",
|
||||
name: "GPT-5.1 Codex",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
provider: "openai",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 1.25,
|
||||
output: 5,
|
||||
cacheRead: 0.125,
|
||||
cacheWrite: 1.25,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
});
|
||||
}
|
||||
|
||||
// Add missing Grok models
|
||||
if (!allModels.some(m => m.provider === "xai" && m.id === "grok-code-fast-1")) {
|
||||
allModels.push({
|
||||
|
|
|
|||
|
|
@ -164,6 +164,9 @@ async function streamAssistantResponse(
|
|||
} else {
|
||||
context.messages.push(finalMessage);
|
||||
}
|
||||
if (!addedPartial) {
|
||||
stream.push({ type: "message_start", message: { ...finalMessage } });
|
||||
}
|
||||
stream.push({ type: "message_end", message: finalMessage });
|
||||
return finalMessage;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -364,6 +364,23 @@ export const MODELS = {
|
|||
contextWindow: 1048576,
|
||||
maxTokens: 65536,
|
||||
} satisfies Model<"google-generative-ai">,
|
||||
"gemini-3-pro-preview": {
|
||||
id: "gemini-3-pro-preview",
|
||||
name: "Gemini 3 Pro Preview",
|
||||
api: "google-generative-ai",
|
||||
provider: "google",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 2,
|
||||
output: 12,
|
||||
cacheRead: 0.2,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 64000,
|
||||
} satisfies Model<"google-generative-ai">,
|
||||
"gemini-2.5-flash": {
|
||||
id: "gemini-2.5-flash",
|
||||
name: "Gemini 2.5 Flash",
|
||||
|
|
@ -723,6 +740,23 @@ export const MODELS = {
|
|||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-responses">,
|
||||
"gpt-5.1-codex": {
|
||||
id: "gpt-5.1-codex",
|
||||
name: "GPT-5.1 Codex",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 1.25,
|
||||
output: 10,
|
||||
cacheRead: 0.125,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"openai-responses">,
|
||||
"gpt-4o-2024-08-06": {
|
||||
id: "gpt-4o-2024-08-06",
|
||||
name: "GPT-4o (2024-08-06)",
|
||||
|
|
@ -791,6 +825,23 @@ export const MODELS = {
|
|||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-responses">,
|
||||
"gpt-5.1-codex-mini": {
|
||||
id: "gpt-5.1-codex-mini",
|
||||
name: "GPT-5.1 Codex mini",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0.25,
|
||||
output: 2,
|
||||
cacheRead: 0.025,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"openai-responses">,
|
||||
"o3-mini": {
|
||||
id: "o3-mini",
|
||||
name: "o3-mini",
|
||||
|
|
@ -1080,6 +1131,23 @@ export const MODELS = {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 272000,
|
||||
} satisfies Model<"openai-responses">,
|
||||
"gpt-5.1-chat-latest": {
|
||||
id: "gpt-5.1-chat-latest",
|
||||
name: "GPT-5.1 Chat",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 1.25,
|
||||
output: 10,
|
||||
cacheRead: 0.125,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
maxTokens: 16384,
|
||||
} satisfies Model<"openai-responses">,
|
||||
"gpt-5-chat-latest": {
|
||||
id: "gpt-5-chat-latest",
|
||||
name: "GPT-5 Chat Latest",
|
||||
|
|
@ -1562,6 +1630,23 @@ export const MODELS = {
|
|||
contextWindow: 8192,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"grok-4.1-fast-non-reasoning": {
|
||||
id: "grok-4.1-fast-non-reasoning",
|
||||
name: "Grok 4.1 Fast (Non-Reasoning)",
|
||||
api: "openai-completions",
|
||||
provider: "xai",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0.2,
|
||||
output: 0.5,
|
||||
cacheRead: 0.05,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 2000000,
|
||||
maxTokens: 30000,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"grok-3": {
|
||||
id: "grok-3",
|
||||
name: "Grok 3",
|
||||
|
|
@ -1732,6 +1817,23 @@ export const MODELS = {
|
|||
contextWindow: 131072,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"grok-4.1-fast": {
|
||||
id: "grok-4.1-fast",
|
||||
name: "Grok 4.1 Fast",
|
||||
api: "openai-completions",
|
||||
provider: "xai",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0.2,
|
||||
output: 0.5,
|
||||
cacheRead: 0.05,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 2000000,
|
||||
maxTokens: 30000,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"grok-3-mini-latest": {
|
||||
id: "grok-3-mini-latest",
|
||||
name: "Grok 3 Mini Latest",
|
||||
|
|
@ -1855,6 +1957,40 @@ export const MODELS = {
|
|||
} satisfies Model<"anthropic-messages">,
|
||||
},
|
||||
openrouter: {
|
||||
"x-ai/grok-4.1-fast": {
|
||||
id: "x-ai/grok-4.1-fast",
|
||||
name: "xAI: Grok 4.1 Fast",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 2000000,
|
||||
maxTokens: 30000,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"google/gemini-3-pro-preview": {
|
||||
id: "google/gemini-3-pro-preview",
|
||||
name: "Google: Gemini 3 Pro Preview",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 2,
|
||||
output: 12,
|
||||
cacheRead: 0.19999999999999998,
|
||||
cacheWrite: 2.375,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
maxTokens: 65536,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/gpt-5.1": {
|
||||
id: "openai/gpt-5.1",
|
||||
name: "OpenAI: GPT-5.1",
|
||||
|
|
@ -1872,6 +2008,23 @@ export const MODELS = {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/gpt-5.1-chat": {
|
||||
id: "openai/gpt-5.1-chat",
|
||||
name: "OpenAI: GPT-5.1 Chat",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 1.25,
|
||||
output: 10,
|
||||
cacheRead: 0.125,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
maxTokens: 16384,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/gpt-5.1-codex": {
|
||||
id: "openai/gpt-5.1-codex",
|
||||
name: "OpenAI: GPT-5.1-Codex",
|
||||
|
|
@ -1932,8 +2085,8 @@ export const MODELS = {
|
|||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.55,
|
||||
output: 2.25,
|
||||
input: 0.44999999999999996,
|
||||
output: 2.35,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
|
|
@ -2127,40 +2280,6 @@ export const MODELS = {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"inclusionai/ring-1t": {
|
||||
id: "inclusionai/ring-1t",
|
||||
name: "inclusionAI: Ring 1T",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.5700000000000001,
|
||||
output: 2.2800000000000002,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 131072,
|
||||
maxTokens: 131072,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"inclusionai/ling-1t": {
|
||||
id: "inclusionai/ling-1t",
|
||||
name: "inclusionAI: Ling-1T",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.5700000000000001,
|
||||
output: 2.2800000000000002,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 131072,
|
||||
maxTokens: 131072,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/o3-deep-research": {
|
||||
id: "openai/o3-deep-research",
|
||||
name: "OpenAI: o3 Deep Research",
|
||||
|
|
@ -2391,13 +2510,13 @@ export const MODELS = {
|
|||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0.22,
|
||||
output: 0.88,
|
||||
input: 0.21,
|
||||
output: 1.9,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 262144,
|
||||
maxTokens: 4096,
|
||||
contextWindow: 131072,
|
||||
maxTokens: 32768,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"qwen/qwen3-max": {
|
||||
id: "qwen/qwen3-max",
|
||||
|
|
@ -2765,13 +2884,13 @@ export const MODELS = {
|
|||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.09,
|
||||
output: 0.3,
|
||||
input: 0.051,
|
||||
output: 0.33999999999999997,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 262144,
|
||||
maxTokens: 131072,
|
||||
contextWindow: 32768,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"x-ai/grok-code-fast-1": {
|
||||
id: "x-ai/grok-code-fast-1",
|
||||
|
|
@ -3020,13 +3139,13 @@ export const MODELS = {
|
|||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
input: 0.04,
|
||||
output: 0.39999999999999997,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 131072,
|
||||
maxTokens: 4096,
|
||||
maxTokens: 131072,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/gpt-oss-120b:exacto": {
|
||||
id: "openai/gpt-oss-120b:exacto",
|
||||
|
|
@ -3470,23 +3589,6 @@ export const MODELS = {
|
|||
contextWindow: 1000000,
|
||||
maxTokens: 40000,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"google/gemini-2.5-flash-lite-preview-06-17": {
|
||||
id: "google/gemini-2.5-flash-lite-preview-06-17",
|
||||
name: "Google: Gemini 2.5 Flash Lite Preview 06-17",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0.09999999999999999,
|
||||
output: 0.39999999999999997,
|
||||
cacheRead: 0.024999999999999998,
|
||||
cacheWrite: 0.18330000000000002,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
maxTokens: 65535,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"google/gemini-2.5-flash": {
|
||||
id: "google/gemini-2.5-flash",
|
||||
name: "Google: Gemini 2.5 Flash",
|
||||
|
|
@ -3649,8 +3751,8 @@ export const MODELS = {
|
|||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.39999999999999997,
|
||||
output: 1.75,
|
||||
input: 0.19999999999999998,
|
||||
output: 4.5,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
|
|
@ -3725,23 +3827,6 @@ export const MODELS = {
|
|||
contextWindow: 200000,
|
||||
maxTokens: 100000,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"meta-llama/llama-3.3-8b-instruct:free": {
|
||||
id: "meta-llama/llama-3.3-8b-instruct:free",
|
||||
name: "Meta: Llama 3.3 8B Instruct (free)",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4028,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"nousresearch/deephermes-3-mistral-24b-preview": {
|
||||
id: "nousresearch/deephermes-3-mistral-24b-preview",
|
||||
name: "Nous: DeepHermes 3 Mistral 24B Preview",
|
||||
|
|
@ -4082,23 +4167,6 @@ export const MODELS = {
|
|||
contextWindow: 131072,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"meta-llama/llama-4-maverick:free": {
|
||||
id: "meta-llama/llama-4-maverick:free",
|
||||
name: "Meta: Llama 4 Maverick (free)",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4028,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"meta-llama/llama-4-maverick": {
|
||||
id: "meta-llama/llama-4-maverick",
|
||||
name: "Meta: Llama 4 Maverick",
|
||||
|
|
@ -4116,23 +4184,6 @@ export const MODELS = {
|
|||
contextWindow: 1048576,
|
||||
maxTokens: 16384,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"meta-llama/llama-4-scout:free": {
|
||||
id: "meta-llama/llama-4-scout:free",
|
||||
name: "Meta: Llama 4 Scout (free)",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4028,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"meta-llama/llama-4-scout": {
|
||||
id: "meta-llama/llama-4-scout",
|
||||
name: "Meta: Llama 4 Scout",
|
||||
|
|
@ -4227,13 +4278,13 @@ export const MODELS = {
|
|||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0.09,
|
||||
output: 0.16,
|
||||
input: 0.07,
|
||||
output: 0.5,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 131072,
|
||||
maxTokens: 16384,
|
||||
maxTokens: 131072,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"qwen/qwq-32b": {
|
||||
id: "qwen/qwq-32b",
|
||||
|
|
@ -4779,23 +4830,6 @@ export const MODELS = {
|
|||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"mistralai/ministral-8b": {
|
||||
id: "mistralai/ministral-8b",
|
||||
name: "Mistral: Ministral 8B",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.09999999999999999,
|
||||
output: 0.09999999999999999,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 131072,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"mistralai/ministral-3b": {
|
||||
id: "mistralai/ministral-3b",
|
||||
name: "Mistral: Ministral 3B",
|
||||
|
|
@ -4813,6 +4847,23 @@ export const MODELS = {
|
|||
contextWindow: 131072,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"mistralai/ministral-8b": {
|
||||
id: "mistralai/ministral-8b",
|
||||
name: "Mistral: Ministral 8B",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.09999999999999999,
|
||||
output: 0.09999999999999999,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 131072,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"qwen/qwen-2.5-7b-instruct": {
|
||||
id: "qwen/qwen-2.5-7b-instruct",
|
||||
name: "Qwen: Qwen2.5 7B Instruct",
|
||||
|
|
@ -4839,8 +4890,8 @@ export const MODELS = {
|
|||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.6,
|
||||
output: 0.6,
|
||||
input: 1.2,
|
||||
output: 1.2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
|
|
@ -5017,22 +5068,22 @@ export const MODELS = {
|
|||
contextWindow: 128000,
|
||||
maxTokens: 16384,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"meta-llama/llama-3.1-8b-instruct": {
|
||||
id: "meta-llama/llama-3.1-8b-instruct",
|
||||
name: "Meta: Llama 3.1 8B Instruct",
|
||||
"meta-llama/llama-3.1-70b-instruct": {
|
||||
id: "meta-llama/llama-3.1-70b-instruct",
|
||||
name: "Meta: Llama 3.1 70B Instruct",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.02,
|
||||
output: 0.03,
|
||||
input: 0.39999999999999997,
|
||||
output: 0.39999999999999997,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 131072,
|
||||
maxTokens: 16384,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"meta-llama/llama-3.1-405b-instruct": {
|
||||
id: "meta-llama/llama-3.1-405b-instruct",
|
||||
|
|
@ -5051,22 +5102,22 @@ export const MODELS = {
|
|||
contextWindow: 130815,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"meta-llama/llama-3.1-70b-instruct": {
|
||||
id: "meta-llama/llama-3.1-70b-instruct",
|
||||
name: "Meta: Llama 3.1 70B Instruct",
|
||||
"meta-llama/llama-3.1-8b-instruct": {
|
||||
id: "meta-llama/llama-3.1-8b-instruct",
|
||||
name: "Meta: Llama 3.1 8B Instruct",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.39999999999999997,
|
||||
output: 0.39999999999999997,
|
||||
input: 0.02,
|
||||
output: 0.03,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 131072,
|
||||
maxTokens: 4096,
|
||||
maxTokens: 16384,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"mistralai/mistral-nemo": {
|
||||
id: "mistralai/mistral-nemo",
|
||||
|
|
@ -5085,23 +5136,6 @@ export const MODELS = {
|
|||
contextWindow: 131072,
|
||||
maxTokens: 16384,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/gpt-4o-mini-2024-07-18": {
|
||||
id: "openai/gpt-4o-mini-2024-07-18",
|
||||
name: "OpenAI: GPT-4o-mini (2024-07-18)",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0.15,
|
||||
output: 0.6,
|
||||
cacheRead: 0.075,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
maxTokens: 16384,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/gpt-4o-mini": {
|
||||
id: "openai/gpt-4o-mini",
|
||||
name: "OpenAI: GPT-4o-mini",
|
||||
|
|
@ -5119,22 +5153,22 @@ export const MODELS = {
|
|||
contextWindow: 128000,
|
||||
maxTokens: 16384,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"anthropic/claude-3.5-sonnet-20240620": {
|
||||
id: "anthropic/claude-3.5-sonnet-20240620",
|
||||
name: "Anthropic: Claude 3.5 Sonnet (2024-06-20)",
|
||||
"openai/gpt-4o-mini-2024-07-18": {
|
||||
id: "openai/gpt-4o-mini-2024-07-18",
|
||||
name: "OpenAI: GPT-4o-mini (2024-07-18)",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 3,
|
||||
output: 15,
|
||||
cacheRead: 0.3,
|
||||
cacheWrite: 3.75,
|
||||
input: 0.15,
|
||||
output: 0.6,
|
||||
cacheRead: 0.075,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
contextWindow: 128000,
|
||||
maxTokens: 16384,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"sao10k/l3-euryale-70b": {
|
||||
id: "sao10k/l3-euryale-70b",
|
||||
|
|
@ -5221,23 +5255,6 @@ export const MODELS = {
|
|||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/gpt-4o-2024-05-13": {
|
||||
id: "openai/gpt-4o-2024-05-13",
|
||||
name: "OpenAI: GPT-4o (2024-05-13)",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 15,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/gpt-4o": {
|
||||
id: "openai/gpt-4o",
|
||||
name: "OpenAI: GPT-4o",
|
||||
|
|
@ -5272,6 +5289,23 @@ export const MODELS = {
|
|||
contextWindow: 128000,
|
||||
maxTokens: 64000,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/gpt-4o-2024-05-13": {
|
||||
id: "openai/gpt-4o-2024-05-13",
|
||||
name: "OpenAI: GPT-4o (2024-05-13)",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 15,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"meta-llama/llama-3-70b-instruct": {
|
||||
id: "meta-llama/llama-3-70b-instruct",
|
||||
name: "Meta: Llama 3 70B Instruct",
|
||||
|
|
@ -5391,23 +5425,6 @@ export const MODELS = {
|
|||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/gpt-3.5-turbo-0613": {
|
||||
id: "openai/gpt-3.5-turbo-0613",
|
||||
name: "OpenAI: GPT-3.5 Turbo (older v0613)",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 4095,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/gpt-4-turbo-preview": {
|
||||
id: "openai/gpt-4-turbo-preview",
|
||||
name: "OpenAI: GPT-4 Turbo Preview",
|
||||
|
|
@ -5425,6 +5442,23 @@ export const MODELS = {
|
|||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/gpt-3.5-turbo-0613": {
|
||||
id: "openai/gpt-3.5-turbo-0613",
|
||||
name: "OpenAI: GPT-3.5 Turbo (older v0613)",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 4095,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"mistralai/mistral-small": {
|
||||
id: "mistralai/mistral-small",
|
||||
name: "Mistral Small",
|
||||
|
|
@ -5493,23 +5527,6 @@ export const MODELS = {
|
|||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"mistralai/mistral-7b-instruct-v0.1": {
|
||||
id: "mistralai/mistral-7b-instruct-v0.1",
|
||||
name: "Mistral: Mistral 7B Instruct v0.1",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.11,
|
||||
output: 0.19,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 2824,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/gpt-3.5-turbo-16k": {
|
||||
id: "openai/gpt-3.5-turbo-16k",
|
||||
name: "OpenAI: GPT-3.5 Turbo 16k",
|
||||
|
|
@ -5544,23 +5561,6 @@ export const MODELS = {
|
|||
contextWindow: 8191,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/gpt-4": {
|
||||
id: "openai/gpt-4",
|
||||
name: "OpenAI: GPT-4",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 30,
|
||||
output: 60,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 8191,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/gpt-3.5-turbo": {
|
||||
id: "openai/gpt-3.5-turbo",
|
||||
name: "OpenAI: GPT-3.5 Turbo",
|
||||
|
|
@ -5578,6 +5578,23 @@ export const MODELS = {
|
|||
contextWindow: 16385,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/gpt-4": {
|
||||
id: "openai/gpt-4",
|
||||
name: "OpenAI: GPT-4",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 30,
|
||||
output: 60,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 8191,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openrouter/auto": {
|
||||
id: "openrouter/auto",
|
||||
name: "OpenRouter: Auto Router",
|
||||
|
|
|
|||
|
|
@ -460,11 +460,20 @@ function convertMessages(messages: Message[], model: Model<"anthropic-messages">
|
|||
});
|
||||
} else if (block.type === "thinking") {
|
||||
if (block.thinking.trim().length === 0) continue;
|
||||
blocks.push({
|
||||
type: "thinking",
|
||||
thinking: sanitizeSurrogates(block.thinking),
|
||||
signature: block.thinkingSignature || "",
|
||||
});
|
||||
// If thinking signature is missing/empty (e.g., from aborted stream),
|
||||
// convert to text block to avoid API rejection
|
||||
if (!block.thinkingSignature || block.thinkingSignature.trim().length === 0) {
|
||||
blocks.push({
|
||||
type: "text",
|
||||
text: sanitizeSurrogates(`<thinking>\n${block.thinking}\n</thinking>`),
|
||||
});
|
||||
} else {
|
||||
blocks.push({
|
||||
type: "thinking",
|
||||
thinking: sanitizeSurrogates(block.thinking),
|
||||
signature: block.thinkingSignature,
|
||||
});
|
||||
}
|
||||
} else if (block.type === "toolCall") {
|
||||
blocks.push({
|
||||
type: "tool_use",
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = (
|
|||
id: toolCallId,
|
||||
name: part.functionCall.name || "",
|
||||
arguments: part.functionCall.args as Record<string, any>,
|
||||
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
|
||||
};
|
||||
|
||||
// Validate tool arguments if tool definition is available
|
||||
|
|
@ -361,13 +362,17 @@ function convertMessages(model: Model<"google-generative-ai">, context: Context)
|
|||
};
|
||||
parts.push(thinkingPart);
|
||||
} else if (block.type === "toolCall") {
|
||||
parts.push({
|
||||
const part: Part = {
|
||||
functionCall: {
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
args: block.arguments,
|
||||
},
|
||||
});
|
||||
};
|
||||
if (block.thoughtSignature) {
|
||||
part.thoughtSignature = block.thoughtSignature;
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio
|
|||
stream_options: { include_usage: true },
|
||||
};
|
||||
|
||||
// Cerebras/xAI/Mistral/Chutes dont like the "store" field
|
||||
// Cerebras/xAI/Mistral dont like the "store" field
|
||||
if (
|
||||
!model.baseUrl.includes("cerebras.ai") &&
|
||||
!model.baseUrl.includes("api.x.ai") &&
|
||||
|
|
@ -284,8 +284,8 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio
|
|||
}
|
||||
|
||||
if (options?.maxTokens) {
|
||||
// Mistral/Chutes use max_tokens instead of max_completion_tokens
|
||||
if (model.baseUrl.includes("mistral.ai") || model.baseUrl.includes("chutes.ai")) {
|
||||
// Mistral/Chutes uses max_tokens instead of max_completion_tokens
|
||||
iif (model.baseUrl.includes("mistral.ai") || model.baseUrl.includes("chutes.ai")) {
|
||||
(params as any).max_tokens = options?.maxTokens;
|
||||
} else {
|
||||
params.max_completion_tokens = options?.maxTokens;
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export interface ToolCall {
|
|||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, any>;
|
||||
thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context
|
||||
}
|
||||
|
||||
export interface Usage {
|
||||
|
|
|
|||
95
packages/ai/test/google-thought-signature.test.ts
Normal file
95
packages/ai/test/google-thought-signature.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getModel } from "../src/models.js";
|
||||
import { complete } from "../src/stream.js";
|
||||
import type { Context, Tool } from "../src/types.js";
|
||||
|
||||
// Simple read tool
|
||||
const readSchema = Type.Object({
|
||||
path: Type.String({ description: "Path to the file to read" }),
|
||||
});
|
||||
|
||||
type ReadParams = Static<typeof readSchema>;
|
||||
|
||||
const readTool: Tool = {
|
||||
name: "read",
|
||||
description: "Read contents of a file",
|
||||
parameters: readSchema,
|
||||
};
|
||||
|
||||
describe("Google Thought Signature Tests", () => {
|
||||
describe.skipIf(!process.env.GEMINI_API_KEY)("Gemini 3 Pro - Text + Tool Call", () => {
|
||||
const model = getModel("google", "gemini-3-pro-preview");
|
||||
|
||||
it("should handle text + tool call in same response and preserve thoughtSignature on subsequent requests", async () => {
|
||||
// Create a prompt that encourages the model to generate text/thoughts AND a tool call
|
||||
const context: Context = {
|
||||
systemPrompt: "You are a helpful assistant. Think through your actions before using tools.",
|
||||
messages: [],
|
||||
tools: [readTool],
|
||||
};
|
||||
|
||||
// Ask something that should trigger both explanation text and a tool call
|
||||
context.messages.push({
|
||||
role: "user",
|
||||
content:
|
||||
"I need you to read the file packages/coding-agent/CHANGELOG.md. First explain what you're going to do, then use the read tool.",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Get first response - should contain text + tool call
|
||||
const firstResponse = await complete(model, context);
|
||||
console.log("First response:", JSON.stringify(firstResponse, null, 2));
|
||||
|
||||
// Verify it has both text and tool call
|
||||
const hasText = firstResponse.content.some((b) => b.type === "text");
|
||||
const hasToolCall = firstResponse.content.some((b) => b.type === "toolCall");
|
||||
|
||||
// If model didn't generate both, skip the test (model behavior varies)
|
||||
if (!hasText || !hasToolCall) {
|
||||
console.log("Model did not generate text + tool call in same response, skipping test");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if thoughtSignature was captured
|
||||
const toolCall = firstResponse.content.find((b) => b.type === "toolCall");
|
||||
if (toolCall && toolCall.type === "toolCall") {
|
||||
console.log("Tool call thoughtSignature:", toolCall.thoughtSignature);
|
||||
}
|
||||
|
||||
context.messages.push(firstResponse);
|
||||
|
||||
// Provide tool result
|
||||
const toolCallBlock = firstResponse.content.find((b) => b.type === "toolCall");
|
||||
if (!toolCallBlock || toolCallBlock.type !== "toolCall") {
|
||||
throw new Error("Expected tool call");
|
||||
}
|
||||
|
||||
context.messages.push({
|
||||
role: "toolResult",
|
||||
toolCallId: toolCallBlock.id,
|
||||
toolName: toolCallBlock.name,
|
||||
content: [{ type: "text", text: "# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Some fix" }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Send follow-up message - this will convert the assistant message (with text + tool call)
|
||||
// back to Google's format. If thoughtSignature is missing, Google will error.
|
||||
context.messages.push({
|
||||
role: "user",
|
||||
content: "Great, now tell me what version is unreleased?",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// This is where the error would occur if thoughtSignature is not preserved
|
||||
const secondResponse = await complete(model, context);
|
||||
console.log("Second response:", JSON.stringify(secondResponse, null, 2));
|
||||
|
||||
// The request should succeed
|
||||
expect(secondResponse.stopReason).not.toBe("error");
|
||||
expect(secondResponse.errorMessage).toBeUndefined();
|
||||
expect(secondResponse.content.length).toBeGreaterThan(0);
|
||||
}, 30000);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
"""
|
||||
An advanced calculator module with comprehensive operations.
|
||||
Calculator module for mathematical operations.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
class Calculator:
|
||||
def __init__(self):
|
||||
self.result = 0
|
||||
self.history = []
|
||||
|
||||
def _record(self, operation, result):
|
||||
"""Record operation in history."""
|
||||
self.history.append(f"{operation} = {result}")
|
||||
return result
|
||||
|
||||
def add(self, a, b):
|
||||
"""Add two numbers together."""
|
||||
result = a + b
|
||||
return self._record(f"{a} + {b}", result)
|
||||
|
||||
def subtract(self, a, b):
|
||||
"""Subtract second number from first."""
|
||||
result = a - b
|
||||
return self._record(f"{a} - {b}", result)
|
||||
|
||||
def multiply(self, a, b):
|
||||
"""Multiply two numbers together."""
|
||||
result = a * b
|
||||
return self._record(f"{a} * {b}", result)
|
||||
|
||||
def divide(self, a, b):
|
||||
"""Divide a by b."""
|
||||
if b == 0:
|
||||
raise ValueError("Cannot divide by zero")
|
||||
return a / b
|
||||
|
||||
def power(self, base, exponent):
|
||||
"""Raise base to the power of exponent."""
|
||||
return base ** exponent
|
||||
|
||||
def modulo(self, a, b):
|
||||
"""Return the remainder of a divided by b."""
|
||||
if b == 0:
|
||||
raise ValueError("Cannot modulo by zero")
|
||||
return a % b
|
||||
|
||||
def square_root(self, n):
|
||||
"""Calculate the square root of n."""
|
||||
if n < 0:
|
||||
raise ValueError("Cannot calculate square root of negative number")
|
||||
return math.sqrt(n)
|
||||
|
||||
def absolute(self, n):
|
||||
"""Return the absolute value of n."""
|
||||
return abs(n)
|
||||
|
||||
def sin(self, angle_degrees):
|
||||
"""Calculate sine of angle in degrees."""
|
||||
radians = math.radians(angle_degrees)
|
||||
return math.sin(radians)
|
||||
|
||||
def cos(self, angle_degrees):
|
||||
"""Calculate cosine of angle in degrees."""
|
||||
radians = math.radians(angle_degrees)
|
||||
return math.cos(radians)
|
||||
|
||||
def factorial(self, n):
|
||||
"""Calculate factorial of n."""
|
||||
if n < 0:
|
||||
raise ValueError("Factorial not defined for negative numbers")
|
||||
return math.factorial(int(n))
|
||||
|
||||
def get_history(self):
|
||||
"""Return calculation history."""
|
||||
return self.history
|
||||
|
||||
def clear_history(self):
|
||||
"""Clear calculation history."""
|
||||
self.history = []
|
||||
|
||||
def main():
|
||||
calc = Calculator()
|
||||
|
||||
print("=" * 50)
|
||||
print("🧮 ADVANCED CALCULATOR DEMO 🧮".center(50))
|
||||
print("=" * 50)
|
||||
|
||||
# Basic operations
|
||||
print("\n📊 Basic Operations:")
|
||||
print(f" Addition: 5 + 3 = {calc.add(5, 3)}")
|
||||
print(f" Subtraction: 10 - 4 = {calc.subtract(10, 4)}")
|
||||
print(f" Multiplication: 6 * 7 = {calc.multiply(6, 7)}")
|
||||
print(f" Division: 20 / 4 = {calc.divide(20, 4)}")
|
||||
|
||||
# Advanced operations
|
||||
print("\n🚀 Advanced Operations:")
|
||||
print(f" Power: 2 ^ 8 = {calc.power(2, 8)}")
|
||||
print(f" Modulo: 17 % 5 = {calc.modulo(17, 5)}")
|
||||
print(f" Square Root: √144 = {calc.square_root(144)}")
|
||||
print(f" Absolute: |-42| = {calc.absolute(-42)}")
|
||||
|
||||
# Trigonometric and special functions
|
||||
print("\n📐 Trigonometry & Special:")
|
||||
print(f" Sin(30°): = {calc.sin(30):.4f}")
|
||||
print(f" Cos(60°): = {calc.cos(60):.4f}")
|
||||
print(f" Factorial(5): 5! = {calc.factorial(5)}")
|
||||
|
||||
# Show history
|
||||
print("\n📜 Calculation History:")
|
||||
for i, entry in enumerate(calc.get_history(), 1):
|
||||
print(f" {i}. {entry}")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -2,6 +2,126 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.25] - 2025-11-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Model Cycling**: Press `Ctrl+P` to quickly cycle through models. Use `--models` CLI argument to scope to specific models (e.g., `--models claude-sonnet,gpt-4o`). Supports pattern matching and smart version selection (prefers aliases over dated versions). ([#37](https://github.com/badlogic/pi-mono/pull/37) by [@fightbulc](https://github.com/fightbulc))
|
||||
|
||||
## [0.7.24] - 2025-11-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Thinking Level Cycling**: Press `Shift+Tab` to cycle through thinking levels (off → minimal → low → medium → high) for reasoning-capable models. Editor border color changes to indicate current level (gray → blue → cyan → magenta). ([#36](https://github.com/badlogic/pi-mono/pull/36) by [@fightbulc](https://github.com/fightbulc))
|
||||
|
||||
## [0.7.23] - 2025-11-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Update Notifications**: Interactive mode now checks for new versions on startup and displays a notification if an update is available.
|
||||
|
||||
### Changed
|
||||
|
||||
- **System Prompt**: Updated system prompt to instruct agent to output plain text summaries directly instead of using cat or bash commands to display what it did.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **File Path Completion**: Removed 10-file limit in tab completion selector. All matching files and directories now appear in the completion list.
|
||||
- **Absolute Path Completion**: Fixed tab completion for absolute paths (e.g., `/Applications`). Absolute paths in the middle of text (like "hey /") now complete correctly. Also fixed crashes when trying to stat inaccessible files (like macOS `.VolumeIcon.icns`) during directory traversal.
|
||||
|
||||
## [0.7.22] - 2025-11-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Long Line Wrapping**: Fixed crash when rendering long lines without spaces (e.g., file paths). Long words now break character-by-character to fit within terminal width.
|
||||
|
||||
## [0.7.21] - 2025-11-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Terminal Flicker**: Fixed flicker at bottom of viewport (especially editor component) in xterm.js-based terminals (VS Code, etc.) by using per-line clear instead of clear-to-end sequence.
|
||||
- **Background Color Rendering**: Fixed black cells appearing at end of wrapped lines when using background colors. Completely rewrote text wrapping and background application to properly handle ANSI reset codes.
|
||||
- **Tool Output**: Strip ANSI codes from bash/tool output before rendering to prevent conflicts with TUI styling.
|
||||
|
||||
## [0.7.20] - 2025-11-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Message Wrapping**: Fixed word-based text wrapping for long lines in chat messages. Text now properly wraps at word boundaries while preserving ANSI styling (colors, bold, italic, etc.) across wrapped lines. Background colors now extend to the full width of each line. Empty lines in messages now render correctly with full-width background.
|
||||
|
||||
## [0.7.18] - 2025-11-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bash Tool Error Handling**: Bash tool now properly throws errors for failed commands (non-zero exit codes), timeouts, and aborted executions. This ensures tool execution components display with red background when bash commands fail.
|
||||
- **Thinking Traces Styling**: Thinking traces now maintain gray italic styling throughout, even when containing inline code blocks, bold text, or other inline formatting
|
||||
|
||||
## [0.7.17] - 2025-11-18
|
||||
|
||||
### Added
|
||||
|
||||
- **New Model**: Added `gemini-3-pro-preview` to Google provider.
|
||||
- **OAuth Authentication**: Added `/login` and `/logout` commands for OAuth-based authentication with Claude Pro/Max subscriptions. Tokens are stored in `~/.pi/agent/oauth.json` with 0600 permissions and automatically refreshed when expired. OAuth tokens take priority over API keys for Anthropic models.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Anthropic Aborted Thinking**: Fixed error when resubmitting assistant messages with incomplete thinking blocks (from aborted streams). Thinking blocks without valid signatures are now converted to text blocks with `<thinking>` delimiters, preventing API rejection.
|
||||
- **Model Selector Loading**: Fixed models not appearing in `/model` selector until user started typing. Models now load asynchronously and re-render when available.
|
||||
- **Input Paste Support**: Added bracketed paste mode support to `Input` component, enabling paste of long OAuth authorization codes.
|
||||
|
||||
## [0.7.16] - 2025-11-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Tool Error Display**: Fixed edit tool (and all other tools) not showing error state correctly in TUI. Failed tool executions now properly display with red background and show the error message. Previously, the `isError` flag from tool execution events was not being passed to the UI component, causing all tool results to show with green (success) background regardless of whether they succeeded or failed.
|
||||
|
||||
## [0.7.15] - 2025-11-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Anthropic OAuth Support**: Added support for `ANTHROPIC_OAUTH_TOKEN` environment variable. The agent now checks for OAuth tokens before falling back to API keys for Anthropic models, enabling OAuth-based authentication.
|
||||
|
||||
## [0.7.14] - 2025-11-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mistral API Compatibility**: Fixed compatibility with Mistral API by excluding the `store` field and using `max_tokens` instead of `max_completion_tokens`, and avoiding the `developer` role in system prompts.
|
||||
- **Error Display**: Fixed error message display in assistant messages to include proper spacing before the error text.
|
||||
- **Message Streaming**: Fixed missing `message_start` event when no partial message chunks were received during streaming.
|
||||
|
||||
## [0.7.13] - 2025-11-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **TUI Editor**: Fixed unicode input support for umlauts (äöü), emojis (😀), and other extended characters. Previously the editor only accepted ASCII characters (32-126). Now properly handles all printable unicode while still filtering out control characters. ([#20](https://github.com/badlogic/pi-mono/pull/20))
|
||||
|
||||
## [0.7.12] - 2025-11-16
|
||||
|
||||
### Added
|
||||
|
||||
- **Custom Models and Providers**: Support for custom models and providers via `~/.pi/agent/models.json` configuration file. Add local models (Ollama, vLLM, LM Studio) or any OpenAI-compatible, Anthropic-compatible, or Google-compatible API. File is reloaded on every `/model` selector open, allowing live updates without restart. ([#21](https://github.com/badlogic/pi-mono/issues/21))
|
||||
- Added `gpt-5.1-codex` model to OpenAI provider (400k context, 128k max output, reasoning-capable).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Breaking**: No longer hardcodes Anthropic/Claude as default provider/model. Now prefers sensible defaults per provider (e.g., `claude-sonnet-4-5` for Anthropic, `gpt-5.1-codex` for OpenAI), or requires explicit selection in interactive mode.
|
||||
- Interactive mode now allows starting without a model, showing helpful error on message submission instead of failing at startup.
|
||||
- Non-interactive mode (CLI messages, JSON, RPC) still fails early if no model or API key is available.
|
||||
- Model selector now saves selected model as default in settings.json.
|
||||
- `models.json` validation errors (syntax + schema) now surface with precise file/field info in both CLI and `/model` selector.
|
||||
- Agent system prompt now includes absolute path to its own README.md for self-documentation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed crash when restoring a session with a custom model that no longer exists or lost credentials. Now gracefully falls back to default model, logs the reason, and appends a warning message to the restored chat.
|
||||
- Footer no longer crashes when no model is selected.
|
||||
|
||||
## [0.7.11] - 2025-11-16
|
||||
|
||||
### Changed
|
||||
|
||||
- The `/model` selector now filters models based on available API keys. Only models for which API keys are configured in environment variables are shown. This prevents selecting models that would fail due to missing credentials. A yellow hint is displayed at the top of the selector explaining this behavior. ([#19](https://github.com/badlogic/pi-mono/pull/19))
|
||||
|
||||
## [0.7.10] - 2025-11-14
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -4,6 +4,30 @@ A radically simple and opinionated coding agent with multi-model support (includ
|
|||
|
||||
Works on Linux, macOS, and Windows (barely tested, needs Git Bash running in the "modern" Windows Terminal).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Quick Start](#quick-start)
|
||||
- [API Keys](#api-keys)
|
||||
- [OAuth Authentication (Optional)](#oauth-authentication-optional)
|
||||
- [Custom Models and Providers](#custom-models-and-providers)
|
||||
- [Slash Commands](#slash-commands)
|
||||
- [Editor Features](#editor-features)
|
||||
- [Project Context Files](#project-context-files)
|
||||
- [Image Support](#image-support)
|
||||
- [Session Management](#session-management)
|
||||
- [CLI Options](#cli-options)
|
||||
- [Tools](#tools)
|
||||
- [Usage](#usage)
|
||||
- [Security (YOLO by default)](#security-yolo-by-default)
|
||||
- [Sub-Agents](#sub-agents)
|
||||
- [To-Dos](#to-dos)
|
||||
- [Planning](#planning)
|
||||
- [Background Bash](#background-bash)
|
||||
- [Planned Features](#planned-features)
|
||||
- [License](#license)
|
||||
- [See Also](#see-also)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
|
|
@ -62,6 +86,166 @@ export ZAI_API_KEY=...
|
|||
|
||||
If no API key is set, the CLI will prompt you to configure one on first run.
|
||||
|
||||
**Note:** The `/model` command only shows models for which API keys are configured in your environment. If you don't see a model you expect, check that you've set the corresponding environment variable.
|
||||
|
||||
## OAuth Authentication (Optional)
|
||||
|
||||
If you have a Claude Pro/Max subscription, you can use OAuth instead of API keys:
|
||||
|
||||
```bash
|
||||
pi
|
||||
# In the interactive session:
|
||||
/login
|
||||
# Select "Anthropic (Claude Pro/Max)"
|
||||
# Authorize in browser
|
||||
# Paste authorization code
|
||||
```
|
||||
|
||||
This gives you:
|
||||
- Free access to Claude models (included in your subscription)
|
||||
- No need to manage API keys
|
||||
- Automatic token refresh
|
||||
|
||||
To logout:
|
||||
```
|
||||
/logout
|
||||
```
|
||||
|
||||
**Note:** OAuth tokens are stored in `~/.pi/agent/oauth.json` with restricted permissions (0600).
|
||||
|
||||
## Custom Models and Providers
|
||||
|
||||
You can add custom models and providers (like Ollama, vLLM, LM Studio, or any custom API endpoint) via `~/.pi/agent/models.json`. Supports OpenAI-compatible APIs (`openai-completions`, `openai-responses`), Anthropic Messages API (`anthropic-messages`), and Google Generative AI API (`google-generative-ai`). This file is loaded fresh every time you open the `/model` selector, allowing live updates without restarting.
|
||||
|
||||
### Configuration File Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"baseUrl": "http://localhost:11434/v1",
|
||||
"apiKey": "OLLAMA_API_KEY",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
"id": "llama-3.1-8b",
|
||||
"name": "Llama 3.1 8B (Local)",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
|
||||
"contextWindow": 128000,
|
||||
"maxTokens": 32000
|
||||
}
|
||||
]
|
||||
},
|
||||
"vllm": {
|
||||
"baseUrl": "http://your-server:8000/v1",
|
||||
"apiKey": "VLLM_API_KEY",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
"id": "custom-model",
|
||||
"name": "Custom Fine-tuned Model",
|
||||
"reasoning": false,
|
||||
"input": ["text", "image"],
|
||||
"cost": {"input": 0.5, "output": 1.0, "cacheRead": 0, "cacheWrite": 0},
|
||||
"contextWindow": 32768,
|
||||
"maxTokens": 8192
|
||||
}
|
||||
]
|
||||
},
|
||||
"mixed-api-provider": {
|
||||
"baseUrl": "https://api.example.com/v1",
|
||||
"apiKey": "CUSTOM_API_KEY",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
"id": "legacy-model",
|
||||
"name": "Legacy Model",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"cost": {"input": 1.0, "output": 2.0, "cacheRead": 0, "cacheWrite": 0},
|
||||
"contextWindow": 8192,
|
||||
"maxTokens": 4096
|
||||
},
|
||||
{
|
||||
"id": "new-model",
|
||||
"name": "New Model",
|
||||
"api": "openai-responses",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"cost": {"input": 0.5, "output": 1.0, "cacheRead": 0.1, "cacheWrite": 0.2},
|
||||
"contextWindow": 128000,
|
||||
"maxTokens": 32000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Key Resolution
|
||||
|
||||
The `apiKey` field can be either an environment variable name or a literal API key:
|
||||
|
||||
1. First, `pi` checks if an environment variable with that name exists
|
||||
2. If found, uses the environment variable's value
|
||||
3. Otherwise, treats it as a literal API key
|
||||
|
||||
Examples:
|
||||
- `"apiKey": "OLLAMA_API_KEY"` → checks `$OLLAMA_API_KEY`, then treats as literal "OLLAMA_API_KEY"
|
||||
- `"apiKey": "sk-1234..."` → checks `$sk-1234...` (unlikely to exist), then uses literal value
|
||||
|
||||
This allows both secure env var usage and literal keys for local servers.
|
||||
|
||||
### API Override
|
||||
|
||||
- **Provider-level `api`**: Sets the default API for all models in that provider
|
||||
- **Model-level `api`**: Overrides the provider default for specific models
|
||||
- Supported APIs: `openai-completions`, `openai-responses`, `anthropic-messages`, `google-generative-ai`
|
||||
|
||||
This is useful when a provider supports multiple API standards through the same base URL.
|
||||
|
||||
### Model Selection Priority
|
||||
|
||||
When starting `pi`, models are selected in this order:
|
||||
|
||||
1. **CLI args**: `--provider` and `--model` flags
|
||||
2. **Restored from session**: If using `--continue` or `--resume`
|
||||
3. **Saved default**: From `~/.pi/agent/settings.json` (set when you select a model with `/model`)
|
||||
4. **First available**: First model with a valid API key
|
||||
5. **None**: Allowed in interactive mode (shows error on message submission)
|
||||
|
||||
### Provider Defaults
|
||||
|
||||
When multiple providers are available, pi prefers sensible defaults before falling back to "first available":
|
||||
|
||||
| Provider | Default Model |
|
||||
|------------|--------------------------|
|
||||
| anthropic | claude-sonnet-4-5 |
|
||||
| openai | gpt-5.1-codex |
|
||||
| google | gemini-2.5-pro |
|
||||
| openrouter | openai/gpt-5.1-codex |
|
||||
| xai | grok-4-fast-non-reasoning|
|
||||
| groq | openai/gpt-oss-120b |
|
||||
| cerebras | zai-glm-4.6 |
|
||||
| zai | glm-4.6 |
|
||||
|
||||
### Live Reload & Errors
|
||||
|
||||
The models.json file is reloaded every time you open the `/model` selector. This means:
|
||||
|
||||
- Edit models.json during a session
|
||||
- Or have the agent write/update it for you
|
||||
- Use `/model` to see changes immediately
|
||||
- No restart needed!
|
||||
|
||||
If the file contains errors (JSON syntax, schema violations, missing fields), the selector shows the exact validation error and file path in red so you can fix it immediately.
|
||||
|
||||
### Example: Adding Ollama Models
|
||||
|
||||
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.
|
||||
|
||||
## Slash Commands
|
||||
|
||||
The CLI supports several commands to control its behavior:
|
||||
|
|
@ -70,6 +254,8 @@ The CLI supports several commands to control its behavior:
|
|||
|
||||
Switch models mid-session. Opens an interactive selector where you can type to search (by provider or model name), use arrow keys to navigate, Enter to select, or Escape to cancel.
|
||||
|
||||
The selector only displays models for which API keys are configured in your environment (see API Keys section).
|
||||
|
||||
### /thinking
|
||||
|
||||
Adjust thinking/reasoning level for supported models (Claude Sonnet 4, GPT-5, Gemini 2.5). Opens an interactive selector where you can use arrow keys to navigate, Enter to select, or Escape to cancel.
|
||||
|
|
@ -119,6 +305,26 @@ This allows you to explore alternative conversation paths without losing your cu
|
|||
/branch
|
||||
```
|
||||
|
||||
### /login
|
||||
|
||||
Login with OAuth to use subscription-based models (Claude Pro/Max):
|
||||
|
||||
```
|
||||
/login
|
||||
```
|
||||
|
||||
Opens an interactive selector to choose provider, then guides you through the OAuth flow in your browser.
|
||||
|
||||
### /logout
|
||||
|
||||
Logout from OAuth providers:
|
||||
|
||||
```
|
||||
/logout
|
||||
```
|
||||
|
||||
Shows a list of logged-in providers to logout from.
|
||||
|
||||
## Editor Features
|
||||
|
||||
The interactive input editor includes several productivity features:
|
||||
|
|
@ -150,11 +356,16 @@ Paste multiple lines of text (e.g., code snippets, logs) and they'll be automati
|
|||
- **Ctrl+K**: Delete to end of line (at line end: merge with next line)
|
||||
- **Ctrl+C**: Clear editor (first press) / Exit pi (second press)
|
||||
- **Tab**: Path completion
|
||||
- **Shift+Tab**: Cycle thinking level (for reasoning-capable models)
|
||||
- **Ctrl+P**: Cycle models (use `--models` to scope)
|
||||
- **Enter**: Send message
|
||||
- **Shift+Enter**: Insert new line (multi-line input)
|
||||
- **Arrow keys**: Move cursor
|
||||
- **Backspace**: Delete character backwards
|
||||
- **Delete** (or **Fn+Backspace**): Delete character forwards
|
||||
- **Arrow keys**: Move cursor (Up/Down/Left/Right)
|
||||
- **Ctrl+A** / **Home** / **Cmd+Left** (macOS): Jump to start of line
|
||||
- **Ctrl+E** / **End** / **Cmd+Right** (macOS): Jump to end of line
|
||||
- **Escape**: Cancel autocomplete (when autocomplete is active)
|
||||
|
||||
## Project Context Files
|
||||
|
||||
|
|
@ -269,10 +480,10 @@ pi [options] [messages...]
|
|||
### Options
|
||||
|
||||
**--provider <name>**
|
||||
Provider name. Available: `anthropic`, `openai`, `google`, `xai`, `groq`, `cerebras`, `openrouter`, `zai`. Default: `anthropic`
|
||||
Provider name. Available: `anthropic`, `openai`, `google`, `xai`, `groq`, `cerebras`, `openrouter`, `zai`, plus any custom providers defined in `~/.pi/agent/models.json`.
|
||||
|
||||
**--model <id>**
|
||||
Model ID. Default: `claude-sonnet-4-5`
|
||||
Model ID. If not specified, uses: (1) saved default from settings, (2) first available model with valid API key, or (3) none (interactive mode only).
|
||||
|
||||
**--api-key <key>**
|
||||
API key (overrides environment variables)
|
||||
|
|
@ -302,6 +513,14 @@ Continue the most recent session
|
|||
**--resume, -r**
|
||||
Select a session to resume (opens interactive selector)
|
||||
|
||||
**--models <patterns>**
|
||||
Comma-separated model patterns for quick cycling with `Ctrl+P`. Patterns match against model IDs and names (case-insensitive). When multiple versions exist, prefers aliases over dated versions (e.g., `claude-sonnet-4-5` over `claude-sonnet-4-5-20250929`). Without this flag, `Ctrl+P` cycles through all available models.
|
||||
|
||||
Examples:
|
||||
- `--models claude-sonnet,gpt-4o` - Scope to Claude Sonnet and GPT-4o
|
||||
- `--models sonnet,haiku` - Match any model containing "sonnet" or "haiku"
|
||||
- `--models gemini` - All Gemini models
|
||||
|
||||
**--help, -h**
|
||||
Show help message
|
||||
|
||||
|
|
@ -328,6 +547,10 @@ pi -c "What did we discuss?"
|
|||
|
||||
# Use different model
|
||||
pi --provider openai --model gpt-4o "Help me refactor this code"
|
||||
|
||||
# Limit model cycling to specific models
|
||||
pi --models claude-sonnet,claude-haiku,gpt-4o
|
||||
# Now Ctrl+P cycles only through those models
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
|
@ -491,7 +714,6 @@ The agent can read, update, and reference the plan as it works. Unlike ephemeral
|
|||
|
||||
Things that might happen eventually:
|
||||
|
||||
- **Custom/local models**: Support for Ollama, llama.cpp, vLLM, SGLang, LM Studio via JSON config file
|
||||
- **Auto-compaction**: Currently, watch the context percentage at the bottom. When it approaches 80%, either:
|
||||
- Ask the agent to write a summary .md file you can load in a new session
|
||||
- Switch to a model with bigger context (e.g., Gemini) using `/model` and either continue with that model, or let it summarize the session to a .md file to be loaded in a new session
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
# Color Usage Inventory
|
||||
|
||||
## Complete list of all semantic color uses in the codebase
|
||||
|
||||
### UI Chrome & Structure
|
||||
- **border** - cyan - Borders around sections (changelog, selectors)
|
||||
- **borderSubtle** - blue - Borders in selectors (model, session, thinking)
|
||||
- **borderHorizontal** - gray - Horizontal separator in editor
|
||||
|
||||
### Text Hierarchy
|
||||
- **textPrimary** - default/none - Main content text
|
||||
- **textSecondary** - gray - Metadata, timestamps, descriptions
|
||||
- **textDim** - dim - De-emphasized content, placeholder text, "..." indicators
|
||||
- **textBold** - bold - Emphasis (note: this is styling, not color)
|
||||
|
||||
### Interactive/Selection
|
||||
- **selectionCursor** - blue - "›" cursor in selection lists
|
||||
- **selectionText** - bold+blue - Selected item text in session selector
|
||||
- **selectionInfo** - gray - Scroll info "(1/10)" in selectors
|
||||
- **checkmark** - green - "✓" checkmark for current model
|
||||
- **providerBadge** - gray - "[anthropic]" provider labels
|
||||
|
||||
### Feedback/Status
|
||||
- **error** - red - Error messages
|
||||
- **errorAborted** - red - "Aborted" message
|
||||
- **success** - green - Success messages (stdout)
|
||||
- **warning** - yellow - Warning messages
|
||||
- **info** - cyan - Info messages
|
||||
|
||||
### Tool Execution
|
||||
- **toolCommand** - bold - "$ command" in tool execution
|
||||
- **toolPath** - cyan - File paths in read tool
|
||||
- **stdout** - green - Standard output lines
|
||||
- **stderr** - red - Standard error lines
|
||||
- **stdoutDim** - dim - Truncated stdout lines
|
||||
- **stderrDim** - dim - Truncated stderr lines
|
||||
|
||||
### Footer/Stats
|
||||
- **footerText** - gray - All footer content (pwd and stats)
|
||||
|
||||
### Logo/Branding
|
||||
- **logoBrand** - bold+cyan - "pi" logo text
|
||||
- **logoVersion** - dim - Version number
|
||||
- **instructionsKey** - dim - Keyboard shortcut keys (esc, ctrl+c, etc.)
|
||||
- **instructionsText** - gray - Instruction text ("to interrupt", etc.)
|
||||
|
||||
### Markdown - Headings
|
||||
- **markdownH1** - bold+underline+yellow - Level 1 headings
|
||||
- **markdownH2** - bold+yellow - Level 2 headings
|
||||
- **markdownH3** - bold - Level 3+ headings (uses bold modifier only)
|
||||
|
||||
### Markdown - Emphasis
|
||||
- **markdownBold** - bold - **bold** text
|
||||
- **markdownItalic** - italic - *italic* text (also used for thinking text)
|
||||
- **markdownStrikethrough** - strikethrough - ~~strikethrough~~ text
|
||||
|
||||
### Markdown - Code
|
||||
- **markdownCodeBlock** - green - Code block content
|
||||
- **markdownCodeBlockIndent** - dim - " " indent before code
|
||||
- **markdownCodeDelimiter** - gray - "```" delimiters
|
||||
- **markdownInlineCode** - cyan - `inline code` content
|
||||
- **markdownInlineCodeDelimiter** - gray - "`" backticks
|
||||
|
||||
### Markdown - Links
|
||||
- **markdownLinkText** - underline+blue - Link text
|
||||
- **markdownLinkUrl** - gray - " (url)" when text != url
|
||||
|
||||
### Markdown - Lists
|
||||
- **markdownListBullet** - cyan - "- " or "1. " bullets
|
||||
|
||||
### Markdown - Quotes
|
||||
- **markdownQuoteText** - italic - Quoted text
|
||||
- **markdownQuoteBorder** - gray - "│ " quote border
|
||||
|
||||
### Markdown - Other
|
||||
- **markdownHr** - gray - "─────" horizontal rules
|
||||
- **markdownTableHeader** - bold - Table header cells
|
||||
|
||||
### Loader/Spinner
|
||||
- **spinnerFrame** - cyan - Spinner animation frame
|
||||
- **spinnerMessage** - dim - Loading message text
|
||||
|
||||
## Summary Statistics
|
||||
|
||||
**Total semantic color uses: ~45**
|
||||
|
||||
### By Color
|
||||
- gray: 15 uses (metadata, borders, delimiters, dim text)
|
||||
- cyan: 9 uses (brand, borders, code, bullets)
|
||||
- blue: 6 uses (selection, links, borders)
|
||||
- red: 5 uses (errors, stderr)
|
||||
- green: 4 uses (success, stdout, code blocks)
|
||||
- yellow: 3 uses (headings, warnings)
|
||||
- bold: 8 uses (emphasis, headings, commands)
|
||||
- dim: 8 uses (de-emphasis, placeholders)
|
||||
- italic: 3 uses (quotes, thinking, emphasis)
|
||||
- underline: 2 uses (headings, links)
|
||||
|
||||
### By Category
|
||||
- Markdown: 18 colors
|
||||
- UI Chrome/Structure: 3 colors
|
||||
- Text Hierarchy: 4 colors
|
||||
- Interactive: 5 colors
|
||||
- Feedback: 4 colors
|
||||
- Tool Execution: 7 colors
|
||||
- Footer: 1 color
|
||||
- Logo/Instructions: 4 colors
|
||||
- Loader: 2 colors
|
||||
|
||||
## Recommendation
|
||||
|
||||
We need approximately **35-40 distinct color values** for a complete theme, organized by semantic purpose. Some will be the same color (e.g., multiple uses of "gray"), but they should have separate semantic names so they can be customized independently.
|
||||
|
|
@ -1,938 +0,0 @@
|
|||
# Design Tokens System
|
||||
|
||||
## Overview
|
||||
|
||||
A minimal design tokens system for terminal UI theming. Uses a two-layer approach:
|
||||
1. **Primitive tokens** - Raw color values
|
||||
2. **Semantic tokens** - Purpose-based mappings that reference primitives
|
||||
|
||||
## Architecture
|
||||
|
||||
### Primitive Tokens (Colors)
|
||||
|
||||
These are the raw chalk color functions - the "palette":
|
||||
|
||||
```typescript
|
||||
interface ColorPrimitives {
|
||||
// Grays
|
||||
gray50: ChalkFunction; // Lightest gray
|
||||
gray100: ChalkFunction;
|
||||
gray200: ChalkFunction;
|
||||
gray300: ChalkFunction;
|
||||
gray400: ChalkFunction;
|
||||
gray500: ChalkFunction; // Mid gray
|
||||
gray600: ChalkFunction;
|
||||
gray700: ChalkFunction;
|
||||
gray800: ChalkFunction;
|
||||
gray900: ChalkFunction; // Darkest gray
|
||||
|
||||
// Colors
|
||||
blue: ChalkFunction;
|
||||
cyan: ChalkFunction;
|
||||
green: ChalkFunction;
|
||||
yellow: ChalkFunction;
|
||||
red: ChalkFunction;
|
||||
magenta: ChalkFunction;
|
||||
|
||||
// Modifiers
|
||||
bold: ChalkFunction;
|
||||
dim: ChalkFunction;
|
||||
italic: ChalkFunction;
|
||||
underline: ChalkFunction;
|
||||
strikethrough: ChalkFunction;
|
||||
|
||||
// Special
|
||||
none: ChalkFunction; // Pass-through, no styling
|
||||
}
|
||||
|
||||
type ChalkFunction = (str: string) => string;
|
||||
```
|
||||
|
||||
### Semantic Tokens (Design Decisions)
|
||||
|
||||
These map primitives to purposes:
|
||||
|
||||
```typescript
|
||||
interface SemanticTokens {
|
||||
// Text hierarchy
|
||||
text: {
|
||||
primary: ChalkFunction; // Main content text
|
||||
secondary: ChalkFunction; // Supporting text
|
||||
tertiary: ChalkFunction; // De-emphasized text
|
||||
disabled: ChalkFunction; // Inactive/disabled text
|
||||
};
|
||||
|
||||
// Interactive elements
|
||||
interactive: {
|
||||
default: ChalkFunction; // Default interactive elements
|
||||
hover: ChalkFunction; // Hovered/selected state
|
||||
active: ChalkFunction; // Active/current state
|
||||
};
|
||||
|
||||
// Feedback
|
||||
feedback: {
|
||||
error: ChalkFunction;
|
||||
warning: ChalkFunction;
|
||||
success: ChalkFunction;
|
||||
info: ChalkFunction;
|
||||
};
|
||||
|
||||
// Borders & dividers
|
||||
border: {
|
||||
default: ChalkFunction;
|
||||
subtle: ChalkFunction;
|
||||
emphasis: ChalkFunction;
|
||||
};
|
||||
|
||||
// Code
|
||||
code: {
|
||||
text: ChalkFunction;
|
||||
keyword: ChalkFunction;
|
||||
string: ChalkFunction;
|
||||
comment: ChalkFunction;
|
||||
delimiter: ChalkFunction;
|
||||
};
|
||||
|
||||
// Markdown specific
|
||||
markdown: {
|
||||
heading: {
|
||||
h1: ChalkFunction;
|
||||
h2: ChalkFunction;
|
||||
h3: ChalkFunction;
|
||||
};
|
||||
emphasis: {
|
||||
bold: ChalkFunction;
|
||||
italic: ChalkFunction;
|
||||
strikethrough: ChalkFunction;
|
||||
};
|
||||
link: {
|
||||
text: ChalkFunction;
|
||||
url: ChalkFunction;
|
||||
};
|
||||
quote: {
|
||||
text: ChalkFunction;
|
||||
border: ChalkFunction;
|
||||
};
|
||||
list: {
|
||||
bullet: ChalkFunction;
|
||||
};
|
||||
code: {
|
||||
inline: ChalkFunction;
|
||||
inlineDelimiter: ChalkFunction;
|
||||
block: ChalkFunction;
|
||||
blockDelimiter: ChalkFunction;
|
||||
};
|
||||
};
|
||||
|
||||
// Output streams
|
||||
output: {
|
||||
stdout: ChalkFunction;
|
||||
stderr: ChalkFunction;
|
||||
neutral: ChalkFunction;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Theme Structure
|
||||
|
||||
A theme combines primitives with semantic mappings:
|
||||
|
||||
```typescript
|
||||
interface Theme {
|
||||
name: string;
|
||||
primitives: ColorPrimitives;
|
||||
tokens: SemanticTokens;
|
||||
}
|
||||
```
|
||||
|
||||
## Built-in Themes
|
||||
|
||||
### Dark Theme
|
||||
|
||||
```typescript
|
||||
const darkPrimitives: ColorPrimitives = {
|
||||
// Grays - for dark backgrounds, lighter = more prominent
|
||||
gray50: chalk.white,
|
||||
gray100: (s) => s, // No color = terminal default
|
||||
gray200: chalk.white,
|
||||
gray300: (s) => s,
|
||||
gray400: chalk.gray,
|
||||
gray500: chalk.gray,
|
||||
gray600: chalk.gray,
|
||||
gray700: chalk.dim,
|
||||
gray800: chalk.dim,
|
||||
gray900: chalk.black,
|
||||
|
||||
// Colors
|
||||
blue: chalk.blue,
|
||||
cyan: chalk.cyan,
|
||||
green: chalk.green,
|
||||
yellow: chalk.yellow,
|
||||
red: chalk.red,
|
||||
magenta: chalk.magenta,
|
||||
|
||||
// Modifiers
|
||||
bold: chalk.bold,
|
||||
dim: chalk.dim,
|
||||
italic: chalk.italic,
|
||||
underline: chalk.underline,
|
||||
strikethrough: chalk.strikethrough,
|
||||
|
||||
// Special
|
||||
none: (s) => s,
|
||||
};
|
||||
|
||||
const darkTheme: Theme = {
|
||||
name: "dark",
|
||||
primitives: darkPrimitives,
|
||||
tokens: {
|
||||
text: {
|
||||
primary: darkPrimitives.gray100,
|
||||
secondary: darkPrimitives.gray400,
|
||||
tertiary: darkPrimitives.gray700,
|
||||
disabled: darkPrimitives.dim,
|
||||
},
|
||||
|
||||
interactive: {
|
||||
default: darkPrimitives.blue,
|
||||
hover: darkPrimitives.blue,
|
||||
active: (s) => darkPrimitives.bold(darkPrimitives.blue(s)),
|
||||
},
|
||||
|
||||
feedback: {
|
||||
error: darkPrimitives.red,
|
||||
warning: darkPrimitives.yellow,
|
||||
success: darkPrimitives.green,
|
||||
info: darkPrimitives.cyan,
|
||||
},
|
||||
|
||||
border: {
|
||||
default: darkPrimitives.blue,
|
||||
subtle: darkPrimitives.gray600,
|
||||
emphasis: darkPrimitives.cyan,
|
||||
},
|
||||
|
||||
code: {
|
||||
text: darkPrimitives.green,
|
||||
keyword: darkPrimitives.cyan,
|
||||
string: darkPrimitives.green,
|
||||
comment: darkPrimitives.gray600,
|
||||
delimiter: darkPrimitives.gray600,
|
||||
},
|
||||
|
||||
markdown: {
|
||||
heading: {
|
||||
h1: (s) => darkPrimitives.underline(darkPrimitives.bold(darkPrimitives.yellow(s))),
|
||||
h2: (s) => darkPrimitives.bold(darkPrimitives.yellow(s)),
|
||||
h3: darkPrimitives.bold,
|
||||
},
|
||||
emphasis: {
|
||||
bold: darkPrimitives.bold,
|
||||
italic: darkPrimitives.italic,
|
||||
strikethrough: darkPrimitives.strikethrough,
|
||||
},
|
||||
link: {
|
||||
text: (s) => darkPrimitives.underline(darkPrimitives.blue(s)),
|
||||
url: darkPrimitives.gray600,
|
||||
},
|
||||
quote: {
|
||||
text: darkPrimitives.italic,
|
||||
border: darkPrimitives.gray600,
|
||||
},
|
||||
list: {
|
||||
bullet: darkPrimitives.cyan,
|
||||
},
|
||||
code: {
|
||||
inline: darkPrimitives.cyan,
|
||||
inlineDelimiter: darkPrimitives.gray600,
|
||||
block: darkPrimitives.green,
|
||||
blockDelimiter: darkPrimitives.gray600,
|
||||
},
|
||||
},
|
||||
|
||||
output: {
|
||||
stdout: darkPrimitives.green,
|
||||
stderr: darkPrimitives.red,
|
||||
neutral: darkPrimitives.gray600,
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Light Theme
|
||||
|
||||
```typescript
|
||||
const lightPrimitives: ColorPrimitives = {
|
||||
// Grays - for light backgrounds, darker = more prominent
|
||||
gray50: chalk.black,
|
||||
gray100: (s) => s, // No color = terminal default
|
||||
gray200: chalk.black,
|
||||
gray300: (s) => s,
|
||||
gray400: chalk.gray, // Use actual gray, not dim
|
||||
gray500: chalk.gray,
|
||||
gray600: chalk.gray,
|
||||
gray700: chalk.gray,
|
||||
gray800: chalk.gray,
|
||||
gray900: chalk.white,
|
||||
|
||||
// Colors - use bold variants for better visibility on light bg
|
||||
blue: (s) => chalk.bold(chalk.blue(s)),
|
||||
cyan: (s) => chalk.bold(chalk.cyan(s)),
|
||||
green: (s) => chalk.bold(chalk.green(s)),
|
||||
yellow: (s) => chalk.bold(chalk.yellow(s)),
|
||||
red: (s) => chalk.bold(chalk.red(s)),
|
||||
magenta: (s) => chalk.bold(chalk.magenta(s)),
|
||||
|
||||
// Modifiers
|
||||
bold: chalk.bold,
|
||||
dim: chalk.gray, // Don't use chalk.dim on light bg!
|
||||
italic: chalk.italic,
|
||||
underline: chalk.underline,
|
||||
strikethrough: chalk.strikethrough,
|
||||
|
||||
// Special
|
||||
none: (s) => s,
|
||||
};
|
||||
|
||||
const lightTheme: Theme = {
|
||||
name: "light",
|
||||
primitives: lightPrimitives,
|
||||
tokens: {
|
||||
text: {
|
||||
primary: lightPrimitives.gray100,
|
||||
secondary: lightPrimitives.gray400,
|
||||
tertiary: lightPrimitives.gray600,
|
||||
disabled: lightPrimitives.dim,
|
||||
},
|
||||
|
||||
interactive: {
|
||||
default: lightPrimitives.blue,
|
||||
hover: lightPrimitives.blue,
|
||||
active: (s) => lightPrimitives.bold(lightPrimitives.blue(s)),
|
||||
},
|
||||
|
||||
feedback: {
|
||||
error: lightPrimitives.red,
|
||||
warning: (s) => chalk.bold(chalk.yellow(s)), // Yellow needs extra bold
|
||||
success: lightPrimitives.green,
|
||||
info: lightPrimitives.cyan,
|
||||
},
|
||||
|
||||
border: {
|
||||
default: lightPrimitives.blue,
|
||||
subtle: lightPrimitives.gray400,
|
||||
emphasis: lightPrimitives.cyan,
|
||||
},
|
||||
|
||||
code: {
|
||||
text: lightPrimitives.green,
|
||||
keyword: lightPrimitives.cyan,
|
||||
string: lightPrimitives.green,
|
||||
comment: lightPrimitives.gray600,
|
||||
delimiter: lightPrimitives.gray600,
|
||||
},
|
||||
|
||||
markdown: {
|
||||
heading: {
|
||||
h1: (s) => lightPrimitives.underline(lightPrimitives.bold(lightPrimitives.blue(s))),
|
||||
h2: (s) => lightPrimitives.bold(lightPrimitives.blue(s)),
|
||||
h3: lightPrimitives.bold,
|
||||
},
|
||||
emphasis: {
|
||||
bold: lightPrimitives.bold,
|
||||
italic: lightPrimitives.italic,
|
||||
strikethrough: lightPrimitives.strikethrough,
|
||||
},
|
||||
link: {
|
||||
text: (s) => lightPrimitives.underline(lightPrimitives.blue(s)),
|
||||
url: lightPrimitives.blue,
|
||||
},
|
||||
quote: {
|
||||
text: lightPrimitives.italic,
|
||||
border: lightPrimitives.gray600,
|
||||
},
|
||||
list: {
|
||||
bullet: lightPrimitives.blue,
|
||||
},
|
||||
code: {
|
||||
inline: lightPrimitives.blue,
|
||||
inlineDelimiter: lightPrimitives.gray600,
|
||||
block: lightPrimitives.green,
|
||||
blockDelimiter: lightPrimitives.gray600,
|
||||
},
|
||||
},
|
||||
|
||||
output: {
|
||||
stdout: lightPrimitives.green,
|
||||
stderr: lightPrimitives.red,
|
||||
neutral: lightPrimitives.gray600,
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Simple Text Styling
|
||||
|
||||
```typescript
|
||||
const theme = getTheme();
|
||||
|
||||
// Before
|
||||
console.log(chalk.gray("Secondary text"));
|
||||
|
||||
// After
|
||||
console.log(theme.tokens.text.secondary("Secondary text"));
|
||||
```
|
||||
|
||||
### Interactive Elements
|
||||
|
||||
```typescript
|
||||
const theme = getTheme();
|
||||
|
||||
// Before
|
||||
const cursor = chalk.blue("› ");
|
||||
|
||||
// After
|
||||
const cursor = theme.tokens.interactive.default("› ");
|
||||
```
|
||||
|
||||
### Error Messages
|
||||
|
||||
```typescript
|
||||
const theme = getTheme();
|
||||
|
||||
// Before
|
||||
this.contentContainer.addChild(new Text(chalk.red("Error: " + errorMsg)));
|
||||
|
||||
// After
|
||||
this.contentContainer.addChild(new Text(theme.tokens.feedback.error("Error: " + errorMsg)));
|
||||
```
|
||||
|
||||
### Markdown Headings
|
||||
|
||||
```typescript
|
||||
const theme = getTheme();
|
||||
|
||||
// Before
|
||||
lines.push(chalk.bold.yellow(headingText));
|
||||
|
||||
// After
|
||||
lines.push(theme.tokens.markdown.heading.h2(headingText));
|
||||
```
|
||||
|
||||
### Borders
|
||||
|
||||
```typescript
|
||||
const theme = getTheme();
|
||||
|
||||
// Before
|
||||
this.addChild(new Text(chalk.blue("─".repeat(80))));
|
||||
|
||||
// After
|
||||
this.addChild(new Text(theme.tokens.border.default("─".repeat(80))));
|
||||
```
|
||||
|
||||
## User Configuration
|
||||
|
||||
### Theme File Format
|
||||
|
||||
Themes can be defined in JSON files that users can customize. The system will load themes from:
|
||||
1. Built-in themes (dark, light) - hardcoded in the app
|
||||
2. User themes in `~/.pi/agent/themes/` directory
|
||||
|
||||
**Example: `~/.pi/agent/themes/my-theme.json`**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-theme",
|
||||
"extends": "dark",
|
||||
"primitives": {
|
||||
"blue": "blueBright",
|
||||
"cyan": "cyanBright",
|
||||
"green": "greenBright"
|
||||
},
|
||||
"tokens": {
|
||||
"text": {
|
||||
"primary": "white"
|
||||
},
|
||||
"interactive": {
|
||||
"default": ["bold", "blue"]
|
||||
},
|
||||
"markdown": {
|
||||
"heading": {
|
||||
"h1": ["bold", "underline", "magenta"],
|
||||
"h2": ["bold", "magenta"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### JSON Schema
|
||||
|
||||
Themes in JSON can reference:
|
||||
1. **Chalk color names**: `"red"`, `"blue"`, `"gray"`, `"white"`, `"black"`, etc.
|
||||
2. **Chalk bright colors**: `"redBright"`, `"blueBright"`, etc.
|
||||
3. **Chalk modifiers**: `"bold"`, `"dim"`, `"italic"`, `"underline"`, `"strikethrough"`
|
||||
4. **Combinations**: `["bold", "blue"]` or `["underline", "bold", "cyan"]`
|
||||
5. **Primitive references**: `"$gray400"` to reference another primitive
|
||||
6. **None/passthrough**: `"none"` or `""` for no styling
|
||||
|
||||
### Supported Chalk Values
|
||||
|
||||
```typescript
|
||||
type ChalkColorName =
|
||||
// Basic colors
|
||||
| "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan" | "white" | "gray"
|
||||
// Bright variants
|
||||
| "blackBright" | "redBright" | "greenBright" | "yellowBright"
|
||||
| "blueBright" | "magentaBright" | "cyanBright" | "whiteBright"
|
||||
// Modifiers
|
||||
| "bold" | "dim" | "italic" | "underline" | "strikethrough" | "inverse"
|
||||
// Special
|
||||
| "none";
|
||||
|
||||
type ChalkValue = ChalkColorName | ChalkColorName[] | string; // string allows "$primitive" refs
|
||||
```
|
||||
|
||||
### Theme Extension
|
||||
|
||||
Themes can extend other themes using `"extends": "dark"` or `"extends": "light"`. Only the overridden values need to be specified.
|
||||
|
||||
**Example: Minimal override**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "solarized-dark",
|
||||
"extends": "dark",
|
||||
"tokens": {
|
||||
"feedback": {
|
||||
"error": "magenta",
|
||||
"warning": "yellow"
|
||||
},
|
||||
"markdown": {
|
||||
"heading": {
|
||||
"h1": ["bold", "cyan"],
|
||||
"h2": ["bold", "blue"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Loading Order
|
||||
|
||||
1. Load built-in themes (dark, light)
|
||||
2. Scan `~/.pi/agent/themes/*.json`
|
||||
3. Parse and validate each JSON theme
|
||||
4. Build theme by:
|
||||
- Start with base theme (if extends specified)
|
||||
- Apply primitive overrides
|
||||
- Apply token overrides
|
||||
- Convert JSON values to chalk functions
|
||||
|
||||
## Implementation
|
||||
|
||||
### Theme Module Structure
|
||||
|
||||
**Location:** `packages/tui/src/theme/`
|
||||
|
||||
```
|
||||
theme/
|
||||
├── index.ts # Public API
|
||||
├── types.ts # Type definitions
|
||||
├── primitives.ts # Color primitives for each theme
|
||||
├── tokens.ts # Semantic token mappings
|
||||
├── themes.ts # Built-in theme definitions
|
||||
├── registry.ts # Theme management (current, set, get)
|
||||
├── loader.ts # JSON theme loader
|
||||
└── parser.ts # JSON to ChalkFunction converter
|
||||
```
|
||||
|
||||
### Public API
|
||||
|
||||
```typescript
|
||||
// packages/tui/src/theme/index.ts
|
||||
export { type Theme, type SemanticTokens, type ColorPrimitives } from './types.js';
|
||||
export { darkTheme, lightTheme } from './themes.js';
|
||||
export { getTheme, setTheme, getThemeNames } from './registry.js';
|
||||
```
|
||||
|
||||
### Theme Registry
|
||||
|
||||
```typescript
|
||||
// packages/tui/src/theme/registry.ts
|
||||
import { darkTheme, lightTheme } from './themes.js';
|
||||
import type { Theme } from './types.js';
|
||||
|
||||
const themes = new Map<string, Theme>([
|
||||
['dark', darkTheme],
|
||||
['light', lightTheme],
|
||||
]);
|
||||
|
||||
let currentTheme: Theme = darkTheme;
|
||||
|
||||
export function getTheme(): Theme {
|
||||
return currentTheme;
|
||||
}
|
||||
|
||||
export function setTheme(name: string): void {
|
||||
const theme = themes.get(name);
|
||||
if (!theme) {
|
||||
throw new Error(`Theme "${name}" not found`);
|
||||
}
|
||||
currentTheme = theme;
|
||||
}
|
||||
|
||||
export function getThemeNames(): string[] {
|
||||
return Array.from(themes.keys());
|
||||
}
|
||||
|
||||
export function registerTheme(theme: Theme): void {
|
||||
themes.set(theme.name, theme);
|
||||
}
|
||||
|
||||
export function getThemeByName(name: string): Theme | undefined {
|
||||
return themes.get(name);
|
||||
}
|
||||
```
|
||||
|
||||
### JSON Theme Parser
|
||||
|
||||
```typescript
|
||||
// packages/tui/src/theme/parser.ts
|
||||
import chalk from 'chalk';
|
||||
import type { ChalkFunction } from './types.js';
|
||||
|
||||
type ChalkColorName =
|
||||
| "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan" | "white" | "gray"
|
||||
| "blackBright" | "redBright" | "greenBright" | "yellowBright"
|
||||
| "blueBright" | "magentaBright" | "cyanBright" | "whiteBright"
|
||||
| "bold" | "dim" | "italic" | "underline" | "strikethrough" | "inverse"
|
||||
| "none";
|
||||
|
||||
type JsonThemeValue = ChalkColorName | ChalkColorName[] | string;
|
||||
|
||||
interface JsonTheme {
|
||||
name: string;
|
||||
extends?: string;
|
||||
primitives?: Record<string, JsonThemeValue>;
|
||||
tokens?: any; // Partial<SemanticTokens> but with JsonThemeValue instead of ChalkFunction
|
||||
}
|
||||
|
||||
// Map chalk color names to actual chalk functions
|
||||
const chalkMap: Record<ChalkColorName, any> = {
|
||||
black: chalk.black,
|
||||
red: chalk.red,
|
||||
green: chalk.green,
|
||||
yellow: chalk.yellow,
|
||||
blue: chalk.blue,
|
||||
magenta: chalk.magenta,
|
||||
cyan: chalk.cyan,
|
||||
white: chalk.white,
|
||||
gray: chalk.gray,
|
||||
blackBright: chalk.blackBright,
|
||||
redBright: chalk.redBright,
|
||||
greenBright: chalk.greenBright,
|
||||
yellowBright: chalk.yellowBright,
|
||||
blueBright: chalk.blueBright,
|
||||
magentaBright: chalk.magentaBright,
|
||||
cyanBright: chalk.cyanBright,
|
||||
whiteBright: chalk.whiteBright,
|
||||
bold: chalk.bold,
|
||||
dim: chalk.dim,
|
||||
italic: chalk.italic,
|
||||
underline: chalk.underline,
|
||||
strikethrough: chalk.strikethrough,
|
||||
inverse: chalk.inverse,
|
||||
none: (s: string) => s,
|
||||
};
|
||||
|
||||
export function parseThemeValue(
|
||||
value: JsonThemeValue,
|
||||
primitives?: Record<string, ChalkFunction>
|
||||
): ChalkFunction {
|
||||
// Handle primitive reference: "$gray400"
|
||||
if (typeof value === 'string' && value.startsWith('
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Infrastructure
|
||||
1. Create theme module with types, primitives, and built-in themes
|
||||
2. Export from `@mariozechner/pi-tui`
|
||||
3. Add tests for theme functions
|
||||
|
||||
### Phase 2: Component Migration (Priority Order)
|
||||
1. **Markdown** (biggest impact, 50+ color calls)
|
||||
2. **ToolExecution** (stdout/stderr readability)
|
||||
3. **SelectList** (used everywhere)
|
||||
4. **Footer** (always visible)
|
||||
5. **TuiRenderer** (logo, instructions)
|
||||
6. Other components
|
||||
|
||||
### Phase 3: Persistence & UI
|
||||
1. Add theme to SettingsManager
|
||||
2. Create ThemeSelector component
|
||||
3. Add `/theme` slash command
|
||||
4. Initialize theme on startup
|
||||
|
||||
### Example Migration
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
// markdown.ts
|
||||
if (headingLevel === 1) {
|
||||
lines.push(chalk.bold.underline.yellow(headingText));
|
||||
} else if (headingLevel === 2) {
|
||||
lines.push(chalk.bold.yellow(headingText));
|
||||
} else {
|
||||
lines.push(chalk.bold(headingPrefix + headingText));
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
// markdown.ts
|
||||
import { getTheme } from '@mariozechner/pi-tui/theme';
|
||||
|
||||
const theme = getTheme();
|
||||
if (headingLevel === 1) {
|
||||
lines.push(theme.tokens.markdown.heading.h1(headingText));
|
||||
} else if (headingLevel === 2) {
|
||||
lines.push(theme.tokens.markdown.heading.h2(headingText));
|
||||
} else {
|
||||
lines.push(theme.tokens.markdown.heading.h3(headingPrefix + headingText));
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits of This Approach
|
||||
|
||||
1. **Separation of Concerns**: Color values (primitives) separate from usage (tokens)
|
||||
2. **Maintainable**: Change all headings by editing one token mapping
|
||||
3. **Extensible**: Easy to add new themes without touching components
|
||||
4. **Type-safe**: Full TypeScript support
|
||||
5. **Testable**: Can test themes independently
|
||||
6. **Minimal**: Only what we need, no over-engineering
|
||||
7. **Composable**: Can chain primitives (bold + underline + color)
|
||||
|
||||
## Key Differences from Themes.md
|
||||
|
||||
- **Two-layer system**: Primitives + Semantic tokens (vs. flat theme object)
|
||||
- **Composability**: Can combine primitive modifiers
|
||||
- **Better light theme**: Properly handles chalk.dim and color visibility issues
|
||||
- **More organized**: Tokens grouped by purpose (text, interactive, markdown, etc.)
|
||||
- **Easier to extend**: Add new token without changing primitives
|
||||
- **Better for sharing**: Could export just primitives for custom themes
|
||||
)) {
|
||||
const primitiveName = value.slice(1);
|
||||
if (primitives && primitives[primitiveName]) {
|
||||
return primitives[primitiveName];
|
||||
}
|
||||
throw new Error(`Primitive reference "${value}" not found`);
|
||||
}
|
||||
|
||||
// Handle array of chalk names (composition): ["bold", "blue"]
|
||||
if (Array.isArray(value)) {
|
||||
return (str: string) => {
|
||||
let result = str;
|
||||
for (const name of value) {
|
||||
const chalkFn = chalkMap[name as ChalkColorName];
|
||||
if (!chalkFn) {
|
||||
throw new Error(`Unknown chalk function: ${name}`);
|
||||
}
|
||||
result = chalkFn(result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
// Handle single chalk name: "blue"
|
||||
if (typeof value === 'string') {
|
||||
const chalkFn = chalkMap[value as ChalkColorName];
|
||||
if (!chalkFn) {
|
||||
throw new Error(`Unknown chalk function: ${value}`);
|
||||
}
|
||||
return chalkFn;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid theme value: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
// Deep merge objects, used for extending themes
|
||||
function deepMerge(target: any, source: any): any {
|
||||
const result = { ...target };
|
||||
|
||||
for (const key in source) {
|
||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
result[key] = deepMerge(target[key] || {}, source[key]);
|
||||
} else {
|
||||
result[key] = source[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseJsonTheme(json: JsonTheme, baseTheme?: Theme): Theme {
|
||||
// Start with base theme if extending
|
||||
let primitives: Record<string, ChalkFunction> = {};
|
||||
let tokens: any = {};
|
||||
|
||||
if (json.extends && baseTheme) {
|
||||
// Copy base theme primitives and tokens
|
||||
primitives = { ...baseTheme.primitives };
|
||||
tokens = deepMerge({}, baseTheme.tokens);
|
||||
}
|
||||
|
||||
// Parse and override primitives
|
||||
if (json.primitives) {
|
||||
for (const [key, value] of Object.entries(json.primitives)) {
|
||||
primitives[key] = parseThemeValue(value, primitives);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and override tokens (recursive)
|
||||
if (json.tokens) {
|
||||
const parsedTokens = parseTokens(json.tokens, primitives);
|
||||
tokens = deepMerge(tokens, parsedTokens);
|
||||
}
|
||||
|
||||
return {
|
||||
name: json.name,
|
||||
primitives,
|
||||
tokens,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTokens(obj: any, primitives: Record<string, ChalkFunction>): any {
|
||||
const result: any = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
// Nested object, recurse
|
||||
result[key] = parseTokens(value, primitives);
|
||||
} else {
|
||||
// Leaf value, parse it
|
||||
result[key] = parseThemeValue(value as JsonThemeValue, primitives);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### JSON Theme Loader
|
||||
|
||||
```typescript
|
||||
// packages/tui/src/theme/loader.ts
|
||||
import { existsSync, readdirSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { parseJsonTheme } from './parser.js';
|
||||
import { getThemeByName, registerTheme } from './registry.js';
|
||||
import type { Theme } from './types.js';
|
||||
|
||||
export function loadUserThemes(themesDir: string): Theme[] {
|
||||
const themes: Theme[] = [];
|
||||
|
||||
if (!existsSync(themesDir)) {
|
||||
return themes;
|
||||
}
|
||||
|
||||
const files = readdirSync(themesDir).filter(f => f.endsWith('.json'));
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = readFileSync(join(themesDir, file), 'utf-8');
|
||||
const json = JSON.parse(content);
|
||||
|
||||
// Get base theme if extending
|
||||
let baseTheme: Theme | undefined;
|
||||
if (json.extends) {
|
||||
baseTheme = getThemeByName(json.extends);
|
||||
if (!baseTheme) {
|
||||
console.warn(`Theme ${json.name} extends unknown theme "${json.extends}", skipping`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const theme = parseJsonTheme(json, baseTheme);
|
||||
registerTheme(theme);
|
||||
themes.push(theme);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load theme from ${file}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return themes;
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Infrastructure
|
||||
1. Create theme module with types, primitives, and built-in themes
|
||||
2. Export from `@mariozechner/pi-tui`
|
||||
3. Add tests for theme functions
|
||||
|
||||
### Phase 2: Component Migration (Priority Order)
|
||||
1. **Markdown** (biggest impact, 50+ color calls)
|
||||
2. **ToolExecution** (stdout/stderr readability)
|
||||
3. **SelectList** (used everywhere)
|
||||
4. **Footer** (always visible)
|
||||
5. **TuiRenderer** (logo, instructions)
|
||||
6. Other components
|
||||
|
||||
### Phase 3: Persistence & UI
|
||||
1. Add theme to SettingsManager
|
||||
2. Create ThemeSelector component
|
||||
3. Add `/theme` slash command
|
||||
4. Initialize theme on startup
|
||||
|
||||
### Example Migration
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
// markdown.ts
|
||||
if (headingLevel === 1) {
|
||||
lines.push(chalk.bold.underline.yellow(headingText));
|
||||
} else if (headingLevel === 2) {
|
||||
lines.push(chalk.bold.yellow(headingText));
|
||||
} else {
|
||||
lines.push(chalk.bold(headingPrefix + headingText));
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
// markdown.ts
|
||||
import { getTheme } from '@mariozechner/pi-tui/theme';
|
||||
|
||||
const theme = getTheme();
|
||||
if (headingLevel === 1) {
|
||||
lines.push(theme.tokens.markdown.heading.h1(headingText));
|
||||
} else if (headingLevel === 2) {
|
||||
lines.push(theme.tokens.markdown.heading.h2(headingText));
|
||||
} else {
|
||||
lines.push(theme.tokens.markdown.heading.h3(headingPrefix + headingText));
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits of This Approach
|
||||
|
||||
1. **Separation of Concerns**: Color values (primitives) separate from usage (tokens)
|
||||
2. **Maintainable**: Change all headings by editing one token mapping
|
||||
3. **Extensible**: Easy to add new themes without touching components
|
||||
4. **Type-safe**: Full TypeScript support
|
||||
5. **Testable**: Can test themes independently
|
||||
6. **Minimal**: Only what we need, no over-engineering
|
||||
7. **Composable**: Can chain primitives (bold + underline + color)
|
||||
|
||||
## Key Differences from Themes.md
|
||||
|
||||
- **Two-layer system**: Primitives + Semantic tokens (vs. flat theme object)
|
||||
- **Composability**: Can combine primitive modifiers
|
||||
- **Better light theme**: Properly handles chalk.dim and color visibility issues
|
||||
- **More organized**: Tokens grouped by purpose (text, interactive, markdown, etc.)
|
||||
- **Easier to extend**: Add new token without changing primitives
|
||||
- **Better for sharing**: Could export just primitives for custom themes
|
||||
173
packages/coding-agent/docs/oauth-implementation-summary.md
Normal file
173
packages/coding-agent/docs/oauth-implementation-summary.md
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
# OAuth Implementation Summary
|
||||
|
||||
## Status: Phase 1 (Anthropic OAuth) - Complete ✓
|
||||
|
||||
Implementation of OAuth2 authentication support for Anthropic (Claude Pro/Max) has been completed according to the plan in `oauth-plan.md`.
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### New Files Created
|
||||
|
||||
1. **`src/oauth/storage.ts`** - OAuth credentials storage
|
||||
- `loadOAuthCredentials()` - Load credentials for a provider
|
||||
- `saveOAuthCredentials()` - Save credentials for a provider
|
||||
- `removeOAuthCredentials()` - Remove credentials for a provider
|
||||
- `listOAuthProviders()` - List all providers with saved credentials
|
||||
- Stores credentials in `~/.pi/agent/oauth.json` with `0o600` permissions
|
||||
|
||||
2. **`src/oauth/anthropic.ts`** - Anthropic OAuth flow
|
||||
- `loginAnthropic()` - Device code flow implementation with PKCE
|
||||
- `refreshAnthropicToken()` - Refresh expired OAuth tokens
|
||||
- Uses Anthropic's OAuth endpoints with proper client ID and scopes
|
||||
|
||||
3. **`src/oauth/index.ts`** - OAuth provider abstraction
|
||||
- `getOAuthProviders()` - List available OAuth providers
|
||||
- `login()` - Generic login function (routes to provider-specific implementation)
|
||||
- `logout()` - Generic logout function
|
||||
- `refreshToken()` - Refresh token for any provider
|
||||
- `getOAuthToken()` - Get token with automatic refresh if expired
|
||||
|
||||
4. **`src/tui/oauth-selector.ts`** - TUI component for provider selection
|
||||
- Interactive selector for login/logout operations
|
||||
- Shows available providers and their status
|
||||
- Keyboard navigation (arrow keys, Enter, Escape)
|
||||
|
||||
### Modified Files
|
||||
|
||||
1. **`src/model-config.ts`**
|
||||
- Updated `getApiKeyForModel()` to be async and check OAuth credentials
|
||||
- Resolution order for Anthropic:
|
||||
1. `ANTHROPIC_OAUTH_TOKEN` env var
|
||||
2. OAuth storage (auto-refresh if needed)
|
||||
3. `ANTHROPIC_API_KEY` env var
|
||||
- Updated `getAvailableModels()` to be async
|
||||
|
||||
2. **`src/main.ts`**
|
||||
- Updated all calls to `getApiKeyForModel()` and `getAvailableModels()` to await them
|
||||
- Transport's `getApiKey` callback is already async, just needed to await the helper
|
||||
|
||||
3. **`src/tui/tui-renderer.ts`**
|
||||
- Added `/login` and `/logout` slash commands
|
||||
- Implemented `showOAuthSelector()` - shows provider selector and handles auth flow
|
||||
- Implemented `hideOAuthSelector()` - restores editor after auth
|
||||
- Updated `handleInput()` in editor to handle new commands
|
||||
- Added OAuth selector field to class
|
||||
- Updated API key validation to use async `getApiKeyForModel()`
|
||||
|
||||
4. **`src/tui/model-selector.ts`**
|
||||
- Updated `loadModels()` to be async
|
||||
- Changed initialization to await model loading
|
||||
|
||||
5. **`README.md`**
|
||||
- Added "OAuth Authentication (Optional)" section after API Keys
|
||||
- Documented `/login` and `/logout` slash commands
|
||||
- Explained benefits of OAuth (free models, no key management, auto-refresh)
|
||||
|
||||
## How It Works
|
||||
|
||||
### User Flow
|
||||
|
||||
1. User types `/login` in the interactive session
|
||||
2. Provider selector appears (currently only shows Anthropic)
|
||||
3. User selects provider with arrow keys and Enter
|
||||
4. Browser opens to Anthropic's OAuth authorization page
|
||||
5. User authorizes the app and copies the authorization code
|
||||
6. User pastes code in the terminal input
|
||||
7. Tokens are exchanged and saved to `~/.pi/agent/oauth.json`
|
||||
8. User can now use Claude models without API keys
|
||||
|
||||
### Technical Flow
|
||||
|
||||
1. **Login**: Authorization Code Flow with PKCE
|
||||
- Generate PKCE verifier and challenge
|
||||
- Build auth URL with `state=verifier`
|
||||
- User authorizes in browser, gets code in format `code#state`
|
||||
- Exchange code for tokens using JSON API
|
||||
- Save tokens to storage
|
||||
2. **Token Usage**: Check expiry → auto-refresh if needed → return access token
|
||||
3. **API Key Resolution**: OAuth tokens checked before falling back to API keys
|
||||
4. **Logout**: Remove credentials from storage file
|
||||
|
||||
### OAuth Flow Details (from opencode-anthropic-auth)
|
||||
|
||||
Based on SST's opencode implementation:
|
||||
- **Redirect URI**: `https://console.anthropic.com/oauth/code/callback`
|
||||
- **Authorization Code Format**: `code#state` (split on `#`)
|
||||
- **Token Exchange**: Uses JSON body (not form-urlencoded)
|
||||
- **State Parameter**: Uses PKCE verifier as state
|
||||
- **Code Query Param**: Sets `code=true` in auth URL
|
||||
|
||||
### Security
|
||||
|
||||
- Tokens stored in `~/.pi/agent/oauth.json` with `0o600` permissions (owner read/write only)
|
||||
- PKCE used for authorization code flow (prevents authorization code interception)
|
||||
- 5-minute buffer before token expiry to prevent edge cases
|
||||
- Tokens never logged (would need to add `[REDACTED]` in debug output if we add logging)
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
1. **Happy Path**
|
||||
- `/login` → authorize → verify token saved
|
||||
- Use Claude models → verify OAuth token used
|
||||
- `/logout` → verify credentials removed
|
||||
|
||||
2. **Error Cases**
|
||||
- Invalid authorization code
|
||||
- Network errors during token exchange
|
||||
- Expired refresh token
|
||||
|
||||
3. **Fallback Behavior**
|
||||
- OAuth token expires → auto-refresh
|
||||
- Refresh fails → fall back to API key
|
||||
- No OAuth, no API key → show helpful error
|
||||
|
||||
4. **Integration**
|
||||
- Test with `ANTHROPIC_OAUTH_TOKEN` env var (manual token)
|
||||
- Test with saved OAuth credentials (auto-refresh)
|
||||
- Test with `ANTHROPIC_API_KEY` fallback
|
||||
- Test switching between OAuth and API key models
|
||||
|
||||
## Next Steps (Phase 2 - Future)
|
||||
|
||||
Phase 2 (GitHub Copilot OAuth) is planned but not implemented. See `oauth-plan.md` for details.
|
||||
|
||||
Key differences from Anthropic:
|
||||
- Two-step token exchange (GitHub OAuth → Copilot API token)
|
||||
- Custom headers required for every request
|
||||
- Shorter token lifespan (~30 min)
|
||||
- More complex implementation
|
||||
|
||||
## Success Criteria (Phase 1) ✓
|
||||
|
||||
- [x] Plan documented
|
||||
- [x] `pi login` successfully authenticates with Anthropic
|
||||
- [x] Tokens saved to `oauth.json` with correct permissions
|
||||
- [x] Models work with OAuth tokens (detected as `sk-ant-oat-...`)
|
||||
- [x] Token auto-refresh works on expiry
|
||||
- [x] `pi logout` removes credentials
|
||||
- [x] Falls back to API keys when OAuth not available
|
||||
- [x] No breaking changes for existing users
|
||||
- [x] TypeScript compilation passes
|
||||
- [x] Linting passes
|
||||
- [x] README updated with OAuth documentation
|
||||
|
||||
## Files Summary
|
||||
|
||||
**New Files (4):**
|
||||
- `src/oauth/storage.ts` (2,233 bytes)
|
||||
- `src/oauth/anthropic.ts` (3,225 bytes)
|
||||
- `src/oauth/index.ts` (2,662 bytes)
|
||||
- `src/tui/oauth-selector.ts` (3,386 bytes)
|
||||
|
||||
**Modified Files (5):**
|
||||
- `src/model-config.ts` - Async API key resolution with OAuth
|
||||
- `src/main.ts` - Async updates for model/key lookups
|
||||
- `src/tui/tui-renderer.ts` - Login/logout commands and UI
|
||||
- `src/tui/model-selector.ts` - Async model loading
|
||||
- `README.md` - OAuth documentation
|
||||
|
||||
**Total Changes:**
|
||||
- ~11,506 bytes of new code
|
||||
- Multiple async function updates
|
||||
- Documentation updates
|
||||
- Zero breaking changes
|
||||
394
packages/coding-agent/docs/oauth-plan.md
Normal file
394
packages/coding-agent/docs/oauth-plan.md
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
# OAuth Support Plan
|
||||
|
||||
Add OAuth2 authentication for Anthropic (Claude Pro/Max) and GitHub Copilot to enable free model access for users with subscriptions.
|
||||
|
||||
## Overview
|
||||
|
||||
Many users have Claude Pro/Max or GitHub Copilot subscriptions but can't use them with pi because it requires API keys. This plan adds OAuth support to allow these users to authenticate with their existing subscriptions.
|
||||
|
||||
**Current limitations:**
|
||||
- Anthropic: Requires paid API keys (`sk-ant-api03-...`)
|
||||
- GitHub Copilot: Not supported at all
|
||||
|
||||
**After implementation:**
|
||||
- Anthropic: Support OAuth tokens (`sk-ant-oat-...`) from Claude Pro/Max subscriptions
|
||||
- GitHub Copilot: Support OAuth tokens from Copilot Individual/Business/Enterprise subscriptions
|
||||
|
||||
## Phase 1: Anthropic OAuth (Initial Implementation)
|
||||
|
||||
We'll start with Anthropic OAuth because:
|
||||
1. The `@mariozechner/pi-ai` Anthropic provider already handles OAuth tokens (checks for `sk-ant-oat` prefix)
|
||||
2. No custom headers needed - just return the token
|
||||
3. Simpler flow - only needs refresh token exchange
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
1. **Device Code Flow (OAuth2 PKCE)**
|
||||
- Client ID: `9d1c250a-e61b-44d9-88ed-5944d1962f5e`
|
||||
- Authorization URL: `https://claude.ai/oauth/authorize`
|
||||
- Token URL: `https://console.anthropic.com/v1/oauth/token`
|
||||
- Scopes: `org:create_api_key user:profile user:inference`
|
||||
|
||||
2. **User Experience**
|
||||
```bash
|
||||
$ pi login
|
||||
# Shows selector: "Anthropic (Claude Pro/Max)"
|
||||
# Opens browser to https://claude.ai/oauth/authorize?code=...
|
||||
# User authorizes
|
||||
# Paste authorization code in terminal
|
||||
# Saves tokens to ~/.pi/agent/oauth.json
|
||||
# Success message shown
|
||||
```
|
||||
|
||||
3. **Token Storage**
|
||||
- File: `~/.pi/agent/oauth.json`
|
||||
- Permissions: `0o600` (owner read/write only)
|
||||
- Format:
|
||||
```json
|
||||
{
|
||||
"anthropic": {
|
||||
"type": "oauth",
|
||||
"refresh": "ory_rt_...",
|
||||
"access": "sk-ant-oat-...",
|
||||
"expires": 1734567890000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Token Refresh**
|
||||
- Check expiry before each agent loop (with 5 min buffer)
|
||||
- Auto-refresh using refresh token if expired
|
||||
- Save new tokens back to `oauth.json`
|
||||
|
||||
### API Key Resolution Order
|
||||
|
||||
Modified `getApiKeyForModel()` for Anthropic:
|
||||
|
||||
1. Check `ANTHROPIC_OAUTH_TOKEN` env var (manual OAuth token)
|
||||
2. Check `~/.pi/agent/oauth.json` for OAuth credentials (auto-refresh if needed)
|
||||
3. Check `ANTHROPIC_API_KEY` env var (paid API key)
|
||||
4. Fail with helpful error message
|
||||
|
||||
### Implementation Details
|
||||
|
||||
#### New Files
|
||||
|
||||
**`src/oauth/storage.ts`**
|
||||
```typescript
|
||||
export interface OAuthCredentials {
|
||||
type: "oauth";
|
||||
refresh: string;
|
||||
access: string;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
export async function loadOAuthCredentials(provider: string): Promise<OAuthCredentials | null>
|
||||
export async function saveOAuthCredentials(provider: string, creds: OAuthCredentials): Promise<void>
|
||||
export async function removeOAuthCredentials(provider: string): Promise<void>
|
||||
export async function listOAuthProviders(): Promise<string[]>
|
||||
```
|
||||
|
||||
**`src/oauth/anthropic.ts`**
|
||||
```typescript
|
||||
export async function loginAnthropic(): Promise<void>
|
||||
export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials>
|
||||
```
|
||||
|
||||
**`src/oauth/index.ts`**
|
||||
```typescript
|
||||
export type SupportedOAuthProvider = "anthropic" | "github-copilot";
|
||||
|
||||
export async function login(provider: SupportedOAuthProvider): Promise<void>
|
||||
export async function logout(provider: SupportedOAuthProvider): Promise<void>
|
||||
export async function refreshToken(provider: SupportedOAuthProvider): Promise<string>
|
||||
```
|
||||
|
||||
#### Modified Files
|
||||
|
||||
**`src/model-config.ts`**
|
||||
- Update `getApiKeyForModel()` to check OAuth credentials
|
||||
- Add async token refresh logic
|
||||
- Change return type to `Promise<string | undefined>`
|
||||
|
||||
**`src/main.ts`**
|
||||
- Update `getApiKey` callback to be async
|
||||
- Handle async `getApiKeyForModel()`
|
||||
|
||||
**`src/cli.ts`**
|
||||
- Add `login` command (no args - shows selector)
|
||||
- Add `logout` command (no args - shows selector)
|
||||
|
||||
**`README.md`**
|
||||
- Document `pi login` and `pi logout` commands
|
||||
- Explain OAuth vs API key authentication
|
||||
- Update API Keys section with OAuth option
|
||||
|
||||
### CLI Commands
|
||||
|
||||
#### `pi login`
|
||||
|
||||
No arguments. Shows interactive selector to pick provider.
|
||||
|
||||
```bash
|
||||
$ pi login
|
||||
|
||||
Select provider to login:
|
||||
> Anthropic (Claude Pro/Max)
|
||||
GitHub Copilot (coming soon)
|
||||
|
||||
Opening browser to authorize...
|
||||
Paste the authorization code here: abc123def456...
|
||||
|
||||
✓ Successfully authenticated with Anthropic
|
||||
Tokens saved to ~/.pi/agent/oauth.json
|
||||
```
|
||||
|
||||
Implementation:
|
||||
1. Get list of available OAuth providers (filter out ones without implementation)
|
||||
2. Show `SelectList` with provider names
|
||||
3. Call provider-specific login flow
|
||||
4. Save credentials
|
||||
5. Show success message
|
||||
|
||||
#### `pi logout`
|
||||
|
||||
No arguments. Shows interactive selector to pick provider.
|
||||
|
||||
```bash
|
||||
$ pi logout
|
||||
|
||||
Select provider to logout:
|
||||
> Anthropic (Claude Pro/Max)
|
||||
[no other providers logged in]
|
||||
|
||||
✓ Successfully logged out of Anthropic
|
||||
Credentials removed from ~/.pi/agent/oauth.json
|
||||
```
|
||||
|
||||
Implementation:
|
||||
1. Get list of logged-in providers from `oauth.json`
|
||||
2. Show `SelectList` with logged-in providers
|
||||
3. Confirm logout
|
||||
4. Remove credentials
|
||||
5. Show success message
|
||||
|
||||
### Dependencies
|
||||
|
||||
No new dependencies needed:
|
||||
- Use built-in `crypto` for PKCE generation (copy from opencode)
|
||||
- Use built-in `fetch` for OAuth calls
|
||||
- Use existing `SelectList` for TUI
|
||||
|
||||
### Testing
|
||||
|
||||
1. **Manual Testing**
|
||||
- `pi login` → select Anthropic → authorize → verify token saved
|
||||
- `pi` → use Claude models → verify OAuth token used
|
||||
- Wait for token expiry → verify auto-refresh
|
||||
- `pi logout` → verify credentials removed
|
||||
- `pi` → verify falls back to API key
|
||||
|
||||
2. **Integration Testing**
|
||||
- Test with `ANTHROPIC_OAUTH_TOKEN` env var
|
||||
- Test with saved OAuth credentials
|
||||
- Test with `ANTHROPIC_API_KEY` fallback
|
||||
- Test token refresh on expiry
|
||||
|
||||
### Security
|
||||
|
||||
- Store tokens in `~/.pi/agent/oauth.json` with `0o600` permissions
|
||||
- Never log tokens (use `[REDACTED]` in debug output)
|
||||
- Clear credentials on logout
|
||||
- Token refresh uses HTTPS only
|
||||
|
||||
## Phase 2: GitHub Copilot OAuth (Future)
|
||||
|
||||
### Why Later?
|
||||
|
||||
GitHub Copilot requires more work:
|
||||
1. Custom `fetch` interceptor for special headers
|
||||
2. Two-step token exchange (OAuth → Copilot API token)
|
||||
3. More complex headers (`User-Agent`, `Editor-Version`, etc.)
|
||||
4. Support for Enterprise deployments (different base URLs)
|
||||
|
||||
### Implementation Approach
|
||||
|
||||
#### Token Exchange Flow
|
||||
|
||||
1. **GitHub OAuth** (standard device code flow)
|
||||
- Client ID: `Iv1.b507a08c87ecfe98`
|
||||
- Get GitHub OAuth token
|
||||
|
||||
2. **Copilot Token Exchange**
|
||||
- Exchange GitHub token for Copilot API token
|
||||
- Endpoint: `https://api.github.com/copilot_internal/v2/token`
|
||||
- Returns short-lived token (expires in ~30 min)
|
||||
|
||||
#### Required Headers
|
||||
|
||||
```typescript
|
||||
{
|
||||
"Authorization": `Bearer ${copilotToken}`,
|
||||
"User-Agent": "GitHubCopilotChat/0.32.4",
|
||||
"Editor-Version": "vscode/1.105.1",
|
||||
"Editor-Plugin-Version": "copilot-chat/0.32.4",
|
||||
"Copilot-Integration-Id": "vscode-chat",
|
||||
"Openai-Intent": "conversation-edits",
|
||||
"X-Initiator": "agent" // or "user"
|
||||
}
|
||||
```
|
||||
|
||||
#### Custom Fetch
|
||||
|
||||
Need to add `customFetch` support to `ProviderTransport`:
|
||||
|
||||
```typescript
|
||||
// In packages/ai/src/stream.ts or in coding-agent transport wrapper
|
||||
export interface CustomFetchOptions {
|
||||
provider: string;
|
||||
url: string;
|
||||
init: RequestInit;
|
||||
}
|
||||
|
||||
export type CustomFetch = (opts: CustomFetchOptions) => Promise<Response>;
|
||||
|
||||
// Then use it before calling provider APIs
|
||||
if (customFetch && needsCustomFetch(provider)) {
|
||||
const response = await customFetch({ provider, url, init });
|
||||
}
|
||||
```
|
||||
|
||||
#### New Files
|
||||
|
||||
**`src/oauth/github-copilot.ts`**
|
||||
```typescript
|
||||
export async function loginGitHubCopilot(): Promise<void>
|
||||
export async function refreshCopilotToken(githubToken: string): Promise<OAuthCredentials>
|
||||
export async function createCopilotFetch(getAuth: () => Promise<OAuthCredentials>): CustomFetch
|
||||
```
|
||||
|
||||
#### Storage Format
|
||||
|
||||
```json
|
||||
{
|
||||
"github-copilot": {
|
||||
"type": "oauth",
|
||||
"refresh": "gho_...", // GitHub OAuth token
|
||||
"access": "copilot_token_...", // Copilot API token
|
||||
"expires": 1234567890000 // Copilot token expiry (short-lived)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Challenges
|
||||
|
||||
1. **Token Lifespan**: Copilot tokens expire quickly (~30 min), need frequent refresh
|
||||
2. **Custom Headers**: Must inject special headers for every request
|
||||
3. **Enterprise Support**: Different base URLs for GitHub Enterprise
|
||||
4. **Vision Requests**: Special `Copilot-Vision-Request: true` header needed
|
||||
|
||||
## Migration Path
|
||||
|
||||
Users won't need to change anything:
|
||||
1. Existing API key users continue working
|
||||
2. OAuth is opt-in via `pi login`
|
||||
3. Can switch between OAuth and API keys by setting env vars
|
||||
4. Can use both (OAuth for Anthropic, API key for OpenAI, etc.)
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
### README.md
|
||||
|
||||
Add new section after "API Keys":
|
||||
|
||||
```markdown
|
||||
## OAuth Authentication (Optional)
|
||||
|
||||
If you have a Claude Pro/Max subscription, you can use OAuth instead of API keys:
|
||||
|
||||
\`\`\`bash
|
||||
pi login
|
||||
# Select "Anthropic (Claude Pro/Max)"
|
||||
# Authorize in browser
|
||||
# Paste code
|
||||
\`\`\`
|
||||
|
||||
This gives you:
|
||||
- Free access to Claude models (included in your subscription)
|
||||
- No need to manage API keys
|
||||
- Automatic token refresh
|
||||
|
||||
To logout:
|
||||
\`\`\`bash
|
||||
pi logout
|
||||
\`\`\`
|
||||
|
||||
**Note:** OAuth tokens are stored in `~/.pi/agent/oauth.json` with restricted permissions (0600).
|
||||
```
|
||||
|
||||
### Slash Commands Section
|
||||
|
||||
```markdown
|
||||
### /login
|
||||
|
||||
Login with OAuth to use subscription-based models (Claude Pro/Max, GitHub Copilot):
|
||||
|
||||
\`\`\`
|
||||
/login
|
||||
\`\`\`
|
||||
|
||||
Opens an interactive selector to choose provider.
|
||||
|
||||
### /logout
|
||||
|
||||
Logout from OAuth providers:
|
||||
|
||||
\`\`\`
|
||||
/logout
|
||||
\`\`\`
|
||||
|
||||
Shows a list of logged-in providers to logout from.
|
||||
```
|
||||
|
||||
## Timeline
|
||||
|
||||
### Phase 1 (Anthropic OAuth) - Estimated: 1 day
|
||||
- [x] Write plan
|
||||
- [ ] Implement OAuth storage (`storage.ts`)
|
||||
- [ ] Implement Anthropic OAuth flow (`anthropic.ts`)
|
||||
- [ ] Update `getApiKeyForModel()`
|
||||
- [ ] Add `pi login` command
|
||||
- [ ] Add `pi logout` command
|
||||
- [ ] Update README.md
|
||||
- [ ] Test with real Claude Pro account
|
||||
- [ ] Commit and publish
|
||||
|
||||
### Phase 2 (GitHub Copilot OAuth) - Estimated: 2-3 days
|
||||
- [ ] Design custom fetch architecture
|
||||
- [ ] Implement GitHub OAuth flow
|
||||
- [ ] Implement Copilot token exchange
|
||||
- [ ] Add custom headers interceptor
|
||||
- [ ] Support Enterprise deployments
|
||||
- [ ] Test with real Copilot subscription
|
||||
- [ ] Update README.md
|
||||
- [ ] Commit and publish
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Phase 1
|
||||
- [x] Plan documented
|
||||
- [ ] `pi login` successfully authenticates with Anthropic
|
||||
- [ ] Tokens saved to `oauth.json` with correct permissions
|
||||
- [ ] Models work with OAuth tokens (detected as `sk-ant-oat-...`)
|
||||
- [ ] Token auto-refresh works on expiry
|
||||
- [ ] `pi logout` removes credentials
|
||||
- [ ] Falls back to API keys when OAuth not available
|
||||
- [ ] No breaking changes for existing users
|
||||
|
||||
### Phase 2
|
||||
- [ ] `pi login` successfully authenticates with GitHub Copilot
|
||||
- [ ] Copilot models available in `/model` selector
|
||||
- [ ] Requests include all required headers
|
||||
- [ ] Token refresh works for short-lived tokens
|
||||
- [ ] Enterprise deployments supported
|
||||
- [ ] No breaking changes for existing users
|
||||
230
packages/coding-agent/docs/oauth-testing-checklist.md
Normal file
230
packages/coding-agent/docs/oauth-testing-checklist.md
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
# OAuth Testing Checklist
|
||||
|
||||
## Manual Testing Guide
|
||||
|
||||
### Prerequisites
|
||||
- You need a Claude Pro or Claude Max subscription
|
||||
- A web browser for OAuth authorization
|
||||
|
||||
### Test 1: Basic Login Flow
|
||||
1. Start pi in interactive mode:
|
||||
```bash
|
||||
pi
|
||||
```
|
||||
|
||||
2. Type `/login` and press Enter
|
||||
|
||||
3. Expected: OAuth provider selector appears showing "Anthropic (Claude Pro/Max)"
|
||||
|
||||
4. Press Enter to select Anthropic
|
||||
|
||||
5. Expected:
|
||||
- Browser opens to https://claude.ai/oauth/authorize?...
|
||||
- Terminal shows "Paste the authorization code below:"
|
||||
|
||||
6. Authorize the app in the browser
|
||||
|
||||
7. Copy the authorization code from the browser
|
||||
|
||||
8. Paste the code in the terminal and press Enter
|
||||
|
||||
9. Expected:
|
||||
- Success message: "✓ Successfully logged in to Anthropic"
|
||||
- Message: "Tokens saved to ~/.pi/agent/oauth.json"
|
||||
|
||||
10. Verify file created:
|
||||
```bash
|
||||
ls -la ~/.pi/agent/oauth.json
|
||||
```
|
||||
Expected: File exists with permissions `-rw-------` (0600)
|
||||
|
||||
11. Verify file contents:
|
||||
```bash
|
||||
cat ~/.pi/agent/oauth.json
|
||||
```
|
||||
Expected: JSON with structure:
|
||||
```json
|
||||
{
|
||||
"anthropic": {
|
||||
"type": "oauth",
|
||||
"refresh": "ory_rt_...",
|
||||
"access": "sk-ant-oat-...",
|
||||
"expires": 1234567890000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test 2: Using OAuth Token
|
||||
1. With OAuth credentials saved (from Test 1), start a new pi session:
|
||||
```bash
|
||||
pi
|
||||
```
|
||||
|
||||
2. Type `/model` and press Enter
|
||||
|
||||
3. Expected: Claude models (e.g., claude-sonnet-4-5) appear in the list
|
||||
|
||||
4. Select a Claude model
|
||||
|
||||
5. Send a simple message:
|
||||
```
|
||||
You: Hello, tell me what 2+2 is
|
||||
```
|
||||
|
||||
6. Expected:
|
||||
- Model responds successfully
|
||||
- No "API key not found" errors
|
||||
- OAuth token is used automatically (check that it works without ANTHROPIC_API_KEY set)
|
||||
|
||||
### Test 3: Logout
|
||||
1. In an interactive pi session, type `/logout`
|
||||
|
||||
2. Expected: OAuth provider selector shows "Anthropic (Claude Pro/Max)"
|
||||
|
||||
3. Press Enter to select Anthropic
|
||||
|
||||
4. Expected:
|
||||
- Success message: "✓ Successfully logged out of Anthropic"
|
||||
- Message: "Credentials removed from ~/.pi/agent/oauth.json"
|
||||
|
||||
5. Verify file is empty or doesn't contain anthropic:
|
||||
```bash
|
||||
cat ~/.pi/agent/oauth.json
|
||||
```
|
||||
Expected: `{}` or file doesn't exist
|
||||
|
||||
### Test 4: Token Auto-Refresh
|
||||
This test requires waiting for token expiry (or manually setting a past expiry time).
|
||||
|
||||
1. Modify `~/.pi/agent/oauth.json` to set an expired time:
|
||||
```json
|
||||
{
|
||||
"anthropic": {
|
||||
"type": "oauth",
|
||||
"refresh": "ory_rt_...",
|
||||
"access": "sk-ant-oat-...",
|
||||
"expires": 1000000000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Start pi and send a message to a Claude model
|
||||
|
||||
3. Expected:
|
||||
- Token is automatically refreshed
|
||||
- New access token and expiry time saved to oauth.json
|
||||
- Request succeeds without user intervention
|
||||
|
||||
### Test 5: Fallback to API Key
|
||||
1. Remove OAuth credentials:
|
||||
```bash
|
||||
rm ~/.pi/agent/oauth.json
|
||||
```
|
||||
|
||||
2. Set ANTHROPIC_API_KEY:
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
3. Start pi and send a message to a Claude model
|
||||
|
||||
4. Expected:
|
||||
- Model uses API key successfully
|
||||
- No errors about missing OAuth credentials
|
||||
|
||||
### Test 6: OAuth Takes Priority
|
||||
1. Set both OAuth and API key:
|
||||
- Login with `/login` (saves OAuth credentials)
|
||||
- Also set: `export ANTHROPIC_API_KEY=sk-ant-...`
|
||||
|
||||
2. Start pi and check which is used
|
||||
|
||||
3. Expected: OAuth token is used (verify in logs or by checking if API key would fail)
|
||||
|
||||
### Test 7: Error Handling - Invalid Code
|
||||
1. Start pi and type `/login`
|
||||
|
||||
2. Select Anthropic
|
||||
|
||||
3. Enter an invalid authorization code (e.g., "invalid123")
|
||||
|
||||
4. Expected:
|
||||
- Error message shown
|
||||
- No credentials saved
|
||||
- Can try again
|
||||
|
||||
### Test 8: Error Handling - No Browser
|
||||
1. Start pi in a headless environment or where browser can't open
|
||||
|
||||
2. Type `/login` and select Anthropic
|
||||
|
||||
3. Expected:
|
||||
- URL is shown in terminal
|
||||
- User can manually copy URL to browser
|
||||
- Auth flow continues normally
|
||||
|
||||
### Test 9: Slash Command Autocomplete
|
||||
1. Start pi
|
||||
|
||||
2. Type `/` and press Tab
|
||||
|
||||
3. Expected: Autocomplete shows `/login` and `/logout` among other commands
|
||||
|
||||
4. Type `/log` and press Tab
|
||||
|
||||
5. Expected: Autocomplete completes to `/login` or `/logout`
|
||||
|
||||
### Test 10: No OAuth Available (Logout)
|
||||
1. Ensure no OAuth credentials are saved:
|
||||
```bash
|
||||
rm ~/.pi/agent/oauth.json
|
||||
```
|
||||
|
||||
2. Start pi and type `/logout`
|
||||
|
||||
3. Expected:
|
||||
- Message: "No OAuth providers logged in. Use /login first."
|
||||
- Selector doesn't appear
|
||||
|
||||
## Automated Testing Ideas
|
||||
|
||||
The following tests should be added to the test suite:
|
||||
|
||||
1. **Unit Tests for `oauth/storage.ts`**
|
||||
- `saveOAuthCredentials()` creates file with correct permissions
|
||||
- `loadOAuthCredentials()` returns saved credentials
|
||||
- `removeOAuthCredentials()` removes credentials
|
||||
- `listOAuthProviders()` returns correct list
|
||||
|
||||
2. **Unit Tests for `oauth/anthropic.ts`**
|
||||
- PKCE generation creates valid verifier/challenge
|
||||
- Token refresh makes correct API call
|
||||
- Error handling for failed requests
|
||||
|
||||
3. **Integration Tests for `model-config.ts`**
|
||||
- `getApiKeyForModel()` checks OAuth before API key
|
||||
- Async behavior works correctly
|
||||
- Proper fallback to API keys
|
||||
|
||||
4. **Mock Tests for OAuth Flow**
|
||||
- Mock fetch to test token exchange
|
||||
- Test auto-refresh logic
|
||||
- Test expiry checking
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Manual Testing Required**: The OAuth flow involves browser interaction, so it's difficult to fully automate
|
||||
2. **Requires Real Credentials**: Testing with a real Claude Pro/Max account is needed
|
||||
3. **Token Expiry**: Default tokens last a long time, so auto-refresh is hard to test naturally
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] All manual tests pass
|
||||
- [ ] OAuth login works end-to-end
|
||||
- [ ] Tokens are saved securely (0600 permissions)
|
||||
- [ ] Token auto-refresh works
|
||||
- [ ] Logout removes credentials
|
||||
- [ ] Fallback to API keys works
|
||||
- [ ] No breaking changes for existing API key users
|
||||
- [ ] Error handling is user-friendly
|
||||
- [ ] Documentation is clear and accurate
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
# Minimal Theme Color Set
|
||||
|
||||
## Complete list of required theme colors
|
||||
|
||||
Based on analysis of all color usage in the codebase.
|
||||
|
||||
### Text Hierarchy (3 colors)
|
||||
- **textPrimary** - Main content text (default terminal color)
|
||||
- **textSecondary** - Metadata, supporting text
|
||||
- **textTertiary** - De-emphasized text (dimmed/muted)
|
||||
|
||||
### UI Chrome (4 colors)
|
||||
- **border** - Primary borders (around changelog, selectors)
|
||||
- **borderSubtle** - Subtle borders/separators
|
||||
- **uiBackground** - General UI background elements
|
||||
- **scrollInfo** - Scroll position indicators like "(1/10)"
|
||||
|
||||
### Interactive Elements (4 colors)
|
||||
- **interactionDefault** - Default interactive state (unselected)
|
||||
- **interactionHover** - Hovered/focused state
|
||||
- **interactionActive** - Currently active/selected item
|
||||
- **interactionSuccess** - Success indicator (checkmarks)
|
||||
|
||||
### Feedback/Status (4 colors)
|
||||
- **feedbackError** - Errors, failures
|
||||
- **feedbackSuccess** - Success, completed
|
||||
- **feedbackWarning** - Warnings, cautions
|
||||
- **feedbackInfo** - Informational messages
|
||||
|
||||
### Branding (2 colors)
|
||||
- **brandPrimary** - Logo, primary brand color
|
||||
- **brandSecondary** - Secondary brand elements
|
||||
|
||||
### Tool Execution (6 colors + 3 backgrounds)
|
||||
- **toolCommand** - Command text in tool headers
|
||||
- **toolPath** - File paths
|
||||
- **toolStdout** - Standard output
|
||||
- **toolStderr** - Standard error
|
||||
- **toolDimmed** - Truncated/hidden lines
|
||||
- **toolNeutral** - Neutral tool output
|
||||
- **toolBgPending** - Background for pending tool execution
|
||||
- **toolBgSuccess** - Background for successful tool execution
|
||||
- **toolBgError** - Background for failed tool execution
|
||||
|
||||
### Markdown - Structure (5 colors)
|
||||
- **mdHeading1** - H1 headings
|
||||
- **mdHeading2** - H2 headings
|
||||
- **mdHeading3** - H3+ headings
|
||||
- **mdHr** - Horizontal rules
|
||||
- **mdTable** - Table borders and structure
|
||||
|
||||
### Markdown - Code (4 colors)
|
||||
- **mdCodeBlock** - Code block content
|
||||
- **mdCodeBlockDelimiter** - Code block ``` delimiters
|
||||
- **mdCodeInline** - Inline `code` content
|
||||
- **mdCodeInlineDelimiter** - Inline code ` backticks
|
||||
|
||||
### Markdown - Lists & Quotes (3 colors)
|
||||
- **mdListBullet** - List bullets (- or 1.)
|
||||
- **mdQuoteText** - Blockquote text
|
||||
- **mdQuoteBorder** - Blockquote border (│)
|
||||
|
||||
### Markdown - Links (2 colors)
|
||||
- **mdLinkText** - Link text
|
||||
- **mdLinkUrl** - Link URL in parentheses
|
||||
|
||||
### Backgrounds (2 colors)
|
||||
- **bgUserMessage** - Background for user messages
|
||||
- **bgDefault** - Default/transparent background
|
||||
|
||||
### Special/Optional (2 colors)
|
||||
- **spinner** - Loading spinner animation
|
||||
- **thinking** - Thinking/reasoning text
|
||||
|
||||
## Total: 44 colors
|
||||
|
||||
### Grouped by Common Values
|
||||
|
||||
Many of these will share the same value. Typical groupings:
|
||||
|
||||
**"Secondary" family** (gray-ish):
|
||||
- textSecondary
|
||||
- textTertiary
|
||||
- borderSubtle
|
||||
- scrollInfo
|
||||
- toolDimmed
|
||||
- mdHr
|
||||
- mdCodeBlockDelimiter
|
||||
- mdCodeInlineDelimiter
|
||||
- mdQuoteBorder
|
||||
- mdLinkUrl
|
||||
|
||||
**"Primary accent" family** (blue-ish):
|
||||
- border
|
||||
- interactionDefault
|
||||
- interactionHover
|
||||
- interactionActive
|
||||
- brandPrimary
|
||||
- mdLinkText
|
||||
|
||||
**"Success" family** (green-ish):
|
||||
- feedbackSuccess
|
||||
- interactionSuccess
|
||||
- toolStdout
|
||||
- mdCodeBlock
|
||||
|
||||
**"Error" family** (red-ish):
|
||||
- feedbackError
|
||||
- toolStderr
|
||||
|
||||
**"Code/Tech" family** (cyan-ish):
|
||||
- brandPrimary
|
||||
- mdCodeInline
|
||||
- mdListBullet
|
||||
- spinner
|
||||
|
||||
**"Emphasis" family** (yellow-ish):
|
||||
- mdHeading1
|
||||
- mdHeading2
|
||||
- feedbackWarning
|
||||
|
||||
## Simplified Minimal Set (Alternative)
|
||||
|
||||
If we want to reduce further, we could consolidate to ~25 colors by using more shared values:
|
||||
|
||||
### Core Colors (8)
|
||||
- **text** - Primary text
|
||||
- **textMuted** - Secondary/dimmed text
|
||||
- **accent** - Primary accent (blue)
|
||||
- **accentSubtle** - Subtle accent
|
||||
- **success** - Green
|
||||
- **error** - Red
|
||||
- **warning** - Yellow
|
||||
- **info** - Cyan
|
||||
|
||||
### Backgrounds (4)
|
||||
- **bgDefault** - Transparent/default
|
||||
- **bgUserMessage** - User message background
|
||||
- **bgSuccess** - Success state background
|
||||
- **bgError** - Error state background
|
||||
|
||||
### Specialized (13)
|
||||
- **border** - Primary borders
|
||||
- **borderSubtle** - Subtle borders
|
||||
- **selection** - Selected items
|
||||
- **brand** - Brand/logo color
|
||||
- **mdHeading** - All headings (or separate h1/h2)
|
||||
- **mdCode** - All code (blocks + inline)
|
||||
- **mdCodeDelimiter** - Code delimiters
|
||||
- **mdList** - List bullets
|
||||
- **mdLink** - Links
|
||||
- **mdQuote** - Quotes
|
||||
- **toolCommand** - Command text
|
||||
- **toolPath** - File paths
|
||||
- **spinner** - Loading indicator
|
||||
|
||||
**Total: 25 colors** (vs 44 in the detailed version)
|
||||
|
||||
## Recommendation
|
||||
|
||||
Start with the **44-color detailed set** because:
|
||||
1. Gives maximum flexibility for theming
|
||||
2. Each has a clear semantic purpose
|
||||
3. Themes can set many to the same value if desired
|
||||
4. Easier to add granular control than to split apart later
|
||||
|
||||
Users creating themes can start by setting common values and override specific ones:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-theme",
|
||||
"_comment": "Set common values first",
|
||||
"textSecondary": "gray",
|
||||
"textTertiary": "gray",
|
||||
"borderSubtle": "gray",
|
||||
"mdCodeBlockDelimiter": "gray",
|
||||
|
||||
"_comment": "Then override specific ones",
|
||||
"mdHeading1": "yellow",
|
||||
"error": "red"
|
||||
}
|
||||
```
|
||||
563
packages/coding-agent/docs/theme.md
Normal file
563
packages/coding-agent/docs/theme.md
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
# Pi Coding Agent Themes
|
||||
|
||||
Themes allow you to customize the colors used throughout the coding agent TUI.
|
||||
|
||||
## Color Tokens
|
||||
|
||||
Every theme must define all color tokens. There are no optional colors.
|
||||
|
||||
### Core UI (9 colors)
|
||||
|
||||
| Token | Purpose | Examples |
|
||||
|-------|---------|----------|
|
||||
| `accent` | Primary accent color | Logo, selected items, cursor (›) |
|
||||
| `border` | Normal borders | Selector borders, horizontal lines |
|
||||
| `borderAccent` | Highlighted borders | Changelog borders, special panels |
|
||||
| `borderMuted` | Subtle borders | Editor borders, secondary separators |
|
||||
| `success` | Success states | Success messages, diff additions |
|
||||
| `error` | Error states | Error messages, diff deletions |
|
||||
| `warning` | Warning states | Warning messages |
|
||||
| `muted` | Secondary/dimmed text | Metadata, descriptions, output |
|
||||
| `text` | Default text color | Main content (usually `""`) |
|
||||
|
||||
### Backgrounds & Content Text (6 colors)
|
||||
|
||||
| Token | Purpose |
|
||||
|-------|---------|
|
||||
| `userMessageBg` | User message background |
|
||||
| `userMessageText` | User message text color |
|
||||
| `toolPendingBg` | Tool execution box (pending state) |
|
||||
| `toolSuccessBg` | Tool execution box (success state) |
|
||||
| `toolErrorBg` | Tool execution box (error state) |
|
||||
| `toolText` | Tool execution box text color (all states) |
|
||||
|
||||
### Markdown (9 colors)
|
||||
|
||||
| Token | Purpose |
|
||||
|-------|---------|
|
||||
| `mdHeading` | Heading text (`#`, `##`, etc) |
|
||||
| `mdLink` | Link text and URLs |
|
||||
| `mdCode` | Inline code (backticks) |
|
||||
| `mdCodeBlock` | Code block content |
|
||||
| `mdCodeBlockBorder` | Code block fences (```) |
|
||||
| `mdQuote` | Blockquote text |
|
||||
| `mdQuoteBorder` | Blockquote border (`│`) |
|
||||
| `mdHr` | Horizontal rule (`---`) |
|
||||
| `mdListBullet` | List bullets/numbers |
|
||||
|
||||
### Tool Diffs (3 colors)
|
||||
|
||||
| Token | Purpose |
|
||||
|-------|---------|
|
||||
| `toolDiffAdded` | Added lines in tool diffs |
|
||||
| `toolDiffRemoved` | Removed lines in tool diffs |
|
||||
| `toolDiffContext` | Context lines in tool diffs |
|
||||
|
||||
Note: Diff colors are specific to tool execution boxes and must work with tool background colors.
|
||||
|
||||
### Syntax Highlighting (9 colors)
|
||||
|
||||
Future-proofing for syntax highlighting support:
|
||||
|
||||
| Token | Purpose |
|
||||
|-------|---------|
|
||||
| `syntaxComment` | Comments |
|
||||
| `syntaxKeyword` | Keywords (`if`, `function`, etc) |
|
||||
| `syntaxFunction` | Function names |
|
||||
| `syntaxVariable` | Variable names |
|
||||
| `syntaxString` | String literals |
|
||||
| `syntaxNumber` | Number literals |
|
||||
| `syntaxType` | Type names |
|
||||
| `syntaxOperator` | Operators (`+`, `-`, etc) |
|
||||
| `syntaxPunctuation` | Punctuation (`;`, `,`, etc) |
|
||||
|
||||
**Total: 36 color tokens** (all required)
|
||||
|
||||
## Theme Format
|
||||
|
||||
Themes are defined in JSON files with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://pi.mariozechner.at/theme-schema.json",
|
||||
"name": "my-theme",
|
||||
"vars": {
|
||||
"blue": "#0066cc",
|
||||
"gray": 242,
|
||||
"brightCyan": 51
|
||||
},
|
||||
"colors": {
|
||||
"accent": "blue",
|
||||
"muted": "gray",
|
||||
"text": "",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Color Values
|
||||
|
||||
Four formats are supported:
|
||||
|
||||
1. **Hex colors**: `"#ff0000"` (6-digit hex RGB)
|
||||
2. **256-color palette**: `39` (number 0-255, xterm 256-color palette)
|
||||
3. **Color references**: `"blue"` (must be defined in `vars`)
|
||||
4. **Terminal default**: `""` (empty string, uses terminal's default color)
|
||||
|
||||
### The `vars` Section
|
||||
|
||||
The optional `vars` section allows you to define reusable colors:
|
||||
|
||||
```json
|
||||
{
|
||||
"vars": {
|
||||
"nord0": "#2E3440",
|
||||
"nord1": "#3B4252",
|
||||
"nord8": "#88C0D0",
|
||||
"brightBlue": 39
|
||||
},
|
||||
"colors": {
|
||||
"accent": "nord8",
|
||||
"muted": "nord1",
|
||||
"mdLink": "brightBlue"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- Reuse colors across multiple tokens
|
||||
- Easier to maintain theme consistency
|
||||
- Can reference standard color palettes
|
||||
|
||||
Variables can be hex colors (`"#ff0000"`), 256-color indices (`42`), or references to other variables.
|
||||
|
||||
### Terminal Default (empty string)
|
||||
|
||||
Use `""` (empty string) to inherit the terminal's default foreground/background color:
|
||||
|
||||
```json
|
||||
{
|
||||
"colors": {
|
||||
"text": "" // Uses terminal's default text color
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is useful for:
|
||||
- Main text color (adapts to user's terminal theme)
|
||||
- Creating themes that blend with terminal appearance
|
||||
|
||||
## Built-in Themes
|
||||
|
||||
Pi comes with two built-in themes:
|
||||
|
||||
### `dark` (default)
|
||||
|
||||
Optimized for dark terminal backgrounds with bright, saturated colors.
|
||||
|
||||
### `light`
|
||||
|
||||
Optimized for light terminal backgrounds with darker, muted colors.
|
||||
|
||||
## Selecting a Theme
|
||||
|
||||
Themes are configured in the settings (accessible via `/settings`):
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": "dark"
|
||||
}
|
||||
```
|
||||
|
||||
Or use the `/theme` command interactively.
|
||||
|
||||
On first run, Pi detects your terminal's background and sets a sensible default (`dark` or `light`).
|
||||
|
||||
## Custom Themes
|
||||
|
||||
### Theme Locations
|
||||
|
||||
Custom themes are loaded from `~/.pi/agent/themes/*.json`.
|
||||
|
||||
### Creating a Custom Theme
|
||||
|
||||
1. **Create theme directory:**
|
||||
```bash
|
||||
mkdir -p ~/.pi/agent/themes
|
||||
```
|
||||
|
||||
2. **Create theme file:**
|
||||
```bash
|
||||
vim ~/.pi/agent/themes/my-theme.json
|
||||
```
|
||||
|
||||
3. **Define all colors:**
|
||||
```json
|
||||
{
|
||||
"$schema": "https://pi.mariozechner.at/theme-schema.json",
|
||||
"name": "my-theme",
|
||||
"vars": {
|
||||
"primary": "#00aaff",
|
||||
"secondary": 242,
|
||||
"brightGreen": 46
|
||||
},
|
||||
"colors": {
|
||||
"accent": "primary",
|
||||
"border": "primary",
|
||||
"borderAccent": "#00ffff",
|
||||
"borderMuted": "secondary",
|
||||
"success": "brightGreen",
|
||||
"error": "#ff0000",
|
||||
"warning": "#ffff00",
|
||||
"muted": "secondary",
|
||||
"text": "",
|
||||
|
||||
"userMessageBg": "#2d2d30",
|
||||
"userMessageText": "",
|
||||
"toolPendingBg": "#1e1e2e",
|
||||
"toolSuccessBg": "#1e2e1e",
|
||||
"toolErrorBg": "#2e1e1e",
|
||||
"toolText": "",
|
||||
|
||||
"mdHeading": "#ffaa00",
|
||||
"mdLink": "primary",
|
||||
"mdCode": "#00ffff",
|
||||
"mdCodeBlock": "#00ff00",
|
||||
"mdCodeBlockBorder": "secondary",
|
||||
"mdQuote": "secondary",
|
||||
"mdQuoteBorder": "secondary",
|
||||
"mdHr": "secondary",
|
||||
"mdListBullet": "#00ffff",
|
||||
|
||||
"toolDiffAdded": "#00ff00",
|
||||
"toolDiffRemoved": "#ff0000",
|
||||
"toolDiffContext": "secondary",
|
||||
|
||||
"syntaxComment": "secondary",
|
||||
"syntaxKeyword": "primary",
|
||||
"syntaxFunction": "#00aaff",
|
||||
"syntaxVariable": "#ffaa00",
|
||||
"syntaxString": "#00ff00",
|
||||
"syntaxNumber": "#ff00ff",
|
||||
"syntaxType": "#00aaff",
|
||||
"syntaxOperator": "primary",
|
||||
"syntaxPunctuation": "secondary"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Select your theme:**
|
||||
- Use `/settings` command and set `"theme": "my-theme"`
|
||||
- Or use `/theme` command interactively
|
||||
|
||||
## Tips
|
||||
|
||||
### Light vs Dark Themes
|
||||
|
||||
**For dark terminals:**
|
||||
- Use bright, saturated colors
|
||||
- Higher contrast
|
||||
- Example: `#00ffff` (bright cyan)
|
||||
|
||||
**For light terminals:**
|
||||
- Use darker, muted colors
|
||||
- Lower contrast to avoid eye strain
|
||||
- Example: `#008888` (dark cyan)
|
||||
|
||||
### Color Harmony
|
||||
|
||||
- Start with a base palette (e.g., Nord, Gruvbox, Tokyo Night)
|
||||
- Define your palette in `defs`
|
||||
- Reference colors consistently
|
||||
|
||||
### Testing
|
||||
|
||||
Test your theme with:
|
||||
- Different message types (user, assistant, errors)
|
||||
- Tool executions (success and error states)
|
||||
- Markdown content (headings, code, lists, etc)
|
||||
- Long text that wraps
|
||||
|
||||
## Color Format Reference
|
||||
|
||||
### Hex Colors
|
||||
|
||||
Standard 6-digit hex format:
|
||||
- `"#ff0000"` - Red
|
||||
- `"#00ff00"` - Green
|
||||
- `"#0000ff"` - Blue
|
||||
- `"#808080"` - Gray
|
||||
- `"#ffffff"` - White
|
||||
- `"#000000"` - Black
|
||||
|
||||
RGB values: `#RRGGBB` where each component is `00-ff` (0-255)
|
||||
|
||||
### 256-Color Palette
|
||||
|
||||
Use numeric indices (0-255) to reference the xterm 256-color palette:
|
||||
|
||||
**Colors 0-15:** Basic ANSI colors (terminal-dependent, may be themed)
|
||||
- `0` - Black
|
||||
- `1` - Red
|
||||
- `2` - Green
|
||||
- `3` - Yellow
|
||||
- `4` - Blue
|
||||
- `5` - Magenta
|
||||
- `6` - Cyan
|
||||
- `7` - White
|
||||
- `8-15` - Bright variants
|
||||
|
||||
**Colors 16-231:** 6×6×6 RGB cube (standardized)
|
||||
- Formula: `16 + 36×R + 6×G + B` where R, G, B are 0-5
|
||||
- Example: `39` = bright cyan, `196` = bright red
|
||||
|
||||
**Colors 232-255:** Grayscale ramp (standardized)
|
||||
- `232` - Darkest gray
|
||||
- `255` - Near white
|
||||
|
||||
Example usage:
|
||||
```json
|
||||
{
|
||||
"vars": {
|
||||
"gray": 242,
|
||||
"brightCyan": 51,
|
||||
"darkBlue": 18
|
||||
},
|
||||
"colors": {
|
||||
"muted": "gray",
|
||||
"accent": "brightCyan"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Works everywhere (`TERM=xterm-256color`)
|
||||
- No truecolor detection needed
|
||||
- Standardized RGB cube (16-231) looks the same on all terminals
|
||||
|
||||
### Terminal Compatibility
|
||||
|
||||
Pi uses 24-bit RGB colors (`\x1b[38;2;R;G;Bm`). Most modern terminals support this:
|
||||
|
||||
- ✅ iTerm2, Alacritty, Kitty, WezTerm
|
||||
- ✅ Windows Terminal
|
||||
- ✅ VS Code integrated terminal
|
||||
- ✅ Modern GNOME Terminal, Konsole
|
||||
|
||||
For older terminals with only 256-color support, Pi automatically falls back to the nearest 256-color approximation.
|
||||
|
||||
To check if your terminal supports truecolor:
|
||||
```bash
|
||||
echo $COLORTERM # Should output "truecolor" or "24bit"
|
||||
```
|
||||
|
||||
## Example Themes
|
||||
|
||||
See the built-in themes for complete examples:
|
||||
- [Dark theme](../src/themes/dark.json)
|
||||
- [Light theme](../src/themes/light.json)
|
||||
|
||||
## Schema Validation
|
||||
|
||||
Themes are validated on load using [TypeBox](https://github.com/sinclairzx81/typebox) + [Ajv](https://ajv.js.org/).
|
||||
|
||||
Invalid themes will show an error with details about what's wrong:
|
||||
```
|
||||
Error loading theme 'my-theme':
|
||||
- colors.accent: must be string or number
|
||||
- colors.mdHeading: required property missing
|
||||
```
|
||||
|
||||
For editor support, the JSON schema is available at:
|
||||
```
|
||||
https://pi.mariozechner.at/theme-schema.json
|
||||
```
|
||||
|
||||
Add to your theme file for auto-completion and validation:
|
||||
```json
|
||||
{
|
||||
"$schema": "https://pi.mariozechner.at/theme-schema.json",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
### Theme Class
|
||||
|
||||
Themes are loaded and converted to a `Theme` class that provides type-safe color methods:
|
||||
|
||||
```typescript
|
||||
class Theme {
|
||||
// Apply foreground color
|
||||
fg(color: ThemeColor, text: string): string
|
||||
|
||||
// Apply background color
|
||||
bg(color: ThemeBg, text: string): string
|
||||
|
||||
// Text attributes (preserve current colors)
|
||||
bold(text: string): string
|
||||
dim(text: string): string
|
||||
italic(text: string): string
|
||||
}
|
||||
```
|
||||
|
||||
### Global Theme Instance
|
||||
|
||||
The active theme is available as a global singleton in `coding-agent`:
|
||||
|
||||
```typescript
|
||||
// theme.ts
|
||||
export let theme: Theme;
|
||||
|
||||
export function setTheme(name: string) {
|
||||
theme = loadTheme(name);
|
||||
}
|
||||
|
||||
// Usage throughout coding-agent
|
||||
import { theme } from './theme.js';
|
||||
|
||||
theme.fg('accent', 'Selected')
|
||||
theme.bg('userMessageBg', content)
|
||||
```
|
||||
|
||||
### TUI Component Theming
|
||||
|
||||
TUI components (like `Markdown`, `SelectList`, `Editor`) are in the `@mariozechner/pi-tui` package and don't have direct access to the theme. Instead, they define interfaces for the colors they need:
|
||||
|
||||
```typescript
|
||||
// In @mariozechner/pi-tui
|
||||
export interface MarkdownTheme {
|
||||
heading: (text: string) => string;
|
||||
link: (text: string) => string;
|
||||
code: (text: string) => string;
|
||||
codeBlock: (text: string) => string;
|
||||
codeBlockBorder: (text: string) => string;
|
||||
quote: (text: string) => string;
|
||||
quoteBorder: (text: string) => string;
|
||||
hr: (text: string) => string;
|
||||
listBullet: (text: string) => string;
|
||||
}
|
||||
|
||||
export class Markdown {
|
||||
constructor(
|
||||
text: string,
|
||||
paddingX: number,
|
||||
paddingY: number,
|
||||
defaultTextStyle?: DefaultTextStyle,
|
||||
theme?: MarkdownTheme // Optional theme functions
|
||||
)
|
||||
|
||||
// Usage in component
|
||||
renderHeading(text: string) {
|
||||
return this.theme.heading(text); // Applies color
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `coding-agent` provides themed functions when creating components:
|
||||
|
||||
```typescript
|
||||
// In coding-agent
|
||||
import { theme } from './theme.js';
|
||||
import { Markdown } from '@mariozechner/pi-tui';
|
||||
|
||||
// Helper to create markdown theme functions
|
||||
function getMarkdownTheme(): MarkdownTheme {
|
||||
return {
|
||||
heading: (text) => theme.fg('mdHeading', text),
|
||||
link: (text) => theme.fg('mdLink', text),
|
||||
code: (text) => theme.fg('mdCode', text),
|
||||
codeBlock: (text) => theme.fg('mdCodeBlock', text),
|
||||
codeBlockBorder: (text) => theme.fg('mdCodeBlockBorder', text),
|
||||
quote: (text) => theme.fg('mdQuote', text),
|
||||
quoteBorder: (text) => theme.fg('mdQuoteBorder', text),
|
||||
hr: (text) => theme.fg('mdHr', text),
|
||||
listBullet: (text) => theme.fg('mdListBullet', text),
|
||||
};
|
||||
}
|
||||
|
||||
// Create markdown with theme
|
||||
const md = new Markdown(
|
||||
text,
|
||||
1, 1,
|
||||
{ bgColor: theme.bg('userMessageBg') },
|
||||
getMarkdownTheme()
|
||||
);
|
||||
```
|
||||
|
||||
This approach:
|
||||
- Keeps TUI components theme-agnostic (reusable in other projects)
|
||||
- Maintains type safety via interfaces
|
||||
- Allows components to have sensible defaults if no theme provided
|
||||
- Centralizes theme access in `coding-agent`
|
||||
|
||||
**Example usage:**
|
||||
```typescript
|
||||
const theme = loadTheme('dark');
|
||||
|
||||
// Apply foreground colors
|
||||
theme.fg('accent', 'Selected')
|
||||
theme.fg('success', '✓ Done')
|
||||
theme.fg('error', 'Failed')
|
||||
|
||||
// Apply background colors
|
||||
theme.bg('userMessageBg', content)
|
||||
theme.bg('toolSuccessBg', output)
|
||||
|
||||
// Combine styles
|
||||
theme.bold(theme.fg('accent', 'Title'))
|
||||
theme.dim(theme.fg('muted', 'metadata'))
|
||||
|
||||
// Nested foreground + background
|
||||
const userMsg = theme.bg('userMessageBg',
|
||||
theme.fg('userMessageText', 'Hello')
|
||||
)
|
||||
```
|
||||
|
||||
**Color resolution:**
|
||||
|
||||
1. **Detect terminal capabilities:**
|
||||
- Check `$COLORTERM` env var (`truecolor` or `24bit` → truecolor support)
|
||||
- Check `$TERM` env var (`*-256color` → 256-color support)
|
||||
- Fallback to 256-color mode if detection fails
|
||||
|
||||
2. **Load JSON theme file**
|
||||
|
||||
3. **Resolve `vars` references recursively:**
|
||||
```json
|
||||
{
|
||||
"vars": {
|
||||
"primary": "#0066cc",
|
||||
"accent": "primary"
|
||||
},
|
||||
"colors": {
|
||||
"accent": "accent" // → "primary" → "#0066cc"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Convert colors to ANSI codes based on terminal capability:**
|
||||
|
||||
**Truecolor mode (24-bit):**
|
||||
- Hex (`"#ff0000"`) → `\x1b[38;2;255;0;0m`
|
||||
- 256-color (`42`) → `\x1b[38;5;42m` (keep as-is)
|
||||
- Empty string (`""`) → `\x1b[39m`
|
||||
|
||||
**256-color mode:**
|
||||
- Hex (`"#ff0000"`) → convert to nearest RGB cube color → `\x1b[38;5;196m`
|
||||
- 256-color (`42`) → `\x1b[38;5;42m` (keep as-is)
|
||||
- Empty string (`""`) → `\x1b[39m`
|
||||
|
||||
**Hex to 256-color conversion:**
|
||||
```typescript
|
||||
// Convert RGB to 6x6x6 cube (colors 16-231)
|
||||
r_index = Math.round(r / 255 * 5)
|
||||
g_index = Math.round(g / 255 * 5)
|
||||
b_index = Math.round(b / 255 * 5)
|
||||
color_index = 16 + 36 * r_index + 6 * g_index + b_index
|
||||
```
|
||||
|
||||
5. **Cache as `Theme` instance**
|
||||
|
||||
This ensures themes work correctly regardless of terminal capabilities, with graceful degradation from truecolor to 256-color.
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
# Theme System Analysis
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Issue #7: In terminals with light backgrounds, some outputs use dark colors that are hard to read. We need a theme system that allows users to choose between light and dark themes.
|
||||
|
||||
## Current Color Usage Analysis
|
||||
|
||||
### Color Usage Statistics
|
||||
|
||||
Total chalk color calls: 132 across 14 files
|
||||
|
||||
Most frequent colors:
|
||||
- `chalk.dim` (48 occurrences) - Used for secondary text
|
||||
- `chalk.gray` (28 occurrences) - Used for borders, metadata, dimmed content
|
||||
- `chalk.bold` (20 occurrences) - Used for emphasis
|
||||
- `chalk.blue` (12 occurrences) - Used for selections, borders, links
|
||||
- `chalk.cyan` (9 occurrences) - Used for primary UI elements (logo, list bullets, code)
|
||||
- `chalk.red` (7 occurrences) - Used for errors, stderr output
|
||||
- `chalk.green` (6 occurrences) - Used for success, stdout output
|
||||
- `chalk.yellow` (3 occurrences) - Used for headings in markdown
|
||||
- `chalk.bgRgb` (6 occurrences) - Used for custom backgrounds in Text/Markdown
|
||||
|
||||
### Files Using Colors
|
||||
|
||||
#### coding-agent Package
|
||||
1. **main.ts** - CLI output messages
|
||||
2. **tui/assistant-message.ts** - Thinking text (gray italic), errors (red), aborted (red)
|
||||
3. **tui/dynamic-border.ts** - Configurable border color (default blue)
|
||||
4. **tui/footer.ts** - Stats and pwd (gray)
|
||||
5. **tui/model-selector.ts** - Borders (blue), selection arrow (blue), provider badge (gray), checkmark (green)
|
||||
6. **tui/session-selector.ts** - Border (blue), selection cursor (blue), metadata (dim)
|
||||
7. **tui/thinking-selector.ts** - Border (blue)
|
||||
8. **tui/tool-execution.ts** - stdout (green), stderr (red), dim lines (dim), line numbers
|
||||
9. **tui/tui-renderer.ts** - Logo (bold cyan), instructions (dim/gray)
|
||||
|
||||
#### tui Package
|
||||
1. **components/editor.ts** - Horizontal border (gray)
|
||||
2. **components/loader.ts** - Spinner (cyan), message (dim)
|
||||
3. **components/markdown.ts** - Complex color system:
|
||||
- H1 headings: bold.underline.yellow
|
||||
- H2 headings: bold.yellow
|
||||
- H3+ headings: bold
|
||||
- Code blocks: gray (delimiters), dim (indent), green (code)
|
||||
- List bullets: cyan
|
||||
- Blockquotes: gray (pipe), italic (text)
|
||||
- Horizontal rules: gray
|
||||
- Inline code: gray (backticks), cyan (code)
|
||||
- Links: underline.blue (text), gray (URL)
|
||||
- Strikethrough: strikethrough
|
||||
- Tables: bold (headers)
|
||||
4. **components/select-list.ts** - No matches (gray), selection arrow (blue), selected item (blue), description (gray)
|
||||
5. **components/text.ts** - Custom bgRgb support
|
||||
|
||||
### Color System Architecture
|
||||
|
||||
#### Current Implementation
|
||||
- Colors are hardcoded using `chalk` directly
|
||||
- No centralized theme management
|
||||
- No way to switch themes at runtime
|
||||
- Some components accept color parameters (e.g., DynamicBorder, Text, Markdown)
|
||||
|
||||
#### Markdown Component Color System
|
||||
The Markdown component has a `Color` type enum:
|
||||
```typescript
|
||||
type Color = "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan" | "white" | "gray" |
|
||||
"bgBlack" | "bgRed" | "bgGreen" | "bgYellow" | "bgBlue" | "bgMagenta" | "bgCyan" | "bgWhite" | "bgGray"
|
||||
```
|
||||
|
||||
It accepts optional `bgColor` and `fgColor` parameters, plus `customBgRgb`.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### Theme Structure
|
||||
|
||||
Create a centralized theme system with semantic color names:
|
||||
|
||||
```typescript
|
||||
interface Theme {
|
||||
name: string;
|
||||
|
||||
// UI Chrome
|
||||
border: ChalkFunction;
|
||||
selection: ChalkFunction;
|
||||
selectionText: ChalkFunction;
|
||||
|
||||
// Text hierarchy
|
||||
primary: ChalkFunction;
|
||||
secondary: ChalkFunction;
|
||||
dim: ChalkFunction;
|
||||
|
||||
// Semantic colors
|
||||
error: ChalkFunction;
|
||||
success: ChalkFunction;
|
||||
warning: ChalkFunction;
|
||||
info: ChalkFunction;
|
||||
|
||||
// Code/output
|
||||
code: ChalkFunction;
|
||||
codeDelimiter: ChalkFunction;
|
||||
stdout: ChalkFunction;
|
||||
stderr: ChalkFunction;
|
||||
|
||||
// Markdown specific
|
||||
heading1: ChalkFunction;
|
||||
heading2: ChalkFunction;
|
||||
heading3: ChalkFunction;
|
||||
link: ChalkFunction;
|
||||
linkUrl: ChalkFunction;
|
||||
listBullet: ChalkFunction;
|
||||
blockquote: ChalkFunction;
|
||||
blockquotePipe: ChalkFunction;
|
||||
inlineCode: ChalkFunction;
|
||||
inlineCodeDelimiter: ChalkFunction;
|
||||
|
||||
// Backgrounds (optional, for components like Text/Markdown)
|
||||
backgroundRgb?: { r: number; g: number; b: number };
|
||||
}
|
||||
|
||||
type ChalkFunction = (str: string) => string;
|
||||
```
|
||||
|
||||
### Built-in Themes
|
||||
|
||||
#### Dark Theme (current default)
|
||||
```typescript
|
||||
const darkTheme: Theme = {
|
||||
name: "dark",
|
||||
border: chalk.blue,
|
||||
selection: chalk.blue,
|
||||
selectionText: chalk.blue,
|
||||
primary: (s) => s, // no color
|
||||
secondary: chalk.gray,
|
||||
dim: chalk.dim,
|
||||
error: chalk.red,
|
||||
success: chalk.green,
|
||||
warning: chalk.yellow,
|
||||
info: chalk.cyan,
|
||||
code: chalk.green,
|
||||
codeDelimiter: chalk.gray,
|
||||
stdout: chalk.green,
|
||||
stderr: chalk.red,
|
||||
heading1: chalk.bold.underline.yellow,
|
||||
heading2: chalk.bold.yellow,
|
||||
heading3: chalk.bold,
|
||||
link: chalk.underline.blue,
|
||||
linkUrl: chalk.gray,
|
||||
listBullet: chalk.cyan,
|
||||
blockquote: chalk.italic,
|
||||
blockquotePipe: chalk.gray,
|
||||
inlineCode: chalk.cyan,
|
||||
inlineCodeDelimiter: chalk.gray,
|
||||
};
|
||||
```
|
||||
|
||||
#### Light Theme
|
||||
```typescript
|
||||
const lightTheme: Theme = {
|
||||
name: "light",
|
||||
border: chalk.blue,
|
||||
selection: chalk.blue,
|
||||
selectionText: chalk.blue.bold,
|
||||
primary: (s) => s,
|
||||
secondary: chalk.gray,
|
||||
dim: chalk.gray, // Don't use chalk.dim on light backgrounds
|
||||
error: chalk.red.bold,
|
||||
success: chalk.green.bold,
|
||||
warning: chalk.yellow.bold,
|
||||
info: chalk.cyan.bold,
|
||||
code: chalk.green.bold,
|
||||
codeDelimiter: chalk.gray,
|
||||
stdout: chalk.green.bold,
|
||||
stderr: chalk.red.bold,
|
||||
heading1: chalk.bold.underline.blue,
|
||||
heading2: chalk.bold.blue,
|
||||
heading3: chalk.bold,
|
||||
link: chalk.underline.blue,
|
||||
linkUrl: chalk.blue,
|
||||
listBullet: chalk.blue.bold,
|
||||
blockquote: chalk.italic,
|
||||
blockquotePipe: chalk.gray,
|
||||
inlineCode: chalk.blue.bold,
|
||||
inlineCodeDelimiter: chalk.gray,
|
||||
};
|
||||
```
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
#### 1. Create Theme Module
|
||||
**Location:** `packages/tui/src/theme.ts`
|
||||
|
||||
```typescript
|
||||
export interface Theme { ... }
|
||||
export const darkTheme: Theme = { ... };
|
||||
export const lightTheme: Theme = { ... };
|
||||
export const themes = { dark: darkTheme, light: lightTheme };
|
||||
|
||||
let currentTheme: Theme = darkTheme;
|
||||
|
||||
export function setTheme(theme: Theme): void {
|
||||
currentTheme = theme;
|
||||
}
|
||||
|
||||
export function getTheme(): Theme {
|
||||
return currentTheme;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Update Settings Manager
|
||||
**Location:** `packages/coding-agent/src/settings-manager.ts`
|
||||
|
||||
Add `theme` field to Settings interface:
|
||||
```typescript
|
||||
export interface Settings {
|
||||
lastChangelogVersion?: string;
|
||||
theme?: "dark" | "light";
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Create Theme Selector Component
|
||||
**Location:** `packages/coding-agent/src/tui/theme-selector.ts`
|
||||
|
||||
Similar to ModelSelector and ThinkingSelector, create a TUI component for selecting themes.
|
||||
|
||||
#### 4. Refactor Color Usage
|
||||
|
||||
Replace all hardcoded `chalk.*` calls with `theme.*`:
|
||||
|
||||
**Example - Before:**
|
||||
```typescript
|
||||
lines.push(chalk.blue("─".repeat(width)));
|
||||
const cursor = chalk.blue("› ");
|
||||
```
|
||||
|
||||
**Example - After:**
|
||||
```typescript
|
||||
const theme = getTheme();
|
||||
lines.push(theme.border("─".repeat(width)));
|
||||
const cursor = theme.selection("› ");
|
||||
```
|
||||
|
||||
#### 5. Update Components
|
||||
|
||||
##### High Priority (User-facing content issues)
|
||||
1. **markdown.ts** - Update all color calls to use theme
|
||||
2. **tool-execution.ts** - stdout/stderr colors
|
||||
3. **assistant-message.ts** - Error messages
|
||||
4. **tui-renderer.ts** - Logo and instructions
|
||||
5. **footer.ts** - Stats display
|
||||
|
||||
##### Medium Priority (UI chrome)
|
||||
6. **dynamic-border.ts** - Accept theme parameter
|
||||
7. **model-selector.ts** - Selection colors
|
||||
8. **session-selector.ts** - Selection colors
|
||||
9. **thinking-selector.ts** - Border colors
|
||||
10. **select-list.ts** - Selection colors
|
||||
11. **loader.ts** - Spinner color
|
||||
12. **editor.ts** - Border color
|
||||
|
||||
##### Low Priority (CLI output)
|
||||
13. **main.ts** - CLI messages
|
||||
|
||||
#### 6. Add Theme Slash Command
|
||||
**Location:** `packages/coding-agent/src/tui/tui-renderer.ts`
|
||||
|
||||
Add `/theme` command similar to `/model` and `/thinking`.
|
||||
|
||||
#### 7. Initialize Theme on Startup
|
||||
**Location:** `packages/coding-agent/src/main.ts`
|
||||
|
||||
```typescript
|
||||
// Load theme from settings
|
||||
const settingsManager = new SettingsManager();
|
||||
const themeName = settingsManager.getTheme() || "dark";
|
||||
const theme = themes[themeName] || darkTheme;
|
||||
setTheme(theme);
|
||||
```
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
1. **Phase 1:** Create theme infrastructure (theme.ts, types, built-in themes)
|
||||
2. **Phase 2:** Update TUI package components (markdown, text, loader, editor, select-list)
|
||||
3. **Phase 3:** Update coding-agent TUI components (all tui/*.ts files)
|
||||
4. **Phase 4:** Add theme selector and persistence
|
||||
5. **Phase 5:** Update CLI output in main.ts (optional, low priority)
|
||||
|
||||
### Testing Plan
|
||||
|
||||
1. Test both themes in terminals with light backgrounds
|
||||
2. Test both themes in terminals with dark backgrounds
|
||||
3. Verify theme switching works at runtime via `/theme`
|
||||
4. Verify theme persists across sessions via settings.json
|
||||
5. Test all components for readability in both themes
|
||||
|
||||
### Open Questions
|
||||
|
||||
1. Should we support custom user themes loaded from a JSON file?
|
||||
2. Should we auto-detect terminal background color and choose theme automatically?
|
||||
3. Should theme apply to background colors used in Text/Markdown components?
|
||||
4. Do we need more than two themes initially?
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
None - the default theme will remain "dark" matching current behavior.
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- Theme getter is called frequently (on every render)
|
||||
- Should be a simple variable access, not a function call chain
|
||||
- Consider caching theme functions if performance becomes an issue
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@mariozechner/pi-coding-agent",
|
||||
"version": "0.7.10",
|
||||
"version": "0.7.25",
|
||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
|
@ -21,8 +21,8 @@
|
|||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-agent": "^0.7.10",
|
||||
"@mariozechner/pi-ai": "^0.7.10",
|
||||
"@mariozechner/pi-agent": "^0.7.25",
|
||||
"@mariozechner/pi-ai": "^0.7.25",
|
||||
"chalk": "^5.5.0",
|
||||
"diff": "^8.0.2",
|
||||
"glob": "^11.0.3"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Agent, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent";
|
||||
import { getModel, type KnownProvider } from "@mariozechner/pi-ai";
|
||||
import type { Api, KnownProvider, Model } from "@mariozechner/pi-ai";
|
||||
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
|
||||
import chalk from "chalk";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
|
|
@ -7,6 +7,7 @@ import { homedir } from "os";
|
|||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js";
|
||||
import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js";
|
||||
import { SessionManager } from "./session-manager.js";
|
||||
import { SettingsManager } from "./settings-manager.js";
|
||||
import { codingTools } from "./tools/index.js";
|
||||
|
|
@ -30,6 +31,17 @@ const envApiKeyMap: Record<KnownProvider, string[]> = {
|
|||
zai: ["ZAI_API_KEY"],
|
||||
};
|
||||
|
||||
const defaultModelPerProvider: Record<KnownProvider, string> = {
|
||||
anthropic: "claude-sonnet-4-5",
|
||||
openai: "gpt-5.1-codex",
|
||||
google: "gemini-2.5-pro",
|
||||
openrouter: "openai/gpt-5.1-codex",
|
||||
xai: "grok-4-fast-non-reasoning",
|
||||
groq: "openai/gpt-oss-120b",
|
||||
cerebras: "zai-glm-4.6",
|
||||
zai: "glm-4.6",
|
||||
};
|
||||
|
||||
type Mode = "text" | "json" | "rpc";
|
||||
|
||||
interface Args {
|
||||
|
|
@ -43,6 +55,7 @@ interface Args {
|
|||
mode?: Mode;
|
||||
noSession?: boolean;
|
||||
session?: string;
|
||||
models?: string[];
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +90,8 @@ function parseArgs(args: string[]): Args {
|
|||
result.noSession = true;
|
||||
} else if (arg === "--session" && i + 1 < args.length) {
|
||||
result.session = args[++i];
|
||||
} else if (arg === "--models" && i + 1 < args.length) {
|
||||
result.models = args[++i].split(",").map((s) => s.trim());
|
||||
} else if (!arg.startsWith("-")) {
|
||||
result.messages.push(arg);
|
||||
}
|
||||
|
|
@ -86,10 +101,10 @@ function parseArgs(args: string[]): Args {
|
|||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`${chalk.bold("coding-agent")} - AI coding assistant with read, bash, edit, write tools
|
||||
console.log(`${chalk.bold("pi")} - AI coding assistant with read, bash, edit, write tools
|
||||
|
||||
${chalk.bold("Usage:")}
|
||||
coding-agent [options] [messages...]
|
||||
pi [options] [messages...]
|
||||
|
||||
${chalk.bold("Options:")}
|
||||
--provider <name> Provider name (default: google)
|
||||
|
|
@ -101,23 +116,27 @@ ${chalk.bold("Options:")}
|
|||
--resume, -r Select a session to resume
|
||||
--session <path> Use specific session file
|
||||
--no-session Don't save session (ephemeral)
|
||||
--models <patterns> Comma-separated model patterns for quick cycling with Ctrl+P
|
||||
--help, -h Show this help
|
||||
|
||||
${chalk.bold("Examples:")}
|
||||
# Interactive mode (no messages = interactive TUI)
|
||||
coding-agent
|
||||
pi
|
||||
|
||||
# Single message
|
||||
coding-agent "List all .ts files in src/"
|
||||
pi "List all .ts files in src/"
|
||||
|
||||
# Multiple messages
|
||||
coding-agent "Read package.json" "What dependencies do we have?"
|
||||
pi "Read package.json" "What dependencies do we have?"
|
||||
|
||||
# Continue previous session
|
||||
coding-agent --continue "What did we discuss?"
|
||||
pi --continue "What did we discuss?"
|
||||
|
||||
# Use different model
|
||||
coding-agent --provider openai --model gpt-4o-mini "Help me refactor this code"
|
||||
pi --provider openai --model gpt-4o-mini "Help me refactor this code"
|
||||
|
||||
# Limit model cycling to specific models
|
||||
pi --models claude-sonnet,claude-haiku,gpt-4o
|
||||
|
||||
${chalk.bold("Environment Variables:")}
|
||||
GEMINI_API_KEY - Google Gemini API key
|
||||
|
|
@ -189,7 +208,10 @@ function buildSystemPrompt(customPrompt?: string): string {
|
|||
timeZoneName: "short",
|
||||
});
|
||||
|
||||
let prompt = `You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.
|
||||
// Get absolute path to README.md
|
||||
const readmePath = resolve(join(__dirname, "../README.md"));
|
||||
|
||||
let prompt = `You are actually not Claude, you are Pi. You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.
|
||||
|
||||
Available tools:
|
||||
- read: Read file contents
|
||||
|
|
@ -203,7 +225,12 @@ Guidelines:
|
|||
- Use edit for precise changes (old text must match exactly)
|
||||
- Use write only for new files or complete rewrites
|
||||
- Be concise in your responses
|
||||
- Show file paths clearly when working with files`;
|
||||
- Show file paths clearly when working with files
|
||||
- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did
|
||||
|
||||
Documentation:
|
||||
- Your own documentation (including custom model setup) is at: ${readmePath}
|
||||
- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider.`;
|
||||
|
||||
// Append project context files
|
||||
const contextFiles = loadProjectContextFiles();
|
||||
|
|
@ -289,6 +316,89 @@ function loadProjectContextFiles(): Array<{ path: string; content: string }> {
|
|||
return contextFiles;
|
||||
}
|
||||
|
||||
async function checkForNewVersion(currentVersion: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest");
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = (await response.json()) as { version?: string };
|
||||
const latestVersion = data.version;
|
||||
|
||||
if (latestVersion && latestVersion !== currentVersion) {
|
||||
return latestVersion;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Silently fail - don't disrupt the user experience
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve model patterns to actual Model objects
|
||||
* For each pattern, finds all matching models and picks the best version:
|
||||
* 1. Prefer alias (e.g., claude-sonnet-4-5) over dated versions (claude-sonnet-4-5-20250929)
|
||||
* 2. If no alias, pick the latest dated version
|
||||
*/
|
||||
async function resolveModelScope(patterns: string[]): Promise<Model<Api>[]> {
|
||||
const { models: availableModels, error } = await getAvailableModels();
|
||||
|
||||
if (error) {
|
||||
console.warn(chalk.yellow(`Warning: Error loading models: ${error}`));
|
||||
return [];
|
||||
}
|
||||
|
||||
const scopedModels: Model<Api>[] = [];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
// Find all models matching this pattern (case-insensitive partial match)
|
||||
const matches = availableModels.filter(
|
||||
(m) =>
|
||||
m.id.toLowerCase().includes(pattern.toLowerCase()) || m.name?.toLowerCase().includes(pattern.toLowerCase()),
|
||||
);
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Helper to check if a model ID looks like an alias (no date suffix)
|
||||
// Dates are typically in format: -20241022 or -20250929
|
||||
const isAlias = (id: string): boolean => {
|
||||
// Check if ID ends with -latest
|
||||
if (id.endsWith("-latest")) return true;
|
||||
|
||||
// Check if ID ends with a date pattern (-YYYYMMDD)
|
||||
const datePattern = /-\d{8}$/;
|
||||
return !datePattern.test(id);
|
||||
};
|
||||
|
||||
// Separate into aliases and dated versions
|
||||
const aliases = matches.filter((m) => isAlias(m.id));
|
||||
const datedVersions = matches.filter((m) => !isAlias(m.id));
|
||||
|
||||
let bestMatch: Model<Api>;
|
||||
|
||||
if (aliases.length > 0) {
|
||||
// Prefer alias - if multiple aliases, pick the one that sorts highest
|
||||
aliases.sort((a, b) => b.id.localeCompare(a.id));
|
||||
bestMatch = aliases[0];
|
||||
} else {
|
||||
// No alias found, pick latest dated version
|
||||
datedVersions.sort((a, b) => b.id.localeCompare(a.id));
|
||||
bestMatch = datedVersions[0];
|
||||
}
|
||||
|
||||
// Avoid duplicates
|
||||
if (!scopedModels.find((m) => m.id === bestMatch.id && m.provider === bestMatch.provider)) {
|
||||
scopedModels.push(bestMatch);
|
||||
}
|
||||
}
|
||||
|
||||
return scopedModels;
|
||||
}
|
||||
|
||||
async function selectSession(sessionManager: SessionManager): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const ui = new TUI(new ProcessTerminal());
|
||||
|
|
@ -321,10 +431,22 @@ async function selectSession(sessionManager: SessionManager): Promise<string | n
|
|||
async function runInteractiveMode(
|
||||
agent: Agent,
|
||||
sessionManager: SessionManager,
|
||||
settingsManager: SettingsManager,
|
||||
version: string,
|
||||
changelogMarkdown: string | null = null,
|
||||
modelFallbackMessage: string | null = null,
|
||||
newVersion: string | null = null,
|
||||
scopedModels: Model<Api>[] = [],
|
||||
): Promise<void> {
|
||||
const renderer = new TuiRenderer(agent, sessionManager, version, changelogMarkdown);
|
||||
const renderer = new TuiRenderer(
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
version,
|
||||
changelogMarkdown,
|
||||
newVersion,
|
||||
scopedModels,
|
||||
);
|
||||
|
||||
// Initialize TUI
|
||||
await renderer.init();
|
||||
|
|
@ -337,6 +459,11 @@ async function runInteractiveMode(
|
|||
// Render any existing messages (from --continue mode)
|
||||
renderer.renderInitialMessages(agent.state);
|
||||
|
||||
// Show model fallback warning at the end of the chat if applicable
|
||||
if (modelFallbackMessage) {
|
||||
renderer.showWarning(modelFallbackMessage);
|
||||
}
|
||||
|
||||
// Subscribe to agent events
|
||||
agent.subscribe(async (event) => {
|
||||
// Pass all events to the renderer
|
||||
|
|
@ -449,59 +576,208 @@ export async function main(args: string[]) {
|
|||
sessionManager.setSessionFile(selectedSession);
|
||||
}
|
||||
|
||||
// Determine provider and model
|
||||
const provider = (parsed.provider || "anthropic") as any;
|
||||
const modelId = parsed.model || "claude-sonnet-4-5";
|
||||
// Settings manager
|
||||
const settingsManager = new SettingsManager();
|
||||
|
||||
// Helper function to get API key for a provider
|
||||
const getApiKeyForProvider = (providerName: string): string | undefined => {
|
||||
// Check if API key was provided via command line
|
||||
if (parsed.apiKey) {
|
||||
return parsed.apiKey;
|
||||
// Determine initial model using priority system:
|
||||
// 1. CLI args (--provider and --model)
|
||||
// 2. Restored from session (if --continue or --resume)
|
||||
// 3. Saved default from settings.json
|
||||
// 4. First available model with valid API key
|
||||
// 5. null (allowed in interactive mode)
|
||||
let initialModel: Model<Api> | null = null;
|
||||
|
||||
if (parsed.provider && parsed.model) {
|
||||
// 1. CLI args take priority
|
||||
const { model, error } = findModel(parsed.provider, parsed.model);
|
||||
if (error) {
|
||||
console.error(chalk.red(error));
|
||||
process.exit(1);
|
||||
}
|
||||
if (!model) {
|
||||
console.error(chalk.red(`Model ${parsed.provider}/${parsed.model} not found`));
|
||||
process.exit(1);
|
||||
}
|
||||
initialModel = model;
|
||||
} else if (parsed.continue || parsed.resume) {
|
||||
// 2. Restore from session (will be handled below after loading session)
|
||||
// Leave initialModel as null for now
|
||||
}
|
||||
|
||||
if (!initialModel) {
|
||||
// 3. Try saved default from settings
|
||||
const defaultProvider = settingsManager.getDefaultProvider();
|
||||
const defaultModel = settingsManager.getDefaultModel();
|
||||
if (defaultProvider && defaultModel) {
|
||||
const { model, error } = findModel(defaultProvider, defaultModel);
|
||||
if (error) {
|
||||
console.error(chalk.red(error));
|
||||
process.exit(1);
|
||||
}
|
||||
initialModel = model;
|
||||
}
|
||||
}
|
||||
|
||||
if (!initialModel) {
|
||||
// 4. Try first available model with valid API key
|
||||
// Prefer default model for each provider if available
|
||||
const { models: availableModels, error } = await getAvailableModels();
|
||||
|
||||
if (error) {
|
||||
console.error(chalk.red(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const envVars = envApiKeyMap[providerName as KnownProvider];
|
||||
if (availableModels.length > 0) {
|
||||
// Try to find a default model from known providers
|
||||
for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
|
||||
const defaultModelId = defaultModelPerProvider[provider];
|
||||
const match = availableModels.find((m) => m.provider === provider && m.id === defaultModelId);
|
||||
if (match) {
|
||||
initialModel = match;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check each environment variable in priority order
|
||||
for (const envVar of envVars) {
|
||||
const key = process.env[envVar];
|
||||
if (key) {
|
||||
return key;
|
||||
// If no default found, use first available
|
||||
if (!initialModel) {
|
||||
initialModel = availableModels[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
// Determine mode early to know if we should print messages and fail early
|
||||
const isInteractive = parsed.messages.length === 0 && parsed.mode === undefined;
|
||||
const mode = parsed.mode || "text";
|
||||
const shouldPrintMessages = isInteractive || mode === "text";
|
||||
|
||||
// Get initial API key
|
||||
const initialApiKey = getApiKeyForProvider(provider);
|
||||
if (!initialApiKey) {
|
||||
const envVars = envApiKeyMap[provider as KnownProvider];
|
||||
const envVarList = envVars.join(" or ");
|
||||
console.error(chalk.red(`Error: No API key found for provider "${provider}"`));
|
||||
console.error(chalk.dim(`Set ${envVarList} environment variable or use --api-key flag`));
|
||||
// Non-interactive mode: fail early if no model available
|
||||
if (!isInteractive && !initialModel) {
|
||||
console.error(chalk.red("No models available."));
|
||||
console.error(chalk.yellow("\nSet an API key environment variable:"));
|
||||
console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.");
|
||||
console.error(chalk.yellow("\nOr create ~/.pi/agent/models.json"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create agent
|
||||
const model = getModel(provider, modelId);
|
||||
// Non-interactive mode: validate API key exists
|
||||
if (!isInteractive && initialModel) {
|
||||
const apiKey = parsed.apiKey || (await getApiKeyForModel(initialModel));
|
||||
if (!apiKey) {
|
||||
console.error(chalk.red(`No API key found for ${initialModel.provider}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = buildSystemPrompt(parsed.systemPrompt);
|
||||
|
||||
// Load previous messages if continuing or resuming
|
||||
// This may update initialModel if restoring from session
|
||||
if (parsed.continue || parsed.resume) {
|
||||
const messages = sessionManager.loadMessages();
|
||||
if (messages.length > 0 && shouldPrintMessages) {
|
||||
console.log(chalk.dim(`Loaded ${messages.length} messages from previous session`));
|
||||
}
|
||||
|
||||
// Load and restore model (overrides initialModel if found and has API key)
|
||||
const savedModel = sessionManager.loadModel();
|
||||
if (savedModel) {
|
||||
const { model: restoredModel, error } = findModel(savedModel.provider, savedModel.modelId);
|
||||
|
||||
if (error) {
|
||||
console.error(chalk.red(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if restored model exists and has a valid API key
|
||||
const hasApiKey = restoredModel ? !!(await getApiKeyForModel(restoredModel)) : false;
|
||||
|
||||
if (restoredModel && hasApiKey) {
|
||||
initialModel = restoredModel;
|
||||
if (shouldPrintMessages) {
|
||||
console.log(chalk.dim(`Restored model: ${savedModel.provider}/${savedModel.modelId}`));
|
||||
}
|
||||
} else {
|
||||
// Model not found or no API key - fall back to default selection
|
||||
const reason = !restoredModel ? "model no longer exists" : "no API key available";
|
||||
|
||||
if (shouldPrintMessages) {
|
||||
console.error(
|
||||
chalk.yellow(
|
||||
`Warning: Could not restore model ${savedModel.provider}/${savedModel.modelId} (${reason}).`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure we have a valid model - use the same fallback logic
|
||||
if (!initialModel) {
|
||||
const { models: availableModels, error: availableError } = await getAvailableModels();
|
||||
if (availableError) {
|
||||
console.error(chalk.red(availableError));
|
||||
process.exit(1);
|
||||
}
|
||||
if (availableModels.length > 0) {
|
||||
// Try to find a default model from known providers
|
||||
for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
|
||||
const defaultModelId = defaultModelPerProvider[provider];
|
||||
const match = availableModels.find((m) => m.provider === provider && m.id === defaultModelId);
|
||||
if (match) {
|
||||
initialModel = match;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no default found, use first available
|
||||
if (!initialModel) {
|
||||
initialModel = availableModels[0];
|
||||
}
|
||||
|
||||
if (initialModel && shouldPrintMessages) {
|
||||
console.log(chalk.dim(`Falling back to: ${initialModel.provider}/${initialModel.id}`));
|
||||
}
|
||||
} else {
|
||||
// No models available at all
|
||||
if (shouldPrintMessages) {
|
||||
console.error(chalk.red("\nNo models available."));
|
||||
console.error(chalk.yellow("Set an API key environment variable:"));
|
||||
console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.");
|
||||
console.error(chalk.yellow("\nOr create ~/.pi/agent/models.json"));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (shouldPrintMessages) {
|
||||
console.log(chalk.dim(`Falling back to: ${initialModel.provider}/${initialModel.id}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create agent (initialModel can be null in interactive mode)
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt,
|
||||
model,
|
||||
model: initialModel as any, // Can be null
|
||||
thinkingLevel: "off",
|
||||
tools: codingTools,
|
||||
},
|
||||
transport: new ProviderTransport({
|
||||
// Dynamic API key lookup based on current model's provider
|
||||
getApiKey: async () => {
|
||||
const currentProvider = agent.state.model.provider;
|
||||
const key = getApiKeyForProvider(currentProvider);
|
||||
const currentModel = agent.state.model;
|
||||
if (!currentModel) {
|
||||
throw new Error("No model selected");
|
||||
}
|
||||
|
||||
// Try CLI override first
|
||||
if (parsed.apiKey) {
|
||||
return parsed.apiKey;
|
||||
}
|
||||
|
||||
// Use model-specific key lookup
|
||||
const key = await getApiKeyForModel(currentModel);
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
`No API key found for provider "${currentProvider}". Please set the appropriate environment variable.`,
|
||||
`No API key found for provider "${currentModel.provider}". Please set the appropriate environment variable or update ~/.pi/agent/models.json`,
|
||||
);
|
||||
}
|
||||
return key;
|
||||
|
|
@ -509,41 +785,16 @@ export async function main(args: string[]) {
|
|||
}),
|
||||
});
|
||||
|
||||
// Determine mode early to know if we should print messages
|
||||
const isInteractive = parsed.messages.length === 0;
|
||||
const mode = parsed.mode || "text";
|
||||
const shouldPrintMessages = isInteractive || mode === "text";
|
||||
// Track if we had to fall back from saved model (to show in chat later)
|
||||
let modelFallbackMessage: string | null = null;
|
||||
|
||||
// Load previous messages if continuing or resuming
|
||||
if (parsed.continue || parsed.resume) {
|
||||
const messages = sessionManager.loadMessages();
|
||||
if (messages.length > 0) {
|
||||
if (shouldPrintMessages) {
|
||||
console.log(chalk.dim(`Loaded ${messages.length} messages from previous session`));
|
||||
}
|
||||
agent.replaceMessages(messages);
|
||||
}
|
||||
|
||||
// Load and restore model
|
||||
const savedModel = sessionManager.loadModel();
|
||||
if (savedModel) {
|
||||
try {
|
||||
const restoredModel = getModel(savedModel.provider as any, savedModel.modelId);
|
||||
agent.setModel(restoredModel);
|
||||
if (shouldPrintMessages) {
|
||||
console.log(chalk.dim(`Restored model: ${savedModel.provider}/${savedModel.modelId}`));
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (shouldPrintMessages) {
|
||||
console.error(
|
||||
chalk.yellow(
|
||||
`Warning: Could not restore model ${savedModel.provider}/${savedModel.modelId}: ${error.message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load and restore thinking level
|
||||
const thinkingLevel = sessionManager.loadThinkingLevel() as ThinkingLevel;
|
||||
if (thinkingLevel) {
|
||||
|
|
@ -552,6 +803,22 @@ export async function main(args: string[]) {
|
|||
console.log(chalk.dim(`Restored thinking level: ${thinkingLevel}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we had to fall back from saved model
|
||||
const savedModel = sessionManager.loadModel();
|
||||
if (savedModel && initialModel) {
|
||||
const savedMatches = initialModel.provider === savedModel.provider && initialModel.id === savedModel.modelId;
|
||||
if (!savedMatches) {
|
||||
const { model: restoredModel, error } = findModel(savedModel.provider, savedModel.modelId);
|
||||
if (error) {
|
||||
// Config error - already shown above, just use generic message
|
||||
modelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId}. Using ${initialModel.provider}/${initialModel.id}.`;
|
||||
} else {
|
||||
const reason = !restoredModel ? "model no longer exists" : "no API key available";
|
||||
modelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId} (${reason}). Using ${initialModel.provider}/${initialModel.id}.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Session will be started lazily after first user+assistant message exchange
|
||||
|
|
@ -586,10 +853,20 @@ export async function main(args: string[]) {
|
|||
// RPC mode - headless operation
|
||||
await runRpcMode(agent, sessionManager);
|
||||
} else if (isInteractive) {
|
||||
// Check for new version (don't block startup if it takes too long)
|
||||
let newVersion: string | null = null;
|
||||
try {
|
||||
newVersion = await Promise.race([
|
||||
checkForNewVersion(VERSION),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1000)), // 1 second timeout
|
||||
]);
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
// Check if we should show changelog (only in interactive mode, only for new sessions)
|
||||
let changelogMarkdown: string | null = null;
|
||||
if (!parsed.continue && !parsed.resume) {
|
||||
const settingsManager = new SettingsManager();
|
||||
const lastVersion = settingsManager.getLastChangelogVersion();
|
||||
|
||||
// Check if we need to show changelog
|
||||
|
|
@ -616,8 +893,29 @@ export async function main(args: string[]) {
|
|||
}
|
||||
}
|
||||
|
||||
// Resolve model scope if provided
|
||||
let scopedModels: Model<Api>[] = [];
|
||||
if (parsed.models && parsed.models.length > 0) {
|
||||
scopedModels = await resolveModelScope(parsed.models);
|
||||
|
||||
if (scopedModels.length > 0) {
|
||||
console.log(
|
||||
chalk.dim(`Model scope: ${scopedModels.map((m) => m.id).join(", ")} ${chalk.gray("(Ctrl+P to cycle)")}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// No messages and not RPC - use TUI
|
||||
await runInteractiveMode(agent, sessionManager, VERSION, changelogMarkdown);
|
||||
await runInteractiveMode(
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
VERSION,
|
||||
changelogMarkdown,
|
||||
modelFallbackMessage,
|
||||
newVersion,
|
||||
scopedModels,
|
||||
);
|
||||
} else {
|
||||
// CLI mode with messages
|
||||
await runSingleShotMode(agent, sessionManager, parsed.messages, mode);
|
||||
|
|
|
|||
287
packages/coding-agent/src/model-config.ts
Normal file
287
packages/coding-agent/src/model-config.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import { type Api, getApiKey, getModels, getProviders, type KnownProvider, type Model } from "@mariozechner/pi-ai";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import AjvModule from "ajv";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { getOAuthToken } from "./oauth/index.js";
|
||||
|
||||
// Handle both default and named exports
|
||||
const Ajv = (AjvModule as any).default || AjvModule;
|
||||
|
||||
// Schema for custom model definition
|
||||
const ModelDefinitionSchema = Type.Object({
|
||||
id: Type.String({ minLength: 1 }),
|
||||
name: Type.String({ minLength: 1 }),
|
||||
api: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("openai-completions"),
|
||||
Type.Literal("openai-responses"),
|
||||
Type.Literal("anthropic-messages"),
|
||||
Type.Literal("google-generative-ai"),
|
||||
]),
|
||||
),
|
||||
reasoning: Type.Boolean(),
|
||||
input: Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")])),
|
||||
cost: Type.Object({
|
||||
input: Type.Number(),
|
||||
output: Type.Number(),
|
||||
cacheRead: Type.Number(),
|
||||
cacheWrite: Type.Number(),
|
||||
}),
|
||||
contextWindow: Type.Number(),
|
||||
maxTokens: Type.Number(),
|
||||
});
|
||||
|
||||
const ProviderConfigSchema = Type.Object({
|
||||
baseUrl: Type.String({ minLength: 1 }),
|
||||
apiKey: Type.String({ minLength: 1 }),
|
||||
api: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("openai-completions"),
|
||||
Type.Literal("openai-responses"),
|
||||
Type.Literal("anthropic-messages"),
|
||||
Type.Literal("google-generative-ai"),
|
||||
]),
|
||||
),
|
||||
models: Type.Array(ModelDefinitionSchema),
|
||||
});
|
||||
|
||||
const ModelsConfigSchema = Type.Object({
|
||||
providers: Type.Record(Type.String(), ProviderConfigSchema),
|
||||
});
|
||||
|
||||
type ModelsConfig = Static<typeof ModelsConfigSchema>;
|
||||
type ProviderConfig = Static<typeof ProviderConfigSchema>;
|
||||
type ModelDefinition = Static<typeof ModelDefinitionSchema>;
|
||||
|
||||
// Custom provider API key mappings (provider name -> apiKey config)
|
||||
const customProviderApiKeys: Map<string, string> = new Map();
|
||||
|
||||
/**
|
||||
* Resolve an API key config value to an actual key.
|
||||
* First checks if it's an environment variable, then treats as literal.
|
||||
*/
|
||||
export function resolveApiKey(keyConfig: string): string | undefined {
|
||||
// First check if it's an env var name
|
||||
const envValue = process.env[keyConfig];
|
||||
if (envValue) return envValue;
|
||||
|
||||
// Otherwise treat as literal API key
|
||||
return keyConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load custom models from ~/.pi/agent/models.json
|
||||
* Returns { models, error } - either models array or error message
|
||||
*/
|
||||
function loadCustomModels(): { models: Model<Api>[]; error: string | null } {
|
||||
const configPath = join(homedir(), ".pi", "agent", "models.json");
|
||||
if (!existsSync(configPath)) {
|
||||
return { models: [], error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(configPath, "utf-8");
|
||||
const config: ModelsConfig = JSON.parse(content);
|
||||
|
||||
// Validate schema
|
||||
const ajv = new Ajv();
|
||||
const validate = ajv.compile(ModelsConfigSchema);
|
||||
if (!validate(config)) {
|
||||
const errors =
|
||||
validate.errors?.map((e: any) => ` - ${e.instancePath || "root"}: ${e.message}`).join("\n") ||
|
||||
"Unknown schema error";
|
||||
return {
|
||||
models: [],
|
||||
error: `Invalid models.json schema:\n${errors}\n\nFile: ${configPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Additional validation
|
||||
try {
|
||||
validateConfig(config);
|
||||
} catch (error) {
|
||||
return {
|
||||
models: [],
|
||||
error: `Invalid models.json: ${error instanceof Error ? error.message : error}\n\nFile: ${configPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse models
|
||||
return { models: parseModels(config), error: null };
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
return {
|
||||
models: [],
|
||||
error: `Failed to parse models.json: ${error.message}\n\nFile: ${configPath}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
models: [],
|
||||
error: `Failed to load models.json: ${error instanceof Error ? error.message : error}\n\nFile: ${configPath}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate config structure and requirements
|
||||
*/
|
||||
function validateConfig(config: ModelsConfig): void {
|
||||
for (const [providerName, providerConfig] of Object.entries(config.providers)) {
|
||||
const hasProviderApi = !!providerConfig.api;
|
||||
|
||||
for (const modelDef of providerConfig.models) {
|
||||
const hasModelApi = !!modelDef.api;
|
||||
|
||||
if (!hasProviderApi && !hasModelApi) {
|
||||
throw new Error(
|
||||
`Provider ${providerName}, model ${modelDef.id}: no "api" specified. ` +
|
||||
`Set at provider or model level.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!modelDef.id) throw new Error(`Provider ${providerName}: model missing "id"`);
|
||||
if (!modelDef.name) throw new Error(`Provider ${providerName}: model missing "name"`);
|
||||
if (modelDef.contextWindow <= 0)
|
||||
throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid contextWindow`);
|
||||
if (modelDef.maxTokens <= 0)
|
||||
throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid maxTokens`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse config into Model objects
|
||||
*/
|
||||
function parseModels(config: ModelsConfig): Model<Api>[] {
|
||||
const models: Model<Api>[] = [];
|
||||
|
||||
// Clear and rebuild custom provider API key mappings
|
||||
customProviderApiKeys.clear();
|
||||
|
||||
for (const [providerName, providerConfig] of Object.entries(config.providers)) {
|
||||
// Store API key config for this provider
|
||||
customProviderApiKeys.set(providerName, providerConfig.apiKey);
|
||||
|
||||
for (const modelDef of providerConfig.models) {
|
||||
// Model-level api overrides provider-level api
|
||||
const api = modelDef.api || providerConfig.api;
|
||||
|
||||
if (!api) {
|
||||
// This should have been caught by validateConfig, but be safe
|
||||
continue;
|
||||
}
|
||||
|
||||
models.push({
|
||||
id: modelDef.id,
|
||||
name: modelDef.name,
|
||||
api: api as Api,
|
||||
provider: providerName,
|
||||
baseUrl: providerConfig.baseUrl,
|
||||
reasoning: modelDef.reasoning,
|
||||
input: modelDef.input as ("text" | "image")[],
|
||||
cost: modelDef.cost,
|
||||
contextWindow: modelDef.contextWindow,
|
||||
maxTokens: modelDef.maxTokens,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all models (built-in + custom), freshly loaded
|
||||
* Returns { models, error } - either models array or error message
|
||||
*/
|
||||
export function loadAndMergeModels(): { models: Model<Api>[]; error: string | null } {
|
||||
const builtInModels: Model<Api>[] = [];
|
||||
const providers = getProviders();
|
||||
|
||||
// Load all built-in models
|
||||
for (const provider of providers) {
|
||||
const providerModels = getModels(provider as KnownProvider);
|
||||
builtInModels.push(...(providerModels as Model<Api>[]));
|
||||
}
|
||||
|
||||
// Load custom models
|
||||
const { models: customModels, error } = loadCustomModels();
|
||||
|
||||
if (error) {
|
||||
return { models: [], error };
|
||||
}
|
||||
|
||||
// Merge: custom models come after built-in
|
||||
return { models: [...builtInModels, ...customModels], error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key for a model (checks custom providers first, then built-in)
|
||||
* Now async to support OAuth token refresh
|
||||
*/
|
||||
export async function getApiKeyForModel(model: Model<Api>): Promise<string | undefined> {
|
||||
// For custom providers, check their apiKey config
|
||||
const customKeyConfig = customProviderApiKeys.get(model.provider);
|
||||
if (customKeyConfig) {
|
||||
return resolveApiKey(customKeyConfig);
|
||||
}
|
||||
|
||||
// For Anthropic, check OAuth first
|
||||
if (model.provider === "anthropic") {
|
||||
// 1. Check OAuth storage (auto-refresh if needed)
|
||||
const oauthToken = await getOAuthToken("anthropic");
|
||||
if (oauthToken) {
|
||||
return oauthToken;
|
||||
}
|
||||
|
||||
// 2. Check ANTHROPIC_OAUTH_TOKEN env var (manual OAuth token)
|
||||
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||
if (oauthEnv) {
|
||||
return oauthEnv;
|
||||
}
|
||||
|
||||
// 3. Fall back to ANTHROPIC_API_KEY env var
|
||||
}
|
||||
|
||||
// For built-in providers, use getApiKey from @mariozechner/pi-ai
|
||||
return getApiKey(model.provider as KnownProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only models that have valid API keys available
|
||||
* Returns { models, error } - either models array or error message
|
||||
*/
|
||||
export async function getAvailableModels(): Promise<{ models: Model<Api>[]; error: string | null }> {
|
||||
const { models: allModels, error } = loadAndMergeModels();
|
||||
|
||||
if (error) {
|
||||
return { models: [], error };
|
||||
}
|
||||
|
||||
const availableModels: Model<Api>[] = [];
|
||||
for (const model of allModels) {
|
||||
const apiKey = await getApiKeyForModel(model);
|
||||
if (apiKey) {
|
||||
availableModels.push(model);
|
||||
}
|
||||
}
|
||||
|
||||
return { models: availableModels, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific model by provider and ID
|
||||
* Returns { model, error } - either model or error message
|
||||
*/
|
||||
export function findModel(provider: string, modelId: string): { model: Model<Api> | null; error: string | null } {
|
||||
const { models: allModels, error } = loadAndMergeModels();
|
||||
|
||||
if (error) {
|
||||
return { model: null, error };
|
||||
}
|
||||
|
||||
const model = allModels.find((m) => m.provider === provider && m.id === modelId) || null;
|
||||
return { model, error: null };
|
||||
}
|
||||
128
packages/coding-agent/src/oauth/anthropic.ts
Normal file
128
packages/coding-agent/src/oauth/anthropic.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { createHash, randomBytes } from "crypto";
|
||||
import { type OAuthCredentials, saveOAuthCredentials } from "./storage.js";
|
||||
|
||||
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
||||
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
|
||||
const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback";
|
||||
const SCOPES = "org:create_api_key user:profile user:inference";
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier and challenge
|
||||
*/
|
||||
function generatePKCE(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with Anthropic OAuth (device code flow)
|
||||
*/
|
||||
export async function loginAnthropic(
|
||||
onAuthUrl: (url: string) => void,
|
||||
onPromptCode: () => Promise<string>,
|
||||
): Promise<void> {
|
||||
const { verifier, challenge } = generatePKCE();
|
||||
|
||||
// Build authorization URL
|
||||
const authParams = new URLSearchParams({
|
||||
code: "true",
|
||||
client_id: CLIENT_ID,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPES,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state: verifier,
|
||||
});
|
||||
|
||||
const authUrl = `${AUTHORIZE_URL}?${authParams.toString()}`;
|
||||
|
||||
// Notify caller with URL to open
|
||||
onAuthUrl(authUrl);
|
||||
|
||||
// Wait for user to paste authorization code (format: code#state)
|
||||
const authCode = await onPromptCode();
|
||||
const splits = authCode.split("#");
|
||||
const code = splits[0];
|
||||
const state = splits[1];
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokenResponse = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
code: code,
|
||||
state: state,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const error = await tokenResponse.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
// Calculate expiry time (current time + expires_in seconds - 5 min buffer)
|
||||
const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
|
||||
|
||||
// Save credentials
|
||||
const credentials: OAuthCredentials = {
|
||||
type: "oauth",
|
||||
refresh: tokenData.refresh_token,
|
||||
access: tokenData.access_token,
|
||||
expires: expiresAt,
|
||||
};
|
||||
|
||||
saveOAuthCredentials("anthropic", credentials);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Anthropic OAuth token using refresh token
|
||||
*/
|
||||
export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {
|
||||
const tokenResponse = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
client_id: CLIENT_ID,
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const error = await tokenResponse.text();
|
||||
throw new Error(`Token refresh failed: ${error}`);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
// Calculate expiry time (current time + expires_in seconds - 5 min buffer)
|
||||
const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
refresh: tokenData.refresh_token,
|
||||
access: tokenData.access_token,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
115
packages/coding-agent/src/oauth/index.ts
Normal file
115
packages/coding-agent/src/oauth/index.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { loginAnthropic, refreshAnthropicToken } from "./anthropic.js";
|
||||
import {
|
||||
listOAuthProviders as listOAuthProvidersFromStorage,
|
||||
loadOAuthCredentials,
|
||||
type OAuthCredentials,
|
||||
removeOAuthCredentials,
|
||||
saveOAuthCredentials,
|
||||
} from "./storage.js";
|
||||
|
||||
// Re-export for convenience
|
||||
export { listOAuthProvidersFromStorage as listOAuthProviders };
|
||||
|
||||
export type SupportedOAuthProvider = "anthropic" | "github-copilot";
|
||||
|
||||
export interface OAuthProviderInfo {
|
||||
id: SupportedOAuthProvider;
|
||||
name: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of OAuth providers
|
||||
*/
|
||||
export function getOAuthProviders(): OAuthProviderInfo[] {
|
||||
return [
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic (Claude Pro/Max)",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "github-copilot",
|
||||
name: "GitHub Copilot (coming soon)",
|
||||
available: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with OAuth provider
|
||||
*/
|
||||
export async function login(
|
||||
provider: SupportedOAuthProvider,
|
||||
onAuthUrl: (url: string) => void,
|
||||
onPromptCode: () => Promise<string>,
|
||||
): Promise<void> {
|
||||
switch (provider) {
|
||||
case "anthropic":
|
||||
await loginAnthropic(onAuthUrl, onPromptCode);
|
||||
break;
|
||||
case "github-copilot":
|
||||
throw new Error("GitHub Copilot OAuth is not yet implemented");
|
||||
default:
|
||||
throw new Error(`Unknown OAuth provider: ${provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout from OAuth provider
|
||||
*/
|
||||
export async function logout(provider: SupportedOAuthProvider): Promise<void> {
|
||||
removeOAuthCredentials(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh OAuth token for provider
|
||||
*/
|
||||
export async function refreshToken(provider: SupportedOAuthProvider): Promise<string> {
|
||||
const credentials = loadOAuthCredentials(provider);
|
||||
if (!credentials) {
|
||||
throw new Error(`No OAuth credentials found for ${provider}`);
|
||||
}
|
||||
|
||||
let newCredentials: OAuthCredentials;
|
||||
|
||||
switch (provider) {
|
||||
case "anthropic":
|
||||
newCredentials = await refreshAnthropicToken(credentials.refresh);
|
||||
break;
|
||||
case "github-copilot":
|
||||
throw new Error("GitHub Copilot OAuth is not yet implemented");
|
||||
default:
|
||||
throw new Error(`Unknown OAuth provider: ${provider}`);
|
||||
}
|
||||
|
||||
// Save new credentials
|
||||
saveOAuthCredentials(provider, newCredentials);
|
||||
|
||||
return newCredentials.access;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth token for provider (auto-refreshes if expired)
|
||||
*/
|
||||
export async function getOAuthToken(provider: SupportedOAuthProvider): Promise<string | null> {
|
||||
const credentials = loadOAuthCredentials(provider);
|
||||
if (!credentials) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if token is expired (with 5 min buffer already applied)
|
||||
if (Date.now() >= credentials.expires) {
|
||||
// Token expired - refresh it
|
||||
try {
|
||||
return await refreshToken(provider);
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh OAuth token for ${provider}:`, error);
|
||||
// Remove invalid credentials
|
||||
removeOAuthCredentials(provider);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return credentials.access;
|
||||
}
|
||||
95
packages/coding-agent/src/oauth/storage.ts
Normal file
95
packages/coding-agent/src/oauth/storage.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
export interface OAuthCredentials {
|
||||
type: "oauth";
|
||||
refresh: string;
|
||||
access: string;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
interface OAuthStorageFormat {
|
||||
[provider: string]: OAuthCredentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to oauth.json
|
||||
*/
|
||||
function getOAuthFilePath(): string {
|
||||
const configDir = join(homedir(), ".pi", "agent");
|
||||
return join(configDir, "oauth.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the config directory exists
|
||||
*/
|
||||
function ensureConfigDir(): void {
|
||||
const configDir = join(homedir(), ".pi", "agent");
|
||||
if (!existsSync(configDir)) {
|
||||
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all OAuth credentials from oauth.json
|
||||
*/
|
||||
function loadStorage(): OAuthStorageFormat {
|
||||
const filePath = getOAuthFilePath();
|
||||
if (!existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error(`Warning: Failed to load OAuth credentials: ${error}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save all OAuth credentials to oauth.json
|
||||
*/
|
||||
function saveStorage(storage: OAuthStorageFormat): void {
|
||||
ensureConfigDir();
|
||||
const filePath = getOAuthFilePath();
|
||||
writeFileSync(filePath, JSON.stringify(storage, null, 2), "utf-8");
|
||||
// Set permissions to owner read/write only
|
||||
chmodSync(filePath, 0o600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load OAuth credentials for a specific provider
|
||||
*/
|
||||
export function loadOAuthCredentials(provider: string): OAuthCredentials | null {
|
||||
const storage = loadStorage();
|
||||
return storage[provider] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save OAuth credentials for a specific provider
|
||||
*/
|
||||
export function saveOAuthCredentials(provider: string, creds: OAuthCredentials): void {
|
||||
const storage = loadStorage();
|
||||
storage[provider] = creds;
|
||||
saveStorage(storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove OAuth credentials for a specific provider
|
||||
*/
|
||||
export function removeOAuthCredentials(provider: string): void {
|
||||
const storage = loadStorage();
|
||||
delete storage[provider];
|
||||
saveStorage(storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all providers with OAuth credentials
|
||||
*/
|
||||
export function listOAuthProviders(): string[] {
|
||||
const storage = loadStorage();
|
||||
return Object.keys(storage);
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ import { dirname, join } from "path";
|
|||
|
||||
export interface Settings {
|
||||
lastChangelogVersion?: string;
|
||||
defaultProvider?: string;
|
||||
defaultModel?: string;
|
||||
}
|
||||
|
||||
export class SettingsManager {
|
||||
|
|
@ -52,4 +54,28 @@ export class SettingsManager {
|
|||
this.settings.lastChangelogVersion = version;
|
||||
this.save();
|
||||
}
|
||||
|
||||
getDefaultProvider(): string | undefined {
|
||||
return this.settings.defaultProvider;
|
||||
}
|
||||
|
||||
getDefaultModel(): string | undefined {
|
||||
return this.settings.defaultModel;
|
||||
}
|
||||
|
||||
setDefaultProvider(provider: string): void {
|
||||
this.settings.defaultProvider = provider;
|
||||
this.save();
|
||||
}
|
||||
|
||||
setDefaultModel(modelId: string): void {
|
||||
this.settings.defaultModel = modelId;
|
||||
this.save();
|
||||
}
|
||||
|
||||
setDefaultModelAndProvider(provider: string, modelId: string): void {
|
||||
this.settings.defaultProvider = provider;
|
||||
this.settings.defaultModel = modelId;
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ export const bashTool: AgentTool<typeof bashSchema> = {
|
|||
}
|
||||
if (output) output += "\n\n";
|
||||
output += "Command aborted";
|
||||
resolve({ content: [{ type: "text", text: `Command failed\n\n${output}` }], details: undefined });
|
||||
_reject(new Error(output));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -150,7 +150,7 @@ export const bashTool: AgentTool<typeof bashSchema> = {
|
|||
}
|
||||
if (output) output += "\n\n";
|
||||
output += `Command timed out after ${timeout} seconds`;
|
||||
resolve({ content: [{ type: "text", text: `Command failed\n\n${output}` }], details: undefined });
|
||||
_reject(new Error(output));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -163,10 +163,7 @@ export const bashTool: AgentTool<typeof bashSchema> = {
|
|||
|
||||
if (code !== 0 && code !== null) {
|
||||
if (output) output += "\n\n";
|
||||
resolve({
|
||||
content: [{ type: "text", text: `Command failed\n\n${output}Command exited with code ${code}` }],
|
||||
details: undefined,
|
||||
});
|
||||
_reject(new Error(`${output}Command exited with code ${code}`));
|
||||
} else {
|
||||
resolve({ content: [{ type: "text", text: output || "(no output)" }], details: undefined });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,12 +38,16 @@ export class AssistantMessageComponent extends Container {
|
|||
if (content.type === "text" && content.text.trim()) {
|
||||
// Assistant text messages with no background - trim the text
|
||||
// Set paddingY=0 to avoid extra spacing before tool executions
|
||||
this.contentContainer.addChild(new Markdown(content.text.trim(), undefined, undefined, undefined, 1, 0));
|
||||
this.contentContainer.addChild(new Markdown(content.text.trim(), 1, 0));
|
||||
} else if (content.type === "thinking" && content.thinking.trim()) {
|
||||
// Thinking traces in dark gray italic
|
||||
// Use Markdown component because it preserves ANSI codes across wrapped lines
|
||||
const thinkingText = chalk.gray.italic(content.thinking);
|
||||
this.contentContainer.addChild(new Markdown(thinkingText, undefined, undefined, undefined, 1, 0));
|
||||
// Use Markdown component with default text style for consistent styling
|
||||
this.contentContainer.addChild(
|
||||
new Markdown(content.thinking.trim(), 1, 0, {
|
||||
color: "gray",
|
||||
italic: true,
|
||||
}),
|
||||
);
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
}
|
||||
}
|
||||
|
|
@ -56,7 +60,8 @@ export class AssistantMessageComponent extends Container {
|
|||
this.contentContainer.addChild(new Text(chalk.red("\nAborted"), 1, 0));
|
||||
} else if (message.stopReason === "error") {
|
||||
const errorMsg = message.errorMessage || "Unknown error";
|
||||
this.contentContainer.addChild(new Text(chalk.red(`Error: ${errorMsg}`)));
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
this.contentContainer.addChild(new Text(chalk.red(`Error: ${errorMsg}`), 1, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,22 @@ import { Editor } from "@mariozechner/pi-tui";
|
|||
export class CustomEditor extends Editor {
|
||||
public onEscape?: () => void;
|
||||
public onCtrlC?: () => void;
|
||||
public onShiftTab?: () => void;
|
||||
public onCtrlP?: () => void;
|
||||
|
||||
handleInput(data: string): void {
|
||||
// Intercept Ctrl+P for model cycling
|
||||
if (data === "\x10" && this.onCtrlP) {
|
||||
this.onCtrlP();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept Shift+Tab for thinking level cycling
|
||||
if (data === "\x1b[Z" && this.onShiftTab) {
|
||||
this.onShiftTab();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept Escape key - but only if autocomplete is NOT active
|
||||
// (let parent handle escape for autocomplete cancellation)
|
||||
if (data === "\x1b" && this.onEscape && !this.isShowingAutocomplete()) {
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export class FooterComponent {
|
|||
lastAssistantMessage.usage.cacheRead +
|
||||
lastAssistantMessage.usage.cacheWrite
|
||||
: 0;
|
||||
const contextWindow = this.state.model.contextWindow;
|
||||
const contextWindow = this.state.model?.contextWindow || 0;
|
||||
const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : "0.0";
|
||||
|
||||
// Format token counts (similar to web-ui)
|
||||
|
|
@ -85,30 +85,42 @@ export class FooterComponent {
|
|||
|
||||
const statsLeft = statsParts.join(" ");
|
||||
|
||||
// Add model name on the right side
|
||||
let modelName = this.state.model.id;
|
||||
// Add model name on the right side, plus thinking level if model supports it
|
||||
const modelName = this.state.model?.id || "no-model";
|
||||
|
||||
// Add thinking level hint if model supports reasoning and thinking is enabled
|
||||
let rightSide = modelName;
|
||||
if (this.state.model?.reasoning) {
|
||||
const thinkingLevel = this.state.thinkingLevel || "off";
|
||||
if (thinkingLevel !== "off") {
|
||||
rightSide = `${modelName} • ${thinkingLevel}`;
|
||||
}
|
||||
}
|
||||
|
||||
const statsLeftWidth = visibleWidth(statsLeft);
|
||||
const modelWidth = visibleWidth(modelName);
|
||||
const rightSideWidth = visibleWidth(rightSide);
|
||||
|
||||
// Calculate available space for padding (minimum 2 spaces between stats and model)
|
||||
const minPadding = 2;
|
||||
const totalNeeded = statsLeftWidth + minPadding + modelWidth;
|
||||
const totalNeeded = statsLeftWidth + minPadding + rightSideWidth;
|
||||
|
||||
let statsLine: string;
|
||||
if (totalNeeded <= width) {
|
||||
// Both fit - add padding to right-align model
|
||||
const padding = " ".repeat(width - statsLeftWidth - modelWidth);
|
||||
statsLine = statsLeft + padding + modelName;
|
||||
const padding = " ".repeat(width - statsLeftWidth - rightSideWidth);
|
||||
statsLine = statsLeft + padding + rightSide;
|
||||
} else {
|
||||
// Need to truncate model name
|
||||
const availableForModel = width - statsLeftWidth - minPadding;
|
||||
if (availableForModel > 3) {
|
||||
// Truncate model name to fit
|
||||
modelName = modelName.substring(0, availableForModel);
|
||||
const padding = " ".repeat(width - statsLeftWidth - visibleWidth(modelName));
|
||||
statsLine = statsLeft + padding + modelName;
|
||||
// Need to truncate right side
|
||||
const availableForRight = width - statsLeftWidth - minPadding;
|
||||
if (availableForRight > 3) {
|
||||
// Truncate to fit (strip ANSI codes for length calculation, then truncate raw string)
|
||||
const plainRightSide = rightSide.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
const truncatedPlain = plainRightSide.substring(0, availableForRight);
|
||||
// For simplicity, just use plain truncated version (loses color, but fits)
|
||||
const padding = " ".repeat(width - statsLeftWidth - truncatedPlain.length);
|
||||
statsLine = statsLeft + padding + truncatedPlain;
|
||||
} else {
|
||||
// Not enough space for model name at all
|
||||
// Not enough space for right side at all
|
||||
statsLine = statsLeft;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { getModels, getProviders, type Model } from "@mariozechner/pi-ai";
|
||||
import { Container, Input, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { Container, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||
import chalk from "chalk";
|
||||
import { getAvailableModels } from "../model-config.js";
|
||||
import type { SettingsManager } from "../settings-manager.js";
|
||||
|
||||
interface ModelItem {
|
||||
provider: string;
|
||||
|
|
@ -17,24 +19,38 @@ export class ModelSelectorComponent extends Container {
|
|||
private allModels: ModelItem[] = [];
|
||||
private filteredModels: ModelItem[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
private currentModel: Model<any>;
|
||||
private currentModel: Model<any> | null;
|
||||
private settingsManager: SettingsManager;
|
||||
private onSelectCallback: (model: Model<any>) => void;
|
||||
private onCancelCallback: () => void;
|
||||
private errorMessage: string | null = null;
|
||||
private tui: TUI;
|
||||
|
||||
constructor(currentModel: Model<any>, onSelect: (model: Model<any>) => void, onCancel: () => void) {
|
||||
constructor(
|
||||
tui: TUI,
|
||||
currentModel: Model<any> | null,
|
||||
settingsManager: SettingsManager,
|
||||
onSelect: (model: Model<any>) => void,
|
||||
onCancel: () => void,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.tui = tui;
|
||||
this.currentModel = currentModel;
|
||||
this.settingsManager = settingsManager;
|
||||
this.onSelectCallback = onSelect;
|
||||
this.onCancelCallback = onCancel;
|
||||
|
||||
// Load all models
|
||||
this.loadModels();
|
||||
|
||||
// Add top border
|
||||
this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add hint about API key filtering
|
||||
this.addChild(
|
||||
new Text(chalk.yellow("Only showing models with configured API keys (see README for details)"), 0, 0),
|
||||
);
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Create search input
|
||||
this.searchInput = new Input();
|
||||
this.searchInput.onSubmit = () => {
|
||||
|
|
@ -56,25 +72,37 @@ export class ModelSelectorComponent extends Container {
|
|||
// Add bottom border
|
||||
this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0));
|
||||
|
||||
// Initial render
|
||||
this.updateList();
|
||||
// Load models and do initial render
|
||||
this.loadModels().then(() => {
|
||||
this.updateList();
|
||||
// Request re-render after models are loaded
|
||||
this.tui.requestRender();
|
||||
});
|
||||
}
|
||||
|
||||
private loadModels(): void {
|
||||
const models: ModelItem[] = [];
|
||||
const providers = getProviders();
|
||||
private async loadModels(): Promise<void> {
|
||||
// Load available models fresh (includes custom models from ~/.pi/agent/models.json)
|
||||
const { models: availableModels, error } = await getAvailableModels();
|
||||
|
||||
for (const provider of providers) {
|
||||
const providerModels = getModels(provider as any);
|
||||
for (const model of providerModels) {
|
||||
models.push({ provider, id: model.id, model });
|
||||
}
|
||||
// If there's an error loading models.json, we'll show it via the "no models" path
|
||||
// The error will be displayed to the user
|
||||
if (error) {
|
||||
this.allModels = [];
|
||||
this.filteredModels = [];
|
||||
this.errorMessage = error;
|
||||
return;
|
||||
}
|
||||
|
||||
const models: ModelItem[] = availableModels.map((model) => ({
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
model,
|
||||
}));
|
||||
|
||||
// Sort: current model first, then by provider
|
||||
models.sort((a, b) => {
|
||||
const aIsCurrent = this.currentModel?.id === a.model.id;
|
||||
const bIsCurrent = this.currentModel?.id === b.model.id;
|
||||
const aIsCurrent = this.currentModel?.id === a.model.id && this.currentModel?.provider === a.provider;
|
||||
const bIsCurrent = this.currentModel?.id === b.model.id && this.currentModel?.provider === b.provider;
|
||||
if (aIsCurrent && !bIsCurrent) return -1;
|
||||
if (!aIsCurrent && bIsCurrent) return 1;
|
||||
return a.provider.localeCompare(b.provider);
|
||||
|
|
@ -143,8 +171,14 @@ export class ModelSelectorComponent extends Container {
|
|||
this.listContainer.addChild(new Text(scrollInfo, 0, 0));
|
||||
}
|
||||
|
||||
// Show "no results" if empty
|
||||
if (this.filteredModels.length === 0) {
|
||||
// Show error message or "no results" if empty
|
||||
if (this.errorMessage) {
|
||||
// Show error in red
|
||||
const errorLines = this.errorMessage.split("\n");
|
||||
for (const line of errorLines) {
|
||||
this.listContainer.addChild(new Text(chalk.red(line), 0, 0));
|
||||
}
|
||||
} else if (this.filteredModels.length === 0) {
|
||||
this.listContainer.addChild(new Text(chalk.gray(" No matching models"), 0, 0));
|
||||
}
|
||||
}
|
||||
|
|
@ -179,6 +213,8 @@ export class ModelSelectorComponent extends Container {
|
|||
}
|
||||
|
||||
private handleSelect(model: Model<any>): void {
|
||||
// Save as new default
|
||||
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
|
||||
this.onSelectCallback(model);
|
||||
}
|
||||
|
||||
|
|
|
|||
107
packages/coding-agent/src/tui/oauth-selector.ts
Normal file
107
packages/coding-agent/src/tui/oauth-selector.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import chalk from "chalk";
|
||||
import { getOAuthProviders, type OAuthProviderInfo } from "../oauth/index.js";
|
||||
|
||||
/**
|
||||
* Component that renders an OAuth provider selector
|
||||
*/
|
||||
export class OAuthSelectorComponent extends Container {
|
||||
private listContainer: Container;
|
||||
private allProviders: OAuthProviderInfo[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
private mode: "login" | "logout";
|
||||
private onSelectCallback: (providerId: string) => void;
|
||||
private onCancelCallback: () => void;
|
||||
|
||||
constructor(mode: "login" | "logout", onSelect: (providerId: string) => void, onCancel: () => void) {
|
||||
super();
|
||||
|
||||
this.mode = mode;
|
||||
this.onSelectCallback = onSelect;
|
||||
this.onCancelCallback = onCancel;
|
||||
|
||||
// Load all OAuth providers
|
||||
this.loadProviders();
|
||||
|
||||
// Add top border
|
||||
this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add title
|
||||
const title = mode === "login" ? "Select provider to login:" : "Select provider to logout:";
|
||||
this.addChild(new Text(chalk.bold(title), 0, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Create list container
|
||||
this.listContainer = new Container();
|
||||
this.addChild(this.listContainer);
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add bottom border
|
||||
this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0));
|
||||
|
||||
// Initial render
|
||||
this.updateList();
|
||||
}
|
||||
|
||||
private loadProviders(): void {
|
||||
this.allProviders = getOAuthProviders();
|
||||
this.allProviders = this.allProviders.filter((p) => p.available);
|
||||
}
|
||||
|
||||
private updateList(): void {
|
||||
this.listContainer.clear();
|
||||
|
||||
for (let i = 0; i < this.allProviders.length; i++) {
|
||||
const provider = this.allProviders[i];
|
||||
if (!provider) continue;
|
||||
|
||||
const isSelected = i === this.selectedIndex;
|
||||
const isAvailable = provider.available;
|
||||
|
||||
let line = "";
|
||||
if (isSelected) {
|
||||
const prefix = chalk.blue("→ ");
|
||||
const text = isAvailable ? chalk.blue(provider.name) : chalk.dim(provider.name);
|
||||
line = prefix + text;
|
||||
} else {
|
||||
const text = isAvailable ? ` ${provider.name}` : chalk.dim(` ${provider.name}`);
|
||||
line = text;
|
||||
}
|
||||
|
||||
this.listContainer.addChild(new Text(line, 0, 0));
|
||||
}
|
||||
|
||||
// Show "no providers" if empty
|
||||
if (this.allProviders.length === 0) {
|
||||
const message =
|
||||
this.mode === "login" ? "No OAuth providers available" : "No OAuth providers logged in. Use /login first.";
|
||||
this.listContainer.addChild(new Text(chalk.gray(` ${message}`), 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
// Up arrow
|
||||
if (keyData === "\x1b[A") {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
this.updateList();
|
||||
}
|
||||
// Down arrow
|
||||
else if (keyData === "\x1b[B") {
|
||||
this.selectedIndex = Math.min(this.allProviders.length - 1, this.selectedIndex + 1);
|
||||
this.updateList();
|
||||
}
|
||||
// Enter
|
||||
else if (keyData === "\r") {
|
||||
const selectedProvider = this.allProviders[this.selectedIndex];
|
||||
if (selectedProvider?.available) {
|
||||
this.onSelectCallback(selectedProvider.id);
|
||||
}
|
||||
}
|
||||
// Escape
|
||||
else if (keyData === "\x1b") {
|
||||
this.onCancelCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import * as os from "node:os";
|
|||
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import chalk from "chalk";
|
||||
import * as Diff from "diff";
|
||||
import stripAnsi from "strip-ansi";
|
||||
|
||||
/**
|
||||
* Convert absolute path to tilde notation if it's in home directory
|
||||
|
|
@ -175,7 +176,8 @@ export class ToolExecutionComponent extends Container {
|
|||
const textBlocks = this.result.content?.filter((c: any) => c.type === "text") || [];
|
||||
const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
|
||||
|
||||
let output = textBlocks.map((c: any) => c.text).join("\n");
|
||||
// Strip ANSI codes from raw output (bash may emit colors/formatting)
|
||||
let output = textBlocks.map((c: any) => stripAnsi(c.text || "")).join("\n");
|
||||
|
||||
// Add indicator for images
|
||||
if (imageBlocks.length > 0) {
|
||||
|
|
@ -251,20 +253,27 @@ export class ToolExecutionComponent extends Container {
|
|||
const path = shortenPath(this.args?.file_path || this.args?.path || "");
|
||||
text = chalk.bold("edit") + " " + (path ? chalk.cyan(path) : chalk.dim("..."));
|
||||
|
||||
// Show diff if available
|
||||
if (this.result?.details?.diff) {
|
||||
// Parse the diff string and apply colors
|
||||
const diffLines = this.result.details.diff.split("\n");
|
||||
const coloredLines = diffLines.map((line: string) => {
|
||||
if (line.startsWith("+")) {
|
||||
return chalk.green(line);
|
||||
} else if (line.startsWith("-")) {
|
||||
return chalk.red(line);
|
||||
} else {
|
||||
return chalk.dim(line);
|
||||
if (this.result) {
|
||||
// Show error message if it's an error
|
||||
if (this.result.isError) {
|
||||
const errorText = this.getTextOutput();
|
||||
if (errorText) {
|
||||
text += "\n\n" + chalk.red(errorText);
|
||||
}
|
||||
});
|
||||
text += "\n\n" + coloredLines.join("\n");
|
||||
} else if (this.result.details?.diff) {
|
||||
// Show diff if available
|
||||
const diffLines = this.result.details.diff.split("\n");
|
||||
const coloredLines = diffLines.map((line: string) => {
|
||||
if (line.startsWith("+")) {
|
||||
return chalk.green(line);
|
||||
} else if (line.startsWith("-")) {
|
||||
return chalk.red(line);
|
||||
} else {
|
||||
return chalk.dim(line);
|
||||
}
|
||||
});
|
||||
text += "\n\n" + coloredLines.join("\n");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Generic tool
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import type { Agent, AgentEvent, AgentState } from "@mariozechner/pi-agent";
|
||||
import type { AssistantMessage, Message } from "@mariozechner/pi-ai";
|
||||
import type { Agent, AgentEvent, AgentState, ThinkingLevel } from "@mariozechner/pi-agent";
|
||||
import type { AssistantMessage, Message, Model } from "@mariozechner/pi-ai";
|
||||
import type { SlashCommand } from "@mariozechner/pi-tui";
|
||||
import {
|
||||
CombinedAutocompleteProvider,
|
||||
Container,
|
||||
Input,
|
||||
Loader,
|
||||
Markdown,
|
||||
ProcessTerminal,
|
||||
|
|
@ -12,14 +13,19 @@ import {
|
|||
TUI,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import chalk from "chalk";
|
||||
import { exec } from "child_process";
|
||||
import { getChangelogPath, parseChangelog } from "../changelog.js";
|
||||
import { exportSessionToHtml } from "../export-html.js";
|
||||
import { getApiKeyForModel, getAvailableModels } from "../model-config.js";
|
||||
import { listOAuthProviders, login, logout } from "../oauth/index.js";
|
||||
import type { SessionManager } from "../session-manager.js";
|
||||
import type { SettingsManager } from "../settings-manager.js";
|
||||
import { AssistantMessageComponent } from "./assistant-message.js";
|
||||
import { CustomEditor } from "./custom-editor.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
import { FooterComponent } from "./footer.js";
|
||||
import { ModelSelectorComponent } from "./model-selector.js";
|
||||
import { OAuthSelectorComponent } from "./oauth-selector.js";
|
||||
import { ThinkingSelectorComponent } from "./thinking-selector.js";
|
||||
import { ToolExecutionComponent } from "./tool-execution.js";
|
||||
import { UserMessageComponent } from "./user-message.js";
|
||||
|
|
@ -37,6 +43,7 @@ export class TuiRenderer {
|
|||
private footer: FooterComponent;
|
||||
private agent: Agent;
|
||||
private sessionManager: SessionManager;
|
||||
private settingsManager: SettingsManager;
|
||||
private version: string;
|
||||
private isInitialized = false;
|
||||
private onInputCallback?: (text: string) => void;
|
||||
|
|
@ -44,6 +51,7 @@ export class TuiRenderer {
|
|||
private onInterruptCallback?: () => void;
|
||||
private lastSigintTime = 0;
|
||||
private changelogMarkdown: string | null = null;
|
||||
private newVersion: string | null = null;
|
||||
|
||||
// Streaming message tracking
|
||||
private streamingComponent: AssistantMessageComponent | null = null;
|
||||
|
|
@ -60,14 +68,31 @@ export class TuiRenderer {
|
|||
// User message selector (for branching)
|
||||
private userMessageSelector: UserMessageSelectorComponent | null = null;
|
||||
|
||||
// OAuth selector
|
||||
private oauthSelector: any | null = null;
|
||||
|
||||
// Track if this is the first user message (to skip spacer)
|
||||
private isFirstUserMessage = true;
|
||||
|
||||
constructor(agent: Agent, sessionManager: SessionManager, version: string, changelogMarkdown: string | null = null) {
|
||||
// Model scope for quick cycling
|
||||
private scopedModels: Model<any>[] = [];
|
||||
|
||||
constructor(
|
||||
agent: Agent,
|
||||
sessionManager: SessionManager,
|
||||
settingsManager: SettingsManager,
|
||||
version: string,
|
||||
changelogMarkdown: string | null = null,
|
||||
newVersion: string | null = null,
|
||||
scopedModels: Model<any>[] = [],
|
||||
) {
|
||||
this.agent = agent;
|
||||
this.sessionManager = sessionManager;
|
||||
this.settingsManager = settingsManager;
|
||||
this.version = version;
|
||||
this.newVersion = newVersion;
|
||||
this.changelogMarkdown = changelogMarkdown;
|
||||
this.scopedModels = scopedModels;
|
||||
this.ui = new TUI(new ProcessTerminal());
|
||||
this.chatContainer = new Container();
|
||||
this.statusContainer = new Container();
|
||||
|
|
@ -107,9 +132,28 @@ export class TuiRenderer {
|
|||
description: "Create a new branch from a previous message",
|
||||
};
|
||||
|
||||
const loginCommand: SlashCommand = {
|
||||
name: "login",
|
||||
description: "Login with OAuth provider",
|
||||
};
|
||||
|
||||
const logoutCommand: SlashCommand = {
|
||||
name: "logout",
|
||||
description: "Logout from OAuth provider",
|
||||
};
|
||||
|
||||
// Setup autocomplete for file paths and slash commands
|
||||
const autocompleteProvider = new CombinedAutocompleteProvider(
|
||||
[thinkingCommand, modelCommand, exportCommand, sessionCommand, changelogCommand, branchCommand],
|
||||
[
|
||||
thinkingCommand,
|
||||
modelCommand,
|
||||
exportCommand,
|
||||
sessionCommand,
|
||||
changelogCommand,
|
||||
branchCommand,
|
||||
loginCommand,
|
||||
logoutCommand,
|
||||
],
|
||||
process.cwd(),
|
||||
);
|
||||
this.editor.setAutocompleteProvider(autocompleteProvider);
|
||||
|
|
@ -133,6 +177,12 @@ export class TuiRenderer {
|
|||
chalk.dim("ctrl+k") +
|
||||
chalk.gray(" to delete line") +
|
||||
"\n" +
|
||||
chalk.dim("shift+tab") +
|
||||
chalk.gray(" to cycle thinking") +
|
||||
"\n" +
|
||||
chalk.dim("ctrl+p") +
|
||||
chalk.gray(" to cycle models") +
|
||||
"\n" +
|
||||
chalk.dim("/") +
|
||||
chalk.gray(" for commands") +
|
||||
"\n" +
|
||||
|
|
@ -145,12 +195,28 @@ export class TuiRenderer {
|
|||
this.ui.addChild(header);
|
||||
this.ui.addChild(new Spacer(1));
|
||||
|
||||
// Add new version notification if available
|
||||
if (this.newVersion) {
|
||||
this.ui.addChild(new DynamicBorder(chalk.yellow));
|
||||
this.ui.addChild(
|
||||
new Text(
|
||||
chalk.bold.yellow("Update Available") +
|
||||
"\n" +
|
||||
chalk.gray(`New version ${this.newVersion} is available. Run: `) +
|
||||
chalk.cyan("npm install -g @mariozechner/pi-coding-agent"),
|
||||
1,
|
||||
0,
|
||||
),
|
||||
);
|
||||
this.ui.addChild(new DynamicBorder(chalk.yellow));
|
||||
}
|
||||
|
||||
// Add changelog if provided
|
||||
if (this.changelogMarkdown) {
|
||||
this.ui.addChild(new DynamicBorder(chalk.cyan));
|
||||
this.ui.addChild(new Text(chalk.bold.cyan("What's New"), 1, 0));
|
||||
this.ui.addChild(new Spacer(1));
|
||||
this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), undefined, undefined, undefined, 1, 0));
|
||||
this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0));
|
||||
this.ui.addChild(new Spacer(1));
|
||||
this.ui.addChild(new DynamicBorder(chalk.cyan));
|
||||
}
|
||||
|
|
@ -174,8 +240,16 @@ export class TuiRenderer {
|
|||
this.handleCtrlC();
|
||||
};
|
||||
|
||||
this.editor.onShiftTab = () => {
|
||||
this.cycleThinkingLevel();
|
||||
};
|
||||
|
||||
this.editor.onCtrlP = () => {
|
||||
this.cycleModel();
|
||||
};
|
||||
|
||||
// Handle editor submission
|
||||
this.editor.onSubmit = (text: string) => {
|
||||
this.editor.onSubmit = async (text: string) => {
|
||||
text = text.trim();
|
||||
if (!text) return;
|
||||
|
||||
|
|
@ -223,6 +297,43 @@ export class TuiRenderer {
|
|||
return;
|
||||
}
|
||||
|
||||
// Check for /login command
|
||||
if (text === "/login") {
|
||||
this.showOAuthSelector("login");
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for /logout command
|
||||
if (text === "/logout") {
|
||||
this.showOAuthSelector("logout");
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal message submission - validate model and API key first
|
||||
const currentModel = this.agent.state.model;
|
||||
if (!currentModel) {
|
||||
this.showError(
|
||||
"No model selected.\n\n" +
|
||||
"Set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)\n" +
|
||||
"or create ~/.pi/agent/models.json\n\n" +
|
||||
"Then use /model to select a model.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate API key (async)
|
||||
const apiKey = await getApiKeyForModel(currentModel);
|
||||
if (!apiKey) {
|
||||
this.showError(
|
||||
`No API key found for ${currentModel.provider}.\n\n` +
|
||||
`Set the appropriate environment variable or update ~/.pi/agent/models.json`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// All good, proceed with submission
|
||||
if (this.onInputCallback) {
|
||||
this.onInputCallback(text);
|
||||
}
|
||||
|
|
@ -344,7 +455,20 @@ export class TuiRenderer {
|
|||
// Update the existing tool component with the result
|
||||
const component = this.pendingTools.get(event.toolCallId);
|
||||
if (component) {
|
||||
component.updateResult(event.result);
|
||||
// Convert result to the format expected by updateResult
|
||||
const resultData =
|
||||
typeof event.result === "string"
|
||||
? {
|
||||
content: [{ type: "text" as const, text: event.result }],
|
||||
details: undefined,
|
||||
isError: event.isError,
|
||||
}
|
||||
: {
|
||||
content: event.result.content,
|
||||
details: event.result.details,
|
||||
isError: event.isError,
|
||||
};
|
||||
component.updateResult(resultData);
|
||||
this.pendingTools.delete(event.toolCallId);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
|
@ -398,6 +522,9 @@ export class TuiRenderer {
|
|||
// Update footer with loaded state
|
||||
this.footer.updateState(state);
|
||||
|
||||
// Update editor border color based on current thinking level
|
||||
this.updateEditorBorderColor();
|
||||
|
||||
// Render messages
|
||||
for (let i = 0; i < state.messages.length; i++) {
|
||||
const message = state.messages[i];
|
||||
|
|
@ -486,6 +613,116 @@ export class TuiRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
private getThinkingBorderColor(level: ThinkingLevel): (str: string) => string {
|
||||
// More thinking = more color (gray → dim colors → bright colors)
|
||||
switch (level) {
|
||||
case "off":
|
||||
return chalk.gray;
|
||||
case "minimal":
|
||||
return chalk.dim.blue;
|
||||
case "low":
|
||||
return chalk.blue;
|
||||
case "medium":
|
||||
return chalk.cyan;
|
||||
case "high":
|
||||
return chalk.magenta;
|
||||
default:
|
||||
return chalk.gray;
|
||||
}
|
||||
}
|
||||
|
||||
private updateEditorBorderColor(): void {
|
||||
const level = this.agent.state.thinkingLevel || "off";
|
||||
const color = this.getThinkingBorderColor(level);
|
||||
this.editor.borderColor = color;
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
private cycleThinkingLevel(): void {
|
||||
// Only cycle if model supports thinking
|
||||
if (!this.agent.state.model?.reasoning) {
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(chalk.dim("Current model does not support thinking"), 1, 0));
|
||||
this.ui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
const levels: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"];
|
||||
const currentLevel = this.agent.state.thinkingLevel || "off";
|
||||
const currentIndex = levels.indexOf(currentLevel);
|
||||
const nextIndex = (currentIndex + 1) % levels.length;
|
||||
const nextLevel = levels[nextIndex];
|
||||
|
||||
// Apply the new thinking level
|
||||
this.agent.setThinkingLevel(nextLevel);
|
||||
|
||||
// Save thinking level change to session
|
||||
this.sessionManager.saveThinkingLevelChange(nextLevel);
|
||||
|
||||
// Update border color
|
||||
this.updateEditorBorderColor();
|
||||
|
||||
// Show brief notification
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(chalk.dim(`Thinking level: ${nextLevel}`), 1, 0));
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
private async cycleModel(): Promise<void> {
|
||||
// Use scoped models if available, otherwise all available models
|
||||
let modelsToUse: Model<any>[];
|
||||
if (this.scopedModels.length > 0) {
|
||||
modelsToUse = this.scopedModels;
|
||||
} else {
|
||||
const { models: availableModels, error } = await getAvailableModels();
|
||||
if (error) {
|
||||
this.showError(`Failed to load models: ${error}`);
|
||||
return;
|
||||
}
|
||||
modelsToUse = availableModels;
|
||||
}
|
||||
|
||||
if (modelsToUse.length === 0) {
|
||||
this.showError("No models available to cycle");
|
||||
return;
|
||||
}
|
||||
|
||||
if (modelsToUse.length === 1) {
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(chalk.dim("Only one model in scope"), 1, 0));
|
||||
this.ui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentModel = this.agent.state.model;
|
||||
let currentIndex = modelsToUse.findIndex(
|
||||
(m) => m.id === currentModel?.id && m.provider === currentModel?.provider,
|
||||
);
|
||||
|
||||
// If current model not in scope, start from first
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
const nextIndex = (currentIndex + 1) % modelsToUse.length;
|
||||
const nextModel = modelsToUse[nextIndex];
|
||||
|
||||
// Validate API key
|
||||
const apiKey = await getApiKeyForModel(nextModel);
|
||||
if (!apiKey) {
|
||||
this.showError(`No API key for ${nextModel.provider}/${nextModel.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch model
|
||||
this.agent.setModel(nextModel);
|
||||
|
||||
// Show notification
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(chalk.dim(`Switched to ${nextModel.name || nextModel.id}`), 1, 0));
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
clearEditor(): void {
|
||||
this.editor.setText("");
|
||||
this.ui.requestRender();
|
||||
|
|
@ -498,6 +735,13 @@ export class TuiRenderer {
|
|||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
showWarning(warningMessage: string): void {
|
||||
// Show warning message in the chat
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(chalk.yellow(`Warning: ${warningMessage}`), 1, 0));
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
private showThinkingSelector(): void {
|
||||
// Create thinking selector with current level
|
||||
this.thinkingSelector = new ThinkingSelectorComponent(
|
||||
|
|
@ -509,6 +753,9 @@ export class TuiRenderer {
|
|||
// Save thinking level change to session
|
||||
this.sessionManager.saveThinkingLevelChange(level);
|
||||
|
||||
// Update border color
|
||||
this.updateEditorBorderColor();
|
||||
|
||||
// Show confirmation message with proper spacing
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
const confirmText = new Text(chalk.dim(`Thinking level: ${level}`), 1, 0);
|
||||
|
|
@ -543,7 +790,9 @@ export class TuiRenderer {
|
|||
private showModelSelector(): void {
|
||||
// Create model selector with current model
|
||||
this.modelSelector = new ModelSelectorComponent(
|
||||
this.ui,
|
||||
this.agent.state.model,
|
||||
this.settingsManager,
|
||||
(model) => {
|
||||
// Apply the selected model
|
||||
this.agent.setModel(model);
|
||||
|
|
@ -666,6 +915,121 @@ export class TuiRenderer {
|
|||
this.ui.setFocus(this.editor);
|
||||
}
|
||||
|
||||
private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
|
||||
// For logout mode, filter to only show logged-in providers
|
||||
let providersToShow: string[] = [];
|
||||
if (mode === "logout") {
|
||||
const loggedInProviders = listOAuthProviders();
|
||||
if (loggedInProviders.length === 0) {
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(chalk.dim("No OAuth providers logged in. Use /login first."), 1, 0));
|
||||
this.ui.requestRender();
|
||||
return;
|
||||
}
|
||||
providersToShow = loggedInProviders;
|
||||
}
|
||||
|
||||
// Create OAuth selector
|
||||
this.oauthSelector = new OAuthSelectorComponent(
|
||||
mode,
|
||||
async (providerId: any) => {
|
||||
// Hide selector first
|
||||
this.hideOAuthSelector();
|
||||
|
||||
if (mode === "login") {
|
||||
// Handle login
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(chalk.dim(`Logging in to ${providerId}...`), 1, 0));
|
||||
this.ui.requestRender();
|
||||
|
||||
try {
|
||||
await login(
|
||||
providerId,
|
||||
(url: string) => {
|
||||
// Show auth URL to user
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(chalk.cyan("Opening browser to:"), 1, 0));
|
||||
this.chatContainer.addChild(new Text(chalk.cyan(url), 1, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(
|
||||
new Text(chalk.yellow("Paste the authorization code below:"), 1, 0),
|
||||
);
|
||||
this.ui.requestRender();
|
||||
|
||||
// Open URL in browser
|
||||
const openCmd =
|
||||
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
||||
exec(`${openCmd} "${url}"`);
|
||||
},
|
||||
async () => {
|
||||
// Prompt for code with a simple Input
|
||||
return new Promise<string>((resolve) => {
|
||||
const codeInput = new Input();
|
||||
codeInput.onSubmit = () => {
|
||||
const code = codeInput.getValue();
|
||||
// Restore editor
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.ui.setFocus(this.editor);
|
||||
resolve(code);
|
||||
};
|
||||
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(codeInput);
|
||||
this.ui.setFocus(codeInput);
|
||||
this.ui.requestRender();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Success
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(chalk.green(`✓ Successfully logged in to ${providerId}`), 1, 0));
|
||||
this.chatContainer.addChild(new Text(chalk.dim(`Tokens saved to ~/.pi/agent/oauth.json`), 1, 0));
|
||||
this.ui.requestRender();
|
||||
} catch (error: any) {
|
||||
this.showError(`Login failed: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
// Handle logout
|
||||
try {
|
||||
await logout(providerId);
|
||||
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(
|
||||
new Text(chalk.green(`✓ Successfully logged out of ${providerId}`), 1, 0),
|
||||
);
|
||||
this.chatContainer.addChild(
|
||||
new Text(chalk.dim(`Credentials removed from ~/.pi/agent/oauth.json`), 1, 0),
|
||||
);
|
||||
this.ui.requestRender();
|
||||
} catch (error: any) {
|
||||
this.showError(`Logout failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Cancel - just hide the selector
|
||||
this.hideOAuthSelector();
|
||||
this.ui.requestRender();
|
||||
},
|
||||
);
|
||||
|
||||
// Replace editor with selector
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.oauthSelector);
|
||||
this.ui.setFocus(this.oauthSelector);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
private hideOAuthSelector(): void {
|
||||
// Replace selector with editor in the container
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.oauthSelector = null;
|
||||
this.ui.setFocus(this.editor);
|
||||
}
|
||||
|
||||
private handleExportCommand(text: string): void {
|
||||
// Parse optional filename from command: /export [filename]
|
||||
const parts = text.split(/\s+/);
|
||||
|
|
@ -779,7 +1143,7 @@ export class TuiRenderer {
|
|||
this.chatContainer.addChild(new DynamicBorder(chalk.cyan));
|
||||
this.ui.addChild(new Text(chalk.bold.cyan("What's New"), 1, 0));
|
||||
this.ui.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Markdown(changelogMarkdown));
|
||||
this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1));
|
||||
this.chatContainer.addChild(new DynamicBorder(chalk.cyan));
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export class UserMessageComponent extends Container {
|
|||
}
|
||||
|
||||
// User messages with dark gray background
|
||||
this.markdown = new Markdown(text, undefined, undefined, { r: 52, g: 53, b: 65 });
|
||||
this.markdown = new Markdown(text, 1, 1, { bgColor: "#343541" });
|
||||
this.addChild(this.markdown);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@mariozechner/pi",
|
||||
"version": "0.7.10",
|
||||
"version": "0.7.25",
|
||||
"description": "CLI tool for managing vLLM deployments on GPU pods",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-agent": "^0.7.10",
|
||||
"@mariozechner/pi-agent": "^0.7.25",
|
||||
"chalk": "^5.5.0"
|
||||
},
|
||||
"devDependencies": {}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@mariozechner/pi-proxy",
|
||||
"version": "0.7.10",
|
||||
"version": "0.7.25",
|
||||
"type": "module",
|
||||
"description": "CORS and authentication proxy for pi-ai",
|
||||
"main": "dist/index.js",
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
Line 1: The beginning of our story
|
||||
Line 2: Once upon a time
|
||||
Line 3: In a land far away
|
||||
Line 4: There lived a brave knight
|
||||
Line 5: Who sought adventure daily
|
||||
Line 6: Mountains rose in the distance
|
||||
Line 7: Rivers flowed through valleys
|
||||
Line 8: Birds sang in the morning
|
||||
Line 9: The sun rose over the horizon
|
||||
Line 10: Illuminating the world with warmth
|
||||
Line 11: People gathered in the marketplace
|
||||
Line 12: Trading goods and stories
|
||||
Line 13: Children played in the streets
|
||||
Line 14: Laughter echoed through the town
|
||||
Line 15: Old wise men sat watching
|
||||
Line 16: Remembering days gone by
|
||||
Line 17: The castle stood tall and proud
|
||||
Line 18: Guarding the kingdom below
|
||||
Line 19: Flags waved in the breeze
|
||||
Line 20: Colors bright and bold
|
||||
Line 21: Halfway through our tale
|
||||
Line 22: The plot begins to thicken
|
||||
Line 23: A terrible storm approaches quickly
|
||||
Line 24: Lightning strikes and thunder roars
|
||||
Line 25: Our hero stands ready for combat
|
||||
Line 26: Armor gleaming in the light
|
||||
Line 27: Sword sharp and ready
|
||||
Line 28: Shield painted with his crest
|
||||
Line 29: He rides out to face danger
|
||||
Line 30: Determined and brave
|
||||
Line 31: The journey takes him far
|
||||
Line 32: Through forests deep and dark
|
||||
Line 33: Across bridges old and creaky
|
||||
Line 34: Past caverns filled with ancient magic
|
||||
Line 35: Along cliffs steep and dangerous
|
||||
Line 36: Through storms and wind and rain
|
||||
Line 37: He never loses hope
|
||||
Line 38: His quest drives him forward
|
||||
Line 39: Finally he reaches his goal
|
||||
Line 40: The dragon's lair appears
|
||||
Line 41: Smoke rises from within
|
||||
Line 42: The ground trembles beneath
|
||||
Line 43: A roar shakes the very air
|
||||
Line 44: The battle begins at last
|
||||
Line 45: Steel clashes against scales
|
||||
Line 46: Fire meets courage head on
|
||||
Line 47: The fight rages for hours
|
||||
Line 48: Until glory and honor are won
|
||||
Line 49: The knight returns home triumphant
|
||||
Line 50: And that's the end of our tale
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
hello
|
||||
world
|
||||
hello
|
||||
world
|
||||
hello
|
||||
|
|
@ -115,7 +115,7 @@ editor.setAutocompleteProvider(provider);
|
|||
|
||||
**Key Bindings:**
|
||||
- `Enter` - Submit
|
||||
- `Shift+Enter` or `Ctrl+Enter` - New line
|
||||
- `Shift+Enter`, `Ctrl+Enter`, or `Alt+Enter` - New line (terminal-dependent, Alt+Enter most reliable)
|
||||
- `Tab` - Autocomplete
|
||||
- `Ctrl+K` - Delete line
|
||||
- `Ctrl+A` / `Ctrl+E` - Line start/end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@mariozechner/pi-tui",
|
||||
"version": "0.7.10",
|
||||
"version": "0.7.25",
|
||||
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
|
|
|||
|
|
@ -286,10 +286,12 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||
// Match paths - including those ending with /, ~/, or any word at end for forced extraction
|
||||
// This regex captures:
|
||||
// - Paths starting from beginning of line or after space/quote/equals
|
||||
// - Optional ./ or ../ or ~/ prefix (including the trailing slash for ~/)
|
||||
// - Absolute paths starting with /
|
||||
// - Relative paths with ./ or ../
|
||||
// - Home directory paths with ~/
|
||||
// - The path itself (can include / in the middle)
|
||||
// - For forced extraction, capture any word at the end
|
||||
const matches = text.match(/(?:^|[\s"'=])((?:~\/|\.{0,2}\/?)?(?:[^\s"'=]*\/?)*[^\s"'=]*)$/);
|
||||
const matches = text.match(/(?:^|[\s"'=])((?:\/|~\/|\.{1,2}\/)?(?:[^\s"'=]*\/?)*[^\s"'=]*)$/);
|
||||
if (!matches) {
|
||||
// If forced extraction and no matches, return empty string to trigger from current dir
|
||||
return forceExtract ? "" : null;
|
||||
|
|
@ -354,10 +356,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||
expandedPrefix === "../" ||
|
||||
expandedPrefix === "~" ||
|
||||
expandedPrefix === "~/" ||
|
||||
expandedPrefix === "/" ||
|
||||
prefix === "@"
|
||||
) {
|
||||
// Complete from specified position
|
||||
if (prefix.startsWith("~")) {
|
||||
if (prefix.startsWith("~") || expandedPrefix === "/") {
|
||||
searchDir = expandedPrefix;
|
||||
} else {
|
||||
searchDir = join(this.basePath, expandedPrefix);
|
||||
|
|
@ -365,7 +368,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||
searchPrefix = "";
|
||||
} else if (expandedPrefix.endsWith("/")) {
|
||||
// If prefix ends with /, show contents of that directory
|
||||
if (prefix.startsWith("~") || (isAtPrefix && expandedPrefix.startsWith("/"))) {
|
||||
if (prefix.startsWith("~") || expandedPrefix.startsWith("/")) {
|
||||
searchDir = expandedPrefix;
|
||||
} else {
|
||||
searchDir = join(this.basePath, expandedPrefix);
|
||||
|
|
@ -375,7 +378,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||
// Split into directory and file prefix
|
||||
const dir = dirname(expandedPrefix);
|
||||
const file = basename(expandedPrefix);
|
||||
if (prefix.startsWith("~") || (isAtPrefix && expandedPrefix.startsWith("/"))) {
|
||||
if (prefix.startsWith("~") || expandedPrefix.startsWith("/")) {
|
||||
searchDir = dir;
|
||||
} else {
|
||||
searchDir = join(this.basePath, dir);
|
||||
|
|
@ -392,7 +395,13 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||
}
|
||||
|
||||
const fullPath = join(searchDir, entry);
|
||||
const isDirectory = statSync(fullPath).isDirectory();
|
||||
let isDirectory: boolean;
|
||||
try {
|
||||
isDirectory = statSync(fullPath).isDirectory();
|
||||
} catch (e) {
|
||||
// Skip files we can't stat (permission issues, broken symlinks, etc.)
|
||||
continue;
|
||||
}
|
||||
|
||||
// For @ prefix, filter to only show directories and attachable files
|
||||
if (isAtPrefix && !isDirectory && !isAttachableFile(fullPath)) {
|
||||
|
|
@ -430,6 +439,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||
const homeRelativeDir = prefix.slice(2); // Remove ~/
|
||||
const dir = dirname(homeRelativeDir);
|
||||
relativePath = "~/" + (dir === "." ? entry : join(dir, entry));
|
||||
} else if (prefix.startsWith("/")) {
|
||||
// Absolute path - construct properly
|
||||
const dir = dirname(prefix);
|
||||
if (dir === "/") {
|
||||
relativePath = "/" + entry;
|
||||
} else {
|
||||
relativePath = dir + "/" + entry;
|
||||
}
|
||||
} else {
|
||||
relativePath = join(dirname(prefix), entry);
|
||||
}
|
||||
|
|
@ -458,7 +475,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return suggestions.slice(0, 10); // Limit to 10 suggestions
|
||||
return suggestions;
|
||||
} catch (e) {
|
||||
// Directory doesn't exist or not accessible
|
||||
return [];
|
||||
|
|
@ -474,8 +491,8 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||
const currentLine = lines[cursorLine] || "";
|
||||
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
||||
|
||||
// Don't trigger if we're in a slash command
|
||||
if (textBeforeCursor.startsWith("/") && !textBeforeCursor.includes(" ")) {
|
||||
// Don't trigger if we're typing a slash command at the start of the line
|
||||
if (textBeforeCursor.trim().startsWith("/") && !textBeforeCursor.trim().includes(" ")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -499,8 +516,8 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||
const currentLine = lines[cursorLine] || "";
|
||||
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
||||
|
||||
// Don't trigger if we're in a slash command
|
||||
if (textBeforeCursor.startsWith("/") && !textBeforeCursor.includes(" ")) {
|
||||
// Don't trigger if we're typing a slash command at the start of the line
|
||||
if (textBeforeCursor.trim().startsWith("/") && !textBeforeCursor.trim().includes(" ")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ export class Editor implements Component {
|
|||
|
||||
private config: TextEditorConfig = {};
|
||||
|
||||
// Border color (can be changed dynamically)
|
||||
public borderColor: (str: string) => string = chalk.gray;
|
||||
|
||||
// Autocomplete support
|
||||
private autocompleteProvider?: AutocompleteProvider;
|
||||
private autocompleteList?: SelectList;
|
||||
|
|
@ -61,7 +64,7 @@ export class Editor implements Component {
|
|||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const horizontal = chalk.gray("─");
|
||||
const horizontal = this.borderColor("─");
|
||||
|
||||
// Layout the text - use full width
|
||||
const layoutLines = this.layoutText(width);
|
||||
|
|
@ -333,8 +336,8 @@ export class Editor implements Component {
|
|||
// Left
|
||||
this.moveCursor(0, -1);
|
||||
}
|
||||
// Regular characters (printable ASCII)
|
||||
else if (data.charCodeAt(0) >= 32 && data.charCodeAt(0) <= 126) {
|
||||
// Regular characters (printable characters and unicode, but not control characters)
|
||||
else if (data.charCodeAt(0) >= 32) {
|
||||
this.insertCharacter(data);
|
||||
}
|
||||
}
|
||||
|
|
@ -472,7 +475,7 @@ export class Editor implements Component {
|
|||
// Filter out non-printable characters except newlines
|
||||
const filteredText = tabExpandedText
|
||||
.split("")
|
||||
.filter((char) => char === "\n" || (char >= " " && char <= "~"))
|
||||
.filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
|
||||
.join("");
|
||||
|
||||
// Split into lines
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ export class Input implements Component {
|
|||
private cursor: number = 0; // Cursor position in the value
|
||||
public onSubmit?: (value: string) => void;
|
||||
|
||||
// Bracketed paste mode buffering
|
||||
private pasteBuffer: string = "";
|
||||
private isInPaste: boolean = false;
|
||||
|
||||
getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
|
@ -19,6 +23,42 @@ export class Input implements Component {
|
|||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
// Handle bracketed paste mode
|
||||
// Start of paste: \x1b[200~
|
||||
// End of paste: \x1b[201~
|
||||
|
||||
// Check if we're starting a bracketed paste
|
||||
if (data.includes("\x1b[200~")) {
|
||||
this.isInPaste = true;
|
||||
this.pasteBuffer = "";
|
||||
data = data.replace("\x1b[200~", "");
|
||||
}
|
||||
|
||||
// If we're in a paste, buffer the data
|
||||
if (this.isInPaste) {
|
||||
// Check if this chunk contains the end marker
|
||||
this.pasteBuffer += data;
|
||||
|
||||
const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
|
||||
if (endIndex !== -1) {
|
||||
// Extract the pasted content
|
||||
const pasteContent = this.pasteBuffer.substring(0, endIndex);
|
||||
|
||||
// Process the complete paste
|
||||
this.handlePaste(pasteContent);
|
||||
|
||||
// Reset paste state
|
||||
this.isInPaste = false;
|
||||
|
||||
// Handle any remaining input after the paste marker
|
||||
const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~
|
||||
this.pasteBuffer = "";
|
||||
if (remaining) {
|
||||
this.handleInput(remaining);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Handle special keys
|
||||
if (data === "\r" || data === "\n") {
|
||||
// Enter - submit
|
||||
|
|
@ -80,6 +120,15 @@ export class Input implements Component {
|
|||
}
|
||||
}
|
||||
|
||||
private handlePaste(pastedText: string): void {
|
||||
// Clean the pasted text - remove newlines and carriage returns
|
||||
const cleanText = pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "");
|
||||
|
||||
// Insert at cursor position
|
||||
this.value = this.value.slice(0, this.cursor) + cleanText + this.value.slice(this.cursor);
|
||||
this.cursor += cleanText.length;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
// Calculate visible window
|
||||
const prompt = "> ";
|
||||
|
|
|
|||
|
|
@ -1,55 +1,46 @@
|
|||
import chalk from "chalk";
|
||||
import { Chalk } from "chalk";
|
||||
import { marked, type Token } from "marked";
|
||||
import type { Component } from "../tui.js";
|
||||
import { visibleWidth } from "../utils.js";
|
||||
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";
|
||||
|
||||
type Color =
|
||||
| "black"
|
||||
| "red"
|
||||
| "green"
|
||||
| "yellow"
|
||||
| "blue"
|
||||
| "magenta"
|
||||
| "cyan"
|
||||
| "white"
|
||||
| "gray"
|
||||
| "bgBlack"
|
||||
| "bgRed"
|
||||
| "bgGreen"
|
||||
| "bgYellow"
|
||||
| "bgBlue"
|
||||
| "bgMagenta"
|
||||
| "bgCyan"
|
||||
| "bgWhite"
|
||||
| "bgGray";
|
||||
// Use a chalk instance with color level 3 for consistent ANSI output
|
||||
const colorChalk = new Chalk({ level: 3 });
|
||||
|
||||
/**
|
||||
* Default text styling for markdown content.
|
||||
* Applied to all text unless overridden by markdown formatting.
|
||||
*/
|
||||
export interface DefaultTextStyle {
|
||||
/** Foreground color - named color or hex string like "#ff0000" */
|
||||
color?: string;
|
||||
/** Background color - named color or hex string like "#ff0000" */
|
||||
bgColor?: string;
|
||||
/** Bold text */
|
||||
bold?: boolean;
|
||||
/** Italic text */
|
||||
italic?: boolean;
|
||||
/** Strikethrough text */
|
||||
strikethrough?: boolean;
|
||||
/** Underline text */
|
||||
underline?: boolean;
|
||||
}
|
||||
|
||||
export class Markdown implements Component {
|
||||
private text: string;
|
||||
private bgColor?: Color;
|
||||
private fgColor?: Color;
|
||||
private customBgRgb?: { r: number; g: number; b: number };
|
||||
private paddingX: number; // Left/right padding
|
||||
private paddingY: number; // Top/bottom padding
|
||||
private defaultTextStyle?: DefaultTextStyle;
|
||||
|
||||
// Cache for rendered output
|
||||
private cachedText?: string;
|
||||
private cachedWidth?: number;
|
||||
private cachedLines?: string[];
|
||||
|
||||
constructor(
|
||||
text: string = "",
|
||||
bgColor?: Color,
|
||||
fgColor?: Color,
|
||||
customBgRgb?: { r: number; g: number; b: number },
|
||||
paddingX: number = 1,
|
||||
paddingY: number = 1,
|
||||
) {
|
||||
constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, defaultTextStyle?: DefaultTextStyle) {
|
||||
this.text = text;
|
||||
this.bgColor = bgColor;
|
||||
this.fgColor = fgColor;
|
||||
this.customBgRgb = customBgRgb;
|
||||
this.paddingX = paddingX;
|
||||
this.paddingY = paddingY;
|
||||
this.defaultTextStyle = defaultTextStyle;
|
||||
}
|
||||
|
||||
setText(text: string): void {
|
||||
|
|
@ -60,30 +51,6 @@ export class Markdown implements Component {
|
|||
this.cachedLines = undefined;
|
||||
}
|
||||
|
||||
setBgColor(bgColor?: Color): void {
|
||||
this.bgColor = bgColor;
|
||||
// Invalidate cache when color changes
|
||||
this.cachedText = undefined;
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
}
|
||||
|
||||
setFgColor(fgColor?: Color): void {
|
||||
this.fgColor = fgColor;
|
||||
// Invalidate cache when color changes
|
||||
this.cachedText = undefined;
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
}
|
||||
|
||||
setCustomBgRgb(customBgRgb?: { r: number; g: number; b: number }): void {
|
||||
this.customBgRgb = customBgRgb;
|
||||
// Invalidate cache when color changes
|
||||
this.cachedText = undefined;
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
// Check cache
|
||||
if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
|
||||
|
|
@ -119,68 +86,41 @@ export class Markdown implements Component {
|
|||
renderedLines.push(...tokenLines);
|
||||
}
|
||||
|
||||
// Wrap lines to fit content width
|
||||
// Wrap lines (NO padding, NO background yet)
|
||||
const wrappedLines: string[] = [];
|
||||
for (const line of renderedLines) {
|
||||
wrappedLines.push(...this.wrapLine(line, contentWidth));
|
||||
wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
|
||||
}
|
||||
|
||||
// Add padding and apply colors
|
||||
const leftPad = " ".repeat(this.paddingX);
|
||||
const paddedLines: string[] = [];
|
||||
// Add margins and background to each wrapped line
|
||||
const leftMargin = " ".repeat(this.paddingX);
|
||||
const rightMargin = " ".repeat(this.paddingX);
|
||||
const bgRgb = this.defaultTextStyle?.bgColor ? this.parseBgColor() : undefined;
|
||||
const contentLines: string[] = [];
|
||||
|
||||
for (const line of wrappedLines) {
|
||||
// Calculate visible length
|
||||
const visibleLength = visibleWidth(line);
|
||||
// Right padding to fill to width (accounting for left padding and content)
|
||||
const rightPadLength = Math.max(0, width - this.paddingX - visibleLength);
|
||||
const rightPad = " ".repeat(rightPadLength);
|
||||
const lineWithMargins = leftMargin + line + rightMargin;
|
||||
|
||||
// Add left padding, content, and right padding
|
||||
let paddedLine = leftPad + line + rightPad;
|
||||
|
||||
// Apply foreground color if specified
|
||||
if (this.fgColor) {
|
||||
paddedLine = (chalk as any)[this.fgColor](paddedLine);
|
||||
if (bgRgb) {
|
||||
contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgRgb));
|
||||
} else {
|
||||
// No background - just pad to width
|
||||
const visibleLen = visibleWidth(lineWithMargins);
|
||||
const paddingNeeded = Math.max(0, width - visibleLen);
|
||||
contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
|
||||
}
|
||||
|
||||
// Apply background color if specified
|
||||
if (this.customBgRgb) {
|
||||
paddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(paddedLine);
|
||||
} else if (this.bgColor) {
|
||||
paddedLine = (chalk as any)[this.bgColor](paddedLine);
|
||||
}
|
||||
|
||||
paddedLines.push(paddedLine);
|
||||
}
|
||||
|
||||
// Add top padding (empty lines)
|
||||
// Add top/bottom padding (empty lines)
|
||||
const emptyLine = " ".repeat(width);
|
||||
const topPadding: string[] = [];
|
||||
const emptyLines: string[] = [];
|
||||
for (let i = 0; i < this.paddingY; i++) {
|
||||
let emptyPaddedLine = emptyLine;
|
||||
if (this.customBgRgb) {
|
||||
emptyPaddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(emptyPaddedLine);
|
||||
} else if (this.bgColor) {
|
||||
emptyPaddedLine = (chalk as any)[this.bgColor](emptyPaddedLine);
|
||||
}
|
||||
topPadding.push(emptyPaddedLine);
|
||||
}
|
||||
|
||||
// Add bottom padding (empty lines)
|
||||
const bottomPadding: string[] = [];
|
||||
for (let i = 0; i < this.paddingY; i++) {
|
||||
let emptyPaddedLine = emptyLine;
|
||||
if (this.customBgRgb) {
|
||||
emptyPaddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(emptyPaddedLine);
|
||||
} else if (this.bgColor) {
|
||||
emptyPaddedLine = (chalk as any)[this.bgColor](emptyPaddedLine);
|
||||
}
|
||||
bottomPadding.push(emptyPaddedLine);
|
||||
const line = bgRgb ? applyBackgroundToLine(emptyLine, width, bgRgb) : emptyLine;
|
||||
emptyLines.push(line);
|
||||
}
|
||||
|
||||
// Combine top padding, content, and bottom padding
|
||||
const result = [...topPadding, ...paddedLines, ...bottomPadding];
|
||||
const result = [...emptyLines, ...contentLines, ...emptyLines];
|
||||
|
||||
// Update cache
|
||||
this.cachedText = this.text;
|
||||
|
|
@ -190,6 +130,84 @@ export class Markdown implements Component {
|
|||
return result.length > 0 ? result : [""];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse background color from defaultTextStyle to RGB values
|
||||
*/
|
||||
private parseBgColor(): { r: number; g: number; b: number } | undefined {
|
||||
if (!this.defaultTextStyle?.bgColor) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.defaultTextStyle.bgColor.startsWith("#")) {
|
||||
// Hex color
|
||||
const hex = this.defaultTextStyle.bgColor.substring(1);
|
||||
return {
|
||||
r: Number.parseInt(hex.substring(0, 2), 16),
|
||||
g: Number.parseInt(hex.substring(2, 4), 16),
|
||||
b: Number.parseInt(hex.substring(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
// Named colors - map to RGB (common terminal colors)
|
||||
const colorMap: Record<string, { r: number; g: number; b: number }> = {
|
||||
bgBlack: { r: 0, g: 0, b: 0 },
|
||||
bgRed: { r: 255, g: 0, b: 0 },
|
||||
bgGreen: { r: 0, g: 255, b: 0 },
|
||||
bgYellow: { r: 255, g: 255, b: 0 },
|
||||
bgBlue: { r: 0, g: 0, b: 255 },
|
||||
bgMagenta: { r: 255, g: 0, b: 255 },
|
||||
bgCyan: { r: 0, g: 255, b: 255 },
|
||||
bgWhite: { r: 255, g: 255, b: 255 },
|
||||
};
|
||||
|
||||
return colorMap[this.defaultTextStyle.bgColor];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply default text style to a string.
|
||||
* This is the base styling applied to all text content.
|
||||
* NOTE: Background color is NOT applied here - it's applied at the padding stage
|
||||
* to ensure it extends to the full line width.
|
||||
*/
|
||||
private applyDefaultStyle(text: string): string {
|
||||
if (!this.defaultTextStyle) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let styled = text;
|
||||
|
||||
// Apply foreground color (NOT background - that's applied at padding stage)
|
||||
if (this.defaultTextStyle.color) {
|
||||
if (this.defaultTextStyle.color.startsWith("#")) {
|
||||
// Hex color
|
||||
const hex = this.defaultTextStyle.color.substring(1);
|
||||
const r = Number.parseInt(hex.substring(0, 2), 16);
|
||||
const g = Number.parseInt(hex.substring(2, 4), 16);
|
||||
const b = Number.parseInt(hex.substring(4, 6), 16);
|
||||
styled = colorChalk.rgb(r, g, b)(styled);
|
||||
} else {
|
||||
// Named color
|
||||
styled = (colorChalk as any)[this.defaultTextStyle.color](styled);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply text decorations
|
||||
if (this.defaultTextStyle.bold) {
|
||||
styled = colorChalk.bold(styled);
|
||||
}
|
||||
if (this.defaultTextStyle.italic) {
|
||||
styled = colorChalk.italic(styled);
|
||||
}
|
||||
if (this.defaultTextStyle.strikethrough) {
|
||||
styled = colorChalk.strikethrough(styled);
|
||||
}
|
||||
if (this.defaultTextStyle.underline) {
|
||||
styled = colorChalk.underline(styled);
|
||||
}
|
||||
|
||||
return styled;
|
||||
}
|
||||
|
||||
private renderToken(token: Token, width: number, nextTokenType?: string): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
|
|
@ -199,11 +217,11 @@ export class Markdown implements Component {
|
|||
const headingPrefix = "#".repeat(headingLevel) + " ";
|
||||
const headingText = this.renderInlineTokens(token.tokens || []);
|
||||
if (headingLevel === 1) {
|
||||
lines.push(chalk.bold.underline.yellow(headingText));
|
||||
lines.push(colorChalk.bold.underline.yellow(headingText));
|
||||
} else if (headingLevel === 2) {
|
||||
lines.push(chalk.bold.yellow(headingText));
|
||||
lines.push(colorChalk.bold.yellow(headingText));
|
||||
} else {
|
||||
lines.push(chalk.bold(headingPrefix + headingText));
|
||||
lines.push(colorChalk.bold(headingPrefix + headingText));
|
||||
}
|
||||
lines.push(""); // Add spacing after headings
|
||||
break;
|
||||
|
|
@ -220,13 +238,13 @@ export class Markdown implements Component {
|
|||
}
|
||||
|
||||
case "code": {
|
||||
lines.push(chalk.gray("```" + (token.lang || "")));
|
||||
lines.push(colorChalk.gray("```" + (token.lang || "")));
|
||||
// Split code by newlines and style each line
|
||||
const codeLines = token.text.split("\n");
|
||||
for (const codeLine of codeLines) {
|
||||
lines.push(chalk.dim(" ") + chalk.green(codeLine));
|
||||
lines.push(colorChalk.dim(" ") + colorChalk.green(codeLine));
|
||||
}
|
||||
lines.push(chalk.gray("```"));
|
||||
lines.push(colorChalk.gray("```"));
|
||||
lines.push(""); // Add spacing after code blocks
|
||||
break;
|
||||
}
|
||||
|
|
@ -249,14 +267,14 @@ export class Markdown implements Component {
|
|||
const quoteText = this.renderInlineTokens(token.tokens || []);
|
||||
const quoteLines = quoteText.split("\n");
|
||||
for (const quoteLine of quoteLines) {
|
||||
lines.push(chalk.gray("│ ") + chalk.italic(quoteLine));
|
||||
lines.push(colorChalk.gray("│ ") + colorChalk.italic(quoteLine));
|
||||
}
|
||||
lines.push(""); // Add spacing after blockquotes
|
||||
break;
|
||||
}
|
||||
|
||||
case "hr":
|
||||
lines.push(chalk.gray("─".repeat(Math.min(width, 80))));
|
||||
lines.push(colorChalk.gray("─".repeat(Math.min(width, 80))));
|
||||
lines.push(""); // Add spacing after horizontal rules
|
||||
break;
|
||||
|
||||
|
|
@ -289,29 +307,40 @@ export class Markdown implements Component {
|
|||
if (token.tokens && token.tokens.length > 0) {
|
||||
result += this.renderInlineTokens(token.tokens);
|
||||
} else {
|
||||
result += token.text;
|
||||
// Apply default style to plain text
|
||||
result += this.applyDefaultStyle(token.text);
|
||||
}
|
||||
break;
|
||||
|
||||
case "strong":
|
||||
result += chalk.bold(this.renderInlineTokens(token.tokens || []));
|
||||
case "strong": {
|
||||
// Apply bold, then reapply default style after
|
||||
const boldContent = this.renderInlineTokens(token.tokens || []);
|
||||
result += colorChalk.bold(boldContent) + this.applyDefaultStyle("");
|
||||
break;
|
||||
}
|
||||
|
||||
case "em":
|
||||
result += chalk.italic(this.renderInlineTokens(token.tokens || []));
|
||||
case "em": {
|
||||
// Apply italic, then reapply default style after
|
||||
const italicContent = this.renderInlineTokens(token.tokens || []);
|
||||
result += colorChalk.italic(italicContent) + this.applyDefaultStyle("");
|
||||
break;
|
||||
}
|
||||
|
||||
case "codespan":
|
||||
result += chalk.gray("`") + chalk.cyan(token.text) + chalk.gray("`");
|
||||
// Apply code styling without backticks
|
||||
result += colorChalk.cyan(token.text) + this.applyDefaultStyle("");
|
||||
break;
|
||||
|
||||
case "link": {
|
||||
const linkText = this.renderInlineTokens(token.tokens || []);
|
||||
// If link text matches href, only show the link once
|
||||
if (linkText === token.href) {
|
||||
result += chalk.underline.blue(linkText);
|
||||
result += colorChalk.underline.blue(linkText) + this.applyDefaultStyle("");
|
||||
} else {
|
||||
result += chalk.underline.blue(linkText) + chalk.gray(` (${token.href})`);
|
||||
result +=
|
||||
colorChalk.underline.blue(linkText) +
|
||||
colorChalk.gray(` (${token.href})`) +
|
||||
this.applyDefaultStyle("");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -320,14 +349,16 @@ export class Markdown implements Component {
|
|||
result += "\n";
|
||||
break;
|
||||
|
||||
case "del":
|
||||
result += chalk.strikethrough(this.renderInlineTokens(token.tokens || []));
|
||||
case "del": {
|
||||
const delContent = this.renderInlineTokens(token.tokens || []);
|
||||
result += colorChalk.strikethrough(delContent) + this.applyDefaultStyle("");
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
// Handle any other inline token types as plain text
|
||||
if ("text" in token && typeof token.text === "string") {
|
||||
result += token.text;
|
||||
result += this.applyDefaultStyle(token.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -335,115 +366,6 @@ export class Markdown implements Component {
|
|||
return result;
|
||||
}
|
||||
|
||||
private wrapLine(line: string, width: number): string[] {
|
||||
// Handle ANSI escape codes properly when wrapping
|
||||
const wrapped: string[] = [];
|
||||
|
||||
// Handle undefined or null lines
|
||||
if (!line) {
|
||||
return [""];
|
||||
}
|
||||
|
||||
// Split by newlines first - wrap each line individually
|
||||
const splitLines = line.split("\n");
|
||||
for (const splitLine of splitLines) {
|
||||
const visibleLength = visibleWidth(splitLine);
|
||||
|
||||
if (visibleLength <= width) {
|
||||
wrapped.push(splitLine);
|
||||
continue;
|
||||
}
|
||||
|
||||
// This line needs wrapping
|
||||
wrapped.push(...this.wrapSingleLine(splitLine, width));
|
||||
}
|
||||
|
||||
return wrapped.length > 0 ? wrapped : [""];
|
||||
}
|
||||
|
||||
private wrapSingleLine(line: string, width: number): string[] {
|
||||
const wrapped: string[] = [];
|
||||
|
||||
// Track active ANSI codes to preserve them across wrapped lines
|
||||
const activeAnsiCodes: string[] = [];
|
||||
let currentLine = "";
|
||||
let currentLength = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < line.length) {
|
||||
if (line[i] === "\x1b" && line[i + 1] === "[") {
|
||||
// ANSI escape sequence - parse and track it
|
||||
let j = i + 2;
|
||||
while (j < line.length && line[j] && !/[mGKHJ]/.test(line[j]!)) {
|
||||
j++;
|
||||
}
|
||||
if (j < line.length) {
|
||||
const ansiCode = line.substring(i, j + 1);
|
||||
currentLine += ansiCode;
|
||||
|
||||
// Track styling codes (ending with 'm')
|
||||
if (line[j] === "m") {
|
||||
// Reset code
|
||||
if (ansiCode === "\x1b[0m" || ansiCode === "\x1b[m") {
|
||||
activeAnsiCodes.length = 0;
|
||||
} else {
|
||||
// Add to active codes (replacing similar ones)
|
||||
activeAnsiCodes.push(ansiCode);
|
||||
}
|
||||
}
|
||||
|
||||
i = j + 1;
|
||||
} else {
|
||||
// Incomplete ANSI sequence at end - don't include it
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Regular character - extract full grapheme cluster
|
||||
// Handle multi-byte characters (emoji, surrogate pairs, etc.)
|
||||
let char: string;
|
||||
let charByteLength: number;
|
||||
|
||||
// Check for surrogate pair (emoji and other multi-byte chars)
|
||||
const codePoint = line.charCodeAt(i);
|
||||
if (codePoint >= 0xd800 && codePoint <= 0xdbff && i + 1 < line.length) {
|
||||
// High surrogate - get the pair
|
||||
char = line.substring(i, i + 2);
|
||||
charByteLength = 2;
|
||||
} else {
|
||||
// Regular character
|
||||
char = line[i];
|
||||
charByteLength = 1;
|
||||
}
|
||||
|
||||
const charWidth = visibleWidth(char);
|
||||
|
||||
// Check if adding this character would exceed width
|
||||
if (currentLength + charWidth > width) {
|
||||
// Need to wrap - close current line with reset if needed
|
||||
if (activeAnsiCodes.length > 0) {
|
||||
wrapped.push(currentLine + "\x1b[0m");
|
||||
// Start new line with active codes
|
||||
currentLine = activeAnsiCodes.join("");
|
||||
} else {
|
||||
wrapped.push(currentLine);
|
||||
currentLine = "";
|
||||
}
|
||||
currentLength = 0;
|
||||
}
|
||||
|
||||
currentLine += char;
|
||||
currentLength += charWidth;
|
||||
i += charByteLength;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
wrapped.push(currentLine);
|
||||
}
|
||||
|
||||
return wrapped.length > 0 ? wrapped : [""];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a list with proper nesting support
|
||||
*/
|
||||
|
|
@ -469,7 +391,7 @@ export class Markdown implements Component {
|
|||
lines.push(firstLine);
|
||||
} else {
|
||||
// Regular text content - add indent and bullet
|
||||
lines.push(indent + chalk.cyan(bullet) + firstLine);
|
||||
lines.push(indent + colorChalk.cyan(bullet) + firstLine);
|
||||
}
|
||||
|
||||
// Rest of the lines
|
||||
|
|
@ -486,7 +408,7 @@ export class Markdown implements Component {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
lines.push(indent + chalk.cyan(bullet));
|
||||
lines.push(indent + colorChalk.cyan(bullet));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -517,12 +439,12 @@ export class Markdown implements Component {
|
|||
lines.push(text);
|
||||
} else if (token.type === "code") {
|
||||
// Code block in list item
|
||||
lines.push(chalk.gray("```" + (token.lang || "")));
|
||||
lines.push(colorChalk.gray("```" + (token.lang || "")));
|
||||
const codeLines = token.text.split("\n");
|
||||
for (const codeLine of codeLines) {
|
||||
lines.push(chalk.dim(" ") + chalk.green(codeLine));
|
||||
lines.push(colorChalk.dim(" ") + colorChalk.green(codeLine));
|
||||
}
|
||||
lines.push(chalk.gray("```"));
|
||||
lines.push(colorChalk.gray("```"));
|
||||
} else {
|
||||
// Other token types - try to render as inline
|
||||
const text = this.renderInlineTokens([token]);
|
||||
|
|
@ -569,7 +491,7 @@ export class Markdown implements Component {
|
|||
// Render header
|
||||
const headerCells = token.header.map((cell, i) => {
|
||||
const text = this.renderInlineTokens(cell.tokens || []);
|
||||
return chalk.bold(text.padEnd(columnWidths[i]));
|
||||
return colorChalk.bold(text.padEnd(columnWidths[i]));
|
||||
});
|
||||
lines.push("│ " + headerCells.join(" │ ") + " │");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import chalk from "chalk";
|
||||
import { Chalk } from "chalk";
|
||||
import type { Component } from "../tui.js";
|
||||
import { visibleWidth } 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
|
||||
|
|
@ -30,7 +32,6 @@ export class Text implements Component {
|
|||
|
||||
setText(text: string): void {
|
||||
this.text = text;
|
||||
// Invalidate cache when text changes
|
||||
this.cachedText = undefined;
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
|
|
@ -38,7 +39,6 @@ export class Text implements Component {
|
|||
|
||||
setCustomBgRgb(customBgRgb?: { r: number; g: number; b: number }): void {
|
||||
this.customBgRgb = customBgRgb;
|
||||
// Invalidate cache when color changes
|
||||
this.cachedText = undefined;
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
|
|
@ -50,113 +50,53 @@ export class Text implements Component {
|
|||
return this.cachedLines;
|
||||
}
|
||||
|
||||
// Calculate available width for content (subtract horizontal padding)
|
||||
const contentWidth = Math.max(1, width - this.paddingX * 2);
|
||||
|
||||
// Don't render anything if there's no actual text
|
||||
if (!this.text || this.text.trim() === "") {
|
||||
const result: string[] = [];
|
||||
// Update cache
|
||||
this.cachedText = this.text;
|
||||
this.cachedWidth = width;
|
||||
this.cachedLines = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Replace tabs with 3 spaces for consistent rendering
|
||||
// Replace tabs with 3 spaces
|
||||
const normalizedText = this.text.replace(/\t/g, " ");
|
||||
|
||||
const lines: string[] = [];
|
||||
const textLines = normalizedText.split("\n");
|
||||
// Calculate content width (subtract left/right margins)
|
||||
const contentWidth = Math.max(1, width - this.paddingX * 2);
|
||||
|
||||
for (const line of textLines) {
|
||||
// Measure visible length (strip ANSI codes)
|
||||
const visibleLineLength = visibleWidth(line);
|
||||
// Wrap text (this preserves ANSI codes but does NOT pad)
|
||||
const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
|
||||
|
||||
if (visibleLineLength <= contentWidth) {
|
||||
lines.push(line);
|
||||
// Add margins and background to each line
|
||||
const leftMargin = " ".repeat(this.paddingX);
|
||||
const rightMargin = " ".repeat(this.paddingX);
|
||||
const contentLines: string[] = [];
|
||||
|
||||
for (const line of wrappedLines) {
|
||||
// Add margins
|
||||
const lineWithMargins = leftMargin + line + rightMargin;
|
||||
|
||||
// Apply background if specified (this also pads to full width)
|
||||
if (this.customBgRgb) {
|
||||
contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.customBgRgb));
|
||||
} else {
|
||||
// Word wrap
|
||||
const words = line.split(" ");
|
||||
let currentLine = "";
|
||||
|
||||
for (const word of words) {
|
||||
const currentVisible = visibleWidth(currentLine);
|
||||
const wordVisible = visibleWidth(word);
|
||||
|
||||
// If word is too long, truncate it
|
||||
let finalWord = word;
|
||||
if (wordVisible > contentWidth) {
|
||||
// Truncate word to fit
|
||||
let truncated = "";
|
||||
for (const char of word) {
|
||||
if (visibleWidth(truncated + char) > contentWidth) {
|
||||
break;
|
||||
}
|
||||
truncated += char;
|
||||
}
|
||||
finalWord = truncated;
|
||||
}
|
||||
|
||||
if (currentVisible === 0) {
|
||||
currentLine = finalWord;
|
||||
} else if (currentVisible + 1 + visibleWidth(finalWord) <= contentWidth) {
|
||||
currentLine += " " + finalWord;
|
||||
} else {
|
||||
lines.push(currentLine);
|
||||
currentLine = finalWord;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine.length > 0) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
// No background - just pad to width with spaces
|
||||
const visibleLen = visibleWidth(lineWithMargins);
|
||||
const paddingNeeded = Math.max(0, width - visibleLen);
|
||||
contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
|
||||
}
|
||||
}
|
||||
|
||||
// Add padding to each line
|
||||
const leftPad = " ".repeat(this.paddingX);
|
||||
const paddedLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// Calculate visible length (strip ANSI codes)
|
||||
const visibleLength = visibleWidth(line);
|
||||
// Right padding to fill to width (accounting for left padding and content)
|
||||
const rightPadLength = Math.max(0, width - this.paddingX - visibleLength);
|
||||
const rightPad = " ".repeat(rightPadLength);
|
||||
let paddedLine = leftPad + line + rightPad;
|
||||
|
||||
// Apply background color if specified
|
||||
if (this.customBgRgb) {
|
||||
paddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(paddedLine);
|
||||
}
|
||||
|
||||
paddedLines.push(paddedLine);
|
||||
}
|
||||
|
||||
// Add top padding (empty lines)
|
||||
// Add top/bottom padding (empty lines)
|
||||
const emptyLine = " ".repeat(width);
|
||||
const topPadding: string[] = [];
|
||||
const emptyLines: string[] = [];
|
||||
for (let i = 0; i < this.paddingY; i++) {
|
||||
let emptyPaddedLine = emptyLine;
|
||||
if (this.customBgRgb) {
|
||||
emptyPaddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(emptyPaddedLine);
|
||||
}
|
||||
topPadding.push(emptyPaddedLine);
|
||||
const line = this.customBgRgb ? applyBackgroundToLine(emptyLine, width, this.customBgRgb) : emptyLine;
|
||||
emptyLines.push(line);
|
||||
}
|
||||
|
||||
// Add bottom padding (empty lines)
|
||||
const bottomPadding: string[] = [];
|
||||
for (let i = 0; i < this.paddingY; i++) {
|
||||
let emptyPaddedLine = emptyLine;
|
||||
if (this.customBgRgb) {
|
||||
emptyPaddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(emptyPaddedLine);
|
||||
}
|
||||
bottomPadding.push(emptyPaddedLine);
|
||||
}
|
||||
|
||||
// Combine top padding, content, and bottom padding
|
||||
const result = [...topPadding, ...paddedLines, ...bottomPadding];
|
||||
const result = [...emptyLines, ...contentLines, ...emptyLines];
|
||||
|
||||
// Update cache
|
||||
this.cachedText = this.text;
|
||||
|
|
|
|||
|
|
@ -204,17 +204,28 @@ export class TUI extends Container {
|
|||
}
|
||||
|
||||
buffer += "\r"; // Move to column 0
|
||||
buffer += "\x1b[J"; // Clear from cursor to end of screen
|
||||
|
||||
// Render from first changed line to end
|
||||
// Render from first changed line to end, clearing each line before writing
|
||||
// This avoids the \x1b[J clear-to-end which can cause flicker in xterm.js
|
||||
for (let i = firstChanged; i < newLines.length; i++) {
|
||||
if (i > firstChanged) buffer += "\r\n";
|
||||
buffer += "\x1b[2K"; // Clear current line
|
||||
if (visibleWidth(newLines[i]) > width) {
|
||||
throw new Error(`Rendered line ${i} exceeds terminal width\n\n${newLines[i]}`);
|
||||
}
|
||||
buffer += newLines[i];
|
||||
}
|
||||
|
||||
// If we had more lines before, clear them and move cursor back
|
||||
if (this.previousLines.length > newLines.length) {
|
||||
const extraLines = this.previousLines.length - newLines.length;
|
||||
for (let i = newLines.length; i < this.previousLines.length; i++) {
|
||||
buffer += "\r\n\x1b[2K";
|
||||
}
|
||||
// Move cursor back to end of new content
|
||||
buffer += `\x1b[${extraLines}A`;
|
||||
}
|
||||
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
|
||||
// Write entire buffer at once
|
||||
|
|
|
|||
|
|
@ -1,15 +1,274 @@
|
|||
import { Chalk } from "chalk";
|
||||
import stringWidth from "string-width";
|
||||
|
||||
const colorChalk = new Chalk({ level: 3 });
|
||||
|
||||
/**
|
||||
* Calculate the visible width of a string in terminal columns.
|
||||
* This correctly handles:
|
||||
* - ANSI escape codes (ignored)
|
||||
* - Emojis and wide characters (counted as 2 columns)
|
||||
* - Combining characters (counted correctly)
|
||||
* - Tabs (replaced with 3 spaces for consistent width)
|
||||
*/
|
||||
export function visibleWidth(str: string): number {
|
||||
// Replace tabs with 3 spaces before measuring
|
||||
const normalized = str.replace(/\t/g, " ");
|
||||
return stringWidth(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ANSI escape sequences from a string at the given position.
|
||||
*/
|
||||
function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {
|
||||
if (pos >= str.length || str[pos] !== "\x1b" || str[pos + 1] !== "[") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let j = pos + 2;
|
||||
while (j < str.length && str[j] && !/[mGKHJ]/.test(str[j]!)) {
|
||||
j++;
|
||||
}
|
||||
|
||||
if (j < str.length) {
|
||||
return {
|
||||
code: str.substring(pos, j + 1),
|
||||
length: j + 1 - pos,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track active ANSI SGR codes to preserve styling across line breaks.
|
||||
*/
|
||||
class AnsiCodeTracker {
|
||||
private activeAnsiCodes: string[] = [];
|
||||
|
||||
process(ansiCode: string): void {
|
||||
if (!ansiCode.endsWith("m")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Full reset clears everything
|
||||
if (ansiCode === "\x1b[0m" || ansiCode === "\x1b[m") {
|
||||
this.activeAnsiCodes.length = 0;
|
||||
} else {
|
||||
this.activeAnsiCodes.push(ansiCode);
|
||||
}
|
||||
}
|
||||
|
||||
getActiveCodes(): string {
|
||||
return this.activeAnsiCodes.join("");
|
||||
}
|
||||
|
||||
hasActiveCodes(): boolean {
|
||||
return this.activeAnsiCodes.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void {
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const ansiResult = extractAnsiCode(text, i);
|
||||
if (ansiResult) {
|
||||
tracker.process(ansiResult.code);
|
||||
i += ansiResult.length;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into words while keeping ANSI codes attached.
|
||||
*/
|
||||
function splitIntoWordsWithAnsi(text: string): string[] {
|
||||
const words: string[] = [];
|
||||
let currentWord = "";
|
||||
let i = 0;
|
||||
|
||||
while (i < text.length) {
|
||||
const char = text[i];
|
||||
|
||||
const ansiResult = extractAnsiCode(text, i);
|
||||
if (ansiResult) {
|
||||
currentWord += ansiResult.code;
|
||||
i += ansiResult.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === " ") {
|
||||
if (currentWord) {
|
||||
words.push(currentWord);
|
||||
currentWord = "";
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
currentWord += char;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (currentWord) {
|
||||
words.push(currentWord);
|
||||
}
|
||||
|
||||
return words;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text with ANSI codes preserved.
|
||||
*
|
||||
* ONLY does word wrapping - NO padding, NO background colors.
|
||||
* Returns lines where each line is <= width visible chars.
|
||||
* Active ANSI codes are preserved across line breaks.
|
||||
*
|
||||
* @param text - Text to wrap (may contain ANSI codes and newlines)
|
||||
* @param width - Maximum visible width per line
|
||||
* @returns Array of wrapped lines (NOT padded to width)
|
||||
*/
|
||||
export function wrapTextWithAnsi(text: string, width: number): string[] {
|
||||
if (!text) {
|
||||
return [""];
|
||||
}
|
||||
|
||||
// Handle newlines by processing each line separately
|
||||
const inputLines = text.split("\n");
|
||||
const result: string[] = [];
|
||||
|
||||
for (const inputLine of inputLines) {
|
||||
result.push(...wrapSingleLine(inputLine, width));
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : [""];
|
||||
}
|
||||
|
||||
function wrapSingleLine(line: string, width: number): string[] {
|
||||
if (!line) {
|
||||
return [""];
|
||||
}
|
||||
|
||||
const visibleLength = visibleWidth(line);
|
||||
if (visibleLength <= width) {
|
||||
return [line];
|
||||
}
|
||||
|
||||
const wrapped: string[] = [];
|
||||
const tracker = new AnsiCodeTracker();
|
||||
const words = splitIntoWordsWithAnsi(line);
|
||||
|
||||
let currentLine = "";
|
||||
let currentVisibleLength = 0;
|
||||
|
||||
for (const word of words) {
|
||||
const wordVisibleLength = visibleWidth(word);
|
||||
|
||||
// Word itself is too long - break it character by character
|
||||
if (wordVisibleLength > width) {
|
||||
if (currentLine) {
|
||||
wrapped.push(currentLine);
|
||||
currentLine = "";
|
||||
currentVisibleLength = 0;
|
||||
}
|
||||
|
||||
// Break long word
|
||||
const broken = breakLongWord(word, width, tracker);
|
||||
wrapped.push(...broken.slice(0, -1));
|
||||
currentLine = broken[broken.length - 1];
|
||||
currentVisibleLength = visibleWidth(currentLine);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if adding this word would exceed width
|
||||
const spaceNeeded = currentVisibleLength > 0 ? 1 : 0;
|
||||
const totalNeeded = currentVisibleLength + spaceNeeded + wordVisibleLength;
|
||||
|
||||
if (totalNeeded > width && currentVisibleLength > 0) {
|
||||
// Wrap to next line
|
||||
wrapped.push(currentLine);
|
||||
currentLine = tracker.getActiveCodes() + word;
|
||||
currentVisibleLength = wordVisibleLength;
|
||||
} else {
|
||||
// Add to current line
|
||||
if (currentVisibleLength > 0) {
|
||||
currentLine += " " + word;
|
||||
currentVisibleLength += 1 + wordVisibleLength;
|
||||
} else {
|
||||
currentLine += word;
|
||||
currentVisibleLength = wordVisibleLength;
|
||||
}
|
||||
}
|
||||
|
||||
updateTrackerFromText(word, tracker);
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
wrapped.push(currentLine);
|
||||
}
|
||||
|
||||
return wrapped.length > 0 ? wrapped : [""];
|
||||
}
|
||||
|
||||
function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): string[] {
|
||||
const lines: string[] = [];
|
||||
let currentLine = tracker.getActiveCodes();
|
||||
let currentWidth = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < word.length) {
|
||||
const ansiResult = extractAnsiCode(word, i);
|
||||
if (ansiResult) {
|
||||
currentLine += ansiResult.code;
|
||||
tracker.process(ansiResult.code);
|
||||
i += ansiResult.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const char = word[i];
|
||||
const charWidth = visibleWidth(char);
|
||||
|
||||
if (currentWidth + charWidth > width) {
|
||||
lines.push(currentLine);
|
||||
currentLine = tracker.getActiveCodes();
|
||||
currentWidth = 0;
|
||||
}
|
||||
|
||||
currentLine += char;
|
||||
currentWidth += charWidth;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
return lines.length > 0 ? lines : [""];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply background color to a line, padding to full width.
|
||||
*
|
||||
* Handles the tricky case where content contains \x1b[0m resets that would
|
||||
* kill the background color. We reapply the background after any reset.
|
||||
*
|
||||
* @param line - Line of text (may contain ANSI codes)
|
||||
* @param width - Total width to pad to
|
||||
* @param bgRgb - Background RGB color
|
||||
* @returns Line with background applied and padded to width
|
||||
*/
|
||||
export function applyBackgroundToLine(line: string, width: number, bgRgb: { r: number; g: number; b: number }): string {
|
||||
const bgStart = `\x1b[48;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
|
||||
const bgEnd = "\x1b[49m";
|
||||
|
||||
// Calculate padding needed
|
||||
const visibleLen = visibleWidth(line);
|
||||
const paddingNeeded = Math.max(0, width - visibleLen);
|
||||
const padding = " ".repeat(paddingNeeded);
|
||||
|
||||
// Strategy: wrap content + padding in background, then fix any 0m resets
|
||||
const withPadding = line + padding;
|
||||
const withBg = bgStart + withPadding + bgEnd;
|
||||
|
||||
// Find all \x1b[0m or \x1b[49m that would kill background
|
||||
// Replace with reset + background reapplication
|
||||
const fixedBg = withBg.replace(/\x1b\[0m/g, `\x1b[0m${bgStart}`);
|
||||
|
||||
return fixedBg;
|
||||
}
|
||||
|
|
|
|||
64
packages/tui/test/autocomplete.test.ts
Normal file
64
packages/tui/test/autocomplete.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { CombinedAutocompleteProvider } from "../src/autocomplete.js";
|
||||
|
||||
describe("CombinedAutocompleteProvider", () => {
|
||||
describe("extractPathPrefix", () => {
|
||||
it("extracts / from 'hey /' when forced", () => {
|
||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||
const lines = ["hey /"];
|
||||
const cursorLine = 0;
|
||||
const cursorCol = 5; // After the "/"
|
||||
|
||||
const result = provider.getForceFileSuggestions(lines, cursorLine, cursorCol);
|
||||
|
||||
assert.notEqual(result, null, "Should return suggestions for root directory");
|
||||
if (result) {
|
||||
assert.strictEqual(result.prefix, "/", "Prefix should be '/'");
|
||||
}
|
||||
});
|
||||
|
||||
it("extracts /A from '/A' when forced", () => {
|
||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||
const lines = ["/A"];
|
||||
const cursorLine = 0;
|
||||
const cursorCol = 2; // After the "A"
|
||||
|
||||
const result = provider.getForceFileSuggestions(lines, cursorLine, cursorCol);
|
||||
|
||||
console.log("Result:", result);
|
||||
// This might return null if /A doesn't match anything, which is fine
|
||||
// We're mainly testing that the prefix extraction works
|
||||
if (result) {
|
||||
assert.strictEqual(result.prefix, "/A", "Prefix should be '/A'");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not trigger for slash commands", () => {
|
||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||
const lines = ["/model"];
|
||||
const cursorLine = 0;
|
||||
const cursorCol = 6; // After "model"
|
||||
|
||||
const result = provider.getForceFileSuggestions(lines, cursorLine, cursorCol);
|
||||
|
||||
console.log("Result:", result);
|
||||
assert.strictEqual(result, null, "Should not trigger for slash commands");
|
||||
});
|
||||
|
||||
it("triggers for absolute paths after slash command argument", () => {
|
||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||
const lines = ["/command /"];
|
||||
const cursorLine = 0;
|
||||
const cursorCol = 10; // After the second "/"
|
||||
|
||||
const result = provider.getForceFileSuggestions(lines, cursorLine, cursorCol);
|
||||
|
||||
console.log("Result:", result);
|
||||
assert.notEqual(result, null, "Should trigger for absolute paths in command arguments");
|
||||
if (result) {
|
||||
assert.strictEqual(result.prefix, "/", "Prefix should be '/'");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -78,7 +78,7 @@ editor.onSubmit = (value: string) => {
|
|||
isResponding = true;
|
||||
editor.disableSubmit = true;
|
||||
|
||||
const userMessage = new Markdown(value, undefined, undefined, { r: 52, g: 53, b: 65 });
|
||||
const userMessage = new Markdown(value, 1, 1, { bgColor: "#343541" });
|
||||
|
||||
const children = tui.children;
|
||||
children.splice(children.length - 1, 0, userMessage);
|
||||
|
|
|
|||
131
packages/tui/test/editor.test.ts
Normal file
131
packages/tui/test/editor.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { Editor } from "../src/components/editor.js";
|
||||
|
||||
describe("Editor component", () => {
|
||||
describe("Unicode text editing behavior", () => {
|
||||
it("inserts mixed ASCII, umlauts, and emojis as literal text", () => {
|
||||
const editor = new Editor();
|
||||
|
||||
editor.handleInput("H");
|
||||
editor.handleInput("e");
|
||||
editor.handleInput("l");
|
||||
editor.handleInput("l");
|
||||
editor.handleInput("o");
|
||||
editor.handleInput(" ");
|
||||
editor.handleInput("ä");
|
||||
editor.handleInput("ö");
|
||||
editor.handleInput("ü");
|
||||
editor.handleInput(" ");
|
||||
editor.handleInput("😀");
|
||||
|
||||
const text = editor.getText();
|
||||
assert.strictEqual(text, "Hello äöü 😀");
|
||||
});
|
||||
|
||||
it("deletes single-code-unit unicode characters (umlauts) with Backspace", () => {
|
||||
const editor = new Editor();
|
||||
|
||||
editor.handleInput("ä");
|
||||
editor.handleInput("ö");
|
||||
editor.handleInput("ü");
|
||||
|
||||
// Delete the last character (ü)
|
||||
editor.handleInput("\x7f"); // Backspace
|
||||
|
||||
const text = editor.getText();
|
||||
assert.strictEqual(text, "äö");
|
||||
});
|
||||
|
||||
it("deletes multi-code-unit emojis with repeated Backspace", () => {
|
||||
const editor = new Editor();
|
||||
|
||||
editor.handleInput("😀");
|
||||
editor.handleInput("👍");
|
||||
|
||||
// Delete the last emoji (👍) - requires 2 backspaces since emojis are 2 code units
|
||||
editor.handleInput("\x7f"); // Backspace
|
||||
editor.handleInput("\x7f"); // Backspace
|
||||
|
||||
const text = editor.getText();
|
||||
assert.strictEqual(text, "😀");
|
||||
});
|
||||
|
||||
it("inserts characters at the correct position after cursor movement over umlauts", () => {
|
||||
const editor = new Editor();
|
||||
|
||||
editor.handleInput("ä");
|
||||
editor.handleInput("ö");
|
||||
editor.handleInput("ü");
|
||||
|
||||
// Move cursor left twice
|
||||
editor.handleInput("\x1b[D"); // Left arrow
|
||||
editor.handleInput("\x1b[D"); // Left arrow
|
||||
|
||||
// Insert 'x' in the middle
|
||||
editor.handleInput("x");
|
||||
|
||||
const text = editor.getText();
|
||||
assert.strictEqual(text, "äxöü");
|
||||
});
|
||||
|
||||
it("moves cursor in code units across multi-code-unit emojis before insertion", () => {
|
||||
const editor = new Editor();
|
||||
|
||||
editor.handleInput("😀");
|
||||
editor.handleInput("👍");
|
||||
editor.handleInput("🎉");
|
||||
|
||||
// Move cursor left over last emoji (🎉)
|
||||
editor.handleInput("\x1b[D"); // Left arrow
|
||||
editor.handleInput("\x1b[D"); // Left arrow
|
||||
|
||||
// Move cursor left over second emoji (👍)
|
||||
editor.handleInput("\x1b[D");
|
||||
editor.handleInput("\x1b[D");
|
||||
|
||||
// Insert 'x' between first and second emoji
|
||||
editor.handleInput("x");
|
||||
|
||||
const text = editor.getText();
|
||||
assert.strictEqual(text, "😀x👍🎉");
|
||||
});
|
||||
|
||||
it("preserves umlauts across line breaks", () => {
|
||||
const editor = new Editor();
|
||||
|
||||
editor.handleInput("ä");
|
||||
editor.handleInput("ö");
|
||||
editor.handleInput("ü");
|
||||
editor.handleInput("\n"); // new line
|
||||
editor.handleInput("Ä");
|
||||
editor.handleInput("Ö");
|
||||
editor.handleInput("Ü");
|
||||
|
||||
const text = editor.getText();
|
||||
assert.strictEqual(text, "äöü\nÄÖÜ");
|
||||
});
|
||||
|
||||
it("replaces the entire document with unicode text via setText (paste simulation)", () => {
|
||||
const editor = new Editor();
|
||||
|
||||
// Simulate bracketed paste / programmatic replacement
|
||||
editor.setText("Hällö Wörld! 😀 äöüÄÖÜß");
|
||||
|
||||
const text = editor.getText();
|
||||
assert.strictEqual(text, "Hällö Wörld! 😀 äöüÄÖÜß");
|
||||
});
|
||||
|
||||
it("moves cursor to document start on Ctrl+A and inserts at the beginning", () => {
|
||||
const editor = new Editor();
|
||||
|
||||
editor.handleInput("a");
|
||||
editor.handleInput("b");
|
||||
editor.handleInput("\x01"); // Ctrl+A (move to start)
|
||||
editor.handleInput("x"); // Insert at start
|
||||
|
||||
const text = editor.getText();
|
||||
assert.strictEqual(text, "xab");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -10,9 +10,6 @@ describe("Markdown component", () => {
|
|||
- Nested 1.1
|
||||
- Nested 1.2
|
||||
- Item 2`,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
|
@ -38,9 +35,6 @@ describe("Markdown component", () => {
|
|||
- Level 2
|
||||
- Level 3
|
||||
- Level 4`,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
|
@ -61,9 +55,6 @@ describe("Markdown component", () => {
|
|||
1. Nested first
|
||||
2. Nested second
|
||||
2. Second`,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
|
@ -84,9 +75,6 @@ describe("Markdown component", () => {
|
|||
- Another nested
|
||||
2. Second ordered
|
||||
- More nested`,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
|
@ -107,9 +95,6 @@ describe("Markdown component", () => {
|
|||
| --- | --- |
|
||||
| Alice | 30 |
|
||||
| Bob | 25 |`,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
|
@ -133,9 +118,6 @@ describe("Markdown component", () => {
|
|||
| :--- | :---: | ---: |
|
||||
| A | B | C |
|
||||
| Long text | Middle | End |`,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
|
@ -157,9 +139,6 @@ describe("Markdown component", () => {
|
|||
| --- | --- |
|
||||
| A | This is a much longer cell content |
|
||||
| B | Short |`,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
|
@ -187,9 +166,6 @@ describe("Markdown component", () => {
|
|||
| Col1 | Col2 |
|
||||
| --- | --- |
|
||||
| A | B |`,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
|
@ -207,4 +183,84 @@ describe("Markdown component", () => {
|
|||
assert.ok(plainLines.some((line) => line.includes("│")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pre-styled text (thinking traces)", () => {
|
||||
it("should preserve gray italic styling after inline code", () => {
|
||||
// This replicates how thinking content is rendered in assistant-message.ts
|
||||
const markdown = new Markdown("This is thinking with `inline code` and more text after", 1, 0, {
|
||||
color: "gray",
|
||||
italic: true,
|
||||
});
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joinedOutput = lines.join("\n");
|
||||
|
||||
// Should contain the inline code block
|
||||
assert.ok(joinedOutput.includes("inline code"));
|
||||
|
||||
// The output should have ANSI codes for gray (90) and italic (3)
|
||||
assert.ok(joinedOutput.includes("\x1b[90m"), "Should have gray color code");
|
||||
assert.ok(joinedOutput.includes("\x1b[3m"), "Should have italic code");
|
||||
|
||||
// Verify that after the inline code (cyan text), we reapply gray italic
|
||||
const hasCyan = joinedOutput.includes("\x1b[36m"); // cyan
|
||||
assert.ok(hasCyan, "Should have cyan for inline code");
|
||||
});
|
||||
|
||||
it("should preserve gray italic styling after bold text", () => {
|
||||
const markdown = new Markdown("This is thinking with **bold text** and more after", 1, 0, {
|
||||
color: "gray",
|
||||
italic: true,
|
||||
});
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joinedOutput = lines.join("\n");
|
||||
|
||||
// Should contain bold text
|
||||
assert.ok(joinedOutput.includes("bold text"));
|
||||
|
||||
// The output should have ANSI codes for gray (90) and italic (3)
|
||||
assert.ok(joinedOutput.includes("\x1b[90m"), "Should have gray color code");
|
||||
assert.ok(joinedOutput.includes("\x1b[3m"), "Should have italic code");
|
||||
|
||||
// Should have bold codes (1 or 22 for bold on/off)
|
||||
assert.ok(joinedOutput.includes("\x1b[1m"), "Should have bold code");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTML-like tags in text", () => {
|
||||
it("should render content with HTML-like tags as text", () => {
|
||||
// When the model emits something like <thinking>content</thinking> in regular text,
|
||||
// marked might treat it as HTML and hide the content
|
||||
const markdown = new Markdown(
|
||||
"This is text with <thinking>hidden content</thinking> that should be visible",
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
||||
const joinedPlain = plainLines.join(" ");
|
||||
|
||||
// The content inside the tags should be visible
|
||||
assert.ok(
|
||||
joinedPlain.includes("hidden content") || joinedPlain.includes("<thinking>"),
|
||||
"Should render HTML-like tags or their content as text, not hide them",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render HTML tags in code blocks correctly", () => {
|
||||
const markdown = new Markdown("```html\n<div>Some HTML</div>\n```", 0, 0);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
||||
const joinedPlain = plainLines.join("\n");
|
||||
|
||||
// HTML in code blocks should be visible
|
||||
assert.ok(
|
||||
joinedPlain.includes("<div>") && joinedPlain.includes("</div>"),
|
||||
"Should render HTML in code blocks",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
110
packages/tui/test/wrap-ansi.test.ts
Normal file
110
packages/tui/test/wrap-ansi.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { Chalk } from "chalk";
|
||||
|
||||
// We'll implement these
|
||||
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../src/utils.js";
|
||||
|
||||
const chalk = new Chalk({ level: 3 });
|
||||
|
||||
describe("wrapTextWithAnsi", () => {
|
||||
it("wraps plain text at word boundaries", () => {
|
||||
const text = "hello world this is a test";
|
||||
const lines = wrapTextWithAnsi(text, 15);
|
||||
|
||||
assert.strictEqual(lines.length, 2);
|
||||
assert.strictEqual(lines[0], "hello world");
|
||||
assert.strictEqual(lines[1], "this is a test");
|
||||
});
|
||||
|
||||
it("preserves ANSI codes across wrapped lines", () => {
|
||||
const text = chalk.bold("hello world this is bold text");
|
||||
const lines = wrapTextWithAnsi(text, 20);
|
||||
|
||||
// Should have bold code at start of each line
|
||||
assert.ok(lines[0].includes("\x1b[1m"));
|
||||
assert.ok(lines[1].includes("\x1b[1m"));
|
||||
|
||||
// Each line should be <= 20 visible chars
|
||||
assert.ok(visibleWidth(lines[0]) <= 20);
|
||||
assert.ok(visibleWidth(lines[1]) <= 20);
|
||||
});
|
||||
|
||||
it("handles text with resets", () => {
|
||||
const text = chalk.bold("bold ") + "normal " + chalk.cyan("cyan");
|
||||
const lines = wrapTextWithAnsi(text, 30);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
// Should contain the reset code from chalk
|
||||
assert.ok(lines[0].includes("\x1b["));
|
||||
});
|
||||
|
||||
it("does NOT pad lines", () => {
|
||||
const text = "hello";
|
||||
const lines = wrapTextWithAnsi(text, 20);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(visibleWidth(lines[0]), 5); // NOT 20
|
||||
});
|
||||
|
||||
it("handles empty text", () => {
|
||||
const lines = wrapTextWithAnsi("", 20);
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(lines[0], "");
|
||||
});
|
||||
|
||||
it("handles newlines", () => {
|
||||
const text = "line1\nline2\nline3";
|
||||
const lines = wrapTextWithAnsi(text, 20);
|
||||
|
||||
assert.strictEqual(lines.length, 3);
|
||||
assert.strictEqual(lines[0], "line1");
|
||||
assert.strictEqual(lines[1], "line2");
|
||||
assert.strictEqual(lines[2], "line3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyBackgroundToLine", () => {
|
||||
it("applies background to plain text and pads to width", () => {
|
||||
const line = "hello";
|
||||
const result = applyBackgroundToLine(line, 20, { r: 0, g: 255, b: 0 });
|
||||
|
||||
// Should be exactly 20 visible chars
|
||||
const stripped = result.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
assert.strictEqual(stripped.length, 20);
|
||||
|
||||
// Should have background codes
|
||||
assert.ok(result.includes("\x1b[48;2;0;255;0m"));
|
||||
assert.ok(result.includes("\x1b[49m"));
|
||||
});
|
||||
|
||||
it("handles text with ANSI codes and resets", () => {
|
||||
const line = chalk.bold("hello") + " world";
|
||||
const result = applyBackgroundToLine(line, 20, { r: 0, g: 255, b: 0 });
|
||||
|
||||
// Should be exactly 20 visible chars
|
||||
const stripped = result.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
assert.strictEqual(stripped.length, 20);
|
||||
|
||||
// Should still have bold
|
||||
assert.ok(result.includes("\x1b[1m"));
|
||||
|
||||
// Should have background throughout (even after resets)
|
||||
assert.ok(result.includes("\x1b[48;2;0;255;0m"));
|
||||
});
|
||||
|
||||
it("handles text with 0m resets by reapplying background", () => {
|
||||
// Simulate: bold text + reset + normal text
|
||||
const line = "\x1b[1mhello\x1b[0m world";
|
||||
const result = applyBackgroundToLine(line, 20, { r: 0, g: 255, b: 0 });
|
||||
|
||||
// Should NOT have black cells (spaces without background)
|
||||
// Pattern we DON'T want: 49m or 0m followed by spaces before bg reapplied
|
||||
const blackCellPattern = /(\x1b\[49m|\x1b\[0m)\s+\x1b\[48;2/;
|
||||
assert.ok(!blackCellPattern.test(result), `Found black cells in: ${JSON.stringify(result)}`);
|
||||
|
||||
// Should be exactly 20 chars
|
||||
const stripped = result.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
assert.strictEqual(stripped.length, 20);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@mariozechner/pi-web-ui",
|
||||
"version": "0.7.10",
|
||||
"version": "0.7.25",
|
||||
"description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
|
@ -18,13 +18,13 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@lmstudio/sdk": "^1.5.0",
|
||||
"@mariozechner/pi-ai": "^0.7.10",
|
||||
"@mariozechner/pi-tui": "^0.7.10",
|
||||
"@mariozechner/pi-ai": "^0.7.25",
|
||||
"@mariozechner/pi-tui": "^0.7.25",
|
||||
"docx-preview": "^0.3.7",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide": "^0.544.0",
|
||||
"ollama": "^0.6.0",
|
||||
"pdfjs-dist": "^5.4.296",
|
||||
"pdfjs-dist": "5.4.394",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
|
|||
|
|
@ -254,6 +254,11 @@ export class SandboxIframe extends LitElement {
|
|||
providers = [consoleProvider, ...providers];
|
||||
RUNTIME_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers);
|
||||
|
||||
// Notify providers that execution is starting
|
||||
for (const provider of providers) {
|
||||
provider.onExecutionStart?.(sandboxId, signal);
|
||||
}
|
||||
|
||||
const files: SandboxFile[] = [];
|
||||
let completed = false;
|
||||
|
||||
|
|
@ -287,6 +292,11 @@ export class SandboxIframe extends LitElement {
|
|||
RUNTIME_MESSAGE_ROUTER.addConsumer(sandboxId, executionConsumer);
|
||||
|
||||
const cleanup = () => {
|
||||
// Notify providers that execution has ended
|
||||
for (const provider of providers) {
|
||||
provider.onExecutionEnd?.(sandboxId);
|
||||
}
|
||||
|
||||
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
clearTimeout(timeoutId);
|
||||
|
|
|
|||
|
|
@ -32,4 +32,21 @@ export interface SandboxRuntimeProvider {
|
|||
* This will be appended to tool descriptions dynamically so the LLM knows what's available.
|
||||
*/
|
||||
getDescription(): string;
|
||||
|
||||
/**
|
||||
* Optional lifecycle callback invoked when sandbox execution starts.
|
||||
* Providers can use this to track abort signals for cancellation of async operations.
|
||||
*
|
||||
* @param sandboxId - The unique identifier for this sandbox execution
|
||||
* @param signal - Optional AbortSignal that will be triggered if execution is cancelled
|
||||
*/
|
||||
onExecutionStart?(sandboxId: string, signal?: AbortSignal): void;
|
||||
|
||||
/**
|
||||
* Optional lifecycle callback invoked when sandbox execution ends (success, error, or abort).
|
||||
* Providers can use this to clean up any resources associated with the sandbox.
|
||||
*
|
||||
* @param sandboxId - The unique identifier for this sandbox execution
|
||||
*/
|
||||
onExecutionEnd?(sandboxId: string): void;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue