mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 05:02:07 +00:00
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:
parent
1caa3cc1a7
commit
985f955ea0
40 changed files with 998 additions and 4516 deletions
188
package-lock.json
generated
188
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,20 +3,46 @@ import type { AssistantMessage, Message } from "@mariozechner/pi-ai";
|
|||
import {
|
||||
CombinedAutocompleteProvider,
|
||||
Container,
|
||||
LoadingAnimation,
|
||||
MarkdownComponent,
|
||||
TextComponent,
|
||||
TextEditor,
|
||||
Editor,
|
||||
Loader,
|
||||
Markdown,
|
||||
ProcessTerminal,
|
||||
Spacer,
|
||||
Text,
|
||||
TUI,
|
||||
WhitespaceComponent,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import chalk from "chalk";
|
||||
|
||||
/**
|
||||
* Custom editor that handles Escape and Ctrl+C keys for coding-agent
|
||||
*/
|
||||
class CustomEditor extends Editor {
|
||||
public onEscape?: () => void;
|
||||
public onCtrlC?: () => void;
|
||||
|
||||
handleInput(data: string): void {
|
||||
// Intercept Escape key
|
||||
if (data === "\x1b" && this.onEscape) {
|
||||
this.onEscape();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept Ctrl+C
|
||||
if (data === "\x03" && this.onCtrlC) {
|
||||
this.onCtrlC();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass to parent for normal handling
|
||||
super.handleInput(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders a streaming message with live updates
|
||||
*/
|
||||
class StreamingMessageComponent extends Container {
|
||||
private textComponent: MarkdownComponent | null = null;
|
||||
private textComponent: Markdown | null = null;
|
||||
private toolCallsContainer: Container | null = null;
|
||||
private currentContent = "";
|
||||
private currentToolCalls: any[] = [];
|
||||
|
|
@ -41,7 +67,7 @@ class StreamingMessageComponent extends Container {
|
|||
this.removeChild(this.textComponent);
|
||||
}
|
||||
if (textContent) {
|
||||
this.textComponent = new MarkdownComponent(textContent);
|
||||
this.textComponent = new Markdown(textContent);
|
||||
this.addChild(this.textComponent);
|
||||
}
|
||||
}
|
||||
|
|
@ -58,9 +84,7 @@ class StreamingMessageComponent extends Container {
|
|||
for (const toolCall of toolCalls) {
|
||||
const argsStr =
|
||||
typeof toolCall.arguments === "string" ? toolCall.arguments : JSON.stringify(toolCall.arguments);
|
||||
this.toolCallsContainer.addChild(
|
||||
new TextComponent(chalk.yellow(`[tool] ${toolCall.name}(${argsStr})`)),
|
||||
);
|
||||
this.toolCallsContainer.addChild(new Text(chalk.yellow(`[tool] ${toolCall.name}(${argsStr})`)));
|
||||
}
|
||||
this.addChild(this.toolCallsContainer);
|
||||
}
|
||||
|
|
@ -76,10 +100,10 @@ export class TuiRenderer {
|
|||
private ui: TUI;
|
||||
private chatContainer: Container;
|
||||
private statusContainer: Container;
|
||||
private editor: TextEditor;
|
||||
private editor: CustomEditor;
|
||||
private isInitialized = false;
|
||||
private onInputCallback?: (text: string) => void;
|
||||
private loadingAnimation: LoadingAnimation | null = null;
|
||||
private loadingAnimation: Loader | null = null;
|
||||
private onInterruptCallback?: () => void;
|
||||
private lastSigintTime = 0;
|
||||
|
||||
|
|
@ -88,10 +112,10 @@ export class TuiRenderer {
|
|||
private streamingComponent: StreamingMessageComponent | null = null;
|
||||
|
||||
constructor() {
|
||||
this.ui = new TUI();
|
||||
this.ui = new TUI(new ProcessTerminal());
|
||||
this.chatContainer = new Container();
|
||||
this.statusContainer = new Container();
|
||||
this.editor = new TextEditor();
|
||||
this.editor = new CustomEditor();
|
||||
|
||||
// Setup autocomplete for file paths and slash commands
|
||||
const autocompleteProvider = new CombinedAutocompleteProvider([], process.cwd());
|
||||
|
|
@ -102,7 +126,7 @@ export class TuiRenderer {
|
|||
if (this.isInitialized) return;
|
||||
|
||||
// Add header with instructions
|
||||
const header = new TextComponent(
|
||||
const header = new Text(
|
||||
chalk.blueBright(">> coding-agent interactive <<") +
|
||||
"\n" +
|
||||
chalk.dim("Press Escape to interrupt while processing") +
|
||||
|
|
@ -110,45 +134,38 @@ export class TuiRenderer {
|
|||
chalk.dim("Press CTRL+C to clear the text editor") +
|
||||
"\n" +
|
||||
chalk.dim("Press CTRL+C twice quickly to exit"),
|
||||
{ bottom: 1 },
|
||||
);
|
||||
|
||||
// Setup UI layout
|
||||
this.ui.addChild(header);
|
||||
this.ui.addChild(this.chatContainer);
|
||||
this.ui.addChild(this.statusContainer);
|
||||
this.ui.addChild(new WhitespaceComponent(1));
|
||||
this.ui.addChild(new Spacer(1));
|
||||
this.ui.addChild(this.editor);
|
||||
this.ui.setFocus(this.editor);
|
||||
|
||||
// Set up global key handler for Escape and Ctrl+C
|
||||
this.ui.onGlobalKeyPress = (data: string): boolean => {
|
||||
// Set up custom key handlers on the editor
|
||||
this.editor.onEscape = () => {
|
||||
// Intercept Escape key when processing
|
||||
if (data === "\x1b" && this.loadingAnimation) {
|
||||
if (this.onInterruptCallback) {
|
||||
this.onInterruptCallback();
|
||||
}
|
||||
return false;
|
||||
if (this.loadingAnimation && this.onInterruptCallback) {
|
||||
this.onInterruptCallback();
|
||||
}
|
||||
};
|
||||
|
||||
this.editor.onCtrlC = () => {
|
||||
// Handle Ctrl+C (raw mode sends \x03)
|
||||
if (data === "\x03") {
|
||||
const now = Date.now();
|
||||
const timeSinceLastCtrlC = now - this.lastSigintTime;
|
||||
const now = Date.now();
|
||||
const timeSinceLastCtrlC = now - this.lastSigintTime;
|
||||
|
||||
if (timeSinceLastCtrlC < 500) {
|
||||
// Second Ctrl+C within 500ms - exit
|
||||
this.stop();
|
||||
process.exit(0);
|
||||
} else {
|
||||
// First Ctrl+C - clear the editor
|
||||
this.clearEditor();
|
||||
this.lastSigintTime = now;
|
||||
}
|
||||
return false;
|
||||
if (timeSinceLastCtrlC < 500) {
|
||||
// Second Ctrl+C within 500ms - exit
|
||||
this.stop();
|
||||
process.exit(0);
|
||||
} else {
|
||||
// First Ctrl+C - clear the editor
|
||||
this.clearEditor();
|
||||
this.lastSigintTime = now;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Handle editor submission
|
||||
|
|
@ -191,7 +208,7 @@ export class TuiRenderer {
|
|||
if (!this.loadingAnimation) {
|
||||
this.editor.disableSubmit = true;
|
||||
this.statusContainer.clear();
|
||||
this.loadingAnimation = new LoadingAnimation(this.ui);
|
||||
this.loadingAnimation = new Loader(this.ui);
|
||||
this.statusContainer.addChild(this.loadingAnimation);
|
||||
}
|
||||
|
||||
|
|
@ -222,12 +239,13 @@ export class TuiRenderer {
|
|||
|
||||
private addMessageToChat(message: Message): void {
|
||||
if (message.role === "user") {
|
||||
this.chatContainer.addChild(new TextComponent(chalk.green("[user]")));
|
||||
this.chatContainer.addChild(new Text(chalk.green("[user]")));
|
||||
const userMsg = message as any;
|
||||
const textContent = userMsg.content?.map((c: any) => c.text || "").join("") || message.content || "";
|
||||
this.chatContainer.addChild(new TextComponent(textContent, { bottom: 1 }));
|
||||
this.chatContainer.addChild(new Text(textContent));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
} else if (message.role === "assistant") {
|
||||
this.chatContainer.addChild(new TextComponent(chalk.hex("#FFA500")("[assistant]")));
|
||||
this.chatContainer.addChild(new Text(chalk.hex("#FFA500")("[assistant]")));
|
||||
const assistantMsg = message as AssistantMessage;
|
||||
|
||||
// Render text content
|
||||
|
|
@ -236,7 +254,7 @@ export class TuiRenderer {
|
|||
.map((c) => c.text)
|
||||
.join("");
|
||||
if (textContent) {
|
||||
this.chatContainer.addChild(new MarkdownComponent(textContent));
|
||||
this.chatContainer.addChild(new Markdown(textContent));
|
||||
}
|
||||
|
||||
// Render tool calls
|
||||
|
|
@ -244,10 +262,10 @@ export class TuiRenderer {
|
|||
for (const toolCall of toolCalls) {
|
||||
const argsStr =
|
||||
typeof toolCall.arguments === "string" ? toolCall.arguments : JSON.stringify(toolCall.arguments);
|
||||
this.chatContainer.addChild(new TextComponent(chalk.yellow(`[tool] ${toolCall.name}(${argsStr})`)));
|
||||
this.chatContainer.addChild(new Text(chalk.yellow(`[tool] ${toolCall.name}(${argsStr})`)));
|
||||
}
|
||||
|
||||
this.chatContainer.addChild(new WhitespaceComponent(1));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
} else if (message.role === "toolResult") {
|
||||
const toolResultMsg = message as any;
|
||||
const output = toolResultMsg.result?.output || toolResultMsg.result || "";
|
||||
|
|
@ -259,13 +277,13 @@ export class TuiRenderer {
|
|||
const toShow = truncated ? lines.slice(0, maxLines) : lines;
|
||||
|
||||
for (const line of toShow) {
|
||||
this.chatContainer.addChild(new TextComponent(chalk.gray(line)));
|
||||
this.chatContainer.addChild(new Text(chalk.gray(line)));
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
this.chatContainer.addChild(new TextComponent(chalk.dim(`... (${lines.length - maxLines} more lines)`)));
|
||||
this.chatContainer.addChild(new Text(chalk.dim(`... (${lines.length - maxLines} more lines)`)));
|
||||
}
|
||||
this.chatContainer.addChild(new WhitespaceComponent(1));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -285,7 +303,7 @@ export class TuiRenderer {
|
|||
clearEditor(): void {
|
||||
this.editor.setText("");
|
||||
this.statusContainer.clear();
|
||||
const hint = new TextComponent(chalk.dim("Press Ctrl+C again to exit"));
|
||||
const hint = new Text(chalk.dim("Press Ctrl+C again to exit"));
|
||||
this.statusContainer.addChild(hint);
|
||||
this.ui.requestRender();
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
137
packages/tui/src/components/input.ts
Normal file
137
packages/tui/src/components/input.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Component } from "../tui-new.js";
|
||||
import type { Component } from "../tui.js";
|
||||
|
||||
/**
|
||||
* Spacer component that renders empty lines
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
113
packages/tui/src/components/text.ts
Normal file
113
packages/tui/src/components/text.ts
Normal 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 : [""];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,483 +1,228 @@
|
|||
import process from "process";
|
||||
import { ProcessTerminal, type Terminal } from "./terminal.js";
|
||||
|
||||
/**
|
||||
* Result of rendering a component
|
||||
* Minimal TUI implementation with differential rendering
|
||||
*/
|
||||
export interface ComponentRenderResult {
|
||||
lines: string[];
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
import type { Terminal } from "./terminal.js";
|
||||
|
||||
/**
|
||||
* Component interface
|
||||
* Component interface - all components must implement this
|
||||
*/
|
||||
export interface Component {
|
||||
readonly id: number;
|
||||
render(width: number): ComponentRenderResult;
|
||||
handleInput?(keyData: string): void;
|
||||
}
|
||||
/**
|
||||
* Render the component to lines for the given viewport width
|
||||
* @param width - Current viewport width
|
||||
* @returns Array of strings, each representing a line
|
||||
*/
|
||||
render(width: number): string[];
|
||||
|
||||
// Global component ID counter
|
||||
let nextComponentId = 1;
|
||||
|
||||
// Helper to get next component ID
|
||||
export function getNextComponentId(): number {
|
||||
return nextComponentId++;
|
||||
}
|
||||
|
||||
// Padding type for components
|
||||
export interface Padding {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
/**
|
||||
* Optional handler for keyboard input when component has focus
|
||||
*/
|
||||
handleInput?(data: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for managing child components
|
||||
* Container - a component that contains other components
|
||||
*/
|
||||
export class Container implements Component {
|
||||
readonly id: number;
|
||||
public children: (Component | Container)[] = [];
|
||||
private tui?: TUI;
|
||||
private previousChildCount: number = 0;
|
||||
children: Component[] = [];
|
||||
|
||||
constructor() {
|
||||
this.id = getNextComponentId();
|
||||
}
|
||||
|
||||
setTui(tui: TUI | undefined): void {
|
||||
this.tui = tui;
|
||||
for (const child of this.children) {
|
||||
if (child instanceof Container) {
|
||||
child.setTui(tui);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addChild(component: Component | Container): void {
|
||||
addChild(component: Component): void {
|
||||
this.children.push(component);
|
||||
if (component instanceof Container) {
|
||||
component.setTui(this.tui);
|
||||
}
|
||||
this.tui?.requestRender();
|
||||
}
|
||||
|
||||
removeChild(component: Component | Container): void {
|
||||
removeChild(component: Component): void {
|
||||
const index = this.children.indexOf(component);
|
||||
if (index >= 0) {
|
||||
if (index !== -1) {
|
||||
this.children.splice(index, 1);
|
||||
if (component instanceof Container) {
|
||||
component.setTui(undefined);
|
||||
}
|
||||
this.tui?.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
removeChildAt(index: number): void {
|
||||
if (index >= 0 && index < this.children.length) {
|
||||
const component = this.children[index];
|
||||
this.children.splice(index, 1);
|
||||
if (component instanceof Container) {
|
||||
component.setTui(undefined);
|
||||
}
|
||||
this.tui?.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const child of this.children) {
|
||||
if (child instanceof Container) {
|
||||
child.setTui(undefined);
|
||||
}
|
||||
}
|
||||
this.children = [];
|
||||
this.tui?.requestRender();
|
||||
}
|
||||
|
||||
getChild(index: number): (Component | Container) | undefined {
|
||||
return this.children[index];
|
||||
}
|
||||
|
||||
getChildCount(): number {
|
||||
return this.children.length;
|
||||
}
|
||||
|
||||
render(width: number): ComponentRenderResult {
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
let changed = false;
|
||||
|
||||
// Check if the number of children changed (important for detecting clears)
|
||||
if (this.children.length !== this.previousChildCount) {
|
||||
changed = true;
|
||||
this.previousChildCount = this.children.length;
|
||||
}
|
||||
|
||||
for (const child of this.children) {
|
||||
const result = child.render(width);
|
||||
lines.push(...result.lines);
|
||||
if (result.changed) {
|
||||
changed = true;
|
||||
}
|
||||
lines.push(...child.render(width));
|
||||
}
|
||||
|
||||
return { lines, changed };
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render command for tracking component output
|
||||
*/
|
||||
interface RenderCommand {
|
||||
id: number;
|
||||
lines: string[];
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* TUI - Smart differential rendering TUI implementation.
|
||||
* TUI - Main class for managing terminal UI with differential rendering
|
||||
*/
|
||||
export class TUI extends Container {
|
||||
private focusedComponent: Component | null = null;
|
||||
private needsRender = false;
|
||||
private isFirstRender = true;
|
||||
private isStarted = false;
|
||||
public onGlobalKeyPress?: (data: string) => boolean;
|
||||
private terminal: Terminal;
|
||||
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: Used in renderToScreen method on lines 260 and 276
|
||||
private previousRenderCommands: RenderCommand[] = [];
|
||||
private previousLines: string[] = []; // What we rendered last time
|
||||
private previousLines: string[] = [];
|
||||
private previousWidth = 0;
|
||||
private focusedComponent: Component | null = null;
|
||||
private renderRequested = false;
|
||||
private cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line)
|
||||
|
||||
// Performance metrics
|
||||
private totalLinesRedrawn = 0;
|
||||
private renderCount = 0;
|
||||
public getLinesRedrawn(): number {
|
||||
return this.totalLinesRedrawn;
|
||||
}
|
||||
public getAverageLinesRedrawn(): number {
|
||||
return this.renderCount > 0 ? this.totalLinesRedrawn / this.renderCount : 0;
|
||||
}
|
||||
|
||||
constructor(terminal?: Terminal) {
|
||||
constructor(terminal: Terminal) {
|
||||
super();
|
||||
this.setTui(this);
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
this.handleKeypress = this.handleKeypress.bind(this);
|
||||
|
||||
// Use provided terminal or default to ProcessTerminal
|
||||
this.terminal = terminal || new ProcessTerminal();
|
||||
this.terminal = terminal;
|
||||
}
|
||||
|
||||
setFocus(component: Component): void {
|
||||
if (this.findComponent(component)) {
|
||||
this.focusedComponent = component;
|
||||
}
|
||||
}
|
||||
|
||||
private findComponent(component: Component): boolean {
|
||||
if (this.children.includes(component)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const child of this.children) {
|
||||
if (child instanceof Container) {
|
||||
if (this.findInContainer(child, component)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private findInContainer(container: Container, component: Component): boolean {
|
||||
const childCount = container.getChildCount();
|
||||
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
const child = container.getChild(i);
|
||||
if (child === component) {
|
||||
return true;
|
||||
}
|
||||
if (child instanceof Container) {
|
||||
if (this.findInContainer(child, component)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
requestRender(): void {
|
||||
if (!this.isStarted) return;
|
||||
|
||||
// Only queue a render if we haven't already
|
||||
if (!this.needsRender) {
|
||||
this.needsRender = true;
|
||||
process.nextTick(() => {
|
||||
if (this.needsRender) {
|
||||
this.renderToScreen();
|
||||
this.needsRender = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
setFocus(component: Component | null): void {
|
||||
this.focusedComponent = component;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.isStarted = true;
|
||||
|
||||
// Hide cursor
|
||||
this.terminal.write("\x1b[?25l");
|
||||
|
||||
// Start terminal with handlers
|
||||
try {
|
||||
this.terminal.start(this.handleKeypress, this.handleResize);
|
||||
} catch (error) {
|
||||
console.error("Error starting terminal:", error);
|
||||
}
|
||||
|
||||
// Trigger initial render if we have components
|
||||
if (this.children.length > 0) {
|
||||
this.requestRender();
|
||||
}
|
||||
this.terminal.start(
|
||||
(data) => this.handleInput(data),
|
||||
() => this.requestRender(),
|
||||
);
|
||||
this.terminal.hideCursor();
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Show cursor
|
||||
this.terminal.write("\x1b[?25h");
|
||||
|
||||
// Stop terminal
|
||||
this.terminal.showCursor();
|
||||
this.terminal.stop();
|
||||
|
||||
this.isStarted = false;
|
||||
}
|
||||
|
||||
private renderToScreen(resize = false): void {
|
||||
const termWidth = this.terminal.columns;
|
||||
const termHeight = this.terminal.rows;
|
||||
|
||||
if (resize) {
|
||||
this.isFirstRender = true;
|
||||
this.previousRenderCommands = [];
|
||||
this.previousLines = [];
|
||||
}
|
||||
|
||||
// Collect all render commands
|
||||
const currentRenderCommands: RenderCommand[] = [];
|
||||
this.collectRenderCommands(this, termWidth, currentRenderCommands);
|
||||
|
||||
if (this.isFirstRender) {
|
||||
this.renderInitial(currentRenderCommands);
|
||||
this.isFirstRender = false;
|
||||
} else {
|
||||
this.renderLineBased(currentRenderCommands, termHeight);
|
||||
}
|
||||
|
||||
// Save for next render
|
||||
this.previousRenderCommands = currentRenderCommands;
|
||||
this.renderCount++;
|
||||
requestRender(): void {
|
||||
if (this.renderRequested) return;
|
||||
this.renderRequested = true;
|
||||
process.nextTick(() => {
|
||||
this.renderRequested = false;
|
||||
this.doRender();
|
||||
});
|
||||
}
|
||||
|
||||
private collectRenderCommands(container: Container, width: number, commands: RenderCommand[]): void {
|
||||
const childCount = container.getChildCount();
|
||||
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
const child = container.getChild(i);
|
||||
if (!child) continue;
|
||||
|
||||
const result = child.render(width);
|
||||
commands.push({
|
||||
id: child.id,
|
||||
lines: result.lines,
|
||||
changed: result.changed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderInitial(commands: RenderCommand[]): void {
|
||||
let output = "";
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const command of commands) {
|
||||
lines.push(...command.lines);
|
||||
}
|
||||
|
||||
// Output all lines
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (i > 0) output += "\r\n";
|
||||
output += lines[i];
|
||||
}
|
||||
|
||||
// Add final newline to position cursor below content
|
||||
if (lines.length > 0) output += "\r\n";
|
||||
|
||||
this.terminal.write(output);
|
||||
|
||||
// Save what we rendered
|
||||
this.previousLines = lines;
|
||||
this.totalLinesRedrawn += lines.length;
|
||||
}
|
||||
|
||||
private renderLineBased(currentCommands: RenderCommand[], termHeight: number): void {
|
||||
const viewportHeight = termHeight - 1; // Leave one line for cursor
|
||||
|
||||
// Build the new lines array
|
||||
const newLines: string[] = [];
|
||||
for (const command of currentCommands) {
|
||||
newLines.push(...command.lines);
|
||||
}
|
||||
|
||||
const totalNewLines = newLines.length;
|
||||
const totalOldLines = this.previousLines.length;
|
||||
|
||||
// Find first changed line by comparing old and new
|
||||
let firstChangedLine = -1;
|
||||
const minLines = Math.min(totalOldLines, totalNewLines);
|
||||
|
||||
for (let i = 0; i < minLines; i++) {
|
||||
if (this.previousLines[i] !== newLines[i]) {
|
||||
firstChangedLine = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If all common lines are the same, check if we have different lengths
|
||||
if (firstChangedLine === -1 && totalOldLines !== totalNewLines) {
|
||||
firstChangedLine = minLines;
|
||||
}
|
||||
|
||||
// No changes at all
|
||||
if (firstChangedLine === -1) {
|
||||
this.previousLines = newLines;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate viewport boundaries
|
||||
const oldViewportStart = Math.max(0, totalOldLines - viewportHeight);
|
||||
const cursorPosition = totalOldLines; // Cursor is one line below last content
|
||||
|
||||
let output = "";
|
||||
let linesRedrawn = 0;
|
||||
|
||||
// Check if change is in scrollback (unreachable by cursor)
|
||||
if (firstChangedLine < oldViewportStart) {
|
||||
// Must do full clear and re-render
|
||||
output = "\x1b[3J\x1b[H"; // Clear scrollback and screen, home cursor
|
||||
|
||||
for (let i = 0; i < newLines.length; i++) {
|
||||
if (i > 0) output += "\r\n";
|
||||
output += newLines[i];
|
||||
}
|
||||
|
||||
if (newLines.length > 0) output += "\r\n";
|
||||
linesRedrawn = newLines.length;
|
||||
} else {
|
||||
// Change is in viewport - we can reach it with cursor movements
|
||||
// Calculate viewport position of the change
|
||||
const viewportChangePosition = firstChangedLine - oldViewportStart;
|
||||
|
||||
// Move cursor to the change position
|
||||
const linesToMoveUp = cursorPosition - oldViewportStart - viewportChangePosition;
|
||||
if (linesToMoveUp > 0) {
|
||||
output += `\x1b[${linesToMoveUp}A`;
|
||||
}
|
||||
|
||||
// Now do surgical updates or partial clear based on what's more efficient
|
||||
let currentLine = firstChangedLine;
|
||||
const currentViewportLine = viewportChangePosition;
|
||||
|
||||
// If we have significant structural changes, just clear and re-render from here
|
||||
const hasSignificantChanges = totalNewLines !== totalOldLines || totalNewLines - firstChangedLine > 10; // Arbitrary threshold
|
||||
|
||||
if (hasSignificantChanges) {
|
||||
// Clear from cursor to end of screen and render all remaining lines
|
||||
output += "\r\x1b[0J";
|
||||
|
||||
for (let i = firstChangedLine; i < newLines.length; i++) {
|
||||
if (i > firstChangedLine) output += "\r\n";
|
||||
output += newLines[i];
|
||||
linesRedrawn++;
|
||||
}
|
||||
|
||||
if (newLines.length > firstChangedLine) output += "\r\n";
|
||||
} else {
|
||||
// Do surgical line-by-line updates
|
||||
for (let i = firstChangedLine; i < minLines; i++) {
|
||||
if (this.previousLines[i] !== newLines[i]) {
|
||||
// Move to this line if needed
|
||||
const moveLines = i - currentLine;
|
||||
if (moveLines > 0) {
|
||||
output += `\x1b[${moveLines}B`;
|
||||
}
|
||||
|
||||
// Clear and rewrite the line
|
||||
output += "\r\x1b[2K" + newLines[i];
|
||||
currentLine = i;
|
||||
linesRedrawn++;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle added/removed lines at the end
|
||||
if (totalNewLines > totalOldLines) {
|
||||
// Move to end of old content and add new lines
|
||||
const moveToEnd = totalOldLines - 1 - currentLine;
|
||||
if (moveToEnd > 0) {
|
||||
output += `\x1b[${moveToEnd}B`;
|
||||
}
|
||||
output += "\r\n";
|
||||
|
||||
for (let i = totalOldLines; i < totalNewLines; i++) {
|
||||
if (i > totalOldLines) output += "\r\n";
|
||||
output += newLines[i];
|
||||
linesRedrawn++;
|
||||
}
|
||||
output += "\r\n";
|
||||
} else if (totalNewLines < totalOldLines) {
|
||||
// Move to end of new content and clear rest
|
||||
const moveToEnd = totalNewLines - 1 - currentLine;
|
||||
if (moveToEnd > 0) {
|
||||
output += `\x1b[${moveToEnd}B`;
|
||||
} else if (moveToEnd < 0) {
|
||||
output += `\x1b[${-moveToEnd}A`;
|
||||
}
|
||||
output += "\r\n\x1b[0J";
|
||||
} else {
|
||||
// Same length, just position cursor at end
|
||||
const moveToEnd = totalNewLines - 1 - currentLine;
|
||||
if (moveToEnd > 0) {
|
||||
output += `\x1b[${moveToEnd}B`;
|
||||
} else if (moveToEnd < 0) {
|
||||
output += `\x1b[${-moveToEnd}A`;
|
||||
}
|
||||
output += "\r\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.terminal.write(output);
|
||||
this.previousLines = newLines;
|
||||
this.totalLinesRedrawn += linesRedrawn;
|
||||
}
|
||||
|
||||
private handleResize(): void {
|
||||
// Clear screen and reset
|
||||
this.terminal.write("\x1b[2J\x1b[H\x1b[?25l");
|
||||
this.renderToScreen(true);
|
||||
}
|
||||
|
||||
private handleKeypress(data: string): void {
|
||||
if (this.onGlobalKeyPress) {
|
||||
const shouldForward = this.onGlobalKeyPress(data);
|
||||
if (!shouldForward) {
|
||||
this.requestRender();
|
||||
return;
|
||||
}
|
||||
private handleInput(data: string): void {
|
||||
// Exit on Ctrl+C
|
||||
if (data === "\x03") {
|
||||
this.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Pass input to focused component
|
||||
if (this.focusedComponent?.handleInput) {
|
||||
this.focusedComponent.handleInput(data);
|
||||
this.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
private doRender(): void {
|
||||
const width = this.terminal.columns;
|
||||
const height = this.terminal.rows;
|
||||
|
||||
// Render all components to get new lines
|
||||
const newLines = this.render(width);
|
||||
|
||||
// Width changed - need full re-render
|
||||
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
||||
|
||||
// First render - just output everything without clearing
|
||||
if (this.previousLines.length === 0) {
|
||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||
for (let i = 0; i < newLines.length; i++) {
|
||||
if (i > 0) buffer += "\r\n";
|
||||
buffer += newLines[i];
|
||||
}
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
this.terminal.write(buffer);
|
||||
// After rendering N lines, cursor is at end of last line (line N-1)
|
||||
this.cursorRow = newLines.length - 1;
|
||||
this.previousLines = newLines;
|
||||
this.previousWidth = width;
|
||||
return;
|
||||
}
|
||||
|
||||
// Width changed - full re-render
|
||||
if (widthChanged) {
|
||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||
buffer += "\x1b[2J\x1b[H"; // Clear screen and home
|
||||
for (let i = 0; i < newLines.length; i++) {
|
||||
if (i > 0) buffer += "\r\n";
|
||||
buffer += newLines[i];
|
||||
}
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
this.terminal.write(buffer);
|
||||
this.cursorRow = newLines.length - 1;
|
||||
this.previousLines = newLines;
|
||||
this.previousWidth = width;
|
||||
return;
|
||||
}
|
||||
|
||||
// Find first and last changed lines
|
||||
let firstChanged = -1;
|
||||
let lastChanged = -1;
|
||||
|
||||
const maxLines = Math.max(newLines.length, this.previousLines.length);
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
|
||||
const newLine = i < newLines.length ? newLines[i] : "";
|
||||
|
||||
if (oldLine !== newLine) {
|
||||
if (firstChanged === -1) {
|
||||
firstChanged = i;
|
||||
}
|
||||
lastChanged = i;
|
||||
}
|
||||
}
|
||||
|
||||
// No changes
|
||||
if (firstChanged === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if firstChanged is outside the viewport
|
||||
// cursorRow is the line where cursor is (0-indexed)
|
||||
// Viewport shows lines from (cursorRow - height + 1) to cursorRow
|
||||
// If firstChanged < viewportTop, we need full re-render
|
||||
const viewportTop = this.cursorRow - height + 1;
|
||||
if (firstChanged < viewportTop) {
|
||||
// First change is above viewport - need full re-render
|
||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||
buffer += "\x1b[2J\x1b[H"; // Clear screen and home
|
||||
for (let i = 0; i < newLines.length; i++) {
|
||||
if (i > 0) buffer += "\r\n";
|
||||
buffer += newLines[i];
|
||||
}
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
this.terminal.write(buffer);
|
||||
this.cursorRow = newLines.length - 1;
|
||||
this.previousLines = newLines;
|
||||
this.previousWidth = width;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render from first changed line to end
|
||||
// Build buffer with all updates wrapped in synchronized output
|
||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||
|
||||
// Move cursor to first changed line
|
||||
const lineDiff = firstChanged - this.cursorRow;
|
||||
if (lineDiff > 0) {
|
||||
buffer += `\x1b[${lineDiff}B`; // Move down
|
||||
} else if (lineDiff < 0) {
|
||||
buffer += `\x1b[${-lineDiff}A`; // Move up
|
||||
}
|
||||
|
||||
buffer += "\r"; // Move to column 0
|
||||
buffer += "\x1b[J"; // Clear from cursor to end of screen
|
||||
|
||||
// Render from first changed line to end
|
||||
for (let i = firstChanged; i < newLines.length; i++) {
|
||||
if (i > firstChanged) buffer += "\r\n";
|
||||
buffer += newLines[i];
|
||||
}
|
||||
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
|
||||
// Write entire buffer at once
|
||||
this.terminal.write(buffer);
|
||||
|
||||
// Cursor is now at end of last line
|
||||
this.cursorRow = newLines.length - 1;
|
||||
|
||||
this.previousLines = newLines;
|
||||
this.previousWidth = width;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue