diff --git a/package-lock.json b/package-lock.json index e7b52e40..402cae53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "devDependencies": { "@biomejs/biome": "^2.1.4", "@types/node": "^22.10.5", + "@typescript/native-preview": "^7.0.0-dev.20251111.1", "concurrently": "^9.2.1", "husky": "^9.1.7", "tsx": "^4.20.3", @@ -988,10 +989,6 @@ "resolved": "packages/agent", "link": true }, - "node_modules/@mariozechner/pi-agent-old": { - "resolved": "packages/agent-old", - "link": true - }, "node_modules/@mariozechner/pi-ai": { "resolved": "packages/ai", "link": true @@ -2144,32 +2141,17 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==", - "license": "MIT", - "dependencies": { - "@types/minimatch": "^5.1.2", - "@types/node": "*" - } - }, "node_modules/@types/mime-types": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==", "license": "MIT" }, - "node_modules/@types/minimatch": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "22.18.8", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.8.tgz", "integrity": "sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2181,6 +2163,123 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@typescript/native-preview": { + "version": "7.0.0-dev.20251111.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20251111.1.tgz", + "integrity": "sha512-IWafo9qVR7sEl/59AYoy7XTkC+rH62QaIKp7PuMYYcNwqjNlFKszGeJtXadqBpyY61lVhtb3xoZ6otsNotAgMg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsgo": "bin/tsgo.js" + }, + "optionalDependencies": { + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251111.1", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251111.1", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20251111.1", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251111.1", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20251111.1", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251111.1", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20251111.1" + } + }, + "node_modules/@typescript/native-preview-darwin-arm64": { + "version": "7.0.0-dev.20251111.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20251111.1.tgz", + "integrity": "sha512-swE3/8vfsWGp+TNtmmW+amKd1V1cQrwbLSA2MFTiXZt3HUJPnLR8/aaWTkkrMCoLNSJd+KZV76X9Cw6QLAzdGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@typescript/native-preview-darwin-x64": { + "version": "7.0.0-dev.20251111.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20251111.1.tgz", + "integrity": "sha512-vyToe0VYi+Trd9jNyrFersj0ZDtww/IjyoM8oOQN88K6AZEqxkxSxJC8SovCWqmPhfVhb5k/pnaqBE47hc6GLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@typescript/native-preview-linux-arm": { + "version": "7.0.0-dev.20251111.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20251111.1.tgz", + "integrity": "sha512-8WMqKSp3Yq5saVL//BCocUgFnTznAaNajLrFbTOPtfwtlpIUddXM3Imb614NgPb6GX72C44WDB8oNgO7KEBJsQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@typescript/native-preview-linux-arm64": { + "version": "7.0.0-dev.20251111.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20251111.1.tgz", + "integrity": "sha512-o5p3urr5YyrdotWwwqNn/SYJlujICStgqEdukvaWh7vG9UI911Dp7hGFmmn+8UvWubcZm+DBWijkkUJcxIN7Gw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@typescript/native-preview-linux-x64": { + "version": "7.0.0-dev.20251111.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20251111.1.tgz", + "integrity": "sha512-0MEDwP3pt1IeG+MwW/qR2r9d7ZMvYv61a9MJJCjtkFGMm23LL/kitaEEYdeQ6efTeUJDWfRleol1dDkh8aoBEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@typescript/native-preview-win32-arm64": { + "version": "7.0.0-dev.20251111.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20251111.1.tgz", + "integrity": "sha512-ko3sx2l2bzcaCIQ+MDXT/b5FVX2NoSuji5IK/DdZvrbYGgNkh2qUb7Td7RqXaTIE1yaUTEv5PK7UGu1C1qg2EQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@typescript/native-preview-win32-x64": { + "version": "7.0.0-dev.20251111.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20251111.1.tgz", + "integrity": "sha512-XClWd0RpDQ/ouF9d8+gafFHob8XUFAJ49yfLkQqRncRwCXiGfMXyZTbHms+BdyAl5qguv1sgm2YhAWHeaggltQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -3141,9 +3240,9 @@ } }, "node_modules/hono": { - "version": "4.9.10", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.9.10.tgz", - "integrity": "sha512-AlI15ijFyKTXR7eHo7QK7OR4RoKIedZvBuRjO8iy4zrxvlY5oFCdiRG/V/lFJHCNXJ0k72ATgnyzx8Yqa5arug==", + "version": "4.10.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.4.tgz", + "integrity": "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -3964,27 +4063,6 @@ "wrappy": "1" } }, - "node_modules/openai": { - "version": "5.23.2", - "resolved": "https://registry.npmjs.org/openai/-/openai-5.23.2.tgz", - "integrity": "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -4578,11 +4656,11 @@ } }, "node_modules/tar": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", - "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", + "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", @@ -4791,6 +4869,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/util-deprecate": { @@ -5200,6 +5279,7 @@ "packages/agent-old": { "name": "@mariozechner/pi-agent-old", "version": "0.5.48", + "extraneous": true, "license": "MIT", "dependencies": { "@mariozechner/pi-tui": "^0.5.44", @@ -5216,18 +5296,6 @@ "node": ">=20.0.0" } }, - "packages/agent-old/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "packages/agent/node_modules/@types/node": { "version": "24.8.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.0.tgz", diff --git a/package.json b/package.json index 42928d92..80f78cfa 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,10 @@ ], "scripts": { "clean": "npm run clean --workspaces", - "build": "npm run build -w @mariozechner/pi-tui && npm run build -w @mariozechner/pi-ai && npm run build -w @mariozechner/pi-agent && npm run build -w @mariozechner/pi-agent-old && npm run build -w @mariozechner/coding-agent && npm run build -w @mariozechner/pi-web-ui && npm run build -w @mariozechner/pi-proxy && npm run build -w @mariozechner/pi", + "build": "npm run build -w @mariozechner/pi-tui && npm run build -w @mariozechner/pi-ai && npm run build -w @mariozechner/pi-agent && npm run build -w @mariozechner/coding-agent && npm run build -w @mariozechner/pi-web-ui && npm run build -w @mariozechner/pi-proxy && npm run build -w @mariozechner/pi", "dev": "concurrently --names \"ai,web-ui,tui,proxy\" --prefix-colors \"cyan,green,magenta,blue\" \"npm run dev -w @mariozechner/pi-ai\" \"npm run dev -w @mariozechner/pi-web-ui\" \"npm run dev -w @mariozechner/pi-tui\" \"npm run dev -w @mariozechner/pi-proxy\"", "dev:tsc": "concurrently --names \"ai,web-ui\" --prefix-colors \"cyan,green\" \"npm run dev:tsc -w @mariozechner/pi-ai\" \"npm run dev:tsc -w @mariozechner/pi-web-ui\"", - "check": "biome check --write . && npm run check --workspaces && tsc --noEmit", + "check": "biome check --write . && npm run check --workspaces && tsgo --noEmit", "test": "npm run test --workspaces --if-present", "version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js", "version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js", @@ -24,6 +24,7 @@ "devDependencies": { "@biomejs/biome": "^2.1.4", "@types/node": "^22.10.5", + "@typescript/native-preview": "^7.0.0-dev.20251111.1", "concurrently": "^9.2.1", "husky": "^9.1.7", "tsx": "^4.20.3", diff --git a/packages/agent/package.json b/packages/agent/package.json index e63abfd0..8776bc3a 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -11,9 +11,9 @@ ], "scripts": { "clean": "rm -rf dist", - "build": "tsc -p tsconfig.build.json", - "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput", - "check": "tsc --noEmit", + "build": "tsgo -p tsconfig.build.json", + "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput", + "check": "tsgo --noEmit", "test": "vitest --run", "prepublishOnly": "npm run clean && npm run build" }, diff --git a/packages/ai/package.json b/packages/ai/package.json index 18eca83a..7808bf7a 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -12,10 +12,10 @@ "scripts": { "clean": "rm -rf dist", "generate-models": "npx tsx scripts/generate-models.ts", - "build": "npm run generate-models && tsc -p tsconfig.build.json", - "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput", - "dev:tsc": "tsc -p tsconfig.build.json --watch --preserveWatchOutput", - "check": "biome check --write .", + "build": "npm run generate-models && tsgo -p tsconfig.build.json", + "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput", + "dev:tsc": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput", + "check": "biome check --write . && tsgo --noEmit", "test": "vitest --run", "prepublishOnly": "npm run clean && npm run build" }, diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 434b42a9..f31fc652 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -1838,6 +1838,74 @@ export const MODELS = { } satisfies Model<"anthropic-messages">, }, openrouter: { + "kwaipilot/kat-coder-pro:free": { + id: "kwaipilot/kat-coder-pro:free", + name: "Kwaipilot: Kat Coder (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: 256000, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "openrouter/polaris-alpha": { + id: "openrouter/polaris-alpha", + name: "Polaris Alpha", + 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: 256000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2-thinking": { + id: "moonshotai/kimi-k2-thinking", + name: "MoonshotAI: Kimi K2 Thinking", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "amazon/nova-premier-v1": { + id: "amazon/nova-premier-v1", + name: "Amazon: Nova Premier 1.0", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 12.5, + cacheRead: 0.625, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 32000, + } satisfies Model<"openai-completions">, "mistralai/voxtral-small-24b-2507": { id: "mistralai/voxtral-small-24b-2507", name: "Mistral: Voxtral Small 24B 2507", @@ -1915,13 +1983,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.15, - output: 0.44999999999999996, + input: 0.255, + output: 1.02, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 196608, - maxTokens: 196608, + contextWindow: 204800, + maxTokens: 131072, } satisfies Model<"openai-completions">, "deepcogito/cogito-v2-preview-llama-405b": { id: "deepcogito/cogito-v2-preview-llama-405b", @@ -2141,8 +2209,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 4096, - maxTokens: 4000, + contextWindow: 262144, + maxTokens: 32768, } satisfies Model<"openai-completions">, "openai/gpt-5-pro": { id: "openai/gpt-5-pro", @@ -2187,7 +2255,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.6, + input: 0.44999999999999996, output: 1.9, cacheRead: 0, cacheWrite: 0, @@ -2206,8 +2274,8 @@ export const MODELS = { cost: { input: 3, output: 15, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.3, + cacheWrite: 3.75, }, contextWindow: 1000000, maxTokens: 64000, @@ -2294,8 +2362,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 4000, + contextWindow: 262144, + maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen3-max": { id: "qwen/qwen3-max", @@ -2663,13 +2731,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.08, - output: 0.29, + input: 0.09, + output: 0.3, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 131072, } satisfies Model<"openai-completions">, "x-ai/grok-code-fast-1": { id: "x-ai/grok-code-fast-1", @@ -3224,13 +3292,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.14, - output: 2.4899999999999998, + input: 0.5, + output: 2.4, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 63000, - maxTokens: 63000, + contextWindow: 131072, + maxTokens: 4096, } satisfies Model<"openai-completions">, "mistralai/devstral-medium": { id: "mistralai/devstral-medium", @@ -3589,23 +3657,6 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "mistralai/devstral-small-2505:free": { - id: "mistralai/devstral-small-2505:free", - name: "Mistral: Devstral Small 2505 (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: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "mistralai/devstral-small-2505": { id: "mistralai/devstral-small-2505", name: "Mistral: Devstral Small 2505", @@ -3615,13 +3666,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.049999999999999996, - output: 0.22, + input: 0.06, + output: 0.12, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 131072, + contextWindow: 128000, + maxTokens: 4096, } satisfies Model<"openai-completions">, "openai/codex-mini": { id: "openai/codex-mini", @@ -4126,12 +4177,12 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.049999999999999996, - output: 0.09999999999999999, + output: 0.22, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 4096, + contextWindow: 131072, + maxTokens: 131072, } satisfies Model<"openai-completions">, "google/gemma-3-27b-it": { id: "google/gemma-3-27b-it", @@ -4167,23 +4218,6 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "nousresearch/deephermes-3-llama-3-8b-preview": { - id: "nousresearch/deephermes-3-llama-3-8b-preview", - name: "Nous: DeepHermes 3 Llama 3 8B Preview", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.03, - output: 0.11, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, "google/gemini-2.0-flash-lite-001": { id: "google/gemini-2.0-flash-lite-001", name: "Google: Gemini 2.0 Flash Lite", @@ -4449,7 +4483,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.3, - output: 0.85, + output: 1.2, cacheRead: 0, cacheWrite: 0, }, @@ -4660,9 +4694,9 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -4677,9 +4711,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -4711,6 +4745,23 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, + "mistralai/ministral-3b": { + id: "mistralai/ministral-3b", + name: "Mistral: Ministral 3B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.04, + output: 0.04, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/ministral-8b": { id: "mistralai/ministral-8b", name: "Mistral: Ministral 8B", @@ -4793,7 +4844,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 16384, + contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, "qwen/qwen-2.5-72b-instruct": { @@ -4830,23 +4881,6 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "cohere/command-r-plus-08-2024": { - id: "cohere/command-r-plus-08-2024", - name: "Cohere: Command R+ (08-2024)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2.5, - output: 10, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4000, - } satisfies Model<"openai-completions">, "cohere/command-r-08-2024": { id: "cohere/command-r-08-2024", name: "Cohere: Command R (08-2024)", @@ -4864,6 +4898,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4000, } satisfies Model<"openai-completions">, + "cohere/command-r-plus-08-2024": { + id: "cohere/command-r-plus-08-2024", + name: "Cohere: Command R+ (08-2024)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2.5, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4000, + } satisfies Model<"openai-completions">, "sao10k/l3.1-euryale-70b": { id: "sao10k/l3.1-euryale-70b", name: "Sao10K: Llama 3.1 Euryale 70B v2.2", @@ -4932,6 +4983,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-405b-instruct": { + id: "meta-llama/llama-3.1-405b-instruct", + name: "Meta: Llama 3.1 405B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3.5, + output: 3.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 130815, + maxTokens: 4096, + } 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", @@ -4946,24 +5014,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 16384, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-405b-instruct": { - id: "meta-llama/llama-3.1-405b-instruct", - name: "Meta: Llama 3.1 405B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.7999999999999999, - output: 0.7999999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, + contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-70b-instruct": { @@ -5000,9 +5051,9 @@ export const MODELS = { contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, - "openai/gpt-4o-mini": { - id: "openai/gpt-4o-mini", - name: "OpenAI: GPT-4o-mini", + "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", @@ -5017,9 +5068,9 @@ export const MODELS = { contextWindow: 128000, 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)", + "openai/gpt-4o-mini": { + id: "openai/gpt-4o-mini", + name: "OpenAI: GPT-4o-mini", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5102,23 +5153,6 @@ export const MODELS = { contextWindow: 32768, maxTokens: 16384, } satisfies Model<"openai-completions">, - "mistralai/mistral-7b-instruct-v0.3": { - id: "mistralai/mistral-7b-instruct-v0.3", - name: "Mistral: Mistral 7B Instruct v0.3", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.028, - output: 0.054, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "microsoft/phi-3-mini-128k-instruct": { id: "microsoft/phi-3-mini-128k-instruct", name: "Microsoft: Phi-3 Mini 128K Instruct", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 0c593d9e..2c9f66aa 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -13,9 +13,9 @@ ], "scripts": { "clean": "rm -rf dist", - "build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js", - "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput", - "check": "tsc --noEmit", + "build": "tsgo -p tsconfig.build.json && chmod +x dist/cli.js", + "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput", + "check": "tsgo --noEmit", "test": "vitest --run", "prepublishOnly": "npm run clean && npm run build" }, diff --git a/packages/coding-agent/src/tui-renderer.ts b/packages/coding-agent/src/tui-renderer.ts index 62616bb7..e0dbce19 100644 --- a/packages/coding-agent/src/tui-renderer.ts +++ b/packages/coding-agent/src/tui-renderer.ts @@ -3,20 +3,46 @@ import type { AssistantMessage, Message } from "@mariozechner/pi-ai"; import { CombinedAutocompleteProvider, Container, - LoadingAnimation, - MarkdownComponent, - TextComponent, - TextEditor, + Editor, + Loader, + Markdown, + ProcessTerminal, + Spacer, + Text, TUI, - WhitespaceComponent, } from "@mariozechner/pi-tui"; import chalk from "chalk"; +/** + * Custom editor that handles Escape and Ctrl+C keys for coding-agent + */ +class CustomEditor extends Editor { + public onEscape?: () => void; + public onCtrlC?: () => void; + + handleInput(data: string): void { + // Intercept Escape key + if (data === "\x1b" && this.onEscape) { + this.onEscape(); + return; + } + + // Intercept Ctrl+C + if (data === "\x03" && this.onCtrlC) { + this.onCtrlC(); + return; + } + + // Pass to parent for normal handling + super.handleInput(data); + } +} + /** * Component that renders a streaming message with live updates */ class StreamingMessageComponent extends Container { - private textComponent: MarkdownComponent | null = null; + private textComponent: Markdown | null = null; private toolCallsContainer: Container | null = null; private currentContent = ""; private currentToolCalls: any[] = []; @@ -41,7 +67,7 @@ class StreamingMessageComponent extends Container { this.removeChild(this.textComponent); } if (textContent) { - this.textComponent = new MarkdownComponent(textContent); + this.textComponent = new Markdown(textContent); this.addChild(this.textComponent); } } @@ -58,9 +84,7 @@ class StreamingMessageComponent extends Container { for (const toolCall of toolCalls) { const argsStr = typeof toolCall.arguments === "string" ? toolCall.arguments : JSON.stringify(toolCall.arguments); - this.toolCallsContainer.addChild( - new TextComponent(chalk.yellow(`[tool] ${toolCall.name}(${argsStr})`)), - ); + this.toolCallsContainer.addChild(new Text(chalk.yellow(`[tool] ${toolCall.name}(${argsStr})`))); } this.addChild(this.toolCallsContainer); } @@ -76,10 +100,10 @@ export class TuiRenderer { private ui: TUI; private chatContainer: Container; private statusContainer: Container; - private editor: TextEditor; + private editor: CustomEditor; private isInitialized = false; private onInputCallback?: (text: string) => void; - private loadingAnimation: LoadingAnimation | null = null; + private loadingAnimation: Loader | null = null; private onInterruptCallback?: () => void; private lastSigintTime = 0; @@ -88,10 +112,10 @@ export class TuiRenderer { private streamingComponent: StreamingMessageComponent | null = null; constructor() { - this.ui = new TUI(); + this.ui = new TUI(new ProcessTerminal()); this.chatContainer = new Container(); this.statusContainer = new Container(); - this.editor = new TextEditor(); + this.editor = new CustomEditor(); // Setup autocomplete for file paths and slash commands const autocompleteProvider = new CombinedAutocompleteProvider([], process.cwd()); @@ -102,7 +126,7 @@ export class TuiRenderer { if (this.isInitialized) return; // Add header with instructions - const header = new TextComponent( + const header = new Text( chalk.blueBright(">> coding-agent interactive <<") + "\n" + chalk.dim("Press Escape to interrupt while processing") + @@ -110,45 +134,38 @@ export class TuiRenderer { chalk.dim("Press CTRL+C to clear the text editor") + "\n" + chalk.dim("Press CTRL+C twice quickly to exit"), - { bottom: 1 }, ); // Setup UI layout this.ui.addChild(header); this.ui.addChild(this.chatContainer); this.ui.addChild(this.statusContainer); - this.ui.addChild(new WhitespaceComponent(1)); + this.ui.addChild(new Spacer(1)); this.ui.addChild(this.editor); this.ui.setFocus(this.editor); - // Set up global key handler for Escape and Ctrl+C - this.ui.onGlobalKeyPress = (data: string): boolean => { + // Set up custom key handlers on the editor + this.editor.onEscape = () => { // Intercept Escape key when processing - if (data === "\x1b" && this.loadingAnimation) { - if (this.onInterruptCallback) { - this.onInterruptCallback(); - } - return false; + if (this.loadingAnimation && this.onInterruptCallback) { + this.onInterruptCallback(); } + }; + this.editor.onCtrlC = () => { // Handle Ctrl+C (raw mode sends \x03) - if (data === "\x03") { - const now = Date.now(); - const timeSinceLastCtrlC = now - this.lastSigintTime; + const now = Date.now(); + const timeSinceLastCtrlC = now - this.lastSigintTime; - if (timeSinceLastCtrlC < 500) { - // Second Ctrl+C within 500ms - exit - this.stop(); - process.exit(0); - } else { - // First Ctrl+C - clear the editor - this.clearEditor(); - this.lastSigintTime = now; - } - return false; + if (timeSinceLastCtrlC < 500) { + // Second Ctrl+C within 500ms - exit + this.stop(); + process.exit(0); + } else { + // First Ctrl+C - clear the editor + this.clearEditor(); + this.lastSigintTime = now; } - - return true; }; // Handle editor submission @@ -191,7 +208,7 @@ export class TuiRenderer { if (!this.loadingAnimation) { this.editor.disableSubmit = true; this.statusContainer.clear(); - this.loadingAnimation = new LoadingAnimation(this.ui); + this.loadingAnimation = new Loader(this.ui); this.statusContainer.addChild(this.loadingAnimation); } @@ -222,12 +239,13 @@ export class TuiRenderer { private addMessageToChat(message: Message): void { if (message.role === "user") { - this.chatContainer.addChild(new TextComponent(chalk.green("[user]"))); + this.chatContainer.addChild(new Text(chalk.green("[user]"))); const userMsg = message as any; const textContent = userMsg.content?.map((c: any) => c.text || "").join("") || message.content || ""; - this.chatContainer.addChild(new TextComponent(textContent, { bottom: 1 })); + this.chatContainer.addChild(new Text(textContent)); + this.chatContainer.addChild(new Spacer(1)); } else if (message.role === "assistant") { - this.chatContainer.addChild(new TextComponent(chalk.hex("#FFA500")("[assistant]"))); + this.chatContainer.addChild(new Text(chalk.hex("#FFA500")("[assistant]"))); const assistantMsg = message as AssistantMessage; // Render text content @@ -236,7 +254,7 @@ export class TuiRenderer { .map((c) => c.text) .join(""); if (textContent) { - this.chatContainer.addChild(new MarkdownComponent(textContent)); + this.chatContainer.addChild(new Markdown(textContent)); } // Render tool calls @@ -244,10 +262,10 @@ export class TuiRenderer { for (const toolCall of toolCalls) { const argsStr = typeof toolCall.arguments === "string" ? toolCall.arguments : JSON.stringify(toolCall.arguments); - this.chatContainer.addChild(new TextComponent(chalk.yellow(`[tool] ${toolCall.name}(${argsStr})`))); + this.chatContainer.addChild(new Text(chalk.yellow(`[tool] ${toolCall.name}(${argsStr})`))); } - this.chatContainer.addChild(new WhitespaceComponent(1)); + this.chatContainer.addChild(new Spacer(1)); } else if (message.role === "toolResult") { const toolResultMsg = message as any; const output = toolResultMsg.result?.output || toolResultMsg.result || ""; @@ -259,13 +277,13 @@ export class TuiRenderer { const toShow = truncated ? lines.slice(0, maxLines) : lines; for (const line of toShow) { - this.chatContainer.addChild(new TextComponent(chalk.gray(line))); + this.chatContainer.addChild(new Text(chalk.gray(line))); } if (truncated) { - this.chatContainer.addChild(new TextComponent(chalk.dim(`... (${lines.length - maxLines} more lines)`))); + this.chatContainer.addChild(new Text(chalk.dim(`... (${lines.length - maxLines} more lines)`))); } - this.chatContainer.addChild(new WhitespaceComponent(1)); + this.chatContainer.addChild(new Spacer(1)); } } @@ -285,7 +303,7 @@ export class TuiRenderer { clearEditor(): void { this.editor.setText(""); this.statusContainer.clear(); - const hint = new TextComponent(chalk.dim("Press Ctrl+C again to exit")); + const hint = new Text(chalk.dim("Press Ctrl+C again to exit")); this.statusContainer.addChild(hint); this.ui.requestRender(); diff --git a/packages/pods/package.json b/packages/pods/package.json index 8f3c49e3..08fc05f7 100644 --- a/packages/pods/package.json +++ b/packages/pods/package.json @@ -8,7 +8,7 @@ }, "scripts": { "clean": "rm -rf dist", - "build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js && cp src/models.json dist/ && cp -r scripts dist/", + "build": "tsgo -p tsconfig.build.json && chmod +x dist/cli.js && cp src/models.json dist/ && cp -r scripts dist/", "check": "biome check --write .", "prepublishOnly": "npm run clean && npm run build" }, diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 7dcd493c..3c81ce07 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -12,7 +12,7 @@ "clean": "rm -rf dist", "build": "tsc", "check": "biome check --write .", - "typecheck": "tsc --noEmit", + "typecheck": "tsgo --noEmit", "dev": "tsx src/cors-proxy.ts 3001" }, "dependencies": { diff --git a/packages/tui/README.md b/packages/tui/README.md index 2c1e0308..5ae89f3d 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -1,559 +1,261 @@ # @mariozechner/pi-tui -Terminal UI framework with surgical differential rendering for building flicker-free interactive CLI applications. +Minimal terminal UI framework with differential rendering and synchronized output for flicker-free interactive CLI applications. ## Features -- **Surgical Differential Rendering**: Three-strategy system that minimizes redraws to 1-2 lines for typical updates -- **Scrollback Buffer Preservation**: Correctly maintains terminal history when content exceeds viewport -- **Zero Flicker**: Components like text editors remain perfectly still while other parts update -- **Interactive Components**: Text editor with autocomplete, selection lists, markdown rendering -- **Composable Architecture**: Container-based component system with automatic lifecycle management +- **Differential Rendering**: Three-strategy rendering system that only updates what changed +- **Synchronized Output**: Uses CSI 2026 for atomic screen updates (no flicker) +- **Bracketed Paste Mode**: Handles large pastes correctly with markers for >10 line pastes +- **Component-based**: Simple Component interface with render() method +- **Built-in Components**: Text, Input, Editor, Markdown, Loader, SelectList, Spacer +- **Autocomplete Support**: File paths and slash commands ## Quick Start ```typescript -import { TUI, Container, TextComponent, TextEditor } from "@mariozechner/pi-tui"; +import { TUI, Text, Editor, ProcessTerminal } from "@mariozechner/pi-tui"; -// Create TUI manager -const ui = new TUI(); +// Create terminal +const terminal = new ProcessTerminal(); -// Create components -const header = new TextComponent("🚀 My TUI App"); -const chatContainer = new Container(); -const editor = new TextEditor(); +// Create TUI +const tui = new TUI(terminal); -// Add components to UI -ui.addChild(header); -ui.addChild(chatContainer); -ui.addChild(editor); +// Add components +tui.addChild(new Text("Welcome to my app!")); -// Set focus to the editor -ui.setFocus(editor); - -// Handle editor submissions -editor.onSubmit = (text: string) => { - if (text.trim()) { - const message = new TextComponent(`💬 ${text}`); - chatContainer.addChild(message); - // Note: Container automatically calls requestRender when children change - } +const editor = new Editor(); +editor.onSubmit = (text) => { + console.log("Submitted:", text); + tui.addChild(new Text(`You said: ${text}`)); }; +tui.addChild(editor); -// Start the UI -ui.start(); +// Start +tui.start(); ``` -## Core Components +## Core API ### TUI -Main TUI manager with surgical differential rendering that handles input and component lifecycle. +Main container that manages components and rendering. -**Key Features:** -- **Three rendering strategies**: Automatically selects optimal approach - - Surgical: Updates only changed lines (1-2 lines typical) - - Partial: Re-renders from first change when structure shifts - - Full: Complete re-render when changes are above viewport -- **Performance metrics**: Built-in tracking via `getLinesRedrawn()` and `getAverageLinesRedrawn()` -- **Terminal abstraction**: Works with any Terminal interface implementation +```typescript +const tui = new TUI(terminal); +tui.addChild(component); +tui.removeChild(component); +tui.start(); +tui.stop(); +tui.requestRender(); // Request a re-render +``` -**Methods:** -- `addChild(component)` - Add a component -- `removeChild(component)` - Remove a component -- `setFocus(component)` - Set keyboard focus -- `start()` / `stop()` - Lifecycle management -- `requestRender()` - Queue re-render (automatically debounced) -- `configureLogging(config)` - Enable debug logging +### Component Interface + +All components implement: + +```typescript +interface Component { + render(width: number): string[]; + handleInput?(data: string): void; +} +``` + +## Built-in Components ### Container -Component that manages child components. Automatically triggers re-renders when children change. +Groups child components. ```typescript const container = new Container(); -container.addChild(new TextComponent("Child 1")); +container.addChild(component); container.removeChild(component); -container.clear(); ``` -### TextEditor +### Text -Interactive multiline text editor with autocomplete support. +Displays multi-line text with word wrapping and padding. ```typescript -const editor = new TextEditor(); -editor.setText("Initial text"); -editor.onSubmit = (text) => console.log("Submitted:", text); -editor.setAutocompleteProvider(provider); -``` - -**Key Bindings:** -- `Enter` - Submit text -- `Shift+Enter` - New line -- `Tab` - Autocomplete -- `Ctrl+K` - Delete line -- `Ctrl+A/E` - Start/end of line -- Arrow keys, Backspace, Delete work as expected - -### TextComponent - -Simple text display with automatic word wrapping. - -```typescript -const text = new TextComponent("Hello World", { top: 1, bottom: 1 }); +const text = new Text("Hello World", paddingX, paddingY); // defaults: 1, 1 text.setText("Updated text"); ``` -### MarkdownComponent +### Input -Renders markdown content with syntax highlighting and proper formatting. - -**Constructor:** +Single-line text input with horizontal scrolling. ```typescript -new MarkdownComponent(text?: string) +const input = new Input(); +input.onSubmit = (value) => console.log(value); +input.setValue("initial"); ``` -**Methods:** +### Editor -- `setText(text)` - Update markdown content -- `render(width)` - Render parsed markdown +Multi-line text editor with autocomplete, file completion, and paste handling. + +```typescript +const editor = new Editor(); +editor.onSubmit = (text) => console.log(text); +editor.onChange = (text) => console.log("Changed:", text); +editor.disableSubmit = true; // Disable submit temporarily +editor.setAutocompleteProvider(provider); +``` **Features:** +- Multi-line editing with word wrap +- Slash command autocomplete (type `/`) +- File path autocomplete (press `Tab`) +- Large paste handling (>10 lines creates `[paste #1 +50 lines]` marker) +- Horizontal lines above/below editor +- Fake cursor rendering (hidden real cursor) -- **Headings**: Styled with colors and formatting -- **Code blocks**: Syntax highlighting with gray background -- **Lists**: Bullet points (•) and numbered lists -- **Emphasis**: **Bold** and _italic_ text -- **Links**: Underlined with URL display -- **Blockquotes**: Styled with left border -- **Inline code**: Highlighted with background -- **Horizontal rules**: Terminal-width separator lines -- Differential rendering for performance +**Key Bindings:** +- `Enter` - Submit +- `Shift+Enter` or `Ctrl+Enter` - New line +- `Tab` - Autocomplete +- `Ctrl+K` - Delete line +- `Ctrl+A` / `Ctrl+E` - Line start/end +- Arrow keys, Backspace, Delete work as expected + +### Markdown + +Renders markdown with syntax highlighting and optional background colors. + +```typescript +const md = new Markdown( + "# Hello\n\nSome **bold** text", + bgColor, // optional: "bgRed", "bgBlue", etc. + fgColor, // optional: "white", "cyan", etc. + customBgRgb, // optional: { r: 52, g: 53, b: 65 } + paddingX, // optional: default 1 + paddingY // optional: default 1 +); +md.setText("Updated markdown"); +``` + +**Features:** +- Headings, bold, italic, code blocks, lists, links, blockquotes +- Syntax highlighting with chalk +- Optional background colors (including custom RGB) +- Padding support +- Render caching for performance + +### Loader + +Animated loading spinner. + +```typescript +const loader = new Loader(tui, "Loading..."); +loader.start(); +loader.stop(); +``` ### SelectList -Interactive selection component for choosing from options. - -**Constructor:** +Interactive selection list with keyboard navigation. ```typescript -new SelectList(items: SelectItem[], maxVisible?: number) +const list = new SelectList([ + { value: "opt1", label: "Option 1", description: "First option" }, + { value: "opt2", label: "Option 2", description: "Second option" }, +], 5); // maxVisible -interface SelectItem { - value: string; - label: string; - description?: string; -} +list.onSelect = (item) => console.log("Selected:", item); +list.onCancel = () => console.log("Cancelled"); +list.setFilter("opt"); // Filter items ``` -**Properties:** +**Controls:** +- Arrow keys: Navigate +- Enter or Tab: Select +- Escape: Cancel -- `onSelect?: (item: SelectItem) => void` - Called when item is selected -- `onCancel?: () => void` - Called when selection is cancelled +### Spacer -**Methods:** - -- `setFilter(filter)` - Filter items by value -- `getSelectedItem()` - Get currently selected item -- `handleInput(keyData)` - Handle keyboard navigation -- `render(width)` - Render the selection list - -**Features:** - -- Keyboard navigation (arrow keys, Enter) -- Search/filter functionality -- Scrolling for long lists -- Custom option rendering with descriptions -- Visual selection indicator (→) -- Scroll position indicator - -### Autocomplete System - -Comprehensive autocomplete system supporting slash commands and file paths. - -#### AutocompleteProvider Interface +Empty lines for vertical spacing. ```typescript -interface AutocompleteProvider { - getSuggestions( - lines: string[], - cursorLine: number, - cursorCol: number, - ): { - items: AutocompleteItem[]; - prefix: string; - } | null; - - applyCompletion( - lines: string[], - cursorLine: number, - cursorCol: number, - item: AutocompleteItem, - prefix: string, - ): { - lines: string[]; - cursorLine: number; - cursorCol: number; - }; -} - -interface AutocompleteItem { - value: string; - label: string; - description?: string; -} +const spacer = new Spacer(2); // 2 empty lines (default: 1) ``` -#### CombinedAutocompleteProvider +## Autocomplete -Built-in provider supporting slash commands and file completion. +### CombinedAutocompleteProvider -**Constructor:** +Supports both slash commands and file paths. ```typescript -new CombinedAutocompleteProvider( - commands: (SlashCommand | AutocompleteItem)[] = [], - basePath: string = process.cwd() -) +import { CombinedAutocompleteProvider } from "@mariozechner/pi-tui"; -interface SlashCommand { - name: string; - description?: string; - getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null; -} +const provider = new CombinedAutocompleteProvider( + [ + { name: "help", description: "Show help" }, + { name: "clear", description: "Clear screen" }, + { name: "delete", description: "Delete last message" }, + ], + process.cwd() // base path for file completion +); + +editor.setAutocompleteProvider(provider); ``` **Features:** - -**Slash Commands:** - -- Type `/` to trigger command completion -- Auto-completion for command names -- Argument completion for commands that support it -- Space after command name for argument input - -**File Completion:** - -- `Tab` key triggers file completion -- `@` prefix for file attachments -- Home directory expansion (`~/`) -- Relative and absolute path support -- Directory-first sorting +- Type `/` to see slash commands +- Press `Tab` for file path completion +- Works with `~/`, `./`, `../`, and `@` prefix - Filters to attachable files for `@` prefix -**Path Patterns:** +## Differential Rendering -- `./` and `../` - Relative paths -- `~/` - Home directory -- `@path` - File attachment syntax -- Tab completion from any context +The TUI uses three rendering strategies: -**Methods:** +1. **First Render**: Output all lines without clearing scrollback +2. **Width Changed or Change Above Viewport**: Clear screen and full re-render +3. **Normal Update**: Move cursor to first changed line, clear to end, render changed lines -- `getSuggestions()` - Get completions for current context -- `getForceFileSuggestions()` - Force file completion (Tab key) -- `shouldTriggerFileCompletion()` - Check if file completion should trigger -- `applyCompletion()` - Apply selected completion +All updates are wrapped in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering. -## Surgical Differential Rendering +## Terminal Interface -The TUI uses a three-strategy rendering system that minimizes redraws to only what's necessary: - -### Rendering Strategies - -1. **Surgical Updates** (most common) - - When: Only content changes, same line counts, all changes in viewport - - Action: Updates only specific changed lines (typically 1-2 lines) - - Example: Loading spinner animation, updating status text - -2. **Partial Re-render** - - When: Line count changes or structural changes within viewport - - Action: Clears from first change to end of screen, re-renders tail - - Example: Adding new messages to a chat, expanding text editor - -3. **Full Re-render** - - When: Changes occur above the viewport (in scrollback buffer) - - Action: Clears scrollback and screen, renders everything fresh - - Example: Content exceeds viewport and early components change - -### How Components Participate - -Components implement the simple `Component` interface: +The TUI works with any object implementing the `Terminal` interface: ```typescript -interface ComponentRenderResult { - lines: string[]; // The lines to display - changed: boolean; // Whether content changed since last render -} - -interface Component { - readonly id: number; // Unique ID for tracking - render(width: number): ComponentRenderResult; - handleInput?(keyData: string): void; +interface Terminal { + start(onInput: (data: string) => void, onResize: () => void): void; + stop(): void; + write(data: string): void; + get columns(): number; + get rows(): number; + moveBy(lines: number): void; + hideCursor(): void; + showCursor(): void; + clearLine(): void; + clearFromCursor(): void; + clearScreen(): void; } ``` -The TUI tracks component IDs and line positions to determine the optimal strategy automatically. +**Built-in implementations:** +- `ProcessTerminal` - Uses `process.stdin/stdout` +- `VirtualTerminal` - For testing (uses `@xterm/headless`) -### Performance Metrics +## Example -Monitor rendering efficiency: - -```typescript -const ui = new TUI(); -// After some rendering... -console.log(`Total lines redrawn: ${ui.getLinesRedrawn()}`); -console.log(`Average per render: ${ui.getAverageLinesRedrawn()}`); -``` - -Typical performance: 1-2 lines redrawn for animations, 0 for static content. - -## Examples - -Run the example applications in the `test/` directory: +See `test/chat-simple.ts` for a complete chat interface example with: +- Markdown messages with custom background colors +- Loading spinner during responses +- Editor with autocomplete and slash commands +- Spacers between messages +Run it: ```bash -# Chat application with slash commands and autocomplete -npx tsx test/chat-app.ts - -# File browser with navigation -npx tsx test/file-browser.ts - -# Multi-component layout demo -npx tsx test/multi-layout.ts - -# Performance benchmark with animation -npx tsx test/bench.ts +npx tsx test/chat-simple.ts ``` -### Example Descriptions - -- **chat-app.ts** - Chat interface with slash commands (/clear, /help, /attach) and autocomplete -- **file-browser.ts** - Interactive file browser with directory navigation -- **multi-layout.ts** - Complex layout with header, sidebar, main content, and footer -- **bench.ts** - Performance test with animation showing surgical rendering efficiency - -## Interfaces and Types - -### Core Types - -```typescript -interface ComponentRenderResult { - lines: string[]; - changed: boolean; -} - -interface ContainerRenderResult extends ComponentRenderResult { - keepLines: number; -} - -interface Component { - render(width: number): ComponentRenderResult; - handleInput?(keyData: string): void; -} - -interface Padding { - top?: number; - bottom?: number; - left?: number; - right?: number; -} -``` - -### Autocomplete Types - -```typescript -interface AutocompleteItem { - value: string; - label: string; - description?: string; -} - -interface SlashCommand { - name: string; - description?: string; - getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null; -} - -interface AutocompleteProvider { - getSuggestions( - lines: string[], - cursorLine: number, - cursorCol: number, - ): { - items: AutocompleteItem[]; - prefix: string; - } | null; - - applyCompletion( - lines: string[], - cursorLine: number, - cursorCol: number, - item: AutocompleteItem, - prefix: string, - ): { - lines: string[]; - cursorLine: number; - cursorCol: number; - }; -} -``` - -### Selection Types - -```typescript -interface SelectItem { - value: string; - label: string; - description?: string; -} -``` - -## Testing - -### Running Tests - -```bash -# Run all tests -npm test - -# Run specific test file -npm test -- test/tui-rendering.test.ts - -# Run tests matching a pattern -npm test -- --test-name-pattern="preserves existing" -``` - -### Test Infrastructure - -The TUI uses a **VirtualTerminal** for testing that provides accurate terminal emulation via `@xterm/headless`: - -```typescript -import { VirtualTerminal } from "./test/virtual-terminal.js"; -import { TUI, TextComponent } from "../src/index.js"; - -test("my TUI test", async () => { - const terminal = new VirtualTerminal(80, 24); - const ui = new TUI(terminal); - ui.start(); - - ui.addChild(new TextComponent("Hello")); - - // Wait for render - await new Promise(resolve => process.nextTick(resolve)); - - // Get rendered output - const viewport = await terminal.flushAndGetViewport(); - assert.strictEqual(viewport[0], "Hello"); - - ui.stop(); -}); -``` - -### Writing a New Test - -1. **Create test file** in `test/` directory with `.test.ts` extension -2. **Use VirtualTerminal** for accurate terminal emulation -3. **Key testing patterns**: - -```typescript -import { test, describe } from "node:test"; -import assert from "node:assert"; -import { VirtualTerminal } from "./virtual-terminal.js"; -import { TUI, Container, TextComponent } from "../src/index.js"; - -describe("My Feature", () => { - test("should handle dynamic content", async () => { - const terminal = new VirtualTerminal(80, 24); - const ui = new TUI(terminal); - ui.start(); - - // Setup components - const container = new Container(); - ui.addChild(container); - - // Initial render - await new Promise(resolve => process.nextTick(resolve)); - await terminal.flush(); - - // Check viewport (visible content) - let viewport = terminal.getViewport(); - assert.strictEqual(viewport.length, 24); - - // Check scrollback buffer (all content including history) - let scrollBuffer = terminal.getScrollBuffer(); - - // Simulate user input - terminal.sendInput("Hello"); - - // Wait for processing - await new Promise(resolve => process.nextTick(resolve)); - await terminal.flush(); - - // Verify changes - viewport = terminal.getViewport(); - // ... assertions - - ui.stop(); - }); -}); -``` - -### VirtualTerminal API - -- `new VirtualTerminal(columns, rows)` - Create terminal with dimensions -- `write(data)` - Write ANSI sequences to terminal -- `sendInput(data)` - Simulate keyboard input -- `flush()` - Wait for all writes to complete -- `getViewport()` - Get visible lines (what user sees) -- `getScrollBuffer()` - Get all lines including scrollback -- `flushAndGetViewport()` - Convenience method -- `getCursorPosition()` - Get cursor row/column -- `resize(columns, rows)` - Resize terminal - -### Testing Best Practices - -1. **Always flush after renders**: Terminal writes are async - ```typescript - await new Promise(resolve => process.nextTick(resolve)); - await terminal.flush(); - ``` - -2. **Test both viewport and scrollback**: Ensure content preservation - ```typescript - const viewport = terminal.getViewport(); // Visible content - const scrollBuffer = terminal.getScrollBuffer(); // All content - ``` - -3. **Use exact string matching**: Don't trim() - whitespace matters - ```typescript - assert.strictEqual(viewport[0], "Expected text"); // Good - assert.strictEqual(viewport[0].trim(), "Expected"); // Bad - ``` - -4. **Test rendering strategies**: Verify surgical vs partial vs full - ```typescript - const beforeLines = ui.getLinesRedrawn(); - // Make change... - const afterLines = ui.getLinesRedrawn(); - assert.strictEqual(afterLines - beforeLines, 1); // Only 1 line changed - ``` - -### Performance Testing - -Use `test/bench.ts` as a template for performance testing: - -```bash -npx tsx test/bench.ts -``` - -Monitor real-time performance metrics: -- Render count and timing -- Lines redrawn per render -- Visual verification of flicker-free updates - ## Development ```bash @@ -563,17 +265,6 @@ npm install # Run type checking npm run check -# Run tests -npm test -``` - -**Debugging:** -Enable logging to see detailed component behavior: - -```typescript -ui.configureLogging({ - enabled: true, - level: "debug", // "error" | "warn" | "info" | "debug" - logFile: "tui-debug.log", -}); +# Run the demo +npx tsx test/chat-simple.ts ``` diff --git a/packages/tui/package.json b/packages/tui/package.json index dc038fbe..ba64c9ac 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -6,9 +6,9 @@ "main": "dist/index.js", "scripts": { "clean": "rm -rf dist", - "build": "tsc -p tsconfig.build.json", - "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput", - "check": "biome check --write .", + "build": "tsgo -p tsconfig.build.json", + "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput", + "check": "biome check --write . && tsgo --noEmit", "test": "node --test --import tsx test/*.test.ts", "prepublishOnly": "npm run clean && npm run build" }, diff --git a/packages/tui/src/components-new/select-list.ts b/packages/tui/src/components-new/select-list.ts deleted file mode 100644 index 1f64947b..00000000 --- a/packages/tui/src/components-new/select-list.ts +++ /dev/null @@ -1,154 +0,0 @@ -import chalk from "chalk"; -import type { Component } from "../tui-new.js"; - -export interface SelectItem { - value: string; - label: string; - description?: string; -} - -export class SelectList implements Component { - private items: SelectItem[] = []; - private filteredItems: SelectItem[] = []; - private selectedIndex: number = 0; - private filter: string = ""; - private maxVisible: number = 5; - - public onSelect?: (item: SelectItem) => void; - public onCancel?: () => void; - - constructor(items: SelectItem[], maxVisible: number = 5) { - this.items = items; - this.filteredItems = items; - this.maxVisible = maxVisible; - } - - setFilter(filter: string): void { - this.filter = filter; - this.filteredItems = this.items.filter((item) => item.value.toLowerCase().startsWith(filter.toLowerCase())); - // Reset selection when filter changes - this.selectedIndex = 0; - } - - render(width: number): string[] { - const lines: string[] = []; - - // If no items match filter, show message - if (this.filteredItems.length === 0) { - lines.push(chalk.gray(" No matching commands")); - return lines; - } - - // Calculate visible range with scrolling - const startIndex = Math.max( - 0, - Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible), - ); - const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length); - - // Render visible items - for (let i = startIndex; i < endIndex; i++) { - const item = this.filteredItems[i]; - if (!item) continue; - - const isSelected = i === this.selectedIndex; - - let line = ""; - if (isSelected) { - // Use arrow indicator for selection - const prefix = chalk.blue("→ "); - const displayValue = item.label || item.value; - - if (item.description && width > 40) { - // Calculate how much space we have for value + description - const maxValueLength = Math.min(displayValue.length, 30); - const truncatedValue = displayValue.substring(0, maxValueLength); - const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length)); - - // Calculate remaining space for description - const descriptionStart = prefix.length + truncatedValue.length + spacing.length - 2; // -2 for arrow color codes - const remainingWidth = width - descriptionStart - 2; // -2 for safety - - if (remainingWidth > 10) { - const truncatedDesc = item.description.substring(0, remainingWidth); - line = prefix + chalk.blue(truncatedValue) + chalk.gray(spacing + truncatedDesc); - } else { - // Not enough space for description - const maxWidth = width - 4; // 2 for arrow + space, 2 for safety - line = prefix + chalk.blue(displayValue.substring(0, maxWidth)); - } - } else { - // No description or not enough width - const maxWidth = width - 4; // 2 for arrow + space, 2 for safety - line = prefix + chalk.blue(displayValue.substring(0, maxWidth)); - } - } else { - const displayValue = item.label || item.value; - const prefix = " "; - - if (item.description && width > 40) { - // Calculate how much space we have for value + description - const maxValueLength = Math.min(displayValue.length, 30); - const truncatedValue = displayValue.substring(0, maxValueLength); - const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length)); - - // Calculate remaining space for description - const descriptionStart = prefix.length + truncatedValue.length + spacing.length; - const remainingWidth = width - descriptionStart - 2; // -2 for safety - - if (remainingWidth > 10) { - const truncatedDesc = item.description.substring(0, remainingWidth); - line = prefix + truncatedValue + chalk.gray(spacing + truncatedDesc); - } else { - // Not enough space for description - const maxWidth = width - prefix.length - 2; - line = prefix + displayValue.substring(0, maxWidth); - } - } else { - // No description or not enough width - const maxWidth = width - prefix.length - 2; - line = prefix + displayValue.substring(0, maxWidth); - } - } - - lines.push(line); - } - - // Add scroll indicators if needed - if (startIndex > 0 || endIndex < this.filteredItems.length) { - const scrollInfo = chalk.gray(` (${this.selectedIndex + 1}/${this.filteredItems.length})`); - lines.push(scrollInfo); - } - - return lines; - } - - handleInput(keyData: string): void { - // Up arrow - if (keyData === "\x1b[A") { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - } - // Down arrow - else if (keyData === "\x1b[B") { - this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1); - } - // Enter - else if (keyData === "\r") { - const selectedItem = this.filteredItems[this.selectedIndex]; - if (selectedItem && this.onSelect) { - this.onSelect(selectedItem); - } - } - // Escape - else if (keyData === "\x1b") { - if (this.onCancel) { - this.onCancel(); - } - } - } - - getSelectedItem(): SelectItem | null { - const item = this.filteredItems[this.selectedIndex]; - return item || null; - } -} diff --git a/packages/tui/src/components-new/editor.ts b/packages/tui/src/components/editor.ts similarity index 99% rename from packages/tui/src/components-new/editor.ts rename to packages/tui/src/components/editor.ts index 0474363b..92c28f54 100644 --- a/packages/tui/src/components-new/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,6 +1,6 @@ import chalk from "chalk"; import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; -import type { Component } from "../tui-new.js"; +import type { Component } from "../tui.js"; import { SelectList } from "./select-list.js"; interface EditorState { diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts new file mode 100644 index 00000000..e325010c --- /dev/null +++ b/packages/tui/src/components/input.ts @@ -0,0 +1,137 @@ +import { stripVTControlCharacters } from "node:util"; +import type { Component } from "../tui.js"; + +/** + * Input component - single-line text input with horizontal scrolling + */ +export class Input implements Component { + private value: string = ""; + private cursor: number = 0; // Cursor position in the value + public onSubmit?: (value: string) => void; + + getValue(): string { + return this.value; + } + + setValue(value: string): void { + this.value = value; + this.cursor = Math.min(this.cursor, value.length); + } + + handleInput(data: string): void { + // Handle special keys + if (data === "\r" || data === "\n") { + // Enter - submit + if (this.onSubmit) { + this.onSubmit(this.value); + } + return; + } + + if (data === "\x7f" || data === "\x08") { + // Backspace + if (this.cursor > 0) { + this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor); + this.cursor--; + } + return; + } + + if (data === "\x1b[D") { + // Left arrow + if (this.cursor > 0) { + this.cursor--; + } + return; + } + + if (data === "\x1b[C") { + // Right arrow + if (this.cursor < this.value.length) { + this.cursor++; + } + return; + } + + if (data === "\x1b[3~") { + // Delete + if (this.cursor < this.value.length) { + this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1); + } + return; + } + + if (data === "\x01") { + // Ctrl+A - beginning of line + this.cursor = 0; + return; + } + + if (data === "\x05") { + // Ctrl+E - end of line + this.cursor = this.value.length; + return; + } + + // Regular character input + if (data.length === 1 && data >= " " && data <= "~") { + this.value = this.value.slice(0, this.cursor) + data + this.value.slice(this.cursor); + this.cursor++; + } + } + + render(width: number): string[] { + // Calculate visible window + const prompt = "> "; + const availableWidth = width - prompt.length; + + if (availableWidth <= 0) { + return [prompt]; + } + + let visibleText = ""; + let cursorDisplay = this.cursor; + + if (this.value.length < availableWidth) { + // Everything fits (leave room for cursor at end) + visibleText = this.value; + } else { + // Need horizontal scrolling + // Reserve one character for cursor if it's at the end + const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth; + const halfWidth = Math.floor(scrollWidth / 2); + + if (this.cursor < halfWidth) { + // Cursor near start + visibleText = this.value.slice(0, scrollWidth); + cursorDisplay = this.cursor; + } else if (this.cursor > this.value.length - halfWidth) { + // Cursor near end + visibleText = this.value.slice(this.value.length - scrollWidth); + cursorDisplay = scrollWidth - (this.value.length - this.cursor); + } else { + // Cursor in middle + const start = this.cursor - halfWidth; + visibleText = this.value.slice(start, start + scrollWidth); + cursorDisplay = halfWidth; + } + } + + // Build line with fake cursor + // Insert cursor character at cursor position + const beforeCursor = visibleText.slice(0, cursorDisplay); + const atCursor = visibleText[cursorDisplay] || " "; // Character at cursor, or space if at end + const afterCursor = visibleText.slice(cursorDisplay + 1); + + // Use inverse video to show cursor + const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal + const textWithCursor = beforeCursor + cursorChar + afterCursor; + + // Calculate visual width (strip ANSI codes to measure actual displayed characters) + const visualLength = stripVTControlCharacters(textWithCursor).length; + const padding = " ".repeat(Math.max(0, availableWidth - visualLength)); + const line = prompt + textWithCursor + padding; + + return [line]; + } +} diff --git a/packages/tui/src/components-new/loader.ts b/packages/tui/src/components/loader.ts similarity index 93% rename from packages/tui/src/components-new/loader.ts rename to packages/tui/src/components/loader.ts index e694e441..39755706 100644 --- a/packages/tui/src/components-new/loader.ts +++ b/packages/tui/src/components/loader.ts @@ -1,5 +1,6 @@ import chalk from "chalk"; -import { Text, type TUI } from "../tui-new.js"; +import type { TUI } from "../tui.js"; +import { Text } from "./text.js"; /** * Loader component that updates every 80ms with spinning animation diff --git a/packages/tui/src/components/loading-animation.ts b/packages/tui/src/components/loading-animation.ts deleted file mode 100644 index f90a35aa..00000000 --- a/packages/tui/src/components/loading-animation.ts +++ /dev/null @@ -1,51 +0,0 @@ -import chalk from "chalk"; -import type { TUI } from "../tui.js"; -import { TextComponent } from "./text-component.js"; - -/** - * LoadingAnimation component that updates every 80ms - * Simulates the animation component that causes flicker in single-buffer mode - */ -export class LoadingAnimation extends TextComponent { - private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - private currentFrame = 0; - private intervalId: NodeJS.Timeout | null = null; - private ui: TUI | null = null; - - constructor( - ui: TUI, - private message: string = "Loading...", - ) { - super("", { bottom: 1 }); - this.ui = ui; - this.start(); - } - - start() { - this.updateDisplay(); - this.intervalId = setInterval(() => { - this.currentFrame = (this.currentFrame + 1) % this.frames.length; - this.updateDisplay(); - }, 80); - } - - stop() { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; - } - } - - setMessage(message: string) { - this.message = message; - this.updateDisplay(); - } - - private updateDisplay() { - const frame = this.frames[this.currentFrame]; - this.setText(`${chalk.cyan(frame)} ${chalk.dim(this.message)}`); - if (this.ui) { - this.ui.requestRender(); - } - } -} diff --git a/packages/tui/src/components/markdown-component.ts b/packages/tui/src/components/markdown-component.ts deleted file mode 100644 index 7e5a0fef..00000000 --- a/packages/tui/src/components/markdown-component.ts +++ /dev/null @@ -1,282 +0,0 @@ -import chalk from "chalk"; -import { marked, type Token } from "marked"; -import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js"; - -export class MarkdownComponent implements Component { - readonly id = getNextComponentId(); - private text: string; - private lines: string[] = []; - private previousLines: string[] = []; - - constructor(text: string = "") { - this.text = text; - } - - setText(text: string): void { - this.text = text; - } - - render(width: number): ComponentRenderResult { - // Parse markdown to HTML-like tokens - const tokens = marked.lexer(this.text); - - // Convert tokens to styled terminal output - const renderedLines: string[] = []; - - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]; - const nextToken = tokens[i + 1]; - const tokenLines = this.renderToken(token, width, nextToken?.type); - renderedLines.push(...tokenLines); - } - - // Wrap lines to fit width - const wrappedLines: string[] = []; - for (const line of renderedLines) { - wrappedLines.push(...this.wrapLine(line, width)); - } - - this.previousLines = this.lines; - this.lines = wrappedLines; - - // Determine if content changed - const changed = - this.lines.length !== this.previousLines.length || - this.lines.some((line, i) => line !== this.previousLines[i]); - - return { - lines: this.lines, - changed, - }; - } - - private renderToken(token: Token, width: number, nextTokenType?: string): string[] { - const lines: string[] = []; - - switch (token.type) { - case "heading": { - const headingLevel = token.depth; - const headingPrefix = "#".repeat(headingLevel) + " "; - const headingText = this.renderInlineTokens(token.tokens || []); - 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)); - } - lines.push(""); // Add spacing after headings - break; - } - - case "paragraph": { - const paragraphText = this.renderInlineTokens(token.tokens || []); - lines.push(paragraphText); - // Don't add spacing if next token is space or list - if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") { - lines.push(""); - } - break; - } - - case "code": { - lines.push(chalk.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(chalk.gray("```")); - lines.push(""); // Add spacing after code blocks - break; - } - - case "list": - for (let i = 0; i < token.items.length; i++) { - const item = token.items[i]; - const bullet = token.ordered ? `${i + 1}. ` : "- "; - const itemText = this.renderInlineTokens(item.tokens || []); - - // Check if the item text contains multiple lines (embedded content) - const itemLines = itemText.split("\n").filter((line) => line.trim()); - if (itemLines.length > 1) { - // First line is the list item - lines.push(chalk.cyan(bullet) + itemLines[0]); - // Rest are treated as separate content - for (let j = 1; j < itemLines.length; j++) { - lines.push(""); // Add spacing - lines.push(itemLines[j]); - } - } else { - lines.push(chalk.cyan(bullet) + itemText); - } - } - // Don't add spacing after lists if a space token follows - // (the space token will handle it) - break; - - case "blockquote": { - 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(""); // Add spacing after blockquotes - break; - } - - case "hr": - lines.push(chalk.gray("─".repeat(Math.min(width, 80)))); - lines.push(""); // Add spacing after horizontal rules - break; - - case "html": - // Skip HTML for terminal output - break; - - case "space": - // Space tokens represent blank lines in markdown - lines.push(""); - break; - - default: - // Handle any other token types as plain text - if ("text" in token && typeof token.text === "string") { - lines.push(token.text); - } - } - - return lines; - } - - private renderInlineTokens(tokens: Token[]): string { - let result = ""; - - for (const token of tokens) { - switch (token.type) { - case "text": - // Text tokens in list items can have nested tokens for inline formatting - if (token.tokens && token.tokens.length > 0) { - result += this.renderInlineTokens(token.tokens); - } else { - result += token.text; - } - break; - - case "strong": - result += chalk.bold(this.renderInlineTokens(token.tokens || [])); - break; - - case "em": - result += chalk.italic(this.renderInlineTokens(token.tokens || [])); - break; - - case "codespan": - result += chalk.gray("`") + chalk.cyan(token.text) + chalk.gray("`"); - break; - - case "link": { - const linkText = this.renderInlineTokens(token.tokens || []); - result += chalk.underline.blue(linkText) + chalk.gray(` (${token.href})`); - break; - } - - case "br": - result += "\n"; - break; - - case "del": - result += chalk.strikethrough(this.renderInlineTokens(token.tokens || [])); - break; - - default: - // Handle any other inline token types as plain text - if ("text" in token && typeof token.text === "string") { - result += token.text; - } - } - } - - 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 [""]; - } - - // If line fits within width, return as-is - const visibleLength = this.getVisibleLength(line); - if (visibleLength <= width) { - return [line]; - } - - // 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 - if (currentLength >= 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 += line[i]; - currentLength++; - i++; - } - } - - if (currentLine) { - wrapped.push(currentLine); - } - - return wrapped.length > 0 ? wrapped : [""]; - } - - private getVisibleLength(str: string): number { - // Remove ANSI escape codes and count visible characters - return (str || "").replace(/\x1b\[[0-9;]*m/g, "").length; - } -} diff --git a/packages/tui/src/components-new/markdown.ts b/packages/tui/src/components/markdown.ts similarity index 99% rename from packages/tui/src/components-new/markdown.ts rename to packages/tui/src/components/markdown.ts index 6e79629c..8cc49225 100644 --- a/packages/tui/src/components-new/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -1,7 +1,7 @@ import { stripVTControlCharacters } from "node:util"; import chalk from "chalk"; import { marked, type Token } from "marked"; -import type { Component } from "../tui-new.js"; +import type { Component } from "../tui.js"; type Color = | "black" diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index d0f09eca..1be4b1f4 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -1,5 +1,5 @@ import chalk from "chalk"; -import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js"; +import type { Component } from "../tui.js"; export interface SelectItem { value: string; @@ -8,7 +8,6 @@ export interface SelectItem { } export class SelectList implements Component { - readonly id = getNextComponentId(); private items: SelectItem[] = []; private filteredItems: SelectItem[] = []; private selectedIndex: number = 0; @@ -31,13 +30,13 @@ export class SelectList implements Component { this.selectedIndex = 0; } - render(width: number): ComponentRenderResult { + render(width: number): string[] { const lines: string[] = []; // If no items match filter, show message if (this.filteredItems.length === 0) { lines.push(chalk.gray(" No matching commands")); - return { lines, changed: true }; + return lines; } // Calculate visible range with scrolling @@ -121,7 +120,7 @@ export class SelectList implements Component { lines.push(scrollInfo); } - return { lines, changed: true }; + return lines; } handleInput(keyData: string): void { diff --git a/packages/tui/src/components-new/spacer.ts b/packages/tui/src/components/spacer.ts similarity index 89% rename from packages/tui/src/components-new/spacer.ts rename to packages/tui/src/components/spacer.ts index 0673ef62..868ea617 100644 --- a/packages/tui/src/components-new/spacer.ts +++ b/packages/tui/src/components/spacer.ts @@ -1,4 +1,4 @@ -import type { Component } from "../tui-new.js"; +import type { Component } from "../tui.js"; /** * Spacer component that renders empty lines diff --git a/packages/tui/src/components/text-component.ts b/packages/tui/src/components/text-component.ts deleted file mode 100644 index 43ba1818..00000000 --- a/packages/tui/src/components/text-component.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { type Component, type ComponentRenderResult, getNextComponentId, type Padding } from "../tui.js"; - -export class TextComponent implements Component { - readonly id = getNextComponentId(); - private text: string; - private lastRenderedLines: string[] = []; - private padding: Required; - - constructor(text: string, padding?: Padding) { - this.text = text; - this.padding = { - top: padding?.top ?? 0, - bottom: padding?.bottom ?? 0, - left: padding?.left ?? 0, - right: padding?.right ?? 0, - }; - } - - render(width: number): ComponentRenderResult { - // Calculate available width after horizontal padding - const availableWidth = Math.max(1, width - this.padding.left - this.padding.right); - const leftPadding = " ".repeat(this.padding.left); - - // First split by newlines to preserve line breaks - const textLines = this.text.split("\n"); - const lines: string[] = []; - - // Add top padding - for (let i = 0; i < this.padding.top; i++) { - lines.push(""); - } - - // Process each line for word wrapping - for (const textLine of textLines) { - if (textLine.length === 0) { - // Preserve empty lines with padding - lines.push(leftPadding); - } else { - // Word wrapping with ANSI-aware length calculation - const words = textLine.split(" "); - let currentLine = ""; - let currentVisibleLength = 0; - - for (const word of words) { - const wordVisibleLength = this.getVisibleLength(word); - const spaceLength = currentLine ? 1 : 0; - - if (currentVisibleLength + spaceLength + wordVisibleLength <= availableWidth) { - currentLine += (currentLine ? " " : "") + word; - currentVisibleLength += spaceLength + wordVisibleLength; - } else { - if (currentLine) { - lines.push(leftPadding + currentLine); - } - currentLine = word; - currentVisibleLength = wordVisibleLength; - } - } - - if (currentLine) { - lines.push(leftPadding + currentLine); - } - } - } - - // Add bottom padding - for (let i = 0; i < this.padding.bottom; i++) { - lines.push(""); - } - - const newLines = lines.length > 0 ? lines : [""]; - - // Check if content changed - const changed = !this.arraysEqual(newLines, this.lastRenderedLines); - - // Always cache the current rendered lines - this.lastRenderedLines = [...newLines]; - - return { - lines: newLines, - changed, - }; - } - - setText(text: string): void { - this.text = text; - } - - getText(): string { - return this.text; - } - - private arraysEqual(a: string[], b: string[]): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; - } - return true; - } - - private getVisibleLength(str: string): number { - // Remove ANSI escape codes and count visible characters - return (str || "").replace(/\x1b\[[0-9;]*m/g, "").length; - } -} diff --git a/packages/tui/src/components/text-editor.ts b/packages/tui/src/components/text-editor.ts deleted file mode 100644 index dd133615..00000000 --- a/packages/tui/src/components/text-editor.ts +++ /dev/null @@ -1,714 +0,0 @@ -import chalk from "chalk"; -import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; -import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js"; -import { SelectList } from "./select-list.js"; - -interface EditorState { - lines: string[]; - cursorLine: number; - cursorCol: number; -} - -interface LayoutLine { - text: string; - hasCursor: boolean; - cursorPos?: number; -} - -export interface TextEditorConfig { - // Configuration options for text editor (none currently) -} - -export class TextEditor implements Component { - readonly id = getNextComponentId(); - private state: EditorState = { - lines: [""], - cursorLine: 0, - cursorCol: 0, - }; - - private config: TextEditorConfig = {}; - - // Autocomplete support - private autocompleteProvider?: AutocompleteProvider; - private autocompleteList?: SelectList; - private isAutocompleting: boolean = false; - private autocompletePrefix: string = ""; - - public onSubmit?: (text: string) => void; - public onChange?: (text: string) => void; - public disableSubmit: boolean = false; - - constructor(config?: TextEditorConfig) { - if (config) { - this.config = { ...this.config, ...config }; - } - } - - configure(config: Partial): void { - this.config = { ...this.config, ...config }; - } - - setAutocompleteProvider(provider: AutocompleteProvider): void { - this.autocompleteProvider = provider; - } - - render(width: number): ComponentRenderResult { - // Box drawing characters - const topLeft = chalk.gray("╭"); - const topRight = chalk.gray("╮"); - const bottomLeft = chalk.gray("╰"); - const bottomRight = chalk.gray("╯"); - const horizontal = chalk.gray("─"); - const vertical = chalk.gray("│"); - - // Calculate box width - leave 1 char margin to avoid edge wrapping - const boxWidth = width - 1; - const contentWidth = boxWidth - 4; // Account for "│ " and " │" - - // Layout the text - const layoutLines = this.layoutText(contentWidth); - - const result: string[] = []; - - // Render top border - result.push(topLeft + horizontal.repeat(boxWidth - 2) + topRight); - - // Render each layout line - for (const layoutLine of layoutLines) { - let displayText = layoutLine.text; - let visibleLength = layoutLine.text.length; - - // Add cursor if this line has it - if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) { - const before = displayText.slice(0, layoutLine.cursorPos); - const after = displayText.slice(layoutLine.cursorPos); - - if (after.length > 0) { - // Cursor is on a character - replace it with highlighted version - const cursor = `\x1b[7m${after[0]}\x1b[0m`; - const restAfter = after.slice(1); - displayText = before + cursor + restAfter; - // visibleLength stays the same - we're replacing, not adding - } else { - // Cursor is at the end - add highlighted space - const cursor = "\x1b[7m \x1b[0m"; - displayText = before + cursor; - // visibleLength increases by 1 - we're adding a space - visibleLength = layoutLine.text.length + 1; - } - } - - // Calculate padding based on actual visible length - const padding = " ".repeat(Math.max(0, contentWidth - visibleLength)); - - // Render the line - result.push(`${vertical} ${displayText}${padding} ${vertical}`); - } - - // Render bottom border - result.push(bottomLeft + horizontal.repeat(boxWidth - 2) + bottomRight); - - // Add autocomplete list if active - if (this.isAutocompleting && this.autocompleteList) { - const autocompleteResult = this.autocompleteList.render(width); - result.push(...autocompleteResult.lines); - } - - // For interactive components like text editors, always assume changed - // This ensures cursor position updates are always reflected - return { - lines: result, - changed: true, - }; - } - - handleInput(data: string): void { - // Handle special key combinations first - - // Ctrl+C - Exit (let parent handle this) - if (data.charCodeAt(0) === 3) { - return; - } - - // Handle paste - detect when we get a lot of text at once - const isPaste = data.length > 10 || (data.length > 2 && data.includes("\n")); - if (isPaste) { - this.handlePaste(data); - return; - } - - // Handle autocomplete special keys first (but don't block other input) - if (this.isAutocompleting && this.autocompleteList) { - // Escape - cancel autocomplete - if (data === "\x1b") { - this.cancelAutocomplete(); - return; - } - // Let the autocomplete list handle navigation and selection - else if (data === "\x1b[A" || data === "\x1b[B" || data === "\r" || data === "\t") { - // Only pass arrow keys to the list, not Enter/Tab (we handle those directly) - if (data === "\x1b[A" || data === "\x1b[B") { - this.autocompleteList.handleInput(data); - } - - // If Tab was pressed, apply the selection - if (data === "\t") { - const selected = this.autocompleteList.getSelectedItem(); - if (selected && this.autocompleteProvider) { - const result = this.autocompleteProvider.applyCompletion( - this.state.lines, - this.state.cursorLine, - this.state.cursorCol, - selected, - this.autocompletePrefix, - ); - - this.state.lines = result.lines; - this.state.cursorLine = result.cursorLine; - this.state.cursorCol = result.cursorCol; - - this.cancelAutocomplete(); - - if (this.onChange) { - this.onChange(this.getText()); - } - } - return; - } - // If Enter was pressed, cancel autocomplete and let it fall through to submission - else if (data === "\r") { - this.cancelAutocomplete(); - // Don't return here - let Enter fall through to normal submission handling - } else { - // For other keys, handle normally within autocomplete - return; - } - } - // For other keys (like regular typing), DON'T return here - // Let them fall through to normal character handling - } - - // Tab key - context-aware completion (but not when already autocompleting) - if (data === "\t" && !this.isAutocompleting) { - this.handleTabCompletion(); - return; - } - - // Continue with rest of input handling - // Ctrl+K - Delete current line - if (data.charCodeAt(0) === 11) { - this.deleteCurrentLine(); - } - // Ctrl+A - Move to start of line - else if (data.charCodeAt(0) === 1) { - this.moveToLineStart(); - } - // Ctrl+E - Move to end of line - else if (data.charCodeAt(0) === 5) { - this.moveToLineEnd(); - } - // New line shortcuts (but not plain LF/CR which should be submit) - else if ( - (data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers - data === "\x1b\r" || // Option+Enter in some terminals - data === "\x1b[13;2~" || // Shift+Enter in some terminals - (data.length > 1 && data.includes("\x1b") && data.includes("\r")) || - (data === "\n" && data.length === 1) || // Shift+Enter from iTerm2 mapping - data === "\\\r" // Shift+Enter in VS Code terminal - ) { - // Modifier + Enter = new line - this.addNewLine(); - } - // Plain Enter (char code 13 for CR) - only CR submits, LF adds new line - else if (data.charCodeAt(0) === 13 && data.length === 1) { - // If submit is disabled, do nothing - if (this.disableSubmit) { - return; - } - - // Plain Enter = submit - const result = this.state.lines.join("\n").trim(); - - // Reset editor - this.state = { - lines: [""], - cursorLine: 0, - cursorCol: 0, - }; - - // Notify that editor is now empty - if (this.onChange) { - this.onChange(""); - } - - if (this.onSubmit) { - this.onSubmit(result); - } - } - // Backspace - else if (data.charCodeAt(0) === 127 || data.charCodeAt(0) === 8) { - this.handleBackspace(); - } - // Line navigation shortcuts (Home/End keys) - else if (data === "\x1b[H" || data === "\x1b[1~" || data === "\x1b[7~") { - // Home key - this.moveToLineStart(); - } else if (data === "\x1b[F" || data === "\x1b[4~" || data === "\x1b[8~") { - // End key - this.moveToLineEnd(); - } - // Forward delete (Fn+Backspace or Delete key) - else if (data === "\x1b[3~") { - // Delete key - this.handleForwardDelete(); - } - // Arrow keys - else if (data === "\x1b[A") { - // Up - this.moveCursor(-1, 0); - } else if (data === "\x1b[B") { - // Down - this.moveCursor(1, 0); - } else if (data === "\x1b[C") { - // Right - this.moveCursor(0, 1); - } else if (data === "\x1b[D") { - // Left - this.moveCursor(0, -1); - } - // Regular characters (printable ASCII) - else if (data.charCodeAt(0) >= 32 && data.charCodeAt(0) <= 126) { - this.insertCharacter(data); - } - } - - private layoutText(contentWidth: number): LayoutLine[] { - const layoutLines: LayoutLine[] = []; - - if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) { - // Empty editor - layoutLines.push({ - text: "> ", - hasCursor: true, - cursorPos: 2, - }); - return layoutLines; - } - - // Process each logical line - for (let i = 0; i < this.state.lines.length; i++) { - const line = this.state.lines[i] || ""; - const isCurrentLine = i === this.state.cursorLine; - const prefix = i === 0 ? "> " : " "; - const prefixedLine = prefix + line; - const maxLineLength = contentWidth; - - if (prefixedLine.length <= maxLineLength) { - // Line fits in one layout line - if (isCurrentLine) { - layoutLines.push({ - text: prefixedLine, - hasCursor: true, - cursorPos: prefix.length + this.state.cursorCol, - }); - } else { - layoutLines.push({ - text: prefixedLine, - hasCursor: false, - }); - } - } else { - // Line needs wrapping - const chunks = []; - for (let pos = 0; pos < prefixedLine.length; pos += maxLineLength) { - chunks.push(prefixedLine.slice(pos, pos + maxLineLength)); - } - - for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { - const chunk = chunks[chunkIndex]; - if (!chunk) continue; - - const chunkStart = chunkIndex * maxLineLength; - const chunkEnd = chunkStart + chunk.length; - const cursorPos = prefix.length + this.state.cursorCol; - const hasCursorInChunk = isCurrentLine && cursorPos >= chunkStart && cursorPos < chunkEnd; - - if (hasCursorInChunk) { - layoutLines.push({ - text: chunk, - hasCursor: true, - cursorPos: cursorPos - chunkStart, - }); - } else { - layoutLines.push({ - text: chunk, - hasCursor: false, - }); - } - } - } - } - - return layoutLines; - } - - getText(): string { - return this.state.lines.join("\n"); - } - - setText(text: string): void { - // Split text into lines, handling different line endings - const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); - - // Ensure at least one empty line - this.state.lines = lines.length === 0 ? [""] : lines; - - // Reset cursor to end of text - this.state.cursorLine = this.state.lines.length - 1; - this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0; - - // Notify of change - if (this.onChange) { - this.onChange(this.getText()); - } - } - - // All the editor methods from before... - private insertCharacter(char: string): void { - const line = this.state.lines[this.state.cursorLine] || ""; - - const before = line.slice(0, this.state.cursorCol); - const after = line.slice(this.state.cursorCol); - - this.state.lines[this.state.cursorLine] = before + char + after; - this.state.cursorCol += char.length; // Fix: increment by the length of the inserted string - - if (this.onChange) { - this.onChange(this.getText()); - } - - // Check if we should trigger or update autocomplete - if (!this.isAutocompleting) { - // Auto-trigger for "/" at the start of a line (slash commands) - if (char === "/" && this.isAtStartOfMessage()) { - this.tryTriggerAutocomplete(); - } - // Also auto-trigger when typing letters in a slash command context - else if (/[a-zA-Z0-9]/.test(char)) { - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); - // Check if we're in a slash command with a space (i.e., typing arguments) - if (textBeforeCursor.startsWith("/") && textBeforeCursor.includes(" ")) { - this.tryTriggerAutocomplete(); - } - } - } else { - this.updateAutocomplete(); - } - } - - private handlePaste(pastedText: string): void { - // Clean the pasted text - const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - - // Convert tabs to spaces (4 spaces per tab) - const tabExpandedText = cleanText.replace(/\t/g, " "); - - // Filter out non-printable characters except newlines - const filteredText = tabExpandedText - .split("") - .filter((char) => char === "\n" || (char >= " " && char <= "~")) - .join(""); - - // Split into lines - const pastedLines = filteredText.split("\n"); - - if (pastedLines.length === 1) { - // Single line - just insert each character - const text = pastedLines[0] || ""; - for (const char of text) { - this.insertCharacter(char); - } - - return; - } - - // Multi-line paste - be very careful with array manipulation - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const beforeCursor = currentLine.slice(0, this.state.cursorCol); - const afterCursor = currentLine.slice(this.state.cursorCol); - - // Build the new lines array step by step - const newLines: string[] = []; - - // Add all lines before current line - for (let i = 0; i < this.state.cursorLine; i++) { - newLines.push(this.state.lines[i] || ""); - } - - // Add the first pasted line merged with before cursor text - newLines.push(beforeCursor + (pastedLines[0] || "")); - - // Add all middle pasted lines - for (let i = 1; i < pastedLines.length - 1; i++) { - newLines.push(pastedLines[i] || ""); - } - - // Add the last pasted line with after cursor text - newLines.push((pastedLines[pastedLines.length - 1] || "") + afterCursor); - - // Add all lines after current line - for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) { - newLines.push(this.state.lines[i] || ""); - } - - // Replace the entire lines array - this.state.lines = newLines; - - // Update cursor position to end of pasted content - this.state.cursorLine += pastedLines.length - 1; - this.state.cursorCol = (pastedLines[pastedLines.length - 1] || "").length; - - // Notify of change - if (this.onChange) { - this.onChange(this.getText()); - } - } - - private addNewLine(): void { - const currentLine = this.state.lines[this.state.cursorLine] || ""; - - const before = currentLine.slice(0, this.state.cursorCol); - const after = currentLine.slice(this.state.cursorCol); - - // Split current line - this.state.lines[this.state.cursorLine] = before; - this.state.lines.splice(this.state.cursorLine + 1, 0, after); - - // Move cursor to start of new line - this.state.cursorLine++; - this.state.cursorCol = 0; - - if (this.onChange) { - this.onChange(this.getText()); - } - } - - private handleBackspace(): void { - if (this.state.cursorCol > 0) { - // Delete character in current line - const line = this.state.lines[this.state.cursorLine] || ""; - - const before = line.slice(0, this.state.cursorCol - 1); - const after = line.slice(this.state.cursorCol); - - this.state.lines[this.state.cursorLine] = before + after; - this.state.cursorCol--; - } else if (this.state.cursorLine > 0) { - // Merge with previous line - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const previousLine = this.state.lines[this.state.cursorLine - 1] || ""; - - this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; - this.state.lines.splice(this.state.cursorLine, 1); - - this.state.cursorLine--; - this.state.cursorCol = previousLine.length; - } - - if (this.onChange) { - this.onChange(this.getText()); - } - - // Update autocomplete after backspace - if (this.isAutocompleting) { - this.updateAutocomplete(); - } - } - - private moveToLineStart(): void { - this.state.cursorCol = 0; - } - - private moveToLineEnd(): void { - const currentLine = this.state.lines[this.state.cursorLine] || ""; - this.state.cursorCol = currentLine.length; - } - - private handleForwardDelete(): void { - const currentLine = this.state.lines[this.state.cursorLine] || ""; - - if (this.state.cursorCol < currentLine.length) { - // Delete character at cursor position (forward delete) - const before = currentLine.slice(0, this.state.cursorCol); - const after = currentLine.slice(this.state.cursorCol + 1); - this.state.lines[this.state.cursorLine] = before + after; - } else if (this.state.cursorLine < this.state.lines.length - 1) { - // At end of line - merge with next line - const nextLine = this.state.lines[this.state.cursorLine + 1] || ""; - this.state.lines[this.state.cursorLine] = currentLine + nextLine; - this.state.lines.splice(this.state.cursorLine + 1, 1); - } - - if (this.onChange) { - this.onChange(this.getText()); - } - } - - private deleteCurrentLine(): void { - if (this.state.lines.length === 1) { - // Only one line - just clear it - this.state.lines[0] = ""; - this.state.cursorCol = 0; - } else { - // Multiple lines - remove current line - this.state.lines.splice(this.state.cursorLine, 1); - - // Adjust cursor position - if (this.state.cursorLine >= this.state.lines.length) { - // Was on last line, move to new last line - this.state.cursorLine = this.state.lines.length - 1; - } - - // Clamp cursor column to new line length - const newLine = this.state.lines[this.state.cursorLine] || ""; - this.state.cursorCol = Math.min(this.state.cursorCol, newLine.length); - } - - if (this.onChange) { - this.onChange(this.getText()); - } - } - - private moveCursor(deltaLine: number, deltaCol: number): void { - if (deltaLine !== 0) { - const newLine = this.state.cursorLine + deltaLine; - if (newLine >= 0 && newLine < this.state.lines.length) { - this.state.cursorLine = newLine; - // Clamp cursor column to new line length - const line = this.state.lines[this.state.cursorLine] || ""; - this.state.cursorCol = Math.min(this.state.cursorCol, line.length); - } - } - - if (deltaCol !== 0) { - // Move column - const newCol = this.state.cursorCol + deltaCol; - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const maxCol = currentLine.length; - this.state.cursorCol = Math.max(0, Math.min(maxCol, newCol)); - } - } - - // Helper method to check if cursor is at start of message (for slash command detection) - private isAtStartOfMessage(): boolean { - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const beforeCursor = currentLine.slice(0, this.state.cursorCol); - - // At start if line is empty, only contains whitespace, or is just "/" - return beforeCursor.trim() === "" || beforeCursor.trim() === "/"; - } - - // Autocomplete methods - private tryTriggerAutocomplete(explicitTab: boolean = false): void { - if (!this.autocompleteProvider) return; - - // Check if we should trigger file completion on Tab - if (explicitTab) { - const provider = this.autocompleteProvider as CombinedAutocompleteProvider; - const shouldTrigger = - !provider.shouldTriggerFileCompletion || - provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol); - if (!shouldTrigger) { - return; - } - } - - const suggestions = this.autocompleteProvider.getSuggestions( - this.state.lines, - this.state.cursorLine, - this.state.cursorCol, - ); - - if (suggestions && suggestions.items.length > 0) { - this.autocompletePrefix = suggestions.prefix; - this.autocompleteList = new SelectList(suggestions.items, 5); - this.isAutocompleting = true; - } else { - this.cancelAutocomplete(); - } - } - - private handleTabCompletion(): void { - if (!this.autocompleteProvider) return; - - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const beforeCursor = currentLine.slice(0, this.state.cursorCol); - - // Check if we're in a slash command context - if (beforeCursor.trimStart().startsWith("/")) { - this.handleSlashCommandCompletion(); - } else { - this.forceFileAutocomplete(); - } - } - - private handleSlashCommandCompletion(): void { - // For now, fall back to regular autocomplete (slash commands) - // This can be extended later to handle command-specific argument completion - this.tryTriggerAutocomplete(true); - } - - private forceFileAutocomplete(): void { - if (!this.autocompleteProvider) return; - - // Check if provider has the force method - const provider = this.autocompleteProvider as any; - if (!provider.getForceFileSuggestions) { - this.tryTriggerAutocomplete(true); - return; - } - - const suggestions = provider.getForceFileSuggestions( - this.state.lines, - this.state.cursorLine, - this.state.cursorCol, - ); - - if (suggestions && suggestions.items.length > 0) { - this.autocompletePrefix = suggestions.prefix; - this.autocompleteList = new SelectList(suggestions.items, 5); - this.isAutocompleting = true; - } else { - this.cancelAutocomplete(); - } - } - - private cancelAutocomplete(): void { - this.isAutocompleting = false; - this.autocompleteList = undefined as any; - this.autocompletePrefix = ""; - } - - private updateAutocomplete(): void { - if (!this.isAutocompleting || !this.autocompleteProvider) return; - - const suggestions = this.autocompleteProvider.getSuggestions( - this.state.lines, - this.state.cursorLine, - this.state.cursorCol, - ); - - if (suggestions && suggestions.items.length > 0) { - this.autocompletePrefix = suggestions.prefix; - if (this.autocompleteList) { - // Update the existing list with new items - this.autocompleteList = new SelectList(suggestions.items, 5); - } - } else { - // No more matches, cancel autocomplete - this.cancelAutocomplete(); - } - } -} diff --git a/packages/tui/src/components/text.ts b/packages/tui/src/components/text.ts new file mode 100644 index 00000000..6a5d654c --- /dev/null +++ b/packages/tui/src/components/text.ts @@ -0,0 +1,113 @@ +import { stripVTControlCharacters } from "node:util"; +import type { Component } from "../tui.js"; + +/** + * Text component - displays multi-line text with word wrapping + */ +export class Text implements Component { + private text: string; + private paddingX: number; // Left/right padding + private paddingY: number; // Top/bottom padding + + // Cache for rendered output + private cachedText?: string; + private cachedWidth?: number; + private cachedLines?: string[]; + + constructor(text: string = "", paddingX: number = 1, paddingY: number = 1) { + this.text = text; + this.paddingX = paddingX; + this.paddingY = paddingY; + } + + setText(text: string): void { + this.text = text; + // Invalidate cache when text 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) { + return this.cachedLines; + } + + // Calculate available width for content (subtract horizontal padding) + const contentWidth = Math.max(1, width - this.paddingX * 2); + + if (!this.text) { + const result = [""]; + // Update cache + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = result; + return result; + } + + const lines: string[] = []; + const textLines = this.text.split("\n"); + + for (const line of textLines) { + if (line.length <= contentWidth) { + lines.push(line); + } else { + // Word wrap + const words = line.split(" "); + let currentLine = ""; + + for (const word of words) { + if (currentLine.length === 0) { + currentLine = word; + } else if (currentLine.length + 1 + word.length <= contentWidth) { + currentLine += " " + word; + } else { + lines.push(currentLine); + currentLine = word; + } + } + + if (currentLine.length > 0) { + lines.push(currentLine); + } + } + } + + // 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 = stripVTControlCharacters(line).length; + // 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); + paddedLines.push(leftPad + line + rightPad); + } + + // Add top padding (empty lines) + const emptyLine = " ".repeat(width); + const topPadding: string[] = []; + for (let i = 0; i < this.paddingY; i++) { + topPadding.push(emptyLine); + } + + // Add bottom padding (empty lines) + const bottomPadding: string[] = []; + for (let i = 0; i < this.paddingY; i++) { + bottomPadding.push(emptyLine); + } + + // Combine top padding, content, and bottom padding + const result = [...topPadding, ...paddedLines, ...bottomPadding]; + + // Update cache + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = result; + + return result.length > 0 ? result : [""]; + } +} diff --git a/packages/tui/src/components/whitespace-component.ts b/packages/tui/src/components/whitespace-component.ts deleted file mode 100644 index 3d50ed62..00000000 --- a/packages/tui/src/components/whitespace-component.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js"; - -/** - * A simple component that renders blank lines for spacing - */ -export class WhitespaceComponent implements Component { - readonly id = getNextComponentId(); - private lines: string[] = []; - private lineCount: number; - private firstRender: boolean = true; - - constructor(lineCount: number = 1) { - this.lineCount = Math.max(0, lineCount); // Ensure non-negative - this.lines = new Array(this.lineCount).fill(""); - } - - render(_width: number): ComponentRenderResult { - const result = { - lines: this.lines, - changed: this.firstRender, - }; - this.firstRender = false; - return result; - } -} diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 0e9c61e9..6d8b8346 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -7,25 +7,14 @@ export { CombinedAutocompleteProvider, type SlashCommand, } from "./autocomplete.js"; -// Loading animation component -export { LoadingAnimation } from "./components/loading-animation.js"; -// Markdown component -export { MarkdownComponent } from "./components/markdown-component.js"; -// Select list component +// Components +export { Editor, type TextEditorConfig } from "./components/editor.js"; +export { Input } from "./components/input.js"; +export { Loader } from "./components/loader.js"; +export { Markdown } from "./components/markdown.js"; export { type SelectItem, SelectList } from "./components/select-list.js"; -// Text component -export { TextComponent } from "./components/text-component.js"; -// Text editor component -export { TextEditor, type TextEditorConfig } from "./components/text-editor.js"; -// Whitespace component -export { WhitespaceComponent } from "./components/whitespace-component.js"; +export { Spacer } from "./components/spacer.js"; +export { Text } from "./components/text.js"; // Terminal interface and implementations export { ProcessTerminal, type Terminal } from "./terminal.js"; -export { - type Component, - type ComponentRenderResult, - Container, - getNextComponentId, - type Padding, - TUI, -} from "./tui.js"; +export { Component, Container, TUI } from "./tui.js"; diff --git a/packages/tui/src/tui-new.ts b/packages/tui/src/tui-new.ts deleted file mode 100644 index 4b09bc8e..00000000 --- a/packages/tui/src/tui-new.ts +++ /dev/null @@ -1,450 +0,0 @@ -/** - * Minimal TUI implementation with differential rendering - */ - -import { stripVTControlCharacters } from "node:util"; -import type { Terminal } from "./terminal.js"; - -/** - * Component interface - all components must implement this - */ -export interface Component { - /** - * Render the component to lines for the given viewport width - * @param width - Current viewport width - * @returns Array of strings, each representing a line - */ - render(width: number): string[]; - - /** - * Optional handler for keyboard input when component has focus - */ - handleInput?(data: string): void; -} - -/** - * Container - a component that contains other components - */ -export class Container implements Component { - children: Component[] = []; - - addChild(component: Component): void { - this.children.push(component); - } - - removeChild(component: Component): void { - const index = this.children.indexOf(component); - if (index !== -1) { - this.children.splice(index, 1); - } - } - - clear(): void { - this.children = []; - } - - render(width: number): string[] { - const lines: string[] = []; - for (const child of this.children) { - lines.push(...child.render(width)); - } - return lines; - } -} - -/** - * Text component - displays multi-line text with word wrapping - */ -export class Text implements Component { - private paddingX: number; // Left/right padding - private paddingY: number; // Top/bottom padding - - constructor( - private text: string = "", - paddingX: number = 1, - paddingY: number = 1, - ) { - this.paddingX = paddingX; - this.paddingY = paddingY; - } - - setText(text: string): void { - this.text = text; - } - - render(width: number): string[] { - // Calculate available width for content (subtract horizontal padding) - const contentWidth = Math.max(1, width - this.paddingX * 2); - - if (!this.text) { - return [""]; - } - - const lines: string[] = []; - const textLines = this.text.split("\n"); - - for (const line of textLines) { - if (line.length <= contentWidth) { - lines.push(line); - } else { - // Word wrap - const words = line.split(" "); - let currentLine = ""; - - for (const word of words) { - if (currentLine.length === 0) { - currentLine = word; - } else if (currentLine.length + 1 + word.length <= contentWidth) { - currentLine += " " + word; - } else { - lines.push(currentLine); - currentLine = word; - } - } - - if (currentLine.length > 0) { - lines.push(currentLine); - } - } - } - - // Add padding to each line - const leftPad = " ".repeat(this.paddingX); - const paddedLines: string[] = []; - - for (const line of lines) { - const rightPadLength = Math.max(0, width - this.paddingX - line.length); - const rightPad = " ".repeat(rightPadLength); - paddedLines.push(leftPad + line + rightPad); - } - - // Add top padding (empty lines) - const emptyLine = " ".repeat(width); - const topPadding: string[] = []; - for (let i = 0; i < this.paddingY; i++) { - topPadding.push(emptyLine); - } - - // Add bottom padding (empty lines) - const bottomPadding: string[] = []; - for (let i = 0; i < this.paddingY; i++) { - bottomPadding.push(emptyLine); - } - - // Combine top padding, content, and bottom padding - const result = [...topPadding, ...paddedLines, ...bottomPadding]; - - return result.length > 0 ? result : [""]; - } -} - -/** - * Input component - single-line text input with horizontal scrolling - */ -export class Input implements Component { - private value: string = ""; - private cursor: number = 0; // Cursor position in the value - public onSubmit?: (value: string) => void; - - getValue(): string { - return this.value; - } - - setValue(value: string): void { - this.value = value; - this.cursor = Math.min(this.cursor, value.length); - } - - handleInput(data: string): void { - // Handle special keys - if (data === "\r" || data === "\n") { - // Enter - submit - if (this.onSubmit) { - this.onSubmit(this.value); - } - return; - } - - if (data === "\x7f" || data === "\x08") { - // Backspace - if (this.cursor > 0) { - this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor); - this.cursor--; - } - return; - } - - if (data === "\x1b[D") { - // Left arrow - if (this.cursor > 0) { - this.cursor--; - } - return; - } - - if (data === "\x1b[C") { - // Right arrow - if (this.cursor < this.value.length) { - this.cursor++; - } - return; - } - - if (data === "\x1b[3~") { - // Delete - if (this.cursor < this.value.length) { - this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1); - } - return; - } - - if (data === "\x01") { - // Ctrl+A - beginning of line - this.cursor = 0; - return; - } - - if (data === "\x05") { - // Ctrl+E - end of line - this.cursor = this.value.length; - return; - } - - // Regular character input - if (data.length === 1 && data >= " " && data <= "~") { - this.value = this.value.slice(0, this.cursor) + data + this.value.slice(this.cursor); - this.cursor++; - } - } - - render(width: number): string[] { - // Calculate visible window - const prompt = "> "; - const availableWidth = width - prompt.length; - - if (availableWidth <= 0) { - return [prompt]; - } - - let visibleText = ""; - let cursorDisplay = this.cursor; - - if (this.value.length < availableWidth) { - // Everything fits (leave room for cursor at end) - visibleText = this.value; - } else { - // Need horizontal scrolling - // Reserve one character for cursor if it's at the end - const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth; - const halfWidth = Math.floor(scrollWidth / 2); - - if (this.cursor < halfWidth) { - // Cursor near start - visibleText = this.value.slice(0, scrollWidth); - cursorDisplay = this.cursor; - } else if (this.cursor > this.value.length - halfWidth) { - // Cursor near end - visibleText = this.value.slice(this.value.length - scrollWidth); - cursorDisplay = scrollWidth - (this.value.length - this.cursor); - } else { - // Cursor in middle - const start = this.cursor - halfWidth; - visibleText = this.value.slice(start, start + scrollWidth); - cursorDisplay = halfWidth; - } - } - - // Build line with fake cursor - // Insert cursor character at cursor position - const beforeCursor = visibleText.slice(0, cursorDisplay); - const atCursor = visibleText[cursorDisplay] || " "; // Character at cursor, or space if at end - const afterCursor = visibleText.slice(cursorDisplay + 1); - - // Use inverse video to show cursor - const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal - const textWithCursor = beforeCursor + cursorChar + afterCursor; - - // Calculate visual width (strip ANSI codes to measure actual displayed characters) - const visualLength = stripVTControlCharacters(textWithCursor).length; - const padding = " ".repeat(Math.max(0, availableWidth - visualLength)); - const line = prompt + textWithCursor + padding; - - return [line]; - } -} - -/** - * TUI - Main class for managing terminal UI with differential rendering - */ -export class TUI extends Container { - private terminal: Terminal; - private previousLines: string[] = []; - private previousWidth = 0; - private focusedComponent: Component | null = null; - private renderRequested = false; - private cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line) - - constructor(terminal: Terminal) { - super(); - this.terminal = terminal; - } - - setFocus(component: Component | null): void { - this.focusedComponent = component; - } - - start(): void { - this.terminal.start( - (data) => this.handleInput(data), - () => this.requestRender(), - ); - this.terminal.hideCursor(); - this.requestRender(); - } - - stop(): void { - this.terminal.showCursor(); - this.terminal.stop(); - } - - requestRender(): void { - if (this.renderRequested) return; - this.renderRequested = true; - process.nextTick(() => { - this.renderRequested = false; - this.doRender(); - }); - } - - private handleInput(data: string): void { - // Exit on Ctrl+C - if (data === "\x03") { - this.stop(); - process.exit(0); - } - - // Pass input to focused component - if (this.focusedComponent?.handleInput) { - this.focusedComponent.handleInput(data); - this.requestRender(); - } - } - - private doRender(): void { - const width = this.terminal.columns; - const height = this.terminal.rows; - - // Render all components to get new lines - const newLines = this.render(width); - - // Width changed - need full re-render - const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width; - - // First render - just output everything without clearing - if (this.previousLines.length === 0) { - let buffer = "\x1b[?2026h"; // Begin synchronized output - for (let i = 0; i < newLines.length; i++) { - if (i > 0) buffer += "\r\n"; - buffer += newLines[i]; - } - buffer += "\x1b[?2026l"; // End synchronized output - this.terminal.write(buffer); - // After rendering N lines, cursor is at end of last line (line N-1) - this.cursorRow = newLines.length - 1; - this.previousLines = newLines; - this.previousWidth = width; - return; - } - - // Width changed - full re-render - if (widthChanged) { - let buffer = "\x1b[?2026h"; // Begin synchronized output - buffer += "\x1b[2J\x1b[H"; // Clear screen and home - for (let i = 0; i < newLines.length; i++) { - if (i > 0) buffer += "\r\n"; - buffer += newLines[i]; - } - buffer += "\x1b[?2026l"; // End synchronized output - this.terminal.write(buffer); - this.cursorRow = newLines.length - 1; - this.previousLines = newLines; - this.previousWidth = width; - return; - } - - // Find first and last changed lines - let firstChanged = -1; - let lastChanged = -1; - - const maxLines = Math.max(newLines.length, this.previousLines.length); - for (let i = 0; i < maxLines; i++) { - const oldLine = i < this.previousLines.length ? this.previousLines[i] : ""; - const newLine = i < newLines.length ? newLines[i] : ""; - - if (oldLine !== newLine) { - if (firstChanged === -1) { - firstChanged = i; - } - lastChanged = i; - } - } - - // No changes - if (firstChanged === -1) { - return; - } - - // Check if firstChanged is outside the viewport - // cursorRow is the line where cursor is (0-indexed) - // Viewport shows lines from (cursorRow - height + 1) to cursorRow - // If firstChanged < viewportTop, we need full re-render - const viewportTop = this.cursorRow - height + 1; - if (firstChanged < viewportTop) { - // First change is above viewport - need full re-render - let buffer = "\x1b[?2026h"; // Begin synchronized output - buffer += "\x1b[2J\x1b[H"; // Clear screen and home - for (let i = 0; i < newLines.length; i++) { - if (i > 0) buffer += "\r\n"; - buffer += newLines[i]; - } - buffer += "\x1b[?2026l"; // End synchronized output - this.terminal.write(buffer); - this.cursorRow = newLines.length - 1; - this.previousLines = newLines; - this.previousWidth = width; - return; - } - - // Render from first changed line to end - // Build buffer with all updates wrapped in synchronized output - let buffer = "\x1b[?2026h"; // Begin synchronized output - - // Move cursor to first changed line - const lineDiff = firstChanged - this.cursorRow; - if (lineDiff > 0) { - buffer += `\x1b[${lineDiff}B`; // Move down - } else if (lineDiff < 0) { - buffer += `\x1b[${-lineDiff}A`; // Move up - } - - buffer += "\r"; // Move to column 0 - buffer += "\x1b[J"; // Clear from cursor to end of screen - - // Render from first changed line to end - for (let i = firstChanged; i < newLines.length; i++) { - if (i > firstChanged) buffer += "\r\n"; - buffer += newLines[i]; - } - - buffer += "\x1b[?2026l"; // End synchronized output - - // Write entire buffer at once - this.terminal.write(buffer); - - // Cursor is now at end of last line - this.cursorRow = newLines.length - 1; - - this.previousLines = newLines; - this.previousWidth = width; - } -} diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 0261012f..d99c8a3d 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1,483 +1,228 @@ -import process from "process"; -import { ProcessTerminal, type Terminal } from "./terminal.js"; - /** - * Result of rendering a component + * Minimal TUI implementation with differential rendering */ -export interface ComponentRenderResult { - lines: string[]; - changed: boolean; -} + +import type { Terminal } from "./terminal.js"; /** - * Component interface + * Component interface - all components must implement this */ export interface Component { - readonly id: number; - render(width: number): ComponentRenderResult; - handleInput?(keyData: string): void; -} + /** + * Render the component to lines for the given viewport width + * @param width - Current viewport width + * @returns Array of strings, each representing a line + */ + render(width: number): string[]; -// Global component ID counter -let nextComponentId = 1; - -// Helper to get next component ID -export function getNextComponentId(): number { - return nextComponentId++; -} - -// Padding type for components -export interface Padding { - top?: number; - bottom?: number; - left?: number; - right?: number; + /** + * Optional handler for keyboard input when component has focus + */ + handleInput?(data: string): void; } /** - * Container for managing child components + * Container - a component that contains other components */ export class Container implements Component { - readonly id: number; - public children: (Component | Container)[] = []; - private tui?: TUI; - private previousChildCount: number = 0; + children: Component[] = []; - constructor() { - this.id = getNextComponentId(); - } - - setTui(tui: TUI | undefined): void { - this.tui = tui; - for (const child of this.children) { - if (child instanceof Container) { - child.setTui(tui); - } - } - } - - addChild(component: Component | Container): void { + addChild(component: Component): void { this.children.push(component); - if (component instanceof Container) { - component.setTui(this.tui); - } - this.tui?.requestRender(); } - removeChild(component: Component | Container): void { + removeChild(component: Component): void { const index = this.children.indexOf(component); - if (index >= 0) { + if (index !== -1) { this.children.splice(index, 1); - if (component instanceof Container) { - component.setTui(undefined); - } - this.tui?.requestRender(); - } - } - - removeChildAt(index: number): void { - if (index >= 0 && index < this.children.length) { - const component = this.children[index]; - this.children.splice(index, 1); - if (component instanceof Container) { - component.setTui(undefined); - } - this.tui?.requestRender(); } } clear(): void { - for (const child of this.children) { - if (child instanceof Container) { - child.setTui(undefined); - } - } this.children = []; - this.tui?.requestRender(); } - getChild(index: number): (Component | Container) | undefined { - return this.children[index]; - } - - getChildCount(): number { - return this.children.length; - } - - render(width: number): ComponentRenderResult { + render(width: number): string[] { const lines: string[] = []; - let changed = false; - - // Check if the number of children changed (important for detecting clears) - if (this.children.length !== this.previousChildCount) { - changed = true; - this.previousChildCount = this.children.length; - } - for (const child of this.children) { - const result = child.render(width); - lines.push(...result.lines); - if (result.changed) { - changed = true; - } + lines.push(...child.render(width)); } - - return { lines, changed }; + return lines; } } /** - * Render command for tracking component output - */ -interface RenderCommand { - id: number; - lines: string[]; - changed: boolean; -} - -/** - * TUI - Smart differential rendering TUI implementation. + * TUI - Main class for managing terminal UI with differential rendering */ export class TUI extends Container { - private focusedComponent: Component | null = null; - private needsRender = false; - private isFirstRender = true; - private isStarted = false; - public onGlobalKeyPress?: (data: string) => boolean; private terminal: Terminal; - // biome-ignore lint/correctness/noUnusedPrivateClassMembers: Used in renderToScreen method on lines 260 and 276 - private previousRenderCommands: RenderCommand[] = []; - private previousLines: string[] = []; // What we rendered last time + private previousLines: string[] = []; + private previousWidth = 0; + private focusedComponent: Component | null = null; + private renderRequested = false; + private cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line) - // Performance metrics - private totalLinesRedrawn = 0; - private renderCount = 0; - public getLinesRedrawn(): number { - return this.totalLinesRedrawn; - } - public getAverageLinesRedrawn(): number { - return this.renderCount > 0 ? this.totalLinesRedrawn / this.renderCount : 0; - } - - constructor(terminal?: Terminal) { + constructor(terminal: Terminal) { super(); - this.setTui(this); - this.handleResize = this.handleResize.bind(this); - this.handleKeypress = this.handleKeypress.bind(this); - - // Use provided terminal or default to ProcessTerminal - this.terminal = terminal || new ProcessTerminal(); + this.terminal = terminal; } - setFocus(component: Component): void { - if (this.findComponent(component)) { - this.focusedComponent = component; - } - } - - private findComponent(component: Component): boolean { - if (this.children.includes(component)) { - return true; - } - - for (const child of this.children) { - if (child instanceof Container) { - if (this.findInContainer(child, component)) { - return true; - } - } - } - - return false; - } - - private findInContainer(container: Container, component: Component): boolean { - const childCount = container.getChildCount(); - - for (let i = 0; i < childCount; i++) { - const child = container.getChild(i); - if (child === component) { - return true; - } - if (child instanceof Container) { - if (this.findInContainer(child, component)) { - return true; - } - } - } - - return false; - } - - requestRender(): void { - if (!this.isStarted) return; - - // Only queue a render if we haven't already - if (!this.needsRender) { - this.needsRender = true; - process.nextTick(() => { - if (this.needsRender) { - this.renderToScreen(); - this.needsRender = false; - } - }); - } + setFocus(component: Component | null): void { + this.focusedComponent = component; } start(): void { - this.isStarted = true; - - // Hide cursor - this.terminal.write("\x1b[?25l"); - - // Start terminal with handlers - try { - this.terminal.start(this.handleKeypress, this.handleResize); - } catch (error) { - console.error("Error starting terminal:", error); - } - - // Trigger initial render if we have components - if (this.children.length > 0) { - this.requestRender(); - } + this.terminal.start( + (data) => this.handleInput(data), + () => this.requestRender(), + ); + this.terminal.hideCursor(); + this.requestRender(); } stop(): void { - // Show cursor - this.terminal.write("\x1b[?25h"); - - // Stop terminal + this.terminal.showCursor(); this.terminal.stop(); - - this.isStarted = false; } - private renderToScreen(resize = false): void { - const termWidth = this.terminal.columns; - const termHeight = this.terminal.rows; - - if (resize) { - this.isFirstRender = true; - this.previousRenderCommands = []; - this.previousLines = []; - } - - // Collect all render commands - const currentRenderCommands: RenderCommand[] = []; - this.collectRenderCommands(this, termWidth, currentRenderCommands); - - if (this.isFirstRender) { - this.renderInitial(currentRenderCommands); - this.isFirstRender = false; - } else { - this.renderLineBased(currentRenderCommands, termHeight); - } - - // Save for next render - this.previousRenderCommands = currentRenderCommands; - this.renderCount++; + requestRender(): void { + if (this.renderRequested) return; + this.renderRequested = true; + process.nextTick(() => { + this.renderRequested = false; + this.doRender(); + }); } - private collectRenderCommands(container: Container, width: number, commands: RenderCommand[]): void { - const childCount = container.getChildCount(); - - for (let i = 0; i < childCount; i++) { - const child = container.getChild(i); - if (!child) continue; - - const result = child.render(width); - commands.push({ - id: child.id, - lines: result.lines, - changed: result.changed, - }); - } - } - - private renderInitial(commands: RenderCommand[]): void { - let output = ""; - const lines: string[] = []; - - for (const command of commands) { - lines.push(...command.lines); - } - - // Output all lines - for (let i = 0; i < lines.length; i++) { - if (i > 0) output += "\r\n"; - output += lines[i]; - } - - // Add final newline to position cursor below content - if (lines.length > 0) output += "\r\n"; - - this.terminal.write(output); - - // Save what we rendered - this.previousLines = lines; - this.totalLinesRedrawn += lines.length; - } - - private renderLineBased(currentCommands: RenderCommand[], termHeight: number): void { - const viewportHeight = termHeight - 1; // Leave one line for cursor - - // Build the new lines array - const newLines: string[] = []; - for (const command of currentCommands) { - newLines.push(...command.lines); - } - - const totalNewLines = newLines.length; - const totalOldLines = this.previousLines.length; - - // Find first changed line by comparing old and new - let firstChangedLine = -1; - const minLines = Math.min(totalOldLines, totalNewLines); - - for (let i = 0; i < minLines; i++) { - if (this.previousLines[i] !== newLines[i]) { - firstChangedLine = i; - break; - } - } - - // If all common lines are the same, check if we have different lengths - if (firstChangedLine === -1 && totalOldLines !== totalNewLines) { - firstChangedLine = minLines; - } - - // No changes at all - if (firstChangedLine === -1) { - this.previousLines = newLines; - return; - } - - // Calculate viewport boundaries - const oldViewportStart = Math.max(0, totalOldLines - viewportHeight); - const cursorPosition = totalOldLines; // Cursor is one line below last content - - let output = ""; - let linesRedrawn = 0; - - // Check if change is in scrollback (unreachable by cursor) - if (firstChangedLine < oldViewportStart) { - // Must do full clear and re-render - output = "\x1b[3J\x1b[H"; // Clear scrollback and screen, home cursor - - for (let i = 0; i < newLines.length; i++) { - if (i > 0) output += "\r\n"; - output += newLines[i]; - } - - if (newLines.length > 0) output += "\r\n"; - linesRedrawn = newLines.length; - } else { - // Change is in viewport - we can reach it with cursor movements - // Calculate viewport position of the change - const viewportChangePosition = firstChangedLine - oldViewportStart; - - // Move cursor to the change position - const linesToMoveUp = cursorPosition - oldViewportStart - viewportChangePosition; - if (linesToMoveUp > 0) { - output += `\x1b[${linesToMoveUp}A`; - } - - // Now do surgical updates or partial clear based on what's more efficient - let currentLine = firstChangedLine; - const currentViewportLine = viewportChangePosition; - - // If we have significant structural changes, just clear and re-render from here - const hasSignificantChanges = totalNewLines !== totalOldLines || totalNewLines - firstChangedLine > 10; // Arbitrary threshold - - if (hasSignificantChanges) { - // Clear from cursor to end of screen and render all remaining lines - output += "\r\x1b[0J"; - - for (let i = firstChangedLine; i < newLines.length; i++) { - if (i > firstChangedLine) output += "\r\n"; - output += newLines[i]; - linesRedrawn++; - } - - if (newLines.length > firstChangedLine) output += "\r\n"; - } else { - // Do surgical line-by-line updates - for (let i = firstChangedLine; i < minLines; i++) { - if (this.previousLines[i] !== newLines[i]) { - // Move to this line if needed - const moveLines = i - currentLine; - if (moveLines > 0) { - output += `\x1b[${moveLines}B`; - } - - // Clear and rewrite the line - output += "\r\x1b[2K" + newLines[i]; - currentLine = i; - linesRedrawn++; - } - } - - // Handle added/removed lines at the end - if (totalNewLines > totalOldLines) { - // Move to end of old content and add new lines - const moveToEnd = totalOldLines - 1 - currentLine; - if (moveToEnd > 0) { - output += `\x1b[${moveToEnd}B`; - } - output += "\r\n"; - - for (let i = totalOldLines; i < totalNewLines; i++) { - if (i > totalOldLines) output += "\r\n"; - output += newLines[i]; - linesRedrawn++; - } - output += "\r\n"; - } else if (totalNewLines < totalOldLines) { - // Move to end of new content and clear rest - const moveToEnd = totalNewLines - 1 - currentLine; - if (moveToEnd > 0) { - output += `\x1b[${moveToEnd}B`; - } else if (moveToEnd < 0) { - output += `\x1b[${-moveToEnd}A`; - } - output += "\r\n\x1b[0J"; - } else { - // Same length, just position cursor at end - const moveToEnd = totalNewLines - 1 - currentLine; - if (moveToEnd > 0) { - output += `\x1b[${moveToEnd}B`; - } else if (moveToEnd < 0) { - output += `\x1b[${-moveToEnd}A`; - } - output += "\r\n"; - } - } - } - - this.terminal.write(output); - this.previousLines = newLines; - this.totalLinesRedrawn += linesRedrawn; - } - - private handleResize(): void { - // Clear screen and reset - this.terminal.write("\x1b[2J\x1b[H\x1b[?25l"); - this.renderToScreen(true); - } - - private handleKeypress(data: string): void { - if (this.onGlobalKeyPress) { - const shouldForward = this.onGlobalKeyPress(data); - if (!shouldForward) { - this.requestRender(); - return; - } + private handleInput(data: string): void { + // Exit on Ctrl+C + if (data === "\x03") { + this.stop(); + process.exit(0); } + // Pass input to focused component if (this.focusedComponent?.handleInput) { this.focusedComponent.handleInput(data); this.requestRender(); } } + + private doRender(): void { + const width = this.terminal.columns; + const height = this.terminal.rows; + + // Render all components to get new lines + const newLines = this.render(width); + + // Width changed - need full re-render + const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width; + + // First render - just output everything without clearing + if (this.previousLines.length === 0) { + let buffer = "\x1b[?2026h"; // Begin synchronized output + for (let i = 0; i < newLines.length; i++) { + if (i > 0) buffer += "\r\n"; + buffer += newLines[i]; + } + buffer += "\x1b[?2026l"; // End synchronized output + this.terminal.write(buffer); + // After rendering N lines, cursor is at end of last line (line N-1) + this.cursorRow = newLines.length - 1; + this.previousLines = newLines; + this.previousWidth = width; + return; + } + + // Width changed - full re-render + if (widthChanged) { + let buffer = "\x1b[?2026h"; // Begin synchronized output + buffer += "\x1b[2J\x1b[H"; // Clear screen and home + for (let i = 0; i < newLines.length; i++) { + if (i > 0) buffer += "\r\n"; + buffer += newLines[i]; + } + buffer += "\x1b[?2026l"; // End synchronized output + this.terminal.write(buffer); + this.cursorRow = newLines.length - 1; + this.previousLines = newLines; + this.previousWidth = width; + return; + } + + // Find first and last changed lines + let firstChanged = -1; + let lastChanged = -1; + + const maxLines = Math.max(newLines.length, this.previousLines.length); + for (let i = 0; i < maxLines; i++) { + const oldLine = i < this.previousLines.length ? this.previousLines[i] : ""; + const newLine = i < newLines.length ? newLines[i] : ""; + + if (oldLine !== newLine) { + if (firstChanged === -1) { + firstChanged = i; + } + lastChanged = i; + } + } + + // No changes + if (firstChanged === -1) { + return; + } + + // Check if firstChanged is outside the viewport + // cursorRow is the line where cursor is (0-indexed) + // Viewport shows lines from (cursorRow - height + 1) to cursorRow + // If firstChanged < viewportTop, we need full re-render + const viewportTop = this.cursorRow - height + 1; + if (firstChanged < viewportTop) { + // First change is above viewport - need full re-render + let buffer = "\x1b[?2026h"; // Begin synchronized output + buffer += "\x1b[2J\x1b[H"; // Clear screen and home + for (let i = 0; i < newLines.length; i++) { + if (i > 0) buffer += "\r\n"; + buffer += newLines[i]; + } + buffer += "\x1b[?2026l"; // End synchronized output + this.terminal.write(buffer); + this.cursorRow = newLines.length - 1; + this.previousLines = newLines; + this.previousWidth = width; + return; + } + + // Render from first changed line to end + // Build buffer with all updates wrapped in synchronized output + let buffer = "\x1b[?2026h"; // Begin synchronized output + + // Move cursor to first changed line + const lineDiff = firstChanged - this.cursorRow; + if (lineDiff > 0) { + buffer += `\x1b[${lineDiff}B`; // Move down + } else if (lineDiff < 0) { + buffer += `\x1b[${-lineDiff}A`; // Move up + } + + buffer += "\r"; // Move to column 0 + buffer += "\x1b[J"; // Clear from cursor to end of screen + + // Render from first changed line to end + for (let i = firstChanged; i < newLines.length; i++) { + if (i > firstChanged) buffer += "\r\n"; + buffer += newLines[i]; + } + + buffer += "\x1b[?2026l"; // End synchronized output + + // Write entire buffer at once + this.terminal.write(buffer); + + // Cursor is now at end of last line + this.cursorRow = newLines.length - 1; + + this.previousLines = newLines; + this.previousWidth = width; + } } diff --git a/packages/tui/test/bench.ts b/packages/tui/test/bench.ts deleted file mode 100644 index c8f2ce9c..00000000 --- a/packages/tui/test/bench.ts +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env npx tsx -import chalk from "chalk"; -import { Container, LoadingAnimation, TextComponent, TextEditor, TUI, WhitespaceComponent } from "../src/index.js"; - -/** - * Test the new smart double-buffered TUI implementation - */ -async function main() { - const ui = new TUI(); - - // Track render timings - let renderCount = 0; - let totalRenderTime = 0n; - const renderTimings: bigint[] = []; - - // Monkey-patch requestRender to measure performance - const originalRequestRender = ui.requestRender.bind(ui); - ui.requestRender = () => { - const startTime = process.hrtime.bigint(); - originalRequestRender(); - process.nextTick(() => { - const endTime = process.hrtime.bigint(); - const duration = endTime - startTime; - renderTimings.push(duration); - totalRenderTime += duration; - renderCount++; - }); - }; - - // Add header - const header = new TextComponent( - chalk.bold.green("Smart Double Buffer TUI Test") + - "\n" + - chalk.dim("Testing new implementation with component-level caching and smart diffing") + - "\n" + - chalk.dim("Press CTRL+C to exit"), - { bottom: 1 }, - ); - ui.addChild(header); - - // Add container for animation and editor - const container = new Container(); - - // Add loading animation (should NOT cause flicker with smart diffing) - const animation = new LoadingAnimation(ui); - container.addChild(animation); - - // Add some spacing - container.addChild(new WhitespaceComponent(1)); - - // Add text editor - const editor = new TextEditor(); - editor.setText( - "Type here to test the text editor.\n\nWith smart diffing, only changed lines are redrawn!\n\nThe animation above updates every 80ms but the editor stays perfectly still.", - ); - container.addChild(editor); - - // Add the container to UI - ui.addChild(container); - - // Add performance stats display - const statsComponent = new TextComponent("", { top: 1 }); - ui.addChild(statsComponent); - - // Update stats every second - const statsInterval = setInterval(() => { - if (renderCount > 0) { - const avgRenderTime = Number(totalRenderTime / BigInt(renderCount)) / 1_000_000; // Convert to ms - const lastRenderTime = - renderTimings.length > 0 ? Number(renderTimings[renderTimings.length - 1]) / 1_000_000 : 0; - const avgLinesRedrawn = ui.getAverageLinesRedrawn(); - - statsComponent.setText( - chalk.yellow(`Performance Stats:`) + - "\n" + - chalk.dim( - `Renders: ${renderCount} | Avg Time: ${avgRenderTime.toFixed(2)}ms | Last: ${lastRenderTime.toFixed(2)}ms`, - ) + - "\n" + - chalk.dim( - `Lines Redrawn: ${ui.getLinesRedrawn()} total | Avg per render: ${avgLinesRedrawn.toFixed(1)}`, - ), - ); - } - }, 1000); - - // Set focus to the editor - ui.setFocus(editor); - - // Handle global keypresses - ui.onGlobalKeyPress = (data: string) => { - // CTRL+C to exit - if (data === "\x03") { - animation.stop(); - clearInterval(statsInterval); - ui.stop(); - console.log("\n" + chalk.green("Exited double-buffer test")); - console.log(chalk.dim(`Total renders: ${renderCount}`)); - console.log( - chalk.dim( - `Average render time: ${renderCount > 0 ? (Number(totalRenderTime / BigInt(renderCount)) / 1_000_000).toFixed(2) : 0}ms`, - ), - ); - console.log(chalk.dim(`Total lines redrawn: ${ui.getLinesRedrawn()}`)); - console.log(chalk.dim(`Average lines redrawn per render: ${ui.getAverageLinesRedrawn().toFixed(1)}`)); - process.exit(0); - } - return true; // Forward other keys to focused component - }; - - // Start the UI - ui.start(); -} - -// Run the test -main().catch((error) => { - console.error("Error:", error); - process.exit(1); -}); diff --git a/packages/tui/test/chat-app.ts b/packages/tui/test/chat-app.ts deleted file mode 100644 index b6de35d6..00000000 --- a/packages/tui/test/chat-app.ts +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env npx tsx -import { - CombinedAutocompleteProvider, - Container, - MarkdownComponent, - TextComponent, - TextEditor, - TUI, -} from "../src/index.js"; - -/** - * Chat Application with Autocomplete - * - * Demonstrates: - * - Slash command system with autocomplete - * - Dynamic message history - * - Markdown rendering for messages - * - Container-based layout - */ - -const ui = new TUI(); - -// Add header with instructions -const header = new TextComponent( - "💬 Chat Demo | Type '/' for commands | Start typing a filename + Tab to autocomplete | Ctrl+C to exit", - { bottom: 1 }, -); - -const chatHistory = new Container(); -const editor = new TextEditor(); - -// Set up autocomplete with slash commands -const autocompleteProvider = new CombinedAutocompleteProvider([ - { name: "clear", description: "Clear chat history" }, - { name: "help", description: "Show help information" }, - { - name: "attach", - description: "Attach a file", - getArgumentCompletions: () => { - // Return file suggestions for attach command - return null; // Use default file completion - }, - }, -]); - -editor.setAutocompleteProvider(autocompleteProvider); - -editor.onSubmit = (text) => { - // Handle slash commands - if (text.startsWith("/")) { - const [command, ...args] = text.slice(1).split(" "); - if (command === "clear") { - chatHistory.clear(); - return; - } - if (command === "help") { - const help = new MarkdownComponent(` -## Available Commands -- \`/clear\` - Clear chat history -- \`/help\` - Show this help -- \`/attach \` - Attach a file - `); - chatHistory.addChild(help); - ui.requestRender(); - return; - } - } - - // Regular message - const message = new MarkdownComponent(`**You:** ${text}`); - chatHistory.addChild(message); - - // Add AI response (simulated) - setTimeout(() => { - const response = new MarkdownComponent(`**AI:** Response to "${text}"`); - chatHistory.addChild(response); - ui.requestRender(); - }, 1000); -}; - -// Handle Ctrl+C to exit -ui.onGlobalKeyPress = (data: string) => { - if (data === "\x03") { - ui.stop(); - console.log("\nChat application exited"); - process.exit(0); - } - return true; -}; - -// Add initial welcome message to chat history -chatHistory.addChild( - new MarkdownComponent(` -## Welcome to the Chat Demo! - -**Available slash commands:** -- \`/clear\` - Clear the chat history -- \`/help\` - Show help information -- \`/attach \` - Attach a file (with autocomplete) - -**File autocomplete:** -- Start typing any filename or directory name and press **Tab** -- Works with relative paths (\`./\`, \`../\`) -- Works with home directory (\`~/\`) - -Try it out! Type a message or command below. -`), -); - -ui.addChild(header); -ui.addChild(chatHistory); -ui.addChild(editor); -ui.setFocus(editor); -ui.start(); diff --git a/packages/tui/test/chat-debug.ts b/packages/tui/test/chat-debug.ts deleted file mode 100644 index f1352477..00000000 --- a/packages/tui/test/chat-debug.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Debug version of chat-simple with logging - */ - -import fs from "fs"; -import { ProcessTerminal } from "../src/terminal.js"; -import { Input, Text, TUI } from "../src/tui-new.js"; - -// Clear debug log -fs.writeFileSync("debug.log", ""); - -function log(msg: string) { - fs.appendFileSync("debug.log", msg + "\n"); -} - -// Create terminal -const terminal = new ProcessTerminal(); - -// Wrap terminal methods to log -const originalWrite = terminal.write.bind(terminal); -const originalMoveBy = terminal.moveBy.bind(terminal); - -terminal.write = (data: string) => { - log(`WRITE: ${JSON.stringify(data)}`); - originalWrite(data); -}; - -terminal.moveBy = (lines: number) => { - log(`MOVEBY: ${lines}`); - originalMoveBy(lines); -}; - -// Create TUI -const tui = new TUI(terminal); - -// Create chat container with some initial messages -tui.addChild(new Text("Welcome to Simple Chat!")); -tui.addChild(new Text("Type your messages below. Press Ctrl+C to exit.\n")); - -// Create input field -const input = new Input(); -tui.addChild(input); - -// Focus the input -tui.setFocus(input); - -// Start the TUI -tui.start(); diff --git a/packages/tui/test/chat-simple.ts b/packages/tui/test/chat-simple.ts index 555bd681..b7afef75 100644 --- a/packages/tui/test/chat-simple.ts +++ b/packages/tui/test/chat-simple.ts @@ -1,13 +1,14 @@ /** - * Simple chat interface demo using tui-new.ts + * Simple chat interface demo using tui.ts */ import { CombinedAutocompleteProvider } from "../src/autocomplete.js"; -import { Editor } from "../src/components-new/editor.js"; -import { Loader } from "../src/components-new/loader.js"; -import { Markdown } from "../src/components-new/markdown.js"; +import { Editor } from "../src/components/editor.js"; +import { Loader } from "../src/components/loader.js"; +import { Markdown } from "../src/components/markdown.js"; +import { Text } from "../src/components/text.js"; import { ProcessTerminal } from "../src/terminal.js"; -import { Text, TUI } from "../src/tui-new.js"; +import { TUI } from "../src/tui.js"; // Create terminal const terminal = new ProcessTerminal(); @@ -16,8 +17,9 @@ const terminal = new ProcessTerminal(); const tui = new TUI(terminal); // Create chat container with some initial messages -tui.addChild(new Text("Welcome to Simple Chat!")); -tui.addChild(new Text("Type your messages below. Type '/' for commands. Press Ctrl+C to exit.\n")); +tui.addChild( + new Text("Welcome to Simple Chat!\n\nType your messages below. Type '/' for commands. Press Ctrl+C to exit."), +); // Create editor with autocomplete const editor = new Editor(); diff --git a/packages/tui/test/differential-render.test.ts b/packages/tui/test/differential-render.test.ts deleted file mode 100644 index f120882d..00000000 --- a/packages/tui/test/differential-render.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import assert from "node:assert"; -import { describe, test } from "node:test"; -import { Container, TextComponent, TextEditor, TUI } from "../src/index.js"; -import { VirtualTerminal } from "./virtual-terminal.js"; - -describe("Differential Rendering - Dynamic Content", () => { - test("handles static text, dynamic container, and text editor correctly", async () => { - const terminal = new VirtualTerminal(80, 10); // Small viewport to test scrolling - const ui = new TUI(terminal); - ui.start(); - - // Step 1: Add a static text component - const staticText = new TextComponent("Static Header Text"); - ui.addChild(staticText); - - // Step 2: Add an initially empty container - const container = new Container(); - ui.addChild(container); - - // Step 3: Add a text editor field - const editor = new TextEditor(); - ui.addChild(editor); - ui.setFocus(editor); - - // Wait for next tick to complete and flush virtual terminal - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - // Step 4: Check initial output in scrollbuffer - let scrollBuffer = terminal.getScrollBuffer(); - let viewport = terminal.getViewport(); - - console.log("Initial render:"); - console.log("Viewport lines:", viewport.length); - console.log("ScrollBuffer lines:", scrollBuffer.length); - - // Count non-empty lines in scrollbuffer - const nonEmptyInBuffer = scrollBuffer.filter((line) => line.trim() !== "").length; - console.log("Non-empty lines in scrollbuffer:", nonEmptyInBuffer); - - // Verify initial render has static text in scrollbuffer - assert.ok( - scrollBuffer.some((line) => line.includes("Static Header Text")), - `Expected static text in scrollbuffer`, - ); - - // Step 5: Add 100 text components to container - console.log("\nAdding 100 components to container..."); - for (let i = 1; i <= 100; i++) { - container.addChild(new TextComponent(`Dynamic Item ${i}`)); - } - - // Request render after adding all components - ui.requestRender(); - - // Wait for next tick to complete and flush - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - // Step 6: Check output after adding 100 components - scrollBuffer = terminal.getScrollBuffer(); - viewport = terminal.getViewport(); - - console.log("\nAfter adding 100 items:"); - console.log("Viewport lines:", viewport.length); - console.log("ScrollBuffer lines:", scrollBuffer.length); - - // Count all dynamic items in scrollbuffer - let dynamicItemsInBuffer = 0; - const allItemNumbers = new Set(); - for (const line of scrollBuffer) { - const match = line.match(/Dynamic Item (\d+)/); - if (match) { - dynamicItemsInBuffer++; - allItemNumbers.add(parseInt(match[1], 10)); - } - } - - console.log("Dynamic items found in scrollbuffer:", dynamicItemsInBuffer); - console.log("Unique item numbers:", allItemNumbers.size); - console.log("Item range:", Math.min(...allItemNumbers), "-", Math.max(...allItemNumbers)); - - // CRITICAL TEST: The scrollbuffer should contain ALL 100 items - // This is what the differential render should preserve! - assert.strictEqual( - allItemNumbers.size, - 100, - `Expected all 100 unique items in scrollbuffer, but found ${allItemNumbers.size}`, - ); - - // Verify items are 1-100 - for (let i = 1; i <= 100; i++) { - assert.ok(allItemNumbers.has(i), `Missing Dynamic Item ${i} in scrollbuffer`); - } - - // Also verify the static header is still in scrollbuffer - assert.ok( - scrollBuffer.some((line) => line.includes("Static Header Text")), - "Static header should still be in scrollbuffer", - ); - - // And the editor should be there too - assert.ok( - scrollBuffer.some((line) => line.includes("╭") && line.includes("╮")), - "Editor top border should be in scrollbuffer", - ); - assert.ok( - scrollBuffer.some((line) => line.includes("╰") && line.includes("╯")), - "Editor bottom border should be in scrollbuffer", - ); - - ui.stop(); - }); - - test("differential render correctly updates only changed components", async () => { - const terminal = new VirtualTerminal(80, 24); - const ui = new TUI(terminal); - ui.start(); - - // Create multiple containers with different content - const header = new TextComponent("=== Application Header ==="); - const statusContainer = new Container(); - const contentContainer = new Container(); - const footer = new TextComponent("=== Footer ==="); - - ui.addChild(header); - ui.addChild(statusContainer); - ui.addChild(contentContainer); - ui.addChild(footer); - - // Add initial content - statusContainer.addChild(new TextComponent("Status: Ready")); - contentContainer.addChild(new TextComponent("Content Line 1")); - contentContainer.addChild(new TextComponent("Content Line 2")); - - // Initial render - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - let viewport = terminal.getViewport(); - assert.strictEqual(viewport[0], "=== Application Header ==="); - assert.strictEqual(viewport[1], "Status: Ready"); - assert.strictEqual(viewport[2], "Content Line 1"); - assert.strictEqual(viewport[3], "Content Line 2"); - assert.strictEqual(viewport[4], "=== Footer ==="); - - // Track lines redrawn - const initialLinesRedrawn = ui.getLinesRedrawn(); - - // Update only the status - statusContainer.clear(); - statusContainer.addChild(new TextComponent("Status: Processing...")); - ui.requestRender(); - - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - viewport = terminal.getViewport(); - assert.strictEqual(viewport[0], "=== Application Header ==="); - assert.strictEqual(viewport[1], "Status: Processing..."); - assert.strictEqual(viewport[2], "Content Line 1"); - assert.strictEqual(viewport[3], "Content Line 2"); - assert.strictEqual(viewport[4], "=== Footer ==="); - - const afterStatusUpdate = ui.getLinesRedrawn(); - const statusUpdateLines = afterStatusUpdate - initialLinesRedrawn; - console.log(`Lines redrawn for status update: ${statusUpdateLines}`); - - // Add many items to content container - for (let i = 3; i <= 20; i++) { - contentContainer.addChild(new TextComponent(`Content Line ${i}`)); - } - ui.requestRender(); - - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - viewport = terminal.getViewport(); - - // With 24 rows - 1 for cursor = 23 visible - // We have: 1 header + 1 status + 20 content + 1 footer = 23 lines - // Should fit exactly - assert.strictEqual(viewport[0], "=== Application Header ==="); - assert.strictEqual(viewport[1], "Status: Processing..."); - assert.strictEqual(viewport[21], "Content Line 20"); - assert.strictEqual(viewport[22], "=== Footer ==="); - - // Now update just one content line - const contentLine10 = contentContainer.getChild(9) as TextComponent; - contentLine10.setText("Content Line 10 - MODIFIED"); - ui.requestRender(); - - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - viewport = terminal.getViewport(); - assert.strictEqual(viewport[11], "Content Line 10 - MODIFIED"); - assert.strictEqual(viewport[0], "=== Application Header ==="); // Should be unchanged - assert.strictEqual(viewport[22], "=== Footer ==="); // Should be unchanged - - ui.stop(); - }); -}); diff --git a/packages/tui/test/file-browser.ts b/packages/tui/test/file-browser.ts deleted file mode 100644 index e2d5c505..00000000 --- a/packages/tui/test/file-browser.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { readdirSync, statSync } from "fs"; -import { join } from "path"; -import { SelectList, TUI } from "../src/index.js"; - -const ui = new TUI(); -ui.start(); -let currentPath = process.cwd(); - -function createFileList(path: string) { - const entries = readdirSync(path).map((entry) => { - const fullPath = join(path, entry); - const isDir = statSync(fullPath).isDirectory(); - return { - value: entry, - label: entry, - description: isDir ? "directory" : "file", - }; - }); - - // Add parent directory option - if (path !== "/") { - entries.unshift({ - value: "..", - label: "..", - description: "parent directory", - }); - } - - return entries; -} - -function showDirectory(path: string) { - ui.clear(); - - const entries = createFileList(path); - const fileList = new SelectList(entries, 10); - - fileList.onSelect = (item) => { - if (item.value === "..") { - currentPath = join(currentPath, ".."); - showDirectory(currentPath); - } else if (item.description === "directory") { - currentPath = join(currentPath, item.value); - showDirectory(currentPath); - } else { - console.log(`Selected file: ${join(currentPath, item.value)}`); - ui.stop(); - } - }; - - ui.addChild(fileList); - ui.setFocus(fileList); -} - -showDirectory(currentPath); diff --git a/packages/tui/test/layout-shift-artifacts.test.ts b/packages/tui/test/layout-shift-artifacts.test.ts deleted file mode 100644 index a1df26e4..00000000 --- a/packages/tui/test/layout-shift-artifacts.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import assert from "node:assert"; -import { describe, test } from "node:test"; -import { Container, TextComponent, TextEditor, TUI } from "../src/index.js"; -import { VirtualTerminal } from "./virtual-terminal.js"; - -describe("Layout shift artifacts", () => { - test("clears artifacts when components shift positions dynamically (like agent Ctrl+C)", async () => { - const term = new VirtualTerminal(80, 20); - const ui = new TUI(term); - - // Simulate agent's layout: header, chat container, status container, editor - const header = new TextComponent(">> pi interactive chat <<<"); - const chatContainer = new Container(); - const statusContainer = new Container(); - const editor = new TextEditor({ multiline: false }); - - // Add some chat content - chatContainer.addChild(new TextComponent("[user]")); - chatContainer.addChild(new TextComponent("Hello")); - chatContainer.addChild(new TextComponent("[assistant]")); - chatContainer.addChild(new TextComponent("Hi there!")); - - ui.addChild(header); - ui.addChild(chatContainer); - ui.addChild(statusContainer); - ui.addChild(editor); - - // Initial render - ui.start(); - await new Promise((resolve) => process.nextTick(resolve)); - await term.flush(); - - // Capture initial state - const initialViewport = term.getViewport(); - - // Simulate what happens when Ctrl+C is pressed (like in agent) - statusContainer.clear(); - const hint = new TextComponent("Press Ctrl+C again to exit"); - statusContainer.addChild(hint); - ui.requestRender(); - - // Wait for render - await new Promise((resolve) => process.nextTick(resolve)); - await term.flush(); - - // Capture state with status message - const withStatusViewport = term.getViewport(); - - // Simulate the timeout that clears the hint (like agent does after 500ms) - statusContainer.clear(); - ui.requestRender(); - - // Wait for render - await new Promise((resolve) => process.nextTick(resolve)); - await term.flush(); - - // Capture final state - const finalViewport = term.getViewport(); - - // Check for artifacts - look for duplicate bottom borders on consecutive lines - let foundDuplicateBorder = false; - for (let i = 0; i < finalViewport.length - 1; i++) { - const currentLine = finalViewport[i]; - const nextLine = finalViewport[i + 1]; - - // Check if we have duplicate bottom borders (the artifact) - if ( - currentLine.includes("╰") && - currentLine.includes("╯") && - nextLine.includes("╰") && - nextLine.includes("╯") - ) { - foundDuplicateBorder = true; - } - } - - // The test should FAIL if we find duplicate borders (indicating the bug exists) - assert.strictEqual(foundDuplicateBorder, false, "Found duplicate bottom borders - rendering artifact detected!"); - - // Also check that there's only one bottom border total - const bottomBorderCount = finalViewport.filter((line) => line.includes("╰")).length; - assert.strictEqual(bottomBorderCount, 1, `Expected 1 bottom border, found ${bottomBorderCount}`); - - // Verify the editor is back in its original position - const finalEditorStartLine = finalViewport.findIndex((line) => line.includes("╭")); - const initialEditorStartLine = initialViewport.findIndex((line) => line.includes("╭")); - assert.strictEqual(finalEditorStartLine, initialEditorStartLine); - - ui.stop(); - }); - - test("handles rapid addition and removal of components", async () => { - const term = new VirtualTerminal(80, 20); - const ui = new TUI(term); - - const header = new TextComponent("Header"); - const editor = new TextEditor({ multiline: false }); - - ui.addChild(header); - ui.addChild(editor); - - // Initial render - ui.start(); - await new Promise((resolve) => process.nextTick(resolve)); - await term.flush(); - - // Rapidly add and remove a status message - const status = new TextComponent("Temporary Status"); - - // Add status - ui.children.splice(1, 0, status); - ui.requestRender(); - await new Promise((resolve) => process.nextTick(resolve)); - await term.flush(); - - // Remove status immediately - ui.children.splice(1, 1); - ui.requestRender(); - await new Promise((resolve) => process.nextTick(resolve)); - await term.flush(); - - // Final output check - const finalViewport = term.getViewport(); - - // Should only have one set of borders for the editor - const topBorderCount = finalViewport.filter((line) => line.includes("╭") && line.includes("╮")).length; - const bottomBorderCount = finalViewport.filter((line) => line.includes("╰") && line.includes("╯")).length; - - assert.strictEqual(topBorderCount, 1); - assert.strictEqual(bottomBorderCount, 1); - - // Check no duplicate lines - for (let i = 0; i < finalViewport.length - 1; i++) { - const currentLine = finalViewport[i]; - const nextLine = finalViewport[i + 1]; - - // If current line is a bottom border, next line should not be a bottom border - if (currentLine.includes("╰") && currentLine.includes("╯")) { - assert.strictEqual(nextLine.includes("╰") && nextLine.includes("╯"), false); - } - } - - ui.stop(); - }); -}); diff --git a/packages/tui/test/multi-layout.ts b/packages/tui/test/multi-layout.ts deleted file mode 100644 index 5db45a1e..00000000 --- a/packages/tui/test/multi-layout.ts +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env npx tsx -import { Container, MarkdownComponent, TextComponent, TextEditor, TUI } from "../src/index.js"; - -/** - * Multi-Component Layout Demo - * - * Demonstrates: - * - Complex layout with multiple containers - * - Header, sidebar, main content, and footer areas - * - Mixing static and dynamic components - * - Debug logging configuration - */ - -const ui = new TUI(); - -// Create layout containers -const header = new TextComponent("📝 Advanced TUI Demo", { bottom: 1 }); -const mainContent = new Container(); -const sidebar = new Container(); -const footer = new TextComponent("Press Ctrl+C to exit", { top: 1 }); - -// Sidebar content -sidebar.addChild(new TextComponent("📁 Files:", { bottom: 1 })); -sidebar.addChild(new TextComponent("- config.json")); -sidebar.addChild(new TextComponent("- README.md")); -sidebar.addChild(new TextComponent("- package.json")); - -// Main content area -const chatArea = new Container(); -const inputArea = new TextEditor(); - -// Add welcome message -chatArea.addChild( - new MarkdownComponent(` -# Welcome to the TUI Demo - -This demonstrates multiple components working together: - -- **Header**: Static title with padding -- **Sidebar**: File list (simulated) -- **Chat Area**: Scrollable message history -- **Input**: Interactive text editor -- **Footer**: Status information - -Try typing a message and pressing Enter! -`), -); - -inputArea.onSubmit = (text) => { - if (text.trim()) { - const message = new MarkdownComponent(` -**${new Date().toLocaleTimeString()}:** ${text} - `); - chatArea.addChild(message); - ui.requestRender(); - } -}; - -// Build layout -mainContent.addChild(chatArea); -mainContent.addChild(inputArea); - -ui.addChild(header); -ui.addChild(mainContent); -ui.addChild(footer); -ui.setFocus(inputArea); - -// Handle Ctrl+C to exit -ui.onGlobalKeyPress = (data: string) => { - if (data === "\x03") { - ui.stop(); - console.log("\nMulti-layout demo exited"); - process.exit(0); - } - return true; -}; - -ui.start(); diff --git a/packages/tui/test/multi-message-garbled.test.ts b/packages/tui/test/multi-message-garbled.test.ts deleted file mode 100644 index 3ce2df04..00000000 --- a/packages/tui/test/multi-message-garbled.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import assert from "node:assert"; -import { describe, test } from "node:test"; -import { Container, LoadingAnimation, MarkdownComponent, TextComponent, TextEditor, TUI } from "../src/index.js"; -import { VirtualTerminal } from "./virtual-terminal.js"; - -describe("Multi-Message Garbled Output Reproduction", () => { - test("handles rapid message additions with large content without garbling", async () => { - const terminal = new VirtualTerminal(100, 30); - const ui = new TUI(terminal); - ui.start(); - - // Simulate the chat demo structure - const chatContainer = new Container(); - const statusContainer = new Container(); - const editor = new TextEditor(); - - ui.addChild(chatContainer); - ui.addChild(statusContainer); - ui.addChild(editor); - ui.setFocus(editor); - - // Initial render - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - // Step 1: Simulate user message - chatContainer.addChild(new TextComponent("[user]")); - chatContainer.addChild(new TextComponent("read all README.md files except in node_modules")); - - // Step 2: Start loading animation (assistant thinking) - const loadingAnim = new LoadingAnimation(ui, "Thinking"); - statusContainer.addChild(loadingAnim); - - ui.requestRender(); - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - // Step 3: Simulate rapid tool calls with large outputs - chatContainer.addChild(new TextComponent("[assistant]")); - - // Simulate glob tool - chatContainer.addChild(new TextComponent('[tool] glob({"pattern":"**/README.md"})')); - const globResult = `README.md -node_modules/@biomejs/biome/README.md -node_modules/@esbuild/darwin-arm64/README.md -node_modules/@types/node/README.md -node_modules/@xterm/headless/README.md -node_modules/@xterm/xterm/README.md -node_modules/chalk/readme.md -node_modules/esbuild/README.md -node_modules/fsevents/README.md -node_modules/get-tsconfig/README.md -... (59 more lines)`; - chatContainer.addChild(new TextComponent(globResult)); - - ui.requestRender(); - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - // Simulate multiple read tool calls with long content - const readmeContent = `# Pi Monorepo -A collection of tools for managing LLM deployments and building AI agents. - -## Packages - -- **[@mariozechner/pi-tui](packages/tui)** - Terminal UI library with differential rendering -- **[@mariozechner/pi-agent](packages/agent)** - General-purpose agent with tool calling and session persistence -- **[@mariozechner/pi](packages/pods)** - CLI for managing vLLM deployments on GPU pods - -... (76 more lines)`; - - // First read - chatContainer.addChild(new TextComponent('[tool] read({"path": "README.md"})')); - chatContainer.addChild(new MarkdownComponent(readmeContent)); - - ui.requestRender(); - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - // Second read with even more content - const tuiReadmeContent = `# @mariozechner/pi-tui - -Terminal UI framework with surgical differential rendering for building flicker-free interactive CLI applications. - -## Features - -- **Surgical Differential Rendering**: Three-strategy system that minimizes redraws to 1-2 lines for typical updates -- **Scrollback Buffer Preservation**: Correctly maintains terminal history when content exceeds viewport -- **Zero Flicker**: Components like text editors remain perfectly still while other parts update -- **Interactive Components**: Text editor with autocomplete, selection lists, markdown rendering -... (570 more lines)`; - - chatContainer.addChild(new TextComponent('[tool] read({"path": "packages/tui/README.md"})')); - chatContainer.addChild(new MarkdownComponent(tuiReadmeContent)); - - ui.requestRender(); - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - // Step 4: Stop loading animation and add assistant response - loadingAnim.stop(); - statusContainer.clear(); - - const assistantResponse = `I've read the README files from your monorepo. Here's a summary: - -The Pi Monorepo contains three main packages: - -1. **pi-tui** - A terminal UI framework with advanced differential rendering -2. **pi-agent** - An AI agent with tool calling capabilities -3. **pi** - A CLI for managing GPU pods with vLLM - -The TUI library features surgical differential rendering that minimizes screen updates.`; - - chatContainer.addChild(new MarkdownComponent(assistantResponse)); - - ui.requestRender(); - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - // Step 5: CRITICAL - Send a new message while previous content is displayed - chatContainer.addChild(new TextComponent("[user]")); - chatContainer.addChild(new TextComponent("What is the main purpose of the TUI library?")); - - // Start new loading animation - const loadingAnim2 = new LoadingAnimation(ui, "Thinking"); - statusContainer.addChild(loadingAnim2); - - ui.requestRender(); - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - // Add assistant response - loadingAnim2.stop(); - statusContainer.clear(); - - chatContainer.addChild(new TextComponent("[assistant]")); - const secondResponse = `The main purpose of the TUI library is to provide a **flicker-free terminal UI framework** with surgical differential rendering. - -Key aspects: -- Minimizes screen redraws to only 1-2 lines for typical updates -- Preserves terminal scrollback buffer -- Enables building interactive CLI applications without visual artifacts`; - - chatContainer.addChild(new MarkdownComponent(secondResponse)); - - ui.requestRender(); - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - // Debug: Show the garbled output after the problematic step - console.log("\n=== After second read (where garbling occurs) ==="); - const debugOutput = terminal.getScrollBuffer(); - debugOutput.forEach((line, i) => { - if (line.trim()) console.log(`${i}: "${line}"`); - }); - - // Step 6: Check final output - const finalOutput = terminal.getScrollBuffer(); - - // Check that first user message is NOT garbled - const userLine1 = finalOutput.find((line) => line.includes("read all README.md files")); - assert.strictEqual( - userLine1, - "read all README.md files except in node_modules", - `First user message is garbled: "${userLine1}"`, - ); - - // Check that second user message is clean - const userLine2 = finalOutput.find((line) => line.includes("What is the main purpose")); - assert.strictEqual( - userLine2, - "What is the main purpose of the TUI library?", - `Second user message is garbled: "${userLine2}"`, - ); - - // Check for common garbling patterns - const garbledPatterns = [ - "README.mdategy", - "README.mdectly", - "modulesl rendering", - "[assistant]ns.", - "node_modules/@esbuild/darwin-arm64/README.mdategy", - ]; - - for (const pattern of garbledPatterns) { - const hasGarbled = finalOutput.some((line) => line.includes(pattern)); - assert.ok(!hasGarbled, `Found garbled pattern "${pattern}" in output`); - } - - ui.stop(); - }); -}); diff --git a/packages/tui/test/tui-rendering.test.ts b/packages/tui/test/tui-rendering.test.ts deleted file mode 100644 index 0bb81d91..00000000 --- a/packages/tui/test/tui-rendering.test.ts +++ /dev/null @@ -1,417 +0,0 @@ -import assert from "node:assert"; -import { describe, test } from "node:test"; -import { - Container, - MarkdownComponent, - SelectList, - TextComponent, - TextEditor, - TUI, - WhitespaceComponent, -} from "../src/index.js"; -import { VirtualTerminal } from "./virtual-terminal.js"; - -describe("TUI Rendering", () => { - test("renders single text component", async () => { - const terminal = new VirtualTerminal(80, 24); - const ui = new TUI(terminal); - ui.start(); - - const text = new TextComponent("Hello, World!"); - ui.addChild(text); - - // Wait for next tick for render to complete - await new Promise((resolve) => process.nextTick(resolve)); - - // Wait for writes to complete and get the rendered output - const output = await terminal.flushAndGetViewport(); - - // Expected: text on first line - assert.strictEqual(output[0], "Hello, World!"); - - // Check cursor position - const cursor = terminal.getCursorPosition(); - assert.strictEqual(cursor.y, 1); - assert.strictEqual(cursor.x, 0); - - ui.stop(); - }); - - test("renders multiple text components", async () => { - const terminal = new VirtualTerminal(80, 24); - const ui = new TUI(terminal); - ui.start(); - - ui.addChild(new TextComponent("Line 1")); - ui.addChild(new TextComponent("Line 2")); - ui.addChild(new TextComponent("Line 3")); - - // Wait for next tick for render to complete - await new Promise((resolve) => process.nextTick(resolve)); - - const output = await terminal.flushAndGetViewport(); - assert.strictEqual(output[0], "Line 1"); - assert.strictEqual(output[1], "Line 2"); - assert.strictEqual(output[2], "Line 3"); - - ui.stop(); - }); - - test("renders text component with padding", async () => { - const terminal = new VirtualTerminal(80, 24); - const ui = new TUI(terminal); - ui.start(); - - ui.addChild(new TextComponent("Top text")); - ui.addChild(new TextComponent("Padded text", { top: 2, bottom: 2 })); - ui.addChild(new TextComponent("Bottom text")); - - // Wait for next tick for render to complete - await new Promise((resolve) => process.nextTick(resolve)); - - const output = await terminal.flushAndGetViewport(); - assert.strictEqual(output[0], "Top text"); - assert.strictEqual(output[1], ""); // top padding - assert.strictEqual(output[2], ""); // top padding - assert.strictEqual(output[3], "Padded text"); - assert.strictEqual(output[4], ""); // bottom padding - assert.strictEqual(output[5], ""); // bottom padding - assert.strictEqual(output[6], "Bottom text"); - - ui.stop(); - }); - - test("renders container with children", async () => { - const terminal = new VirtualTerminal(80, 24); - const ui = new TUI(terminal); - ui.start(); - - const container = new Container(); - container.addChild(new TextComponent("Child 1")); - container.addChild(new TextComponent("Child 2")); - - ui.addChild(new TextComponent("Before container")); - ui.addChild(container); - ui.addChild(new TextComponent("After container")); - - // Wait for next tick for render to complete - await new Promise((resolve) => process.nextTick(resolve)); - - const output = await terminal.flushAndGetViewport(); - assert.strictEqual(output[0], "Before container"); - assert.strictEqual(output[1], "Child 1"); - assert.strictEqual(output[2], "Child 2"); - assert.strictEqual(output[3], "After container"); - - ui.stop(); - }); - - test("handles text editor rendering", async () => { - const terminal = new VirtualTerminal(80, 24); - const ui = new TUI(terminal); - ui.start(); - - const editor = new TextEditor(); - ui.addChild(editor); - ui.setFocus(editor); - - // Wait for next tick for render to complete - await new Promise((resolve) => process.nextTick(resolve)); - - // Initial state - empty editor with cursor - const output = await terminal.flushAndGetViewport(); - - // Check that we have the border characters - assert.ok(output[0].includes("╭")); - assert.ok(output[0].includes("╮")); - assert.ok(output[1].includes("│")); - - ui.stop(); - }); - - test("differential rendering only updates changed lines", async () => { - const terminal = new VirtualTerminal(80, 24); - const ui = new TUI(terminal); - ui.start(); - - const staticText = new TextComponent("Static text"); - const dynamicText = new TextComponent("Initial"); - - ui.addChild(staticText); - ui.addChild(dynamicText); - - // Wait for initial render - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - // Save initial state - const initialViewport = [...terminal.getViewport()]; - - // Change only the dynamic text - dynamicText.setText("Changed"); - ui.requestRender(); - - // Wait for render - await new Promise((resolve) => process.nextTick(resolve)); - - // Flush terminal buffer - await terminal.flush(); - - // Check the viewport now shows the change - const newViewport = terminal.getViewport(); - assert.strictEqual(newViewport[0], "Static text"); // Unchanged - assert.strictEqual(newViewport[1], "Changed"); // Changed - - ui.stop(); - }); - - test("handles component removal", async () => { - const terminal = new VirtualTerminal(80, 24); - const ui = new TUI(terminal); - ui.start(); - - const text1 = new TextComponent("Line 1"); - const text2 = new TextComponent("Line 2"); - const text3 = new TextComponent("Line 3"); - - ui.addChild(text1); - ui.addChild(text2); - ui.addChild(text3); - - // Wait for initial render - await new Promise((resolve) => process.nextTick(resolve)); - - let output = await terminal.flushAndGetViewport(); - assert.strictEqual(output[0], "Line 1"); - assert.strictEqual(output[1], "Line 2"); - assert.strictEqual(output[2], "Line 3"); - - // Remove middle component - ui.removeChild(text2); - ui.requestRender(); - - await new Promise((resolve) => setImmediate(resolve)); - - output = await terminal.flushAndGetViewport(); - assert.strictEqual(output[0], "Line 1"); - assert.strictEqual(output[1], "Line 3"); - assert.strictEqual(output[2].trim(), ""); // Should be cleared - - ui.stop(); - }); - - test("handles viewport overflow", async () => { - const terminal = new VirtualTerminal(80, 10); // Small viewport - const ui = new TUI(terminal); - ui.start(); - - // Add more lines than viewport can hold - for (let i = 1; i <= 15; i++) { - ui.addChild(new TextComponent(`Line ${i}`)); - } - - // Wait for next tick for render to complete - await new Promise((resolve) => process.nextTick(resolve)); - - const output = await terminal.flushAndGetViewport(); - - // Should only render what fits in viewport (9 lines + 1 for cursor) - // When content exceeds viewport, we show the last N lines - assert.strictEqual(output[0], "Line 7"); - assert.strictEqual(output[1], "Line 8"); - assert.strictEqual(output[2], "Line 9"); - assert.strictEqual(output[3], "Line 10"); - assert.strictEqual(output[4], "Line 11"); - assert.strictEqual(output[5], "Line 12"); - assert.strictEqual(output[6], "Line 13"); - assert.strictEqual(output[7], "Line 14"); - assert.strictEqual(output[8], "Line 15"); - - ui.stop(); - }); - - test("handles whitespace component", async () => { - const terminal = new VirtualTerminal(80, 24); - const ui = new TUI(terminal); - ui.start(); - - ui.addChild(new TextComponent("Before")); - ui.addChild(new WhitespaceComponent(3)); - ui.addChild(new TextComponent("After")); - - // Wait for next tick for render to complete - await new Promise((resolve) => process.nextTick(resolve)); - - const output = await terminal.flushAndGetViewport(); - assert.strictEqual(output[0], "Before"); - assert.strictEqual(output[1], ""); - assert.strictEqual(output[2], ""); - assert.strictEqual(output[3], ""); - assert.strictEqual(output[4], "After"); - - ui.stop(); - }); - - test("markdown component renders correctly", async () => { - const terminal = new VirtualTerminal(80, 24); - const ui = new TUI(terminal); - ui.start(); - - const markdown = new MarkdownComponent("# Hello\n\nThis is **bold** text."); - ui.addChild(markdown); - - // Wait for next tick for render to complete - await new Promise((resolve) => process.nextTick(resolve)); - - const output = await terminal.flushAndGetViewport(); - // Should have formatted markdown - assert.ok(output[0].includes("Hello")); // Header - assert.ok(output[2].includes("This is")); // Paragraph after blank line - assert.ok(output[2].includes("bold")); // Bold text - - ui.stop(); - }); - - test("select list renders and handles selection", async () => { - const terminal = new VirtualTerminal(80, 24); - const ui = new TUI(terminal); - ui.start(); - - const items = [ - { label: "Option 1", value: "1" }, - { label: "Option 2", value: "2" }, - { label: "Option 3", value: "3" }, - ]; - - const selectList = new SelectList(items); - ui.addChild(selectList); - ui.setFocus(selectList); - - // Wait for next tick for render to complete - await new Promise((resolve) => process.nextTick(resolve)); - - const output = await terminal.flushAndGetViewport(); - // First option should be selected (has → indicator) - assert.ok(output[0].startsWith("→"), `Expected first line to start with →, got: "${output[0]}"`); - assert.ok(output[0].includes("Option 1")); - assert.ok(output[1].startsWith(" "), `Expected second line to start with space, got: "${output[1]}"`); - assert.ok(output[1].includes("Option 2")); - - ui.stop(); - }); - - test("preserves existing terminal content when rendering", async () => { - const terminal = new VirtualTerminal(80, 24); - - // Write some content to the terminal before starting TUI - // This simulates having existing content in the scrollback buffer - terminal.write("Previous command output line 1\r\n"); - terminal.write("Previous command output line 2\r\n"); - terminal.write("Some important information\r\n"); - terminal.write("Last line before TUI starts\r\n"); - - // Flush to ensure writes are complete - await terminal.flush(); - - // Get the initial state with existing content - const initialOutput = [...terminal.getViewport()]; - assert.strictEqual(initialOutput[0], "Previous command output line 1"); - assert.strictEqual(initialOutput[1], "Previous command output line 2"); - assert.strictEqual(initialOutput[2], "Some important information"); - assert.strictEqual(initialOutput[3], "Last line before TUI starts"); - - // Now start the TUI with a text editor - const ui = new TUI(terminal); - ui.start(); - - const editor = new TextEditor(); - let submittedText = ""; - editor.onSubmit = (text) => { - submittedText = text; - }; - ui.addChild(editor); - ui.setFocus(editor); - - // Wait for initial render - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - // Check that the editor is rendered after the existing content - const afterTuiStart = terminal.getViewport(); - - // The existing content should still be visible above the editor - assert.strictEqual(afterTuiStart[0], "Previous command output line 1"); - assert.strictEqual(afterTuiStart[1], "Previous command output line 2"); - assert.strictEqual(afterTuiStart[2], "Some important information"); - assert.strictEqual(afterTuiStart[3], "Last line before TUI starts"); - - // The editor should appear after the existing content - // The editor is 3 lines tall (top border, content line, bottom border) - // Top border with box drawing characters filling the width (80 chars) - assert.strictEqual(afterTuiStart[4][0], "╭"); - assert.strictEqual(afterTuiStart[4][78], "╮"); - - // Content line should have the prompt - assert.strictEqual(afterTuiStart[5].substring(0, 4), "│ > "); - // And should end with vertical bar - assert.strictEqual(afterTuiStart[5][78], "│"); - - // Bottom border - assert.strictEqual(afterTuiStart[6][0], "╰"); - assert.strictEqual(afterTuiStart[6][78], "╯"); - - // Type some text into the editor - terminal.sendInput("Hello World"); - - // Wait for the input to be processed - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - // Check that text appears in the editor - const afterTyping = terminal.getViewport(); - assert.strictEqual(afterTyping[0], "Previous command output line 1"); - assert.strictEqual(afterTyping[1], "Previous command output line 2"); - assert.strictEqual(afterTyping[2], "Some important information"); - assert.strictEqual(afterTyping[3], "Last line before TUI starts"); - - // The editor content should show the typed text with the prompt ">" - assert.strictEqual(afterTyping[5].substring(0, 15), "│ > Hello World"); - - // Send SHIFT+ENTER to the editor (adds a new line) - // According to text-editor.ts line 251, SHIFT+ENTER is detected as "\n" which calls addNewLine() - terminal.sendInput("\n"); - - // Wait for the input to be processed - await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); - - // Check that existing content is still preserved after adding new line - const afterNewLine = terminal.getViewport(); - assert.strictEqual(afterNewLine[0], "Previous command output line 1"); - assert.strictEqual(afterNewLine[1], "Previous command output line 2"); - assert.strictEqual(afterNewLine[2], "Some important information"); - assert.strictEqual(afterNewLine[3], "Last line before TUI starts"); - - // Editor should now be 4 lines tall (top border, first line, second line, bottom border) - // Top border at line 4 - assert.strictEqual(afterNewLine[4][0], "╭"); - assert.strictEqual(afterNewLine[4][78], "╮"); - - // First line with text at line 5 - assert.strictEqual(afterNewLine[5].substring(0, 15), "│ > Hello World"); - assert.strictEqual(afterNewLine[5][78], "│"); - - // Second line (empty, with continuation prompt " ") at line 6 - assert.strictEqual(afterNewLine[6].substring(0, 4), "│ "); - assert.strictEqual(afterNewLine[6][78], "│"); - - // Bottom border at line 7 - assert.strictEqual(afterNewLine[7][0], "╰"); - assert.strictEqual(afterNewLine[7][78], "╯"); - - // Verify that onSubmit was NOT called (since we pressed SHIFT+ENTER, not plain ENTER) - assert.strictEqual(submittedText, ""); - - ui.stop(); - }); -}); diff --git a/packages/tui/test/virtual-terminal.test.ts b/packages/tui/test/virtual-terminal.test.ts deleted file mode 100644 index 53d584d7..00000000 --- a/packages/tui/test/virtual-terminal.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import assert from "node:assert"; -import { describe, test } from "node:test"; -import { VirtualTerminal } from "./virtual-terminal.js"; - -describe("VirtualTerminal", () => { - test("writes and reads simple text", async () => { - const terminal = new VirtualTerminal(80, 24); - - terminal.write("Hello, World!"); - - // Wait for write to process - const output = await terminal.flushAndGetViewport(); - - assert.strictEqual(output[0], "Hello, World!"); - assert.strictEqual(output[1], ""); - }); - - test("handles newlines correctly", async () => { - const terminal = new VirtualTerminal(80, 24); - - terminal.write("Line 1\r\nLine 2\r\nLine 3"); - - const output = await terminal.flushAndGetViewport(); - - assert.strictEqual(output[0], "Line 1"); - assert.strictEqual(output[1], "Line 2"); - assert.strictEqual(output[2], "Line 3"); - }); - - test("handles ANSI cursor movement", async () => { - const terminal = new VirtualTerminal(80, 24); - - // Write text with proper newlines, move cursor up, overwrite - terminal.write("First line\r\nSecond line"); - terminal.write("\x1b[1A"); // Move up 1 line - terminal.write("\rOverwritten"); - - const output = await terminal.flushAndGetViewport(); - - assert.strictEqual(output[0], "Overwritten"); - assert.strictEqual(output[1], "Second line"); - }); - - test("handles clear line escape sequence", async () => { - const terminal = new VirtualTerminal(80, 24); - - terminal.write("This will be cleared"); - terminal.write("\r\x1b[2K"); // Clear line - terminal.write("New text"); - - const output = await terminal.flushAndGetViewport(); - - assert.strictEqual(output[0], "New text"); - }); - - test("tracks cursor position", async () => { - const terminal = new VirtualTerminal(80, 24); - - terminal.write("Hello"); - await terminal.flush(); - - const cursor = terminal.getCursorPosition(); - assert.strictEqual(cursor.x, 5); // After "Hello" - assert.strictEqual(cursor.y, 0); // First line - - terminal.write("\r\nWorld"); // Use CR+LF for proper newline - await terminal.flush(); - - const cursor2 = terminal.getCursorPosition(); - assert.strictEqual(cursor2.x, 5); // After "World" - assert.strictEqual(cursor2.y, 1); // Second line - }); - - test("handles viewport overflow with scrolling", async () => { - const terminal = new VirtualTerminal(80, 10); // Small viewport - - // Write more lines than viewport can hold - for (let i = 1; i <= 15; i++) { - terminal.write(`Line ${i}\r\n`); - } - - const viewport = await terminal.flushAndGetViewport(); - const scrollBuffer = terminal.getScrollBuffer(); - - // Viewport should show lines 7-15 plus empty line (because viewport starts after scrolling) - assert.strictEqual(viewport.length, 10); - assert.strictEqual(viewport[0], "Line 7"); - assert.strictEqual(viewport[8], "Line 15"); - assert.strictEqual(viewport[9], ""); // Last line is empty after the final \r\n - - // Scroll buffer should have all lines - assert.ok(scrollBuffer.length >= 15); - // Check specific lines exist in the buffer - const hasLine1 = scrollBuffer.some((line) => line === "Line 1"); - const hasLine15 = scrollBuffer.some((line) => line === "Line 15"); - assert.ok(hasLine1, "Buffer should contain 'Line 1'"); - assert.ok(hasLine15, "Buffer should contain 'Line 15'"); - }); - - test("resize updates dimensions", async () => { - const terminal = new VirtualTerminal(80, 24); - - assert.strictEqual(terminal.columns, 80); - assert.strictEqual(terminal.rows, 24); - - terminal.resize(100, 30); - - assert.strictEqual(terminal.columns, 100); - assert.strictEqual(terminal.rows, 30); - }); - - test("reset clears terminal completely", async () => { - const terminal = new VirtualTerminal(80, 24); - - terminal.write("Some text\r\nMore text"); - - let output = await terminal.flushAndGetViewport(); - assert.strictEqual(output[0], "Some text"); - assert.strictEqual(output[1], "More text"); - - terminal.reset(); - - output = await terminal.flushAndGetViewport(); - assert.strictEqual(output[0], ""); - assert.strictEqual(output[1], ""); - }); - - test("sendInput triggers handler", async () => { - const terminal = new VirtualTerminal(80, 24); - - let received = ""; - terminal.start( - (data) => { - received = data; - }, - () => {}, - ); - - terminal.sendInput("a"); - assert.strictEqual(received, "a"); - - terminal.sendInput("\x1b[A"); // Up arrow - assert.strictEqual(received, "\x1b[A"); - - terminal.stop(); - }); - - test("resize triggers handler", async () => { - const terminal = new VirtualTerminal(80, 24); - - let resized = false; - terminal.start( - () => {}, - () => { - resized = true; - }, - ); - - terminal.resize(100, 30); - assert.strictEqual(resized, true); - - terminal.stop(); - }); -}); diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json index b0775d35..785d2dea 100644 --- a/packages/web-ui/example/package.json +++ b/packages/web-ui/example/package.json @@ -6,7 +6,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "check": "tsc --noEmit" + "check": "tsgo --noEmit" }, "dependencies": { "@mariozechner/mini-lit": "^0.1.7", diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index ed073865..10b7c3f0 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -11,10 +11,10 @@ }, "scripts": { "clean": "rm -rf dist", - "build": "tsc -p tsconfig.build.json && tailwindcss -i ./src/app.css -o ./dist/app.css --minify", - "dev": "concurrently --names \"build,example\" --prefix-colors \"cyan,green\" \"tsc -p tsconfig.build.json --watch --preserveWatchOutput\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\" \"npm run dev --prefix example\"", - "dev:tsc": "concurrently --names \"build\" --prefix-colors \"cyan\" \"tsc -p tsconfig.build.json --watch --preserveWatchOutput\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\"", - "typecheck": "tsc --noEmit && cd example && tsc --noEmit", + "build": "tsgo -p tsconfig.build.json && tailwindcss -i ./src/app.css -o ./dist/app.css --minify", + "dev": "concurrently --names \"build,example\" --prefix-colors \"cyan,green\" \"tsgo -p tsconfig.build.json --watch --preserveWatchOutput\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\" \"npm run dev --prefix example\"", + "dev:tsc": "concurrently --names \"build\" --prefix-colors \"cyan\" \"tsgo -p tsconfig.build.json --watch --preserveWatchOutput\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\"", + "typecheck": "tsgo --noEmit && cd example && tsgo --noEmit", "check": "npm run typecheck" }, "dependencies": {