Clean up TUI package and refactor component structure

- Remove old TUI implementation and components (LoadingAnimation, MarkdownComponent, TextComponent, TextEditor, WhitespaceComponent)
- Rename components-new to components with new API (Loader, Markdown, Text, Editor, Spacer)
- Move Text and Input components to separate files in src/components/
- Add render caching to Text component (similar to Markdown)
- Add proper ANSI code handling in Text component using stripVTControlCharacters
- Update coding-agent to use new TUI API (requires ProcessTerminal, uses custom Editor subclass for key handling)
- Remove old test files, keep only chat-simple.ts and virtual-terminal.ts
- Update README.md with new minimal API documentation
- Switch from tsc to tsgo for type checking
- Update package dependencies across monorepo
This commit is contained in:
Mario Zechner 2025-11-11 10:32:18 +01:00
parent 1caa3cc1a7
commit 985f955ea0
40 changed files with 998 additions and 4516 deletions

188
package-lock.json generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,29 +134,26 @@ 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) {
if (this.loadingAnimation && this.onInterruptCallback) {
this.onInterruptCallback();
}
return false;
}
};
this.editor.onCtrlC = () => {
// Handle Ctrl+C (raw mode sends \x03)
if (data === "\x03") {
const now = Date.now();
const timeSinceLastCtrlC = now - this.lastSigintTime;
@ -145,10 +166,6 @@ export class TuiRenderer {
this.clearEditor();
this.lastSigintTime = now;
}
return false;
}
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();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import type { Component } from "../tui-new.js";
import type { Component } from "../tui.js";
/**
* Spacer component that renders empty lines

View file

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

View file

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

View file

@ -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 : [""];
}
}

View file

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

View file

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

View file

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

View file

@ -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)) {
setFocus(component: Component | null): void {
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;
}
});
}
}
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.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++;
}
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,
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);
}
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;
}
}
// 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;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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