mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: fork ink and make it work nicely
This commit is contained in:
parent
cb79018977
commit
8760faf991
139 changed files with 24952 additions and 140 deletions
|
|
@ -7,6 +7,21 @@ import hooksPlugin from 'eslint-plugin-react-hooks'
|
||||||
import unusedImports from 'eslint-plugin-unused-imports'
|
import unusedImports from 'eslint-plugin-unused-imports'
|
||||||
import globals from 'globals'
|
import globals from 'globals'
|
||||||
|
|
||||||
|
const noopRule = {
|
||||||
|
meta: { schema: [], type: 'problem' },
|
||||||
|
create: () => ({})
|
||||||
|
}
|
||||||
|
|
||||||
|
const customRules = {
|
||||||
|
rules: {
|
||||||
|
'no-process-cwd': noopRule,
|
||||||
|
'no-process-env-top-level': noopRule,
|
||||||
|
'no-sync-fs': noopRule,
|
||||||
|
'no-top-level-dynamic-import': noopRule,
|
||||||
|
'no-top-level-side-effects': noopRule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
{
|
{
|
||||||
|
|
@ -22,6 +37,7 @@ export default [
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
'@typescript-eslint': typescriptEslint,
|
'@typescript-eslint': typescriptEslint,
|
||||||
|
'custom-rules': customRules,
|
||||||
perfectionist,
|
perfectionist,
|
||||||
react: reactPlugin,
|
react: reactPlugin,
|
||||||
'react-hooks': hooksPlugin,
|
'react-hooks': hooksPlugin,
|
||||||
|
|
@ -63,6 +79,15 @@ export default [
|
||||||
react: { version: 'detect' }
|
react: { version: 'detect' }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['packages/hermes-ink/**/*.{ts,tsx}'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/consistent-type-imports': 'off',
|
||||||
|
'no-constant-condition': 'off',
|
||||||
|
'no-empty': 'off',
|
||||||
|
'no-redeclare': 'off'
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ignores: ['node_modules/', 'dist/', '*.config.*', 'src/**/*.js']
|
ignores: ['node_modules/', 'dist/', '*.config.*', 'src/**/*.js']
|
||||||
}
|
}
|
||||||
|
|
|
||||||
172
ui-tui/package-lock.json
generated
172
ui-tui/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "hermes-tui",
|
"name": "hermes-tui",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hermes/ink": "file:./packages/hermes-ink",
|
||||||
"ink": "^6.8.0",
|
"ink": "^6.8.0",
|
||||||
"ink-text-input": "^6.0.0",
|
"ink-text-input": "^6.0.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
|
@ -1008,6 +1009,10 @@
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@hermes/ink": {
|
||||||
|
"resolved": "packages/hermes-ink",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
|
|
@ -2118,6 +2123,15 @@
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||||
|
|
@ -3535,7 +3549,6 @@
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
|
|
@ -4635,6 +4648,18 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash-es": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.debounce": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
|
|
@ -5229,6 +5254,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.6",
|
"version": "2.0.0-next.6",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
|
||||||
|
|
@ -5388,7 +5422,6 @@
|
||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
|
|
@ -5791,7 +5824,6 @@
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-flag": "^4.0.0"
|
"has-flag": "^4.0.0"
|
||||||
|
|
@ -5800,6 +5832,22 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/supports-hyperlinks": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0",
|
||||||
|
"supports-color": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/supports-hyperlinks?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-preserve-symlinks-flag": {
|
"node_modules/supports-preserve-symlinks-flag": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||||
|
|
@ -6122,6 +6170,21 @@
|
||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/usehooks-ts": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash.debounce": "^4.0.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.15.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.8",
|
"version": "8.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
|
||||||
|
|
@ -6552,6 +6615,109 @@
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"packages/hermes-ink": {
|
||||||
|
"name": "@hermes/ink",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@alcalzone/ansi-tokenize": "^0.1.0",
|
||||||
|
"auto-bind": "^5.0.0",
|
||||||
|
"bidi-js": "^1.0.0",
|
||||||
|
"chalk": "^5.4.0",
|
||||||
|
"cli-boxes": "^3.0.0",
|
||||||
|
"code-excerpt": "^4.0.0",
|
||||||
|
"emoji-regex": "^10.4.0",
|
||||||
|
"get-east-asian-width": "^1.3.0",
|
||||||
|
"indent-string": "^5.0.0",
|
||||||
|
"lodash-es": "^4.17.0",
|
||||||
|
"react": ">=19.0.0",
|
||||||
|
"react-reconciler": "0.33.0",
|
||||||
|
"semver": "^7.6.0",
|
||||||
|
"signal-exit": "^4.1.0",
|
||||||
|
"stack-utils": "^2.0.0",
|
||||||
|
"strip-ansi": "^7.1.0",
|
||||||
|
"supports-hyperlinks": "^3.1.0",
|
||||||
|
"type-fest": "^4.30.0",
|
||||||
|
"usehooks-ts": "^3.1.0",
|
||||||
|
"wrap-ansi": "^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ink-text-input": ">=6.0.0",
|
||||||
|
"react": ">=19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/hermes-ink/node_modules/@alcalzone/ansi-tokenize": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^6.2.1",
|
||||||
|
"is-fullwidth-code-point": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/hermes-ink/node_modules/ansi-styles": {
|
||||||
|
"version": "6.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/hermes-ink/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/hermes-ink/node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/hermes-ink/node_modules/signal-exit": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/hermes-ink/node_modules/type-fest": {
|
||||||
|
"version": "4.41.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||||
|
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||||
|
"license": "(MIT OR CC0-1.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,16 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx --watch src/entry.tsx",
|
"dev": "tsx --watch src/entry.tsx",
|
||||||
"start": "tsx src/entry.tsx",
|
"start": "tsx src/entry.tsx",
|
||||||
"build": "tsc && chmod +x dist/entry.js",
|
"build": "tsc -p tsconfig.build.json && chmod +x dist/entry.js",
|
||||||
"lint": "eslint src/",
|
"lint": "eslint src/ packages/",
|
||||||
"lint:fix": "eslint src/ --fix",
|
"lint:fix": "eslint src/ packages/ --fix",
|
||||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}'",
|
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'packages/**/*.{ts,tsx}'",
|
||||||
"fix": "npm run lint:fix && npm run fmt",
|
"fix": "npm run lint:fix && npm run fmt",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hermes/ink": "file:./packages/hermes-ink",
|
||||||
"ink": "^6.8.0",
|
"ink": "^6.8.0",
|
||||||
"ink-text-input": "^6.0.0",
|
"ink-text-input": "^6.0.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
|
|
||||||
34
ui-tui/packages/hermes-ink/index.d.ts
vendored
Normal file
34
ui-tui/packages/hermes-ink/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
export { default as useStderr } from './src/hooks/use-stderr.ts'
|
||||||
|
export type { StderrHandle } from './src/hooks/use-stderr.ts'
|
||||||
|
export { default as useStdout } from './src/hooks/use-stdout.ts'
|
||||||
|
export type { StdoutHandle } from './src/hooks/use-stdout.ts'
|
||||||
|
export { Ansi } from './src/ink/Ansi.tsx'
|
||||||
|
export { AlternateScreen } from './src/ink/components/AlternateScreen.tsx'
|
||||||
|
export { default as Box } from './src/ink/components/Box.tsx'
|
||||||
|
export type { Props as BoxProps } from './src/ink/components/Box.tsx'
|
||||||
|
export { default as Link } from './src/ink/components/Link.tsx'
|
||||||
|
export { default as Newline } from './src/ink/components/Newline.tsx'
|
||||||
|
export { NoSelect } from './src/ink/components/NoSelect.tsx'
|
||||||
|
export { RawAnsi } from './src/ink/components/RawAnsi.tsx'
|
||||||
|
export { default as ScrollBox } from './src/ink/components/ScrollBox.tsx'
|
||||||
|
export type { ScrollBoxHandle, ScrollBoxProps } from './src/ink/components/ScrollBox.tsx'
|
||||||
|
export { default as Spacer } from './src/ink/components/Spacer.tsx'
|
||||||
|
export type { Props as StdinProps } from './src/ink/components/StdinContext.ts'
|
||||||
|
export { default as Text } from './src/ink/components/Text.tsx'
|
||||||
|
export type { Props as TextProps } from './src/ink/components/Text.tsx'
|
||||||
|
export type { Key } from './src/ink/events/input-event.ts'
|
||||||
|
export { default as useApp } from './src/ink/hooks/use-app.ts'
|
||||||
|
export { useDeclaredCursor } from './src/ink/hooks/use-declared-cursor.ts'
|
||||||
|
export { default as useInput } from './src/ink/hooks/use-input.ts'
|
||||||
|
export { useHasSelection, useSelection } from './src/ink/hooks/use-selection.ts'
|
||||||
|
export { default as useStdin } from './src/ink/hooks/use-stdin.ts'
|
||||||
|
export { useTabStatus } from './src/ink/hooks/use-tab-status.ts'
|
||||||
|
export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts'
|
||||||
|
export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts'
|
||||||
|
export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts'
|
||||||
|
export { default as measureElement } from './src/ink/measure-element.ts'
|
||||||
|
export { createRoot, default as render, renderSync } from './src/ink/root.ts'
|
||||||
|
export type { Instance, RenderOptions, Root } from './src/ink/root.ts'
|
||||||
|
export { stringWidth } from './src/ink/stringWidth.ts'
|
||||||
|
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
||||||
|
export type { Props as TextInputProps } from 'ink-text-input'
|
||||||
25
ui-tui/packages/hermes-ink/index.js
Normal file
25
ui-tui/packages/hermes-ink/index.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
export { default as render, createRoot, renderSync } from './src/ink/root.ts'
|
||||||
|
export { default as Box } from './src/ink/components/Box.tsx'
|
||||||
|
export { default as Text } from './src/ink/components/Text.tsx'
|
||||||
|
export { Ansi } from './src/ink/Ansi.tsx'
|
||||||
|
export { AlternateScreen } from './src/ink/components/AlternateScreen.tsx'
|
||||||
|
export { default as Link } from './src/ink/components/Link.tsx'
|
||||||
|
export { default as Newline } from './src/ink/components/Newline.tsx'
|
||||||
|
export { NoSelect } from './src/ink/components/NoSelect.tsx'
|
||||||
|
export { RawAnsi } from './src/ink/components/RawAnsi.tsx'
|
||||||
|
export { default as ScrollBox } from './src/ink/components/ScrollBox.tsx'
|
||||||
|
export { default as Spacer } from './src/ink/components/Spacer.tsx'
|
||||||
|
export { default as measureElement } from './src/ink/measure-element.ts'
|
||||||
|
export { stringWidth } from './src/ink/stringWidth.ts'
|
||||||
|
export { default as useApp } from './src/ink/hooks/use-app.ts'
|
||||||
|
export { useDeclaredCursor } from './src/ink/hooks/use-declared-cursor.ts'
|
||||||
|
export { default as useInput } from './src/ink/hooks/use-input.ts'
|
||||||
|
export { default as useStdin } from './src/ink/hooks/use-stdin.ts'
|
||||||
|
export { useHasSelection, useSelection } from './src/ink/hooks/use-selection.ts'
|
||||||
|
export { default as useStdout } from './src/hooks/use-stdout.ts'
|
||||||
|
export { default as useStderr } from './src/hooks/use-stderr.ts'
|
||||||
|
export { useTabStatus } from './src/ink/hooks/use-tab-status.ts'
|
||||||
|
export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts'
|
||||||
|
export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts'
|
||||||
|
export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts'
|
||||||
|
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
||||||
48
ui-tui/packages/hermes-ink/package.json
Normal file
48
ui-tui/packages/hermes-ink/package.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"name": "@hermes/ink",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
|
"main": "./index.js",
|
||||||
|
"types": "./index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./index.js",
|
||||||
|
"default": "./index.js",
|
||||||
|
"types": "./index.d.ts"
|
||||||
|
},
|
||||||
|
"./text-input": {
|
||||||
|
"import": "./text-input.js",
|
||||||
|
"default": "./text-input.js",
|
||||||
|
"types": "./text-input.d.ts"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ink-text-input": ">=6.0.0",
|
||||||
|
"react": ">=19.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@alcalzone/ansi-tokenize": "^0.1.0",
|
||||||
|
"auto-bind": "^5.0.0",
|
||||||
|
"bidi-js": "^1.0.0",
|
||||||
|
"chalk": "^5.4.0",
|
||||||
|
"cli-boxes": "^3.0.0",
|
||||||
|
"code-excerpt": "^4.0.0",
|
||||||
|
"emoji-regex": "^10.4.0",
|
||||||
|
"get-east-asian-width": "^1.3.0",
|
||||||
|
"indent-string": "^5.0.0",
|
||||||
|
"lodash-es": "^4.17.0",
|
||||||
|
"react": ">=19.0.0",
|
||||||
|
"react-reconciler": "0.33.0",
|
||||||
|
"semver": "^7.6.0",
|
||||||
|
"signal-exit": "^4.1.0",
|
||||||
|
"stack-utils": "^2.0.0",
|
||||||
|
"strip-ansi": "^7.1.0",
|
||||||
|
"supports-hyperlinks": "^3.1.0",
|
||||||
|
"type-fest": "^4.30.0",
|
||||||
|
"usehooks-ts": "^3.1.0",
|
||||||
|
"wrap-ansi": "^9.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
ui-tui/packages/hermes-ink/src/bootstrap/state.ts
Normal file
9
ui-tui/packages/hermes-ink/src/bootstrap/state.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export function flushInteractionTime(): void {}
|
||||||
|
|
||||||
|
export function updateLastInteractionTime(): void {}
|
||||||
|
|
||||||
|
export function markScrollActivity(): void {}
|
||||||
|
|
||||||
|
export function getIsInteractive(): boolean {
|
||||||
|
return !!process.stdin.isTTY && !!process.stdout.isTTY
|
||||||
|
}
|
||||||
15
ui-tui/packages/hermes-ink/src/hooks/use-stderr.ts
Normal file
15
ui-tui/packages/hermes-ink/src/hooks/use-stderr.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
export type StderrHandle = {
|
||||||
|
stderr: NodeJS.WriteStream
|
||||||
|
write: (data: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useStderr(): StderrHandle {
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
stderr: process.stderr,
|
||||||
|
write: data => process.stderr.write(data)
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
}
|
||||||
15
ui-tui/packages/hermes-ink/src/hooks/use-stdout.ts
Normal file
15
ui-tui/packages/hermes-ink/src/hooks/use-stdout.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
export type StdoutHandle = {
|
||||||
|
stdout: NodeJS.WriteStream
|
||||||
|
write: (data: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useStdout(): StdoutHandle {
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
stdout: process.stdout,
|
||||||
|
write: data => process.stdout.write(data)
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
}
|
||||||
434
ui-tui/packages/hermes-ink/src/ink/Ansi.tsx
Normal file
434
ui-tui/packages/hermes-ink/src/ink/Ansi.tsx
Normal file
File diff suppressed because one or more lines are too long
145
ui-tui/packages/hermes-ink/src/ink/bidi.ts
Normal file
145
ui-tui/packages/hermes-ink/src/ink/bidi.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
/**
|
||||||
|
* Bidirectional text reordering for terminal rendering.
|
||||||
|
*
|
||||||
|
* Terminals on Windows do not implement the Unicode Bidi Algorithm,
|
||||||
|
* so RTL text (Hebrew, Arabic, etc.) appears reversed. This module
|
||||||
|
* applies the bidi algorithm to reorder ClusteredChar arrays from
|
||||||
|
* logical order to visual order before Ink's LTR cell placement loop.
|
||||||
|
*
|
||||||
|
* On macOS terminals (Terminal.app, iTerm2) bidi works natively.
|
||||||
|
* Windows Terminal (including WSL) does not implement bidi
|
||||||
|
* (https://github.com/microsoft/terminal/issues/538).
|
||||||
|
*
|
||||||
|
* Detection: Windows Terminal sets WT_SESSION; native Windows cmd/conhost
|
||||||
|
* also lacks bidi. We enable bidi reordering when running on Windows or
|
||||||
|
* inside Windows Terminal (covers WSL).
|
||||||
|
*/
|
||||||
|
import bidiFactory from 'bidi-js'
|
||||||
|
|
||||||
|
type ClusteredChar = {
|
||||||
|
value: string
|
||||||
|
width: number
|
||||||
|
styleId: number
|
||||||
|
hyperlink: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let bidiInstance: ReturnType<typeof bidiFactory> | undefined
|
||||||
|
let needsSoftwareBidi: boolean | undefined
|
||||||
|
|
||||||
|
function needsBidi(): boolean {
|
||||||
|
if (needsSoftwareBidi === undefined) {
|
||||||
|
needsSoftwareBidi =
|
||||||
|
process.platform === 'win32' ||
|
||||||
|
typeof process.env['WT_SESSION'] === 'string' || // WSL in Windows Terminal
|
||||||
|
process.env['TERM_PROGRAM'] === 'vscode' // VS Code integrated terminal (xterm.js)
|
||||||
|
}
|
||||||
|
|
||||||
|
return needsSoftwareBidi
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBidi() {
|
||||||
|
if (!bidiInstance) {
|
||||||
|
bidiInstance = bidiFactory()
|
||||||
|
}
|
||||||
|
|
||||||
|
return bidiInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder an array of ClusteredChars from logical order to visual order
|
||||||
|
* using the Unicode Bidi Algorithm. Active on terminals that lack native
|
||||||
|
* bidi support (Windows Terminal, conhost, WSL).
|
||||||
|
*
|
||||||
|
* Returns the same array on bidi-capable terminals (no-op).
|
||||||
|
*/
|
||||||
|
export function reorderBidi(characters: ClusteredChar[]): ClusteredChar[] {
|
||||||
|
if (!needsBidi() || characters.length === 0) {
|
||||||
|
return characters
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a plain string from the clustered chars to run through bidi
|
||||||
|
const plainText = characters.map(c => c.value).join('')
|
||||||
|
|
||||||
|
// Check if there are any RTL characters — skip bidi if pure LTR
|
||||||
|
if (!hasRTLCharacters(plainText)) {
|
||||||
|
return characters
|
||||||
|
}
|
||||||
|
|
||||||
|
const bidi = getBidi()
|
||||||
|
const { levels } = bidi.getEmbeddingLevels(plainText, 'auto')
|
||||||
|
|
||||||
|
// Map bidi levels back to ClusteredChar indices.
|
||||||
|
// Each ClusteredChar may be multiple code units in the joined string.
|
||||||
|
const charLevels: number[] = []
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < characters.length; i++) {
|
||||||
|
charLevels.push(levels[offset]!)
|
||||||
|
offset += characters[i]!.value.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get reorder segments from bidi-js, but we need to work at the
|
||||||
|
// ClusteredChar level, not the string level. We'll implement the
|
||||||
|
// standard bidi reordering: find the max level, then for each level
|
||||||
|
// from max down to 1, reverse all contiguous runs >= that level.
|
||||||
|
const reordered = [...characters]
|
||||||
|
const maxLevel = Math.max(...charLevels)
|
||||||
|
|
||||||
|
for (let level = maxLevel; level >= 1; level--) {
|
||||||
|
let i = 0
|
||||||
|
|
||||||
|
while (i < reordered.length) {
|
||||||
|
if (charLevels[i]! >= level) {
|
||||||
|
// Find the end of this run
|
||||||
|
let j = i + 1
|
||||||
|
|
||||||
|
while (j < reordered.length && charLevels[j]! >= level) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse the run in both arrays
|
||||||
|
reverseRange(reordered, i, j - 1)
|
||||||
|
reverseRangeNumbers(charLevels, i, j - 1)
|
||||||
|
i = j
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reordered
|
||||||
|
}
|
||||||
|
|
||||||
|
function reverseRange<T>(arr: T[], start: number, end: number): void {
|
||||||
|
while (start < end) {
|
||||||
|
const temp = arr[start]!
|
||||||
|
arr[start] = arr[end]!
|
||||||
|
arr[end] = temp
|
||||||
|
start++
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reverseRangeNumbers(arr: number[], start: number, end: number): void {
|
||||||
|
while (start < end) {
|
||||||
|
const temp = arr[start]!
|
||||||
|
arr[start] = arr[end]!
|
||||||
|
arr[end] = temp
|
||||||
|
start++
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick check for RTL characters (Hebrew, Arabic, and related scripts).
|
||||||
|
* Avoids running the full bidi algorithm on pure-LTR text.
|
||||||
|
*/
|
||||||
|
function hasRTLCharacters(text: string): boolean {
|
||||||
|
// Hebrew: U+0590-U+05FF, U+FB1D-U+FB4F
|
||||||
|
// Arabic: U+0600-U+06FF, U+0750-U+077F, U+08A0-U+08FF, U+FB50-U+FDFF, U+FE70-U+FEFF
|
||||||
|
// Thaana: U+0780-U+07BF
|
||||||
|
// Syriac: U+0700-U+074F
|
||||||
|
return /[\u0590-\u05FF\uFB1D-\uFB4F\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF\u0780-\u07BF\u0700-\u074F]/u.test(
|
||||||
|
text
|
||||||
|
)
|
||||||
|
}
|
||||||
68
ui-tui/packages/hermes-ink/src/ink/clearTerminal.ts
Normal file
68
ui-tui/packages/hermes-ink/src/ink/clearTerminal.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
/**
|
||||||
|
* Cross-platform terminal clearing with scrollback support.
|
||||||
|
* Detects modern terminals that support ESC[3J for clearing scrollback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { csi, CURSOR_HOME, ERASE_SCREEN, ERASE_SCROLLBACK } from './termio/csi.js'
|
||||||
|
|
||||||
|
// HVP (Horizontal Vertical Position) - legacy Windows cursor home
|
||||||
|
const CURSOR_HOME_WINDOWS = csi(0, 'f')
|
||||||
|
|
||||||
|
function isWindowsTerminal(): boolean {
|
||||||
|
return process.platform === 'win32' && !!process.env.WT_SESSION
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMintty(): boolean {
|
||||||
|
// mintty 3.1.5+ sets TERM_PROGRAM to 'mintty'
|
||||||
|
if (process.env.TERM_PROGRAM === 'mintty') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitBash/MSYS2/MINGW use mintty and set MSYSTEM
|
||||||
|
if (process.platform === 'win32' && process.env.MSYSTEM) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModernWindowsTerminal(): boolean {
|
||||||
|
// Windows Terminal sets WT_SESSION environment variable
|
||||||
|
if (isWindowsTerminal()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// VS Code integrated terminal on Windows with ConPTY support
|
||||||
|
if (process.platform === 'win32' && process.env.TERM_PROGRAM === 'vscode' && process.env.TERM_PROGRAM_VERSION) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// mintty (GitBash/MSYS2/Cygwin) supports modern escape sequences
|
||||||
|
if (isMintty()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the ANSI escape sequence to clear the terminal including scrollback.
|
||||||
|
* Automatically detects terminal capabilities.
|
||||||
|
*/
|
||||||
|
export function getClearTerminalSequence(): string {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
if (isModernWindowsTerminal()) {
|
||||||
|
return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME
|
||||||
|
} else {
|
||||||
|
// Legacy Windows console - can't clear scrollback
|
||||||
|
return ERASE_SCREEN + CURSOR_HOME_WINDOWS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the terminal screen. On supported terminals, also clears scrollback.
|
||||||
|
*/
|
||||||
|
export const clearTerminal = getClearTerminalSequence()
|
||||||
233
ui-tui/packages/hermes-ink/src/ink/colorize.ts
Normal file
233
ui-tui/packages/hermes-ink/src/ink/colorize.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
import chalk from 'chalk'
|
||||||
|
|
||||||
|
import type { Color, TextStyles } from './styles.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xterm.js (VS Code, Cursor, code-server, Coder) has supported truecolor
|
||||||
|
* since 2017, but code-server/Coder containers often don't set
|
||||||
|
* COLORTERM=truecolor. chalk's supports-color doesn't recognize
|
||||||
|
* TERM_PROGRAM=vscode (it only knows iTerm.app/Apple_Terminal), so it falls
|
||||||
|
* through to the -256color regex → level 2. At level 2, chalk.rgb()
|
||||||
|
* downgrades to the nearest 6×6×6 cube color: rgb(215,119,87) (Claude
|
||||||
|
* orange) → idx 174 rgb(215,135,135) — washed-out salmon.
|
||||||
|
*
|
||||||
|
* Gated on level === 2 (not < 3) to respect NO_COLOR / FORCE_COLOR=0 —
|
||||||
|
* those yield level 0 and are an explicit "no colors" request. Desktop VS
|
||||||
|
* Code sets COLORTERM=truecolor itself, so this is a no-op there (already 3).
|
||||||
|
*
|
||||||
|
* Must run BEFORE the tmux clamp — if tmux is running inside a VS Code
|
||||||
|
* terminal, tmux's passthrough limitation wins and we want level 2.
|
||||||
|
*/
|
||||||
|
function boostChalkLevelForXtermJs(): boolean {
|
||||||
|
if (process.env.TERM_PROGRAM === 'vscode' && chalk.level === 2) {
|
||||||
|
chalk.level = 3
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tmux parses truecolor SGR (\e[48;2;r;g;bm) into its cell buffer correctly,
|
||||||
|
* but its client-side emitter only re-emits truecolor to the outer terminal if
|
||||||
|
* the outer terminal advertises Tc/RGB capability (via terminal-overrides).
|
||||||
|
* Default tmux config doesn't set this, so tmux emits the cell to iTerm2/etc
|
||||||
|
* WITHOUT the bg sequence — outer terminal's buffer has bg=default → black on
|
||||||
|
* dark profiles. Clamping to level 2 makes chalk emit 256-color (\e[48;5;Nm),
|
||||||
|
* which tmux passes through cleanly. grey93 (255) is visually identical to
|
||||||
|
* rgb(240,240,240).
|
||||||
|
*
|
||||||
|
* Users who HAVE set `terminal-overrides ,*:Tc` get a technically-unnecessary
|
||||||
|
* downgrade, but the visual difference is imperceptible. Querying
|
||||||
|
* `tmux show -gv terminal-overrides` to detect this would add a subprocess on
|
||||||
|
* startup — not worth it.
|
||||||
|
*
|
||||||
|
* $TMUX is a pty-lifecycle env var set by tmux itself; it never comes from
|
||||||
|
* globalSettings.env, so reading it here is correct. chalk is a singleton, so
|
||||||
|
* this clamps ALL truecolor output (fg+bg+hex) across the entire app.
|
||||||
|
*/
|
||||||
|
function clampChalkLevelForTmux(): boolean {
|
||||||
|
// bg.ts sets terminal-overrides :Tc before attach, so truecolor passes
|
||||||
|
// through — skip the clamp. General escape hatch for anyone who's
|
||||||
|
// configured their tmux correctly.
|
||||||
|
if (process.env.CLAUDE_CODE_TMUX_TRUECOLOR) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.TMUX && chalk.level > 2) {
|
||||||
|
chalk.level = 2
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed once at module load — terminal/tmux environment doesn't change mid-session.
|
||||||
|
// Order matters: boost first so the tmux clamp can re-clamp if tmux is running
|
||||||
|
// inside a VS Code terminal. Exported for debugging — tree-shaken if unused.
|
||||||
|
export const CHALK_BOOSTED_FOR_XTERMJS = boostChalkLevelForXtermJs()
|
||||||
|
export const CHALK_CLAMPED_FOR_TMUX = clampChalkLevelForTmux()
|
||||||
|
|
||||||
|
export type ColorType = 'foreground' | 'background'
|
||||||
|
|
||||||
|
const RGB_REGEX = /^rgb\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/
|
||||||
|
const ANSI_REGEX = /^ansi256\(\s?(\d+)\s?\)$/
|
||||||
|
|
||||||
|
export const colorize = (str: string, color: string | undefined, type: ColorType): string => {
|
||||||
|
if (!color) {
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color.startsWith('ansi:')) {
|
||||||
|
const value = color.substring('ansi:'.length)
|
||||||
|
|
||||||
|
switch (value) {
|
||||||
|
case 'black':
|
||||||
|
return type === 'foreground' ? chalk.black(str) : chalk.bgBlack(str)
|
||||||
|
|
||||||
|
case 'red':
|
||||||
|
return type === 'foreground' ? chalk.red(str) : chalk.bgRed(str)
|
||||||
|
|
||||||
|
case 'green':
|
||||||
|
return type === 'foreground' ? chalk.green(str) : chalk.bgGreen(str)
|
||||||
|
|
||||||
|
case 'yellow':
|
||||||
|
return type === 'foreground' ? chalk.yellow(str) : chalk.bgYellow(str)
|
||||||
|
|
||||||
|
case 'blue':
|
||||||
|
return type === 'foreground' ? chalk.blue(str) : chalk.bgBlue(str)
|
||||||
|
|
||||||
|
case 'magenta':
|
||||||
|
return type === 'foreground' ? chalk.magenta(str) : chalk.bgMagenta(str)
|
||||||
|
|
||||||
|
case 'cyan':
|
||||||
|
return type === 'foreground' ? chalk.cyan(str) : chalk.bgCyan(str)
|
||||||
|
|
||||||
|
case 'white':
|
||||||
|
return type === 'foreground' ? chalk.white(str) : chalk.bgWhite(str)
|
||||||
|
|
||||||
|
case 'blackBright':
|
||||||
|
return type === 'foreground' ? chalk.blackBright(str) : chalk.bgBlackBright(str)
|
||||||
|
|
||||||
|
case 'redBright':
|
||||||
|
return type === 'foreground' ? chalk.redBright(str) : chalk.bgRedBright(str)
|
||||||
|
|
||||||
|
case 'greenBright':
|
||||||
|
return type === 'foreground' ? chalk.greenBright(str) : chalk.bgGreenBright(str)
|
||||||
|
|
||||||
|
case 'yellowBright':
|
||||||
|
return type === 'foreground' ? chalk.yellowBright(str) : chalk.bgYellowBright(str)
|
||||||
|
|
||||||
|
case 'blueBright':
|
||||||
|
return type === 'foreground' ? chalk.blueBright(str) : chalk.bgBlueBright(str)
|
||||||
|
|
||||||
|
case 'magentaBright':
|
||||||
|
return type === 'foreground' ? chalk.magentaBright(str) : chalk.bgMagentaBright(str)
|
||||||
|
|
||||||
|
case 'cyanBright':
|
||||||
|
return type === 'foreground' ? chalk.cyanBright(str) : chalk.bgCyanBright(str)
|
||||||
|
|
||||||
|
case 'whiteBright':
|
||||||
|
return type === 'foreground' ? chalk.whiteBright(str) : chalk.bgWhiteBright(str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color.startsWith('#')) {
|
||||||
|
return type === 'foreground' ? chalk.hex(color)(str) : chalk.bgHex(color)(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color.startsWith('ansi256')) {
|
||||||
|
const matches = ANSI_REGEX.exec(color)
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = Number(matches[1])
|
||||||
|
|
||||||
|
return type === 'foreground' ? chalk.ansi256(value)(str) : chalk.bgAnsi256(value)(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color.startsWith('rgb')) {
|
||||||
|
const matches = RGB_REGEX.exec(color)
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstValue = Number(matches[1])
|
||||||
|
const secondValue = Number(matches[2])
|
||||||
|
const thirdValue = Number(matches[3])
|
||||||
|
|
||||||
|
return type === 'foreground'
|
||||||
|
? chalk.rgb(firstValue, secondValue, thirdValue)(str)
|
||||||
|
: chalk.bgRgb(firstValue, secondValue, thirdValue)(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply TextStyles to a string using chalk.
|
||||||
|
* This is the inverse of parsing ANSI codes - we generate them from structured styles.
|
||||||
|
* Theme resolution happens at component layer, not here.
|
||||||
|
*/
|
||||||
|
export function applyTextStyles(text: string, styles: TextStyles): string {
|
||||||
|
let result = text
|
||||||
|
|
||||||
|
// Apply styles in reverse order of desired nesting.
|
||||||
|
// chalk wraps text so later calls become outer wrappers.
|
||||||
|
// Desired order (outermost to innermost):
|
||||||
|
// background > foreground > text modifiers
|
||||||
|
// So we apply: text modifiers first, then foreground, then background last.
|
||||||
|
|
||||||
|
if (styles.inverse) {
|
||||||
|
result = chalk.inverse(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (styles.strikethrough) {
|
||||||
|
result = chalk.strikethrough(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (styles.underline) {
|
||||||
|
result = chalk.underline(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (styles.italic) {
|
||||||
|
result = chalk.italic(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (styles.bold) {
|
||||||
|
result = chalk.bold(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (styles.dim) {
|
||||||
|
result = chalk.dim(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (styles.color) {
|
||||||
|
// Color is now always a raw color value (theme resolution happens at component layer)
|
||||||
|
result = colorize(result, styles.color, 'foreground')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (styles.backgroundColor) {
|
||||||
|
// backgroundColor is now always a raw color value
|
||||||
|
result = colorize(result, styles.backgroundColor, 'background')
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a raw color value to text.
|
||||||
|
* Theme resolution should happen at component layer, not here.
|
||||||
|
*/
|
||||||
|
export function applyColor(text: string, color: Color | undefined): string {
|
||||||
|
if (!color) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
return colorize(text, color, 'foreground')
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
748
ui-tui/packages/hermes-ink/src/ink/components/App.tsx
Normal file
748
ui-tui/packages/hermes-ink/src/ink/components/App.tsx
Normal file
File diff suppressed because one or more lines are too long
20
ui-tui/packages/hermes-ink/src/ink/components/AppContext.ts
Normal file
20
ui-tui/packages/hermes-ink/src/ink/components/AppContext.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { createContext } from 'react'
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
/**
|
||||||
|
* Exit (unmount) the whole Ink app.
|
||||||
|
*/
|
||||||
|
readonly exit: (error?: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `AppContext` is a React context, which exposes a method to manually exit the app (unmount).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AppContext = createContext<Props>({
|
||||||
|
exit() {}
|
||||||
|
})
|
||||||
|
|
||||||
|
AppContext.displayName = 'InternalAppContext'
|
||||||
|
|
||||||
|
export default AppContext
|
||||||
265
ui-tui/packages/hermes-ink/src/ink/components/Box.tsx
Normal file
265
ui-tui/packages/hermes-ink/src/ink/components/Box.tsx
Normal file
File diff suppressed because one or more lines are too long
236
ui-tui/packages/hermes-ink/src/ink/components/Button.tsx
Normal file
236
ui-tui/packages/hermes-ink/src/ink/components/Button.tsx
Normal file
File diff suppressed because one or more lines are too long
133
ui-tui/packages/hermes-ink/src/ink/components/ClockContext.tsx
Normal file
133
ui-tui/packages/hermes-ink/src/ink/components/ClockContext.tsx
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { createContext } from 'react'
|
||||||
|
|
||||||
|
import type { DOMElement } from '../dom.js'
|
||||||
|
|
||||||
|
export type CursorDeclaration = {
|
||||||
|
/** Display column (terminal cell width) within the declared node */
|
||||||
|
readonly relativeX: number
|
||||||
|
/** Line number within the declared node */
|
||||||
|
readonly relativeY: number
|
||||||
|
/** The ink-box DOMElement whose yoga layout provides the absolute origin */
|
||||||
|
readonly node: DOMElement
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setter for the declared cursor position.
|
||||||
|
*
|
||||||
|
* The optional second argument makes `null` a conditional clear: the
|
||||||
|
* declaration is only cleared if the currently-declared node matches
|
||||||
|
* `clearIfNode`. This makes the hook safe for sibling components
|
||||||
|
* (e.g. list items) that transfer focus among themselves — without the
|
||||||
|
* node check, a newly-unfocused item's clear could clobber a
|
||||||
|
* newly-focused sibling's set depending on layout-effect order.
|
||||||
|
*/
|
||||||
|
export type CursorDeclarationSetter = (declaration: CursorDeclaration | null, clearIfNode?: DOMElement | null) => void
|
||||||
|
|
||||||
|
const CursorDeclarationContext = createContext<CursorDeclarationSetter>(() => {})
|
||||||
|
|
||||||
|
export default CursorDeclarationContext
|
||||||
130
ui-tui/packages/hermes-ink/src/ink/components/ErrorOverview.tsx
Normal file
130
ui-tui/packages/hermes-ink/src/ink/components/ErrorOverview.tsx
Normal file
File diff suppressed because one or more lines are too long
53
ui-tui/packages/hermes-ink/src/ink/components/Link.tsx
Normal file
53
ui-tui/packages/hermes-ink/src/ink/components/Link.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import React from 'react'
|
||||||
|
import { c as _c } from 'react/compiler-runtime'
|
||||||
|
|
||||||
|
import { supportsHyperlinks } from '../supports-hyperlinks.js'
|
||||||
|
|
||||||
|
import Text from './Text.js'
|
||||||
|
export type Props = {
|
||||||
|
readonly children?: ReactNode
|
||||||
|
readonly url: string
|
||||||
|
readonly fallback?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Link(t0) {
|
||||||
|
const $ = _c(5)
|
||||||
|
|
||||||
|
const { children, url, fallback } = t0
|
||||||
|
|
||||||
|
const content = children ?? url
|
||||||
|
|
||||||
|
if (supportsHyperlinks()) {
|
||||||
|
let t1
|
||||||
|
|
||||||
|
if ($[0] !== content || $[1] !== url) {
|
||||||
|
t1 = (
|
||||||
|
<Text>
|
||||||
|
<ink-link href={url}>{content}</ink-link>
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
$[0] = content
|
||||||
|
$[1] = url
|
||||||
|
$[2] = t1
|
||||||
|
} else {
|
||||||
|
t1 = $[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
return t1
|
||||||
|
}
|
||||||
|
|
||||||
|
const t1 = fallback ?? content
|
||||||
|
let t2
|
||||||
|
|
||||||
|
if ($[3] !== t1) {
|
||||||
|
t2 = <Text>{t1}</Text>
|
||||||
|
$[3] = t1
|
||||||
|
$[4] = t2
|
||||||
|
} else {
|
||||||
|
t2 = $[4]
|
||||||
|
}
|
||||||
|
|
||||||
|
return t2
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdE5vZGUiLCJSZWFjdCIsInN1cHBvcnRzSHlwZXJsaW5rcyIsIlRleHQiLCJQcm9wcyIsImNoaWxkcmVuIiwidXJsIiwiZmFsbGJhY2siLCJMaW5rIiwidDAiLCIkIiwiX2MiLCJjb250ZW50IiwidDEiLCJ0MiJdLCJzb3VyY2VzIjpbIkxpbmsudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB0eXBlIHsgUmVhY3ROb2RlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBzdXBwb3J0c0h5cGVybGlua3MgfSBmcm9tICcuLi9zdXBwb3J0cy1oeXBlcmxpbmtzLmpzJ1xuaW1wb3J0IFRleHQgZnJvbSAnLi9UZXh0LmpzJ1xuXG5leHBvcnQgdHlwZSBQcm9wcyA9IHtcbiAgcmVhZG9ubHkgY2hpbGRyZW4/OiBSZWFjdE5vZGVcbiAgcmVhZG9ubHkgdXJsOiBzdHJpbmdcbiAgcmVhZG9ubHkgZmFsbGJhY2s/OiBSZWFjdE5vZGVcbn1cblxuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gTGluayh7XG4gIGNoaWxkcmVuLFxuICB1cmwsXG4gIGZhbGxiYWNrLFxufTogUHJvcHMpOiBSZWFjdC5SZWFjdE5vZGUge1xuICAvLyBVc2UgY2hpbGRyZW4gaWYgcHJvdmlkZWQsIG90aGVyd2lzZSBkaXNwbGF5IHRoZSBVUkxcbiAgY29uc3QgY29udGVudCA9IGNoaWxkcmVuID8/IHVybFxuXG4gIGlmIChzdXBwb3J0c0h5cGVybGlua3MoKSkge1xuICAgIC8vIFdyYXAgaW4gVGV4dCB0byBlbnN1cmUgd2UncmUgaW4gYSB0ZXh0IGNvbnRleHRcbiAgICAvLyAoaW5rLWxpbmsgaXMgYSB0ZXh0IGVsZW1lbnQgbGlrZSBpbmstdGV4dClcbiAgICByZXR1cm4gKFxuICAgICAgPFRleHQ+XG4gICAgICAgIDxpbmstbGluayBocmVmPXt1cmx9Pntjb250ZW50fTwvaW5rLWxpbms+XG4gICAgICA8L1RleHQ+XG4gICAgKVxuICB9XG5cbiAgcmV0dXJuIDxUZXh0PntmYWxsYmFjayA/PyBjb250ZW50fTwvVGV4dD5cbn1cbiJdLCJtYXBwaW5ncyI6IjtBQUFBLGNBQWNBLFNBQVMsUUFBUSxPQUFPO0FBQ3RDLE9BQU9DLEtBQUssTUFBTSxPQUFPO0FBQ3pCLFNBQVNDLGtCQUFrQixRQUFRLDJCQUEyQjtBQUM5RCxPQUFPQyxJQUFJLE1BQU0sV0FBVztBQUU1QixPQUFPLEtBQUtDLEtBQUssR0FBRztFQUNsQixTQUFTQyxRQUFRLENBQUMsRUFBRUwsU0FBUztFQUM3QixTQUFTTSxHQUFHLEVBQUUsTUFBTTtFQUNwQixTQUFTQyxRQUFRLENBQUMsRUFBRVAsU0FBUztBQUMvQixDQUFDO0FBRUQsZUFBZSxTQUFBUSxLQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQWM7SUFBQU4sUUFBQTtJQUFBQyxHQUFBO0lBQUFDO0VBQUEsSUFBQUUsRUFJckI7RUFFTixNQUFBRyxPQUFBLEdBQWdCUCxRQUFlLElBQWZDLEdBQWU7RUFFL0IsSUFBSUosa0JBQWtCLENBQUMsQ0FBQztJQUFBLElBQUFXLEVBQUE7SUFBQSxJQUFBSCxDQUFBLFFBQUFFLE9BQUEsSUFBQUYsQ0FBQSxRQUFBSixHQUFBO01BSXBCTyxFQUFBLElBQUMsSUFBSSxDQUNILFNBQXlDLENBQXpCUCxJQUFHLENBQUhBLElBQUUsQ0FBQyxDQUFHTSxRQUFNLENBQUUsRUFBOUIsUUFBeUMsQ0FDM0MsRUFGQyxJQUFJLENBRUU7TUFBQUYsQ0FBQSxNQUFBRSxPQUFBO01BQUFGLENBQUEsTUFBQUosR0FBQTtNQUFBSSxDQUFBLE1BQUFHLEVBQUE7SUFBQTtNQUFBQSxFQUFBLEdBQUFILENBQUE7SUFBQTtJQUFBLE9BRlBHLEVBRU87RUFBQTtFQUlHLE1BQUFBLEVBQUEsR0FBQU4sUUFBbUIsSUFBbkJLLE9BQW1CO0VBQUEsSUFBQUUsRUFBQTtFQUFBLElBQUFKLENBQUEsUUFBQUcsRUFBQTtJQUExQkMsRUFBQSxJQUFDLElBQUksQ0FBRSxDQUFBRCxFQUFrQixDQUFFLEVBQTFCLElBQUksQ0FBNkI7SUFBQUgsQ0FBQSxNQUFBRyxFQUFBO0lBQUFILENBQUEsTUFBQUksRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUosQ0FBQTtFQUFBO0VBQUEsT0FBbENJLEVBQWtDO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||||
43
ui-tui/packages/hermes-ink/src/ink/components/Newline.tsx
Normal file
43
ui-tui/packages/hermes-ink/src/ink/components/Newline.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { c as _c } from 'react/compiler-runtime'
|
||||||
|
export type Props = {
|
||||||
|
/**
|
||||||
|
* Number of newlines to insert.
|
||||||
|
*
|
||||||
|
* @default 1
|
||||||
|
*/
|
||||||
|
readonly count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds one or more newline (\n) characters. Must be used within <Text> components.
|
||||||
|
*/
|
||||||
|
export default function Newline(t0) {
|
||||||
|
const $ = _c(4)
|
||||||
|
|
||||||
|
const { count: t1 } = t0
|
||||||
|
|
||||||
|
const count = t1 === undefined ? 1 : t1
|
||||||
|
let t2
|
||||||
|
|
||||||
|
if ($[0] !== count) {
|
||||||
|
t2 = '\n'.repeat(count)
|
||||||
|
$[0] = count
|
||||||
|
$[1] = t2
|
||||||
|
} else {
|
||||||
|
t2 = $[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
let t3
|
||||||
|
|
||||||
|
if ($[2] !== t2) {
|
||||||
|
t3 = <ink-text>{t2}</ink-text>
|
||||||
|
$[2] = t2
|
||||||
|
$[3] = t3
|
||||||
|
} else {
|
||||||
|
t3 = $[3]
|
||||||
|
}
|
||||||
|
|
||||||
|
return t3
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzIiwiY291bnQiLCJOZXdsaW5lIiwidDAiLCIkIiwiX2MiLCJ0MSIsInVuZGVmaW5lZCIsInQyIiwicmVwZWF0IiwidDMiXSwic291cmNlcyI6WyJOZXdsaW5lLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5cbmV4cG9ydCB0eXBlIFByb3BzID0ge1xuICAvKipcbiAgICogTnVtYmVyIG9mIG5ld2xpbmVzIHRvIGluc2VydC5cbiAgICpcbiAgICogQGRlZmF1bHQgMVxuICAgKi9cbiAgcmVhZG9ubHkgY291bnQ/OiBudW1iZXJcbn1cblxuLyoqXG4gKiBBZGRzIG9uZSBvciBtb3JlIG5ld2xpbmUgKFxcbikgY2hhcmFjdGVycy4gTXVzdCBiZSB1c2VkIHdpdGhpbiA8VGV4dD4gY29tcG9uZW50cy5cbiAqL1xuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gTmV3bGluZSh7IGNvdW50ID0gMSB9OiBQcm9wcykge1xuICByZXR1cm4gPGluay10ZXh0PnsnXFxuJy5yZXBlYXQoY291bnQpfTwvaW5rLXRleHQ+XG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUV6QixPQUFPLEtBQUtDLEtBQUssR0FBRztFQUNsQjtBQUNGO0FBQ0E7QUFDQTtBQUNBO0VBQ0UsU0FBU0MsS0FBSyxDQUFDLEVBQUUsTUFBTTtBQUN6QixDQUFDOztBQUVEO0FBQ0E7QUFDQTtBQUNBLGVBQWUsU0FBQUMsUUFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFpQjtJQUFBSixLQUFBLEVBQUFLO0VBQUEsSUFBQUgsRUFBb0I7RUFBbEIsTUFBQUYsS0FBQSxHQUFBSyxFQUFTLEtBQVRDLFNBQVMsR0FBVCxDQUFTLEdBQVRELEVBQVM7RUFBQSxJQUFBRSxFQUFBO0VBQUEsSUFBQUosQ0FBQSxRQUFBSCxLQUFBO0lBQ3ZCTyxFQUFBLE9BQUksQ0FBQUMsTUFBTyxDQUFDUixLQUFLLENBQUM7SUFBQUcsQ0FBQSxNQUFBSCxLQUFBO0lBQUFHLENBQUEsTUFBQUksRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUosQ0FBQTtFQUFBO0VBQUEsSUFBQU0sRUFBQTtFQUFBLElBQUFOLENBQUEsUUFBQUksRUFBQTtJQUE3QkUsRUFBQSxZQUF5QyxDQUE5QixDQUFBRixFQUFpQixDQUFFLEVBQTlCLFFBQXlDO0lBQUFKLENBQUEsTUFBQUksRUFBQTtJQUFBSixDQUFBLE1BQUFNLEVBQUE7RUFBQTtJQUFBQSxFQUFBLEdBQUFOLENBQUE7RUFBQTtFQUFBLE9BQXpDTSxFQUF5QztBQUFBIiwiaWdub3JlTGlzdCI6W119
|
||||||
73
ui-tui/packages/hermes-ink/src/ink/components/NoSelect.tsx
Normal file
73
ui-tui/packages/hermes-ink/src/ink/components/NoSelect.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { c as _c } from 'react/compiler-runtime'
|
||||||
|
|
||||||
|
import Box, { type Props as BoxProps } from './Box.js'
|
||||||
|
type Props = Omit<BoxProps, 'noSelect'> & {
|
||||||
|
/**
|
||||||
|
* Extend the exclusion zone from column 0 to this box's right edge,
|
||||||
|
* for every row this box occupies. Use for gutters rendered inside a
|
||||||
|
* wider indented container (e.g. a diff inside a tool message row):
|
||||||
|
* without this, a multi-row drag picks up the container's leading
|
||||||
|
* indent on rows below the prefix.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
fromLeftEdge?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks its contents as non-selectable in fullscreen text selection.
|
||||||
|
* Cells inside this box are skipped by both the selection highlight and
|
||||||
|
* the copied text — the gutter stays visually unchanged while the user
|
||||||
|
* drags, making it clear what will be copied.
|
||||||
|
*
|
||||||
|
* Use to fence off gutters (line numbers, diff +/- sigils, list bullets)
|
||||||
|
* so click-drag over rendered code yields clean pasteable content:
|
||||||
|
*
|
||||||
|
* <Box flexDirection="row">
|
||||||
|
* <NoSelect fromLeftEdge><Text dimColor> 42 +</Text></NoSelect>
|
||||||
|
* <Text>const x = 1</Text>
|
||||||
|
* </Box>
|
||||||
|
*
|
||||||
|
* Only affects alt-screen text selection (<AlternateScreen> with mouse
|
||||||
|
* tracking). No-op in the main-screen scrollback render where the
|
||||||
|
* terminal's native selection is used instead.
|
||||||
|
*/
|
||||||
|
export function NoSelect(t0) {
|
||||||
|
const $ = _c(8)
|
||||||
|
let boxProps
|
||||||
|
let children
|
||||||
|
let fromLeftEdge
|
||||||
|
|
||||||
|
if ($[0] !== t0) {
|
||||||
|
;({ children, fromLeftEdge, ...boxProps } = t0)
|
||||||
|
$[0] = t0
|
||||||
|
$[1] = boxProps
|
||||||
|
$[2] = children
|
||||||
|
$[3] = fromLeftEdge
|
||||||
|
} else {
|
||||||
|
boxProps = $[1]
|
||||||
|
children = $[2]
|
||||||
|
fromLeftEdge = $[3]
|
||||||
|
}
|
||||||
|
|
||||||
|
const t1 = fromLeftEdge ? 'from-left-edge' : true
|
||||||
|
let t2
|
||||||
|
|
||||||
|
if ($[4] !== boxProps || $[5] !== children || $[6] !== t1) {
|
||||||
|
t2 = (
|
||||||
|
<Box {...boxProps} noSelect={t1}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
$[4] = boxProps
|
||||||
|
$[5] = children
|
||||||
|
$[6] = t1
|
||||||
|
$[7] = t2
|
||||||
|
} else {
|
||||||
|
t2 = $[7]
|
||||||
|
}
|
||||||
|
|
||||||
|
return t2
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzV2l0aENoaWxkcmVuIiwiQm94IiwiUHJvcHMiLCJCb3hQcm9wcyIsIk9taXQiLCJmcm9tTGVmdEVkZ2UiLCJOb1NlbGVjdCIsInQwIiwiJCIsIl9jIiwiYm94UHJvcHMiLCJjaGlsZHJlbiIsInQxIiwidDIiXSwic291cmNlcyI6WyJOb1NlbGVjdC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0LCB7IHR5cGUgUHJvcHNXaXRoQ2hpbGRyZW4gfSBmcm9tICdyZWFjdCdcbmltcG9ydCBCb3gsIHsgdHlwZSBQcm9wcyBhcyBCb3hQcm9wcyB9IGZyb20gJy4vQm94LmpzJ1xuXG50eXBlIFByb3BzID0gT21pdDxCb3hQcm9wcywgJ25vU2VsZWN0Jz4gJiB7XG4gIC8qKlxuICAgKiBFeHRlbmQgdGhlIGV4Y2x1c2lvbiB6b25lIGZyb20gY29sdW1uIDAgdG8gdGhpcyBib3gncyByaWdodCBlZGdlLFxuICAgKiBmb3IgZXZlcnkgcm93IHRoaXMgYm94IG9jY3VwaWVzLiBVc2UgZm9yIGd1dHRlcnMgcmVuZGVyZWQgaW5zaWRlIGFcbiAgICogd2lkZXIgaW5kZW50ZWQgY29udGFpbmVyIChlLmcuIGEgZGlmZiBpbnNpZGUgYSB0b29sIG1lc3NhZ2Ugcm93KTpcbiAgICogd2l0aG91dCB0aGlzLCBhIG11bHRpLXJvdyBkcmFnIHBpY2tzIHVwIHRoZSBjb250YWluZXIncyBsZWFkaW5nXG4gICAqIGluZGVudCBvbiByb3dzIGJlbG93IHRoZSBwcmVmaXguXG4gICAqXG4gICAqIEBkZWZhdWx0IGZhbHNlXG4gICAqL1xuICBmcm9tTGVmdEVkZ2U/OiBib29sZWFuXG59XG5cbi8qKlxuICogTWFya3MgaXRzIGNvbnRlbnRzIGFzIG5vbi1zZWxlY3RhYmxlIGluIGZ1bGxzY3JlZW4gdGV4dCBzZWxlY3Rpb24uXG4gKiBDZWxscyBpbnNpZGUgdGhpcyBib3ggYXJlIHNraXBwZWQgYnkgYm90aCB0aGUgc2VsZWN0aW9uIGhpZ2hsaWdodCBhbmRcbiAqIHRoZSBjb3BpZWQgdGV4dCDigJQgdGhlIGd1dHRlciBzdGF5cyB2aXN1YWxseSB1bmNoYW5nZWQgd2hpbGUgdGhlIHVzZXJcbiAqIGRyYWdzLCBtYWtpbmcgaXQgY2xlYXIgd2hhdCB3aWxsIGJlIGNvcGllZC5cbiAqXG4gKiBVc2UgdG8gZmVuY2Ugb2ZmIGd1dHRlcnMgKGxpbmUgbnVtYmVycywgZGlmZiArLy0gc2lnaWxzLCBsaXN0IGJ1bGxldHMpXG4gKiBzbyBjbGljay1kcmFnIG92ZXIgcmVuZGVyZWQgY29kZSB5aWVsZHMgY2xlYW4gcGFzdGVhYmxlIGNvbnRlbnQ6XG4gKlxuICogICA8Qm94IGZsZXhEaXJlY3Rpb249XCJyb3dcIj5cbiAqICAgICA8Tm9TZWxlY3QgZnJvbUxlZnRFZGdlPjxUZXh0IGRpbUNvbG9yPiA0MiArPC9UZXh0PjwvTm9TZWxlY3Q+XG4gKiAgICAgPFRleHQ+Y29uc3QgeCA9IDE8L1RleHQ+XG4gKiAgIDwvQm94PlxuICpcbiAqIE9ubHkgYWZmZWN0cyBhbHQtc2NyZWVuIHRleHQgc2VsZWN0aW9uICg8QWx0ZXJuYXRlU2NyZWVuPiB3aXRoIG1vdXNlXG4gKiB0cmFja2luZykuIE5vLW9wIGluIHRoZSBtYWluLXNjcmVlbiBzY3JvbGxiYWNrIHJlbmRlciB3aGVyZSB0aGVcbiAqIHRlcm1pbmFsJ3MgbmF0aXZlIHNlbGVjdGlvbiBpcyB1c2VkIGluc3RlYWQuXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBOb1NlbGVjdCh7XG4gIGNoaWxkcmVuLFxuICBmcm9tTGVmdEVkZ2UsXG4gIC4uLmJveFByb3BzXG59OiBQcm9wc1dpdGhDaGlsZHJlbjxQcm9wcz4pOiBSZWFjdC5SZWFjdE5vZGUge1xuICByZXR1cm4gKFxuICAgIDxCb3ggey4uLmJveFByb3BzfSBub1NlbGVjdD17ZnJvbUxlZnRFZGdlID8gJ2Zyb20tbGVmdC1lZGdlJyA6IHRydWV9PlxuICAgICAge2NoaWxkcmVufVxuICAgIDwvQm94PlxuICApXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLElBQUksS0FBS0MsaUJBQWlCLFFBQVEsT0FBTztBQUNyRCxPQUFPQyxHQUFHLElBQUksS0FBS0MsS0FBSyxJQUFJQyxRQUFRLFFBQVEsVUFBVTtBQUV0RCxLQUFLRCxLQUFLLEdBQUdFLElBQUksQ0FBQ0QsUUFBUSxFQUFFLFVBQVUsQ0FBQyxHQUFHO0VBQ3hDO0FBQ0Y7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtFQUNFRSxZQUFZLENBQUMsRUFBRSxPQUFPO0FBQ3hCLENBQUM7O0FBRUQ7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0EsT0FBTyxTQUFBQyxTQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQUEsSUFBQUMsUUFBQTtFQUFBLElBQUFDLFFBQUE7RUFBQSxJQUFBTixZQUFBO0VBQUEsSUFBQUcsQ0FBQSxRQUFBRCxFQUFBO0lBQWtCO01BQUFJLFFBQUE7TUFBQU4sWUFBQTtNQUFBLEdBQUFLO0lBQUEsSUFBQUgsRUFJRTtJQUFBQyxDQUFBLE1BQUFELEVBQUE7SUFBQUMsQ0FBQSxNQUFBRSxRQUFBO0lBQUFGLENBQUEsTUFBQUcsUUFBQTtJQUFBSCxDQUFBLE1BQUFILFlBQUE7RUFBQTtJQUFBSyxRQUFBLEdBQUFGLENBQUE7SUFBQUcsUUFBQSxHQUFBSCxDQUFBO0lBQUFILFlBQUEsR0FBQUcsQ0FBQTtFQUFBO0VBRU0sTUFBQUksRUFBQSxHQUFBUCxZQUFZLEdBQVosZ0JBQXNDLEdBQXRDLElBQXNDO0VBQUEsSUFBQVEsRUFBQTtFQUFBLElBQUFMLENBQUEsUUFBQUUsUUFBQSxJQUFBRixDQUFBLFFBQUFHLFFBQUEsSUFBQUgsQ0FBQSxRQUFBSSxFQUFBO0lBQW5FQyxFQUFBLElBQUMsR0FBRyxLQUFLSCxRQUFRLEVBQVksUUFBc0MsQ0FBdEMsQ0FBQUUsRUFBcUMsQ0FBQyxDQUNoRUQsU0FBTyxDQUNWLEVBRkMsR0FBRyxDQUVFO0lBQUFILENBQUEsTUFBQUUsUUFBQTtJQUFBRixDQUFBLE1BQUFHLFFBQUE7SUFBQUgsQ0FBQSxNQUFBSSxFQUFBO0lBQUFKLENBQUEsTUFBQUssRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUwsQ0FBQTtFQUFBO0VBQUEsT0FGTkssRUFFTTtBQUFBIiwiaWdub3JlTGlzdCI6W119
|
||||||
61
ui-tui/packages/hermes-ink/src/ink/components/RawAnsi.tsx
Normal file
61
ui-tui/packages/hermes-ink/src/ink/components/RawAnsi.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { c as _c } from 'react/compiler-runtime'
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* Pre-rendered ANSI lines. Each element must be exactly one terminal row
|
||||||
|
* (already wrapped to `width` by the producer) with ANSI escape codes inline.
|
||||||
|
*/
|
||||||
|
lines: string[]
|
||||||
|
/** Column width the producer wrapped to. Sent to Yoga as the fixed leaf width. */
|
||||||
|
width: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bypass the <Ansi> → React tree → Yoga → squash → re-serialize roundtrip for
|
||||||
|
* content that is already terminal-ready.
|
||||||
|
*
|
||||||
|
* Use this when an external renderer (e.g. the ColorDiff NAPI module) has
|
||||||
|
* already produced ANSI-escaped, width-wrapped output. A normal <Ansi> mount
|
||||||
|
* reparses that output into one React <Text> per style span, lays out each
|
||||||
|
* span as a Yoga flex child, then walks the tree to re-emit the same escape
|
||||||
|
* codes it was given. For a long transcript full of syntax-highlighted diffs
|
||||||
|
* that roundtrip is the dominant cost of the render.
|
||||||
|
*
|
||||||
|
* This component emits a single Yoga leaf with a constant-time measure func
|
||||||
|
* (width × lines.length) and hands the joined string straight to output.write(),
|
||||||
|
* which already splits on '\n' and parses ANSI into the screen buffer.
|
||||||
|
*/
|
||||||
|
export function RawAnsi(t0) {
|
||||||
|
const $ = _c(6)
|
||||||
|
|
||||||
|
const { lines, width } = t0
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let t1
|
||||||
|
|
||||||
|
if ($[0] !== lines) {
|
||||||
|
t1 = lines.join('\n')
|
||||||
|
$[0] = lines
|
||||||
|
$[1] = t1
|
||||||
|
} else {
|
||||||
|
t1 = $[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
let t2
|
||||||
|
|
||||||
|
if ($[2] !== lines.length || $[3] !== t1 || $[4] !== width) {
|
||||||
|
t2 = <ink-raw-ansi rawHeight={lines.length} rawText={t1} rawWidth={width} />
|
||||||
|
$[2] = lines.length
|
||||||
|
$[3] = t1
|
||||||
|
$[4] = width
|
||||||
|
$[5] = t2
|
||||||
|
} else {
|
||||||
|
t2 = $[5]
|
||||||
|
}
|
||||||
|
|
||||||
|
return t2
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzIiwibGluZXMiLCJ3aWR0aCIsIlJhd0Fuc2kiLCJ0MCIsIiQiLCJfYyIsImxlbmd0aCIsInQxIiwiam9pbiIsInQyIl0sInNvdXJjZXMiOlsiUmF3QW5zaS50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuXG50eXBlIFByb3BzID0ge1xuICAvKipcbiAgICogUHJlLXJlbmRlcmVkIEFOU0kgbGluZXMuIEVhY2ggZWxlbWVudCBtdXN0IGJlIGV4YWN0bHkgb25lIHRlcm1pbmFsIHJvd1xuICAgKiAoYWxyZWFkeSB3cmFwcGVkIHRvIGB3aWR0aGAgYnkgdGhlIHByb2R1Y2VyKSB3aXRoIEFOU0kgZXNjYXBlIGNvZGVzIGlubGluZS5cbiAgICovXG4gIGxpbmVzOiBzdHJpbmdbXVxuICAvKiogQ29sdW1uIHdpZHRoIHRoZSBwcm9kdWNlciB3cmFwcGVkIHRvLiBTZW50IHRvIFlvZ2EgYXMgdGhlIGZpeGVkIGxlYWYgd2lkdGguICovXG4gIHdpZHRoOiBudW1iZXJcbn1cblxuLyoqXG4gKiBCeXBhc3MgdGhlIDxBbnNpPiDihpIgUmVhY3QgdHJlZSDihpIgWW9nYSDihpIgc3F1YXNoIOKGkiByZS1zZXJpYWxpemUgcm91bmR0cmlwIGZvclxuICogY29udGVudCB0aGF0IGlzIGFscmVhZHkgdGVybWluYWwtcmVhZHkuXG4gKlxuICogVXNlIHRoaXMgd2hlbiBhbiBleHRlcm5hbCByZW5kZXJlciAoZS5nLiB0aGUgQ29sb3JEaWZmIE5BUEkgbW9kdWxlKSBoYXNcbiAqIGFscmVhZHkgcHJvZHVjZWQgQU5TSS1lc2NhcGVkLCB3aWR0aC13cmFwcGVkIG91dHB1dC4gQSBub3JtYWwgPEFuc2k+IG1vdW50XG4gKiByZXBhcnNlcyB0aGF0IG91dHB1dCBpbnRvIG9uZSBSZWFjdCA8VGV4dD4gcGVyIHN0eWxlIHNwYW4sIGxheXMgb3V0IGVhY2hcbiAqIHNwYW4gYXMgYSBZb2dhIGZsZXggY2hpbGQsIHRoZW4gd2Fsa3MgdGhlIHRyZWUgdG8gcmUtZW1pdCB0aGUgc2FtZSBlc2NhcGVcbiAqIGNvZGVzIGl0IHdhcyBnaXZlbi4gRm9yIGEgbG9uZyB0cmFuc2NyaXB0IGZ1bGwgb2Ygc3ludGF4LWhpZ2hsaWdodGVkIGRpZmZzXG4gKiB0aGF0IHJvdW5kdHJpcCBpcyB0aGUgZG9taW5hbnQgY29zdCBvZiB0aGUgcmVuZGVyLlxuICpcbiAqIFRoaXMgY29tcG9uZW50IGVtaXRzIGEgc2luZ2xlIFlvZ2EgbGVhZiB3aXRoIGEgY29uc3RhbnQtdGltZSBtZWFzdXJlIGZ1bmNcbiAqICh3aWR0aCDDlyBsaW5lcy5sZW5ndGgpIGFuZCBoYW5kcyB0aGUgam9pbmVkIHN0cmluZyBzdHJhaWdodCB0byBvdXRwdXQud3JpdGUoKSxcbiAqIHdoaWNoIGFscmVhZHkgc3BsaXRzIG9uICdcXG4nIGFuZCBwYXJzZXMgQU5TSSBpbnRvIHRoZSBzY3JlZW4gYnVmZmVyLlxuICovXG5leHBvcnQgZnVuY3Rpb24gUmF3QW5zaSh7IGxpbmVzLCB3aWR0aCB9OiBQcm9wcyk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIGlmIChsaW5lcy5sZW5ndGggPT09IDApIHtcbiAgICByZXR1cm4gbnVsbFxuICB9XG4gIHJldHVybiAoXG4gICAgPGluay1yYXctYW5zaVxuICAgICAgcmF3VGV4dD17bGluZXMuam9pbignXFxuJyl9XG4gICAgICByYXdXaWR0aD17d2lkdGh9XG4gICAgICByYXdIZWlnaHQ9e2xpbmVzLmxlbmd0aH1cbiAgICAvPlxuICApXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUV6QixLQUFLQyxLQUFLLEdBQUc7RUFDWDtBQUNGO0FBQ0E7QUFDQTtFQUNFQyxLQUFLLEVBQUUsTUFBTSxFQUFFO0VBQ2Y7RUFDQUMsS0FBSyxFQUFFLE1BQU07QUFDZixDQUFDOztBQUVEO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBLE9BQU8sU0FBQUMsUUFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFpQjtJQUFBTCxLQUFBO0lBQUFDO0VBQUEsSUFBQUUsRUFBdUI7RUFDN0MsSUFBSUgsS0FBSyxDQUFBTSxNQUFPLEtBQUssQ0FBQztJQUFBLE9BQ2IsSUFBSTtFQUFBO0VBQ1osSUFBQUMsRUFBQTtFQUFBLElBQUFILENBQUEsUUFBQUosS0FBQTtJQUdZTyxFQUFBLEdBQUFQLEtBQUssQ0FBQVEsSUFBSyxDQUFDLElBQUksQ0FBQztJQUFBSixDQUFBLE1BQUFKLEtBQUE7SUFBQUksQ0FBQSxNQUFBRyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBSCxDQUFBO0VBQUE7RUFBQSxJQUFBSyxFQUFBO0VBQUEsSUFBQUwsQ0FBQSxRQUFBSixLQUFBLENBQUFNLE1BQUEsSUFBQUYsQ0FBQSxRQUFBRyxFQUFBLElBQUFILENBQUEsUUFBQUgsS0FBQTtJQUQzQlEsRUFBQSxnQkFJRSxDQUhTLE9BQWdCLENBQWhCLENBQUFGLEVBQWUsQ0FBQyxDQUNmTixRQUFLLENBQUxBLE1BQUksQ0FBQyxDQUNKLFNBQVksQ0FBWixDQUFBRCxLQUFLLENBQUFNLE1BQU0sQ0FBQyxHQUN2QjtJQUFBRixDQUFBLE1BQUFKLEtBQUEsQ0FBQU0sTUFBQTtJQUFBRixDQUFBLE1BQUFHLEVBQUE7SUFBQUgsQ0FBQSxNQUFBSCxLQUFBO0lBQUFHLENBQUEsTUFBQUssRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUwsQ0FBQTtFQUFBO0VBQUEsT0FKRkssRUFJRTtBQUFBIiwiaWdub3JlTGlzdCI6W119
|
||||||
285
ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx
Normal file
285
ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx
Normal file
File diff suppressed because one or more lines are too long
23
ui-tui/packages/hermes-ink/src/ink/components/Spacer.tsx
Normal file
23
ui-tui/packages/hermes-ink/src/ink/components/Spacer.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { c as _c } from 'react/compiler-runtime'
|
||||||
|
|
||||||
|
import Box from './Box.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A flexible space that expands along the major axis of its containing layout.
|
||||||
|
* It's useful as a shortcut for filling all the available spaces between elements.
|
||||||
|
*/
|
||||||
|
export default function Spacer() {
|
||||||
|
const $ = _c(1)
|
||||||
|
let t0
|
||||||
|
|
||||||
|
if ($[0] === Symbol.for('react.memo_cache_sentinel')) {
|
||||||
|
t0 = <Box flexGrow={1} />
|
||||||
|
$[0] = t0
|
||||||
|
} else {
|
||||||
|
t0 = $[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return t0
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkJveCIsIlNwYWNlciIsIiQiLCJfYyIsInQwIiwiU3ltYm9sIiwiZm9yIl0sInNvdXJjZXMiOlsiU3BhY2VyLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgQm94IGZyb20gJy4vQm94LmpzJ1xuXG4vKipcbiAqIEEgZmxleGlibGUgc3BhY2UgdGhhdCBleHBhbmRzIGFsb25nIHRoZSBtYWpvciBheGlzIG9mIGl0cyBjb250YWluaW5nIGxheW91dC5cbiAqIEl0J3MgdXNlZnVsIGFzIGEgc2hvcnRjdXQgZm9yIGZpbGxpbmcgYWxsIHRoZSBhdmFpbGFibGUgc3BhY2VzIGJldHdlZW4gZWxlbWVudHMuXG4gKi9cbmV4cG9ydCBkZWZhdWx0IGZ1bmN0aW9uIFNwYWNlcigpIHtcbiAgcmV0dXJuIDxCb3ggZmxleEdyb3c9ezF9IC8+XG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUN6QixPQUFPQyxHQUFHLE1BQU0sVUFBVTs7QUFFMUI7QUFDQTtBQUNBO0FBQ0E7QUFDQSxlQUFlLFNBQUFDLE9BQUE7RUFBQSxNQUFBQyxDQUFBLEdBQUFDLEVBQUE7RUFBQSxJQUFBQyxFQUFBO0VBQUEsSUFBQUYsQ0FBQSxRQUFBRyxNQUFBLENBQUFDLEdBQUE7SUFDTkYsRUFBQSxJQUFDLEdBQUcsQ0FBVyxRQUFDLENBQUQsR0FBQyxHQUFJO0lBQUFGLENBQUEsTUFBQUUsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUYsQ0FBQTtFQUFBO0VBQUEsT0FBcEJFLEVBQW9CO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { createContext } from 'react'
|
||||||
|
|
||||||
|
import { EventEmitter } from '../events/emitter.js'
|
||||||
|
import type { TerminalQuerier } from '../terminal-querier.js'
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
readonly stdin: NodeJS.ReadStream
|
||||||
|
readonly setRawMode: (value: boolean) => void
|
||||||
|
readonly isRawModeSupported: boolean
|
||||||
|
readonly exitOnCtrlC: boolean
|
||||||
|
readonly inputEmitter: EventEmitter
|
||||||
|
readonly querier: TerminalQuerier | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const StdinContext = createContext<Props>({
|
||||||
|
stdin: process.stdin,
|
||||||
|
inputEmitter: new EventEmitter(),
|
||||||
|
setRawMode() {},
|
||||||
|
isRawModeSupported: false,
|
||||||
|
exitOnCtrlC: true,
|
||||||
|
querier: null
|
||||||
|
})
|
||||||
|
|
||||||
|
StdinContext.displayName = 'StdinContext'
|
||||||
|
export default StdinContext
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import React, { createContext, useSyncExternalStore } from 'react'
|
||||||
|
import { c as _c } from 'react/compiler-runtime'
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTerminalFocused,
|
||||||
|
getTerminalFocusState,
|
||||||
|
subscribeTerminalFocus,
|
||||||
|
type TerminalFocusState
|
||||||
|
} from '../terminal-focus-state.js'
|
||||||
|
export type { TerminalFocusState }
|
||||||
|
export type TerminalFocusContextProps = {
|
||||||
|
readonly isTerminalFocused: boolean
|
||||||
|
readonly terminalFocusState: TerminalFocusState
|
||||||
|
}
|
||||||
|
|
||||||
|
const TerminalFocusContext = createContext<TerminalFocusContextProps>({
|
||||||
|
isTerminalFocused: true,
|
||||||
|
terminalFocusState: 'unknown'
|
||||||
|
})
|
||||||
|
|
||||||
|
TerminalFocusContext.displayName = 'TerminalFocusContext'
|
||||||
|
|
||||||
|
// Separate component so App.tsx doesn't re-render on focus changes.
|
||||||
|
// Children are a stable prop reference, so they don't re-render either —
|
||||||
|
// only components that consume the context will re-render.
|
||||||
|
export function TerminalFocusProvider(t0) {
|
||||||
|
const $ = _c(6)
|
||||||
|
|
||||||
|
const { children } = t0
|
||||||
|
|
||||||
|
const isTerminalFocused = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocused)
|
||||||
|
const terminalFocusState = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocusState)
|
||||||
|
let t1
|
||||||
|
|
||||||
|
if ($[0] !== isTerminalFocused || $[1] !== terminalFocusState) {
|
||||||
|
t1 = {
|
||||||
|
isTerminalFocused,
|
||||||
|
terminalFocusState
|
||||||
|
}
|
||||||
|
$[0] = isTerminalFocused
|
||||||
|
$[1] = terminalFocusState
|
||||||
|
$[2] = t1
|
||||||
|
} else {
|
||||||
|
t1 = $[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = t1
|
||||||
|
let t2
|
||||||
|
|
||||||
|
if ($[3] !== children || $[4] !== value) {
|
||||||
|
t2 = <TerminalFocusContext.Provider value={value}>{children}</TerminalFocusContext.Provider>
|
||||||
|
$[3] = children
|
||||||
|
$[4] = value
|
||||||
|
$[5] = t2
|
||||||
|
} else {
|
||||||
|
t2 = $[5]
|
||||||
|
}
|
||||||
|
|
||||||
|
return t2
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TerminalFocusContext
|
||||||
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsImNyZWF0ZUNvbnRleHQiLCJ1c2VNZW1vIiwidXNlU3luY0V4dGVybmFsU3RvcmUiLCJnZXRUZXJtaW5hbEZvY3VzZWQiLCJnZXRUZXJtaW5hbEZvY3VzU3RhdGUiLCJzdWJzY3JpYmVUZXJtaW5hbEZvY3VzIiwiVGVybWluYWxGb2N1c1N0YXRlIiwiVGVybWluYWxGb2N1c0NvbnRleHRQcm9wcyIsImlzVGVybWluYWxGb2N1c2VkIiwidGVybWluYWxGb2N1c1N0YXRlIiwiVGVybWluYWxGb2N1c0NvbnRleHQiLCJkaXNwbGF5TmFtZSIsIlRlcm1pbmFsRm9jdXNQcm92aWRlciIsInQwIiwiJCIsIl9jIiwiY2hpbGRyZW4iLCJ0MSIsInZhbHVlIiwidDIiXSwic291cmNlcyI6WyJUZXJtaW5hbEZvY3VzQ29udGV4dC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0LCB7IGNyZWF0ZUNvbnRleHQsIHVzZU1lbW8sIHVzZVN5bmNFeHRlcm5hbFN0b3JlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQge1xuICBnZXRUZXJtaW5hbEZvY3VzZWQsXG4gIGdldFRlcm1pbmFsRm9jdXNTdGF0ZSxcbiAgc3Vic2NyaWJlVGVybWluYWxGb2N1cyxcbiAgdHlwZSBUZXJtaW5hbEZvY3VzU3RhdGUsXG59IGZyb20gJy4uL3Rlcm1pbmFsLWZvY3VzLXN0YXRlLmpzJ1xuXG5leHBvcnQgdHlwZSB7IFRlcm1pbmFsRm9jdXNTdGF0ZSB9XG5cbmV4cG9ydCB0eXBlIFRlcm1pbmFsRm9jdXNDb250ZXh0UHJvcHMgPSB7XG4gIHJlYWRvbmx5IGlzVGVybWluYWxGb2N1c2VkOiBib29sZWFuXG4gIHJlYWRvbmx5IHRlcm1pbmFsRm9jdXNTdGF0ZTogVGVybWluYWxGb2N1c1N0YXRlXG59XG5cbmNvbnN0IFRlcm1pbmFsRm9jdXNDb250ZXh0ID0gY3JlYXRlQ29udGV4dDxUZXJtaW5hbEZvY3VzQ29udGV4dFByb3BzPih7XG4gIGlzVGVybWluYWxGb2N1c2VkOiB0cnVlLFxuICB0ZXJtaW5hbEZvY3VzU3RhdGU6ICd1bmtub3duJyxcbn0pXG5cbi8vIGVzbGludC1kaXNhYmxlLW5leHQtbGluZSBjdXN0b20tcnVsZXMvbm8tdG9wLWxldmVsLXNpZGUtZWZmZWN0c1xuVGVybWluYWxGb2N1c0NvbnRleHQuZGlzcGxheU5hbWUgPSAnVGVybWluYWxGb2N1c0NvbnRleHQnXG5cbi8vIFNlcGFyYXRlIGNvbXBvbmVudCBzbyBBcHAudHN4IGRvZXNuJ3QgcmUtcmVuZGVyIG9uIGZvY3VzIGNoYW5nZXMuXG4vLyBDaGlsZHJlbiBhcmUgYSBzdGFibGUgcHJvcCByZWZlcmVuY2UsIHNvIHRoZXkgZG9uJ3QgcmUtcmVuZGVyIGVpdGhlciDigJRcbi8vIG9ubHkgY29tcG9uZW50cyB0aGF0IGNvbnN1bWUgdGhlIGNvbnRleHQgd2lsbCByZS1yZW5kZXIuXG5leHBvcnQgZnVuY3Rpb24gVGVybWluYWxGb2N1c1Byb3ZpZGVyKHtcbiAgY2hpbGRyZW4sXG59OiB7XG4gIGNoaWxkcmVuOiBSZWFjdC5SZWFjdE5vZGVcbn0pOiBSZWFjdC5SZWFjdE5vZGUge1xuICBjb25zdCBpc1Rlcm1pbmFsRm9jdXNlZCA9IHVzZVN5bmNFeHRlcm5hbFN0b3JlKFxuICAgIHN1YnNjcmliZVRlcm1pbmFsRm9jdXMsXG4gICAgZ2V0VGVybWluYWxGb2N1c2VkLFxuICApXG4gIGNvbnN0IHRlcm1pbmFsRm9jdXNTdGF0ZSA9IHVzZVN5bmNFeHRlcm5hbFN0b3JlKFxuICAgIHN1YnNjcmliZVRlcm1pbmFsRm9jdXMsXG4gICAgZ2V0VGVybWluYWxGb2N1c1N0YXRlLFxuICApXG5cbiAgY29uc3QgdmFsdWUgPSB1c2VNZW1vKFxuICAgICgpID0+ICh7IGlzVGVybWluYWxGb2N1c2VkLCB0ZXJtaW5hbEZvY3VzU3RhdGUgfSksXG4gICAgW2lzVGVybWluYWxGb2N1c2VkLCB0ZXJtaW5hbEZvY3VzU3RhdGVdLFxuICApXG5cbiAgcmV0dXJuIChcbiAgICA8VGVybWluYWxGb2N1c0NvbnRleHQuUHJvdmlkZXIgdmFsdWU9e3ZhbHVlfT5cbiAgICAgIHtjaGlsZHJlbn1cbiAgICA8L1Rlcm1pbmFsRm9jdXNDb250ZXh0LlByb3ZpZGVyPlxuICApXG59XG5cbmV4cG9ydCBkZWZhdWx0IFRlcm1pbmFsRm9jdXNDb250ZXh0XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLElBQUlDLGFBQWEsRUFBRUMsT0FBTyxFQUFFQyxvQkFBb0IsUUFBUSxPQUFPO0FBQzNFLFNBQ0VDLGtCQUFrQixFQUNsQkMscUJBQXFCLEVBQ3JCQyxzQkFBc0IsRUFDdEIsS0FBS0Msa0JBQWtCLFFBQ2xCLDRCQUE0QjtBQUVuQyxjQUFjQSxrQkFBa0I7QUFFaEMsT0FBTyxLQUFLQyx5QkFBeUIsR0FBRztFQUN0QyxTQUFTQyxpQkFBaUIsRUFBRSxPQUFPO0VBQ25DLFNBQVNDLGtCQUFrQixFQUFFSCxrQkFBa0I7QUFDakQsQ0FBQztBQUVELE1BQU1JLG9CQUFvQixHQUFHVixhQUFhLENBQUNPLHlCQUF5QixDQUFDLENBQUM7RUFDcEVDLGlCQUFpQixFQUFFLElBQUk7RUFDdkJDLGtCQUFrQixFQUFFO0FBQ3RCLENBQUMsQ0FBQzs7QUFFRjtBQUNBQyxvQkFBb0IsQ0FBQ0MsV0FBVyxHQUFHLHNCQUFzQjs7QUFFekQ7QUFDQTtBQUNBO0FBQ0EsT0FBTyxTQUFBQyxzQkFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUErQjtJQUFBQztFQUFBLElBQUFILEVBSXJDO0VBQ0MsTUFBQUwsaUJBQUEsR0FBMEJOLG9CQUFvQixDQUM1Q0csc0JBQXNCLEVBQ3RCRixrQkFDRixDQUFDO0VBQ0QsTUFBQU0sa0JBQUEsR0FBMkJQLG9CQUFvQixDQUM3Q0csc0JBQXNCLEVBQ3RCRCxxQkFDRixDQUFDO0VBQUEsSUFBQWEsRUFBQTtFQUFBLElBQUFILENBQUEsUUFBQU4saUJBQUEsSUFBQU0sQ0FBQSxRQUFBTCxrQkFBQTtJQUdRUSxFQUFBO01BQUFULGlCQUFBO01BQUFDO0lBQXdDLENBQUM7SUFBQUssQ0FBQSxNQUFBTixpQkFBQTtJQUFBTSxDQUFBLE1BQUFMLGtCQUFBO0lBQUFLLENBQUEsTUFBQUcsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUgsQ0FBQTtFQUFBO0VBRGxELE1BQUFJLEtBQUEsR0FDU0QsRUFBeUM7RUFFakQsSUFBQUUsRUFBQTtFQUFBLElBQUFMLENBQUEsUUFBQUUsUUFBQSxJQUFBRixDQUFBLFFBQUFJLEtBQUE7SUFHQ0MsRUFBQSxrQ0FBc0NELEtBQUssQ0FBTEEsTUFBSSxDQUFDLENBQ3hDRixTQUFPLENBQ1YsZ0NBQWdDO0lBQUFGLENBQUEsTUFBQUUsUUFBQTtJQUFBRixDQUFBLE1BQUFJLEtBQUE7SUFBQUosQ0FBQSxNQUFBSyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBTCxDQUFBO0VBQUE7RUFBQSxPQUZoQ0ssRUFFZ0M7QUFBQTtBQUlwQyxlQUFlVCxvQkFBb0IiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { createContext } from 'react'
|
||||||
|
export type TerminalSize = {
|
||||||
|
columns: number
|
||||||
|
rows: number
|
||||||
|
}
|
||||||
|
export const TerminalSizeContext = createContext<TerminalSize | null>(null)
|
||||||
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJjcmVhdGVDb250ZXh0IiwiVGVybWluYWxTaXplIiwiY29sdW1ucyIsInJvd3MiLCJUZXJtaW5hbFNpemVDb250ZXh0Il0sInNvdXJjZXMiOlsiVGVybWluYWxTaXplQ29udGV4dC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgY3JlYXRlQ29udGV4dCB9IGZyb20gJ3JlYWN0J1xuXG5leHBvcnQgdHlwZSBUZXJtaW5hbFNpemUgPSB7XG4gIGNvbHVtbnM6IG51bWJlclxuICByb3dzOiBudW1iZXJcbn1cblxuZXhwb3J0IGNvbnN0IFRlcm1pbmFsU2l6ZUNvbnRleHQgPSBjcmVhdGVDb250ZXh0PFRlcm1pbmFsU2l6ZSB8IG51bGw+KG51bGwpXG4iXSwibWFwcGluZ3MiOiJBQUFBLFNBQVNBLGFBQWEsUUFBUSxPQUFPO0FBRXJDLE9BQU8sS0FBS0MsWUFBWSxHQUFHO0VBQ3pCQyxPQUFPLEVBQUUsTUFBTTtFQUNmQyxJQUFJLEVBQUUsTUFBTTtBQUNkLENBQUM7QUFFRCxPQUFPLE1BQU1DLG1CQUFtQixHQUFHSixhQUFhLENBQUNDLFlBQVksR0FBRyxJQUFJLENBQUMsQ0FBQyxJQUFJLENBQUMiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||||
296
ui-tui/packages/hermes-ink/src/ink/components/Text.tsx
Normal file
296
ui-tui/packages/hermes-ink/src/ink/components/Text.tsx
Normal file
File diff suppressed because one or more lines are too long
6
ui-tui/packages/hermes-ink/src/ink/constants.ts
Normal file
6
ui-tui/packages/hermes-ink/src/ink/constants.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// Shared frame interval for render throttling and animations (~60fps).
|
||||||
|
export const FRAME_INTERVAL_MS = 16
|
||||||
|
|
||||||
|
// Keep clock-driven animations at full speed when terminal focus changes.
|
||||||
|
// We still pause entirely when there are no keepAlive subscribers.
|
||||||
|
export const BLURRED_FRAME_INTERVAL_MS = FRAME_INTERVAL_MS
|
||||||
5
ui-tui/packages/hermes-ink/src/ink/cursor.ts
Normal file
5
ui-tui/packages/hermes-ink/src/ink/cursor.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export type Cursor = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
visible: boolean
|
||||||
|
}
|
||||||
485
ui-tui/packages/hermes-ink/src/ink/dom.ts
Normal file
485
ui-tui/packages/hermes-ink/src/ink/dom.ts
Normal file
|
|
@ -0,0 +1,485 @@
|
||||||
|
import type { FocusManager } from './focus.js'
|
||||||
|
import { createLayoutNode } from './layout/engine.js'
|
||||||
|
import type { LayoutNode } from './layout/node.js'
|
||||||
|
import { LayoutDisplay, LayoutMeasureMode } from './layout/node.js'
|
||||||
|
import measureText from './measure-text.js'
|
||||||
|
import { addPendingClear, nodeCache } from './node-cache.js'
|
||||||
|
import squashTextNodes from './squash-text-nodes.js'
|
||||||
|
import type { Styles, TextStyles } from './styles.js'
|
||||||
|
import { expandTabs } from './tabstops.js'
|
||||||
|
import wrapText from './wrap-text.js'
|
||||||
|
|
||||||
|
type InkNode = {
|
||||||
|
parentNode: DOMElement | undefined
|
||||||
|
yogaNode?: LayoutNode
|
||||||
|
style: Styles
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TextName = '#text'
|
||||||
|
export type ElementNames =
|
||||||
|
| 'ink-root'
|
||||||
|
| 'ink-box'
|
||||||
|
| 'ink-text'
|
||||||
|
| 'ink-virtual-text'
|
||||||
|
| 'ink-link'
|
||||||
|
| 'ink-progress'
|
||||||
|
| 'ink-raw-ansi'
|
||||||
|
|
||||||
|
export type NodeNames = ElementNames | TextName
|
||||||
|
|
||||||
|
export type DOMElement = {
|
||||||
|
nodeName: ElementNames
|
||||||
|
attributes: Record<string, DOMNodeAttribute>
|
||||||
|
childNodes: DOMNode[]
|
||||||
|
textStyles?: TextStyles
|
||||||
|
|
||||||
|
// Internal properties
|
||||||
|
onComputeLayout?: () => void
|
||||||
|
onRender?: () => void
|
||||||
|
onImmediateRender?: () => void
|
||||||
|
// Used to skip empty renders during React 19's effect double-invoke in test mode
|
||||||
|
hasRenderedContent?: boolean
|
||||||
|
|
||||||
|
// When true, this node needs re-rendering
|
||||||
|
dirty: boolean
|
||||||
|
// Set by the reconciler's hideInstance/unhideInstance; survives style updates.
|
||||||
|
isHidden?: boolean
|
||||||
|
// Event handlers set by the reconciler for the capture/bubble dispatcher.
|
||||||
|
// Stored separately from attributes so handler identity changes don't
|
||||||
|
// mark dirty and defeat the blit optimization.
|
||||||
|
_eventHandlers?: Record<string, unknown>
|
||||||
|
|
||||||
|
// Scroll state for overflow: 'scroll' boxes. scrollTop is the number of
|
||||||
|
// rows the content is scrolled down by. scrollHeight/scrollViewportHeight
|
||||||
|
// are computed at render time and stored for imperative access. stickyScroll
|
||||||
|
// auto-pins scrollTop to the bottom when content grows.
|
||||||
|
scrollTop?: number
|
||||||
|
// Accumulated scroll delta not yet applied to scrollTop. The renderer
|
||||||
|
// drains this at SCROLL_MAX_PER_FRAME rows/frame so fast flicks show
|
||||||
|
// intermediate frames instead of one big jump. Direction reversal
|
||||||
|
// naturally cancels (pure accumulator, no target tracking).
|
||||||
|
pendingScrollDelta?: number
|
||||||
|
// Render-time clamp bounds for virtual scroll. useVirtualScroll writes
|
||||||
|
// the currently-mounted children's coverage span; render-node-to-output
|
||||||
|
// clamps scrollTop to stay within it. Prevents blank screen when
|
||||||
|
// scrollTo's direct write races past React's async re-render — instead
|
||||||
|
// of painting spacer (blank), the renderer holds at the edge of mounted
|
||||||
|
// content until React catches up (next commit updates these bounds and
|
||||||
|
// the clamp releases). Undefined = no clamp (sticky-scroll, cold start).
|
||||||
|
scrollClampMin?: number
|
||||||
|
scrollClampMax?: number
|
||||||
|
scrollHeight?: number
|
||||||
|
scrollViewportHeight?: number
|
||||||
|
scrollViewportTop?: number
|
||||||
|
stickyScroll?: boolean
|
||||||
|
// Set by ScrollBox.scrollToElement; render-node-to-output reads
|
||||||
|
// el.yogaNode.getComputedTop() (FRESH — same Yoga pass as scrollHeight)
|
||||||
|
// and sets scrollTop = top + offset, then clears this. Unlike an
|
||||||
|
// imperative scrollTo(N) which bakes in a number that's stale by the
|
||||||
|
// time the throttled render fires, the element ref defers the position
|
||||||
|
// read to paint time. One-shot.
|
||||||
|
scrollAnchor?: { el: DOMElement; offset: number }
|
||||||
|
// Only set on ink-root. The document owns focus — any node can
|
||||||
|
// reach it by walking parentNode, like browser getRootNode().
|
||||||
|
focusManager?: FocusManager
|
||||||
|
// React component stack captured at createInstance time (reconciler.ts),
|
||||||
|
// e.g. ['ToolUseLoader', 'Messages', 'REPL']. Only populated when
|
||||||
|
// CLAUDE_CODE_DEBUG_REPAINTS is set. Used by findOwnerChainAtRow to
|
||||||
|
// attribute scrollback-diff full-resets to the component that caused them.
|
||||||
|
debugOwnerChain?: string[]
|
||||||
|
} & InkNode
|
||||||
|
|
||||||
|
export type TextNode = {
|
||||||
|
nodeName: TextName
|
||||||
|
nodeValue: string
|
||||||
|
} & InkNode
|
||||||
|
|
||||||
|
export type DOMNode<T = { nodeName: NodeNames }> = T extends {
|
||||||
|
nodeName: infer U
|
||||||
|
}
|
||||||
|
? U extends '#text'
|
||||||
|
? TextNode
|
||||||
|
: DOMElement
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type DOMNodeAttribute = boolean | string | number
|
||||||
|
|
||||||
|
export const createNode = (nodeName: ElementNames): DOMElement => {
|
||||||
|
const needsYogaNode = nodeName !== 'ink-virtual-text' && nodeName !== 'ink-link' && nodeName !== 'ink-progress'
|
||||||
|
|
||||||
|
const node: DOMElement = {
|
||||||
|
nodeName,
|
||||||
|
style: {},
|
||||||
|
attributes: {},
|
||||||
|
childNodes: [],
|
||||||
|
parentNode: undefined,
|
||||||
|
yogaNode: needsYogaNode ? createLayoutNode() : undefined,
|
||||||
|
dirty: false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeName === 'ink-text') {
|
||||||
|
node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node))
|
||||||
|
} else if (nodeName === 'ink-raw-ansi') {
|
||||||
|
node.yogaNode?.setMeasureFunc(measureRawAnsiNode.bind(null, node))
|
||||||
|
}
|
||||||
|
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appendChildNode = (node: DOMElement, childNode: DOMElement): void => {
|
||||||
|
if (childNode.parentNode) {
|
||||||
|
removeChildNode(childNode.parentNode, childNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
childNode.parentNode = node
|
||||||
|
node.childNodes.push(childNode)
|
||||||
|
|
||||||
|
if (childNode.yogaNode) {
|
||||||
|
node.yogaNode?.insertChild(childNode.yogaNode, node.yogaNode.getChildCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
markDirty(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const insertBeforeNode = (node: DOMElement, newChildNode: DOMNode, beforeChildNode: DOMNode): void => {
|
||||||
|
if (newChildNode.parentNode) {
|
||||||
|
removeChildNode(newChildNode.parentNode, newChildNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
newChildNode.parentNode = node
|
||||||
|
|
||||||
|
const index = node.childNodes.indexOf(beforeChildNode)
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
// Calculate yoga index BEFORE modifying childNodes.
|
||||||
|
// We can't use DOM index directly because some children (like ink-progress,
|
||||||
|
// ink-link, ink-virtual-text) don't have yogaNodes, so DOM indices don't
|
||||||
|
// match yoga indices.
|
||||||
|
let yogaIndex = 0
|
||||||
|
|
||||||
|
if (newChildNode.yogaNode && node.yogaNode) {
|
||||||
|
for (let i = 0; i < index; i++) {
|
||||||
|
if (node.childNodes[i]?.yogaNode) {
|
||||||
|
yogaIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.childNodes.splice(index, 0, newChildNode)
|
||||||
|
|
||||||
|
if (newChildNode.yogaNode && node.yogaNode) {
|
||||||
|
node.yogaNode.insertChild(newChildNode.yogaNode, yogaIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
markDirty(node)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node.childNodes.push(newChildNode)
|
||||||
|
|
||||||
|
if (newChildNode.yogaNode) {
|
||||||
|
node.yogaNode?.insertChild(newChildNode.yogaNode, node.yogaNode.getChildCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
markDirty(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const removeChildNode = (node: DOMElement, removeNode: DOMNode): void => {
|
||||||
|
if (removeNode.yogaNode) {
|
||||||
|
removeNode.parentNode?.yogaNode?.removeChild(removeNode.yogaNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect cached rects from the removed subtree so they can be cleared
|
||||||
|
collectRemovedRects(node, removeNode)
|
||||||
|
|
||||||
|
removeNode.parentNode = undefined
|
||||||
|
|
||||||
|
const index = node.childNodes.indexOf(removeNode)
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
node.childNodes.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
markDirty(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRemovedRects(parent: DOMElement, removed: DOMNode, underAbsolute = false): void {
|
||||||
|
if (removed.nodeName === '#text') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const elem = removed as DOMElement
|
||||||
|
// If this node or any ancestor in the removed subtree was absolute,
|
||||||
|
// its painted pixels may overlap non-siblings — flag for global blit
|
||||||
|
// disable. Normal-flow removals only affect direct siblings, which
|
||||||
|
// hasRemovedChild already handles.
|
||||||
|
const isAbsolute = underAbsolute || elem.style.position === 'absolute'
|
||||||
|
const cached = nodeCache.get(elem)
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
addPendingClear(parent, cached, isAbsolute)
|
||||||
|
nodeCache.delete(elem)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of elem.childNodes) {
|
||||||
|
collectRemovedRects(parent, child, isAbsolute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setAttribute = (node: DOMElement, key: string, value: DOMNodeAttribute): void => {
|
||||||
|
// Skip 'children' - React handles children via appendChild/removeChild,
|
||||||
|
// not attributes. React always passes a new children reference, so
|
||||||
|
// tracking it as an attribute would mark everything dirty every render.
|
||||||
|
if (key === 'children') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if unchanged
|
||||||
|
if (node.attributes[key] === value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node.attributes[key] = value
|
||||||
|
markDirty(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setStyle = (node: DOMNode, style: Styles): void => {
|
||||||
|
// Compare style properties to avoid marking dirty unnecessarily.
|
||||||
|
// React creates new style objects on every render even when unchanged.
|
||||||
|
if (stylesEqual(node.style, style)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node.style = style
|
||||||
|
markDirty(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setTextStyles = (node: DOMElement, textStyles: TextStyles): void => {
|
||||||
|
// Same dirty-check guard as setStyle: React (and buildTextStyles in Text.tsx)
|
||||||
|
// allocate a new textStyles object on every render even when values are
|
||||||
|
// unchanged, so compare by value to avoid markDirty -> yoga re-measurement
|
||||||
|
// on every Text re-render.
|
||||||
|
if (shallowEqual(node.textStyles, textStyles)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node.textStyles = textStyles
|
||||||
|
markDirty(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stylesEqual(a: Styles, b: Styles): boolean {
|
||||||
|
return shallowEqual(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
function shallowEqual<T extends object>(a: T | undefined, b: T | undefined): boolean {
|
||||||
|
// Fast path: same object reference (or both undefined)
|
||||||
|
if (a === b) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a === undefined || b === undefined) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all keys from both objects
|
||||||
|
const aKeys = Object.keys(a) as (keyof T)[]
|
||||||
|
const bKeys = Object.keys(b) as (keyof T)[]
|
||||||
|
|
||||||
|
// Different number of properties
|
||||||
|
if (aKeys.length !== bKeys.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare each property
|
||||||
|
for (const key of aKeys) {
|
||||||
|
if (a[key] !== b[key]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTextNode = (text: string): TextNode => {
|
||||||
|
const node: TextNode = {
|
||||||
|
nodeName: '#text',
|
||||||
|
nodeValue: text,
|
||||||
|
yogaNode: undefined,
|
||||||
|
parentNode: undefined,
|
||||||
|
style: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTextNodeValue(node, text)
|
||||||
|
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
const measureTextNode = function (
|
||||||
|
node: DOMNode,
|
||||||
|
width: number,
|
||||||
|
widthMode: LayoutMeasureMode
|
||||||
|
): { width: number; height: number } {
|
||||||
|
const rawText = node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node)
|
||||||
|
|
||||||
|
// Expand tabs for measurement (worst case: 8 spaces each).
|
||||||
|
// Actual tab expansion happens in output.ts based on screen position.
|
||||||
|
const text = expandTabs(rawText)
|
||||||
|
|
||||||
|
const dimensions = measureText(text, width)
|
||||||
|
|
||||||
|
// Text fits into container, no need to wrap
|
||||||
|
if (dimensions.width <= width) {
|
||||||
|
return dimensions
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is happening when <Box> is shrinking child nodes and layout asks
|
||||||
|
// if we can fit this text node in a <1px space, so we just say "no"
|
||||||
|
if (dimensions.width >= 1 && width > 0 && width < 1) {
|
||||||
|
return dimensions
|
||||||
|
}
|
||||||
|
|
||||||
|
// For text with embedded newlines (pre-wrapped content), avoid re-wrapping
|
||||||
|
// at measurement width when layout is asking for intrinsic size (Undefined mode).
|
||||||
|
// This prevents height inflation during min/max size checks.
|
||||||
|
//
|
||||||
|
// However, when layout provides an actual constraint (Exactly or AtMost mode),
|
||||||
|
// we must respect it and measure at that width. Otherwise, if the actual
|
||||||
|
// rendering width is smaller than the natural width, the text will wrap to
|
||||||
|
// more lines than layout expects, causing content to be truncated.
|
||||||
|
if (text.includes('\n') && widthMode === LayoutMeasureMode.Undefined) {
|
||||||
|
const effectiveWidth = Math.max(width, dimensions.width)
|
||||||
|
|
||||||
|
return measureText(text, effectiveWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
const textWrap = node.style?.textWrap ?? 'wrap'
|
||||||
|
const wrappedText = wrapText(text, width, textWrap)
|
||||||
|
|
||||||
|
return measureText(wrappedText, width)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ink-raw-ansi nodes hold pre-rendered ANSI strings with known dimensions.
|
||||||
|
// No stringWidth, no wrapping, no tab expansion — the producer (e.g. ColorDiff)
|
||||||
|
// already wrapped to the target width and each line is exactly one terminal row.
|
||||||
|
const measureRawAnsiNode = function (node: DOMElement): {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
width: node.attributes['rawWidth'] as number,
|
||||||
|
height: node.attributes['rawHeight'] as number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a node and all its ancestors as dirty for re-rendering.
|
||||||
|
* Also marks yoga dirty for text remeasurement if this is a text node.
|
||||||
|
*/
|
||||||
|
export const markDirty = (node?: DOMNode): void => {
|
||||||
|
let current: DOMNode | undefined = node
|
||||||
|
let markedYoga = false
|
||||||
|
|
||||||
|
while (current) {
|
||||||
|
if (current.nodeName !== '#text') {
|
||||||
|
;(current as DOMElement).dirty = true
|
||||||
|
|
||||||
|
// Only mark yoga dirty on leaf nodes that have measure functions
|
||||||
|
if (!markedYoga && (current.nodeName === 'ink-text' || current.nodeName === 'ink-raw-ansi') && current.yogaNode) {
|
||||||
|
current.yogaNode.markDirty()
|
||||||
|
markedYoga = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current.parentNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk to root and call its onRender (the throttled scheduleRender). Use for
|
||||||
|
// DOM-level mutations (scrollTop changes) that should trigger an Ink frame
|
||||||
|
// without going through React's reconciler. Pair with markDirty() so the
|
||||||
|
// renderer knows which subtree to re-evaluate.
|
||||||
|
export const scheduleRenderFrom = (node?: DOMNode): void => {
|
||||||
|
let cur: DOMNode | undefined = node
|
||||||
|
|
||||||
|
while (cur?.parentNode) {
|
||||||
|
cur = cur.parentNode
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cur && cur.nodeName !== '#text') {
|
||||||
|
;(cur as DOMElement).onRender?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setTextNodeValue = (node: TextNode, text: string): void => {
|
||||||
|
if (typeof text !== 'string') {
|
||||||
|
text = String(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if unchanged
|
||||||
|
if (node.nodeValue === text) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node.nodeValue = text
|
||||||
|
markDirty(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDOMElement(node: DOMElement | TextNode): node is DOMElement {
|
||||||
|
return node.nodeName !== '#text'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear yogaNode references recursively before freeing.
|
||||||
|
// freeRecursive() frees the node and ALL its children, so we must clear
|
||||||
|
// all yogaNode references to prevent dangling pointers.
|
||||||
|
export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => {
|
||||||
|
if ('childNodes' in node) {
|
||||||
|
for (const child of node.childNodes) {
|
||||||
|
clearYogaNodeReferences(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.yogaNode = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the React component stack responsible for content at screen row `y`.
|
||||||
|
*
|
||||||
|
* DFS the DOM tree accumulating yoga offsets. Returns the debugOwnerChain of
|
||||||
|
* the deepest node whose bounding box contains `y`. Called from ink.tsx when
|
||||||
|
* log-update triggers a full reset, to attribute the flicker to its source.
|
||||||
|
*
|
||||||
|
* Only useful when CLAUDE_CODE_DEBUG_REPAINTS is set (otherwise chains are
|
||||||
|
* undefined and this returns []).
|
||||||
|
*/
|
||||||
|
export function findOwnerChainAtRow(root: DOMElement, y: number): string[] {
|
||||||
|
let best: string[] = []
|
||||||
|
walk(root, 0)
|
||||||
|
|
||||||
|
return best
|
||||||
|
|
||||||
|
function walk(node: DOMElement, offsetY: number): void {
|
||||||
|
const yoga = node.yogaNode
|
||||||
|
|
||||||
|
if (!yoga || yoga.getDisplay() === LayoutDisplay.None) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = offsetY + yoga.getComputedTop()
|
||||||
|
const height = yoga.getComputedHeight()
|
||||||
|
|
||||||
|
if (y < top || y >= top + height) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.debugOwnerChain) {
|
||||||
|
best = node.debugOwnerChain
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of node.childNodes) {
|
||||||
|
if (isDOMElement(child)) {
|
||||||
|
walk(child, top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
ui-tui/packages/hermes-ink/src/ink/events/click-event.ts
Normal file
38
ui-tui/packages/hermes-ink/src/ink/events/click-event.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Event } from './event.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mouse click event. Fired on left-button release without drag, only when
|
||||||
|
* mouse tracking is enabled (i.e. inside <AlternateScreen>).
|
||||||
|
*
|
||||||
|
* Bubbles from the deepest hit node up through parentNode. Call
|
||||||
|
* stopImmediatePropagation() to prevent ancestors' onClick from firing.
|
||||||
|
*/
|
||||||
|
export class ClickEvent extends Event {
|
||||||
|
/** 0-indexed screen column of the click */
|
||||||
|
readonly col: number
|
||||||
|
/** 0-indexed screen row of the click */
|
||||||
|
readonly row: number
|
||||||
|
/**
|
||||||
|
* Click column relative to the current handler's Box (col - box.x).
|
||||||
|
* Recomputed by dispatchClick before each handler fires, so an onClick
|
||||||
|
* on a container sees coords relative to that container, not to any
|
||||||
|
* child the click landed on.
|
||||||
|
*/
|
||||||
|
localCol = 0
|
||||||
|
/** Click row relative to the current handler's Box (row - box.y). */
|
||||||
|
localRow = 0
|
||||||
|
/**
|
||||||
|
* True if the clicked cell has no visible content (unwritten in the
|
||||||
|
* screen buffer — both packed words are 0). Handlers can check this to
|
||||||
|
* ignore clicks on blank space to the right of text, so accidental
|
||||||
|
* clicks on empty terminal space don't toggle state.
|
||||||
|
*/
|
||||||
|
readonly cellIsBlank: boolean
|
||||||
|
|
||||||
|
constructor(col: number, row: number, cellIsBlank: boolean) {
|
||||||
|
super()
|
||||||
|
this.col = col
|
||||||
|
this.row = row
|
||||||
|
this.cellIsBlank = cellIsBlank
|
||||||
|
}
|
||||||
|
}
|
||||||
242
ui-tui/packages/hermes-ink/src/ink/events/dispatcher.ts
Normal file
242
ui-tui/packages/hermes-ink/src/ink/events/dispatcher.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
import {
|
||||||
|
ContinuousEventPriority,
|
||||||
|
DefaultEventPriority,
|
||||||
|
DiscreteEventPriority,
|
||||||
|
NoEventPriority
|
||||||
|
} from 'react-reconciler/constants.js'
|
||||||
|
|
||||||
|
import { logError } from '../../utils/log.js'
|
||||||
|
|
||||||
|
import { HANDLER_FOR_EVENT } from './event-handlers.js'
|
||||||
|
import type { EventTarget, TerminalEvent } from './terminal-event.js'
|
||||||
|
|
||||||
|
// --
|
||||||
|
|
||||||
|
type DispatchListener = {
|
||||||
|
node: EventTarget
|
||||||
|
handler: (event: TerminalEvent) => void
|
||||||
|
phase: 'capturing' | 'at_target' | 'bubbling'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHandler(
|
||||||
|
node: EventTarget,
|
||||||
|
eventType: string,
|
||||||
|
capture: boolean
|
||||||
|
): ((event: TerminalEvent) => void) | undefined {
|
||||||
|
const handlers = node._eventHandlers
|
||||||
|
|
||||||
|
if (!handlers) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = HANDLER_FOR_EVENT[eventType]
|
||||||
|
|
||||||
|
if (!mapping) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const propName = capture ? mapping.capture : mapping.bubble
|
||||||
|
|
||||||
|
if (!propName) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return handlers[propName] as ((event: TerminalEvent) => void) | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all listeners for an event in dispatch order.
|
||||||
|
*
|
||||||
|
* Uses react-dom's two-phase accumulation pattern:
|
||||||
|
* - Walk from target to root
|
||||||
|
* - Capture handlers are prepended (unshift) → root-first
|
||||||
|
* - Bubble handlers are appended (push) → target-first
|
||||||
|
*
|
||||||
|
* Result: [root-cap, ..., parent-cap, target-cap, target-bub, parent-bub, ..., root-bub]
|
||||||
|
*/
|
||||||
|
function collectListeners(target: EventTarget, event: TerminalEvent): DispatchListener[] {
|
||||||
|
const listeners: DispatchListener[] = []
|
||||||
|
|
||||||
|
let node: EventTarget | undefined = target
|
||||||
|
|
||||||
|
while (node) {
|
||||||
|
const isTarget = node === target
|
||||||
|
|
||||||
|
const captureHandler = getHandler(node, event.type, true)
|
||||||
|
const bubbleHandler = getHandler(node, event.type, false)
|
||||||
|
|
||||||
|
if (captureHandler) {
|
||||||
|
listeners.unshift({
|
||||||
|
node,
|
||||||
|
handler: captureHandler,
|
||||||
|
phase: isTarget ? 'at_target' : 'capturing'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bubbleHandler && (event.bubbles || isTarget)) {
|
||||||
|
listeners.push({
|
||||||
|
node,
|
||||||
|
handler: bubbleHandler,
|
||||||
|
phase: isTarget ? 'at_target' : 'bubbling'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
node = node.parentNode
|
||||||
|
}
|
||||||
|
|
||||||
|
return listeners
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute collected listeners with propagation control.
|
||||||
|
*
|
||||||
|
* Before each handler, calls event._prepareForTarget(node) so event
|
||||||
|
* subclasses can do per-node setup.
|
||||||
|
*/
|
||||||
|
function processDispatchQueue(listeners: DispatchListener[], event: TerminalEvent): void {
|
||||||
|
let previousNode: EventTarget | undefined
|
||||||
|
|
||||||
|
for (const { node, handler, phase } of listeners) {
|
||||||
|
if (event._isImmediatePropagationStopped()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event._isPropagationStopped() && node !== previousNode) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
event._setEventPhase(phase)
|
||||||
|
event._setCurrentTarget(node)
|
||||||
|
event._prepareForTarget(node)
|
||||||
|
|
||||||
|
try {
|
||||||
|
handler(event)
|
||||||
|
} catch (error) {
|
||||||
|
logError(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
previousNode = node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map terminal event types to React scheduling priorities.
|
||||||
|
* Mirrors react-dom's getEventPriority() switch.
|
||||||
|
*/
|
||||||
|
function getEventPriority(eventType: string): number {
|
||||||
|
switch (eventType) {
|
||||||
|
case 'keydown':
|
||||||
|
|
||||||
|
case 'keyup':
|
||||||
|
|
||||||
|
case 'click':
|
||||||
|
|
||||||
|
case 'focus':
|
||||||
|
|
||||||
|
case 'blur':
|
||||||
|
|
||||||
|
case 'paste':
|
||||||
|
return DiscreteEventPriority as number
|
||||||
|
|
||||||
|
case 'resize':
|
||||||
|
|
||||||
|
case 'scroll':
|
||||||
|
|
||||||
|
case 'mousemove':
|
||||||
|
return ContinuousEventPriority as number
|
||||||
|
|
||||||
|
default:
|
||||||
|
return DefaultEventPriority as number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --
|
||||||
|
|
||||||
|
type DiscreteUpdates = <A, B>(fn: (a: A, b: B) => boolean, a: A, b: B, c: undefined, d: undefined) => boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns event dispatch state and the capture/bubble dispatch loop.
|
||||||
|
*
|
||||||
|
* The reconciler host config reads currentEvent and currentUpdatePriority
|
||||||
|
* to implement resolveUpdatePriority, resolveEventType, and
|
||||||
|
* resolveEventTimeStamp — mirroring how react-dom's host config reads
|
||||||
|
* ReactDOMSharedInternals and window.event.
|
||||||
|
*
|
||||||
|
* discreteUpdates is injected after construction (by InkReconciler)
|
||||||
|
* to break the import cycle.
|
||||||
|
*/
|
||||||
|
export class Dispatcher {
|
||||||
|
currentEvent: TerminalEvent | null = null
|
||||||
|
currentUpdatePriority: number = DefaultEventPriority as number
|
||||||
|
discreteUpdates: DiscreteUpdates | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer event priority from the currently-dispatching event.
|
||||||
|
* Called by the reconciler host config's resolveUpdatePriority
|
||||||
|
* when no explicit priority has been set.
|
||||||
|
*/
|
||||||
|
resolveEventPriority(): number {
|
||||||
|
if (this.currentUpdatePriority !== (NoEventPriority as number)) {
|
||||||
|
return this.currentUpdatePriority
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentEvent) {
|
||||||
|
return getEventPriority(this.currentEvent.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DefaultEventPriority as number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch an event through capture and bubble phases.
|
||||||
|
* Returns true if preventDefault() was NOT called.
|
||||||
|
*/
|
||||||
|
dispatch(target: EventTarget, event: TerminalEvent): boolean {
|
||||||
|
const previousEvent = this.currentEvent
|
||||||
|
this.currentEvent = event
|
||||||
|
|
||||||
|
try {
|
||||||
|
event._setTarget(target)
|
||||||
|
|
||||||
|
const listeners = collectListeners(target, event)
|
||||||
|
processDispatchQueue(listeners, event)
|
||||||
|
|
||||||
|
event._setEventPhase('none')
|
||||||
|
event._setCurrentTarget(null)
|
||||||
|
|
||||||
|
return !event.defaultPrevented
|
||||||
|
} finally {
|
||||||
|
this.currentEvent = previousEvent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch with discrete (sync) priority.
|
||||||
|
* For user-initiated events: keyboard, click, focus, paste.
|
||||||
|
*/
|
||||||
|
dispatchDiscrete(target: EventTarget, event: TerminalEvent): boolean {
|
||||||
|
if (!this.discreteUpdates) {
|
||||||
|
return this.dispatch(target, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.discreteUpdates((t, e) => this.dispatch(t, e), target, event, undefined, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch with continuous priority.
|
||||||
|
* For high-frequency events: resize, scroll, mouse move.
|
||||||
|
*/
|
||||||
|
dispatchContinuous(target: EventTarget, event: TerminalEvent): boolean {
|
||||||
|
const previousPriority = this.currentUpdatePriority
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.currentUpdatePriority = ContinuousEventPriority as number
|
||||||
|
|
||||||
|
return this.dispatch(target, event)
|
||||||
|
} finally {
|
||||||
|
this.currentUpdatePriority = previousPriority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
ui-tui/packages/hermes-ink/src/ink/events/emitter.ts
Normal file
40
ui-tui/packages/hermes-ink/src/ink/events/emitter.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { EventEmitter as NodeEventEmitter } from 'events'
|
||||||
|
|
||||||
|
import { Event } from './event.js'
|
||||||
|
|
||||||
|
// Similar to node's builtin EventEmitter, but is also aware of our `Event`
|
||||||
|
// class, and so `emit` respects `stopImmediatePropagation()`.
|
||||||
|
export class EventEmitter extends NodeEventEmitter {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
// Disable the default maxListeners warning. In React, many components
|
||||||
|
// can legitimately listen to the same event (e.g., useInput hooks).
|
||||||
|
// The default limit of 10 causes spurious warnings.
|
||||||
|
this.setMaxListeners(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override emit(type: string | symbol, ...args: unknown[]): boolean {
|
||||||
|
// Delegate to node for `error`, since it's not treated like a normal event
|
||||||
|
if (type === 'error') {
|
||||||
|
return super.emit(type, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners = this.rawListeners(type)
|
||||||
|
|
||||||
|
if (listeners.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const ccEvent = args[0] instanceof Event ? args[0] : null
|
||||||
|
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener.apply(this, args)
|
||||||
|
|
||||||
|
if (ccEvent?.didStopImmediatePropagation()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
73
ui-tui/packages/hermes-ink/src/ink/events/event-handlers.ts
Normal file
73
ui-tui/packages/hermes-ink/src/ink/events/event-handlers.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import type { ClickEvent } from './click-event.js'
|
||||||
|
import type { FocusEvent } from './focus-event.js'
|
||||||
|
import type { KeyboardEvent } from './keyboard-event.js'
|
||||||
|
import type { PasteEvent } from './paste-event.js'
|
||||||
|
import type { ResizeEvent } from './resize-event.js'
|
||||||
|
|
||||||
|
type KeyboardEventHandler = (event: KeyboardEvent) => void
|
||||||
|
type FocusEventHandler = (event: FocusEvent) => void
|
||||||
|
type PasteEventHandler = (event: PasteEvent) => void
|
||||||
|
type ResizeEventHandler = (event: ResizeEvent) => void
|
||||||
|
type ClickEventHandler = (event: ClickEvent) => void
|
||||||
|
type HoverEventHandler = () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for event handlers on Box and other host components.
|
||||||
|
*
|
||||||
|
* Follows the React/DOM naming convention:
|
||||||
|
* - onEventName: handler for bubble phase
|
||||||
|
* - onEventNameCapture: handler for capture phase
|
||||||
|
*/
|
||||||
|
export type EventHandlerProps = {
|
||||||
|
onKeyDown?: KeyboardEventHandler
|
||||||
|
onKeyDownCapture?: KeyboardEventHandler
|
||||||
|
|
||||||
|
onFocus?: FocusEventHandler
|
||||||
|
onFocusCapture?: FocusEventHandler
|
||||||
|
onBlur?: FocusEventHandler
|
||||||
|
onBlurCapture?: FocusEventHandler
|
||||||
|
|
||||||
|
onPaste?: PasteEventHandler
|
||||||
|
onPasteCapture?: PasteEventHandler
|
||||||
|
|
||||||
|
onResize?: ResizeEventHandler
|
||||||
|
|
||||||
|
onClick?: ClickEventHandler
|
||||||
|
onMouseEnter?: HoverEventHandler
|
||||||
|
onMouseLeave?: HoverEventHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse lookup: event type string → handler prop names.
|
||||||
|
* Used by the dispatcher for O(1) handler lookup per node.
|
||||||
|
*/
|
||||||
|
export const HANDLER_FOR_EVENT: Record<
|
||||||
|
string,
|
||||||
|
{ bubble?: keyof EventHandlerProps; capture?: keyof EventHandlerProps }
|
||||||
|
> = {
|
||||||
|
keydown: { bubble: 'onKeyDown', capture: 'onKeyDownCapture' },
|
||||||
|
focus: { bubble: 'onFocus', capture: 'onFocusCapture' },
|
||||||
|
blur: { bubble: 'onBlur', capture: 'onBlurCapture' },
|
||||||
|
paste: { bubble: 'onPaste', capture: 'onPasteCapture' },
|
||||||
|
resize: { bubble: 'onResize' },
|
||||||
|
click: { bubble: 'onClick' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set of all event handler prop names, for the reconciler to detect
|
||||||
|
* event props and store them in _eventHandlers instead of attributes.
|
||||||
|
*/
|
||||||
|
export const EVENT_HANDLER_PROPS = new Set<string>([
|
||||||
|
'onKeyDown',
|
||||||
|
'onKeyDownCapture',
|
||||||
|
'onFocus',
|
||||||
|
'onFocusCapture',
|
||||||
|
'onBlur',
|
||||||
|
'onBlurCapture',
|
||||||
|
'onPaste',
|
||||||
|
'onPasteCapture',
|
||||||
|
'onResize',
|
||||||
|
'onClick',
|
||||||
|
'onMouseEnter',
|
||||||
|
'onMouseLeave'
|
||||||
|
])
|
||||||
11
ui-tui/packages/hermes-ink/src/ink/events/event.ts
Normal file
11
ui-tui/packages/hermes-ink/src/ink/events/event.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export class Event {
|
||||||
|
private _didStopImmediatePropagation = false
|
||||||
|
|
||||||
|
didStopImmediatePropagation(): boolean {
|
||||||
|
return this._didStopImmediatePropagation
|
||||||
|
}
|
||||||
|
|
||||||
|
stopImmediatePropagation(): void {
|
||||||
|
this._didStopImmediatePropagation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
18
ui-tui/packages/hermes-ink/src/ink/events/focus-event.ts
Normal file
18
ui-tui/packages/hermes-ink/src/ink/events/focus-event.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { type EventTarget, TerminalEvent } from './terminal-event.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus event for component focus changes.
|
||||||
|
*
|
||||||
|
* Dispatched when focus moves between elements. 'focus' fires on the
|
||||||
|
* newly focused element, 'blur' fires on the previously focused one.
|
||||||
|
* Both bubble, matching react-dom's use of focusin/focusout semantics
|
||||||
|
* so parent components can observe descendant focus changes.
|
||||||
|
*/
|
||||||
|
export class FocusEvent extends TerminalEvent {
|
||||||
|
readonly relatedTarget: EventTarget | null
|
||||||
|
|
||||||
|
constructor(type: 'focus' | 'blur', relatedTarget: EventTarget | null = null) {
|
||||||
|
super(type, { bubbles: true, cancelable: false })
|
||||||
|
this.relatedTarget = relatedTarget
|
||||||
|
}
|
||||||
|
}
|
||||||
184
ui-tui/packages/hermes-ink/src/ink/events/input-event.ts
Normal file
184
ui-tui/packages/hermes-ink/src/ink/events/input-event.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
import { nonAlphanumericKeys, type ParsedKey } from '../parse-keypress.js'
|
||||||
|
|
||||||
|
import { Event } from './event.js'
|
||||||
|
|
||||||
|
export type Key = {
|
||||||
|
upArrow: boolean
|
||||||
|
downArrow: boolean
|
||||||
|
leftArrow: boolean
|
||||||
|
rightArrow: boolean
|
||||||
|
pageDown: boolean
|
||||||
|
pageUp: boolean
|
||||||
|
wheelUp: boolean
|
||||||
|
wheelDown: boolean
|
||||||
|
home: boolean
|
||||||
|
end: boolean
|
||||||
|
return: boolean
|
||||||
|
escape: boolean
|
||||||
|
ctrl: boolean
|
||||||
|
shift: boolean
|
||||||
|
fn: boolean
|
||||||
|
tab: boolean
|
||||||
|
backspace: boolean
|
||||||
|
delete: boolean
|
||||||
|
meta: boolean
|
||||||
|
super: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKey(keypress: ParsedKey): [Key, string] {
|
||||||
|
const key: Key = {
|
||||||
|
upArrow: keypress.name === 'up',
|
||||||
|
downArrow: keypress.name === 'down',
|
||||||
|
leftArrow: keypress.name === 'left',
|
||||||
|
rightArrow: keypress.name === 'right',
|
||||||
|
pageDown: keypress.name === 'pagedown',
|
||||||
|
pageUp: keypress.name === 'pageup',
|
||||||
|
wheelUp: keypress.name === 'wheelup',
|
||||||
|
wheelDown: keypress.name === 'wheeldown',
|
||||||
|
home: keypress.name === 'home',
|
||||||
|
end: keypress.name === 'end',
|
||||||
|
return: keypress.name === 'return',
|
||||||
|
escape: keypress.name === 'escape',
|
||||||
|
fn: keypress.fn,
|
||||||
|
ctrl: keypress.ctrl,
|
||||||
|
shift: keypress.shift,
|
||||||
|
tab: keypress.name === 'tab',
|
||||||
|
backspace: keypress.name === 'backspace',
|
||||||
|
delete: keypress.name === 'delete',
|
||||||
|
// `parseKeypress` parses \u001B\u001B[A (meta + up arrow) as meta = false
|
||||||
|
// but with option = true, so we need to take this into account here
|
||||||
|
// to avoid breaking changes in Ink.
|
||||||
|
// TODO(vadimdemedes): consider removing this in the next major version.
|
||||||
|
meta: keypress.meta || keypress.name === 'escape' || keypress.option,
|
||||||
|
// Super (Cmd on macOS / Win key) — only arrives via kitty keyboard
|
||||||
|
// protocol CSI u sequences. Distinct from meta (Alt/Option) so
|
||||||
|
// bindings like cmd+c can be expressed separately from opt+c.
|
||||||
|
super: keypress.super
|
||||||
|
}
|
||||||
|
|
||||||
|
let input = keypress.ctrl ? keypress.name : keypress.sequence
|
||||||
|
|
||||||
|
// Handle undefined input case
|
||||||
|
if (input === undefined) {
|
||||||
|
input = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// When ctrl is set, keypress.name for space is the literal word "space".
|
||||||
|
// Convert to actual space character for consistency with the CSI u branch
|
||||||
|
// (which maps 'space' → ' '). Without this, ctrl+space leaks the literal
|
||||||
|
// word "space" into text input.
|
||||||
|
if (keypress.ctrl && input === 'space') {
|
||||||
|
input = ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress unrecognized escape sequences that were parsed as function keys
|
||||||
|
// (matched by FN_KEY_RE) but have no name in the keyName map.
|
||||||
|
// Examples: ESC[25~ (F13/Right Alt on Windows), ESC[26~ (F14), etc.
|
||||||
|
// Without this, the ESC prefix is stripped below and the remainder (e.g.,
|
||||||
|
// "[25~") leaks into the input as literal text.
|
||||||
|
if (keypress.code && !keypress.name) {
|
||||||
|
input = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress ESC-less SGR mouse fragments. When a heavy React commit blocks
|
||||||
|
// the event loop past App's 50ms NORMAL_TIMEOUT flush, a CSI split across
|
||||||
|
// stdin chunks gets its buffered ESC flushed as a lone Escape key, and the
|
||||||
|
// continuation arrives as a text token with name='' — which falls through
|
||||||
|
// all of parseKeypress's ESC-anchored regexes and the nonAlphanumericKeys
|
||||||
|
// clear below (name is falsy). The fragment then leaks into the prompt as
|
||||||
|
// literal `[<64;74;16M`. This is the same defensive sink as the F13 guard
|
||||||
|
// above; the underlying tokenizer-flush race is upstream of this layer.
|
||||||
|
if (!keypress.name && /^\[<\d+;\d+;\d+[Mm]/.test(input)) {
|
||||||
|
input = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip meta if it's still remaining after `parseKeypress`
|
||||||
|
// TODO(vadimdemedes): remove this in the next major version.
|
||||||
|
if (input.startsWith('\u001B')) {
|
||||||
|
input = input.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track whether we've already processed this as a special sequence
|
||||||
|
// that converted input to the key name (CSI u or application keypad mode).
|
||||||
|
// For these, we don't want to clear input with nonAlphanumericKeys check.
|
||||||
|
let processedAsSpecialSequence = false
|
||||||
|
|
||||||
|
// Handle CSI u sequences (Kitty keyboard protocol): after stripping ESC,
|
||||||
|
// we're left with "[codepoint;modifieru" (e.g., "[98;3u" for Alt+b).
|
||||||
|
// Use the parsed key name instead for input handling. Require a digit
|
||||||
|
// after [ — real CSI u is always [<digits>…u, and a bare startsWith('[')
|
||||||
|
// false-matches X10 mouse at row 85 (Cy = 85+32 = 'u'), leaking the
|
||||||
|
// literal text "mouse" into the prompt via processedAsSpecialSequence.
|
||||||
|
if (/^\[\d/.test(input) && input.endsWith('u')) {
|
||||||
|
if (!keypress.name) {
|
||||||
|
// Unmapped Kitty functional key (Caps Lock 57358, F13–F35, KP nav,
|
||||||
|
// bare modifiers, etc.) — keycodeToName() returned undefined. Swallow
|
||||||
|
// so the raw "[57358u" doesn't leak into the prompt. See #38781.
|
||||||
|
input = ''
|
||||||
|
} else {
|
||||||
|
// 'space' → ' '; 'escape' → '' (key.escape carries it;
|
||||||
|
// processedAsSpecialSequence bypasses the nonAlphanumericKeys
|
||||||
|
// clear below, so we must handle it explicitly here);
|
||||||
|
// otherwise use key name.
|
||||||
|
input = keypress.name === 'space' ? ' ' : keypress.name === 'escape' ? '' : keypress.name
|
||||||
|
}
|
||||||
|
|
||||||
|
processedAsSpecialSequence = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle xterm modifyOtherKeys sequences: after stripping ESC, we're left
|
||||||
|
// with "[27;modifier;keycode~" (e.g., "[27;3;98~" for Alt+b). Same
|
||||||
|
// extraction as CSI u — without this, printable-char keycodes (single-letter
|
||||||
|
// names) skip the nonAlphanumericKeys clear and leak "[27;..." as input.
|
||||||
|
if (input.startsWith('[27;') && input.endsWith('~')) {
|
||||||
|
if (!keypress.name) {
|
||||||
|
// Unmapped modifyOtherKeys keycode — swallow for consistency with
|
||||||
|
// the CSI u handler above. Practically untriggerable today (xterm
|
||||||
|
// modifyOtherKeys only sends ASCII keycodes, all mapped), but
|
||||||
|
// guards against future terminal behavior.
|
||||||
|
input = ''
|
||||||
|
} else {
|
||||||
|
input = keypress.name === 'space' ? ' ' : keypress.name === 'escape' ? '' : keypress.name
|
||||||
|
}
|
||||||
|
|
||||||
|
processedAsSpecialSequence = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle application keypad mode sequences: after stripping ESC,
|
||||||
|
// we're left with "O<letter>" (e.g., "Op" for numpad 0, "Oy" for numpad 9).
|
||||||
|
// Use the parsed key name (the digit character) for input handling.
|
||||||
|
if (input.startsWith('O') && input.length === 2 && keypress.name && keypress.name.length === 1) {
|
||||||
|
input = keypress.name
|
||||||
|
processedAsSpecialSequence = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear input for non-alphanumeric keys (arrows, function keys, etc.)
|
||||||
|
// Skip this for CSI u and application keypad mode sequences since
|
||||||
|
// those were already converted to their proper input characters.
|
||||||
|
if (!processedAsSpecialSequence && keypress.name && nonAlphanumericKeys.includes(keypress.name)) {
|
||||||
|
input = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set shift=true for uppercase letters (A-Z)
|
||||||
|
// Must check it's actually a letter, not just any char unchanged by toUpperCase
|
||||||
|
if (input.length === 1 && typeof input[0] === 'string' && input[0] >= 'A' && input[0] <= 'Z') {
|
||||||
|
key.shift = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return [key, input]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InputEvent extends Event {
|
||||||
|
readonly keypress: ParsedKey
|
||||||
|
readonly key: Key
|
||||||
|
readonly input: string
|
||||||
|
|
||||||
|
constructor(keypress: ParsedKey) {
|
||||||
|
super()
|
||||||
|
const [key, input] = parseKey(keypress)
|
||||||
|
|
||||||
|
this.keypress = keypress
|
||||||
|
this.key = key
|
||||||
|
this.input = input
|
||||||
|
}
|
||||||
|
}
|
||||||
57
ui-tui/packages/hermes-ink/src/ink/events/keyboard-event.ts
Normal file
57
ui-tui/packages/hermes-ink/src/ink/events/keyboard-event.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import type { ParsedKey } from '../parse-keypress.js'
|
||||||
|
|
||||||
|
import { TerminalEvent } from './terminal-event.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard event dispatched through the DOM tree via capture/bubble.
|
||||||
|
*
|
||||||
|
* Follows browser KeyboardEvent semantics: `key` is the literal character
|
||||||
|
* for printable keys ('a', '3', ' ', '/') and a multi-char name for
|
||||||
|
* special keys ('down', 'return', 'escape', 'f1'). The idiomatic
|
||||||
|
* printable-char check is `e.key.length === 1`.
|
||||||
|
*/
|
||||||
|
export class KeyboardEvent extends TerminalEvent {
|
||||||
|
readonly key: string
|
||||||
|
readonly ctrl: boolean
|
||||||
|
readonly shift: boolean
|
||||||
|
readonly meta: boolean
|
||||||
|
readonly superKey: boolean
|
||||||
|
readonly fn: boolean
|
||||||
|
|
||||||
|
constructor(parsedKey: ParsedKey) {
|
||||||
|
super('keydown', { bubbles: true, cancelable: true })
|
||||||
|
|
||||||
|
this.key = keyFromParsed(parsedKey)
|
||||||
|
this.ctrl = parsedKey.ctrl
|
||||||
|
this.shift = parsedKey.shift
|
||||||
|
this.meta = parsedKey.meta || parsedKey.option
|
||||||
|
this.superKey = parsedKey.super
|
||||||
|
this.fn = parsedKey.fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyFromParsed(parsed: ParsedKey): string {
|
||||||
|
const seq = parsed.sequence ?? ''
|
||||||
|
const name = parsed.name ?? ''
|
||||||
|
|
||||||
|
// Ctrl combos: sequence is a control byte (\x03 for ctrl+c), name is the
|
||||||
|
// letter. Browsers report e.key === 'c' with e.ctrlKey === true.
|
||||||
|
if (parsed.ctrl) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single printable char (space through ~, plus anything above ASCII):
|
||||||
|
// use the literal char. Browsers report e.key === '3', not 'Digit3'.
|
||||||
|
if (seq.length === 1) {
|
||||||
|
const code = seq.charCodeAt(0)
|
||||||
|
|
||||||
|
if (code >= 0x20 && code !== 0x7f) {
|
||||||
|
return seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special keys (arrows, F-keys, return, tab, escape, etc.): sequence is
|
||||||
|
// either an escape sequence (\x1b[B) or a control byte (\r, \t), so use
|
||||||
|
// the parsed name. Browsers report e.key === 'ArrowDown'.
|
||||||
|
return name || seq
|
||||||
|
}
|
||||||
107
ui-tui/packages/hermes-ink/src/ink/events/terminal-event.ts
Normal file
107
ui-tui/packages/hermes-ink/src/ink/events/terminal-event.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { Event } from './event.js'
|
||||||
|
|
||||||
|
type EventPhase = 'none' | 'capturing' | 'at_target' | 'bubbling'
|
||||||
|
|
||||||
|
type TerminalEventInit = {
|
||||||
|
bubbles?: boolean
|
||||||
|
cancelable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for all terminal events with DOM-style propagation.
|
||||||
|
*
|
||||||
|
* Extends Event so existing event types (ClickEvent, InputEvent,
|
||||||
|
* TerminalFocusEvent) share a common ancestor and can migrate later.
|
||||||
|
*
|
||||||
|
* Mirrors the browser's Event API: target, currentTarget, eventPhase,
|
||||||
|
* stopPropagation(), preventDefault(), timeStamp.
|
||||||
|
*/
|
||||||
|
export class TerminalEvent extends Event {
|
||||||
|
readonly type: string
|
||||||
|
readonly timeStamp: number
|
||||||
|
readonly bubbles: boolean
|
||||||
|
readonly cancelable: boolean
|
||||||
|
|
||||||
|
private _target: EventTarget | null = null
|
||||||
|
private _currentTarget: EventTarget | null = null
|
||||||
|
private _eventPhase: EventPhase = 'none'
|
||||||
|
private _propagationStopped = false
|
||||||
|
private _defaultPrevented = false
|
||||||
|
|
||||||
|
constructor(type: string, init?: TerminalEventInit) {
|
||||||
|
super()
|
||||||
|
this.type = type
|
||||||
|
this.timeStamp = performance.now()
|
||||||
|
this.bubbles = init?.bubbles ?? true
|
||||||
|
this.cancelable = init?.cancelable ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
get target(): EventTarget | null {
|
||||||
|
return this._target
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentTarget(): EventTarget | null {
|
||||||
|
return this._currentTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
get eventPhase(): EventPhase {
|
||||||
|
return this._eventPhase
|
||||||
|
}
|
||||||
|
|
||||||
|
get defaultPrevented(): boolean {
|
||||||
|
return this._defaultPrevented
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPropagation(): void {
|
||||||
|
this._propagationStopped = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override stopImmediatePropagation(): void {
|
||||||
|
super.stopImmediatePropagation()
|
||||||
|
this._propagationStopped = true
|
||||||
|
}
|
||||||
|
|
||||||
|
preventDefault(): void {
|
||||||
|
if (this.cancelable) {
|
||||||
|
this._defaultPrevented = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Internal setters used by the Dispatcher
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
_setTarget(target: EventTarget): void {
|
||||||
|
this._target = target
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
_setCurrentTarget(target: EventTarget | null): void {
|
||||||
|
this._currentTarget = target
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
_setEventPhase(phase: EventPhase): void {
|
||||||
|
this._eventPhase = phase
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
_isPropagationStopped(): boolean {
|
||||||
|
return this._propagationStopped
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
_isImmediatePropagationStopped(): boolean {
|
||||||
|
return this.didStopImmediatePropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for subclasses to do per-node setup before each handler fires.
|
||||||
|
* Default is a no-op.
|
||||||
|
*/
|
||||||
|
_prepareForTarget(_target: EventTarget): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventTarget = {
|
||||||
|
parentNode: EventTarget | undefined
|
||||||
|
_eventHandlers?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Event } from './event.js'
|
||||||
|
|
||||||
|
export type TerminalFocusEventType = 'terminalfocus' | 'terminalblur'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when the terminal window gains or loses focus.
|
||||||
|
*
|
||||||
|
* Uses DECSET 1004 focus reporting - the terminal sends:
|
||||||
|
* - CSI I (\x1b[I) when the terminal gains focus
|
||||||
|
* - CSI O (\x1b[O) when the terminal loses focus
|
||||||
|
*/
|
||||||
|
export class TerminalFocusEvent extends Event {
|
||||||
|
readonly type: TerminalFocusEventType
|
||||||
|
|
||||||
|
constructor(type: TerminalFocusEventType) {
|
||||||
|
super()
|
||||||
|
this.type = type
|
||||||
|
}
|
||||||
|
}
|
||||||
219
ui-tui/packages/hermes-ink/src/ink/focus.ts
Normal file
219
ui-tui/packages/hermes-ink/src/ink/focus.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
import type { DOMElement } from './dom.js'
|
||||||
|
import { FocusEvent } from './events/focus-event.js'
|
||||||
|
|
||||||
|
const MAX_FOCUS_STACK = 32
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DOM-like focus manager for the Ink terminal UI.
|
||||||
|
*
|
||||||
|
* Pure state — tracks activeElement and a focus stack. Has no reference
|
||||||
|
* to the tree; callers pass the root when tree walks are needed.
|
||||||
|
*
|
||||||
|
* Stored on the root DOMElement so any node can reach it by walking
|
||||||
|
* parentNode (like browser's `node.ownerDocument`).
|
||||||
|
*/
|
||||||
|
export class FocusManager {
|
||||||
|
activeElement: DOMElement | null = null
|
||||||
|
private dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean
|
||||||
|
private enabled = true
|
||||||
|
private focusStack: DOMElement[] = []
|
||||||
|
|
||||||
|
constructor(dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean) {
|
||||||
|
this.dispatchFocusEvent = dispatchFocusEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(node: DOMElement): void {
|
||||||
|
if (node === this.activeElement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = this.activeElement
|
||||||
|
|
||||||
|
if (previous) {
|
||||||
|
// Deduplicate before pushing to prevent unbounded growth from Tab cycling
|
||||||
|
const idx = this.focusStack.indexOf(previous)
|
||||||
|
|
||||||
|
if (idx !== -1) {
|
||||||
|
this.focusStack.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.focusStack.push(previous)
|
||||||
|
|
||||||
|
if (this.focusStack.length > MAX_FOCUS_STACK) {
|
||||||
|
this.focusStack.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dispatchFocusEvent(previous, new FocusEvent('blur', node))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeElement = node
|
||||||
|
this.dispatchFocusEvent(node, new FocusEvent('focus', previous))
|
||||||
|
}
|
||||||
|
|
||||||
|
blur(): void {
|
||||||
|
if (!this.activeElement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = this.activeElement
|
||||||
|
this.activeElement = null
|
||||||
|
this.dispatchFocusEvent(previous, new FocusEvent('blur', null))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the reconciler when a node is removed from the tree.
|
||||||
|
* Handles both the exact node and any focused descendant within
|
||||||
|
* the removed subtree. Dispatches blur and restores focus from stack.
|
||||||
|
*/
|
||||||
|
handleNodeRemoved(node: DOMElement, root: DOMElement): void {
|
||||||
|
// Remove the node and any descendants from the stack
|
||||||
|
this.focusStack = this.focusStack.filter(n => n !== node && isInTree(n, root))
|
||||||
|
|
||||||
|
// Check if activeElement is the removed node OR a descendant
|
||||||
|
if (!this.activeElement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeElement !== node && isInTree(this.activeElement, root)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = this.activeElement
|
||||||
|
this.activeElement = null
|
||||||
|
this.dispatchFocusEvent(removed, new FocusEvent('blur', null))
|
||||||
|
|
||||||
|
// Restore focus to the most recent still-mounted element
|
||||||
|
while (this.focusStack.length > 0) {
|
||||||
|
const candidate = this.focusStack.pop()!
|
||||||
|
|
||||||
|
if (isInTree(candidate, root)) {
|
||||||
|
this.activeElement = candidate
|
||||||
|
this.dispatchFocusEvent(candidate, new FocusEvent('focus', removed))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAutoFocus(node: DOMElement): void {
|
||||||
|
this.focus(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClickFocus(node: DOMElement): void {
|
||||||
|
const tabIndex = node.attributes['tabIndex']
|
||||||
|
|
||||||
|
if (typeof tabIndex !== 'number') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.focus(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
enable(): void {
|
||||||
|
this.enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
disable(): void {
|
||||||
|
this.enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
focusNext(root: DOMElement): void {
|
||||||
|
this.moveFocus(1, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
focusPrevious(root: DOMElement): void {
|
||||||
|
this.moveFocus(-1, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
private moveFocus(direction: 1 | -1, root: DOMElement): void {
|
||||||
|
if (!this.enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabbable = collectTabbable(root)
|
||||||
|
|
||||||
|
if (tabbable.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = this.activeElement ? tabbable.indexOf(this.activeElement) : -1
|
||||||
|
|
||||||
|
const nextIndex =
|
||||||
|
currentIndex === -1
|
||||||
|
? direction === 1
|
||||||
|
? 0
|
||||||
|
: tabbable.length - 1
|
||||||
|
: (currentIndex + direction + tabbable.length) % tabbable.length
|
||||||
|
|
||||||
|
const next = tabbable[nextIndex]
|
||||||
|
|
||||||
|
if (next) {
|
||||||
|
this.focus(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTabbable(root: DOMElement): DOMElement[] {
|
||||||
|
const result: DOMElement[] = []
|
||||||
|
walkTree(root, result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkTree(node: DOMElement, result: DOMElement[]): void {
|
||||||
|
const tabIndex = node.attributes['tabIndex']
|
||||||
|
|
||||||
|
if (typeof tabIndex === 'number' && tabIndex >= 0) {
|
||||||
|
result.push(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of node.childNodes) {
|
||||||
|
if (child.nodeName !== '#text') {
|
||||||
|
walkTree(child, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInTree(node: DOMElement, root: DOMElement): boolean {
|
||||||
|
let current: DOMElement | undefined = node
|
||||||
|
|
||||||
|
while (current) {
|
||||||
|
if (current === root) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current.parentNode
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk up to root and return it. The root is the node that holds
|
||||||
|
* the FocusManager — like browser's `node.getRootNode()`.
|
||||||
|
*/
|
||||||
|
export function getRootNode(node: DOMElement): DOMElement {
|
||||||
|
let current: DOMElement | undefined = node
|
||||||
|
|
||||||
|
while (current) {
|
||||||
|
if (current.focusManager) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current.parentNode
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Node is not in a tree with a FocusManager')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk up to root and return its FocusManager.
|
||||||
|
* Like browser's `node.ownerDocument` — focus belongs to the root.
|
||||||
|
*/
|
||||||
|
export function getFocusManager(node: DOMElement): FocusManager {
|
||||||
|
return getRootNode(node).focusManager!
|
||||||
|
}
|
||||||
116
ui-tui/packages/hermes-ink/src/ink/frame.ts
Normal file
116
ui-tui/packages/hermes-ink/src/ink/frame.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import type { Cursor } from './cursor.js'
|
||||||
|
import type { Size } from './layout/geometry.js'
|
||||||
|
import type { ScrollHint } from './render-node-to-output.js'
|
||||||
|
import { type CharPool, createScreen, type HyperlinkPool, type Screen, type StylePool } from './screen.js'
|
||||||
|
|
||||||
|
export type Frame = {
|
||||||
|
readonly screen: Screen
|
||||||
|
readonly viewport: Size
|
||||||
|
readonly cursor: Cursor
|
||||||
|
/** DECSTBM scroll optimization hint (alt-screen only, null otherwise). */
|
||||||
|
readonly scrollHint?: ScrollHint | null
|
||||||
|
/** A ScrollBox has remaining pendingScrollDelta — schedule another frame. */
|
||||||
|
readonly scrollDrainPending?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyFrame(
|
||||||
|
rows: number,
|
||||||
|
columns: number,
|
||||||
|
stylePool: StylePool,
|
||||||
|
charPool: CharPool,
|
||||||
|
hyperlinkPool: HyperlinkPool
|
||||||
|
): Frame {
|
||||||
|
return {
|
||||||
|
screen: createScreen(0, 0, stylePool, charPool, hyperlinkPool),
|
||||||
|
viewport: { width: columns, height: rows },
|
||||||
|
cursor: { x: 0, y: 0, visible: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlickerReason = 'resize' | 'offscreen' | 'clear'
|
||||||
|
|
||||||
|
export type FrameEvent = {
|
||||||
|
durationMs: number
|
||||||
|
/** Phase breakdown in ms + patch count. Populated when the ink instance
|
||||||
|
* has frame-timing instrumentation enabled (via onFrame wiring). */
|
||||||
|
phases?: {
|
||||||
|
/** createRenderer output: DOM → yoga layout → screen buffer */
|
||||||
|
renderer: number
|
||||||
|
/** LogUpdate.render(): screen diff → Patch[] (the hot path this PR optimizes) */
|
||||||
|
diff: number
|
||||||
|
/** optimize(): patch merge/dedupe */
|
||||||
|
optimize: number
|
||||||
|
/** writeDiffToTerminal(): serialize patches → ANSI → stdout */
|
||||||
|
write: number
|
||||||
|
/** Pre-optimize patch count (proxy for how much changed this frame) */
|
||||||
|
patches: number
|
||||||
|
/** yoga calculateLayout() time (runs in resetAfterCommit, before onRender) */
|
||||||
|
yoga: number
|
||||||
|
/** React reconcile time: scrollMutated → resetAfterCommit. 0 if no commit. */
|
||||||
|
commit: number
|
||||||
|
/** layoutNode() calls this frame (recursive, includes cache-hit returns) */
|
||||||
|
yogaVisited: number
|
||||||
|
/** measureFunc (text wrap/width) calls — the expensive part */
|
||||||
|
yogaMeasured: number
|
||||||
|
/** early returns via _hasL single-slot cache */
|
||||||
|
yogaCacheHits: number
|
||||||
|
/** total yoga Node instances alive (create - free). Growth = leak. */
|
||||||
|
yogaLive: number
|
||||||
|
}
|
||||||
|
flickers: Array<{
|
||||||
|
desiredHeight: number
|
||||||
|
availableHeight: number
|
||||||
|
reason: FlickerReason
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Patch =
|
||||||
|
| { type: 'stdout'; content: string }
|
||||||
|
| { type: 'clear'; count: number }
|
||||||
|
| {
|
||||||
|
type: 'clearTerminal'
|
||||||
|
reason: FlickerReason
|
||||||
|
// Populated by log-update when a scrollback diff triggers the reset.
|
||||||
|
// ink.tsx uses triggerY with findOwnerChainAtRow to attribute the
|
||||||
|
// flicker to its source React component.
|
||||||
|
debug?: { triggerY: number; prevLine: string; nextLine: string }
|
||||||
|
}
|
||||||
|
| { type: 'cursorHide' }
|
||||||
|
| { type: 'cursorShow' }
|
||||||
|
| { type: 'cursorMove'; x: number; y: number }
|
||||||
|
| { type: 'cursorTo'; col: number }
|
||||||
|
| { type: 'carriageReturn' }
|
||||||
|
| { type: 'hyperlink'; uri: string }
|
||||||
|
// Pre-serialized style transition string from StylePool.transition() —
|
||||||
|
// cached by (fromId, toId), zero allocations after warmup.
|
||||||
|
| { type: 'styleStr'; str: string }
|
||||||
|
|
||||||
|
export type Diff = Patch[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether the screen should be cleared based on the current and previous frame.
|
||||||
|
* Returns the reason for clearing, or undefined if no clear is needed.
|
||||||
|
*
|
||||||
|
* Screen clearing is triggered when:
|
||||||
|
* 1. Terminal has been resized (viewport dimensions changed) → 'resize'
|
||||||
|
* 2. Current frame screen height exceeds available terminal rows → 'offscreen'
|
||||||
|
* 3. Previous frame screen height exceeded available terminal rows → 'offscreen'
|
||||||
|
*/
|
||||||
|
export function shouldClearScreen(prevFrame: Frame, frame: Frame): FlickerReason | undefined {
|
||||||
|
const didResize =
|
||||||
|
frame.viewport.height !== prevFrame.viewport.height || frame.viewport.width !== prevFrame.viewport.width
|
||||||
|
|
||||||
|
if (didResize) {
|
||||||
|
return 'resize'
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFrameOverflows = frame.screen.height >= frame.viewport.height
|
||||||
|
|
||||||
|
const previousFrameOverflowed = prevFrame.screen.height >= prevFrame.viewport.height
|
||||||
|
|
||||||
|
if (currentFrameOverflows || previousFrameOverflowed) {
|
||||||
|
return 'offscreen'
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
27
ui-tui/packages/hermes-ink/src/ink/get-max-width.ts
Normal file
27
ui-tui/packages/hermes-ink/src/ink/get-max-width.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { LayoutEdge, type LayoutNode } from './layout/node.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the yoga node's content width (computed width minus padding and
|
||||||
|
* border).
|
||||||
|
*
|
||||||
|
* Warning: can return a value WIDER than the parent container. In a
|
||||||
|
* column-direction flex parent, width is the cross axis — align-items:
|
||||||
|
* stretch never shrinks children below their intrinsic size, so the text
|
||||||
|
* node overflows (standard CSS behavior). Yoga measures leaf nodes in two
|
||||||
|
* passes: the AtMost pass determines width, the Exactly pass determines
|
||||||
|
* height. getComputedWidth() reflects the wider AtMost result while
|
||||||
|
* getComputedHeight() reflects the narrower Exactly result. Callers that
|
||||||
|
* use this for wrapping should clamp to actual available screen space so
|
||||||
|
* the rendered line count stays consistent with the layout height.
|
||||||
|
*/
|
||||||
|
const getMaxWidth = (yogaNode: LayoutNode): number => {
|
||||||
|
return (
|
||||||
|
yogaNode.getComputedWidth() -
|
||||||
|
yogaNode.getComputedPadding(LayoutEdge.Left) -
|
||||||
|
yogaNode.getComputedPadding(LayoutEdge.Right) -
|
||||||
|
yogaNode.getComputedBorder(LayoutEdge.Left) -
|
||||||
|
yogaNode.getComputedBorder(LayoutEdge.Right)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getMaxWidth
|
||||||
1
ui-tui/packages/hermes-ink/src/ink/global.d.ts
vendored
Normal file
1
ui-tui/packages/hermes-ink/src/ink/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export {}
|
||||||
146
ui-tui/packages/hermes-ink/src/ink/hit-test.ts
Normal file
146
ui-tui/packages/hermes-ink/src/ink/hit-test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import type { DOMElement } from './dom.js'
|
||||||
|
import { ClickEvent } from './events/click-event.js'
|
||||||
|
import type { EventHandlerProps } from './events/event-handlers.js'
|
||||||
|
import { nodeCache } from './node-cache.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the deepest DOM element whose rendered rect contains (col, row).
|
||||||
|
*
|
||||||
|
* Uses the nodeCache populated by renderNodeToOutput — rects are in screen
|
||||||
|
* coordinates with all offsets (including scrollTop translation) already
|
||||||
|
* applied. Children are traversed in reverse so later siblings (painted on
|
||||||
|
* top) win. Nodes not in nodeCache (not rendered this frame, or lacking a
|
||||||
|
* yogaNode) are skipped along with their subtrees.
|
||||||
|
*
|
||||||
|
* Returns the hit node even if it has no onClick — dispatchClick walks up
|
||||||
|
* via parentNode to find handlers.
|
||||||
|
*/
|
||||||
|
export function hitTest(node: DOMElement, col: number, row: number): DOMElement | null {
|
||||||
|
const rect = nodeCache.get(node)
|
||||||
|
|
||||||
|
if (!rect) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col < rect.x || col >= rect.x + rect.width || row < rect.y || row >= rect.y + rect.height) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Later siblings paint on top; reversed traversal returns topmost hit.
|
||||||
|
for (let i = node.childNodes.length - 1; i >= 0; i--) {
|
||||||
|
const child = node.childNodes[i]!
|
||||||
|
|
||||||
|
if (child.nodeName === '#text') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const hit = hitTest(child, col, row)
|
||||||
|
|
||||||
|
if (hit) {
|
||||||
|
return hit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hit-test the root at (col, row) and bubble a ClickEvent from the deepest
|
||||||
|
* containing node up through parentNode. Only nodes with an onClick handler
|
||||||
|
* fire. Stops when a handler calls stopImmediatePropagation(). Returns
|
||||||
|
* true if at least one onClick handler fired.
|
||||||
|
*/
|
||||||
|
export function dispatchClick(root: DOMElement, col: number, row: number, cellIsBlank = false): boolean {
|
||||||
|
let target: DOMElement | undefined = hitTest(root, col, row) ?? undefined
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click-to-focus: find the closest focusable ancestor and focus it.
|
||||||
|
// root is always ink-root, which owns the FocusManager.
|
||||||
|
if (root.focusManager) {
|
||||||
|
let focusTarget: DOMElement | undefined = target
|
||||||
|
|
||||||
|
while (focusTarget) {
|
||||||
|
if (typeof focusTarget.attributes['tabIndex'] === 'number') {
|
||||||
|
root.focusManager.handleClickFocus(focusTarget)
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
focusTarget = focusTarget.parentNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = new ClickEvent(col, row, cellIsBlank)
|
||||||
|
let handled = false
|
||||||
|
|
||||||
|
while (target) {
|
||||||
|
const handler = target._eventHandlers?.onClick as ((event: ClickEvent) => void) | undefined
|
||||||
|
|
||||||
|
if (handler) {
|
||||||
|
handled = true
|
||||||
|
const rect = nodeCache.get(target)
|
||||||
|
|
||||||
|
if (rect) {
|
||||||
|
event.localCol = col - rect.x
|
||||||
|
event.localRow = row - rect.y
|
||||||
|
}
|
||||||
|
|
||||||
|
handler(event)
|
||||||
|
|
||||||
|
if (event.didStopImmediatePropagation()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target = target.parentNode
|
||||||
|
}
|
||||||
|
|
||||||
|
return handled
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire onMouseEnter/onMouseLeave as the pointer moves. Like DOM
|
||||||
|
* mouseenter/mouseleave: does NOT bubble — moving between children does
|
||||||
|
* not re-fire on the parent. Walks up from the hit node collecting every
|
||||||
|
* ancestor with a hover handler; diffs against the previous hovered set;
|
||||||
|
* fires leave on the nodes exited, enter on the nodes entered.
|
||||||
|
*
|
||||||
|
* Mutates `hovered` in place so the caller (App instance) can hold it
|
||||||
|
* across calls. Clears the set when the hit is null (cursor moved into a
|
||||||
|
* non-rendered gap or off the root rect).
|
||||||
|
*/
|
||||||
|
export function dispatchHover(root: DOMElement, col: number, row: number, hovered: Set<DOMElement>): void {
|
||||||
|
const next = new Set<DOMElement>()
|
||||||
|
let node: DOMElement | undefined = hitTest(root, col, row) ?? undefined
|
||||||
|
|
||||||
|
while (node) {
|
||||||
|
const h = node._eventHandlers as EventHandlerProps | undefined
|
||||||
|
|
||||||
|
if (h?.onMouseEnter || h?.onMouseLeave) {
|
||||||
|
next.add(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
node = node.parentNode
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const old of hovered) {
|
||||||
|
if (!next.has(old)) {
|
||||||
|
hovered.delete(old)
|
||||||
|
|
||||||
|
// Skip handlers on detached nodes (removed between mouse events)
|
||||||
|
if (old.parentNode) {
|
||||||
|
;(old._eventHandlers as EventHandlerProps | undefined)?.onMouseLeave?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n of next) {
|
||||||
|
if (!hovered.has(n)) {
|
||||||
|
hovered.add(n)
|
||||||
|
;(n._eventHandlers as EventHandlerProps | undefined)?.onMouseEnter?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { useContext, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { ClockContext } from '../components/ClockContext.js'
|
||||||
|
import type { DOMElement } from '../dom.js'
|
||||||
|
|
||||||
|
import { useTerminalViewport } from './use-terminal-viewport.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for synchronized animations that pause when offscreen.
|
||||||
|
*
|
||||||
|
* Returns a ref to attach to the animated element and the current animation time.
|
||||||
|
* All instances share the same clock, so animations stay in sync.
|
||||||
|
* The clock only runs when at least one keepAlive subscriber exists.
|
||||||
|
*
|
||||||
|
* Pass `null` to pause — unsubscribes from the clock so no ticks fire.
|
||||||
|
* Time freezes at the last value and resumes from the current clock time
|
||||||
|
* when a number is passed again.
|
||||||
|
*
|
||||||
|
* @param intervalMs - How often to update, or null to pause
|
||||||
|
* @returns [ref, time] - Ref to attach to element, elapsed time in ms
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* function Spinner() {
|
||||||
|
* const [ref, time] = useAnimationFrame(120)
|
||||||
|
* const frame = Math.floor(time / 120) % FRAMES.length
|
||||||
|
* return <Box ref={ref}>{FRAMES[frame]}</Box>
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* The clock automatically slows when the terminal is blurred,
|
||||||
|
* so consumers don't need to handle focus state.
|
||||||
|
*/
|
||||||
|
export function useAnimationFrame(
|
||||||
|
intervalMs: number | null = 16
|
||||||
|
): [ref: (element: DOMElement | null) => void, time: number] {
|
||||||
|
const clock = useContext(ClockContext)
|
||||||
|
const [viewportRef, { isVisible }] = useTerminalViewport()
|
||||||
|
const [time, setTime] = useState(() => clock?.now() ?? 0)
|
||||||
|
|
||||||
|
const active = isVisible && intervalMs !== null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!clock || !active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastUpdate = clock.now()
|
||||||
|
|
||||||
|
const onChange = (): void => {
|
||||||
|
const now = clock.now()
|
||||||
|
|
||||||
|
if (now - lastUpdate >= intervalMs!) {
|
||||||
|
lastUpdate = now
|
||||||
|
setTime(now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepAlive: true — visible animations drive the clock
|
||||||
|
return clock.subscribe(onChange, true)
|
||||||
|
}, [clock, intervalMs, active])
|
||||||
|
|
||||||
|
return [viewportRef, time]
|
||||||
|
}
|
||||||
9
ui-tui/packages/hermes-ink/src/ink/hooks/use-app.ts
Normal file
9
ui-tui/packages/hermes-ink/src/ink/hooks/use-app.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { useContext } from 'react'
|
||||||
|
|
||||||
|
import AppContext from '../components/AppContext.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `useApp` is a React hook, which exposes a method to manually exit the app (unmount).
|
||||||
|
*/
|
||||||
|
const useApp = () => useContext(AppContext)
|
||||||
|
export default useApp
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import CursorDeclarationContext from '../components/CursorDeclarationContext.js'
|
||||||
|
import type { DOMElement } from '../dom.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares where the terminal cursor should be parked after each frame.
|
||||||
|
*
|
||||||
|
* Terminal emulators render IME preedit text at the physical cursor
|
||||||
|
* position, and screen readers / screen magnifiers track the native
|
||||||
|
* cursor — so parking it at the text input's caret makes CJK input
|
||||||
|
* appear inline and lets accessibility tools follow the input.
|
||||||
|
*
|
||||||
|
* Returns a ref callback to attach to the Box that contains the input.
|
||||||
|
* The declared (line, column) is interpreted relative to that Box's
|
||||||
|
* nodeCache rect (populated by renderNodeToOutput).
|
||||||
|
*
|
||||||
|
* Timing: Both ref attach and useLayoutEffect fire in React's layout
|
||||||
|
* phase — after resetAfterCommit calls scheduleRender. scheduleRender
|
||||||
|
* defers onRender via queueMicrotask, so onRender runs AFTER layout
|
||||||
|
* effects commit and reads the fresh declaration on the first frame
|
||||||
|
* (no one-keystroke lag). Test env uses onImmediateRender (synchronous,
|
||||||
|
* no microtask), so tests compensate by calling ink.onRender()
|
||||||
|
* explicitly after render.
|
||||||
|
*/
|
||||||
|
export function useDeclaredCursor({
|
||||||
|
line,
|
||||||
|
column,
|
||||||
|
active
|
||||||
|
}: {
|
||||||
|
line: number
|
||||||
|
column: number
|
||||||
|
active: boolean
|
||||||
|
}): (element: DOMElement | null) => void {
|
||||||
|
const setCursorDeclaration = useContext(CursorDeclarationContext)
|
||||||
|
const nodeRef = useRef<DOMElement | null>(null)
|
||||||
|
|
||||||
|
const setNode = useCallback((node: DOMElement | null) => {
|
||||||
|
nodeRef.current = node
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// When active, set unconditionally. When inactive, clear conditionally
|
||||||
|
// (only if the currently-declared node is ours). The node-identity check
|
||||||
|
// handles two hazards:
|
||||||
|
// 1. A memo()ized active instance elsewhere (e.g. the search input in
|
||||||
|
// a memo'd Footer) doesn't re-render this commit — an inactive
|
||||||
|
// instance re-rendering here must not clobber it.
|
||||||
|
// 2. Sibling handoff (menu focus moving between list items) — when
|
||||||
|
// focus moves opposite to sibling order, the newly-inactive item's
|
||||||
|
// effect runs AFTER the newly-active item's set. Without the node
|
||||||
|
// check it would clobber.
|
||||||
|
// No dep array: must re-declare every commit so the active instance
|
||||||
|
// re-claims the declaration after another instance's unmount-cleanup or
|
||||||
|
// sibling handoff nulls it.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const node = nodeRef.current
|
||||||
|
|
||||||
|
if (active && node) {
|
||||||
|
setCursorDeclaration({ relativeX: column, relativeY: line, node })
|
||||||
|
} else {
|
||||||
|
setCursorDeclaration(null, node)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear on unmount (conditionally — another instance may own by then).
|
||||||
|
// Separate effect with empty deps so cleanup only fires once — not on
|
||||||
|
// every line/column change, which would transiently null between commits.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setCursorDeclaration(null, nodeRef.current)
|
||||||
|
}
|
||||||
|
}, [setCursorDeclaration])
|
||||||
|
|
||||||
|
return setNode
|
||||||
|
}
|
||||||
95
ui-tui/packages/hermes-ink/src/ink/hooks/use-input.ts
Normal file
95
ui-tui/packages/hermes-ink/src/ink/hooks/use-input.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { useEffect, useLayoutEffect } from 'react'
|
||||||
|
import { useEventCallback } from 'usehooks-ts'
|
||||||
|
|
||||||
|
import type { InputEvent, Key } from '../events/input-event.js'
|
||||||
|
|
||||||
|
import useStdin from './use-stdin.js'
|
||||||
|
|
||||||
|
type Handler = (input: string, key: Key, event: InputEvent) => void
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
/**
|
||||||
|
* Enable or disable capturing of user input.
|
||||||
|
* Useful when there are multiple useInput hooks used at once to avoid handling the same input several times.
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
isActive?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This hook is used for handling user input.
|
||||||
|
* It's a more convenient alternative to using `StdinContext` and listening to `data` events.
|
||||||
|
* The callback you pass to `useInput` is called for each character when user enters any input.
|
||||||
|
* However, if user pastes text and it's more than one character, the callback will be called only once and the whole string will be passed as `input`.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* import {useInput} from 'ink';
|
||||||
|
*
|
||||||
|
* const UserInput = () => {
|
||||||
|
* useInput((input, key) => {
|
||||||
|
* if (input === 'q') {
|
||||||
|
* // Exit program
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* if (key.leftArrow) {
|
||||||
|
* // Left arrow key pressed
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* return …
|
||||||
|
* };
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
const useInput = (inputHandler: Handler, options: Options = {}) => {
|
||||||
|
const { setRawMode, exitOnCtrlC, inputEmitter } = useStdin()
|
||||||
|
|
||||||
|
// useLayoutEffect (not useEffect) so that raw mode is enabled synchronously
|
||||||
|
// during React's commit phase, before render() returns. With useEffect, raw
|
||||||
|
// mode setup is deferred to the next event loop tick via React's scheduler,
|
||||||
|
// leaving the terminal in cooked mode — keystrokes echo and the cursor is
|
||||||
|
// visible until the effect fires.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (options.isActive === false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setRawMode(true)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setRawMode(false)
|
||||||
|
}
|
||||||
|
}, [options.isActive, setRawMode])
|
||||||
|
|
||||||
|
// Register the listener once on mount so its slot in the EventEmitter's
|
||||||
|
// listener array is stable. If isActive were in the effect's deps, the
|
||||||
|
// listener would re-append on false→true, moving it behind listeners
|
||||||
|
// that registered while it was inactive — breaking
|
||||||
|
// stopImmediatePropagation() ordering. useEventCallback keeps the
|
||||||
|
// reference stable while reading latest isActive/inputHandler from
|
||||||
|
// closure (it syncs via useLayoutEffect, so it's compiler-safe).
|
||||||
|
const handleData = useEventCallback((event: InputEvent) => {
|
||||||
|
if (options.isActive === false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { input, key } = event
|
||||||
|
|
||||||
|
// If app is not supposed to exit on Ctrl+C, then let input listener handle it
|
||||||
|
// Note: discreteUpdates is called at the App level when emitting events,
|
||||||
|
// so all listeners are already within a high-priority update context.
|
||||||
|
if (!(input === 'c' && key.ctrl) || !exitOnCtrlC) {
|
||||||
|
inputHandler(input, key, event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputEmitter?.on('input', handleData)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
inputEmitter?.removeListener('input', handleData)
|
||||||
|
}
|
||||||
|
}, [inputEmitter, handleData])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useInput
|
||||||
71
ui-tui/packages/hermes-ink/src/ink/hooks/use-interval.ts
Normal file
71
ui-tui/packages/hermes-ink/src/ink/hooks/use-interval.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { useContext, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { ClockContext } from '../components/ClockContext.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the clock time, updating at the given interval.
|
||||||
|
* Subscribes as non-keepAlive — won't keep the clock alive on its own,
|
||||||
|
* but updates whenever a keepAlive subscriber (e.g. the spinner)
|
||||||
|
* is driving the clock.
|
||||||
|
*
|
||||||
|
* Use this to drive pure time-based computations (shimmer position,
|
||||||
|
* frame index) from the shared clock.
|
||||||
|
*/
|
||||||
|
export function useAnimationTimer(intervalMs: number): number {
|
||||||
|
const clock = useContext(ClockContext)
|
||||||
|
const [time, setTime] = useState(() => clock?.now() ?? 0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!clock) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastUpdate = clock.now()
|
||||||
|
|
||||||
|
const onChange = (): void => {
|
||||||
|
const now = clock.now()
|
||||||
|
|
||||||
|
if (now - lastUpdate >= intervalMs) {
|
||||||
|
lastUpdate = now
|
||||||
|
setTime(now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clock.subscribe(onChange, false)
|
||||||
|
}, [clock, intervalMs])
|
||||||
|
|
||||||
|
return time
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interval hook backed by the shared Clock.
|
||||||
|
*
|
||||||
|
* Unlike `useInterval` from `usehooks-ts` (which creates its own setInterval),
|
||||||
|
* this piggybacks on the single shared clock so all timers consolidate into
|
||||||
|
* one wake-up. Pass `null` for intervalMs to pause.
|
||||||
|
*/
|
||||||
|
export function useInterval(callback: () => void, intervalMs: number | null): void {
|
||||||
|
const callbackRef = useRef(callback)
|
||||||
|
callbackRef.current = callback
|
||||||
|
|
||||||
|
const clock = useContext(ClockContext)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!clock || intervalMs === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastUpdate = clock.now()
|
||||||
|
|
||||||
|
const onChange = (): void => {
|
||||||
|
const now = clock.now()
|
||||||
|
|
||||||
|
if (now - lastUpdate >= intervalMs) {
|
||||||
|
lastUpdate = now
|
||||||
|
callbackRef.current()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clock.subscribe(onChange, false)
|
||||||
|
}, [clock, intervalMs])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { useContext, useMemo } from 'react'
|
||||||
|
|
||||||
|
import StdinContext from '../components/StdinContext.js'
|
||||||
|
import type { DOMElement } from '../dom.js'
|
||||||
|
import instances from '../instances.js'
|
||||||
|
import type { MatchPosition } from '../render-to-screen.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the search highlight query on the Ink instance. Non-empty → all
|
||||||
|
* visible occurrences are inverted on the next frame (SGR 7, screen-buffer
|
||||||
|
* overlay, same damage machinery as selection). Empty → clears.
|
||||||
|
*
|
||||||
|
* This is a screen-space highlight — it matches the RENDERED text, not the
|
||||||
|
* source message text. Works for anything visible (bash output, file paths,
|
||||||
|
* error messages) regardless of where it came from in the message tree. A
|
||||||
|
* query that matched in source but got truncated/ellipsized in rendering
|
||||||
|
* won't highlight; that's acceptable — we highlight what you see.
|
||||||
|
*/
|
||||||
|
export function useSearchHighlight(): {
|
||||||
|
setQuery: (query: string) => void
|
||||||
|
/** Paint an existing DOM subtree (from the MAIN tree) to a fresh
|
||||||
|
* Screen at its natural height, scan. Element-relative positions
|
||||||
|
* (row 0 = element top). Zero context duplication — the element
|
||||||
|
* IS the one built with all real providers. */
|
||||||
|
scanElement: (el: DOMElement) => MatchPosition[]
|
||||||
|
/** Position-based CURRENT highlight. Every frame writes yellow at
|
||||||
|
* positions[currentIdx] + rowOffset. The scan-highlight (inverse on
|
||||||
|
* all matches) still runs — this overlays on top. rowOffset tracks
|
||||||
|
* scroll; positions stay stable (message-relative). null clears. */
|
||||||
|
setPositions: (
|
||||||
|
state: {
|
||||||
|
positions: MatchPosition[]
|
||||||
|
rowOffset: number
|
||||||
|
currentIdx: number
|
||||||
|
} | null
|
||||||
|
) => void
|
||||||
|
} {
|
||||||
|
useContext(StdinContext) // anchor to App subtree for hook rules
|
||||||
|
const ink = instances.get(process.stdout)
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!ink) {
|
||||||
|
return {
|
||||||
|
setQuery: () => {},
|
||||||
|
scanElement: () => [],
|
||||||
|
setPositions: () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setQuery: (query: string) => ink.setSearchHighlight(query),
|
||||||
|
scanElement: (el: DOMElement) => ink.scanElementSubtree(el),
|
||||||
|
setPositions: state => ink.setSearchPositions(state)
|
||||||
|
}
|
||||||
|
}, [ink])
|
||||||
|
}
|
||||||
97
ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts
Normal file
97
ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { useContext, useMemo, useSyncExternalStore } from 'react'
|
||||||
|
|
||||||
|
import StdinContext from '../components/StdinContext.js'
|
||||||
|
import instances from '../instances.js'
|
||||||
|
import { type FocusMove, type SelectionState, shiftAnchor } from '../selection.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access to text selection operations on the Ink instance (fullscreen only).
|
||||||
|
* Returns no-op functions when fullscreen mode is disabled.
|
||||||
|
*/
|
||||||
|
export function useSelection(): {
|
||||||
|
copySelection: () => string
|
||||||
|
/** Copy without clearing the highlight (for copy-on-select). */
|
||||||
|
copySelectionNoClear: () => string
|
||||||
|
clearSelection: () => void
|
||||||
|
hasSelection: () => boolean
|
||||||
|
/** Read the raw mutable selection state (for drag-to-scroll). */
|
||||||
|
getState: () => SelectionState | null
|
||||||
|
/** Subscribe to selection mutations (start/update/finish/clear). */
|
||||||
|
subscribe: (cb: () => void) => () => void
|
||||||
|
/** Shift the anchor row by dRow, clamped to [minRow, maxRow]. */
|
||||||
|
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
||||||
|
/** Shift anchor AND focus by dRow (keyboard scroll: whole selection
|
||||||
|
* tracks content). Clamped points get col reset to the full-width edge
|
||||||
|
* since their content was captured by captureScrolledRows. Reads
|
||||||
|
* screen.width from the ink instance for the col-reset boundary. */
|
||||||
|
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
||||||
|
/** Keyboard selection extension (shift+arrow): move focus, anchor fixed.
|
||||||
|
* Left/right wrap across rows; up/down clamp at viewport edges. */
|
||||||
|
moveFocus: (move: FocusMove) => void
|
||||||
|
/** Capture text from rows about to scroll out of the viewport (call
|
||||||
|
* BEFORE scrollBy so the screen buffer still has the outgoing rows). */
|
||||||
|
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
|
||||||
|
/** Set the selection highlight bg color (theme-piping; solid bg
|
||||||
|
* replaces the old SGR-7 inverse so syntax highlighting stays readable
|
||||||
|
* under selection). Call once on mount + whenever theme changes. */
|
||||||
|
setSelectionBgColor: (color: string) => void
|
||||||
|
} {
|
||||||
|
// Look up the Ink instance via stdout — same pattern as instances map.
|
||||||
|
// StdinContext is available (it's always provided), and the Ink instance
|
||||||
|
// is keyed by stdout which we can get from process.stdout since there's
|
||||||
|
// only one Ink instance per process in practice.
|
||||||
|
useContext(StdinContext) // anchor to App subtree for hook rules
|
||||||
|
const ink = instances.get(process.stdout)
|
||||||
|
|
||||||
|
// Memoize so callers can safely use the return value in dependency arrays.
|
||||||
|
// ink is a singleton per stdout — stable across renders.
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!ink) {
|
||||||
|
return {
|
||||||
|
copySelection: () => '',
|
||||||
|
copySelectionNoClear: () => '',
|
||||||
|
clearSelection: () => {},
|
||||||
|
hasSelection: () => false,
|
||||||
|
getState: () => null,
|
||||||
|
subscribe: () => () => {},
|
||||||
|
shiftAnchor: () => {},
|
||||||
|
shiftSelection: () => {},
|
||||||
|
moveFocus: () => {},
|
||||||
|
captureScrolledRows: () => {},
|
||||||
|
setSelectionBgColor: () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
copySelection: () => ink.copySelection(),
|
||||||
|
copySelectionNoClear: () => ink.copySelectionNoClear(),
|
||||||
|
clearSelection: () => ink.clearTextSelection(),
|
||||||
|
hasSelection: () => ink.hasTextSelection(),
|
||||||
|
getState: () => ink.selection,
|
||||||
|
subscribe: (cb: () => void) => ink.subscribeToSelectionChange(cb),
|
||||||
|
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => shiftAnchor(ink.selection, dRow, minRow, maxRow),
|
||||||
|
shiftSelection: (dRow, minRow, maxRow) => ink.shiftSelectionForScroll(dRow, minRow, maxRow),
|
||||||
|
moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move),
|
||||||
|
captureScrolledRows: (firstRow, lastRow, side) => ink.captureScrolledRows(firstRow, lastRow, side),
|
||||||
|
setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color)
|
||||||
|
}
|
||||||
|
}, [ink])
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_SUBSCRIBE = () => () => {}
|
||||||
|
const ALWAYS_FALSE = () => false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive selection-exists state. Re-renders the caller when a text
|
||||||
|
* selection is created or cleared. Always returns false outside
|
||||||
|
* fullscreen mode (selection is only available in alt-screen).
|
||||||
|
*/
|
||||||
|
export function useHasSelection(): boolean {
|
||||||
|
useContext(StdinContext)
|
||||||
|
const ink = instances.get(process.stdout)
|
||||||
|
|
||||||
|
return useSyncExternalStore(
|
||||||
|
ink ? ink.subscribeToSelectionChange : NO_SUBSCRIBE,
|
||||||
|
ink ? ink.hasTextSelection : ALWAYS_FALSE
|
||||||
|
)
|
||||||
|
}
|
||||||
9
ui-tui/packages/hermes-ink/src/ink/hooks/use-stdin.ts
Normal file
9
ui-tui/packages/hermes-ink/src/ink/hooks/use-stdin.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { useContext } from 'react'
|
||||||
|
|
||||||
|
import StdinContext from '../components/StdinContext.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `useStdin` is a React hook, which exposes stdin stream.
|
||||||
|
*/
|
||||||
|
const useStdin = () => useContext(StdinContext)
|
||||||
|
export default useStdin
|
||||||
71
ui-tui/packages/hermes-ink/src/ink/hooks/use-tab-status.ts
Normal file
71
ui-tui/packages/hermes-ink/src/ink/hooks/use-tab-status.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { useContext, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import { CLEAR_TAB_STATUS, supportsTabStatus, tabStatus, wrapForMultiplexer } from '../termio/osc.js'
|
||||||
|
import type { Color } from '../termio/types.js'
|
||||||
|
import { TerminalWriteContext } from '../useTerminalNotification.js'
|
||||||
|
|
||||||
|
export type TabStatusKind = 'idle' | 'busy' | 'waiting'
|
||||||
|
|
||||||
|
const rgb = (r: number, g: number, b: number): Color => ({
|
||||||
|
type: 'rgb',
|
||||||
|
r,
|
||||||
|
g,
|
||||||
|
b
|
||||||
|
})
|
||||||
|
|
||||||
|
// Per the OSC 21337 usage guide's suggested mapping.
|
||||||
|
const TAB_STATUS_PRESETS: Record<TabStatusKind, { indicator: Color; status: string; statusColor: Color }> = {
|
||||||
|
idle: {
|
||||||
|
indicator: rgb(0, 215, 95),
|
||||||
|
status: 'Idle',
|
||||||
|
statusColor: rgb(136, 136, 136)
|
||||||
|
},
|
||||||
|
busy: {
|
||||||
|
indicator: rgb(255, 149, 0),
|
||||||
|
status: 'Working…',
|
||||||
|
statusColor: rgb(255, 149, 0)
|
||||||
|
},
|
||||||
|
waiting: {
|
||||||
|
indicator: rgb(95, 135, 255),
|
||||||
|
status: 'Waiting',
|
||||||
|
statusColor: rgb(95, 135, 255)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declaratively set the tab-status indicator (OSC 21337).
|
||||||
|
*
|
||||||
|
* Emits a colored dot + short status text to the tab sidebar. Terminals
|
||||||
|
* that don't support OSC 21337 discard the sequence silently, so this is
|
||||||
|
* safe to call unconditionally. Wrapped for tmux/screen passthrough.
|
||||||
|
*
|
||||||
|
* Pass `null` to opt out. If a status was previously set, transitioning to
|
||||||
|
* `null` emits CLEAR_TAB_STATUS so toggling off mid-session doesn't leave
|
||||||
|
* a stale dot. Process-exit cleanup is handled by ink.tsx's unmount path.
|
||||||
|
*/
|
||||||
|
export function useTabStatus(kind: TabStatusKind | null): void {
|
||||||
|
const writeRaw = useContext(TerminalWriteContext)
|
||||||
|
const prevKindRef = useRef<TabStatusKind | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// When kind transitions from non-null to null (e.g. user toggles off
|
||||||
|
// showStatusInTerminalTab mid-session), clear the stale dot.
|
||||||
|
if (kind === null) {
|
||||||
|
if (prevKindRef.current !== null && writeRaw && supportsTabStatus()) {
|
||||||
|
writeRaw(wrapForMultiplexer(CLEAR_TAB_STATUS))
|
||||||
|
}
|
||||||
|
|
||||||
|
prevKindRef.current = null
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prevKindRef.current = kind
|
||||||
|
|
||||||
|
if (!writeRaw || !supportsTabStatus()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeRaw(wrapForMultiplexer(tabStatus(TAB_STATUS_PRESETS[kind])))
|
||||||
|
}, [kind, writeRaw])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { useContext } from 'react'
|
||||||
|
|
||||||
|
import TerminalFocusContext from '../components/TerminalFocusContext.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to check if the terminal has focus.
|
||||||
|
*
|
||||||
|
* Uses DECSET 1004 focus reporting - the terminal sends escape sequences
|
||||||
|
* when it gains or loses focus. These are handled automatically
|
||||||
|
* by Ink and filtered from useInput.
|
||||||
|
*
|
||||||
|
* @returns true if the terminal is focused (or focus state is unknown)
|
||||||
|
*/
|
||||||
|
export function useTerminalFocus(): boolean {
|
||||||
|
const { isTerminalFocused } = useContext(TerminalFocusContext)
|
||||||
|
|
||||||
|
return isTerminalFocused
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { useContext, useEffect } from 'react'
|
||||||
|
import stripAnsi from 'strip-ansi'
|
||||||
|
|
||||||
|
import { OSC, osc } from '../termio/osc.js'
|
||||||
|
import { TerminalWriteContext } from '../useTerminalNotification.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declaratively set the terminal tab/window title.
|
||||||
|
*
|
||||||
|
* Pass a string to set the title. ANSI escape sequences are stripped
|
||||||
|
* automatically so callers don't need to know about terminal encoding.
|
||||||
|
* Pass `null` to opt out — the hook becomes a no-op and leaves the
|
||||||
|
* terminal title untouched.
|
||||||
|
*
|
||||||
|
* On Windows, uses `process.title` (classic conhost doesn't support OSC).
|
||||||
|
* Elsewhere, writes OSC 0 (set title+icon) via Ink's stdout.
|
||||||
|
*/
|
||||||
|
export function useTerminalTitle(title: string | null): void {
|
||||||
|
const writeRaw = useContext(TerminalWriteContext)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (title === null || !writeRaw) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const clean = stripAnsi(title)
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
process.title = clean
|
||||||
|
} else {
|
||||||
|
writeRaw(osc(OSC.SET_TITLE_AND_ICON, clean))
|
||||||
|
}
|
||||||
|
}, [title, writeRaw])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import { TerminalSizeContext } from '../components/TerminalSizeContext.js'
|
||||||
|
import type { DOMElement } from '../dom.js'
|
||||||
|
|
||||||
|
type ViewportEntry = {
|
||||||
|
/**
|
||||||
|
* Whether the element is currently within the terminal viewport
|
||||||
|
*/
|
||||||
|
isVisible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect if a component is within the terminal viewport.
|
||||||
|
*
|
||||||
|
* Returns a callback ref and a viewport entry object.
|
||||||
|
* Attach the ref to the component you want to track.
|
||||||
|
*
|
||||||
|
* The entry is updated during the layout phase (useLayoutEffect) so callers
|
||||||
|
* always read fresh values during render. Visibility changes do NOT trigger
|
||||||
|
* re-renders on their own — callers that re-render for other reasons (e.g.
|
||||||
|
* animation ticks, state changes) will pick up the latest value naturally.
|
||||||
|
* This avoids infinite update loops when combined with other layout effects
|
||||||
|
* that also call setState.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const [ref, entry] = useTerminalViewport()
|
||||||
|
* return <Box ref={ref}><Animation enabled={entry.isVisible}>...</Animation></Box>
|
||||||
|
*/
|
||||||
|
export function useTerminalViewport(): [ref: (element: DOMElement | null) => void, entry: ViewportEntry] {
|
||||||
|
const terminalSize = useContext(TerminalSizeContext)
|
||||||
|
const elementRef = useRef<DOMElement | null>(null)
|
||||||
|
const entryRef = useRef<ViewportEntry>({ isVisible: true })
|
||||||
|
|
||||||
|
const setElement = useCallback((el: DOMElement | null) => {
|
||||||
|
elementRef.current = el
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Runs on every render because yoga layout values can change
|
||||||
|
// without React being aware. Only updates the ref — no setState
|
||||||
|
// to avoid cascading re-renders during the commit phase.
|
||||||
|
// Walks the DOM ancestor chain fresh each time to avoid holding stale
|
||||||
|
// references after yoga tree rebuilds.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const element = elementRef.current
|
||||||
|
|
||||||
|
if (!element?.yogaNode || !terminalSize) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const height = element.yogaNode.getComputedHeight()
|
||||||
|
const rows = terminalSize.rows
|
||||||
|
|
||||||
|
// Walk the DOM parent chain (not yoga.getParent()) so we can detect
|
||||||
|
// scroll containers and subtract their scrollTop. Yoga computes layout
|
||||||
|
// positions without scroll offset — scrollTop is applied at render time.
|
||||||
|
// Without this, an element inside a ScrollBox whose yoga position exceeds
|
||||||
|
// terminalRows would be considered offscreen even when scrolled into view
|
||||||
|
// (e.g., the spinner in fullscreen mode after enough messages accumulate).
|
||||||
|
let absoluteTop = element.yogaNode.getComputedTop()
|
||||||
|
let parent: DOMElement | undefined = element.parentNode
|
||||||
|
let root = element.yogaNode
|
||||||
|
|
||||||
|
while (parent) {
|
||||||
|
if (parent.yogaNode) {
|
||||||
|
absoluteTop += parent.yogaNode.getComputedTop()
|
||||||
|
root = parent.yogaNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrollTop is only ever set on scroll containers (by ScrollBox + renderer).
|
||||||
|
// Non-scroll nodes have undefined scrollTop → falsy fast-path.
|
||||||
|
if (parent.scrollTop) {
|
||||||
|
absoluteTop -= parent.scrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
parent = parent.parentNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the root's height matters
|
||||||
|
const screenHeight = root.getComputedHeight()
|
||||||
|
|
||||||
|
const bottom = absoluteTop + height
|
||||||
|
// When content overflows the viewport (screenHeight > rows), the
|
||||||
|
// cursor-restore at frame end scrolls one extra row into scrollback.
|
||||||
|
// log-update.ts accounts for this with scrollbackRows = viewportY + 1.
|
||||||
|
// We must match, otherwise an element at the boundary is considered
|
||||||
|
// "visible" here (animation keeps ticking) but its row is treated as
|
||||||
|
// scrollback by log-update (content change → full reset → flicker).
|
||||||
|
const cursorRestoreScroll = screenHeight > rows ? 1 : 0
|
||||||
|
const viewportY = Math.max(0, screenHeight - rows) + cursorRestoreScroll
|
||||||
|
const viewportBottom = viewportY + rows
|
||||||
|
const visible = bottom > viewportY && absoluteTop < viewportBottom
|
||||||
|
|
||||||
|
if (visible !== entryRef.current.isVisible) {
|
||||||
|
entryRef.current = { isVisible: visible }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return [setElement, entryRef.current]
|
||||||
|
}
|
||||||
2140
ui-tui/packages/hermes-ink/src/ink/ink.tsx
Normal file
2140
ui-tui/packages/hermes-ink/src/ink/ink.tsx
Normal file
File diff suppressed because one or more lines are too long
10
ui-tui/packages/hermes-ink/src/ink/instances.ts
Normal file
10
ui-tui/packages/hermes-ink/src/ink/instances.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// Store all instances of Ink (instance.js) to ensure that consecutive render() calls
|
||||||
|
// use the same instance of Ink and don't create a new one
|
||||||
|
//
|
||||||
|
// This map has to be stored in a separate file, because render.js creates instances,
|
||||||
|
// but instance.js should delete itself from the map on unmount
|
||||||
|
|
||||||
|
import type Ink from './ink.js'
|
||||||
|
|
||||||
|
const instances = new Map<NodeJS.WriteStream, Ink>()
|
||||||
|
export default instances
|
||||||
6
ui-tui/packages/hermes-ink/src/ink/layout/engine.ts
Normal file
6
ui-tui/packages/hermes-ink/src/ink/layout/engine.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { LayoutNode } from './node.js'
|
||||||
|
import { createYogaLayoutNode } from './yoga.js'
|
||||||
|
|
||||||
|
export function createLayoutNode(): LayoutNode {
|
||||||
|
return createYogaLayoutNode()
|
||||||
|
}
|
||||||
98
ui-tui/packages/hermes-ink/src/ink/layout/geometry.ts
Normal file
98
ui-tui/packages/hermes-ink/src/ink/layout/geometry.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
export type Point = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Size = {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Rectangle = Point & Size
|
||||||
|
|
||||||
|
/** Edge insets (padding, margin, border) */
|
||||||
|
export type Edges = {
|
||||||
|
top: number
|
||||||
|
right: number
|
||||||
|
bottom: number
|
||||||
|
left: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create uniform edges */
|
||||||
|
export function edges(all: number): Edges
|
||||||
|
export function edges(vertical: number, horizontal: number): Edges
|
||||||
|
export function edges(top: number, right: number, bottom: number, left: number): Edges
|
||||||
|
|
||||||
|
export function edges(a: number, b?: number, c?: number, d?: number): Edges {
|
||||||
|
if (b === undefined) {
|
||||||
|
return { top: a, right: a, bottom: a, left: a }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c === undefined) {
|
||||||
|
return { top: a, right: b, bottom: a, left: b }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { top: a, right: b, bottom: c, left: d! }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add two edge values */
|
||||||
|
export function addEdges(a: Edges, b: Edges): Edges {
|
||||||
|
return {
|
||||||
|
top: a.top + b.top,
|
||||||
|
right: a.right + b.right,
|
||||||
|
bottom: a.bottom + b.bottom,
|
||||||
|
left: a.left + b.left
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zero edges constant */
|
||||||
|
export const ZERO_EDGES: Edges = { top: 0, right: 0, bottom: 0, left: 0 }
|
||||||
|
|
||||||
|
/** Convert partial edges to full edges with defaults */
|
||||||
|
export function resolveEdges(partial?: Partial<Edges>): Edges {
|
||||||
|
return {
|
||||||
|
top: partial?.top ?? 0,
|
||||||
|
right: partial?.right ?? 0,
|
||||||
|
bottom: partial?.bottom ?? 0,
|
||||||
|
left: partial?.left ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unionRect(a: Rectangle, b: Rectangle): Rectangle {
|
||||||
|
const minX = Math.min(a.x, b.x)
|
||||||
|
const minY = Math.min(a.y, b.y)
|
||||||
|
const maxX = Math.max(a.x + a.width, b.x + b.width)
|
||||||
|
const maxY = Math.max(a.y + a.height, b.y + b.height)
|
||||||
|
|
||||||
|
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampRect(rect: Rectangle, size: Size): Rectangle {
|
||||||
|
const minX = Math.max(0, rect.x)
|
||||||
|
const minY = Math.max(0, rect.y)
|
||||||
|
const maxX = Math.min(size.width - 1, rect.x + rect.width - 1)
|
||||||
|
const maxY = Math.min(size.height - 1, rect.y + rect.height - 1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: minX,
|
||||||
|
y: minY,
|
||||||
|
width: Math.max(0, maxX - minX + 1),
|
||||||
|
height: Math.max(0, maxY - minY + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withinBounds(size: Size, point: Point): boolean {
|
||||||
|
return point.x >= 0 && point.y >= 0 && point.x < size.width && point.y < size.height
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clamp(value: number, min?: number, max?: number): number {
|
||||||
|
if (min !== undefined && value < min) {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
|
||||||
|
if (max !== undefined && value > max) {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
145
ui-tui/packages/hermes-ink/src/ink/layout/node.ts
Normal file
145
ui-tui/packages/hermes-ink/src/ink/layout/node.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
// --
|
||||||
|
// Adapter interface for the layout engine (Yoga)
|
||||||
|
|
||||||
|
export const LayoutEdge = {
|
||||||
|
All: 'all',
|
||||||
|
Horizontal: 'horizontal',
|
||||||
|
Vertical: 'vertical',
|
||||||
|
Left: 'left',
|
||||||
|
Right: 'right',
|
||||||
|
Top: 'top',
|
||||||
|
Bottom: 'bottom',
|
||||||
|
Start: 'start',
|
||||||
|
End: 'end'
|
||||||
|
} as const
|
||||||
|
export type LayoutEdge = (typeof LayoutEdge)[keyof typeof LayoutEdge]
|
||||||
|
|
||||||
|
export const LayoutGutter = {
|
||||||
|
All: 'all',
|
||||||
|
Column: 'column',
|
||||||
|
Row: 'row'
|
||||||
|
} as const
|
||||||
|
export type LayoutGutter = (typeof LayoutGutter)[keyof typeof LayoutGutter]
|
||||||
|
|
||||||
|
export const LayoutDisplay = {
|
||||||
|
Flex: 'flex',
|
||||||
|
None: 'none'
|
||||||
|
} as const
|
||||||
|
export type LayoutDisplay = (typeof LayoutDisplay)[keyof typeof LayoutDisplay]
|
||||||
|
|
||||||
|
export const LayoutFlexDirection = {
|
||||||
|
Row: 'row',
|
||||||
|
RowReverse: 'row-reverse',
|
||||||
|
Column: 'column',
|
||||||
|
ColumnReverse: 'column-reverse'
|
||||||
|
} as const
|
||||||
|
export type LayoutFlexDirection = (typeof LayoutFlexDirection)[keyof typeof LayoutFlexDirection]
|
||||||
|
|
||||||
|
export const LayoutAlign = {
|
||||||
|
Auto: 'auto',
|
||||||
|
Stretch: 'stretch',
|
||||||
|
FlexStart: 'flex-start',
|
||||||
|
Center: 'center',
|
||||||
|
FlexEnd: 'flex-end'
|
||||||
|
} as const
|
||||||
|
export type LayoutAlign = (typeof LayoutAlign)[keyof typeof LayoutAlign]
|
||||||
|
|
||||||
|
export const LayoutJustify = {
|
||||||
|
FlexStart: 'flex-start',
|
||||||
|
Center: 'center',
|
||||||
|
FlexEnd: 'flex-end',
|
||||||
|
SpaceBetween: 'space-between',
|
||||||
|
SpaceAround: 'space-around',
|
||||||
|
SpaceEvenly: 'space-evenly'
|
||||||
|
} as const
|
||||||
|
export type LayoutJustify = (typeof LayoutJustify)[keyof typeof LayoutJustify]
|
||||||
|
|
||||||
|
export const LayoutWrap = {
|
||||||
|
NoWrap: 'nowrap',
|
||||||
|
Wrap: 'wrap',
|
||||||
|
WrapReverse: 'wrap-reverse'
|
||||||
|
} as const
|
||||||
|
export type LayoutWrap = (typeof LayoutWrap)[keyof typeof LayoutWrap]
|
||||||
|
|
||||||
|
export const LayoutPositionType = {
|
||||||
|
Relative: 'relative',
|
||||||
|
Absolute: 'absolute'
|
||||||
|
} as const
|
||||||
|
export type LayoutPositionType = (typeof LayoutPositionType)[keyof typeof LayoutPositionType]
|
||||||
|
|
||||||
|
export const LayoutOverflow = {
|
||||||
|
Visible: 'visible',
|
||||||
|
Hidden: 'hidden',
|
||||||
|
Scroll: 'scroll'
|
||||||
|
} as const
|
||||||
|
export type LayoutOverflow = (typeof LayoutOverflow)[keyof typeof LayoutOverflow]
|
||||||
|
|
||||||
|
export type LayoutMeasureFunc = (width: number, widthMode: LayoutMeasureMode) => { width: number; height: number }
|
||||||
|
|
||||||
|
export const LayoutMeasureMode = {
|
||||||
|
Undefined: 'undefined',
|
||||||
|
Exactly: 'exactly',
|
||||||
|
AtMost: 'at-most'
|
||||||
|
} as const
|
||||||
|
export type LayoutMeasureMode = (typeof LayoutMeasureMode)[keyof typeof LayoutMeasureMode]
|
||||||
|
|
||||||
|
export type LayoutNode = {
|
||||||
|
// Tree
|
||||||
|
insertChild(child: LayoutNode, index: number): void
|
||||||
|
removeChild(child: LayoutNode): void
|
||||||
|
getChildCount(): number
|
||||||
|
getParent(): LayoutNode | null
|
||||||
|
|
||||||
|
// Layout computation
|
||||||
|
calculateLayout(width?: number, height?: number): void
|
||||||
|
setMeasureFunc(fn: LayoutMeasureFunc): void
|
||||||
|
unsetMeasureFunc(): void
|
||||||
|
markDirty(): void
|
||||||
|
|
||||||
|
// Layout reading (post-layout)
|
||||||
|
getComputedLeft(): number
|
||||||
|
getComputedTop(): number
|
||||||
|
getComputedWidth(): number
|
||||||
|
getComputedHeight(): number
|
||||||
|
getComputedBorder(edge: LayoutEdge): number
|
||||||
|
getComputedPadding(edge: LayoutEdge): number
|
||||||
|
|
||||||
|
// Style setters
|
||||||
|
setWidth(value: number): void
|
||||||
|
setWidthPercent(value: number): void
|
||||||
|
setWidthAuto(): void
|
||||||
|
setHeight(value: number): void
|
||||||
|
setHeightPercent(value: number): void
|
||||||
|
setHeightAuto(): void
|
||||||
|
setMinWidth(value: number): void
|
||||||
|
setMinWidthPercent(value: number): void
|
||||||
|
setMinHeight(value: number): void
|
||||||
|
setMinHeightPercent(value: number): void
|
||||||
|
setMaxWidth(value: number): void
|
||||||
|
setMaxWidthPercent(value: number): void
|
||||||
|
setMaxHeight(value: number): void
|
||||||
|
setMaxHeightPercent(value: number): void
|
||||||
|
setFlexDirection(dir: LayoutFlexDirection): void
|
||||||
|
setFlexGrow(value: number): void
|
||||||
|
setFlexShrink(value: number): void
|
||||||
|
setFlexBasis(value: number): void
|
||||||
|
setFlexBasisPercent(value: number): void
|
||||||
|
setFlexWrap(wrap: LayoutWrap): void
|
||||||
|
setAlignItems(align: LayoutAlign): void
|
||||||
|
setAlignSelf(align: LayoutAlign): void
|
||||||
|
setJustifyContent(justify: LayoutJustify): void
|
||||||
|
setDisplay(display: LayoutDisplay): void
|
||||||
|
getDisplay(): LayoutDisplay
|
||||||
|
setPositionType(type: LayoutPositionType): void
|
||||||
|
setPosition(edge: LayoutEdge, value: number): void
|
||||||
|
setPositionPercent(edge: LayoutEdge, value: number): void
|
||||||
|
setOverflow(overflow: LayoutOverflow): void
|
||||||
|
setMargin(edge: LayoutEdge, value: number): void
|
||||||
|
setPadding(edge: LayoutEdge, value: number): void
|
||||||
|
setBorder(edge: LayoutEdge, value: number): void
|
||||||
|
setGap(gutter: LayoutGutter, value: number): void
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
free(): void
|
||||||
|
freeRecursive(): void
|
||||||
|
}
|
||||||
313
ui-tui/packages/hermes-ink/src/ink/layout/yoga.ts
Normal file
313
ui-tui/packages/hermes-ink/src/ink/layout/yoga.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
import Yoga, {
|
||||||
|
Align,
|
||||||
|
Direction,
|
||||||
|
Display,
|
||||||
|
Edge,
|
||||||
|
FlexDirection,
|
||||||
|
Gutter,
|
||||||
|
Justify,
|
||||||
|
MeasureMode,
|
||||||
|
Overflow,
|
||||||
|
PositionType,
|
||||||
|
Wrap,
|
||||||
|
type Node as YogaNode
|
||||||
|
} from '../../native-ts/yoga-layout/index.js'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type LayoutAlign,
|
||||||
|
LayoutDisplay,
|
||||||
|
type LayoutEdge,
|
||||||
|
type LayoutFlexDirection,
|
||||||
|
type LayoutGutter,
|
||||||
|
type LayoutJustify,
|
||||||
|
type LayoutMeasureFunc,
|
||||||
|
LayoutMeasureMode,
|
||||||
|
type LayoutNode,
|
||||||
|
type LayoutOverflow,
|
||||||
|
type LayoutPositionType,
|
||||||
|
type LayoutWrap
|
||||||
|
} from './node.js'
|
||||||
|
|
||||||
|
// --
|
||||||
|
// Edge/Gutter mapping
|
||||||
|
|
||||||
|
const EDGE_MAP: Record<LayoutEdge, Edge> = {
|
||||||
|
all: Edge.All,
|
||||||
|
horizontal: Edge.Horizontal,
|
||||||
|
vertical: Edge.Vertical,
|
||||||
|
left: Edge.Left,
|
||||||
|
right: Edge.Right,
|
||||||
|
top: Edge.Top,
|
||||||
|
bottom: Edge.Bottom,
|
||||||
|
start: Edge.Start,
|
||||||
|
end: Edge.End
|
||||||
|
}
|
||||||
|
|
||||||
|
const GUTTER_MAP: Record<LayoutGutter, Gutter> = {
|
||||||
|
all: Gutter.All,
|
||||||
|
column: Gutter.Column,
|
||||||
|
row: Gutter.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
// --
|
||||||
|
// Yoga adapter
|
||||||
|
|
||||||
|
export class YogaLayoutNode implements LayoutNode {
|
||||||
|
readonly yoga: YogaNode
|
||||||
|
|
||||||
|
constructor(yoga: YogaNode) {
|
||||||
|
this.yoga = yoga
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tree
|
||||||
|
|
||||||
|
insertChild(child: LayoutNode, index: number): void {
|
||||||
|
this.yoga.insertChild((child as YogaLayoutNode).yoga, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChild(child: LayoutNode): void {
|
||||||
|
this.yoga.removeChild((child as YogaLayoutNode).yoga)
|
||||||
|
}
|
||||||
|
|
||||||
|
getChildCount(): number {
|
||||||
|
return this.yoga.getChildCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
getParent(): LayoutNode | null {
|
||||||
|
const p = this.yoga.getParent()
|
||||||
|
|
||||||
|
return p ? new YogaLayoutNode(p) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
|
||||||
|
calculateLayout(width?: number, _height?: number): void {
|
||||||
|
this.yoga.calculateLayout(width, undefined, Direction.LTR)
|
||||||
|
}
|
||||||
|
|
||||||
|
setMeasureFunc(fn: LayoutMeasureFunc): void {
|
||||||
|
this.yoga.setMeasureFunc((w, wMode) => {
|
||||||
|
const mode =
|
||||||
|
wMode === MeasureMode.Exactly
|
||||||
|
? LayoutMeasureMode.Exactly
|
||||||
|
: wMode === MeasureMode.AtMost
|
||||||
|
? LayoutMeasureMode.AtMost
|
||||||
|
: LayoutMeasureMode.Undefined
|
||||||
|
|
||||||
|
return fn(w, mode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
unsetMeasureFunc(): void {
|
||||||
|
this.yoga.unsetMeasureFunc()
|
||||||
|
}
|
||||||
|
|
||||||
|
markDirty(): void {
|
||||||
|
this.yoga.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed layout
|
||||||
|
|
||||||
|
getComputedLeft(): number {
|
||||||
|
return this.yoga.getComputedLeft()
|
||||||
|
}
|
||||||
|
|
||||||
|
getComputedTop(): number {
|
||||||
|
return this.yoga.getComputedTop()
|
||||||
|
}
|
||||||
|
|
||||||
|
getComputedWidth(): number {
|
||||||
|
return this.yoga.getComputedWidth()
|
||||||
|
}
|
||||||
|
|
||||||
|
getComputedHeight(): number {
|
||||||
|
return this.yoga.getComputedHeight()
|
||||||
|
}
|
||||||
|
|
||||||
|
getComputedBorder(edge: LayoutEdge): number {
|
||||||
|
return this.yoga.getComputedBorder(EDGE_MAP[edge]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
getComputedPadding(edge: LayoutEdge): number {
|
||||||
|
return this.yoga.getComputedPadding(EDGE_MAP[edge]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style setters
|
||||||
|
|
||||||
|
setWidth(value: number): void {
|
||||||
|
this.yoga.setWidth(value)
|
||||||
|
}
|
||||||
|
setWidthPercent(value: number): void {
|
||||||
|
this.yoga.setWidthPercent(value)
|
||||||
|
}
|
||||||
|
setWidthAuto(): void {
|
||||||
|
this.yoga.setWidthAuto()
|
||||||
|
}
|
||||||
|
setHeight(value: number): void {
|
||||||
|
this.yoga.setHeight(value)
|
||||||
|
}
|
||||||
|
setHeightPercent(value: number): void {
|
||||||
|
this.yoga.setHeightPercent(value)
|
||||||
|
}
|
||||||
|
setHeightAuto(): void {
|
||||||
|
this.yoga.setHeightAuto()
|
||||||
|
}
|
||||||
|
setMinWidth(value: number): void {
|
||||||
|
this.yoga.setMinWidth(value)
|
||||||
|
}
|
||||||
|
setMinWidthPercent(value: number): void {
|
||||||
|
this.yoga.setMinWidthPercent(value)
|
||||||
|
}
|
||||||
|
setMinHeight(value: number): void {
|
||||||
|
this.yoga.setMinHeight(value)
|
||||||
|
}
|
||||||
|
setMinHeightPercent(value: number): void {
|
||||||
|
this.yoga.setMinHeightPercent(value)
|
||||||
|
}
|
||||||
|
setMaxWidth(value: number): void {
|
||||||
|
this.yoga.setMaxWidth(value)
|
||||||
|
}
|
||||||
|
setMaxWidthPercent(value: number): void {
|
||||||
|
this.yoga.setMaxWidthPercent(value)
|
||||||
|
}
|
||||||
|
setMaxHeight(value: number): void {
|
||||||
|
this.yoga.setMaxHeight(value)
|
||||||
|
}
|
||||||
|
setMaxHeightPercent(value: number): void {
|
||||||
|
this.yoga.setMaxHeightPercent(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlexDirection(dir: LayoutFlexDirection): void {
|
||||||
|
const map: Record<LayoutFlexDirection, FlexDirection> = {
|
||||||
|
row: FlexDirection.Row,
|
||||||
|
'row-reverse': FlexDirection.RowReverse,
|
||||||
|
column: FlexDirection.Column,
|
||||||
|
'column-reverse': FlexDirection.ColumnReverse
|
||||||
|
}
|
||||||
|
|
||||||
|
this.yoga.setFlexDirection(map[dir]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlexGrow(value: number): void {
|
||||||
|
this.yoga.setFlexGrow(value)
|
||||||
|
}
|
||||||
|
setFlexShrink(value: number): void {
|
||||||
|
this.yoga.setFlexShrink(value)
|
||||||
|
}
|
||||||
|
setFlexBasis(value: number): void {
|
||||||
|
this.yoga.setFlexBasis(value)
|
||||||
|
}
|
||||||
|
setFlexBasisPercent(value: number): void {
|
||||||
|
this.yoga.setFlexBasisPercent(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlexWrap(wrap: LayoutWrap): void {
|
||||||
|
const map: Record<LayoutWrap, Wrap> = {
|
||||||
|
nowrap: Wrap.NoWrap,
|
||||||
|
wrap: Wrap.Wrap,
|
||||||
|
'wrap-reverse': Wrap.WrapReverse
|
||||||
|
}
|
||||||
|
|
||||||
|
this.yoga.setFlexWrap(map[wrap]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
setAlignItems(align: LayoutAlign): void {
|
||||||
|
const map: Record<LayoutAlign, Align> = {
|
||||||
|
auto: Align.Auto,
|
||||||
|
stretch: Align.Stretch,
|
||||||
|
'flex-start': Align.FlexStart,
|
||||||
|
center: Align.Center,
|
||||||
|
'flex-end': Align.FlexEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
this.yoga.setAlignItems(map[align]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
setAlignSelf(align: LayoutAlign): void {
|
||||||
|
const map: Record<LayoutAlign, Align> = {
|
||||||
|
auto: Align.Auto,
|
||||||
|
stretch: Align.Stretch,
|
||||||
|
'flex-start': Align.FlexStart,
|
||||||
|
center: Align.Center,
|
||||||
|
'flex-end': Align.FlexEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
this.yoga.setAlignSelf(map[align]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
setJustifyContent(justify: LayoutJustify): void {
|
||||||
|
const map: Record<LayoutJustify, Justify> = {
|
||||||
|
'flex-start': Justify.FlexStart,
|
||||||
|
center: Justify.Center,
|
||||||
|
'flex-end': Justify.FlexEnd,
|
||||||
|
'space-between': Justify.SpaceBetween,
|
||||||
|
'space-around': Justify.SpaceAround,
|
||||||
|
'space-evenly': Justify.SpaceEvenly
|
||||||
|
}
|
||||||
|
|
||||||
|
this.yoga.setJustifyContent(map[justify]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisplay(display: LayoutDisplay): void {
|
||||||
|
this.yoga.setDisplay(display === 'flex' ? Display.Flex : Display.None)
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplay(): LayoutDisplay {
|
||||||
|
return this.yoga.getDisplay() === Display.None ? LayoutDisplay.None : LayoutDisplay.Flex
|
||||||
|
}
|
||||||
|
|
||||||
|
setPositionType(type: LayoutPositionType): void {
|
||||||
|
this.yoga.setPositionType(type === 'absolute' ? PositionType.Absolute : PositionType.Relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosition(edge: LayoutEdge, value: number): void {
|
||||||
|
this.yoga.setPosition(EDGE_MAP[edge]!, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
setPositionPercent(edge: LayoutEdge, value: number): void {
|
||||||
|
this.yoga.setPositionPercent(EDGE_MAP[edge]!, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverflow(overflow: LayoutOverflow): void {
|
||||||
|
const map: Record<LayoutOverflow, Overflow> = {
|
||||||
|
visible: Overflow.Visible,
|
||||||
|
hidden: Overflow.Hidden,
|
||||||
|
scroll: Overflow.Scroll
|
||||||
|
}
|
||||||
|
|
||||||
|
this.yoga.setOverflow(map[overflow]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
setMargin(edge: LayoutEdge, value: number): void {
|
||||||
|
this.yoga.setMargin(EDGE_MAP[edge]!, value)
|
||||||
|
}
|
||||||
|
setPadding(edge: LayoutEdge, value: number): void {
|
||||||
|
this.yoga.setPadding(EDGE_MAP[edge]!, value)
|
||||||
|
}
|
||||||
|
setBorder(edge: LayoutEdge, value: number): void {
|
||||||
|
this.yoga.setBorder(EDGE_MAP[edge]!, value)
|
||||||
|
}
|
||||||
|
setGap(gutter: LayoutGutter, value: number): void {
|
||||||
|
this.yoga.setGap(GUTTER_MAP[gutter]!, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
free(): void {
|
||||||
|
this.yoga.free()
|
||||||
|
}
|
||||||
|
freeRecursive(): void {
|
||||||
|
this.yoga.freeRecursive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --
|
||||||
|
// Instance management
|
||||||
|
//
|
||||||
|
// The TS yoga-layout port is synchronous — no WASM loading, no linear memory
|
||||||
|
// growth, so no preload/swap/reset machinery is needed. The Yoga instance is
|
||||||
|
// just a plain JS object available at import time.
|
||||||
|
|
||||||
|
export function createYogaLayoutNode(): LayoutNode {
|
||||||
|
return new YogaLayoutNode(Yoga.Node.create())
|
||||||
|
}
|
||||||
28
ui-tui/packages/hermes-ink/src/ink/line-width-cache.ts
Normal file
28
ui-tui/packages/hermes-ink/src/ink/line-width-cache.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { stringWidth } from './stringWidth.js'
|
||||||
|
|
||||||
|
// During streaming, text grows but completed lines are immutable.
|
||||||
|
// Caching stringWidth per-line avoids re-measuring hundreds of
|
||||||
|
// unchanged lines on every token (~50x reduction in stringWidth calls).
|
||||||
|
const cache = new Map<string, number>()
|
||||||
|
|
||||||
|
const MAX_CACHE_SIZE = 4096
|
||||||
|
|
||||||
|
export function lineWidth(line: string): number {
|
||||||
|
const cached = cache.get(line)
|
||||||
|
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = stringWidth(line)
|
||||||
|
|
||||||
|
// Evict when cache grows too large (e.g. after many different responses).
|
||||||
|
// Simple full-clear is fine — the cache repopulates in one frame.
|
||||||
|
if (cache.size >= MAX_CACHE_SIZE) {
|
||||||
|
cache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(line, width)
|
||||||
|
|
||||||
|
return width
|
||||||
|
}
|
||||||
738
ui-tui/packages/hermes-ink/src/ink/log-update.ts
Normal file
738
ui-tui/packages/hermes-ink/src/ink/log-update.ts
Normal file
|
|
@ -0,0 +1,738 @@
|
||||||
|
import { type AnsiCode, ansiCodesToString, diffAnsiCodes } from '@alcalzone/ansi-tokenize'
|
||||||
|
|
||||||
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
|
|
||||||
|
import type { Diff, FlickerReason, Frame } from './frame.js'
|
||||||
|
import type { Point } from './layout/geometry.js'
|
||||||
|
import {
|
||||||
|
type Cell,
|
||||||
|
cellAt,
|
||||||
|
CellWidth,
|
||||||
|
charInCellAt,
|
||||||
|
diffEach,
|
||||||
|
type Hyperlink,
|
||||||
|
isEmptyCellAt,
|
||||||
|
type Screen,
|
||||||
|
shiftRows,
|
||||||
|
type StylePool,
|
||||||
|
visibleCellAtIndex
|
||||||
|
} from './screen.js'
|
||||||
|
import {
|
||||||
|
scrollDown as csiScrollDown,
|
||||||
|
scrollUp as csiScrollUp,
|
||||||
|
CURSOR_HOME,
|
||||||
|
RESET_SCROLL_REGION,
|
||||||
|
setScrollRegion
|
||||||
|
} from './termio/csi.js'
|
||||||
|
import { LINK_END, link as oscLink } from './termio/osc.js'
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
previousOutput: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
isTTY: boolean
|
||||||
|
stylePool: StylePool
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARRIAGE_RETURN = { type: 'carriageReturn' } as const
|
||||||
|
const NEWLINE = { type: 'stdout', content: '\n' } as const
|
||||||
|
|
||||||
|
export class LogUpdate {
|
||||||
|
private state: State
|
||||||
|
|
||||||
|
constructor(private readonly options: Options) {
|
||||||
|
this.state = {
|
||||||
|
previousOutput: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPreviousOutput_DEPRECATED(prevFrame: Frame): Diff {
|
||||||
|
if (!this.options.isTTY) {
|
||||||
|
// Non-TTY output is no longer supported (string output was removed)
|
||||||
|
return [NEWLINE]
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getRenderOpsForDone(prevFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when process resumes from suspension (SIGCONT) to prevent clobbering terminal content
|
||||||
|
reset(): void {
|
||||||
|
this.state.previousOutput = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderFullFrame(frame: Frame): Diff {
|
||||||
|
const { screen } = frame
|
||||||
|
const lines: string[] = []
|
||||||
|
let currentStyles: AnsiCode[] = []
|
||||||
|
let currentHyperlink: Hyperlink = undefined
|
||||||
|
|
||||||
|
for (let y = 0; y < screen.height; y++) {
|
||||||
|
let line = ''
|
||||||
|
|
||||||
|
for (let x = 0; x < screen.width; x++) {
|
||||||
|
const cell = cellAt(screen, x, y)
|
||||||
|
|
||||||
|
if (cell && cell.width !== CellWidth.SpacerTail) {
|
||||||
|
// Handle hyperlink transitions
|
||||||
|
if (cell.hyperlink !== currentHyperlink) {
|
||||||
|
if (currentHyperlink !== undefined) {
|
||||||
|
line += LINK_END
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell.hyperlink !== undefined) {
|
||||||
|
line += oscLink(cell.hyperlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentHyperlink = cell.hyperlink
|
||||||
|
}
|
||||||
|
|
||||||
|
const cellStyles = this.options.stylePool.get(cell.styleId)
|
||||||
|
const styleDiff = diffAnsiCodes(currentStyles, cellStyles)
|
||||||
|
|
||||||
|
if (styleDiff.length > 0) {
|
||||||
|
line += ansiCodesToString(styleDiff)
|
||||||
|
currentStyles = cellStyles
|
||||||
|
}
|
||||||
|
|
||||||
|
line += cell.char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close any open hyperlink before resetting styles
|
||||||
|
if (currentHyperlink !== undefined) {
|
||||||
|
line += LINK_END
|
||||||
|
currentHyperlink = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset styles at end of line so trimEnd doesn't leave dangling codes
|
||||||
|
const resetCodes = diffAnsiCodes(currentStyles, [])
|
||||||
|
|
||||||
|
if (resetCodes.length > 0) {
|
||||||
|
line += ansiCodesToString(resetCodes)
|
||||||
|
currentStyles = []
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(line.trimEnd())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ type: 'stdout', content: lines.join('\n') }]
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRenderOpsForDone(prev: Frame): Diff {
|
||||||
|
this.state.previousOutput = ''
|
||||||
|
|
||||||
|
if (!prev.cursor.visible) {
|
||||||
|
return [{ type: 'cursorShow' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
render(prev: Frame, next: Frame, altScreen = false, decstbmSafe = true): Diff {
|
||||||
|
if (!this.options.isTTY) {
|
||||||
|
return this.renderFullFrame(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = performance.now()
|
||||||
|
const stylePool = this.options.stylePool
|
||||||
|
|
||||||
|
// Since we assume the cursor is at the bottom on the screen, we only need
|
||||||
|
// to clear when the viewport gets shorter (i.e. the cursor position drifts)
|
||||||
|
// or when it gets thinner (and text wraps). We _could_ figure out how to
|
||||||
|
// not reset here but that would involve predicting the current layout
|
||||||
|
// _after_ the viewport change which means calcuating text wrapping.
|
||||||
|
// Resizing is a rare enough event that it's not practically a big issue.
|
||||||
|
if (
|
||||||
|
next.viewport.height < prev.viewport.height ||
|
||||||
|
(prev.viewport.width !== 0 && next.viewport.width !== prev.viewport.width)
|
||||||
|
) {
|
||||||
|
return fullResetSequence_CAUSES_FLICKER(next, 'resize', stylePool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DECSTBM scroll optimization: when a ScrollBox's scrollTop changed,
|
||||||
|
// shift content with a hardware scroll (CSI top;bot r + CSI n S/T)
|
||||||
|
// instead of rewriting the whole scroll region. The shiftRows on
|
||||||
|
// prev.screen simulates the shift so the diff loop below naturally
|
||||||
|
// finds only the rows that scrolled IN as diffs. prev.screen is
|
||||||
|
// about to become backFrame (reused next render) so mutation is safe.
|
||||||
|
// CURSOR_HOME after RESET_SCROLL_REGION is defensive — DECSTBM reset
|
||||||
|
// homes cursor per spec but terminal implementations vary.
|
||||||
|
//
|
||||||
|
// decstbmSafe: caller passes false when the DECSTBM→diff sequence
|
||||||
|
// can't be made atomic (no DEC 2026 / BSU/ESU). Without atomicity the
|
||||||
|
// outer terminal renders the intermediate state — region scrolled,
|
||||||
|
// edge rows not yet painted — a visible vertical jump on every frame
|
||||||
|
// where scrollTop moves. Falling through to the diff loop writes all
|
||||||
|
// shifted rows: more bytes, no intermediate state. next.screen from
|
||||||
|
// render-node-to-output's blit+shift is correct either way.
|
||||||
|
let scrollPatch: Diff = []
|
||||||
|
|
||||||
|
if (altScreen && next.scrollHint && decstbmSafe) {
|
||||||
|
const { top, bottom, delta } = next.scrollHint
|
||||||
|
|
||||||
|
if (top >= 0 && bottom < prev.screen.height && bottom < next.screen.height) {
|
||||||
|
shiftRows(prev.screen, top, bottom, delta)
|
||||||
|
scrollPatch = [
|
||||||
|
{
|
||||||
|
type: 'stdout',
|
||||||
|
content:
|
||||||
|
setScrollRegion(top + 1, bottom + 1) +
|
||||||
|
(delta > 0 ? csiScrollUp(delta) : csiScrollDown(-delta)) +
|
||||||
|
RESET_SCROLL_REGION +
|
||||||
|
CURSOR_HOME
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have to use purely relative operations to manipulate the cursor since
|
||||||
|
// we don't know its starting point.
|
||||||
|
//
|
||||||
|
// When content height >= viewport height AND cursor is at the bottom,
|
||||||
|
// the cursor restore at the end of the previous frame caused terminal scroll.
|
||||||
|
// viewportY tells us how many rows are in scrollback from content overflow.
|
||||||
|
// Additionally, the cursor-restore scroll pushes 1 more row into scrollback.
|
||||||
|
// We need fullReset if any changes are to rows that are now in scrollback.
|
||||||
|
//
|
||||||
|
// This early full-reset check only applies in "steady state" (not growing).
|
||||||
|
// For growing, the viewportY calculation below (with cursorRestoreScroll)
|
||||||
|
// catches unreachable scrollback rows in the diff loop instead.
|
||||||
|
const cursorAtBottom = prev.cursor.y >= prev.screen.height
|
||||||
|
const isGrowing = next.screen.height > prev.screen.height
|
||||||
|
|
||||||
|
// When content fills the viewport exactly (height == viewport) and the
|
||||||
|
// cursor is at the bottom, the cursor-restore LF at the end of the
|
||||||
|
// previous frame scrolled 1 row into scrollback. Use >= to catch this.
|
||||||
|
const prevHadScrollback = cursorAtBottom && prev.screen.height >= prev.viewport.height
|
||||||
|
|
||||||
|
const isShrinking = next.screen.height < prev.screen.height
|
||||||
|
const nextFitsViewport = next.screen.height <= prev.viewport.height
|
||||||
|
|
||||||
|
// When shrinking from above-viewport to at-or-below-viewport, content that
|
||||||
|
// was in scrollback should now be visible. Terminal clear operations can't
|
||||||
|
// bring scrollback content into view, so we need a full reset.
|
||||||
|
// Use <= (not <) because even when next height equals viewport height, the
|
||||||
|
// scrollback depth from the previous render differs from a fresh render.
|
||||||
|
if (prevHadScrollback && nextFitsViewport && isShrinking) {
|
||||||
|
logForDebugging(
|
||||||
|
`Full reset (shrink->below): prevHeight=${prev.screen.height}, nextHeight=${next.screen.height}, viewport=${prev.viewport.height}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prev.screen.height >= prev.viewport.height && prev.screen.height > 0 && cursorAtBottom && !isGrowing) {
|
||||||
|
// viewportY = rows in scrollback from content overflow
|
||||||
|
// +1 for the row pushed by cursor-restore scroll
|
||||||
|
const viewportY = prev.screen.height - prev.viewport.height
|
||||||
|
const scrollbackRows = viewportY + 1
|
||||||
|
|
||||||
|
let scrollbackChangeY = -1
|
||||||
|
diffEach(prev.screen, next.screen, (_x, y) => {
|
||||||
|
if (y < scrollbackRows) {
|
||||||
|
scrollbackChangeY = y
|
||||||
|
|
||||||
|
return true // early exit
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (scrollbackChangeY >= 0) {
|
||||||
|
const prevLine = readLine(prev.screen, scrollbackChangeY)
|
||||||
|
const nextLine = readLine(next.screen, scrollbackChangeY)
|
||||||
|
|
||||||
|
return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, {
|
||||||
|
triggerY: scrollbackChangeY,
|
||||||
|
prevLine,
|
||||||
|
nextLine
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const screen = new VirtualScreen(prev.cursor, next.viewport.width)
|
||||||
|
|
||||||
|
// Treat empty screen as height 1 to avoid spurious adjustments on first render
|
||||||
|
const heightDelta = Math.max(next.screen.height, 1) - Math.max(prev.screen.height, 1)
|
||||||
|
|
||||||
|
const shrinking = heightDelta < 0
|
||||||
|
const growing = heightDelta > 0
|
||||||
|
|
||||||
|
// Handle shrinking: clear lines from the bottom
|
||||||
|
if (shrinking) {
|
||||||
|
const linesToClear = prev.screen.height - next.screen.height
|
||||||
|
|
||||||
|
// eraseLines only works within the viewport - it can't clear scrollback.
|
||||||
|
// If we need to clear more lines than fit in the viewport, some are in
|
||||||
|
// scrollback, so we need a full reset.
|
||||||
|
if (linesToClear > prev.viewport.height) {
|
||||||
|
return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', this.options.stylePool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear(N) moves cursor UP by N-1 lines and to column 0
|
||||||
|
// This puts us at line prev.screen.height - N = next.screen.height
|
||||||
|
// But we want to be at next.screen.height - 1 (bottom of new screen)
|
||||||
|
screen.txn(prev => [
|
||||||
|
[
|
||||||
|
{ type: 'clear', count: linesToClear },
|
||||||
|
{ type: 'cursorMove', x: 0, y: -1 }
|
||||||
|
],
|
||||||
|
{ dx: -prev.x, dy: -linesToClear }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewportY = number of rows in scrollback (not visible on terminal).
|
||||||
|
// For shrinking: use max(prev, next) because terminal clears don't scroll.
|
||||||
|
// For growing: use prev state because new rows haven't scrolled old ones yet.
|
||||||
|
// When prevHadScrollback, add 1 for the cursor-restore LF that scrolled
|
||||||
|
// an additional row out of view at the end of the previous frame. Without
|
||||||
|
// this, the diff loop treats that row as reachable — but the cursor clamps
|
||||||
|
// at viewport top, causing writes to land 1 row off and garbling the output.
|
||||||
|
const cursorRestoreScroll = prevHadScrollback ? 1 : 0
|
||||||
|
|
||||||
|
const viewportY = growing
|
||||||
|
? Math.max(0, prev.screen.height - prev.viewport.height + cursorRestoreScroll)
|
||||||
|
: Math.max(prev.screen.height, next.screen.height) - next.viewport.height + cursorRestoreScroll
|
||||||
|
|
||||||
|
let currentStyleId = stylePool.none
|
||||||
|
let currentHyperlink: Hyperlink = undefined
|
||||||
|
|
||||||
|
// First pass: render changes to existing rows (rows < prev.screen.height)
|
||||||
|
let needsFullReset = false
|
||||||
|
let resetTriggerY = -1
|
||||||
|
diffEach(prev.screen, next.screen, (x, y, removed, added) => {
|
||||||
|
// Skip new rows - we'll render them directly after
|
||||||
|
if (growing && y >= prev.screen.height) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip spacers during rendering because the terminal will automatically
|
||||||
|
// advance 2 columns when we write the wide character itself.
|
||||||
|
// SpacerTail: Second cell of a wide character
|
||||||
|
// SpacerHead: Marks line-end position where wide char wraps to next line
|
||||||
|
if (added && (added.width === CellWidth.SpacerTail || added.width === CellWidth.SpacerHead)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed && (removed.width === CellWidth.SpacerTail || removed.width === CellWidth.SpacerHead) && !added) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip empty cells that don't need to overwrite existing content.
|
||||||
|
// This prevents writing trailing spaces that would cause unnecessary
|
||||||
|
// line wrapping at the edge of the screen.
|
||||||
|
// Uses isEmptyCellAt to check if both packed words are zero (empty cell).
|
||||||
|
if (added && isEmptyCellAt(next.screen, x, y) && !removed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the cell outside the viewport range has changed, we need to reset
|
||||||
|
// because we can't move the cursor there to draw.
|
||||||
|
if (y < viewportY) {
|
||||||
|
needsFullReset = true
|
||||||
|
resetTriggerY = y
|
||||||
|
|
||||||
|
return true // early exit
|
||||||
|
}
|
||||||
|
|
||||||
|
moveCursorTo(screen, x, y)
|
||||||
|
|
||||||
|
if (added) {
|
||||||
|
const targetHyperlink = added.hyperlink
|
||||||
|
currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, targetHyperlink)
|
||||||
|
const styleStr = stylePool.transition(currentStyleId, added.styleId)
|
||||||
|
|
||||||
|
if (writeCellWithStyleStr(screen, added, styleStr)) {
|
||||||
|
currentStyleId = added.styleId
|
||||||
|
}
|
||||||
|
} else if (removed) {
|
||||||
|
// Cell was removed - clear it with a space
|
||||||
|
// (This handles shrinking content)
|
||||||
|
// Reset any active styles/hyperlinks first to avoid leaking into cleared cells
|
||||||
|
const styleIdToReset = currentStyleId
|
||||||
|
const hyperlinkToReset = currentHyperlink
|
||||||
|
currentStyleId = stylePool.none
|
||||||
|
currentHyperlink = undefined
|
||||||
|
|
||||||
|
screen.txn(() => {
|
||||||
|
const patches: Diff = []
|
||||||
|
transitionStyle(patches, stylePool, styleIdToReset, stylePool.none)
|
||||||
|
transitionHyperlink(patches, hyperlinkToReset, undefined)
|
||||||
|
patches.push({ type: 'stdout', content: ' ' })
|
||||||
|
|
||||||
|
return [patches, { dx: 1, dy: 0 }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (needsFullReset) {
|
||||||
|
return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, {
|
||||||
|
triggerY: resetTriggerY,
|
||||||
|
prevLine: readLine(prev.screen, resetTriggerY),
|
||||||
|
nextLine: readLine(next.screen, resetTriggerY)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset styles before rendering new rows (they'll set their own styles)
|
||||||
|
currentStyleId = transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none)
|
||||||
|
currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, undefined)
|
||||||
|
|
||||||
|
// Handle growth: render new rows directly (they naturally scroll the terminal)
|
||||||
|
if (growing) {
|
||||||
|
renderFrameSlice(screen, next, prev.screen.height, next.screen.height, stylePool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore cursor. Skipped in alt-screen: the cursor is hidden, its
|
||||||
|
// position only matters as the starting point for the NEXT frame's
|
||||||
|
// relative moves, and in alt-screen the next frame always begins with
|
||||||
|
// CSI H (see ink.tsx onRender) which resets to (0,0) regardless. This
|
||||||
|
// saves a CR + cursorMove round-trip (~6-10 bytes) every frame.
|
||||||
|
//
|
||||||
|
// Main screen: if cursor needs to be past the last line of content
|
||||||
|
// (typical: cursor.y = screen.height), emit \n to create that line
|
||||||
|
// since cursor movement can't create new lines.
|
||||||
|
if (altScreen) {
|
||||||
|
// no-op; next frame's CSI H anchors cursor
|
||||||
|
} else if (next.cursor.y >= next.screen.height) {
|
||||||
|
// Move to column 0 of current line, then emit newlines to reach target row
|
||||||
|
screen.txn(prev => {
|
||||||
|
const rowsToCreate = next.cursor.y - prev.y
|
||||||
|
|
||||||
|
if (rowsToCreate > 0) {
|
||||||
|
// Use CR to resolve pending wrap (if any) without advancing
|
||||||
|
// to the next line, then LF to create each new row.
|
||||||
|
const patches: Diff = new Array<Diff[number]>(1 + rowsToCreate)
|
||||||
|
patches[0] = CARRIAGE_RETURN
|
||||||
|
|
||||||
|
for (let i = 0; i < rowsToCreate; i++) {
|
||||||
|
patches[1 + i] = NEWLINE
|
||||||
|
}
|
||||||
|
|
||||||
|
return [patches, { dx: -prev.x, dy: rowsToCreate }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// At or past target row - need to move cursor to correct position
|
||||||
|
const dy = next.cursor.y - prev.y
|
||||||
|
|
||||||
|
if (dy !== 0 || prev.x !== next.cursor.x) {
|
||||||
|
// Use CR to clear pending wrap (if any), then cursor move
|
||||||
|
const patches: Diff = [CARRIAGE_RETURN]
|
||||||
|
patches.push({ type: 'cursorMove', x: next.cursor.x, y: dy })
|
||||||
|
|
||||||
|
return [patches, { dx: next.cursor.x - prev.x, dy }]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [[], { dx: 0, dy: 0 }]
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
moveCursorTo(screen, next.cursor.x, next.cursor.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = performance.now() - startTime
|
||||||
|
|
||||||
|
if (elapsed > 50) {
|
||||||
|
const damage = next.screen.damage
|
||||||
|
|
||||||
|
const damageInfo = damage ? `${damage.width}x${damage.height} at (${damage.x},${damage.y})` : 'none'
|
||||||
|
|
||||||
|
logForDebugging(
|
||||||
|
`Slow render: ${elapsed.toFixed(1)}ms, screen: ${next.screen.height}x${next.screen.width}, damage: ${damageInfo}, changes: ${screen.diff.length}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return scrollPatch.length > 0 ? [...scrollPatch, ...screen.diff] : screen.diff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitionHyperlink(diff: Diff, current: Hyperlink, target: Hyperlink): Hyperlink {
|
||||||
|
if (current !== target) {
|
||||||
|
diff.push({ type: 'hyperlink', uri: target ?? '' })
|
||||||
|
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitionStyle(diff: Diff, stylePool: StylePool, currentId: number, targetId: number): number {
|
||||||
|
const str = stylePool.transition(currentId, targetId)
|
||||||
|
|
||||||
|
if (str.length > 0) {
|
||||||
|
diff.push({ type: 'styleStr', str })
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetId
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLine(screen: Screen, y: number): string {
|
||||||
|
let line = ''
|
||||||
|
|
||||||
|
for (let x = 0; x < screen.width; x++) {
|
||||||
|
line += charInCellAt(screen, x, y) ?? ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
return line.trimEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullResetSequence_CAUSES_FLICKER(
|
||||||
|
frame: Frame,
|
||||||
|
reason: FlickerReason,
|
||||||
|
stylePool: StylePool,
|
||||||
|
debug?: { triggerY: number; prevLine: string; nextLine: string }
|
||||||
|
): Diff {
|
||||||
|
// After clearTerminal, cursor is at (0, 0)
|
||||||
|
const screen = new VirtualScreen({ x: 0, y: 0 }, frame.viewport.width)
|
||||||
|
renderFrame(screen, frame, stylePool)
|
||||||
|
|
||||||
|
return [{ type: 'clearTerminal', reason, debug }, ...screen.diff]
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFrame(screen: VirtualScreen, frame: Frame, stylePool: StylePool): void {
|
||||||
|
renderFrameSlice(screen, frame, 0, frame.screen.height, stylePool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a slice of rows from the frame's screen.
|
||||||
|
* Each row is rendered followed by a newline. Cursor ends at (0, endY).
|
||||||
|
*/
|
||||||
|
function renderFrameSlice(
|
||||||
|
screen: VirtualScreen,
|
||||||
|
frame: Frame,
|
||||||
|
startY: number,
|
||||||
|
endY: number,
|
||||||
|
stylePool: StylePool
|
||||||
|
): VirtualScreen {
|
||||||
|
let currentStyleId = stylePool.none
|
||||||
|
let currentHyperlink: Hyperlink = undefined
|
||||||
|
// Track the styleId of the last rendered cell on this line (-1 if none).
|
||||||
|
// Passed to visibleCellAtIndex to enable fg-only space optimization.
|
||||||
|
let lastRenderedStyleId = -1
|
||||||
|
|
||||||
|
const { width: screenWidth, cells, charPool, hyperlinkPool } = frame.screen
|
||||||
|
|
||||||
|
let index = startY * screenWidth
|
||||||
|
|
||||||
|
for (let y = startY; y < endY; y += 1) {
|
||||||
|
// Advance cursor to this row using LF (not CSI CUD / cursor-down).
|
||||||
|
// CSI CUD stops at the viewport bottom margin and cannot scroll,
|
||||||
|
// but LF scrolls the viewport to create new lines. Without this,
|
||||||
|
// when the cursor is at the viewport bottom, moveCursorTo's
|
||||||
|
// cursor-down silently fails, creating a permanent off-by-one
|
||||||
|
// between the virtual cursor and the real terminal cursor.
|
||||||
|
if (screen.cursor.y < y) {
|
||||||
|
const rowsToAdvance = y - screen.cursor.y
|
||||||
|
screen.txn(prev => {
|
||||||
|
const patches: Diff = new Array<Diff[number]>(1 + rowsToAdvance)
|
||||||
|
patches[0] = CARRIAGE_RETURN
|
||||||
|
|
||||||
|
for (let i = 0; i < rowsToAdvance; i++) {
|
||||||
|
patches[1 + i] = NEWLINE
|
||||||
|
}
|
||||||
|
|
||||||
|
return [patches, { dx: -prev.x, dy: rowsToAdvance }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset at start of each line — no cell rendered yet
|
||||||
|
lastRenderedStyleId = -1
|
||||||
|
|
||||||
|
for (let x = 0; x < screenWidth; x += 1, index += 1) {
|
||||||
|
// Skip spacers, unstyled empty cells, and fg-only styled spaces that
|
||||||
|
// match the last rendered style (since cursor-forward produces identical
|
||||||
|
// visual result). visibleCellAtIndex handles the optimization internally
|
||||||
|
// to avoid allocating Cell objects for skipped cells.
|
||||||
|
const cell = visibleCellAtIndex(cells, charPool, hyperlinkPool, index, lastRenderedStyleId)
|
||||||
|
|
||||||
|
if (!cell) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
moveCursorTo(screen, x, y)
|
||||||
|
|
||||||
|
// Handle hyperlink
|
||||||
|
const targetHyperlink = cell.hyperlink
|
||||||
|
currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, targetHyperlink)
|
||||||
|
|
||||||
|
// Style transition — cached string, zero allocations after warmup
|
||||||
|
const styleStr = stylePool.transition(currentStyleId, cell.styleId)
|
||||||
|
|
||||||
|
if (writeCellWithStyleStr(screen, cell, styleStr)) {
|
||||||
|
currentStyleId = cell.styleId
|
||||||
|
lastRenderedStyleId = cell.styleId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset styles/hyperlinks before newline so background color doesn't
|
||||||
|
// bleed into the next line when the terminal scrolls. The old code
|
||||||
|
// reset implicitly by writing trailing unstyled spaces; now that we
|
||||||
|
// skip empty cells, we must reset explicitly.
|
||||||
|
currentStyleId = transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none)
|
||||||
|
currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, undefined)
|
||||||
|
// CR+LF at end of row — \r resets to column 0, \n moves to next line.
|
||||||
|
// Without \r, the terminal cursor stays at whatever column content ended
|
||||||
|
// (since we skip trailing spaces, this can be mid-row).
|
||||||
|
screen.txn(prev => [[CARRIAGE_RETURN, NEWLINE], { dx: -prev.x, dy: 1 }])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset any open style/hyperlink at end of slice
|
||||||
|
transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none)
|
||||||
|
transitionHyperlink(screen.diff, currentHyperlink, undefined)
|
||||||
|
|
||||||
|
return screen
|
||||||
|
}
|
||||||
|
|
||||||
|
type Delta = { dx: number; dy: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a cell with a pre-serialized style transition string (from
|
||||||
|
* StylePool.transition). Inlines the txn logic to avoid closure/tuple/delta
|
||||||
|
* allocations on every cell.
|
||||||
|
*
|
||||||
|
* Returns true if the cell was written, false if skipped (wide char at
|
||||||
|
* viewport edge). Callers MUST gate currentStyleId updates on this — when
|
||||||
|
* skipped, styleStr is never pushed and the terminal's style state is
|
||||||
|
* unchanged. Updating the virtual tracker anyway desyncs it from the
|
||||||
|
* terminal, and the next transition is computed from phantom state.
|
||||||
|
*/
|
||||||
|
function writeCellWithStyleStr(screen: VirtualScreen, cell: Cell, styleStr: string): boolean {
|
||||||
|
const cellWidth = cell.width === CellWidth.Wide ? 2 : 1
|
||||||
|
const px = screen.cursor.x
|
||||||
|
const vw = screen.viewportWidth
|
||||||
|
|
||||||
|
// Don't write wide chars that would cross the viewport edge.
|
||||||
|
// Single-codepoint chars (CJK) at vw-2 are safe; multi-codepoint
|
||||||
|
// graphemes (flags, ZWJ emoji) need stricter threshold.
|
||||||
|
if (cellWidth === 2 && px < vw) {
|
||||||
|
const threshold = cell.char.length > 2 ? vw : vw + 1
|
||||||
|
|
||||||
|
if (px + 2 >= threshold) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = screen.diff
|
||||||
|
|
||||||
|
if (styleStr.length > 0) {
|
||||||
|
diff.push({ type: 'styleStr', str: styleStr })
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsCompensation = cellWidth === 2 && needsWidthCompensation(cell.char)
|
||||||
|
|
||||||
|
// On terminals with old wcwidth tables, a compensated emoji only advances
|
||||||
|
// the cursor 1 column, so the CHA below skips column x+1 without painting
|
||||||
|
// it. Write a styled space there first — on correct terminals the emoji
|
||||||
|
// glyph (width 2) overwrites it harmlessly; on old terminals it fills the
|
||||||
|
// gap with the emoji's background. Also clears any stale content at x+1.
|
||||||
|
// CHA is 1-based, so column px+1 (0-based) is CHA target px+2.
|
||||||
|
if (needsCompensation && px + 1 < vw) {
|
||||||
|
diff.push({ type: 'cursorTo', col: px + 2 })
|
||||||
|
diff.push({ type: 'stdout', content: ' ' })
|
||||||
|
diff.push({ type: 'cursorTo', col: px + 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
diff.push({ type: 'stdout', content: cell.char })
|
||||||
|
|
||||||
|
// Force terminal cursor to correct column after the emoji.
|
||||||
|
if (needsCompensation) {
|
||||||
|
diff.push({ type: 'cursorTo', col: px + cellWidth + 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cursor — mutate in place to avoid Point allocation
|
||||||
|
if (px >= vw) {
|
||||||
|
screen.cursor.x = cellWidth
|
||||||
|
screen.cursor.y++
|
||||||
|
} else {
|
||||||
|
screen.cursor.x = px + cellWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCursorTo(screen: VirtualScreen, targetX: number, targetY: number) {
|
||||||
|
screen.txn(prev => {
|
||||||
|
const dx = targetX - prev.x
|
||||||
|
const dy = targetY - prev.y
|
||||||
|
const inPendingWrap = prev.x >= screen.viewportWidth
|
||||||
|
|
||||||
|
// If we're in pending wrap state (cursor.x >= width), use CR
|
||||||
|
// to reset to column 0 on the current line without advancing
|
||||||
|
// to the next line, then issue the cursor movement.
|
||||||
|
if (inPendingWrap) {
|
||||||
|
return [[CARRIAGE_RETURN, { type: 'cursorMove', x: targetX, y: dy }], { dx, dy }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// When moving to a different line, use carriage return (\r) to reset to
|
||||||
|
// column 0 first, then cursor move.
|
||||||
|
if (dy !== 0) {
|
||||||
|
return [[CARRIAGE_RETURN, { type: 'cursorMove', x: targetX, y: dy }], { dx, dy }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard same-line cursor move
|
||||||
|
return [[{ type: 'cursorMove', x: dx, y: dy }], { dx, dy }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify emoji where the terminal's wcwidth may disagree with Unicode.
|
||||||
|
* On terminals with correct tables, the CHA we emit is a harmless no-op.
|
||||||
|
*
|
||||||
|
* Two categories:
|
||||||
|
* 1. Newer emoji (Unicode 12.0+) missing from terminal wcwidth tables.
|
||||||
|
* 2. Text-by-default emoji + VS16 (U+FE0F): the base codepoint is width 1
|
||||||
|
* in wcwidth, but VS16 triggers emoji presentation making it width 2.
|
||||||
|
* Examples: ⚔️ (U+2694), ☠️ (U+2620), ❤️ (U+2764).
|
||||||
|
*/
|
||||||
|
function needsWidthCompensation(char: string): boolean {
|
||||||
|
const cp = char.codePointAt(0)
|
||||||
|
|
||||||
|
if (cp === undefined) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// U+1FA70-U+1FAFF: Symbols and Pictographs Extended-A (Unicode 12.0-15.0)
|
||||||
|
// U+1FB00-U+1FBFF: Symbols for Legacy Computing (Unicode 13.0)
|
||||||
|
if ((cp >= 0x1fa70 && cp <= 0x1faff) || (cp >= 0x1fb00 && cp <= 0x1fbff)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text-by-default emoji with VS16: scan for U+FE0F in multi-codepoint
|
||||||
|
// graphemes. Single BMP chars (length 1) and surrogate pairs without VS16
|
||||||
|
// skip this check. VS16 (0xFE0F) can't collide with surrogates (0xD800-0xDFFF).
|
||||||
|
if (char.length >= 2) {
|
||||||
|
for (let i = 0; i < char.length; i++) {
|
||||||
|
if (char.charCodeAt(i) === 0xfe0f) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
class VirtualScreen {
|
||||||
|
// Public for direct mutation by writeCellWithStyleStr (avoids txn overhead).
|
||||||
|
// File-private class — not exposed outside log-update.ts.
|
||||||
|
cursor: Point
|
||||||
|
diff: Diff = []
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
origin: Point,
|
||||||
|
readonly viewportWidth: number
|
||||||
|
) {
|
||||||
|
this.cursor = { ...origin }
|
||||||
|
}
|
||||||
|
|
||||||
|
txn(fn: (prev: Point) => [patches: Diff, next: Delta]): void {
|
||||||
|
const [patches, next] = fn(this.cursor)
|
||||||
|
|
||||||
|
for (const patch of patches) {
|
||||||
|
this.diff.push(patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cursor.x += next.dx
|
||||||
|
this.cursor.y += next.dy
|
||||||
|
}
|
||||||
|
}
|
||||||
23
ui-tui/packages/hermes-ink/src/ink/measure-element.ts
Normal file
23
ui-tui/packages/hermes-ink/src/ink/measure-element.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import type { DOMElement } from './dom.js'
|
||||||
|
|
||||||
|
type Output = {
|
||||||
|
/**
|
||||||
|
* Element width.
|
||||||
|
*/
|
||||||
|
width: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Element height.
|
||||||
|
*/
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure the dimensions of a particular `<Box>` element.
|
||||||
|
*/
|
||||||
|
const measureElement = (node: DOMElement): Output => ({
|
||||||
|
width: node.yogaNode?.getComputedWidth() ?? 0,
|
||||||
|
height: node.yogaNode?.getComputedHeight() ?? 0
|
||||||
|
})
|
||||||
|
|
||||||
|
export default measureElement
|
||||||
50
ui-tui/packages/hermes-ink/src/ink/measure-text.ts
Normal file
50
ui-tui/packages/hermes-ink/src/ink/measure-text.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { lineWidth } from './line-width-cache.js'
|
||||||
|
|
||||||
|
type Output = {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-pass measurement: computes both width and height in one
|
||||||
|
// iteration instead of two (widestLine + countVisualLines).
|
||||||
|
// Uses indexOf to avoid array allocation from split('\n').
|
||||||
|
function measureText(text: string, maxWidth: number): Output {
|
||||||
|
if (text.length === 0) {
|
||||||
|
return {
|
||||||
|
width: 0,
|
||||||
|
height: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infinite or non-positive width means no wrapping — each line is one visual line.
|
||||||
|
// Must check before the loop since Math.ceil(w / Infinity) = 0.
|
||||||
|
const noWrap = maxWidth <= 0 || !Number.isFinite(maxWidth)
|
||||||
|
|
||||||
|
let height = 0
|
||||||
|
let width = 0
|
||||||
|
let start = 0
|
||||||
|
|
||||||
|
while (start <= text.length) {
|
||||||
|
const end = text.indexOf('\n', start)
|
||||||
|
const line = end === -1 ? text.substring(start) : text.substring(start, end)
|
||||||
|
|
||||||
|
const w = lineWidth(line)
|
||||||
|
width = Math.max(width, w)
|
||||||
|
|
||||||
|
if (noWrap) {
|
||||||
|
height++
|
||||||
|
} else {
|
||||||
|
height += w === 0 ? 1 : Math.ceil(w / maxWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end === -1) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
start = end + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default measureText
|
||||||
53
ui-tui/packages/hermes-ink/src/ink/node-cache.ts
Normal file
53
ui-tui/packages/hermes-ink/src/ink/node-cache.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import type { DOMElement } from './dom.js'
|
||||||
|
import type { Rectangle } from './layout/geometry.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached layout bounds for each rendered node (used for blit + clearing).
|
||||||
|
* `top` is the yoga-local getComputedTop() — stored so ScrollBox viewport
|
||||||
|
* culling can skip yoga reads for clean children whose position hasn't
|
||||||
|
* shifted (O(dirty) instead of O(mounted) first-pass).
|
||||||
|
*/
|
||||||
|
export type CachedLayout = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
top?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nodeCache = new WeakMap<DOMElement, CachedLayout>()
|
||||||
|
|
||||||
|
/** Rects of removed children that need clearing on next render */
|
||||||
|
export const pendingClears = new WeakMap<DOMElement, Rectangle[]>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set when a pendingClear is added for an absolute-positioned node.
|
||||||
|
* Signals renderer to disable blit for the next frame: the removed node
|
||||||
|
* may have painted over non-siblings (e.g. an overlay over a ScrollBox
|
||||||
|
* earlier in tree order), so their blits from prevScreen would restore
|
||||||
|
* the overlay's pixels. Normal-flow removals are already handled by
|
||||||
|
* hasRemovedChild at the parent level; only absolute positioning paints
|
||||||
|
* cross-subtree. Reset at the start of each render.
|
||||||
|
*/
|
||||||
|
let absoluteNodeRemoved = false
|
||||||
|
|
||||||
|
export function addPendingClear(parent: DOMElement, rect: Rectangle, isAbsolute: boolean): void {
|
||||||
|
const existing = pendingClears.get(parent)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.push(rect)
|
||||||
|
} else {
|
||||||
|
pendingClears.set(parent, [rect])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAbsolute) {
|
||||||
|
absoluteNodeRemoved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeAbsoluteRemovedFlag(): boolean {
|
||||||
|
const had = absoluteNodeRemoved
|
||||||
|
absoluteNodeRemoved = false
|
||||||
|
|
||||||
|
return had
|
||||||
|
}
|
||||||
99
ui-tui/packages/hermes-ink/src/ink/optimizer.ts
Normal file
99
ui-tui/packages/hermes-ink/src/ink/optimizer.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import type { Diff } from './frame.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimize a diff by applying all optimization rules in a single pass.
|
||||||
|
* This reduces the number of patches that need to be written to the terminal.
|
||||||
|
*
|
||||||
|
* Rules applied:
|
||||||
|
* - Remove empty stdout patches
|
||||||
|
* - Merge consecutive cursorMove patches
|
||||||
|
* - Remove no-op cursorMove (0,0) patches
|
||||||
|
* - Concat adjacent style patches (transition diffs — can't drop either)
|
||||||
|
* - Dedupe consecutive hyperlinks with same URI
|
||||||
|
* - Cancel cursor hide/show pairs
|
||||||
|
* - Remove clear patches with count 0
|
||||||
|
*/
|
||||||
|
export function optimize(diff: Diff): Diff {
|
||||||
|
if (diff.length <= 1) {
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Diff = []
|
||||||
|
let len = 0
|
||||||
|
|
||||||
|
for (const patch of diff) {
|
||||||
|
const type = patch.type
|
||||||
|
|
||||||
|
// Skip no-ops
|
||||||
|
if (type === 'stdout') {
|
||||||
|
if (patch.content === '') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if (type === 'cursorMove') {
|
||||||
|
if (patch.x === 0 && patch.y === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if (type === 'clear') {
|
||||||
|
if (patch.count === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to merge with previous patch
|
||||||
|
if (len > 0) {
|
||||||
|
const lastIdx = len - 1
|
||||||
|
const last = result[lastIdx]!
|
||||||
|
const lastType = last.type
|
||||||
|
|
||||||
|
// Merge consecutive cursorMove
|
||||||
|
if (type === 'cursorMove' && lastType === 'cursorMove') {
|
||||||
|
result[lastIdx] = {
|
||||||
|
type: 'cursorMove',
|
||||||
|
x: last.x + patch.x,
|
||||||
|
y: last.y + patch.y
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse consecutive cursorTo (only the last one matters)
|
||||||
|
if (type === 'cursorTo' && lastType === 'cursorTo') {
|
||||||
|
result[lastIdx] = patch
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concat adjacent style patches. styleStr is a transition diff
|
||||||
|
// (computed by diffAnsiCodes(from, to)), not a setter — dropping
|
||||||
|
// the first is only sound if its undo-codes are a subset of the
|
||||||
|
// second's, which is NOT guaranteed. e.g. [\e[49m, \e[2m]: dropping
|
||||||
|
// the bg reset leaks it into the next \e[2J/\e[2K via BCE.
|
||||||
|
if (type === 'styleStr' && lastType === 'styleStr') {
|
||||||
|
result[lastIdx] = { type: 'styleStr', str: last.str + patch.str }
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe hyperlinks
|
||||||
|
if (type === 'hyperlink' && lastType === 'hyperlink' && patch.uri === last.uri) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel cursor hide/show pairs
|
||||||
|
if (
|
||||||
|
(type === 'cursorShow' && lastType === 'cursorHide') ||
|
||||||
|
(type === 'cursorHide' && lastType === 'cursorShow')
|
||||||
|
) {
|
||||||
|
result.pop()
|
||||||
|
len--
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(patch)
|
||||||
|
len++
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
808
ui-tui/packages/hermes-ink/src/ink/output.ts
Normal file
808
ui-tui/packages/hermes-ink/src/ink/output.ts
Normal file
|
|
@ -0,0 +1,808 @@
|
||||||
|
import { type AnsiCode, type StyledChar, styledCharsFromTokens, tokenize } from '@alcalzone/ansi-tokenize'
|
||||||
|
|
||||||
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
|
import { getGraphemeSegmenter } from '../utils/intl.js'
|
||||||
|
import sliceAnsi from '../utils/sliceAnsi.js'
|
||||||
|
|
||||||
|
import { reorderBidi } from './bidi.js'
|
||||||
|
import { type Rectangle, unionRect } from './layout/geometry.js'
|
||||||
|
import {
|
||||||
|
blitRegion,
|
||||||
|
CellWidth,
|
||||||
|
extractHyperlinkFromStyles,
|
||||||
|
filterOutHyperlinkStyles,
|
||||||
|
markNoSelectRegion,
|
||||||
|
OSC8_PREFIX,
|
||||||
|
resetScreen,
|
||||||
|
type Screen,
|
||||||
|
setCellAt,
|
||||||
|
shiftRows,
|
||||||
|
type StylePool
|
||||||
|
} from './screen.js'
|
||||||
|
import { stringWidth } from './stringWidth.js'
|
||||||
|
import { widestLine } from './widest-line.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A grapheme cluster with precomputed terminal width, styleId, and hyperlink.
|
||||||
|
* Built once per unique line (cached via charCache), so the per-char hot loop
|
||||||
|
* is just property reads + setCellAt — no stringWidth, no style interning,
|
||||||
|
* no hyperlink extraction per frame.
|
||||||
|
*
|
||||||
|
* styleId is safe to cache: StylePool is session-lived (never reset).
|
||||||
|
* hyperlink is stored as a string (not interned ID) since hyperlinkPool
|
||||||
|
* resets every 5 min; setCellAt interns it per-frame (cheap Map.get).
|
||||||
|
*/
|
||||||
|
type ClusteredChar = {
|
||||||
|
value: string
|
||||||
|
width: number
|
||||||
|
styleId: number
|
||||||
|
hyperlink: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects write/blit/clear/clip operations from the render tree, then
|
||||||
|
* applies them to a Screen buffer in `get()`. The Screen is what gets
|
||||||
|
* diffed against the previous frame to produce terminal updates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
stylePool: StylePool
|
||||||
|
/**
|
||||||
|
* Screen to render into. Will be reset before use.
|
||||||
|
* For double-buffering, pass a reusable screen. Otherwise create a new one.
|
||||||
|
*/
|
||||||
|
screen: Screen
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Operation =
|
||||||
|
| WriteOperation
|
||||||
|
| ClipOperation
|
||||||
|
| UnclipOperation
|
||||||
|
| BlitOperation
|
||||||
|
| ClearOperation
|
||||||
|
| NoSelectOperation
|
||||||
|
| ShiftOperation
|
||||||
|
|
||||||
|
type WriteOperation = {
|
||||||
|
type: 'write'
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
text: string
|
||||||
|
/**
|
||||||
|
* Per-line soft-wrap flags, parallel to text.split('\n'). softWrap[i]=true
|
||||||
|
* means line i is a continuation of line i-1 (the `\n` before it was
|
||||||
|
* inserted by word-wrap, not in the source). Index 0 is always false.
|
||||||
|
* Undefined means the producer didn't track wrapping (e.g. fills,
|
||||||
|
* raw-ansi) — the screen's per-row bitmap is left untouched.
|
||||||
|
*/
|
||||||
|
softWrap?: boolean[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClipOperation = {
|
||||||
|
type: 'clip'
|
||||||
|
clip: Clip
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Clip = {
|
||||||
|
x1: number | undefined
|
||||||
|
x2: number | undefined
|
||||||
|
y1: number | undefined
|
||||||
|
y2: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intersect two clips. `undefined` on an axis means unbounded; the other
|
||||||
|
* clip's bound wins. If both are bounded, take the tighter constraint
|
||||||
|
* (max of mins, min of maxes). If the resulting region is empty
|
||||||
|
* (x1 >= x2 or y1 >= y2), writes clipped by it will be dropped.
|
||||||
|
*/
|
||||||
|
function intersectClip(parent: Clip | undefined, child: Clip): Clip {
|
||||||
|
if (!parent) {
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x1: maxDefined(parent.x1, child.x1),
|
||||||
|
x2: minDefined(parent.x2, child.x2),
|
||||||
|
y1: maxDefined(parent.y1, child.y1),
|
||||||
|
y2: minDefined(parent.y2, child.y2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxDefined(a: number | undefined, b: number | undefined): number | undefined {
|
||||||
|
if (a === undefined) {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b === undefined) {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
function minDefined(a: number | undefined, b: number | undefined): number | undefined {
|
||||||
|
if (a === undefined) {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b === undefined) {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnclipOperation = {
|
||||||
|
type: 'unclip'
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlitOperation = {
|
||||||
|
type: 'blit'
|
||||||
|
src: Screen
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShiftOperation = {
|
||||||
|
type: 'shift'
|
||||||
|
top: number
|
||||||
|
bottom: number
|
||||||
|
n: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClearOperation = {
|
||||||
|
type: 'clear'
|
||||||
|
region: Rectangle
|
||||||
|
/**
|
||||||
|
* Set when the clear is for an absolute-positioned node's old bounds.
|
||||||
|
* Absolute nodes overlay normal-flow siblings, so their stale paint is
|
||||||
|
* what an earlier sibling's clean-subtree blit wrongly restores from
|
||||||
|
* prevScreen. Normal-flow siblings' clears don't have this problem —
|
||||||
|
* their old position can't have been painted on top of a sibling.
|
||||||
|
*/
|
||||||
|
fromAbsolute?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoSelectOperation = {
|
||||||
|
type: 'noSelect'
|
||||||
|
region: Rectangle
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Output {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
private readonly stylePool: StylePool
|
||||||
|
private screen: Screen
|
||||||
|
|
||||||
|
private readonly operations: Operation[] = []
|
||||||
|
|
||||||
|
private charCache: Map<string, ClusteredChar[]> = new Map()
|
||||||
|
|
||||||
|
constructor(options: Options) {
|
||||||
|
const { width, height, stylePool, screen } = options
|
||||||
|
|
||||||
|
this.width = width
|
||||||
|
this.height = height
|
||||||
|
this.stylePool = stylePool
|
||||||
|
this.screen = screen
|
||||||
|
|
||||||
|
resetScreen(screen, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reuse this Output for a new frame. Zeroes the screen buffer, clears
|
||||||
|
* the operation list (backing storage is retained), and caps charCache
|
||||||
|
* growth. Preserving charCache across frames is the main win — most
|
||||||
|
* lines don't change between renders, so tokenize + grapheme clustering
|
||||||
|
* becomes a cache hit.
|
||||||
|
*/
|
||||||
|
reset(width: number, height: number, screen: Screen): void {
|
||||||
|
this.width = width
|
||||||
|
this.height = height
|
||||||
|
this.screen = screen
|
||||||
|
this.operations.length = 0
|
||||||
|
resetScreen(screen, width, height)
|
||||||
|
|
||||||
|
if (this.charCache.size > 16384) {
|
||||||
|
this.charCache.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy cells from a source screen region (blit = block image transfer).
|
||||||
|
*/
|
||||||
|
blit(src: Screen, x: number, y: number, width: number, height: number): void {
|
||||||
|
this.operations.push({ type: 'blit', src, x, y, width, height })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shift full-width rows within [top, bottom] by n. n > 0 = up. Mirrors
|
||||||
|
* what DECSTBM + SU/SD does to the terminal. Paired with blit() to reuse
|
||||||
|
* prevScreen content during pure scroll, avoiding full child re-render.
|
||||||
|
*/
|
||||||
|
shift(top: number, bottom: number, n: number): void {
|
||||||
|
this.operations.push({ type: 'shift', top, bottom, n })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear a region by writing empty cells. Used when a node shrinks to
|
||||||
|
* ensure stale content from the previous frame is removed.
|
||||||
|
*/
|
||||||
|
clear(region: Rectangle, fromAbsolute?: boolean): void {
|
||||||
|
this.operations.push({ type: 'clear', region, fromAbsolute })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a region as non-selectable (excluded from fullscreen text
|
||||||
|
* selection copy + highlight). Used by <NoSelect> to fence off
|
||||||
|
* gutters (line numbers, diff sigils). Applied AFTER blit/write so
|
||||||
|
* the mark wins regardless of what's blitted into the region.
|
||||||
|
*/
|
||||||
|
noSelect(region: Rectangle): void {
|
||||||
|
this.operations.push({ type: 'noSelect', region })
|
||||||
|
}
|
||||||
|
|
||||||
|
write(x: number, y: number, text: string, softWrap?: boolean[]): void {
|
||||||
|
if (!text) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.operations.push({
|
||||||
|
type: 'write',
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
text,
|
||||||
|
softWrap
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
clip(clip: Clip) {
|
||||||
|
this.operations.push({
|
||||||
|
type: 'clip',
|
||||||
|
clip
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
unclip() {
|
||||||
|
this.operations.push({
|
||||||
|
type: 'unclip'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): Screen {
|
||||||
|
const screen = this.screen
|
||||||
|
const screenWidth = this.width
|
||||||
|
const screenHeight = this.height
|
||||||
|
|
||||||
|
// Track blit vs write cell counts for debugging
|
||||||
|
let blitCells = 0
|
||||||
|
let writeCells = 0
|
||||||
|
|
||||||
|
// Pass 1: expand damage to cover clear regions. The buffer is freshly
|
||||||
|
// zeroed by resetScreen, so this pass only marks damage so diff()
|
||||||
|
// checks these regions against the previous frame.
|
||||||
|
//
|
||||||
|
// Also collect clears from absolute-positioned nodes. An absolute
|
||||||
|
// node overlays normal-flow siblings; when it shrinks, its clear is
|
||||||
|
// pushed AFTER those siblings' clean-subtree blits (DOM order). The
|
||||||
|
// blit copies the absolute node's own stale paint from prevScreen,
|
||||||
|
// and since clear is damage-only, the ghost survives diff. Normal-
|
||||||
|
// flow clears don't need this — a normal-flow node's old position
|
||||||
|
// can't have been painted on top of a sibling's current position.
|
||||||
|
const absoluteClears: Rectangle[] = []
|
||||||
|
|
||||||
|
for (const operation of this.operations) {
|
||||||
|
if (operation.type !== 'clear') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const { x, y, width, height } = operation.region
|
||||||
|
const startX = Math.max(0, x)
|
||||||
|
const startY = Math.max(0, y)
|
||||||
|
const maxX = Math.min(x + width, screenWidth)
|
||||||
|
const maxY = Math.min(y + height, screenHeight)
|
||||||
|
|
||||||
|
if (startX >= maxX || startY >= maxY) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = {
|
||||||
|
x: startX,
|
||||||
|
y: startY,
|
||||||
|
width: maxX - startX,
|
||||||
|
height: maxY - startY
|
||||||
|
}
|
||||||
|
|
||||||
|
screen.damage = screen.damage ? unionRect(screen.damage, rect) : rect
|
||||||
|
|
||||||
|
if (operation.fromAbsolute) {
|
||||||
|
absoluteClears.push(rect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clips: Clip[] = []
|
||||||
|
|
||||||
|
for (const operation of this.operations) {
|
||||||
|
switch (operation.type) {
|
||||||
|
case 'clear':
|
||||||
|
// handled in pass 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
case 'clip':
|
||||||
|
// Intersect with the parent clip (if any) so nested
|
||||||
|
// overflow:hidden boxes can't write outside their ancestor's
|
||||||
|
// clip region. Without this, a message with overflow:hidden at
|
||||||
|
// the bottom of a scrollbox pushes its OWN clip (based on its
|
||||||
|
// layout bounds, already translated by -scrollTop) which can
|
||||||
|
// extend below the scrollbox viewport — writes escape into
|
||||||
|
// the sibling bottom section's rows.
|
||||||
|
clips.push(intersectClip(clips.at(-1), operation.clip))
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
case 'unclip':
|
||||||
|
clips.pop()
|
||||||
|
|
||||||
|
continue
|
||||||
|
case 'blit': {
|
||||||
|
// Bulk-copy cells from source screen region using TypedArray.set().
|
||||||
|
// Tracking damage ensures diff() checks blitted cells for stale content
|
||||||
|
// when a parent blits an area that previously contained child content.
|
||||||
|
const { src, x: regionX, y: regionY, width: regionWidth, height: regionHeight } = operation
|
||||||
|
|
||||||
|
// Intersect with active clip — a child's clean-blit passes its full
|
||||||
|
// cached rect, but the parent ScrollBox may have shrunk (pill mount).
|
||||||
|
// Without this, the blit writes past the ScrollBox's new bottom edge
|
||||||
|
// into the pill's row.
|
||||||
|
const clip = clips.at(-1)
|
||||||
|
const startX = Math.max(regionX, clip?.x1 ?? 0)
|
||||||
|
const startY = Math.max(regionY, clip?.y1 ?? 0)
|
||||||
|
|
||||||
|
const maxY = Math.min(regionY + regionHeight, screenHeight, src.height, clip?.y2 ?? Infinity)
|
||||||
|
|
||||||
|
const maxX = Math.min(regionX + regionWidth, screenWidth, src.width, clip?.x2 ?? Infinity)
|
||||||
|
|
||||||
|
if (startX >= maxX || startY >= maxY) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip rows covered by an absolute-positioned node's clear.
|
||||||
|
// Absolute nodes overlay normal-flow siblings, so prevScreen in
|
||||||
|
// that region holds the absolute node's stale paint — blitting
|
||||||
|
// it back would ghost. See absoluteClears collection above.
|
||||||
|
if (absoluteClears.length === 0) {
|
||||||
|
blitRegion(screen, src, startX, startY, maxX, maxY)
|
||||||
|
blitCells += (maxY - startY) * (maxX - startX)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let rowStart = startY
|
||||||
|
|
||||||
|
for (let row = startY; row <= maxY; row++) {
|
||||||
|
const excluded =
|
||||||
|
row < maxY &&
|
||||||
|
absoluteClears.some(r => row >= r.y && row < r.y + r.height && startX >= r.x && maxX <= r.x + r.width)
|
||||||
|
|
||||||
|
if (excluded || row === maxY) {
|
||||||
|
if (row > rowStart) {
|
||||||
|
blitRegion(screen, src, startX, rowStart, maxX, row)
|
||||||
|
blitCells += (row - rowStart) * (maxX - startX)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowStart = row + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'shift': {
|
||||||
|
shiftRows(screen, operation.top, operation.bottom, operation.n)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'write': {
|
||||||
|
const { text, softWrap } = operation
|
||||||
|
let { x, y } = operation
|
||||||
|
let lines = text.split('\n')
|
||||||
|
let swFrom = 0
|
||||||
|
let prevContentEnd = 0
|
||||||
|
|
||||||
|
const clip = clips.at(-1)
|
||||||
|
|
||||||
|
if (clip) {
|
||||||
|
const clipHorizontally = typeof clip?.x1 === 'number' && typeof clip?.x2 === 'number'
|
||||||
|
|
||||||
|
const clipVertically = typeof clip?.y1 === 'number' && typeof clip?.y2 === 'number'
|
||||||
|
|
||||||
|
// If text is positioned outside of clipping area altogether,
|
||||||
|
// skip to the next operation to avoid unnecessary calculations
|
||||||
|
if (clipHorizontally) {
|
||||||
|
const width = widestLine(text)
|
||||||
|
|
||||||
|
if (x + width <= clip.x1! || x >= clip.x2!) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clipVertically) {
|
||||||
|
const height = lines.length
|
||||||
|
|
||||||
|
if (y + height <= clip.y1! || y >= clip.y2!) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clipHorizontally) {
|
||||||
|
lines = lines.map(line => {
|
||||||
|
const from = x < clip.x1! ? clip.x1! - x : 0
|
||||||
|
const width = stringWidth(line)
|
||||||
|
const to = x + width > clip.x2! ? clip.x2! - x : width
|
||||||
|
let sliced = sliceAnsi(line, from, to)
|
||||||
|
|
||||||
|
// Wide chars (CJK, emoji) occupy 2 cells. When `to` lands
|
||||||
|
// on the first cell of a wide char, sliceAnsi includes the
|
||||||
|
// entire glyph and the result overflows clip.x2 by one cell,
|
||||||
|
// writing a SpacerTail into the adjacent sibling. Re-slice
|
||||||
|
// one cell earlier; wide chars are exactly 2 cells, so a
|
||||||
|
// single retry always fits.
|
||||||
|
if (stringWidth(sliced) > to - from) {
|
||||||
|
sliced = sliceAnsi(line, from, to - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sliced
|
||||||
|
})
|
||||||
|
|
||||||
|
if (x < clip.x1!) {
|
||||||
|
x = clip.x1!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clipVertically) {
|
||||||
|
const from = y < clip.y1! ? clip.y1! - y : 0
|
||||||
|
const height = lines.length
|
||||||
|
const to = y + height > clip.y2! ? clip.y2! - y : height
|
||||||
|
|
||||||
|
// If the first visible line is a soft-wrap continuation, we
|
||||||
|
// need the clipped previous line's content end so
|
||||||
|
// screen.softWrap[lineY] correctly records the join point
|
||||||
|
// even though that line's cells were never written.
|
||||||
|
if (softWrap && from > 0 && softWrap[from] === true) {
|
||||||
|
prevContentEnd = x + stringWidth(lines[from - 1]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = lines.slice(from, to)
|
||||||
|
swFrom = from
|
||||||
|
|
||||||
|
if (y < clip.y1!) {
|
||||||
|
y = clip.y1!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const swBits = screen.softWrap
|
||||||
|
let offsetY = 0
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const lineY = y + offsetY
|
||||||
|
|
||||||
|
// Line can be outside screen if `text` is taller than screen height
|
||||||
|
if (lineY >= screenHeight) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentEnd = writeLineToScreen(screen, line, x, lineY, screenWidth, this.stylePool, this.charCache)
|
||||||
|
|
||||||
|
writeCells += contentEnd - x
|
||||||
|
|
||||||
|
// See Screen.softWrap docstring for the encoding. contentEnd
|
||||||
|
// from writeLineToScreen is tab-expansion-aware, unlike
|
||||||
|
// x+stringWidth(line) which treats tabs as width 0.
|
||||||
|
if (softWrap) {
|
||||||
|
const isSW = softWrap[swFrom + offsetY] === true
|
||||||
|
swBits[lineY] = isSW ? prevContentEnd : 0
|
||||||
|
prevContentEnd = contentEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
offsetY++
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noSelect ops go LAST so they win over blits (which copy noSelect
|
||||||
|
// from prevScreen) and writes (which don't touch noSelect). This way
|
||||||
|
// a <NoSelect> box correctly fences its region even when the parent
|
||||||
|
// blits, and moving a <NoSelect> between frames correctly clears the
|
||||||
|
// old region (resetScreen already zeroed the bitmap).
|
||||||
|
for (const operation of this.operations) {
|
||||||
|
if (operation.type === 'noSelect') {
|
||||||
|
const { x, y, width, height } = operation.region
|
||||||
|
markNoSelectRegion(screen, x, y, width, height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log blit/write ratio for debugging - high write count suggests blitting isn't working
|
||||||
|
const totalCells = blitCells + writeCells
|
||||||
|
|
||||||
|
if (totalCells > 1000 && writeCells > blitCells) {
|
||||||
|
logForDebugging(
|
||||||
|
`High write ratio: blit=${blitCells}, write=${writeCells} (${((writeCells / totalCells) * 100).toFixed(1)}% writes), screen=${screenHeight}x${screenWidth}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return screen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stylesEqual(a: AnsiCode[], b: AnsiCode[]): boolean {
|
||||||
|
if (a === b) {
|
||||||
|
return true
|
||||||
|
} // Reference equality fast path
|
||||||
|
|
||||||
|
const len = a.length
|
||||||
|
|
||||||
|
if (len !== b.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (len === 0) {
|
||||||
|
return true
|
||||||
|
} // Both empty
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
if (a[i]!.code !== b[i]!.code) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a string with ANSI codes into styled characters with proper grapheme
|
||||||
|
* clustering. Fixes ansi-tokenize splitting grapheme clusters (like family
|
||||||
|
* emojis) into individual code points.
|
||||||
|
*
|
||||||
|
* Also precomputes styleId + hyperlink per style run (not per char) — an
|
||||||
|
* 80-char line with 3 style runs does 3 intern calls instead of 80.
|
||||||
|
*/
|
||||||
|
function styledCharsWithGraphemeClustering(chars: StyledChar[], stylePool: StylePool): ClusteredChar[] {
|
||||||
|
const charCount = chars.length
|
||||||
|
|
||||||
|
if (charCount === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ClusteredChar[] = []
|
||||||
|
const bufferChars: string[] = []
|
||||||
|
let bufferStyles: AnsiCode[] = chars[0]!.styles
|
||||||
|
|
||||||
|
for (let i = 0; i < charCount; i++) {
|
||||||
|
const char = chars[i]!
|
||||||
|
const styles = char.styles
|
||||||
|
|
||||||
|
// Different styles means we need to flush and start new buffer
|
||||||
|
if (bufferChars.length > 0 && !stylesEqual(styles, bufferStyles)) {
|
||||||
|
flushBuffer(bufferChars.join(''), bufferStyles, stylePool, result)
|
||||||
|
bufferChars.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
bufferChars.push(char.value)
|
||||||
|
bufferStyles = styles
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final flush
|
||||||
|
if (bufferChars.length > 0) {
|
||||||
|
flushBuffer(bufferChars.join(''), bufferStyles, stylePool, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushBuffer(buffer: string, styles: AnsiCode[], stylePool: StylePool, out: ClusteredChar[]): void {
|
||||||
|
// Compute styleId + hyperlink ONCE for the whole style run.
|
||||||
|
// Every grapheme in this buffer shares the same styles.
|
||||||
|
//
|
||||||
|
// Extract and track hyperlinks separately, filter from styles.
|
||||||
|
// Always check for OSC 8 codes to filter, not just when a URL is
|
||||||
|
// extracted. The tokenizer treats OSC 8 close codes (empty URL) as
|
||||||
|
// active styles, so they must be filtered even when no hyperlink
|
||||||
|
// URL is present.
|
||||||
|
const hyperlink = extractHyperlinkFromStyles(styles) ?? undefined
|
||||||
|
|
||||||
|
const hasOsc8Styles =
|
||||||
|
hyperlink !== undefined || styles.some(s => s.code.length >= OSC8_PREFIX.length && s.code.startsWith(OSC8_PREFIX))
|
||||||
|
|
||||||
|
const filteredStyles = hasOsc8Styles ? filterOutHyperlinkStyles(styles) : styles
|
||||||
|
|
||||||
|
const styleId = stylePool.intern(filteredStyles)
|
||||||
|
|
||||||
|
for (const { segment: grapheme } of getGraphemeSegmenter().segment(buffer)) {
|
||||||
|
out.push({
|
||||||
|
value: grapheme,
|
||||||
|
width: stringWidth(grapheme),
|
||||||
|
styleId,
|
||||||
|
hyperlink
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a single line's characters into the screen buffer.
|
||||||
|
* Extracted from Output.get() so JSC can optimize this tight,
|
||||||
|
* monomorphic loop independently — better register allocation,
|
||||||
|
* setCellAt inlining, and type feedback than when buried inside
|
||||||
|
* a 300-line dispatch function.
|
||||||
|
*
|
||||||
|
* Returns the end column (x + visual width, including tab expansion) so
|
||||||
|
* the caller can record it in screen.softWrap without re-walking the
|
||||||
|
* line via stringWidth(). Caller computes the debug cell-count as end-x.
|
||||||
|
*/
|
||||||
|
function writeLineToScreen(
|
||||||
|
screen: Screen,
|
||||||
|
line: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
screenWidth: number,
|
||||||
|
stylePool: StylePool,
|
||||||
|
charCache: Map<string, ClusteredChar[]>
|
||||||
|
): number {
|
||||||
|
let characters = charCache.get(line)
|
||||||
|
|
||||||
|
if (!characters) {
|
||||||
|
characters = reorderBidi(styledCharsWithGraphemeClustering(styledCharsFromTokens(tokenize(line)), stylePool))
|
||||||
|
charCache.set(line, characters)
|
||||||
|
}
|
||||||
|
|
||||||
|
let offsetX = x
|
||||||
|
|
||||||
|
for (let charIdx = 0; charIdx < characters.length; charIdx++) {
|
||||||
|
const character = characters[charIdx]!
|
||||||
|
const codePoint = character.value.codePointAt(0)
|
||||||
|
|
||||||
|
// Handle C0 control characters (0x00-0x1F) that cause cursor movement
|
||||||
|
// mismatches. stringWidth treats these as width 0, but terminals may
|
||||||
|
// move the cursor differently.
|
||||||
|
if (codePoint !== undefined && codePoint <= 0x1f) {
|
||||||
|
// Tab (0x09): expand to spaces to reach next tab stop
|
||||||
|
if (codePoint === 0x09) {
|
||||||
|
const tabWidth = 8
|
||||||
|
const spacesToNextStop = tabWidth - (offsetX % tabWidth)
|
||||||
|
|
||||||
|
for (let i = 0; i < spacesToNextStop && offsetX < screenWidth; i++) {
|
||||||
|
setCellAt(screen, offsetX, y, {
|
||||||
|
char: ' ',
|
||||||
|
styleId: stylePool.none,
|
||||||
|
width: CellWidth.Narrow,
|
||||||
|
hyperlink: undefined
|
||||||
|
})
|
||||||
|
offsetX++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ESC (0x1B): skip incomplete escape sequences that ansi-tokenize
|
||||||
|
// didn't recognize. ansi-tokenize only parses SGR sequences (ESC[...m)
|
||||||
|
// and OSC 8 hyperlinks (ESC]8;;url BEL). Other sequences like cursor
|
||||||
|
// movement, screen clearing, or terminal title become individual char
|
||||||
|
// tokens that we need to skip here.
|
||||||
|
else if (codePoint === 0x1b) {
|
||||||
|
const nextChar = characters[charIdx + 1]?.value
|
||||||
|
const nextCode = nextChar?.codePointAt(0)
|
||||||
|
|
||||||
|
if (nextChar === '(' || nextChar === ')' || nextChar === '*' || nextChar === '+') {
|
||||||
|
// Charset selection: ESC ( X, ESC ) X, etc.
|
||||||
|
// Skip the intermediate char and the charset designator
|
||||||
|
charIdx += 2
|
||||||
|
} else if (nextChar === '[') {
|
||||||
|
// CSI sequence: ESC [ ... final-byte
|
||||||
|
// Final byte is in range 0x40-0x7E (@, A-Z, [\]^_`, a-z, {|}~)
|
||||||
|
// Examples: ESC[2J (clear), ESC[?25l (cursor hide), ESC[H (home)
|
||||||
|
charIdx++ // skip the [
|
||||||
|
|
||||||
|
while (charIdx < characters.length - 1) {
|
||||||
|
charIdx++
|
||||||
|
const c = characters[charIdx]?.value.codePointAt(0)
|
||||||
|
|
||||||
|
// Final byte terminates the sequence
|
||||||
|
if (c !== undefined && c >= 0x40 && c <= 0x7e) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (nextChar === ']' || nextChar === 'P' || nextChar === '_' || nextChar === '^' || nextChar === 'X') {
|
||||||
|
// String-based sequences terminated by BEL (0x07) or ST (ESC \):
|
||||||
|
// - OSC: ESC ] ... (Operating System Command)
|
||||||
|
// - DCS: ESC P ... (Device Control String)
|
||||||
|
// - APC: ESC _ ... (Application Program Command)
|
||||||
|
// - PM: ESC ^ ... (Privacy Message)
|
||||||
|
// - SOS: ESC X ... (Start of String)
|
||||||
|
charIdx++ // skip the introducer char
|
||||||
|
|
||||||
|
while (charIdx < characters.length - 1) {
|
||||||
|
charIdx++
|
||||||
|
const c = characters[charIdx]?.value
|
||||||
|
|
||||||
|
// BEL (0x07) terminates the sequence
|
||||||
|
if (c === '\x07') {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// ST (String Terminator) is ESC \
|
||||||
|
// When we see ESC, check if next char is backslash
|
||||||
|
if (c === '\x1b') {
|
||||||
|
const nextC = characters[charIdx + 1]?.value
|
||||||
|
|
||||||
|
if (nextC === '\\') {
|
||||||
|
charIdx++ // skip the backslash too
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (nextCode !== undefined && nextCode >= 0x30 && nextCode <= 0x7e) {
|
||||||
|
// Single-character escape sequences: ESC followed by 0x30-0x7E
|
||||||
|
// (excluding the multi-char introducers already handled above)
|
||||||
|
// - Fp range (0x30-0x3F): ESC 7 (save cursor), ESC 8 (restore)
|
||||||
|
// - Fe range (0x40-0x5F): ESC D (index), ESC M (reverse index)
|
||||||
|
// - Fs range (0x60-0x7E): ESC c (reset)
|
||||||
|
charIdx++ // skip the command char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carriage return (0x0D): would move cursor to column 0, skip it
|
||||||
|
// Backspace (0x08): would move cursor left, skip it
|
||||||
|
// Bell (0x07), vertical tab (0x0B), form feed (0x0C): skip
|
||||||
|
// All other control chars (0x00-0x06, 0x0E-0x1F): skip
|
||||||
|
// Note: newline (0x0A) is already handled by line splitting
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero-width characters (combining marks, ZWNJ, ZWS, etc.)
|
||||||
|
// don't occupy terminal cells — storing them as Narrow cells
|
||||||
|
// desyncs the virtual cursor from the real terminal cursor.
|
||||||
|
// Width was computed once during clustering (cached via charCache).
|
||||||
|
const charWidth = character.width
|
||||||
|
|
||||||
|
if (charWidth === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWideCharacter = charWidth >= 2
|
||||||
|
|
||||||
|
// Wide char at last column can't fit — terminal would wrap it to
|
||||||
|
// the next line, desyncing our cursor model. Place a SpacerHead
|
||||||
|
// to mark the blank column, matching terminal behavior.
|
||||||
|
if (isWideCharacter && offsetX + 2 > screenWidth) {
|
||||||
|
setCellAt(screen, offsetX, y, {
|
||||||
|
char: ' ',
|
||||||
|
styleId: stylePool.none,
|
||||||
|
width: CellWidth.SpacerHead,
|
||||||
|
hyperlink: undefined
|
||||||
|
})
|
||||||
|
offsetX++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// styleId + hyperlink were precomputed during clustering (once per
|
||||||
|
// style run, cached via charCache). Hot loop is now just property
|
||||||
|
// reads — no intern, no extract, no filter per frame.
|
||||||
|
setCellAt(screen, offsetX, y, {
|
||||||
|
char: character.value,
|
||||||
|
styleId: character.styleId,
|
||||||
|
width: isWideCharacter ? CellWidth.Wide : CellWidth.Narrow,
|
||||||
|
hyperlink: character.hyperlink
|
||||||
|
})
|
||||||
|
offsetX += isWideCharacter ? 2 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return offsetX
|
||||||
|
}
|
||||||
833
ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts
Normal file
833
ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts
Normal file
|
|
@ -0,0 +1,833 @@
|
||||||
|
/**
|
||||||
|
* Keyboard input parser - converts terminal input to key events
|
||||||
|
*
|
||||||
|
* Uses the termio tokenizer for escape sequence boundary detection,
|
||||||
|
* then interprets sequences as keypresses.
|
||||||
|
*/
|
||||||
|
import { Buffer } from 'buffer'
|
||||||
|
|
||||||
|
import { PASTE_END, PASTE_START } from './termio/csi.js'
|
||||||
|
import { createTokenizer, type Tokenizer } from './termio/tokenize.js'
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const META_KEY_CODE_RE = /^(?:\x1b)([a-zA-Z0-9])$/
|
||||||
|
|
||||||
|
const FN_KEY_RE =
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
/^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/
|
||||||
|
|
||||||
|
// CSI u (kitty keyboard protocol): ESC [ codepoint [; modifier] u
|
||||||
|
// Example: ESC[13;2u = Shift+Enter, ESC[27u = Escape (no modifiers)
|
||||||
|
// Modifier is optional - when absent, defaults to 1 (no modifiers)
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/
|
||||||
|
|
||||||
|
// xterm modifyOtherKeys: ESC [ 27 ; modifier ; keycode ~
|
||||||
|
// Example: ESC[27;2;13~ = Shift+Enter. Emitted by Ghostty/tmux/xterm when
|
||||||
|
// modifyOtherKeys=2 is active or via user keybinds, typically over SSH where
|
||||||
|
// TERM sniffing misses Ghostty and we never push Kitty keyboard mode.
|
||||||
|
// Note param order is reversed vs CSI u (modifier first, keycode second).
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~/
|
||||||
|
|
||||||
|
// -- Terminal response patterns (inbound sequences from the terminal itself) --
|
||||||
|
// DECRPM: CSI ? Ps ; Pm $ y — response to DECRQM (request mode)
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const DECRPM_RE = /^\x1b\[\?(\d+);(\d+)\$y$/
|
||||||
|
// DA1: CSI ? Ps ; ... c — primary device attributes response
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const DA1_RE = /^\x1b\[\?([\d;]*)c$/
|
||||||
|
// DA2: CSI > Ps ; ... c — secondary device attributes response
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const DA2_RE = /^\x1b\[>([\d;]*)c$/
|
||||||
|
// Kitty keyboard flags: CSI ? flags u — response to CSI ? u query
|
||||||
|
// (private ? marker distinguishes from CSI u key events)
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const KITTY_FLAGS_RE = /^\x1b\[\?(\d+)u$/
|
||||||
|
// DECXCPR cursor position: CSI ? row ; col R
|
||||||
|
// The ? marker disambiguates from modified F3 keys (Shift+F3 = CSI 1;2 R,
|
||||||
|
// Ctrl+F3 = CSI 1;5 R, etc.) — plain CSI row;col R is genuinely ambiguous.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const CURSOR_POSITION_RE = /^\x1b\[\?(\d+);(\d+)R$/
|
||||||
|
// OSC response: OSC code ; data (BEL|ST)
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const OSC_RESPONSE_RE = /^\x1b\](\d+);(.*?)(?:\x07|\x1b\\)$/s
|
||||||
|
// XTVERSION: DCS > | name ST — terminal name/version string (answer to CSI > 0 q).
|
||||||
|
// xterm.js replies "xterm.js(X.Y.Z)"; Ghostty, kitty, iTerm2, etc. reply with
|
||||||
|
// their own name. Unlike TERM_PROGRAM, this survives SSH since the query/reply
|
||||||
|
// goes through the pty, not the environment.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
|
||||||
|
// SGR mouse event: CSI < button ; col ; row M (press) or m (release)
|
||||||
|
// Button codes: 64=wheel-up, 65=wheel-down (0x40 | wheel-bit).
|
||||||
|
// Button 32=left-drag (0x20 | motion-bit). Plain 0/1/2 = left/mid/right click.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
|
||||||
|
|
||||||
|
function createPasteKey(content: string): ParsedKey {
|
||||||
|
return {
|
||||||
|
kind: 'key',
|
||||||
|
name: '',
|
||||||
|
fn: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
option: false,
|
||||||
|
super: false,
|
||||||
|
sequence: content,
|
||||||
|
raw: content,
|
||||||
|
isPasted: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DECRPM status values (response to DECRQM) */
|
||||||
|
export const DECRPM_STATUS = {
|
||||||
|
NOT_RECOGNIZED: 0,
|
||||||
|
SET: 1,
|
||||||
|
RESET: 2,
|
||||||
|
PERMANENTLY_SET: 3,
|
||||||
|
PERMANENTLY_RESET: 4
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A response sequence received from the terminal (not a keypress).
|
||||||
|
* Emitted in answer to queries like DECRQM, DA1, OSC 11, etc.
|
||||||
|
*/
|
||||||
|
export type TerminalResponse =
|
||||||
|
/** DECRPM: answer to DECRQM (request DEC private mode status) */
|
||||||
|
| { type: 'decrpm'; mode: number; status: number }
|
||||||
|
/** DA1: primary device attributes (used as a universal sentinel) */
|
||||||
|
| { type: 'da1'; params: number[] }
|
||||||
|
/** DA2: secondary device attributes (terminal version info) */
|
||||||
|
| { type: 'da2'; params: number[] }
|
||||||
|
/** Kitty keyboard protocol: current flags (answer to CSI ? u) */
|
||||||
|
| { type: 'kittyKeyboard'; flags: number }
|
||||||
|
/** DSR: cursor position report (answer to CSI 6 n) */
|
||||||
|
| { type: 'cursorPosition'; row: number; col: number }
|
||||||
|
/** OSC response: generic operating-system-command reply (e.g. OSC 11 bg color) */
|
||||||
|
| { type: 'osc'; code: number; data: string }
|
||||||
|
/** XTVERSION: terminal name/version string (answer to CSI > 0 q).
|
||||||
|
* Example values: "xterm.js(5.5.0)", "ghostty 1.2.0", "iTerm2 3.6". */
|
||||||
|
| { type: 'xtversion'; name: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to recognize a sequence token as a terminal response.
|
||||||
|
* Returns null if the sequence is not a known response pattern
|
||||||
|
* (i.e. it should be treated as a keypress).
|
||||||
|
*
|
||||||
|
* These patterns are syntactically distinguishable from keyboard input —
|
||||||
|
* no physical key produces CSI ? ... c or CSI ? ... $ y, so they can be
|
||||||
|
* safely parsed out of the input stream at any time.
|
||||||
|
*/
|
||||||
|
function parseTerminalResponse(s: string): TerminalResponse | null {
|
||||||
|
// CSI-prefixed responses
|
||||||
|
if (s.startsWith('\x1b[')) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
|
||||||
|
if ((m = DECRPM_RE.exec(s))) {
|
||||||
|
return {
|
||||||
|
type: 'decrpm',
|
||||||
|
mode: parseInt(m[1]!, 10),
|
||||||
|
status: parseInt(m[2]!, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((m = DA1_RE.exec(s))) {
|
||||||
|
return { type: 'da1', params: splitNumericParams(m[1]!) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((m = DA2_RE.exec(s))) {
|
||||||
|
return { type: 'da2', params: splitNumericParams(m[1]!) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((m = KITTY_FLAGS_RE.exec(s))) {
|
||||||
|
return { type: 'kittyKeyboard', flags: parseInt(m[1]!, 10) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((m = CURSOR_POSITION_RE.exec(s))) {
|
||||||
|
return {
|
||||||
|
type: 'cursorPosition',
|
||||||
|
row: parseInt(m[1]!, 10),
|
||||||
|
col: parseInt(m[2]!, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// OSC responses (e.g. OSC 11 ; rgb:... for bg color query)
|
||||||
|
if (s.startsWith('\x1b]')) {
|
||||||
|
const m = OSC_RESPONSE_RE.exec(s)
|
||||||
|
|
||||||
|
if (m) {
|
||||||
|
return { type: 'osc', code: parseInt(m[1]!, 10), data: m[2]! }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DCS responses (e.g. XTVERSION: DCS > | name ST)
|
||||||
|
if (s.startsWith('\x1bP')) {
|
||||||
|
const m = XTVERSION_RE.exec(s)
|
||||||
|
|
||||||
|
if (m) {
|
||||||
|
return { type: 'xtversion', name: m[1]! }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitNumericParams(params: string): number[] {
|
||||||
|
if (!params) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return params.split(';').map(p => parseInt(p, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KeyParseState = {
|
||||||
|
mode: 'NORMAL' | 'IN_PASTE'
|
||||||
|
incomplete: string
|
||||||
|
pasteBuffer: string
|
||||||
|
// Internal tokenizer instance
|
||||||
|
_tokenizer?: Tokenizer
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INITIAL_STATE: KeyParseState = {
|
||||||
|
mode: 'NORMAL',
|
||||||
|
incomplete: '',
|
||||||
|
pasteBuffer: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputToString(input: Buffer | string): string {
|
||||||
|
if (Buffer.isBuffer(input)) {
|
||||||
|
if (input[0]! > 127 && input[1] === undefined) {
|
||||||
|
;(input[0] as unknown as number) -= 128
|
||||||
|
|
||||||
|
return '\x1b' + String(input)
|
||||||
|
} else {
|
||||||
|
return String(input)
|
||||||
|
}
|
||||||
|
} else if (input !== undefined && typeof input !== 'string') {
|
||||||
|
return String(input)
|
||||||
|
} else if (!input) {
|
||||||
|
return ''
|
||||||
|
} else {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMultipleKeypresses(
|
||||||
|
prevState: KeyParseState,
|
||||||
|
input: Buffer | string | null = ''
|
||||||
|
): [ParsedInput[], KeyParseState] {
|
||||||
|
const isFlush = input === null
|
||||||
|
const inputString = isFlush ? '' : inputToString(input)
|
||||||
|
|
||||||
|
// Get or create tokenizer
|
||||||
|
const tokenizer = prevState._tokenizer ?? createTokenizer({ x10Mouse: true })
|
||||||
|
|
||||||
|
// Tokenize the input
|
||||||
|
const tokens = isFlush ? tokenizer.flush() : tokenizer.feed(inputString)
|
||||||
|
|
||||||
|
// Convert tokens to parsed keys, handling paste mode
|
||||||
|
const keys: ParsedInput[] = []
|
||||||
|
let inPaste = prevState.mode === 'IN_PASTE'
|
||||||
|
let pasteBuffer = prevState.pasteBuffer
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token.type === 'sequence') {
|
||||||
|
if (token.value === PASTE_START) {
|
||||||
|
inPaste = true
|
||||||
|
pasteBuffer = ''
|
||||||
|
} else if (token.value === PASTE_END) {
|
||||||
|
// Always emit a paste key, even for empty pastes. This allows
|
||||||
|
// downstream handlers to detect empty pastes (e.g., for clipboard
|
||||||
|
// image handling on macOS). The paste content may be empty string.
|
||||||
|
keys.push(createPasteKey(pasteBuffer))
|
||||||
|
inPaste = false
|
||||||
|
pasteBuffer = ''
|
||||||
|
} else if (inPaste) {
|
||||||
|
// Sequences inside paste are treated as literal text
|
||||||
|
pasteBuffer += token.value
|
||||||
|
} else {
|
||||||
|
const response = parseTerminalResponse(token.value)
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
keys.push({ kind: 'response', sequence: token.value, response })
|
||||||
|
} else {
|
||||||
|
const mouse = parseMouseEvent(token.value)
|
||||||
|
|
||||||
|
if (mouse) {
|
||||||
|
keys.push(mouse)
|
||||||
|
} else {
|
||||||
|
keys.push(parseKeypress(token.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (token.type === 'text') {
|
||||||
|
if (inPaste) {
|
||||||
|
pasteBuffer += token.value
|
||||||
|
} else if (/^\[<\d+;\d+;\d+[Mm]$/.test(token.value) || /^\[M[\x60-\x7f][\x20-\uffff]{2}$/.test(token.value)) {
|
||||||
|
// Orphaned SGR/X10 mouse tail (fullscreen only — mouse tracking is off
|
||||||
|
// otherwise). A heavy render blocked the event loop past App's 50ms
|
||||||
|
// flush timer, so the buffered ESC was flushed as a lone Escape and
|
||||||
|
// the continuation `[<btn;col;rowM` arrived as text. Re-synthesize
|
||||||
|
// with the ESC prefix so the scroll event still fires instead of
|
||||||
|
// leaking into the prompt. The spurious Escape is gone; App.tsx's
|
||||||
|
// readableLength check prevents it. The X10 Cb slot is narrowed to
|
||||||
|
// the wheel range [\x60-\x7f] (0x40|modifiers + 32) — a full [\x20-]
|
||||||
|
// range would match typed input like `[MAX]` batched into one read
|
||||||
|
// and silently drop it as a phantom click. Click/drag orphans leak
|
||||||
|
// as visible garbage instead; deletable garbage beats silent loss.
|
||||||
|
const resynthesized = '\x1b' + token.value
|
||||||
|
const mouse = parseMouseEvent(resynthesized)
|
||||||
|
keys.push(mouse ?? parseKeypress(resynthesized))
|
||||||
|
} else {
|
||||||
|
keys.push(parseKeypress(token.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If flushing and still in paste mode, emit what we have
|
||||||
|
if (isFlush && inPaste && pasteBuffer) {
|
||||||
|
keys.push(createPasteKey(pasteBuffer))
|
||||||
|
inPaste = false
|
||||||
|
pasteBuffer = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new state
|
||||||
|
const newState: KeyParseState = {
|
||||||
|
mode: inPaste ? 'IN_PASTE' : 'NORMAL',
|
||||||
|
incomplete: tokenizer.buffer(),
|
||||||
|
pasteBuffer,
|
||||||
|
_tokenizer: tokenizer
|
||||||
|
}
|
||||||
|
|
||||||
|
return [keys, newState]
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyName: Record<string, string> = {
|
||||||
|
/* xterm/gnome ESC O letter */
|
||||||
|
OP: 'f1',
|
||||||
|
OQ: 'f2',
|
||||||
|
OR: 'f3',
|
||||||
|
OS: 'f4',
|
||||||
|
/* Application keypad mode (numpad digits 0-9) */
|
||||||
|
Op: '0',
|
||||||
|
Oq: '1',
|
||||||
|
Or: '2',
|
||||||
|
Os: '3',
|
||||||
|
Ot: '4',
|
||||||
|
Ou: '5',
|
||||||
|
Ov: '6',
|
||||||
|
Ow: '7',
|
||||||
|
Ox: '8',
|
||||||
|
Oy: '9',
|
||||||
|
/* Application keypad mode (numpad operators) */
|
||||||
|
Oj: '*',
|
||||||
|
Ok: '+',
|
||||||
|
Ol: ',',
|
||||||
|
Om: '-',
|
||||||
|
On: '.',
|
||||||
|
Oo: '/',
|
||||||
|
OM: 'return',
|
||||||
|
/* xterm/rxvt ESC [ number ~ */
|
||||||
|
'[11~': 'f1',
|
||||||
|
'[12~': 'f2',
|
||||||
|
'[13~': 'f3',
|
||||||
|
'[14~': 'f4',
|
||||||
|
/* from Cygwin and used in libuv */
|
||||||
|
'[[A': 'f1',
|
||||||
|
'[[B': 'f2',
|
||||||
|
'[[C': 'f3',
|
||||||
|
'[[D': 'f4',
|
||||||
|
'[[E': 'f5',
|
||||||
|
/* common */
|
||||||
|
'[15~': 'f5',
|
||||||
|
'[17~': 'f6',
|
||||||
|
'[18~': 'f7',
|
||||||
|
'[19~': 'f8',
|
||||||
|
'[20~': 'f9',
|
||||||
|
'[21~': 'f10',
|
||||||
|
'[23~': 'f11',
|
||||||
|
'[24~': 'f12',
|
||||||
|
/* xterm ESC [ letter */
|
||||||
|
'[A': 'up',
|
||||||
|
'[B': 'down',
|
||||||
|
'[C': 'right',
|
||||||
|
'[D': 'left',
|
||||||
|
'[E': 'clear',
|
||||||
|
'[F': 'end',
|
||||||
|
'[H': 'home',
|
||||||
|
/* xterm/gnome ESC O letter */
|
||||||
|
OA: 'up',
|
||||||
|
OB: 'down',
|
||||||
|
OC: 'right',
|
||||||
|
OD: 'left',
|
||||||
|
OE: 'clear',
|
||||||
|
OF: 'end',
|
||||||
|
OH: 'home',
|
||||||
|
/* xterm/rxvt ESC [ number ~ */
|
||||||
|
'[1~': 'home',
|
||||||
|
'[2~': 'insert',
|
||||||
|
'[3~': 'delete',
|
||||||
|
'[4~': 'end',
|
||||||
|
'[5~': 'pageup',
|
||||||
|
'[6~': 'pagedown',
|
||||||
|
/* putty */
|
||||||
|
'[[5~': 'pageup',
|
||||||
|
'[[6~': 'pagedown',
|
||||||
|
/* rxvt */
|
||||||
|
'[7~': 'home',
|
||||||
|
'[8~': 'end',
|
||||||
|
/* rxvt keys with modifiers */
|
||||||
|
'[a': 'up',
|
||||||
|
'[b': 'down',
|
||||||
|
'[c': 'right',
|
||||||
|
'[d': 'left',
|
||||||
|
'[e': 'clear',
|
||||||
|
|
||||||
|
'[2$': 'insert',
|
||||||
|
'[3$': 'delete',
|
||||||
|
'[5$': 'pageup',
|
||||||
|
'[6$': 'pagedown',
|
||||||
|
'[7$': 'home',
|
||||||
|
'[8$': 'end',
|
||||||
|
|
||||||
|
Oa: 'up',
|
||||||
|
Ob: 'down',
|
||||||
|
Oc: 'right',
|
||||||
|
Od: 'left',
|
||||||
|
Oe: 'clear',
|
||||||
|
|
||||||
|
'[2^': 'insert',
|
||||||
|
'[3^': 'delete',
|
||||||
|
'[5^': 'pageup',
|
||||||
|
'[6^': 'pagedown',
|
||||||
|
'[7^': 'home',
|
||||||
|
'[8^': 'end',
|
||||||
|
/* misc. */
|
||||||
|
'[Z': 'tab'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nonAlphanumericKeys = [
|
||||||
|
// Filter out single-character values (digits, operators from numpad) since
|
||||||
|
// those are printable characters that should produce input
|
||||||
|
...Object.values(keyName).filter(v => v.length > 1),
|
||||||
|
// escape and backspace are assigned directly in parseKeypress (not via the
|
||||||
|
// keyName map), so the spread above misses them. Without these, ctrl+escape
|
||||||
|
// via Kitty/modifyOtherKeys leaks the literal word "escape" as input text
|
||||||
|
// (input-event.ts:58 assigns keypress.name when ctrl is set).
|
||||||
|
'escape',
|
||||||
|
'backspace',
|
||||||
|
'wheelup',
|
||||||
|
'wheeldown',
|
||||||
|
'mouse'
|
||||||
|
]
|
||||||
|
|
||||||
|
const isShiftKey = (code: string): boolean => {
|
||||||
|
return ['[a', '[b', '[c', '[d', '[e', '[2$', '[3$', '[5$', '[6$', '[7$', '[8$', '[Z'].includes(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCtrlKey = (code: string): boolean => {
|
||||||
|
return ['Oa', 'Ob', 'Oc', 'Od', 'Oe', '[2^', '[3^', '[5^', '[6^', '[7^', '[8^'].includes(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode XTerm-style modifier value to individual flags.
|
||||||
|
* Modifier encoding: 1 + (shift ? 1 : 0) + (alt ? 2 : 0) + (ctrl ? 4 : 0) + (super ? 8 : 0)
|
||||||
|
*
|
||||||
|
* Note: `meta` here means Alt/Option (bit 2). `super` is a distinct
|
||||||
|
* modifier (bit 8, i.e. Cmd on macOS / Win key). Most legacy terminal
|
||||||
|
* sequences can't express super — it only arrives via kitty keyboard
|
||||||
|
* protocol (CSI u) or xterm modifyOtherKeys.
|
||||||
|
*/
|
||||||
|
function decodeModifier(modifier: number): {
|
||||||
|
shift: boolean
|
||||||
|
meta: boolean
|
||||||
|
ctrl: boolean
|
||||||
|
super: boolean
|
||||||
|
} {
|
||||||
|
const m = modifier - 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
shift: !!(m & 1),
|
||||||
|
meta: !!(m & 2),
|
||||||
|
ctrl: !!(m & 4),
|
||||||
|
super: !!(m & 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map keycode to key name for modifyOtherKeys/CSI u sequences.
|
||||||
|
* Handles both ASCII keycodes and Kitty keyboard protocol functional keys.
|
||||||
|
*
|
||||||
|
* Numpad codepoints are from Unicode Private Use Area, defined at:
|
||||||
|
* https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions
|
||||||
|
*/
|
||||||
|
function keycodeToName(keycode: number): string | undefined {
|
||||||
|
switch (keycode) {
|
||||||
|
case 9:
|
||||||
|
return 'tab'
|
||||||
|
|
||||||
|
case 13:
|
||||||
|
return 'return'
|
||||||
|
|
||||||
|
case 27:
|
||||||
|
return 'escape'
|
||||||
|
|
||||||
|
case 32:
|
||||||
|
return 'space'
|
||||||
|
|
||||||
|
case 127:
|
||||||
|
return 'backspace'
|
||||||
|
|
||||||
|
// Kitty keyboard protocol numpad keys (KP_0 through KP_9)
|
||||||
|
case 57399:
|
||||||
|
return '0'
|
||||||
|
|
||||||
|
case 57400:
|
||||||
|
return '1'
|
||||||
|
|
||||||
|
case 57401:
|
||||||
|
return '2'
|
||||||
|
|
||||||
|
case 57402:
|
||||||
|
return '3'
|
||||||
|
|
||||||
|
case 57403:
|
||||||
|
return '4'
|
||||||
|
|
||||||
|
case 57404:
|
||||||
|
return '5'
|
||||||
|
|
||||||
|
case 57405:
|
||||||
|
return '6'
|
||||||
|
|
||||||
|
case 57406:
|
||||||
|
return '7'
|
||||||
|
|
||||||
|
case 57407:
|
||||||
|
return '8'
|
||||||
|
|
||||||
|
case 57408:
|
||||||
|
return '9'
|
||||||
|
|
||||||
|
case 57409: // KP_DECIMAL
|
||||||
|
return '.'
|
||||||
|
|
||||||
|
case 57410: // KP_DIVIDE
|
||||||
|
return '/'
|
||||||
|
|
||||||
|
case 57411: // KP_MULTIPLY
|
||||||
|
return '*'
|
||||||
|
|
||||||
|
case 57412: // KP_SUBTRACT
|
||||||
|
return '-'
|
||||||
|
|
||||||
|
case 57413: // KP_ADD
|
||||||
|
return '+'
|
||||||
|
|
||||||
|
case 57414: // KP_ENTER
|
||||||
|
return 'return'
|
||||||
|
|
||||||
|
case 57415: // KP_EQUAL
|
||||||
|
return '='
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Printable ASCII characters
|
||||||
|
if (keycode >= 32 && keycode <= 126) {
|
||||||
|
return String.fromCharCode(keycode).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParsedKey = {
|
||||||
|
kind: 'key'
|
||||||
|
fn: boolean
|
||||||
|
name: string | undefined
|
||||||
|
ctrl: boolean
|
||||||
|
meta: boolean
|
||||||
|
shift: boolean
|
||||||
|
option: boolean
|
||||||
|
super: boolean
|
||||||
|
sequence: string | undefined
|
||||||
|
raw: string | undefined
|
||||||
|
code?: string
|
||||||
|
isPasted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A terminal response sequence (DECRPM, DA1, OSC reply, etc.) parsed
|
||||||
|
* out of the input stream. Not user input — consumers should dispatch
|
||||||
|
* to a response handler. */
|
||||||
|
export type ParsedResponse = {
|
||||||
|
kind: 'response'
|
||||||
|
/** Raw escape sequence bytes, for debugging/logging */
|
||||||
|
sequence: string
|
||||||
|
response: TerminalResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SGR mouse event with coordinates. Emitted for clicks, drags, and
|
||||||
|
* releases (wheel events remain ParsedKey). col/row are 1-indexed
|
||||||
|
* from the terminal sequence (CSI < btn;col;row M/m). */
|
||||||
|
export type ParsedMouse = {
|
||||||
|
kind: 'mouse'
|
||||||
|
/** Raw SGR button code. Low 2 bits = button (0=left,1=mid,2=right),
|
||||||
|
* bit 5 (0x20) = drag/motion, bit 6 (0x40) = wheel. */
|
||||||
|
button: number
|
||||||
|
/** 'press' for M terminator, 'release' for m terminator */
|
||||||
|
action: 'press' | 'release'
|
||||||
|
/** 1-indexed column (from terminal) */
|
||||||
|
col: number
|
||||||
|
/** 1-indexed row (from terminal) */
|
||||||
|
row: number
|
||||||
|
sequence: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything that can come out of the input parser: a user keypress/paste,
|
||||||
|
* a mouse click/drag event, or a terminal response to a query we sent. */
|
||||||
|
export type ParsedInput = ParsedKey | ParsedMouse | ParsedResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an SGR mouse event sequence into a ParsedMouse, or null if not a
|
||||||
|
* mouse event or if it's a wheel event (wheel stays as ParsedKey for the
|
||||||
|
* keybinding system). Button bit 0x40 = wheel, bit 0x20 = drag/motion.
|
||||||
|
*/
|
||||||
|
function parseMouseEvent(s: string): ParsedMouse | null {
|
||||||
|
const match = SGR_MOUSE_RE.exec(s)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = parseInt(match[1]!, 10)
|
||||||
|
|
||||||
|
// Wheel events (bit 6 set, low bits 0/1 for up/down) stay as ParsedKey
|
||||||
|
// so the keybinding system can route them to scroll handlers.
|
||||||
|
if ((button & 0x40) !== 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'mouse',
|
||||||
|
button,
|
||||||
|
action: match[4] === 'M' ? 'press' : 'release',
|
||||||
|
col: parseInt(match[2]!, 10),
|
||||||
|
row: parseInt(match[3]!, 10),
|
||||||
|
sequence: s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKeypress(s: string = ''): ParsedKey {
|
||||||
|
let parts
|
||||||
|
|
||||||
|
const key: ParsedKey = {
|
||||||
|
kind: 'key',
|
||||||
|
name: '',
|
||||||
|
fn: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
option: false,
|
||||||
|
super: false,
|
||||||
|
sequence: s,
|
||||||
|
raw: s,
|
||||||
|
isPasted: false
|
||||||
|
}
|
||||||
|
|
||||||
|
key.sequence = key.sequence || s || key.name
|
||||||
|
|
||||||
|
// Handle CSI u (kitty keyboard protocol): ESC [ codepoint [; modifier] u
|
||||||
|
// Example: ESC[13;2u = Shift+Enter, ESC[27u = Escape (no modifiers)
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
|
if ((match = CSI_U_RE.exec(s))) {
|
||||||
|
const codepoint = parseInt(match[1]!, 10)
|
||||||
|
// Modifier defaults to 1 (no modifiers) when not present
|
||||||
|
const modifier = match[2] ? parseInt(match[2], 10) : 1
|
||||||
|
const mods = decodeModifier(modifier)
|
||||||
|
const name = keycodeToName(codepoint)
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'key',
|
||||||
|
name,
|
||||||
|
fn: false,
|
||||||
|
ctrl: mods.ctrl,
|
||||||
|
meta: mods.meta,
|
||||||
|
shift: mods.shift,
|
||||||
|
option: false,
|
||||||
|
super: mods.super,
|
||||||
|
sequence: s,
|
||||||
|
raw: s,
|
||||||
|
isPasted: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle xterm modifyOtherKeys: ESC [ 27 ; modifier ; keycode ~
|
||||||
|
// Must run before FN_KEY_RE — FN_KEY_RE only allows 2 params before ~ and
|
||||||
|
// would leave the tail as garbage if it partially matched.
|
||||||
|
if ((match = MODIFY_OTHER_KEYS_RE.exec(s))) {
|
||||||
|
const mods = decodeModifier(parseInt(match[1]!, 10))
|
||||||
|
const name = keycodeToName(parseInt(match[2]!, 10))
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'key',
|
||||||
|
name,
|
||||||
|
fn: false,
|
||||||
|
ctrl: mods.ctrl,
|
||||||
|
meta: mods.meta,
|
||||||
|
shift: mods.shift,
|
||||||
|
option: false,
|
||||||
|
super: mods.super,
|
||||||
|
sequence: s,
|
||||||
|
raw: s,
|
||||||
|
isPasted: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SGR mouse wheel events. Click/drag/release events are handled
|
||||||
|
// earlier by parseMouseEvent and emitted as ParsedMouse, so they
|
||||||
|
// never reach here. Mask with 0x43 (bits 6+1+0) to check wheel-flag
|
||||||
|
// + direction while ignoring modifier bits (Shift=0x04, Meta=0x08,
|
||||||
|
// Ctrl=0x10) — modified wheel events (e.g. Ctrl+scroll, button=80)
|
||||||
|
// should still be recognized as wheelup/wheeldown.
|
||||||
|
if ((match = SGR_MOUSE_RE.exec(s))) {
|
||||||
|
const button = parseInt(match[1]!, 10)
|
||||||
|
|
||||||
|
if ((button & 0x43) === 0x40) {
|
||||||
|
return createNavKey(s, 'wheelup', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((button & 0x43) === 0x41) {
|
||||||
|
return createNavKey(s, 'wheeldown', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shouldn't reach here (parseMouseEvent catches non-wheel) but be safe
|
||||||
|
return createNavKey(s, 'mouse', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// X10 mouse: CSI M + 3 raw bytes (Cb+32, Cx+32, Cy+32). Terminals that
|
||||||
|
// ignore DECSET 1006 (SGR) but honor 1000/1002 emit this legacy encoding.
|
||||||
|
// Button bits match SGR: 0x40 = wheel, low bit = direction. Non-wheel
|
||||||
|
// X10 events (clicks/drags) are swallowed here — we only enable mouse
|
||||||
|
// tracking in alt-screen and only need wheel for ScrollBox.
|
||||||
|
if (s.length === 6 && s.startsWith('\x1b[M')) {
|
||||||
|
const button = s.charCodeAt(3) - 32
|
||||||
|
|
||||||
|
if ((button & 0x43) === 0x40) {
|
||||||
|
return createNavKey(s, 'wheelup', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((button & 0x43) === 0x41) {
|
||||||
|
return createNavKey(s, 'wheeldown', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createNavKey(s, 'mouse', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s === '\r') {
|
||||||
|
key.raw = undefined
|
||||||
|
key.name = 'return'
|
||||||
|
} else if (s === '\n') {
|
||||||
|
key.name = 'enter'
|
||||||
|
} else if (s === '\t') {
|
||||||
|
key.name = 'tab'
|
||||||
|
} else if (s === '\b' || s === '\x1b\b') {
|
||||||
|
key.name = 'backspace'
|
||||||
|
key.meta = s.charAt(0) === '\x1b'
|
||||||
|
} else if (s === '\x7f' || s === '\x1b\x7f') {
|
||||||
|
key.name = 'backspace'
|
||||||
|
key.meta = s.charAt(0) === '\x1b'
|
||||||
|
} else if (s === '\x1b' || s === '\x1b\x1b') {
|
||||||
|
key.name = 'escape'
|
||||||
|
key.meta = s.length === 2
|
||||||
|
} else if (s === ' ' || s === '\x1b ') {
|
||||||
|
key.name = 'space'
|
||||||
|
key.meta = s.length === 2
|
||||||
|
} else if (s === '\x1f') {
|
||||||
|
key.name = '_'
|
||||||
|
key.ctrl = true
|
||||||
|
} else if (s <= '\x1a' && s.length === 1) {
|
||||||
|
key.name = String.fromCharCode(s.charCodeAt(0) + 'a'.charCodeAt(0) - 1)
|
||||||
|
key.ctrl = true
|
||||||
|
} else if (s.length === 1 && s >= '0' && s <= '9') {
|
||||||
|
key.name = 'number'
|
||||||
|
} else if (s.length === 1 && s >= 'a' && s <= 'z') {
|
||||||
|
key.name = s
|
||||||
|
} else if (s.length === 1 && s >= 'A' && s <= 'Z') {
|
||||||
|
key.name = s.toLowerCase()
|
||||||
|
key.shift = true
|
||||||
|
} else if ((parts = META_KEY_CODE_RE.exec(s))) {
|
||||||
|
key.meta = true
|
||||||
|
key.shift = /^[A-Z]$/.test(parts[1]!)
|
||||||
|
} else if ((parts = FN_KEY_RE.exec(s))) {
|
||||||
|
const segs = [...s]
|
||||||
|
|
||||||
|
if (segs[0] === '\u001b' && segs[1] === '\u001b') {
|
||||||
|
key.option = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = [parts[1], parts[2], parts[4], parts[6]].filter(Boolean).join('')
|
||||||
|
|
||||||
|
const modifier = ((parts[3] || parts[5] || 1) as number) - 1
|
||||||
|
|
||||||
|
key.ctrl = !!(modifier & 4)
|
||||||
|
key.meta = !!(modifier & 2)
|
||||||
|
key.super = !!(modifier & 8)
|
||||||
|
key.shift = !!(modifier & 1)
|
||||||
|
key.code = code
|
||||||
|
|
||||||
|
key.name = keyName[code]
|
||||||
|
key.shift = isShiftKey(code) || key.shift
|
||||||
|
key.ctrl = isCtrlKey(code) || key.ctrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// iTerm in natural text editing mode
|
||||||
|
if (key.raw === '\x1Bb') {
|
||||||
|
key.meta = true
|
||||||
|
key.name = 'left'
|
||||||
|
} else if (key.raw === '\x1Bf') {
|
||||||
|
key.meta = true
|
||||||
|
key.name = 'right'
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (s) {
|
||||||
|
case '\u001b[1~':
|
||||||
|
return createNavKey(s, 'home', false)
|
||||||
|
|
||||||
|
case '\u001b[4~':
|
||||||
|
return createNavKey(s, 'end', false)
|
||||||
|
|
||||||
|
case '\u001b[5~':
|
||||||
|
return createNavKey(s, 'pageup', false)
|
||||||
|
|
||||||
|
case '\u001b[6~':
|
||||||
|
return createNavKey(s, 'pagedown', false)
|
||||||
|
|
||||||
|
case '\u001b[1;5D':
|
||||||
|
return createNavKey(s, 'left', true)
|
||||||
|
|
||||||
|
case '\u001b[1;5C':
|
||||||
|
return createNavKey(s, 'right', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNavKey(s: string, name: string, ctrl: boolean): ParsedKey {
|
||||||
|
return {
|
||||||
|
kind: 'key',
|
||||||
|
name,
|
||||||
|
ctrl,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
option: false,
|
||||||
|
super: false,
|
||||||
|
fn: false,
|
||||||
|
sequence: s,
|
||||||
|
raw: s,
|
||||||
|
isPasted: false
|
||||||
|
}
|
||||||
|
}
|
||||||
532
ui-tui/packages/hermes-ink/src/ink/reconciler.ts
Normal file
532
ui-tui/packages/hermes-ink/src/ink/reconciler.ts
Normal file
|
|
@ -0,0 +1,532 @@
|
||||||
|
import { appendFileSync } from 'fs'
|
||||||
|
|
||||||
|
import createReconciler from 'react-reconciler'
|
||||||
|
|
||||||
|
import { getYogaCounters } from '../native-ts/yoga-layout/index.js'
|
||||||
|
import { isEnvTruthy } from '../utils/envUtils.js'
|
||||||
|
|
||||||
|
import {
|
||||||
|
appendChildNode,
|
||||||
|
clearYogaNodeReferences,
|
||||||
|
createNode,
|
||||||
|
createTextNode,
|
||||||
|
type DOMElement,
|
||||||
|
type DOMNodeAttribute,
|
||||||
|
type ElementNames,
|
||||||
|
insertBeforeNode,
|
||||||
|
markDirty,
|
||||||
|
removeChildNode,
|
||||||
|
setAttribute,
|
||||||
|
setStyle,
|
||||||
|
setTextNodeValue,
|
||||||
|
setTextStyles,
|
||||||
|
type TextNode
|
||||||
|
} from './dom.js'
|
||||||
|
import { Dispatcher } from './events/dispatcher.js'
|
||||||
|
import { EVENT_HANDLER_PROPS } from './events/event-handlers.js'
|
||||||
|
import { getFocusManager, getRootNode } from './focus.js'
|
||||||
|
import { LayoutDisplay } from './layout/node.js'
|
||||||
|
import applyStyles, { type Styles, type TextStyles } from './styles.js'
|
||||||
|
|
||||||
|
// We need to conditionally perform devtools connection to avoid
|
||||||
|
// accidentally breaking other third-party code.
|
||||||
|
// See https://github.com/vadimdemedes/ink/issues/384
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
try {
|
||||||
|
void import('./devtools.js')
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ERR_MODULE_NOT_FOUND') {
|
||||||
|
// biome-ignore lint/suspicious/noConsole: intentional warning
|
||||||
|
console.warn(
|
||||||
|
`
|
||||||
|
The environment variable DEV is set to true, so Ink tried to import \`react-devtools-core\`,
|
||||||
|
but this failed as it was not installed. Debugging with React Devtools requires it.
|
||||||
|
|
||||||
|
To install use this command:
|
||||||
|
|
||||||
|
$ npm install --save-dev react-devtools-core
|
||||||
|
`.trim() + '\n'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --
|
||||||
|
|
||||||
|
type AnyObject = Record<string, unknown>
|
||||||
|
|
||||||
|
const diff = (before: AnyObject, after: AnyObject): AnyObject | undefined => {
|
||||||
|
if (before === after) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!before) {
|
||||||
|
return after
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed: AnyObject = {}
|
||||||
|
let isChanged = false
|
||||||
|
|
||||||
|
for (const key of Object.keys(before)) {
|
||||||
|
const isDeleted = after ? !Object.hasOwn(after, key) : true
|
||||||
|
|
||||||
|
if (isDeleted) {
|
||||||
|
changed[key] = undefined
|
||||||
|
isChanged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (after) {
|
||||||
|
for (const key of Object.keys(after)) {
|
||||||
|
if (after[key] !== before[key]) {
|
||||||
|
changed[key] = after[key]
|
||||||
|
isChanged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isChanged ? changed : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupYogaNode = (node: DOMElement | TextNode): void => {
|
||||||
|
const yogaNode = node.yogaNode
|
||||||
|
|
||||||
|
if (yogaNode) {
|
||||||
|
yogaNode.unsetMeasureFunc()
|
||||||
|
// Clear all references BEFORE freeing to prevent other code from
|
||||||
|
// accessing freed WASM memory during concurrent operations
|
||||||
|
clearYogaNodeReferences(node)
|
||||||
|
yogaNode.freeRecursive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --
|
||||||
|
|
||||||
|
type Props = Record<string, unknown>
|
||||||
|
|
||||||
|
type HostContext = {
|
||||||
|
isInsideText: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEventHandler(node: DOMElement, key: string, value: unknown): void {
|
||||||
|
if (!node._eventHandlers) {
|
||||||
|
node._eventHandlers = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
node._eventHandlers[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyProp(node: DOMElement, key: string, value: unknown): void {
|
||||||
|
if (key === 'children') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'style') {
|
||||||
|
setStyle(node, value as Styles)
|
||||||
|
|
||||||
|
if (node.yogaNode) {
|
||||||
|
applyStyles(node.yogaNode, value as Styles)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'textStyles') {
|
||||||
|
node.textStyles = value as TextStyles
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EVENT_HANDLER_PROPS.has(key)) {
|
||||||
|
setEventHandler(node, key, value)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttribute(node, key, value as DOMNodeAttribute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --
|
||||||
|
|
||||||
|
// react-reconciler's Fiber shape — only the fields we walk. The 5th arg to
|
||||||
|
// createInstance is the Fiber (`workInProgress` in react-reconciler.dev.js).
|
||||||
|
// _debugOwner is the component that rendered this element (dev builds only);
|
||||||
|
// return is the parent fiber (always present). We prefer _debugOwner since it
|
||||||
|
// skips past Box/Text wrappers to the actual named component.
|
||||||
|
type FiberLike = {
|
||||||
|
elementType?: { displayName?: string; name?: string } | string | null
|
||||||
|
_debugOwner?: FiberLike | null
|
||||||
|
return?: FiberLike | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOwnerChain(fiber: unknown): string[] {
|
||||||
|
const chain: string[] = []
|
||||||
|
const seen = new Set<unknown>()
|
||||||
|
let cur = fiber as FiberLike | null | undefined
|
||||||
|
|
||||||
|
for (let i = 0; cur && i < 50; i++) {
|
||||||
|
if (seen.has(cur)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(cur)
|
||||||
|
const t = cur.elementType
|
||||||
|
|
||||||
|
const name =
|
||||||
|
typeof t === 'function'
|
||||||
|
? (t as { displayName?: string; name?: string }).displayName ||
|
||||||
|
(t as { displayName?: string; name?: string }).name
|
||||||
|
: typeof t === 'string'
|
||||||
|
? undefined // host element (ink-box etc) — skip
|
||||||
|
: t?.displayName || t?.name
|
||||||
|
|
||||||
|
if (name && name !== chain[chain.length - 1]) {
|
||||||
|
chain.push(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
cur = cur._debugOwner ?? cur.return
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
let debugRepaints: boolean | undefined
|
||||||
|
|
||||||
|
export function isDebugRepaintsEnabled(): boolean {
|
||||||
|
if (debugRepaints === undefined) {
|
||||||
|
debugRepaints = isEnvTruthy(process.env.CLAUDE_CODE_DEBUG_REPAINTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
return debugRepaints
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dispatcher = new Dispatcher()
|
||||||
|
|
||||||
|
// --- COMMIT INSTRUMENTATION (temp debugging) ---
|
||||||
|
|
||||||
|
const COMMIT_LOG = process.env.CLAUDE_CODE_COMMIT_LOG
|
||||||
|
let _commits = 0
|
||||||
|
let _lastLog = 0
|
||||||
|
let _lastCommitAt = 0
|
||||||
|
let _maxGapMs = 0
|
||||||
|
let _createCount = 0
|
||||||
|
let _prepareAt = 0
|
||||||
|
// --- END ---
|
||||||
|
|
||||||
|
// --- SCROLL PROFILING (bench/scroll-e2e.sh reads via getLastYogaMs) ---
|
||||||
|
// Set by onComputeLayout wrapper in ink.tsx; read by onRender for phases.
|
||||||
|
let _lastYogaMs = 0
|
||||||
|
let _lastCommitMs = 0
|
||||||
|
let _commitStart = 0
|
||||||
|
|
||||||
|
export function recordYogaMs(ms: number): void {
|
||||||
|
_lastYogaMs = ms
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLastYogaMs(): number {
|
||||||
|
return _lastYogaMs
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markCommitStart(): void {
|
||||||
|
_commitStart = performance.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLastCommitMs(): number {
|
||||||
|
return _lastCommitMs
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetProfileCounters(): void {
|
||||||
|
_lastYogaMs = 0
|
||||||
|
_lastCommitMs = 0
|
||||||
|
_commitStart = 0
|
||||||
|
}
|
||||||
|
// --- END ---
|
||||||
|
|
||||||
|
const reconciler = createReconciler<
|
||||||
|
ElementNames,
|
||||||
|
Props,
|
||||||
|
DOMElement,
|
||||||
|
DOMElement,
|
||||||
|
TextNode,
|
||||||
|
DOMElement,
|
||||||
|
unknown,
|
||||||
|
unknown,
|
||||||
|
DOMElement,
|
||||||
|
HostContext,
|
||||||
|
null, // UpdatePayload - not used in React 19
|
||||||
|
NodeJS.Timeout,
|
||||||
|
-1,
|
||||||
|
null
|
||||||
|
>({
|
||||||
|
getRootHostContext: () => ({ isInsideText: false }),
|
||||||
|
prepareForCommit: () => {
|
||||||
|
if (COMMIT_LOG) {
|
||||||
|
_prepareAt = performance.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
preparePortalMount: () => null,
|
||||||
|
clearContainer: () => false,
|
||||||
|
resetAfterCommit(rootNode) {
|
||||||
|
_lastCommitMs = _commitStart > 0 ? performance.now() - _commitStart : 0
|
||||||
|
_commitStart = 0
|
||||||
|
|
||||||
|
if (COMMIT_LOG) {
|
||||||
|
const now = performance.now()
|
||||||
|
_commits++
|
||||||
|
const gap = _lastCommitAt > 0 ? now - _lastCommitAt : 0
|
||||||
|
|
||||||
|
if (gap > _maxGapMs) {
|
||||||
|
_maxGapMs = gap
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastCommitAt = now
|
||||||
|
const reconcileMs = _prepareAt > 0 ? now - _prepareAt : 0
|
||||||
|
|
||||||
|
if (gap > 30 || reconcileMs > 20 || _createCount > 50) {
|
||||||
|
appendFileSync(
|
||||||
|
COMMIT_LOG,
|
||||||
|
`${now.toFixed(1)} gap=${gap.toFixed(1)}ms reconcile=${reconcileMs.toFixed(1)}ms creates=${_createCount}\n`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_createCount = 0
|
||||||
|
|
||||||
|
if (now - _lastLog > 1000) {
|
||||||
|
appendFileSync(COMMIT_LOG, `${now.toFixed(1)} commits=${_commits}/s maxGap=${_maxGapMs.toFixed(1)}ms\n`)
|
||||||
|
_commits = 0
|
||||||
|
_maxGapMs = 0
|
||||||
|
_lastLog = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _t0 = COMMIT_LOG ? performance.now() : 0
|
||||||
|
|
||||||
|
if (typeof rootNode.onComputeLayout === 'function') {
|
||||||
|
rootNode.onComputeLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (COMMIT_LOG) {
|
||||||
|
const layoutMs = performance.now() - _t0
|
||||||
|
|
||||||
|
if (layoutMs > 20) {
|
||||||
|
const c = getYogaCounters()
|
||||||
|
|
||||||
|
appendFileSync(
|
||||||
|
COMMIT_LOG,
|
||||||
|
`${_t0.toFixed(1)} SLOW_YOGA ${layoutMs.toFixed(1)}ms visited=${c.visited} measured=${c.measured} hits=${c.cacheHits} live=${c.live}\n`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'test') {
|
||||||
|
if (rootNode.childNodes.length === 0 && rootNode.hasRenderedContent) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rootNode.childNodes.length > 0) {
|
||||||
|
rootNode.hasRenderedContent = true
|
||||||
|
}
|
||||||
|
|
||||||
|
rootNode.onImmediateRender?.()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const _tr = COMMIT_LOG ? performance.now() : 0
|
||||||
|
rootNode.onRender?.()
|
||||||
|
|
||||||
|
if (COMMIT_LOG) {
|
||||||
|
const renderMs = performance.now() - _tr
|
||||||
|
|
||||||
|
if (renderMs > 10) {
|
||||||
|
appendFileSync(COMMIT_LOG, `${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms\n`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getChildHostContext(parentHostContext: HostContext, type: ElementNames): HostContext {
|
||||||
|
const previousIsInsideText = parentHostContext.isInsideText
|
||||||
|
|
||||||
|
const isInsideText = type === 'ink-text' || type === 'ink-virtual-text' || type === 'ink-link'
|
||||||
|
|
||||||
|
if (previousIsInsideText === isInsideText) {
|
||||||
|
return parentHostContext
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isInsideText }
|
||||||
|
},
|
||||||
|
shouldSetTextContent: () => false,
|
||||||
|
createInstance(
|
||||||
|
originalType: ElementNames,
|
||||||
|
newProps: Props,
|
||||||
|
_root: DOMElement,
|
||||||
|
hostContext: HostContext,
|
||||||
|
internalHandle?: unknown
|
||||||
|
): DOMElement {
|
||||||
|
if (hostContext.isInsideText && originalType === 'ink-box') {
|
||||||
|
throw new Error(`<Box> can't be nested inside <Text> component`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = originalType === 'ink-text' && hostContext.isInsideText ? 'ink-virtual-text' : originalType
|
||||||
|
|
||||||
|
const node = createNode(type)
|
||||||
|
|
||||||
|
if (COMMIT_LOG) {
|
||||||
|
_createCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(newProps)) {
|
||||||
|
applyProp(node, key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDebugRepaintsEnabled()) {
|
||||||
|
node.debugOwnerChain = getOwnerChain(internalHandle)
|
||||||
|
}
|
||||||
|
|
||||||
|
return node
|
||||||
|
},
|
||||||
|
createTextInstance(text: string, _root: DOMElement, hostContext: HostContext): TextNode {
|
||||||
|
if (!hostContext.isInsideText) {
|
||||||
|
throw new Error(`Text string "${text}" must be rendered inside <Text> component`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createTextNode(text)
|
||||||
|
},
|
||||||
|
resetTextContent() {},
|
||||||
|
hideTextInstance(node) {
|
||||||
|
setTextNodeValue(node, '')
|
||||||
|
},
|
||||||
|
unhideTextInstance(node, text) {
|
||||||
|
setTextNodeValue(node, text)
|
||||||
|
},
|
||||||
|
getPublicInstance: (instance): DOMElement => instance as DOMElement,
|
||||||
|
hideInstance(node) {
|
||||||
|
node.isHidden = true
|
||||||
|
node.yogaNode?.setDisplay(LayoutDisplay.None)
|
||||||
|
markDirty(node)
|
||||||
|
},
|
||||||
|
unhideInstance(node) {
|
||||||
|
node.isHidden = false
|
||||||
|
node.yogaNode?.setDisplay(LayoutDisplay.Flex)
|
||||||
|
markDirty(node)
|
||||||
|
},
|
||||||
|
appendInitialChild: appendChildNode,
|
||||||
|
appendChild: appendChildNode,
|
||||||
|
insertBefore: insertBeforeNode,
|
||||||
|
finalizeInitialChildren(_node: DOMElement, _type: ElementNames, props: Props): boolean {
|
||||||
|
return props['autoFocus'] === true
|
||||||
|
},
|
||||||
|
commitMount(node: DOMElement): void {
|
||||||
|
getFocusManager(node).handleAutoFocus(node)
|
||||||
|
},
|
||||||
|
isPrimaryRenderer: true,
|
||||||
|
supportsMutation: true,
|
||||||
|
supportsPersistence: false,
|
||||||
|
supportsHydration: false,
|
||||||
|
scheduleTimeout: setTimeout,
|
||||||
|
cancelTimeout: clearTimeout,
|
||||||
|
noTimeout: -1,
|
||||||
|
getCurrentUpdatePriority: () => dispatcher.currentUpdatePriority,
|
||||||
|
beforeActiveInstanceBlur() {},
|
||||||
|
afterActiveInstanceBlur() {},
|
||||||
|
detachDeletedInstance() {},
|
||||||
|
getInstanceFromNode: () => null,
|
||||||
|
prepareScopeUpdate() {},
|
||||||
|
getInstanceFromScope: () => null,
|
||||||
|
appendChildToContainer: appendChildNode,
|
||||||
|
insertInContainerBefore: insertBeforeNode,
|
||||||
|
removeChildFromContainer(node: DOMElement, removeNode: DOMElement): void {
|
||||||
|
removeChildNode(node, removeNode)
|
||||||
|
cleanupYogaNode(removeNode)
|
||||||
|
getFocusManager(node).handleNodeRemoved(removeNode, node)
|
||||||
|
},
|
||||||
|
// React 19 commitUpdate receives old and new props directly instead of an updatePayload
|
||||||
|
commitUpdate(node: DOMElement, _type: ElementNames, oldProps: Props, newProps: Props): void {
|
||||||
|
const props = diff(oldProps, newProps)
|
||||||
|
const style = diff(oldProps['style'] as Styles, newProps['style'] as Styles)
|
||||||
|
|
||||||
|
if (props) {
|
||||||
|
for (const [key, value] of Object.entries(props)) {
|
||||||
|
if (key === 'style') {
|
||||||
|
setStyle(node, value as Styles)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'textStyles') {
|
||||||
|
setTextStyles(node, value as TextStyles)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EVENT_HANDLER_PROPS.has(key)) {
|
||||||
|
setEventHandler(node, key, value)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttribute(node, key, value as DOMNodeAttribute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style && node.yogaNode) {
|
||||||
|
applyStyles(node.yogaNode, style, newProps['style'] as Styles)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
commitTextUpdate(node: TextNode, _oldText: string, newText: string): void {
|
||||||
|
setTextNodeValue(node, newText)
|
||||||
|
},
|
||||||
|
removeChild(node, removeNode) {
|
||||||
|
removeChildNode(node, removeNode)
|
||||||
|
cleanupYogaNode(removeNode)
|
||||||
|
|
||||||
|
if (removeNode.nodeName !== '#text') {
|
||||||
|
const root = getRootNode(node)
|
||||||
|
root.focusManager!.handleNodeRemoved(removeNode, root)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// React 19 required methods
|
||||||
|
maySuspendCommit(): boolean {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
preloadInstance(): boolean {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
startSuspendingCommit(): void {},
|
||||||
|
suspendInstance(): void {},
|
||||||
|
waitForCommitToBeReady(): null {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
NotPendingTransition: null,
|
||||||
|
HostTransitionContext: {
|
||||||
|
$$typeof: Symbol.for('react.context'),
|
||||||
|
_currentValue: null
|
||||||
|
} as never,
|
||||||
|
setCurrentUpdatePriority(newPriority: number): void {
|
||||||
|
dispatcher.currentUpdatePriority = newPriority
|
||||||
|
},
|
||||||
|
resolveUpdatePriority(): number {
|
||||||
|
return dispatcher.resolveEventPriority()
|
||||||
|
},
|
||||||
|
resetFormInstance(): void {},
|
||||||
|
requestPostPaintCallback(): void {},
|
||||||
|
shouldAttemptEagerTransition(): boolean {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
trackSchedulerEvent(): void {},
|
||||||
|
resolveEventType(): string | null {
|
||||||
|
return dispatcher.currentEvent?.type ?? null
|
||||||
|
},
|
||||||
|
resolveEventTimeStamp(): number {
|
||||||
|
return dispatcher.currentEvent?.timeStamp ?? -1.1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wire the reconciler's discreteUpdates into the dispatcher.
|
||||||
|
// This breaks the import cycle: dispatcher.ts doesn't import reconciler.ts.
|
||||||
|
dispatcher.discreteUpdates = reconciler.discreteUpdates.bind(reconciler)
|
||||||
|
|
||||||
|
export default reconciler
|
||||||
206
ui-tui/packages/hermes-ink/src/ink/render-border.ts
Normal file
206
ui-tui/packages/hermes-ink/src/ink/render-border.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import chalk from 'chalk'
|
||||||
|
import cliBoxes, { type Boxes, type BoxStyle } from 'cli-boxes'
|
||||||
|
|
||||||
|
import { applyColor } from './colorize.js'
|
||||||
|
import type { DOMNode } from './dom.js'
|
||||||
|
import type Output from './output.js'
|
||||||
|
import { stringWidth } from './stringWidth.js'
|
||||||
|
import type { Color } from './styles.js'
|
||||||
|
|
||||||
|
export type BorderTextOptions = {
|
||||||
|
content: string // Pre-rendered string with ANSI color codes
|
||||||
|
position: 'top' | 'bottom'
|
||||||
|
align: 'start' | 'end' | 'center'
|
||||||
|
offset?: number // Only used with 'start' or 'end' alignment. Number of characters from the edge.
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CUSTOM_BORDER_STYLES = {
|
||||||
|
dashed: {
|
||||||
|
top: '╌',
|
||||||
|
left: '╎',
|
||||||
|
right: '╎',
|
||||||
|
bottom: '╌',
|
||||||
|
// there aren't any line-drawing characters for dashes unfortunately
|
||||||
|
topLeft: ' ',
|
||||||
|
topRight: ' ',
|
||||||
|
bottomLeft: ' ',
|
||||||
|
bottomRight: ' '
|
||||||
|
}
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type BorderStyle = keyof Boxes | keyof typeof CUSTOM_BORDER_STYLES | BoxStyle
|
||||||
|
|
||||||
|
function embedTextInBorder(
|
||||||
|
borderLine: string,
|
||||||
|
text: string,
|
||||||
|
align: 'start' | 'end' | 'center',
|
||||||
|
offset: number = 0,
|
||||||
|
borderChar: string
|
||||||
|
): [before: string, text: string, after: string] {
|
||||||
|
const textLength = stringWidth(text)
|
||||||
|
const borderLength = borderLine.length
|
||||||
|
|
||||||
|
if (textLength >= borderLength - 2) {
|
||||||
|
return ['', text.substring(0, borderLength), '']
|
||||||
|
}
|
||||||
|
|
||||||
|
let position: number
|
||||||
|
|
||||||
|
if (align === 'center') {
|
||||||
|
position = Math.floor((borderLength - textLength) / 2)
|
||||||
|
} else if (align === 'start') {
|
||||||
|
position = offset + 1 // +1 to account for corner character
|
||||||
|
} else {
|
||||||
|
// align === 'end'
|
||||||
|
position = borderLength - textLength - offset - 1 // -1 for corner character
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure position is valid
|
||||||
|
position = Math.max(1, Math.min(position, borderLength - textLength - 1))
|
||||||
|
|
||||||
|
const before = borderLine.substring(0, 1) + borderChar.repeat(position - 1)
|
||||||
|
|
||||||
|
const after = borderChar.repeat(borderLength - position - textLength - 1) + borderLine.substring(borderLength - 1)
|
||||||
|
|
||||||
|
return [before, text, after]
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleBorderLine(line: string, color: Color | undefined, dim: boolean | undefined): string {
|
||||||
|
let styled = applyColor(line, color)
|
||||||
|
|
||||||
|
if (dim) {
|
||||||
|
styled = chalk.dim(styled)
|
||||||
|
}
|
||||||
|
|
||||||
|
return styled
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderBorder = (x: number, y: number, node: DOMNode, output: Output): void => {
|
||||||
|
if (node.style.borderStyle) {
|
||||||
|
const width = Math.floor(node.yogaNode!.getComputedWidth())
|
||||||
|
const height = Math.floor(node.yogaNode!.getComputedHeight())
|
||||||
|
|
||||||
|
const box =
|
||||||
|
typeof node.style.borderStyle === 'string'
|
||||||
|
? (CUSTOM_BORDER_STYLES[node.style.borderStyle as keyof typeof CUSTOM_BORDER_STYLES] ??
|
||||||
|
cliBoxes[node.style.borderStyle as keyof Boxes])
|
||||||
|
: node.style.borderStyle
|
||||||
|
|
||||||
|
const topBorderColor = node.style.borderTopColor ?? node.style.borderColor
|
||||||
|
|
||||||
|
const bottomBorderColor = node.style.borderBottomColor ?? node.style.borderColor
|
||||||
|
|
||||||
|
const leftBorderColor = node.style.borderLeftColor ?? node.style.borderColor
|
||||||
|
|
||||||
|
const rightBorderColor = node.style.borderRightColor ?? node.style.borderColor
|
||||||
|
|
||||||
|
const dimTopBorderColor = node.style.borderTopDimColor ?? node.style.borderDimColor
|
||||||
|
|
||||||
|
const dimBottomBorderColor = node.style.borderBottomDimColor ?? node.style.borderDimColor
|
||||||
|
|
||||||
|
const dimLeftBorderColor = node.style.borderLeftDimColor ?? node.style.borderDimColor
|
||||||
|
|
||||||
|
const dimRightBorderColor = node.style.borderRightDimColor ?? node.style.borderDimColor
|
||||||
|
|
||||||
|
const showTopBorder = node.style.borderTop !== false
|
||||||
|
const showBottomBorder = node.style.borderBottom !== false
|
||||||
|
const showLeftBorder = node.style.borderLeft !== false
|
||||||
|
const showRightBorder = node.style.borderRight !== false
|
||||||
|
|
||||||
|
const contentWidth = Math.max(0, width - (showLeftBorder ? 1 : 0) - (showRightBorder ? 1 : 0))
|
||||||
|
|
||||||
|
const topBorderLine = showTopBorder
|
||||||
|
? (showLeftBorder ? box.topLeft : '') + box.top.repeat(contentWidth) + (showRightBorder ? box.topRight : '')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
// Handle text in top border
|
||||||
|
let topBorder: string | undefined
|
||||||
|
|
||||||
|
if (showTopBorder && node.style.borderText?.position === 'top') {
|
||||||
|
const [before, text, after] = embedTextInBorder(
|
||||||
|
topBorderLine,
|
||||||
|
node.style.borderText.content,
|
||||||
|
node.style.borderText.align,
|
||||||
|
node.style.borderText.offset,
|
||||||
|
box.top
|
||||||
|
)
|
||||||
|
|
||||||
|
topBorder =
|
||||||
|
styleBorderLine(before, topBorderColor, dimTopBorderColor) +
|
||||||
|
text +
|
||||||
|
styleBorderLine(after, topBorderColor, dimTopBorderColor)
|
||||||
|
} else if (showTopBorder) {
|
||||||
|
topBorder = styleBorderLine(topBorderLine, topBorderColor, dimTopBorderColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
let verticalBorderHeight = height
|
||||||
|
|
||||||
|
if (showTopBorder) {
|
||||||
|
verticalBorderHeight -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBottomBorder) {
|
||||||
|
verticalBorderHeight -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
verticalBorderHeight = Math.max(0, verticalBorderHeight)
|
||||||
|
|
||||||
|
let leftBorder = (applyColor(box.left, leftBorderColor) + '\n').repeat(verticalBorderHeight)
|
||||||
|
|
||||||
|
if (dimLeftBorderColor) {
|
||||||
|
leftBorder = chalk.dim(leftBorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
let rightBorder = (applyColor(box.right, rightBorderColor) + '\n').repeat(verticalBorderHeight)
|
||||||
|
|
||||||
|
if (dimRightBorderColor) {
|
||||||
|
rightBorder = chalk.dim(rightBorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bottomBorderLine = showBottomBorder
|
||||||
|
? (showLeftBorder ? box.bottomLeft : '') +
|
||||||
|
box.bottom.repeat(contentWidth) +
|
||||||
|
(showRightBorder ? box.bottomRight : '')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
// Handle text in bottom border
|
||||||
|
let bottomBorder: string | undefined
|
||||||
|
|
||||||
|
if (showBottomBorder && node.style.borderText?.position === 'bottom') {
|
||||||
|
const [before, text, after] = embedTextInBorder(
|
||||||
|
bottomBorderLine,
|
||||||
|
node.style.borderText.content,
|
||||||
|
node.style.borderText.align,
|
||||||
|
node.style.borderText.offset,
|
||||||
|
box.bottom
|
||||||
|
)
|
||||||
|
|
||||||
|
bottomBorder =
|
||||||
|
styleBorderLine(before, bottomBorderColor, dimBottomBorderColor) +
|
||||||
|
text +
|
||||||
|
styleBorderLine(after, bottomBorderColor, dimBottomBorderColor)
|
||||||
|
} else if (showBottomBorder) {
|
||||||
|
bottomBorder = styleBorderLine(bottomBorderLine, bottomBorderColor, dimBottomBorderColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
const offsetY = showTopBorder ? 1 : 0
|
||||||
|
|
||||||
|
if (topBorder) {
|
||||||
|
output.write(x, y, topBorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showLeftBorder) {
|
||||||
|
output.write(x, y + offsetY, leftBorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showRightBorder) {
|
||||||
|
output.write(x + width - 1, y + offsetY, rightBorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bottomBorder) {
|
||||||
|
output.write(x, y + height - 1, bottomBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default renderBorder
|
||||||
1529
ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts
Normal file
1529
ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts
Normal file
File diff suppressed because it is too large
Load diff
241
ui-tui/packages/hermes-ink/src/ink/render-to-screen.ts
Normal file
241
ui-tui/packages/hermes-ink/src/ink/render-to-screen.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
import noop from 'lodash-es/noop.js'
|
||||||
|
import type { ReactElement } from 'react'
|
||||||
|
import { LegacyRoot } from 'react-reconciler/constants.js'
|
||||||
|
|
||||||
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
|
|
||||||
|
import { createNode, type DOMElement } from './dom.js'
|
||||||
|
import { FocusManager } from './focus.js'
|
||||||
|
import Output from './output.js'
|
||||||
|
import reconciler from './reconciler.js'
|
||||||
|
import renderNodeToOutput, { resetLayoutShifted } from './render-node-to-output.js'
|
||||||
|
import {
|
||||||
|
cellAtIndex,
|
||||||
|
CellWidth,
|
||||||
|
CharPool,
|
||||||
|
createScreen,
|
||||||
|
HyperlinkPool,
|
||||||
|
type Screen,
|
||||||
|
setCellStyleId,
|
||||||
|
StylePool
|
||||||
|
} from './screen.js'
|
||||||
|
|
||||||
|
/** Position of a match within a rendered message, relative to the message's
|
||||||
|
* own bounding box (row 0 = message top). Stable across scroll — to
|
||||||
|
* highlight on the real screen, add the message's screen-row offset. */
|
||||||
|
export type MatchPosition = {
|
||||||
|
row: number
|
||||||
|
col: number
|
||||||
|
/** Number of CELLS the match spans (= query.length for ASCII, more
|
||||||
|
* for wide chars in the query). */
|
||||||
|
len: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared across calls. Pools accumulate style/char interns — reusing them
|
||||||
|
// means later calls hit cache more. Root/container reuse saves the
|
||||||
|
// createContainer cost (~1ms). LegacyRoot: all work sync, no scheduling —
|
||||||
|
// ConcurrentRoot's scheduler backlog leaks across roots via flushSyncWork.
|
||||||
|
let root: DOMElement | undefined
|
||||||
|
let container: ReturnType<typeof reconciler.createContainer> | undefined
|
||||||
|
let stylePool: StylePool | undefined
|
||||||
|
let charPool: CharPool | undefined
|
||||||
|
let hyperlinkPool: HyperlinkPool | undefined
|
||||||
|
let output: Output | undefined
|
||||||
|
|
||||||
|
const timing = { reconcile: 0, yoga: 0, paint: 0, scan: 0, calls: 0 }
|
||||||
|
const LOG_EVERY = 20
|
||||||
|
|
||||||
|
/** Render a React element (wrapped in all contexts the component needs —
|
||||||
|
* caller's job) to an isolated Screen buffer at the given width. Returns
|
||||||
|
* the Screen + natural height (from yoga). Used for search: render ONE
|
||||||
|
* message, scan its Screen for the query, get exact (row, col) positions.
|
||||||
|
*
|
||||||
|
* ~1-3ms per call (yoga alloc + calculateLayout + paint). The
|
||||||
|
* flushSyncWork cross-root leak measured ~0.0003ms/call growth — fine
|
||||||
|
* for on-demand single-message rendering, pathological for render-all-
|
||||||
|
* 8k-upfront. Cache per (msg, query, width) upstream.
|
||||||
|
*
|
||||||
|
* Unmounts between calls. Root/container/pools persist for reuse. */
|
||||||
|
export function renderToScreen(el: ReactElement, width: number): { screen: Screen; height: number } {
|
||||||
|
if (!root) {
|
||||||
|
root = createNode('ink-root')
|
||||||
|
root.focusManager = new FocusManager(() => false)
|
||||||
|
stylePool = new StylePool()
|
||||||
|
charPool = new CharPool()
|
||||||
|
hyperlinkPool = new HyperlinkPool()
|
||||||
|
// @ts-expect-error react-reconciler 0.33 takes 10 args; @types says 11
|
||||||
|
container = reconciler.createContainer(root, LegacyRoot, null, false, null, 'search-render', noop, noop, noop, noop)
|
||||||
|
}
|
||||||
|
|
||||||
|
const t0 = performance.now()
|
||||||
|
// @ts-expect-error updateContainerSync exists but not in @types
|
||||||
|
reconciler.updateContainerSync(el, container, null, noop)
|
||||||
|
// @ts-expect-error flushSyncWork exists but not in @types
|
||||||
|
reconciler.flushSyncWork()
|
||||||
|
const t1 = performance.now()
|
||||||
|
|
||||||
|
// Yoga layout. Root might not have a yogaNode if the tree is empty.
|
||||||
|
root.yogaNode?.setWidth(width)
|
||||||
|
root.yogaNode?.calculateLayout(width)
|
||||||
|
const height = Math.ceil(root.yogaNode?.getComputedHeight() ?? 0)
|
||||||
|
const t2 = performance.now()
|
||||||
|
|
||||||
|
// Paint to a fresh Screen. Width = given, height = yoga's natural.
|
||||||
|
// No alt-screen, no prevScreen (every call is fresh).
|
||||||
|
const screen = createScreen(
|
||||||
|
width,
|
||||||
|
Math.max(1, height), // avoid 0-height Screen (createScreen may choke)
|
||||||
|
stylePool!,
|
||||||
|
charPool!,
|
||||||
|
hyperlinkPool!
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!output) {
|
||||||
|
output = new Output({ width, height, stylePool: stylePool!, screen })
|
||||||
|
} else {
|
||||||
|
output.reset(width, height, screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetLayoutShifted()
|
||||||
|
renderNodeToOutput(root, output, { prevScreen: undefined })
|
||||||
|
// renderNodeToOutput queues writes into Output; .get() flushes the
|
||||||
|
// queue into the Screen's cell arrays. Without this the screen is
|
||||||
|
// blank (constructor-zero).
|
||||||
|
const rendered = output.get()
|
||||||
|
const t3 = performance.now()
|
||||||
|
|
||||||
|
// Unmount so next call gets a fresh tree. Leaves root/container/pools.
|
||||||
|
// @ts-expect-error updateContainerSync exists but not in @types
|
||||||
|
reconciler.updateContainerSync(null, container, null, noop)
|
||||||
|
// @ts-expect-error flushSyncWork exists but not in @types
|
||||||
|
reconciler.flushSyncWork()
|
||||||
|
|
||||||
|
timing.reconcile += t1 - t0
|
||||||
|
timing.yoga += t2 - t1
|
||||||
|
timing.paint += t3 - t2
|
||||||
|
|
||||||
|
if (++timing.calls % LOG_EVERY === 0) {
|
||||||
|
const total = timing.reconcile + timing.yoga + timing.paint + timing.scan
|
||||||
|
logForDebugging(
|
||||||
|
`renderToScreen: ${timing.calls} calls · ` +
|
||||||
|
`reconcile=${timing.reconcile.toFixed(1)}ms yoga=${timing.yoga.toFixed(1)}ms ` +
|
||||||
|
`paint=${timing.paint.toFixed(1)}ms scan=${timing.scan.toFixed(1)}ms · ` +
|
||||||
|
`total=${total.toFixed(1)}ms · avg ${(total / timing.calls).toFixed(2)}ms/call`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { screen: rendered, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scan a Screen buffer for all occurrences of query. Returns positions
|
||||||
|
* relative to the buffer (row 0 = buffer top). Same cell-skip logic as
|
||||||
|
* applySearchHighlight (SpacerTail/SpacerHead/noSelect) so positions
|
||||||
|
* match what the overlay highlight would find. Case-insensitive.
|
||||||
|
*
|
||||||
|
* For the side-render use: this Screen is the FULL message (natural
|
||||||
|
* height, not viewport-clipped). Positions are stable — to highlight
|
||||||
|
* on the real screen, add the message's screen offset (lo). */
|
||||||
|
export function scanPositions(screen: Screen, query: string): MatchPosition[] {
|
||||||
|
const lq = query.toLowerCase()
|
||||||
|
|
||||||
|
if (!lq) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const qlen = lq.length
|
||||||
|
const w = screen.width
|
||||||
|
const h = screen.height
|
||||||
|
const noSelect = screen.noSelect
|
||||||
|
const positions: MatchPosition[] = []
|
||||||
|
|
||||||
|
const t0 = performance.now()
|
||||||
|
|
||||||
|
for (let row = 0; row < h; row++) {
|
||||||
|
const rowOff = row * w
|
||||||
|
// Same text-build as applySearchHighlight. Keep in sync — or extract
|
||||||
|
// to a shared helper (TODO once both are stable). codeUnitToCell
|
||||||
|
// maps indexOf positions (code units in the LOWERCASED text) to cell
|
||||||
|
// indices in colOf — surrogate pairs (emoji) and multi-unit lowercase
|
||||||
|
// (Turkish İ → i + U+0307) make text.length > colOf.length.
|
||||||
|
let text = ''
|
||||||
|
const colOf: number[] = []
|
||||||
|
const codeUnitToCell: number[] = []
|
||||||
|
|
||||||
|
for (let col = 0; col < w; col++) {
|
||||||
|
const idx = rowOff + col
|
||||||
|
const cell = cellAtIndex(screen, idx)
|
||||||
|
|
||||||
|
if (cell.width === CellWidth.SpacerTail || cell.width === CellWidth.SpacerHead || noSelect[idx] === 1) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const lc = cell.char.toLowerCase()
|
||||||
|
const cellIdx = colOf.length
|
||||||
|
|
||||||
|
for (let i = 0; i < lc.length; i++) {
|
||||||
|
codeUnitToCell.push(cellIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
text += lc
|
||||||
|
colOf.push(col)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-overlapping — same advance as applySearchHighlight.
|
||||||
|
let pos = text.indexOf(lq)
|
||||||
|
|
||||||
|
while (pos >= 0) {
|
||||||
|
const startCi = codeUnitToCell[pos]!
|
||||||
|
const endCi = codeUnitToCell[pos + qlen - 1]!
|
||||||
|
const col = colOf[startCi]!
|
||||||
|
const endCol = colOf[endCi]! + 1
|
||||||
|
positions.push({ row, col, len: endCol - col })
|
||||||
|
pos = text.indexOf(lq, pos + qlen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timing.scan += performance.now() - t0
|
||||||
|
|
||||||
|
return positions
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write CURRENT (yellow+bold+underline) at positions[currentIdx] +
|
||||||
|
* rowOffset. OTHER positions are NOT styled here — the scan-highlight
|
||||||
|
* (applySearchHighlight with null hint) does inverse for all visible
|
||||||
|
* matches, including these. Two-layer: scan = 'you could go here',
|
||||||
|
* position = 'you ARE here'. Writing inverse again here would be a
|
||||||
|
* no-op (withInverse idempotent) but wasted work.
|
||||||
|
*
|
||||||
|
* Positions are message-relative (row 0 = message top). rowOffset =
|
||||||
|
* message's current screen-top (lo). Clips outside [0, height). */
|
||||||
|
export function applyPositionedHighlight(
|
||||||
|
screen: Screen,
|
||||||
|
stylePool: StylePool,
|
||||||
|
positions: MatchPosition[],
|
||||||
|
rowOffset: number,
|
||||||
|
currentIdx: number
|
||||||
|
): boolean {
|
||||||
|
if (currentIdx < 0 || currentIdx >= positions.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = positions[currentIdx]!
|
||||||
|
const row = p.row + rowOffset
|
||||||
|
|
||||||
|
if (row < 0 || row >= screen.height) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const transform = (id: number) => stylePool.withCurrentMatch(id)
|
||||||
|
const rowOff = row * screen.width
|
||||||
|
|
||||||
|
for (let col = p.col; col < p.col + p.len; col++) {
|
||||||
|
if (col < 0 || col >= screen.width) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const cell = cellAtIndex(screen, rowOff + col)
|
||||||
|
setCellStyleId(screen, col, row, transform(cell.styleId))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
167
ui-tui/packages/hermes-ink/src/ink/renderer.ts
Normal file
167
ui-tui/packages/hermes-ink/src/ink/renderer.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
|
|
||||||
|
import { type DOMElement, markDirty } from './dom.js'
|
||||||
|
import type { Frame } from './frame.js'
|
||||||
|
import { consumeAbsoluteRemovedFlag } from './node-cache.js'
|
||||||
|
import Output from './output.js'
|
||||||
|
import renderNodeToOutput, {
|
||||||
|
getScrollDrainNode,
|
||||||
|
getScrollHint,
|
||||||
|
resetLayoutShifted,
|
||||||
|
resetScrollDrainNode,
|
||||||
|
resetScrollHint
|
||||||
|
} from './render-node-to-output.js'
|
||||||
|
import { createScreen, type StylePool } from './screen.js'
|
||||||
|
|
||||||
|
export type RenderOptions = {
|
||||||
|
frontFrame: Frame
|
||||||
|
backFrame: Frame
|
||||||
|
isTTY: boolean
|
||||||
|
terminalWidth: number
|
||||||
|
terminalRows: number
|
||||||
|
altScreen: boolean
|
||||||
|
// True when the previous frame's screen buffer was mutated post-render
|
||||||
|
// (selection overlay), reset to blank (alt-screen enter/resize/SIGCONT),
|
||||||
|
// or reset to 0×0 (forceRedraw). Blitting from such a prevScreen would
|
||||||
|
// copy stale inverted cells, blanks, or nothing. When false, blit is safe.
|
||||||
|
prevFrameContaminated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Renderer = (options: RenderOptions) => Frame
|
||||||
|
|
||||||
|
export default function createRenderer(node: DOMElement, stylePool: StylePool): Renderer {
|
||||||
|
// Reuse Output across frames so charCache (tokenize + grapheme clustering)
|
||||||
|
// persists — most lines don't change between renders.
|
||||||
|
let output: Output | undefined
|
||||||
|
|
||||||
|
return options => {
|
||||||
|
const { frontFrame, backFrame, isTTY, terminalWidth, terminalRows } = options
|
||||||
|
|
||||||
|
const prevScreen = frontFrame.screen
|
||||||
|
const backScreen = backFrame.screen
|
||||||
|
// Read pools from the back buffer's screen — pools may be replaced
|
||||||
|
// between frames (generational reset), so we can't capture them in the closure
|
||||||
|
const charPool = backScreen.charPool
|
||||||
|
const hyperlinkPool = backScreen.hyperlinkPool
|
||||||
|
|
||||||
|
// Return empty frame if yoga node doesn't exist or layout hasn't been computed yet.
|
||||||
|
// getComputedHeight() returns NaN before calculateLayout() is called.
|
||||||
|
// Also check for invalid dimensions (negative, Infinity) that would cause RangeError
|
||||||
|
// when creating arrays.
|
||||||
|
const computedHeight = node.yogaNode?.getComputedHeight()
|
||||||
|
const computedWidth = node.yogaNode?.getComputedWidth()
|
||||||
|
|
||||||
|
const hasInvalidHeight = computedHeight === undefined || !Number.isFinite(computedHeight) || computedHeight < 0
|
||||||
|
|
||||||
|
const hasInvalidWidth = computedWidth === undefined || !Number.isFinite(computedWidth) || computedWidth < 0
|
||||||
|
|
||||||
|
if (!node.yogaNode || hasInvalidHeight || hasInvalidWidth) {
|
||||||
|
// Log to help diagnose root cause (visible with --debug flag)
|
||||||
|
if (node.yogaNode && (hasInvalidHeight || hasInvalidWidth)) {
|
||||||
|
logForDebugging(
|
||||||
|
`Invalid yoga dimensions: width=${computedWidth}, height=${computedHeight}, ` +
|
||||||
|
`childNodes=${node.childNodes.length}, terminalWidth=${terminalWidth}, terminalRows=${terminalRows}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
screen: createScreen(terminalWidth, 0, stylePool, charPool, hyperlinkPool),
|
||||||
|
viewport: { width: terminalWidth, height: terminalRows },
|
||||||
|
cursor: { x: 0, y: 0, visible: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = Math.floor(node.yogaNode.getComputedWidth())
|
||||||
|
const yogaHeight = Math.floor(node.yogaNode.getComputedHeight())
|
||||||
|
// Alt-screen: the screen buffer IS the alt buffer — always exactly
|
||||||
|
// terminalRows tall. <AlternateScreen> wraps children in <Box
|
||||||
|
// height={rows} flexShrink={0}>, so yogaHeight should equal
|
||||||
|
// terminalRows. But if something renders as a SIBLING of that Box
|
||||||
|
// (bug: MessageSelector was outside <FullscreenLayout>), yogaHeight
|
||||||
|
// exceeds rows and every assumption below (viewport +1 hack, cursor.y
|
||||||
|
// clamp, log-update's heightDelta===0 fast path) breaks, desyncing
|
||||||
|
// virtual/physical cursors. Clamping here enforces the invariant:
|
||||||
|
// overflow writes land at y >= screen.height and setCellAt drops
|
||||||
|
// them. The sibling is invisible (obvious, easy to find) instead of
|
||||||
|
// corrupting the whole terminal.
|
||||||
|
const height = options.altScreen ? terminalRows : yogaHeight
|
||||||
|
|
||||||
|
if (options.altScreen && yogaHeight > terminalRows) {
|
||||||
|
logForDebugging(
|
||||||
|
`alt-screen: yoga height ${yogaHeight} > terminalRows ${terminalRows} — ` +
|
||||||
|
`something is rendering outside <AlternateScreen>. Overflow clipped.`,
|
||||||
|
{ level: 'warn' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const screen = backScreen ?? createScreen(width, height, stylePool, charPool, hyperlinkPool)
|
||||||
|
|
||||||
|
if (output) {
|
||||||
|
output.reset(width, height, screen)
|
||||||
|
} else {
|
||||||
|
output = new Output({ width, height, stylePool, screen })
|
||||||
|
}
|
||||||
|
|
||||||
|
resetLayoutShifted()
|
||||||
|
resetScrollHint()
|
||||||
|
resetScrollDrainNode()
|
||||||
|
|
||||||
|
// prevFrameContaminated: selection overlay mutated the returned screen
|
||||||
|
// buffer post-render (in ink.tsx), resetFramesForAltScreen() replaced it
|
||||||
|
// with blanks, or forceRedraw() reset it to 0×0. Blit on the NEXT frame
|
||||||
|
// would copy stale inverted cells / blanks / nothing. When clean, blit
|
||||||
|
// restores the O(unchanged) fast path for steady-state frames (spinner
|
||||||
|
// tick, text stream).
|
||||||
|
// Removing an absolute-positioned node poisons prevScreen: it may
|
||||||
|
// have painted over non-siblings (e.g. an overlay over a ScrollBox
|
||||||
|
// earlier in tree order), so their blits would restore the removed
|
||||||
|
// node's pixels. hasRemovedChild only shields direct siblings.
|
||||||
|
// Normal-flow removals don't paint cross-subtree and are fine.
|
||||||
|
const absoluteRemoved = consumeAbsoluteRemovedFlag()
|
||||||
|
renderNodeToOutput(node, output, {
|
||||||
|
prevScreen: absoluteRemoved || options.prevFrameContaminated ? undefined : prevScreen
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderedScreen = output.get()
|
||||||
|
|
||||||
|
// Drain continuation: render cleared scrollbox.dirty, so next frame's
|
||||||
|
// root blit would skip the subtree. markDirty walks ancestors so the
|
||||||
|
// next frame descends. Done AFTER render so the clear-dirty at the end
|
||||||
|
// of renderNodeToOutput doesn't overwrite this.
|
||||||
|
const drainNode = getScrollDrainNode()
|
||||||
|
|
||||||
|
if (drainNode) {
|
||||||
|
markDirty(drainNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scrollHint: options.altScreen ? getScrollHint() : null,
|
||||||
|
scrollDrainPending: drainNode !== null,
|
||||||
|
screen: renderedScreen,
|
||||||
|
viewport: {
|
||||||
|
width: terminalWidth,
|
||||||
|
// Alt screen: fake viewport.height = rows + 1 so that
|
||||||
|
// shouldClearScreen()'s `screen.height >= viewport.height` check
|
||||||
|
// (which treats exactly-filling content as "overflows" for
|
||||||
|
// scrollback purposes) never fires. Alt-screen content is always
|
||||||
|
// exactly `rows` tall (via <Box height={rows}>) but never
|
||||||
|
// scrolls — the cursor.y clamp below keeps the cursor-restore
|
||||||
|
// from emitting an LF. With the standard diff path, every frame
|
||||||
|
// is incremental; no fullResetSequence_CAUSES_FLICKER.
|
||||||
|
height: options.altScreen ? terminalRows + 1 : terminalRows
|
||||||
|
},
|
||||||
|
cursor: {
|
||||||
|
x: 0,
|
||||||
|
// In the alt screen, keep the cursor inside the viewport. When
|
||||||
|
// screen.height === terminalRows exactly (content fills the alt
|
||||||
|
// screen), cursor.y = screen.height would trigger log-update's
|
||||||
|
// cursor-restore LF at the last row, scrolling one row off the top
|
||||||
|
// of the alt buffer and desyncing the diff's cursor model. The
|
||||||
|
// cursor is hidden so its position only matters for diff coords.
|
||||||
|
y: options.altScreen ? Math.max(0, Math.min(screen.height, terminalRows) - 1) : screen.height,
|
||||||
|
// Hide cursor when there's dynamic output to render (only in TTY mode)
|
||||||
|
visible: !isTTY || screen.height === 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
ui-tui/packages/hermes-ink/src/ink/root.ts
Normal file
174
ui-tui/packages/hermes-ink/src/ink/root.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { Stream } from 'stream'
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
|
|
||||||
|
import type { FrameEvent } from './frame.js'
|
||||||
|
import Ink, { type Options as InkOptions } from './ink.js'
|
||||||
|
import instances from './instances.js'
|
||||||
|
|
||||||
|
export type RenderOptions = {
|
||||||
|
/**
|
||||||
|
* Output stream where app will be rendered.
|
||||||
|
*
|
||||||
|
* @default process.stdout
|
||||||
|
*/
|
||||||
|
stdout?: NodeJS.WriteStream
|
||||||
|
/**
|
||||||
|
* Input stream where app will listen for input.
|
||||||
|
*
|
||||||
|
* @default process.stdin
|
||||||
|
*/
|
||||||
|
stdin?: NodeJS.ReadStream
|
||||||
|
/**
|
||||||
|
* Error stream.
|
||||||
|
* @default process.stderr
|
||||||
|
*/
|
||||||
|
stderr?: NodeJS.WriteStream
|
||||||
|
/**
|
||||||
|
* Configure whether Ink should listen to Ctrl+C keyboard input and exit the app. This is needed in case `process.stdin` is in raw mode, because then Ctrl+C is ignored by default and process is expected to handle it manually.
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
exitOnCtrlC?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch console methods to ensure console output doesn't mix with Ink output.
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
patchConsole?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after each frame render with timing and flicker information.
|
||||||
|
*/
|
||||||
|
onFrame?: (event: FrameEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Instance = {
|
||||||
|
/**
|
||||||
|
* Replace previous root node with a new one or update props of the current root node.
|
||||||
|
*/
|
||||||
|
rerender: Ink['render']
|
||||||
|
/**
|
||||||
|
* Manually unmount the whole Ink app.
|
||||||
|
*/
|
||||||
|
unmount: Ink['unmount']
|
||||||
|
/**
|
||||||
|
* Returns a promise, which resolves when app is unmounted.
|
||||||
|
*/
|
||||||
|
waitUntilExit: Ink['waitUntilExit']
|
||||||
|
cleanup: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A managed Ink root, similar to react-dom's createRoot API.
|
||||||
|
* Separates instance creation from rendering so the same root
|
||||||
|
* can be reused for multiple sequential screens.
|
||||||
|
*/
|
||||||
|
export type Root = {
|
||||||
|
render: (node: ReactNode) => void
|
||||||
|
unmount: () => void
|
||||||
|
waitUntilExit: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount a component and render the output.
|
||||||
|
*/
|
||||||
|
export const renderSync = (node: ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance => {
|
||||||
|
const opts = getOptions(options)
|
||||||
|
|
||||||
|
const inkOptions: InkOptions = {
|
||||||
|
stdout: process.stdout,
|
||||||
|
stdin: process.stdin,
|
||||||
|
stderr: process.stderr,
|
||||||
|
exitOnCtrlC: true,
|
||||||
|
patchConsole: true,
|
||||||
|
...opts
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance: Ink = getInstance(inkOptions.stdout, () => new Ink(inkOptions))
|
||||||
|
|
||||||
|
instance.render(node)
|
||||||
|
|
||||||
|
return {
|
||||||
|
rerender: instance.render,
|
||||||
|
unmount() {
|
||||||
|
instance.unmount()
|
||||||
|
},
|
||||||
|
waitUntilExit: instance.waitUntilExit,
|
||||||
|
cleanup: () => instances.delete(inkOptions.stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrappedRender = async (node: ReactNode, options?: NodeJS.WriteStream | RenderOptions): Promise<Instance> => {
|
||||||
|
// Preserve the microtask boundary that `await loadYoga()` used to provide.
|
||||||
|
// Without it, the first render fires synchronously before async startup work
|
||||||
|
// (e.g. useReplBridge notification state) settles, and the subsequent Static
|
||||||
|
// write overwrites scrollback instead of appending below the logo.
|
||||||
|
await Promise.resolve()
|
||||||
|
const instance = renderSync(node, options)
|
||||||
|
logForDebugging(`[render] first ink render: ${Math.round(process.uptime() * 1000)}ms since process start`)
|
||||||
|
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
export default wrappedRender
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an Ink root without rendering anything yet.
|
||||||
|
* Like react-dom's createRoot — call root.render() to mount a tree.
|
||||||
|
*/
|
||||||
|
export async function createRoot({
|
||||||
|
stdout = process.stdout,
|
||||||
|
stdin = process.stdin,
|
||||||
|
stderr = process.stderr,
|
||||||
|
exitOnCtrlC = true,
|
||||||
|
patchConsole = true,
|
||||||
|
onFrame
|
||||||
|
}: RenderOptions = {}): Promise<Root> {
|
||||||
|
// See wrappedRender — preserve microtask boundary from the old WASM await.
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
const instance = new Ink({
|
||||||
|
stdout,
|
||||||
|
stdin,
|
||||||
|
stderr,
|
||||||
|
exitOnCtrlC,
|
||||||
|
patchConsole,
|
||||||
|
onFrame
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register in the instances map so that code that looks up the Ink
|
||||||
|
// instance by stdout (e.g. external editor pause/resume) can find it.
|
||||||
|
instances.set(stdout, instance)
|
||||||
|
|
||||||
|
return {
|
||||||
|
render: node => instance.render(node),
|
||||||
|
unmount: () => instance.unmount(),
|
||||||
|
waitUntilExit: () => instance.waitUntilExit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOptions = (stdout: NodeJS.WriteStream | RenderOptions | undefined = {}): RenderOptions => {
|
||||||
|
if (stdout instanceof Stream) {
|
||||||
|
return {
|
||||||
|
stdout,
|
||||||
|
stdin: process.stdin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInstance = (stdout: NodeJS.WriteStream, createInstance: () => Ink): Ink => {
|
||||||
|
let instance = instances.get(stdout)
|
||||||
|
|
||||||
|
if (!instance) {
|
||||||
|
instance = createInstance()
|
||||||
|
instances.set(stdout, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance
|
||||||
|
}
|
||||||
1543
ui-tui/packages/hermes-ink/src/ink/screen.ts
Normal file
1543
ui-tui/packages/hermes-ink/src/ink/screen.ts
Normal file
File diff suppressed because it is too large
Load diff
91
ui-tui/packages/hermes-ink/src/ink/searchHighlight.ts
Normal file
91
ui-tui/packages/hermes-ink/src/ink/searchHighlight.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { cellAtIndex, CellWidth, type Screen, setCellStyleId, type StylePool } from './screen.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight all visible occurrences of `query` in the screen buffer by
|
||||||
|
* inverting cell styles (SGR 7). Post-render, same damage-tracking machinery
|
||||||
|
* as applySelectionOverlay — the diff picks up highlighted cells as ordinary
|
||||||
|
* changes, LogUpdate stays a pure diff engine.
|
||||||
|
*
|
||||||
|
* Case-insensitive. Handles wide characters (CJK, emoji) by building a
|
||||||
|
* col-of-char map per row — the Nth character isn't at col N when wide chars
|
||||||
|
* are present (each occupies 2 cells: head + SpacerTail).
|
||||||
|
*
|
||||||
|
* This ONLY inverts — there is no "current match" logic here. The yellow
|
||||||
|
* current-match overlay is handled separately by applyPositionedHighlight
|
||||||
|
* (render-to-screen.ts), which writes on top using positions scanned from
|
||||||
|
* the target message's DOM subtree.
|
||||||
|
*
|
||||||
|
* Returns true if any match was highlighted (damage gate — caller forces
|
||||||
|
* full-frame damage when true).
|
||||||
|
*/
|
||||||
|
export function applySearchHighlight(screen: Screen, query: string, stylePool: StylePool): boolean {
|
||||||
|
if (!query) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const lq = query.toLowerCase()
|
||||||
|
const qlen = lq.length
|
||||||
|
const w = screen.width
|
||||||
|
const noSelect = screen.noSelect
|
||||||
|
const height = screen.height
|
||||||
|
|
||||||
|
let applied = false
|
||||||
|
|
||||||
|
for (let row = 0; row < height; row++) {
|
||||||
|
const rowOff = row * w
|
||||||
|
// Build row text (already lowercased) + code-unit→cell-index map.
|
||||||
|
// Three skip conditions, all aligned with setCellStyleId /
|
||||||
|
// extractRowText (selection.ts):
|
||||||
|
// - SpacerTail: 2nd cell of a wide char, no char of its own
|
||||||
|
// - SpacerHead: end-of-line padding when a wide char wraps
|
||||||
|
// - noSelect: gutters (⎿, line numbers) — same exclusion as
|
||||||
|
// applySelectionOverlay. "Highlight what you see" still holds for
|
||||||
|
// content; gutters aren't search targets.
|
||||||
|
// Lowercasing per-char (not on the joined string at the end) means
|
||||||
|
// codeUnitToCell maps positions in the LOWERCASED text — U+0130
|
||||||
|
// (Turkish İ) lowercases to 2 code units, so lowering the joined
|
||||||
|
// string would desync indexOf positions from the map.
|
||||||
|
let text = ''
|
||||||
|
const colOf: number[] = []
|
||||||
|
const codeUnitToCell: number[] = []
|
||||||
|
|
||||||
|
for (let col = 0; col < w; col++) {
|
||||||
|
const idx = rowOff + col
|
||||||
|
const cell = cellAtIndex(screen, idx)
|
||||||
|
|
||||||
|
if (cell.width === CellWidth.SpacerTail || cell.width === CellWidth.SpacerHead || noSelect[idx] === 1) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const lc = cell.char.toLowerCase()
|
||||||
|
const cellIdx = colOf.length
|
||||||
|
|
||||||
|
for (let i = 0; i < lc.length; i++) {
|
||||||
|
codeUnitToCell.push(cellIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
text += lc
|
||||||
|
colOf.push(col)
|
||||||
|
}
|
||||||
|
|
||||||
|
let pos = text.indexOf(lq)
|
||||||
|
|
||||||
|
while (pos >= 0) {
|
||||||
|
applied = true
|
||||||
|
const startCi = codeUnitToCell[pos]!
|
||||||
|
const endCi = codeUnitToCell[pos + qlen - 1]!
|
||||||
|
|
||||||
|
for (let ci = startCi; ci <= endCi; ci++) {
|
||||||
|
const col = colOf[ci]!
|
||||||
|
const cell = cellAtIndex(screen, rowOff + col)
|
||||||
|
setCellStyleId(screen, col, row, stylePool.withInverse(cell.styleId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-overlapping advance (less/vim/grep/Ctrl+F). pos+1 would find
|
||||||
|
// 'aa' at 0 AND 1 in 'aaa' → double-invert cell 1.
|
||||||
|
pos = text.indexOf(lq, pos + qlen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return applied
|
||||||
|
}
|
||||||
1071
ui-tui/packages/hermes-ink/src/ink/selection.ts
Normal file
1071
ui-tui/packages/hermes-ink/src/ink/selection.ts
Normal file
File diff suppressed because it is too large
Load diff
74
ui-tui/packages/hermes-ink/src/ink/squash-text-nodes.ts
Normal file
74
ui-tui/packages/hermes-ink/src/ink/squash-text-nodes.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import type { DOMElement } from './dom.js'
|
||||||
|
import type { TextStyles } from './styles.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A segment of text with its associated styles.
|
||||||
|
* Used for structured rendering without ANSI string transforms.
|
||||||
|
*/
|
||||||
|
export type StyledSegment = {
|
||||||
|
text: string
|
||||||
|
styles: TextStyles
|
||||||
|
hyperlink?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Squash text nodes into styled segments, propagating styles down through the tree.
|
||||||
|
* This allows structured styling without relying on ANSI string transforms.
|
||||||
|
*/
|
||||||
|
export function squashTextNodesToSegments(
|
||||||
|
node: DOMElement,
|
||||||
|
inheritedStyles: TextStyles = {},
|
||||||
|
inheritedHyperlink?: string,
|
||||||
|
out: StyledSegment[] = []
|
||||||
|
): StyledSegment[] {
|
||||||
|
const mergedStyles = node.textStyles ? { ...inheritedStyles, ...node.textStyles } : inheritedStyles
|
||||||
|
|
||||||
|
for (const childNode of node.childNodes) {
|
||||||
|
if (childNode === undefined) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childNode.nodeName === '#text') {
|
||||||
|
if (childNode.nodeValue.length > 0) {
|
||||||
|
out.push({
|
||||||
|
text: childNode.nodeValue,
|
||||||
|
styles: mergedStyles,
|
||||||
|
hyperlink: inheritedHyperlink
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (childNode.nodeName === 'ink-text' || childNode.nodeName === 'ink-virtual-text') {
|
||||||
|
squashTextNodesToSegments(childNode, mergedStyles, inheritedHyperlink, out)
|
||||||
|
} else if (childNode.nodeName === 'ink-link') {
|
||||||
|
const href = childNode.attributes['href'] as string | undefined
|
||||||
|
squashTextNodesToSegments(childNode, mergedStyles, href || inheritedHyperlink, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Squash text nodes into a plain string (without styles).
|
||||||
|
* Used for text measurement in layout calculations.
|
||||||
|
*/
|
||||||
|
function squashTextNodes(node: DOMElement): string {
|
||||||
|
let text = ''
|
||||||
|
|
||||||
|
for (const childNode of node.childNodes) {
|
||||||
|
if (childNode === undefined) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childNode.nodeName === '#text') {
|
||||||
|
text += childNode.nodeValue
|
||||||
|
} else if (childNode.nodeName === 'ink-text' || childNode.nodeName === 'ink-virtual-text') {
|
||||||
|
text += squashTextNodes(childNode)
|
||||||
|
} else if (childNode.nodeName === 'ink-link') {
|
||||||
|
text += squashTextNodes(childNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
export default squashTextNodes
|
||||||
275
ui-tui/packages/hermes-ink/src/ink/stringWidth.ts
Normal file
275
ui-tui/packages/hermes-ink/src/ink/stringWidth.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
import emojiRegex from 'emoji-regex'
|
||||||
|
import { eastAsianWidth } from 'get-east-asian-width'
|
||||||
|
import stripAnsi from 'strip-ansi'
|
||||||
|
|
||||||
|
import { getGraphemeSegmenter } from '../utils/intl.js'
|
||||||
|
|
||||||
|
const EMOJI_REGEX = emojiRegex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback JavaScript implementation of stringWidth when Bun.stringWidth is not available.
|
||||||
|
*
|
||||||
|
* Get the display width of a string as it would appear in a terminal.
|
||||||
|
*
|
||||||
|
* This is a more accurate alternative to the string-width package that correctly handles
|
||||||
|
* characters like ⚠ (U+26A0) which string-width incorrectly reports as width 2.
|
||||||
|
*
|
||||||
|
* The implementation uses eastAsianWidth directly with ambiguousAsWide: false,
|
||||||
|
* which correctly treats ambiguous-width characters as narrow (width 1) as
|
||||||
|
* recommended by the Unicode standard for Western contexts.
|
||||||
|
*/
|
||||||
|
function stringWidthJavaScript(str: string): number {
|
||||||
|
if (typeof str !== 'string' || str.length === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: pure ASCII string (no ANSI codes, no wide chars)
|
||||||
|
let isPureAscii = true
|
||||||
|
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const code = str.charCodeAt(i)
|
||||||
|
|
||||||
|
// Check for non-ASCII or ANSI escape (0x1b)
|
||||||
|
if (code >= 127 || code === 0x1b) {
|
||||||
|
isPureAscii = false
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPureAscii) {
|
||||||
|
// Count printable characters (exclude control chars)
|
||||||
|
let width = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const code = str.charCodeAt(i)
|
||||||
|
|
||||||
|
if (code > 0x1f) {
|
||||||
|
width++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip ANSI if escape character is present
|
||||||
|
if (str.includes('\x1b')) {
|
||||||
|
str = stripAnsi(str)
|
||||||
|
|
||||||
|
if (str.length === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: simple Unicode (no emoji, variation selectors, or joiners)
|
||||||
|
if (!needsSegmentation(str)) {
|
||||||
|
let width = 0
|
||||||
|
|
||||||
|
for (const char of str) {
|
||||||
|
const codePoint = char.codePointAt(0)!
|
||||||
|
|
||||||
|
if (!isZeroWidth(codePoint)) {
|
||||||
|
width += eastAsianWidth(codePoint, { ambiguousAsWide: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
let width = 0
|
||||||
|
|
||||||
|
for (const { segment: grapheme } of getGraphemeSegmenter().segment(str)) {
|
||||||
|
// Check for emoji first (most emoji sequences are width 2)
|
||||||
|
EMOJI_REGEX.lastIndex = 0
|
||||||
|
|
||||||
|
if (EMOJI_REGEX.test(grapheme)) {
|
||||||
|
width += getEmojiWidth(grapheme)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate width for non-emoji graphemes
|
||||||
|
// For grapheme clusters (like Devanagari conjuncts with virama+ZWJ), only count
|
||||||
|
// the first non-zero-width character's width since the cluster renders as one glyph
|
||||||
|
for (const char of grapheme) {
|
||||||
|
const codePoint = char.codePointAt(0)!
|
||||||
|
|
||||||
|
if (!isZeroWidth(codePoint)) {
|
||||||
|
width += eastAsianWidth(codePoint, { ambiguousAsWide: false })
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
function needsSegmentation(str: string): boolean {
|
||||||
|
for (const char of str) {
|
||||||
|
const cp = char.codePointAt(0)!
|
||||||
|
|
||||||
|
// Emoji ranges
|
||||||
|
if (cp >= 0x1f300 && cp <= 0x1faff) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cp >= 0x2600 && cp <= 0x27bf) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cp >= 0x1f1e6 && cp <= 0x1f1ff) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variation selectors, ZWJ
|
||||||
|
if (cp >= 0xfe00 && cp <= 0xfe0f) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cp === 0x200d) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmojiWidth(grapheme: string): number {
|
||||||
|
// Regional indicators: single = 1, pair = 2
|
||||||
|
const first = grapheme.codePointAt(0)!
|
||||||
|
|
||||||
|
if (first >= 0x1f1e6 && first <= 0x1f1ff) {
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
for (const _ of grapheme) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
return count === 1 ? 1 : 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incomplete keycap: digit/symbol + VS16 without U+20E3
|
||||||
|
if (grapheme.length === 2) {
|
||||||
|
const second = grapheme.codePointAt(1)
|
||||||
|
|
||||||
|
if (second === 0xfe0f && ((first >= 0x30 && first <= 0x39) || first === 0x23 || first === 0x2a)) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZeroWidth(codePoint: number): boolean {
|
||||||
|
// Fast path for common printable range
|
||||||
|
if (codePoint >= 0x20 && codePoint < 0x7f) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codePoint >= 0xa0 && codePoint < 0x0300) {
|
||||||
|
return codePoint === 0x00ad
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control characters
|
||||||
|
if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero-width and invisible characters
|
||||||
|
if (
|
||||||
|
(codePoint >= 0x200b && codePoint <= 0x200d) || // ZW space/joiner
|
||||||
|
codePoint === 0xfeff || // BOM
|
||||||
|
(codePoint >= 0x2060 && codePoint <= 0x2064) // Word joiner etc.
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variation selectors
|
||||||
|
if ((codePoint >= 0xfe00 && codePoint <= 0xfe0f) || (codePoint >= 0xe0100 && codePoint <= 0xe01ef)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combining diacritical marks
|
||||||
|
if (
|
||||||
|
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
|
||||||
|
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
|
||||||
|
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
|
||||||
|
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
|
||||||
|
(codePoint >= 0xfe20 && codePoint <= 0xfe2f)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indic script combining marks (covers Devanagari through Malayalam)
|
||||||
|
if (codePoint >= 0x0900 && codePoint <= 0x0d4f) {
|
||||||
|
// Signs and vowel marks at start of each script block
|
||||||
|
const offset = codePoint & 0x7f
|
||||||
|
|
||||||
|
if (offset <= 0x03) {
|
||||||
|
return true
|
||||||
|
} // Signs at block start
|
||||||
|
|
||||||
|
if (offset >= 0x3a && offset <= 0x4f) {
|
||||||
|
return true
|
||||||
|
} // Vowel signs, virama
|
||||||
|
|
||||||
|
if (offset >= 0x51 && offset <= 0x57) {
|
||||||
|
return true
|
||||||
|
} // Stress signs
|
||||||
|
|
||||||
|
if (offset >= 0x62 && offset <= 0x63) {
|
||||||
|
return true
|
||||||
|
} // Vowel signs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thai/Lao combining marks
|
||||||
|
// Note: U+0E32 (SARA AA), U+0E33 (SARA AM), U+0EB2, U+0EB3 are spacing vowels (width 1), not combining marks
|
||||||
|
if (
|
||||||
|
codePoint === 0x0e31 || // Thai MAI HAN-AKAT
|
||||||
|
(codePoint >= 0x0e34 && codePoint <= 0x0e3a) || // Thai vowel signs (skip U+0E32, U+0E33)
|
||||||
|
(codePoint >= 0x0e47 && codePoint <= 0x0e4e) || // Thai vowel signs and marks
|
||||||
|
codePoint === 0x0eb1 || // Lao MAI KAN
|
||||||
|
(codePoint >= 0x0eb4 && codePoint <= 0x0ebc) || // Lao vowel signs (skip U+0EB2, U+0EB3)
|
||||||
|
(codePoint >= 0x0ec8 && codePoint <= 0x0ecd) // Lao tone marks
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arabic formatting
|
||||||
|
if (
|
||||||
|
(codePoint >= 0x0600 && codePoint <= 0x0605) ||
|
||||||
|
codePoint === 0x06dd ||
|
||||||
|
codePoint === 0x070f ||
|
||||||
|
codePoint === 0x08e2
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surrogates, tag characters
|
||||||
|
if (codePoint >= 0xd800 && codePoint <= 0xdfff) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codePoint >= 0xe0000 && codePoint <= 0xe007f) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: complex-script graphemes like Devanagari क्ष (ka+virama+ZWJ+ssa) render
|
||||||
|
// as a single ligature glyph but occupy 2 terminal cells (wcwidth sums the base
|
||||||
|
// consonants). Bun.stringWidth=2 matches terminal cell allocation, which is what
|
||||||
|
// we need for cursor positioning — the JS fallback's grapheme-cluster width of 1
|
||||||
|
// would desync Ink's layout from the terminal.
|
||||||
|
//
|
||||||
|
// Bun.stringWidth is resolved once at module scope rather than checked on every
|
||||||
|
// call — typeof guards deopt property access and this is a hot path (~100k calls/frame).
|
||||||
|
const bunStringWidth = typeof Bun !== 'undefined' && typeof Bun.stringWidth === 'function' ? Bun.stringWidth : null
|
||||||
|
|
||||||
|
const BUN_STRING_WIDTH_OPTS = { ambiguousIsNarrow: true } as const
|
||||||
|
|
||||||
|
export const stringWidth: (str: string) => number = bunStringWidth
|
||||||
|
? str => bunStringWidth(str, BUN_STRING_WIDTH_OPTS)
|
||||||
|
: stringWidthJavaScript
|
||||||
749
ui-tui/packages/hermes-ink/src/ink/styles.ts
Normal file
749
ui-tui/packages/hermes-ink/src/ink/styles.ts
Normal file
|
|
@ -0,0 +1,749 @@
|
||||||
|
import {
|
||||||
|
LayoutAlign,
|
||||||
|
LayoutDisplay,
|
||||||
|
LayoutEdge,
|
||||||
|
LayoutFlexDirection,
|
||||||
|
LayoutGutter,
|
||||||
|
LayoutJustify,
|
||||||
|
type LayoutNode,
|
||||||
|
LayoutOverflow,
|
||||||
|
LayoutPositionType,
|
||||||
|
LayoutWrap
|
||||||
|
} from './layout/node.js'
|
||||||
|
import type { BorderStyle, BorderTextOptions } from './render-border.js'
|
||||||
|
|
||||||
|
export type RGBColor = `rgb(${number},${number},${number})`
|
||||||
|
export type HexColor = `#${string}`
|
||||||
|
export type Ansi256Color = `ansi256(${number})`
|
||||||
|
export type AnsiColor =
|
||||||
|
| 'ansi:black'
|
||||||
|
| 'ansi:red'
|
||||||
|
| 'ansi:green'
|
||||||
|
| 'ansi:yellow'
|
||||||
|
| 'ansi:blue'
|
||||||
|
| 'ansi:magenta'
|
||||||
|
| 'ansi:cyan'
|
||||||
|
| 'ansi:white'
|
||||||
|
| 'ansi:blackBright'
|
||||||
|
| 'ansi:redBright'
|
||||||
|
| 'ansi:greenBright'
|
||||||
|
| 'ansi:yellowBright'
|
||||||
|
| 'ansi:blueBright'
|
||||||
|
| 'ansi:magentaBright'
|
||||||
|
| 'ansi:cyanBright'
|
||||||
|
| 'ansi:whiteBright'
|
||||||
|
|
||||||
|
/** Raw color value - not a theme key */
|
||||||
|
export type Color = RGBColor | HexColor | Ansi256Color | AnsiColor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured text styling properties.
|
||||||
|
* Used to style text without relying on ANSI string transforms.
|
||||||
|
* Colors are raw values - theme resolution happens at the component layer.
|
||||||
|
*/
|
||||||
|
export type TextStyles = {
|
||||||
|
readonly color?: Color
|
||||||
|
readonly backgroundColor?: Color
|
||||||
|
readonly dim?: boolean
|
||||||
|
readonly bold?: boolean
|
||||||
|
readonly italic?: boolean
|
||||||
|
readonly underline?: boolean
|
||||||
|
readonly strikethrough?: boolean
|
||||||
|
readonly inverse?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Styles = {
|
||||||
|
readonly textWrap?:
|
||||||
|
| 'wrap'
|
||||||
|
| 'wrap-trim'
|
||||||
|
| 'end'
|
||||||
|
| 'middle'
|
||||||
|
| 'truncate-end'
|
||||||
|
| 'truncate'
|
||||||
|
| 'truncate-middle'
|
||||||
|
| 'truncate-start'
|
||||||
|
|
||||||
|
readonly position?: 'absolute' | 'relative'
|
||||||
|
readonly top?: number | `${number}%`
|
||||||
|
readonly bottom?: number | `${number}%`
|
||||||
|
readonly left?: number | `${number}%`
|
||||||
|
readonly right?: number | `${number}%`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size of the gap between an element's columns.
|
||||||
|
*/
|
||||||
|
readonly columnGap?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size of the gap between element's rows.
|
||||||
|
*/
|
||||||
|
readonly rowGap?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size of the gap between an element's columns and rows. Shorthand for `columnGap` and `rowGap`.
|
||||||
|
*/
|
||||||
|
readonly gap?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Margin on all sides. Equivalent to setting `marginTop`, `marginBottom`, `marginLeft` and `marginRight`.
|
||||||
|
*/
|
||||||
|
readonly margin?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal margin. Equivalent to setting `marginLeft` and `marginRight`.
|
||||||
|
*/
|
||||||
|
readonly marginX?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical margin. Equivalent to setting `marginTop` and `marginBottom`.
|
||||||
|
*/
|
||||||
|
readonly marginY?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top margin.
|
||||||
|
*/
|
||||||
|
readonly marginTop?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom margin.
|
||||||
|
*/
|
||||||
|
readonly marginBottom?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left margin.
|
||||||
|
*/
|
||||||
|
readonly marginLeft?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right margin.
|
||||||
|
*/
|
||||||
|
readonly marginRight?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Padding on all sides. Equivalent to setting `paddingTop`, `paddingBottom`, `paddingLeft` and `paddingRight`.
|
||||||
|
*/
|
||||||
|
readonly padding?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal padding. Equivalent to setting `paddingLeft` and `paddingRight`.
|
||||||
|
*/
|
||||||
|
readonly paddingX?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical padding. Equivalent to setting `paddingTop` and `paddingBottom`.
|
||||||
|
*/
|
||||||
|
readonly paddingY?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top padding.
|
||||||
|
*/
|
||||||
|
readonly paddingTop?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom padding.
|
||||||
|
*/
|
||||||
|
readonly paddingBottom?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left padding.
|
||||||
|
*/
|
||||||
|
readonly paddingLeft?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right padding.
|
||||||
|
*/
|
||||||
|
readonly paddingRight?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This property defines the ability for a flex item to grow if necessary.
|
||||||
|
* See [flex-grow](https://css-tricks.com/almanac/properties/f/flex-grow/).
|
||||||
|
*/
|
||||||
|
readonly flexGrow?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It specifies the “flex shrink factor”, which determines how much the flex item will shrink relative to the rest of the flex items in the flex container when there isn’t enough space on the row.
|
||||||
|
* See [flex-shrink](https://css-tricks.com/almanac/properties/f/flex-shrink/).
|
||||||
|
*/
|
||||||
|
readonly flexShrink?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It establishes the main-axis, thus defining the direction flex items are placed in the flex container.
|
||||||
|
* See [flex-direction](https://css-tricks.com/almanac/properties/f/flex-direction/).
|
||||||
|
*/
|
||||||
|
readonly flexDirection?: 'row' | 'column' | 'row-reverse' | 'column-reverse'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It specifies the initial size of the flex item, before any available space is distributed according to the flex factors.
|
||||||
|
* See [flex-basis](https://css-tricks.com/almanac/properties/f/flex-basis/).
|
||||||
|
*/
|
||||||
|
readonly flexBasis?: number | string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It defines whether the flex items are forced in a single line or can be flowed into multiple lines. If set to multiple lines, it also defines the cross-axis which determines the direction new lines are stacked in.
|
||||||
|
* See [flex-wrap](https://css-tricks.com/almanac/properties/f/flex-wrap/).
|
||||||
|
*/
|
||||||
|
readonly flexWrap?: 'nowrap' | 'wrap' | 'wrap-reverse'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The align-items property defines the default behavior for how items are laid out along the cross axis (perpendicular to the main axis).
|
||||||
|
* See [align-items](https://css-tricks.com/almanac/properties/a/align-items/).
|
||||||
|
*/
|
||||||
|
readonly alignItems?: 'flex-start' | 'center' | 'flex-end' | 'stretch'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It makes possible to override the align-items value for specific flex items.
|
||||||
|
* See [align-self](https://css-tricks.com/almanac/properties/a/align-self/).
|
||||||
|
*/
|
||||||
|
readonly alignSelf?: 'flex-start' | 'center' | 'flex-end' | 'auto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It defines the alignment along the main axis.
|
||||||
|
* See [justify-content](https://css-tricks.com/almanac/properties/j/justify-content/).
|
||||||
|
*/
|
||||||
|
readonly justifyContent?: 'flex-start' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly' | 'center'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Width of the element in spaces.
|
||||||
|
* You can also set it in percent, which will calculate the width based on the width of parent element.
|
||||||
|
*/
|
||||||
|
readonly width?: number | string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Height of the element in lines (rows).
|
||||||
|
* You can also set it in percent, which will calculate the height based on the height of parent element.
|
||||||
|
*/
|
||||||
|
readonly height?: number | string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a minimum width of the element.
|
||||||
|
*/
|
||||||
|
readonly minWidth?: number | string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a minimum height of the element.
|
||||||
|
*/
|
||||||
|
readonly minHeight?: number | string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a maximum width of the element.
|
||||||
|
*/
|
||||||
|
readonly maxWidth?: number | string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a maximum height of the element.
|
||||||
|
*/
|
||||||
|
readonly maxHeight?: number | string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set this property to `none` to hide the element.
|
||||||
|
*/
|
||||||
|
readonly display?: 'flex' | 'none'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a border with a specified style.
|
||||||
|
* If `borderStyle` is `undefined` (which it is by default), no border will be added.
|
||||||
|
*/
|
||||||
|
readonly borderStyle?: BorderStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether top border is visible.
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
readonly borderTop?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether bottom border is visible.
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
readonly borderBottom?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether left border is visible.
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
readonly borderLeft?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether right border is visible.
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
readonly borderRight?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change border color.
|
||||||
|
* Shorthand for setting `borderTopColor`, `borderRightColor`, `borderBottomColor` and `borderLeftColor`.
|
||||||
|
*/
|
||||||
|
readonly borderColor?: Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change top border color.
|
||||||
|
* Accepts raw color values (rgb, hex, ansi).
|
||||||
|
*/
|
||||||
|
readonly borderTopColor?: Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change bottom border color.
|
||||||
|
* Accepts raw color values (rgb, hex, ansi).
|
||||||
|
*/
|
||||||
|
readonly borderBottomColor?: Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change left border color.
|
||||||
|
* Accepts raw color values (rgb, hex, ansi).
|
||||||
|
*/
|
||||||
|
readonly borderLeftColor?: Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change right border color.
|
||||||
|
* Accepts raw color values (rgb, hex, ansi).
|
||||||
|
*/
|
||||||
|
readonly borderRightColor?: Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dim the border color.
|
||||||
|
* Shorthand for setting `borderTopDimColor`, `borderBottomDimColor`, `borderLeftDimColor` and `borderRightDimColor`.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
readonly borderDimColor?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dim the top border color.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
readonly borderTopDimColor?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dim the bottom border color.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
readonly borderBottomDimColor?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dim the left border color.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
readonly borderLeftDimColor?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dim the right border color.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
readonly borderRightDimColor?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add text within the border. Only applies to top or bottom borders.
|
||||||
|
*/
|
||||||
|
readonly borderText?: BorderTextOptions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background color for the box. Fills the interior with background-colored
|
||||||
|
* spaces and is inherited by child text nodes as their default background.
|
||||||
|
*/
|
||||||
|
readonly backgroundColor?: Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill the box's interior (padding included) with spaces before
|
||||||
|
* rendering children, so nothing behind it shows through. Like
|
||||||
|
* `backgroundColor` but without emitting any SGR — the terminal's
|
||||||
|
* default background is used. Useful for absolute-positioned overlays
|
||||||
|
* where Box padding/gaps would otherwise be transparent.
|
||||||
|
*/
|
||||||
|
readonly opaque?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behavior for an element's overflow in both directions.
|
||||||
|
* 'scroll' constrains the container's size (children do not expand it)
|
||||||
|
* and enables scrollTop-based virtualized scrolling at render time.
|
||||||
|
*
|
||||||
|
* @default 'visible'
|
||||||
|
*/
|
||||||
|
readonly overflow?: 'visible' | 'hidden' | 'scroll'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behavior for an element's overflow in horizontal direction.
|
||||||
|
*
|
||||||
|
* @default 'visible'
|
||||||
|
*/
|
||||||
|
readonly overflowX?: 'visible' | 'hidden' | 'scroll'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behavior for an element's overflow in vertical direction.
|
||||||
|
*
|
||||||
|
* @default 'visible'
|
||||||
|
*/
|
||||||
|
readonly overflowY?: 'visible' | 'hidden' | 'scroll'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exclude this box's cells from text selection in fullscreen mode.
|
||||||
|
* Cells inside this region are skipped by both the selection highlight
|
||||||
|
* and the copied text — useful for fencing off gutters (line numbers,
|
||||||
|
* diff sigils) so click-drag over a diff yields clean copyable code.
|
||||||
|
* Only affects alt-screen text selection; no-op otherwise.
|
||||||
|
*
|
||||||
|
* `'from-left-edge'` extends the exclusion from column 0 to the box's
|
||||||
|
* right edge for every row it occupies — this covers any upstream
|
||||||
|
* indentation (tool message prefix, tree lines) so a multi-row drag
|
||||||
|
* doesn't pick up leading whitespace from middle rows.
|
||||||
|
*/
|
||||||
|
readonly noSelect?: boolean | 'from-left-edge'
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyPositionStyles = (node: LayoutNode, style: Styles): void => {
|
||||||
|
if ('position' in style) {
|
||||||
|
node.setPositionType(style.position === 'absolute' ? LayoutPositionType.Absolute : LayoutPositionType.Relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('top' in style) {
|
||||||
|
applyPositionEdge(node, 'top', style.top)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('bottom' in style) {
|
||||||
|
applyPositionEdge(node, 'bottom', style.bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('left' in style) {
|
||||||
|
applyPositionEdge(node, 'left', style.left)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('right' in style) {
|
||||||
|
applyPositionEdge(node, 'right', style.right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPositionEdge(
|
||||||
|
node: LayoutNode,
|
||||||
|
edge: 'top' | 'bottom' | 'left' | 'right',
|
||||||
|
v: number | `${number}%` | undefined
|
||||||
|
): void {
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
node.setPositionPercent(edge, Number.parseInt(v, 10))
|
||||||
|
} else if (typeof v === 'number') {
|
||||||
|
node.setPosition(edge, v)
|
||||||
|
} else {
|
||||||
|
node.setPosition(edge, Number.NaN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyOverflowStyles = (node: LayoutNode, style: Styles): void => {
|
||||||
|
// Yoga's Overflow controls whether children expand the container.
|
||||||
|
// 'hidden' and 'scroll' both prevent expansion; 'scroll' additionally
|
||||||
|
// signals that the renderer should apply scrollTop translation.
|
||||||
|
// overflowX/Y are render-time concerns; for layout we use the union.
|
||||||
|
const y = style.overflowY ?? style.overflow
|
||||||
|
const x = style.overflowX ?? style.overflow
|
||||||
|
|
||||||
|
if (y === 'scroll' || x === 'scroll') {
|
||||||
|
node.setOverflow(LayoutOverflow.Scroll)
|
||||||
|
} else if (y === 'hidden' || x === 'hidden') {
|
||||||
|
node.setOverflow(LayoutOverflow.Hidden)
|
||||||
|
} else if ('overflow' in style || 'overflowX' in style || 'overflowY' in style) {
|
||||||
|
node.setOverflow(LayoutOverflow.Visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyMarginStyles = (node: LayoutNode, style: Styles): void => {
|
||||||
|
if ('margin' in style) {
|
||||||
|
node.setMargin(LayoutEdge.All, style.margin ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('marginX' in style) {
|
||||||
|
node.setMargin(LayoutEdge.Horizontal, style.marginX ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('marginY' in style) {
|
||||||
|
node.setMargin(LayoutEdge.Vertical, style.marginY ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('marginLeft' in style) {
|
||||||
|
node.setMargin(LayoutEdge.Start, style.marginLeft || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('marginRight' in style) {
|
||||||
|
node.setMargin(LayoutEdge.End, style.marginRight || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('marginTop' in style) {
|
||||||
|
node.setMargin(LayoutEdge.Top, style.marginTop || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('marginBottom' in style) {
|
||||||
|
node.setMargin(LayoutEdge.Bottom, style.marginBottom || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyPaddingStyles = (node: LayoutNode, style: Styles): void => {
|
||||||
|
if ('padding' in style) {
|
||||||
|
node.setPadding(LayoutEdge.All, style.padding ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('paddingX' in style) {
|
||||||
|
node.setPadding(LayoutEdge.Horizontal, style.paddingX ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('paddingY' in style) {
|
||||||
|
node.setPadding(LayoutEdge.Vertical, style.paddingY ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('paddingLeft' in style) {
|
||||||
|
node.setPadding(LayoutEdge.Left, style.paddingLeft || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('paddingRight' in style) {
|
||||||
|
node.setPadding(LayoutEdge.Right, style.paddingRight || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('paddingTop' in style) {
|
||||||
|
node.setPadding(LayoutEdge.Top, style.paddingTop || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('paddingBottom' in style) {
|
||||||
|
node.setPadding(LayoutEdge.Bottom, style.paddingBottom || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyFlexStyles = (node: LayoutNode, style: Styles): void => {
|
||||||
|
if ('flexGrow' in style) {
|
||||||
|
node.setFlexGrow(style.flexGrow ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('flexShrink' in style) {
|
||||||
|
node.setFlexShrink(typeof style.flexShrink === 'number' ? style.flexShrink : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('flexWrap' in style) {
|
||||||
|
if (style.flexWrap === 'nowrap') {
|
||||||
|
node.setFlexWrap(LayoutWrap.NoWrap)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.flexWrap === 'wrap') {
|
||||||
|
node.setFlexWrap(LayoutWrap.Wrap)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.flexWrap === 'wrap-reverse') {
|
||||||
|
node.setFlexWrap(LayoutWrap.WrapReverse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('flexDirection' in style) {
|
||||||
|
if (style.flexDirection === 'row') {
|
||||||
|
node.setFlexDirection(LayoutFlexDirection.Row)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.flexDirection === 'row-reverse') {
|
||||||
|
node.setFlexDirection(LayoutFlexDirection.RowReverse)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.flexDirection === 'column') {
|
||||||
|
node.setFlexDirection(LayoutFlexDirection.Column)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.flexDirection === 'column-reverse') {
|
||||||
|
node.setFlexDirection(LayoutFlexDirection.ColumnReverse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('flexBasis' in style) {
|
||||||
|
if (typeof style.flexBasis === 'number') {
|
||||||
|
node.setFlexBasis(style.flexBasis)
|
||||||
|
} else if (typeof style.flexBasis === 'string') {
|
||||||
|
node.setFlexBasisPercent(Number.parseInt(style.flexBasis, 10))
|
||||||
|
} else {
|
||||||
|
node.setFlexBasis(Number.NaN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('alignItems' in style) {
|
||||||
|
if (style.alignItems === 'stretch' || !style.alignItems) {
|
||||||
|
node.setAlignItems(LayoutAlign.Stretch)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.alignItems === 'flex-start') {
|
||||||
|
node.setAlignItems(LayoutAlign.FlexStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.alignItems === 'center') {
|
||||||
|
node.setAlignItems(LayoutAlign.Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.alignItems === 'flex-end') {
|
||||||
|
node.setAlignItems(LayoutAlign.FlexEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('alignSelf' in style) {
|
||||||
|
if (style.alignSelf === 'auto' || !style.alignSelf) {
|
||||||
|
node.setAlignSelf(LayoutAlign.Auto)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.alignSelf === 'flex-start') {
|
||||||
|
node.setAlignSelf(LayoutAlign.FlexStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.alignSelf === 'center') {
|
||||||
|
node.setAlignSelf(LayoutAlign.Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.alignSelf === 'flex-end') {
|
||||||
|
node.setAlignSelf(LayoutAlign.FlexEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('justifyContent' in style) {
|
||||||
|
if (style.justifyContent === 'flex-start' || !style.justifyContent) {
|
||||||
|
node.setJustifyContent(LayoutJustify.FlexStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.justifyContent === 'center') {
|
||||||
|
node.setJustifyContent(LayoutJustify.Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.justifyContent === 'flex-end') {
|
||||||
|
node.setJustifyContent(LayoutJustify.FlexEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.justifyContent === 'space-between') {
|
||||||
|
node.setJustifyContent(LayoutJustify.SpaceBetween)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.justifyContent === 'space-around') {
|
||||||
|
node.setJustifyContent(LayoutJustify.SpaceAround)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.justifyContent === 'space-evenly') {
|
||||||
|
node.setJustifyContent(LayoutJustify.SpaceEvenly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyDimensionStyles = (node: LayoutNode, style: Styles): void => {
|
||||||
|
if ('width' in style) {
|
||||||
|
if (typeof style.width === 'number') {
|
||||||
|
node.setWidth(style.width)
|
||||||
|
} else if (typeof style.width === 'string') {
|
||||||
|
node.setWidthPercent(Number.parseInt(style.width, 10))
|
||||||
|
} else {
|
||||||
|
node.setWidthAuto()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('height' in style) {
|
||||||
|
if (typeof style.height === 'number') {
|
||||||
|
node.setHeight(style.height)
|
||||||
|
} else if (typeof style.height === 'string') {
|
||||||
|
node.setHeightPercent(Number.parseInt(style.height, 10))
|
||||||
|
} else {
|
||||||
|
node.setHeightAuto()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('minWidth' in style) {
|
||||||
|
if (typeof style.minWidth === 'string') {
|
||||||
|
node.setMinWidthPercent(Number.parseInt(style.minWidth, 10))
|
||||||
|
} else {
|
||||||
|
node.setMinWidth(style.minWidth ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('minHeight' in style) {
|
||||||
|
if (typeof style.minHeight === 'string') {
|
||||||
|
node.setMinHeightPercent(Number.parseInt(style.minHeight, 10))
|
||||||
|
} else {
|
||||||
|
node.setMinHeight(style.minHeight ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('maxWidth' in style) {
|
||||||
|
if (typeof style.maxWidth === 'string') {
|
||||||
|
node.setMaxWidthPercent(Number.parseInt(style.maxWidth, 10))
|
||||||
|
} else {
|
||||||
|
node.setMaxWidth(style.maxWidth ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('maxHeight' in style) {
|
||||||
|
if (typeof style.maxHeight === 'string') {
|
||||||
|
node.setMaxHeightPercent(Number.parseInt(style.maxHeight, 10))
|
||||||
|
} else {
|
||||||
|
node.setMaxHeight(style.maxHeight ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyDisplayStyles = (node: LayoutNode, style: Styles): void => {
|
||||||
|
if ('display' in style) {
|
||||||
|
node.setDisplay(style.display === 'flex' ? LayoutDisplay.Flex : LayoutDisplay.None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyBorderStyles = (node: LayoutNode, style: Styles, resolvedStyle?: Styles): void => {
|
||||||
|
// resolvedStyle is the full current style (already set on the DOM node).
|
||||||
|
// style may be a diff with only changed properties. For border side props,
|
||||||
|
// we need the resolved value because `borderStyle` in a diff may not include
|
||||||
|
// unchanged border side values (e.g. borderTop stays false but isn't in the diff).
|
||||||
|
const resolved = resolvedStyle ?? style
|
||||||
|
|
||||||
|
if ('borderStyle' in style) {
|
||||||
|
const borderWidth = style.borderStyle ? 1 : 0
|
||||||
|
|
||||||
|
node.setBorder(LayoutEdge.Top, resolved.borderTop !== false ? borderWidth : 0)
|
||||||
|
node.setBorder(LayoutEdge.Bottom, resolved.borderBottom !== false ? borderWidth : 0)
|
||||||
|
node.setBorder(LayoutEdge.Left, resolved.borderLeft !== false ? borderWidth : 0)
|
||||||
|
node.setBorder(LayoutEdge.Right, resolved.borderRight !== false ? borderWidth : 0)
|
||||||
|
} else {
|
||||||
|
// Handle individual border property changes (when only borderX changes without borderStyle).
|
||||||
|
// Skip undefined values — they mean the prop was removed or never set,
|
||||||
|
// not that a border should be enabled.
|
||||||
|
if ('borderTop' in style && style.borderTop !== undefined) {
|
||||||
|
node.setBorder(LayoutEdge.Top, style.borderTop === false ? 0 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('borderBottom' in style && style.borderBottom !== undefined) {
|
||||||
|
node.setBorder(LayoutEdge.Bottom, style.borderBottom === false ? 0 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('borderLeft' in style && style.borderLeft !== undefined) {
|
||||||
|
node.setBorder(LayoutEdge.Left, style.borderLeft === false ? 0 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('borderRight' in style && style.borderRight !== undefined) {
|
||||||
|
node.setBorder(LayoutEdge.Right, style.borderRight === false ? 0 : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyGapStyles = (node: LayoutNode, style: Styles): void => {
|
||||||
|
if ('gap' in style) {
|
||||||
|
node.setGap(LayoutGutter.All, style.gap ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('columnGap' in style) {
|
||||||
|
node.setGap(LayoutGutter.Column, style.columnGap ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('rowGap' in style) {
|
||||||
|
node.setGap(LayoutGutter.Row, style.rowGap ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = (node: LayoutNode, style: Styles = {}, resolvedStyle?: Styles): void => {
|
||||||
|
applyPositionStyles(node, style)
|
||||||
|
applyOverflowStyles(node, style)
|
||||||
|
applyMarginStyles(node, style)
|
||||||
|
applyPaddingStyles(node, style)
|
||||||
|
applyFlexStyles(node, style)
|
||||||
|
applyDimensionStyles(node, style)
|
||||||
|
applyDisplayStyles(node, style)
|
||||||
|
applyBorderStyles(node, style, resolvedStyle)
|
||||||
|
applyGapStyles(node, style)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default styles
|
||||||
51
ui-tui/packages/hermes-ink/src/ink/supports-hyperlinks.ts
Normal file
51
ui-tui/packages/hermes-ink/src/ink/supports-hyperlinks.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import supportsHyperlinksLib from 'supports-hyperlinks'
|
||||||
|
|
||||||
|
// Additional terminals that support OSC 8 hyperlinks but aren't detected by supports-hyperlinks.
|
||||||
|
// Checked against both TERM_PROGRAM and LC_TERMINAL (the latter is preserved inside tmux).
|
||||||
|
export const ADDITIONAL_HYPERLINK_TERMINALS = ['ghostty', 'Hyper', 'kitty', 'alacritty', 'iTerm.app', 'iTerm2']
|
||||||
|
|
||||||
|
type EnvLike = Record<string, string | undefined>
|
||||||
|
|
||||||
|
type SupportsHyperlinksOptions = {
|
||||||
|
env?: EnvLike
|
||||||
|
stdoutSupported?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether stdout supports OSC 8 hyperlinks.
|
||||||
|
* Extends the supports-hyperlinks library with additional terminal detection.
|
||||||
|
* @param options Optional overrides for testing (env, stdoutSupported)
|
||||||
|
*/
|
||||||
|
export function supportsHyperlinks(options?: SupportsHyperlinksOptions): boolean {
|
||||||
|
const stdoutSupported = options?.stdoutSupported ?? supportsHyperlinksLib.stdout
|
||||||
|
|
||||||
|
if (stdoutSupported) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = options?.env ?? process.env
|
||||||
|
|
||||||
|
// Check for additional terminals not detected by supports-hyperlinks
|
||||||
|
const termProgram = env['TERM_PROGRAM']
|
||||||
|
|
||||||
|
if (termProgram && ADDITIONAL_HYPERLINK_TERMINALS.includes(termProgram)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// LC_TERMINAL is set by some terminals (e.g. iTerm2) and preserved inside tmux,
|
||||||
|
// where TERM_PROGRAM is overwritten to 'tmux'.
|
||||||
|
const lcTerminal = env['LC_TERMINAL']
|
||||||
|
|
||||||
|
if (lcTerminal && ADDITIONAL_HYPERLINK_TERMINALS.includes(lcTerminal)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kitty sets TERM=xterm-kitty
|
||||||
|
const term = env['TERM']
|
||||||
|
|
||||||
|
if (term?.includes('kitty')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
44
ui-tui/packages/hermes-ink/src/ink/tabstops.ts
Normal file
44
ui-tui/packages/hermes-ink/src/ink/tabstops.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Tab expansion, inspired by Ghostty's Tabstops.zig
|
||||||
|
// Uses 8-column intervals (POSIX default, hardcoded in terminals like Ghostty)
|
||||||
|
|
||||||
|
import { stringWidth } from './stringWidth.js'
|
||||||
|
import { createTokenizer } from './termio/tokenize.js'
|
||||||
|
|
||||||
|
const DEFAULT_TAB_INTERVAL = 8
|
||||||
|
|
||||||
|
export function expandTabs(text: string, interval = DEFAULT_TAB_INTERVAL): string {
|
||||||
|
if (!text.includes('\t')) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenizer = createTokenizer()
|
||||||
|
const tokens = tokenizer.feed(text)
|
||||||
|
tokens.push(...tokenizer.flush())
|
||||||
|
|
||||||
|
let result = ''
|
||||||
|
let column = 0
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token.type === 'sequence') {
|
||||||
|
result += token.value
|
||||||
|
} else {
|
||||||
|
const parts = token.value.split(/(\t|\n)/)
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part === '\t') {
|
||||||
|
const spaces = interval - (column % interval)
|
||||||
|
result += ' '.repeat(spaces)
|
||||||
|
column += spaces
|
||||||
|
} else if (part === '\n') {
|
||||||
|
result += part
|
||||||
|
column = 0
|
||||||
|
} else {
|
||||||
|
result += part
|
||||||
|
column += stringWidth(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
52
ui-tui/packages/hermes-ink/src/ink/terminal-focus-state.ts
Normal file
52
ui-tui/packages/hermes-ink/src/ink/terminal-focus-state.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
// Terminal focus state signal — non-React access to DECSET 1004 focus events.
|
||||||
|
// 'unknown' is the default for terminals that don't support focus reporting;
|
||||||
|
// consumers treat 'unknown' identically to 'focused' (no throttling).
|
||||||
|
// Subscribers are notified synchronously when focus changes, used by
|
||||||
|
// TerminalFocusProvider to avoid polling.
|
||||||
|
export type TerminalFocusState = 'focused' | 'blurred' | 'unknown'
|
||||||
|
|
||||||
|
let focusState: TerminalFocusState = 'unknown'
|
||||||
|
const resolvers: Set<() => void> = new Set()
|
||||||
|
const subscribers: Set<() => void> = new Set()
|
||||||
|
|
||||||
|
export function setTerminalFocused(v: boolean): void {
|
||||||
|
focusState = v ? 'focused' : 'blurred'
|
||||||
|
|
||||||
|
// Notify useSyncExternalStore subscribers
|
||||||
|
for (const cb of subscribers) {
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!v) {
|
||||||
|
for (const resolve of resolvers) {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvers.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTerminalFocused(): boolean {
|
||||||
|
return focusState !== 'blurred'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTerminalFocusState(): TerminalFocusState {
|
||||||
|
return focusState
|
||||||
|
}
|
||||||
|
|
||||||
|
// For useSyncExternalStore
|
||||||
|
export function subscribeTerminalFocus(cb: () => void): () => void {
|
||||||
|
subscribers.add(cb)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscribers.delete(cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetTerminalFocusState(): void {
|
||||||
|
focusState = 'unknown'
|
||||||
|
|
||||||
|
for (const cb of subscribers) {
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
}
|
||||||
222
ui-tui/packages/hermes-ink/src/ink/terminal-querier.ts
Normal file
222
ui-tui/packages/hermes-ink/src/ink/terminal-querier.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
/**
|
||||||
|
* Query the terminal and await responses without timeouts.
|
||||||
|
*
|
||||||
|
* Terminal queries (DECRQM, DA1, OSC 11, etc.) share the stdin stream
|
||||||
|
* with keyboard input. Response sequences are syntactically
|
||||||
|
* distinguishable from key events, so the input parser recognizes them
|
||||||
|
* and dispatches them here.
|
||||||
|
*
|
||||||
|
* To avoid timeouts, each query batch is terminated by a DA1 sentinel
|
||||||
|
* (CSI c) — every terminal since VT100 responds to DA1, and terminals
|
||||||
|
* answer queries in order. So: if your query's response arrives before
|
||||||
|
* DA1's, the terminal supports it; if DA1 arrives first, it doesn't.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const [sync, grapheme] = await Promise.all([
|
||||||
|
* querier.send(decrqm(2026)),
|
||||||
|
* querier.send(decrqm(2027)),
|
||||||
|
* querier.flush(),
|
||||||
|
* ])
|
||||||
|
* // sync and grapheme are DECRPM responses or undefined if unsupported
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TerminalResponse } from './parse-keypress.js'
|
||||||
|
import { csi } from './termio/csi.js'
|
||||||
|
import { osc } from './termio/osc.js'
|
||||||
|
|
||||||
|
/** A terminal query: an outbound request sequence paired with a matcher
|
||||||
|
* that recognizes the expected inbound response. Built by `decrqm()`,
|
||||||
|
* `oscColor()`, `kittyKeyboard()`, etc. */
|
||||||
|
export type TerminalQuery<T extends TerminalResponse = TerminalResponse> = {
|
||||||
|
/** Escape sequence to write to stdout */
|
||||||
|
request: string
|
||||||
|
/** Recognizes the expected response in the inbound stream */
|
||||||
|
match: (r: TerminalResponse) => r is T
|
||||||
|
}
|
||||||
|
|
||||||
|
type DecrpmResponse = Extract<TerminalResponse, { type: 'decrpm' }>
|
||||||
|
type Da1Response = Extract<TerminalResponse, { type: 'da1' }>
|
||||||
|
type Da2Response = Extract<TerminalResponse, { type: 'da2' }>
|
||||||
|
type KittyResponse = Extract<TerminalResponse, { type: 'kittyKeyboard' }>
|
||||||
|
type CursorPosResponse = Extract<TerminalResponse, { type: 'cursorPosition' }>
|
||||||
|
type OscResponse = Extract<TerminalResponse, { type: 'osc' }>
|
||||||
|
type XtversionResponse = Extract<TerminalResponse, { type: 'xtversion' }>
|
||||||
|
|
||||||
|
// -- Query builders --
|
||||||
|
|
||||||
|
/** DECRQM: request DEC private mode status (CSI ? mode $ p).
|
||||||
|
* Terminal replies with DECRPM (CSI ? mode ; status $ y) or ignores. */
|
||||||
|
export function decrqm(mode: number): TerminalQuery<DecrpmResponse> {
|
||||||
|
return {
|
||||||
|
request: csi(`?${mode}$p`),
|
||||||
|
match: (r): r is DecrpmResponse => r.type === 'decrpm' && r.mode === mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Primary Device Attributes query (CSI c). Every terminal answers this —
|
||||||
|
* used internally by flush() as a universal sentinel. Call directly if
|
||||||
|
* you want the DA1 params. */
|
||||||
|
export function da1(): TerminalQuery<Da1Response> {
|
||||||
|
return {
|
||||||
|
request: csi('c'),
|
||||||
|
match: (r): r is Da1Response => r.type === 'da1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Secondary Device Attributes query (CSI > c). Returns terminal version. */
|
||||||
|
export function da2(): TerminalQuery<Da2Response> {
|
||||||
|
return {
|
||||||
|
request: csi('>c'),
|
||||||
|
match: (r): r is Da2Response => r.type === 'da2'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query current Kitty keyboard protocol flags (CSI ? u).
|
||||||
|
* Terminal replies with CSI ? flags u or ignores. */
|
||||||
|
export function kittyKeyboard(): TerminalQuery<KittyResponse> {
|
||||||
|
return {
|
||||||
|
request: csi('?u'),
|
||||||
|
match: (r): r is KittyResponse => r.type === 'kittyKeyboard'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DECXCPR: request cursor position with DEC-private marker (CSI ? 6 n).
|
||||||
|
* Terminal replies with CSI ? row ; col R. The `?` marker is critical —
|
||||||
|
* the plain DSR form (CSI 6 n → CSI row;col R) is ambiguous with
|
||||||
|
* modified F3 keys (Shift+F3 = CSI 1;2 R, etc.). */
|
||||||
|
export function cursorPosition(): TerminalQuery<CursorPosResponse> {
|
||||||
|
return {
|
||||||
|
request: csi('?6n'),
|
||||||
|
match: (r): r is CursorPosResponse => r.type === 'cursorPosition'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OSC dynamic color query (e.g. OSC 11 for bg color, OSC 10 for fg).
|
||||||
|
* The `?` data slot asks the terminal to reply with the current value. */
|
||||||
|
export function oscColor(code: number): TerminalQuery<OscResponse> {
|
||||||
|
return {
|
||||||
|
request: osc(code, '?'),
|
||||||
|
match: (r): r is OscResponse => r.type === 'osc' && r.code === code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** XTVERSION: request terminal name/version (CSI > 0 q).
|
||||||
|
* Terminal replies with DCS > | name ST (e.g. "xterm.js(5.5.0)") or ignores.
|
||||||
|
* This survives SSH — the query goes through the pty, not the environment,
|
||||||
|
* so it identifies the *client* terminal even when TERM_PROGRAM isn't
|
||||||
|
* forwarded. Used to detect xterm.js for wheel-scroll compensation. */
|
||||||
|
export function xtversion(): TerminalQuery<XtversionResponse> {
|
||||||
|
return {
|
||||||
|
request: csi('>0q'),
|
||||||
|
match: (r): r is XtversionResponse => r.type === 'xtversion'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Querier --
|
||||||
|
|
||||||
|
/** Sentinel request sequence (DA1). Kept internal; flush() writes it. */
|
||||||
|
const SENTINEL = csi('c')
|
||||||
|
|
||||||
|
type Pending =
|
||||||
|
| {
|
||||||
|
kind: 'query'
|
||||||
|
match: (r: TerminalResponse) => boolean
|
||||||
|
resolve: (r: TerminalResponse | undefined) => void
|
||||||
|
}
|
||||||
|
| { kind: 'sentinel'; resolve: () => void }
|
||||||
|
|
||||||
|
export class TerminalQuerier {
|
||||||
|
/**
|
||||||
|
* Interleaved queue of queries and sentinels in send order. Terminals
|
||||||
|
* respond in order, so each flush() barrier only drains queries queued
|
||||||
|
* before it — concurrent batches from independent callers stay isolated.
|
||||||
|
*/
|
||||||
|
private queue: Pending[] = []
|
||||||
|
|
||||||
|
constructor(private stdout: NodeJS.WriteStream) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a query and wait for its response.
|
||||||
|
*
|
||||||
|
* Resolves with the response when `query.match` matches an incoming
|
||||||
|
* TerminalResponse, or with `undefined` when a flush() sentinel arrives
|
||||||
|
* before any matching response (meaning the terminal ignored the query).
|
||||||
|
*
|
||||||
|
* Never rejects; never times out on its own. If you never call flush()
|
||||||
|
* and the terminal doesn't respond, the promise remains pending.
|
||||||
|
*/
|
||||||
|
send<T extends TerminalResponse>(query: TerminalQuery<T>): Promise<T | undefined> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
this.queue.push({
|
||||||
|
kind: 'query',
|
||||||
|
match: query.match,
|
||||||
|
resolve: r => resolve(r as T | undefined)
|
||||||
|
})
|
||||||
|
this.stdout.write(query.request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the DA1 sentinel. Resolves when DA1's response arrives.
|
||||||
|
*
|
||||||
|
* As a side effect, all queries still pending when DA1 arrives are
|
||||||
|
* resolved with `undefined` (terminal didn't respond → doesn't support
|
||||||
|
* the query). This is the barrier that makes send() timeout-free.
|
||||||
|
*
|
||||||
|
* Safe to call with no pending queries — still waits for a round-trip.
|
||||||
|
*/
|
||||||
|
flush(): Promise<void> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
this.queue.push({ kind: 'sentinel', resolve })
|
||||||
|
this.stdout.write(SENTINEL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a response parsed from stdin. Called by App.tsx's
|
||||||
|
* processKeysInBatch for every `kind: 'response'` item.
|
||||||
|
*
|
||||||
|
* Matching strategy:
|
||||||
|
* - First, try to match a pending query (FIFO, first match wins).
|
||||||
|
* This lets callers send(da1()) explicitly if they want the DA1
|
||||||
|
* params — a separate DA1 write means the terminal sends TWO DA1
|
||||||
|
* responses. The first matches the explicit query; the second
|
||||||
|
* (unmatched) fires the sentinel.
|
||||||
|
* - Otherwise, if this is a DA1, fire the FIRST pending sentinel:
|
||||||
|
* resolve any queries queued before that sentinel with undefined
|
||||||
|
* (the terminal answered DA1 without answering them → unsupported)
|
||||||
|
* and signal its flush() completion. Only draining up to the first
|
||||||
|
* sentinel keeps later batches intact when multiple callers have
|
||||||
|
* concurrent queries in flight.
|
||||||
|
* - Unsolicited responses (no match, no sentinel) are silently dropped.
|
||||||
|
*/
|
||||||
|
onResponse(r: TerminalResponse): void {
|
||||||
|
const idx = this.queue.findIndex(p => p.kind === 'query' && p.match(r))
|
||||||
|
|
||||||
|
if (idx !== -1) {
|
||||||
|
const [q] = this.queue.splice(idx, 1)
|
||||||
|
|
||||||
|
if (q?.kind === 'query') {
|
||||||
|
q.resolve(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.type === 'da1') {
|
||||||
|
const s = this.queue.findIndex(p => p.kind === 'sentinel')
|
||||||
|
|
||||||
|
if (s === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of this.queue.splice(0, s + 1)) {
|
||||||
|
if (p.kind === 'query') {
|
||||||
|
p.resolve(undefined)
|
||||||
|
} else {
|
||||||
|
p.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
282
ui-tui/packages/hermes-ink/src/ink/terminal.ts
Normal file
282
ui-tui/packages/hermes-ink/src/ink/terminal.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
import type { Writable } from 'stream'
|
||||||
|
|
||||||
|
import { coerce } from 'semver'
|
||||||
|
|
||||||
|
import { env } from '../utils/env.js'
|
||||||
|
import { gte } from '../utils/semver.js'
|
||||||
|
|
||||||
|
import { getClearTerminalSequence } from './clearTerminal.js'
|
||||||
|
import type { Diff } from './frame.js'
|
||||||
|
import { cursorMove, cursorTo, eraseLines } from './termio/csi.js'
|
||||||
|
import { BSU, ESU, HIDE_CURSOR, SHOW_CURSOR } from './termio/dec.js'
|
||||||
|
import { link } from './termio/osc.js'
|
||||||
|
|
||||||
|
export type Progress = {
|
||||||
|
state: 'running' | 'completed' | 'error' | 'indeterminate'
|
||||||
|
percentage?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the terminal supports OSC 9;4 progress reporting.
|
||||||
|
* Supported terminals:
|
||||||
|
* - ConEmu (Windows) - all versions
|
||||||
|
* - Ghostty 1.2.0+
|
||||||
|
* - iTerm2 3.6.6+
|
||||||
|
*
|
||||||
|
* Note: Windows Terminal interprets OSC 9;4 as notifications, not progress.
|
||||||
|
*/
|
||||||
|
export function isProgressReportingAvailable(): boolean {
|
||||||
|
// Only available if we have a TTY (not piped)
|
||||||
|
if (!process.stdout.isTTY) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly exclude Windows Terminal, which interprets OSC 9;4 as
|
||||||
|
// notifications rather than progress indicators
|
||||||
|
if (process.env.WT_SESSION) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConEmu supports OSC 9;4 for progress (all versions)
|
||||||
|
if (process.env.ConEmuANSI || process.env.ConEmuPID || process.env.ConEmuTask) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = coerce(process.env.TERM_PROGRAM_VERSION)
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ghostty 1.2.0+ supports OSC 9;4 for progress
|
||||||
|
// https://ghostty.org/docs/install/release-notes/1-2-0
|
||||||
|
if (process.env.TERM_PROGRAM === 'ghostty') {
|
||||||
|
return gte(version.version, '1.2.0')
|
||||||
|
}
|
||||||
|
|
||||||
|
// iTerm2 3.6.6+ supports OSC 9;4 for progress
|
||||||
|
// https://iterm2.com/downloads.html
|
||||||
|
if (process.env.TERM_PROGRAM === 'iTerm.app') {
|
||||||
|
return gte(version.version, '3.6.6')
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the terminal supports DEC mode 2026 (synchronized output).
|
||||||
|
* When supported, BSU/ESU sequences prevent visible flicker during redraws.
|
||||||
|
*/
|
||||||
|
export function isSynchronizedOutputSupported(): boolean {
|
||||||
|
// tmux parses and proxies every byte but doesn't implement DEC 2026.
|
||||||
|
// BSU/ESU pass through to the outer terminal but tmux has already
|
||||||
|
// broken atomicity by chunking. Skip to save 16 bytes/frame + parser work.
|
||||||
|
if (process.env.TMUX) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const termProgram = process.env.TERM_PROGRAM
|
||||||
|
const term = process.env.TERM
|
||||||
|
|
||||||
|
// Modern terminals with known DEC 2026 support
|
||||||
|
if (
|
||||||
|
termProgram === 'iTerm.app' ||
|
||||||
|
termProgram === 'WezTerm' ||
|
||||||
|
termProgram === 'WarpTerminal' ||
|
||||||
|
termProgram === 'ghostty' ||
|
||||||
|
termProgram === 'contour' ||
|
||||||
|
termProgram === 'vscode' ||
|
||||||
|
termProgram === 'alacritty'
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// kitty sets TERM=xterm-kitty or KITTY_WINDOW_ID
|
||||||
|
if (term?.includes('kitty') || process.env.KITTY_WINDOW_ID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ghostty may set TERM=xterm-ghostty without TERM_PROGRAM
|
||||||
|
if (term === 'xterm-ghostty') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// foot sets TERM=foot or TERM=foot-extra
|
||||||
|
if (term?.startsWith('foot')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alacritty may set TERM containing 'alacritty'
|
||||||
|
if (term?.includes('alacritty')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zed uses the alacritty_terminal crate which supports DEC 2026
|
||||||
|
if (process.env.ZED_TERM) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows Terminal
|
||||||
|
if (process.env.WT_SESSION) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// VTE-based terminals (GNOME Terminal, Tilix, etc.) since VTE 0.68
|
||||||
|
const vteVersion = process.env.VTE_VERSION
|
||||||
|
|
||||||
|
if (vteVersion) {
|
||||||
|
const version = parseInt(vteVersion, 10)
|
||||||
|
|
||||||
|
if (version >= 6800) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- XTVERSION-detected terminal name (populated async at startup) --
|
||||||
|
//
|
||||||
|
// TERM_PROGRAM is not forwarded over SSH by default, so env-based detection
|
||||||
|
// fails when claude runs remotely inside a VS Code integrated terminal.
|
||||||
|
// XTVERSION (CSI > 0 q → DCS > | name ST) goes through the pty — the query
|
||||||
|
// reaches the *client* terminal and the reply comes back through stdin.
|
||||||
|
// App.tsx fires the query when raw mode enables; setXtversionName() is called
|
||||||
|
// from the response handler. Readers should treat undefined as "not yet known"
|
||||||
|
// and fall back to env-var detection.
|
||||||
|
|
||||||
|
let xtversionName: string | undefined
|
||||||
|
|
||||||
|
/** Record the XTVERSION response. Called once from App.tsx when the reply
|
||||||
|
* arrives on stdin. No-op if already set (defend against re-probe). */
|
||||||
|
export function setXtversionName(name: string): void {
|
||||||
|
if (xtversionName === undefined) {
|
||||||
|
xtversionName = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if running in an xterm.js-based terminal (VS Code, Cursor, Windsurf
|
||||||
|
* integrated terminals). Combines TERM_PROGRAM env check (fast, sync, but
|
||||||
|
* not forwarded over SSH) with the XTVERSION probe result (async, survives
|
||||||
|
* SSH — query/reply goes through the pty). Early calls may miss the probe
|
||||||
|
* reply — call lazily (e.g. in an event handler) if SSH detection matters. */
|
||||||
|
export function isXtermJs(): boolean {
|
||||||
|
if (process.env.TERM_PROGRAM === 'vscode') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return xtversionName?.startsWith('xterm.js') ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminals known to correctly implement the Kitty keyboard protocol
|
||||||
|
// (CSI >1u) and/or xterm modifyOtherKeys (CSI >4;2m) for ctrl+shift+<letter>
|
||||||
|
// disambiguation. We previously enabled unconditionally (#23350), assuming
|
||||||
|
// terminals silently ignore unknown CSI — but some terminals honor the enable
|
||||||
|
// and emit codepoints our input parser doesn't handle (notably over SSH and
|
||||||
|
// in xterm.js-based terminals like VS Code). tmux is allowlisted because it
|
||||||
|
// accepts modifyOtherKeys and doesn't forward the kitty sequence to the outer
|
||||||
|
// terminal.
|
||||||
|
const EXTENDED_KEYS_TERMINALS = ['iTerm.app', 'kitty', 'WezTerm', 'ghostty', 'tmux', 'windows-terminal']
|
||||||
|
|
||||||
|
/** True if this terminal correctly handles extended key reporting
|
||||||
|
* (Kitty keyboard protocol + xterm modifyOtherKeys). */
|
||||||
|
export function supportsExtendedKeys(): boolean {
|
||||||
|
return EXTENDED_KEYS_TERMINALS.includes(env.terminal ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the terminal scrolls the viewport when it receives cursor-up
|
||||||
|
* sequences that reach above the visible area. On Windows, conhost's
|
||||||
|
* SetConsoleCursorPosition follows the cursor into scrollback
|
||||||
|
* (microsoft/terminal#14774), yanking users to the top of their buffer
|
||||||
|
* mid-stream. WT_SESSION catches WSL-in-Windows-Terminal where platform
|
||||||
|
* is linux but output still routes through conhost. */
|
||||||
|
export function hasCursorUpViewportYankBug(): boolean {
|
||||||
|
return process.platform === 'win32' || !!process.env.WT_SESSION
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed once at module load — terminal capabilities don't change mid-session.
|
||||||
|
// Exported so callers can pass a sync-skip hint gated to specific modes.
|
||||||
|
export const SYNC_OUTPUT_SUPPORTED = isSynchronizedOutputSupported()
|
||||||
|
|
||||||
|
export type Terminal = {
|
||||||
|
stdout: Writable
|
||||||
|
stderr: Writable
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeDiffToTerminal(terminal: Terminal, diff: Diff, skipSyncMarkers = false): void {
|
||||||
|
// No output if there are no patches
|
||||||
|
if (diff.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// BSU/ESU wrapping is opt-out to keep main-screen behavior unchanged.
|
||||||
|
// Callers pass skipSyncMarkers=true when the terminal doesn't support
|
||||||
|
// DEC 2026 (e.g. tmux) AND the cost matters (high-frequency alt-screen).
|
||||||
|
const useSync = !skipSyncMarkers
|
||||||
|
|
||||||
|
// Buffer all writes into a single string to avoid multiple write calls
|
||||||
|
let buffer = useSync ? BSU : ''
|
||||||
|
|
||||||
|
for (const patch of diff) {
|
||||||
|
switch (patch.type) {
|
||||||
|
case 'stdout':
|
||||||
|
buffer += patch.content
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'clear':
|
||||||
|
if (patch.count > 0) {
|
||||||
|
buffer += eraseLines(patch.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'clearTerminal':
|
||||||
|
buffer += getClearTerminalSequence()
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'cursorHide':
|
||||||
|
buffer += HIDE_CURSOR
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'cursorShow':
|
||||||
|
buffer += SHOW_CURSOR
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'cursorMove':
|
||||||
|
buffer += cursorMove(patch.x, patch.y)
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'cursorTo':
|
||||||
|
buffer += cursorTo(patch.col)
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'carriageReturn':
|
||||||
|
buffer += '\r'
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'hyperlink':
|
||||||
|
buffer += link(patch.uri)
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'styleStr':
|
||||||
|
buffer += patch.str
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add synchronized update end and flush buffer
|
||||||
|
if (useSync) {
|
||||||
|
buffer += ESU
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal.stdout.write(buffer)
|
||||||
|
}
|
||||||
42
ui-tui/packages/hermes-ink/src/ink/termio.ts
Normal file
42
ui-tui/packages/hermes-ink/src/ink/termio.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* ANSI Parser Module
|
||||||
|
*
|
||||||
|
* A semantic ANSI escape sequence parser inspired by ghostty, tmux, and iTerm2.
|
||||||
|
*
|
||||||
|
* Key features:
|
||||||
|
* - Semantic output: produces structured actions, not string tokens
|
||||||
|
* - Streaming: can parse input incrementally via Parser class
|
||||||
|
* - Style tracking: maintains text style state across parse calls
|
||||||
|
* - Comprehensive: supports SGR, CSI, OSC, ESC sequences
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
*
|
||||||
|
* ```typescript
|
||||||
|
* import { Parser } from './termio.js'
|
||||||
|
*
|
||||||
|
* const parser = new Parser()
|
||||||
|
* const actions = parser.feed('\x1b[31mred\x1b[0m')
|
||||||
|
* // => [{ type: 'text', graphemes: [...], style: { fg: { type: 'named', name: 'red' }, ... } }]
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Parser
|
||||||
|
export { Parser } from './termio/parser.js'
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
Action,
|
||||||
|
Color,
|
||||||
|
CursorAction,
|
||||||
|
CursorDirection,
|
||||||
|
EraseAction,
|
||||||
|
Grapheme,
|
||||||
|
LinkAction,
|
||||||
|
ModeAction,
|
||||||
|
NamedColor,
|
||||||
|
ScrollAction,
|
||||||
|
TextSegment,
|
||||||
|
TextStyle,
|
||||||
|
TitleAction,
|
||||||
|
UnderlineStyle
|
||||||
|
} from './termio/types.js'
|
||||||
|
export { colorsEqual, defaultStyle, stylesEqual } from './termio/types.js'
|
||||||
75
ui-tui/packages/hermes-ink/src/ink/termio/ansi.ts
Normal file
75
ui-tui/packages/hermes-ink/src/ink/termio/ansi.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
/**
|
||||||
|
* ANSI Control Characters and Escape Sequence Introducers
|
||||||
|
*
|
||||||
|
* Based on ECMA-48 / ANSI X3.64 standards.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* C0 (7-bit) control characters
|
||||||
|
*/
|
||||||
|
export const C0 = {
|
||||||
|
NUL: 0x00,
|
||||||
|
SOH: 0x01,
|
||||||
|
STX: 0x02,
|
||||||
|
ETX: 0x03,
|
||||||
|
EOT: 0x04,
|
||||||
|
ENQ: 0x05,
|
||||||
|
ACK: 0x06,
|
||||||
|
BEL: 0x07,
|
||||||
|
BS: 0x08,
|
||||||
|
HT: 0x09,
|
||||||
|
LF: 0x0a,
|
||||||
|
VT: 0x0b,
|
||||||
|
FF: 0x0c,
|
||||||
|
CR: 0x0d,
|
||||||
|
SO: 0x0e,
|
||||||
|
SI: 0x0f,
|
||||||
|
DLE: 0x10,
|
||||||
|
DC1: 0x11,
|
||||||
|
DC2: 0x12,
|
||||||
|
DC3: 0x13,
|
||||||
|
DC4: 0x14,
|
||||||
|
NAK: 0x15,
|
||||||
|
SYN: 0x16,
|
||||||
|
ETB: 0x17,
|
||||||
|
CAN: 0x18,
|
||||||
|
EM: 0x19,
|
||||||
|
SUB: 0x1a,
|
||||||
|
ESC: 0x1b,
|
||||||
|
FS: 0x1c,
|
||||||
|
GS: 0x1d,
|
||||||
|
RS: 0x1e,
|
||||||
|
US: 0x1f,
|
||||||
|
DEL: 0x7f
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// String constants for output generation
|
||||||
|
export const ESC = '\x1b'
|
||||||
|
export const BEL = '\x07'
|
||||||
|
export const SEP = ';'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape sequence type introducers (byte after ESC)
|
||||||
|
*/
|
||||||
|
export const ESC_TYPE = {
|
||||||
|
CSI: 0x5b, // [ - Control Sequence Introducer
|
||||||
|
OSC: 0x5d, // ] - Operating System Command
|
||||||
|
DCS: 0x50, // P - Device Control String
|
||||||
|
APC: 0x5f, // _ - Application Program Command
|
||||||
|
PM: 0x5e, // ^ - Privacy Message
|
||||||
|
SOS: 0x58, // X - Start of String
|
||||||
|
ST: 0x5c // \ - String Terminator
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** Check if a byte is a C0 control character */
|
||||||
|
export function isC0(byte: number): boolean {
|
||||||
|
return byte < 0x20 || byte === 0x7f
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a byte is an ESC sequence final byte (0-9, :, ;, <, =, >, ?, @ through ~)
|
||||||
|
* ESC sequences have a wider final byte range than CSI
|
||||||
|
*/
|
||||||
|
export function isEscFinal(byte: number): boolean {
|
||||||
|
return byte >= 0x30 && byte <= 0x7e
|
||||||
|
}
|
||||||
334
ui-tui/packages/hermes-ink/src/ink/termio/csi.ts
Normal file
334
ui-tui/packages/hermes-ink/src/ink/termio/csi.ts
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
/**
|
||||||
|
* CSI (Control Sequence Introducer) Types
|
||||||
|
*
|
||||||
|
* Enums and types for CSI command parameters.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ESC, ESC_TYPE, SEP } from './ansi.js'
|
||||||
|
|
||||||
|
export const CSI_PREFIX = ESC + String.fromCharCode(ESC_TYPE.CSI)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSI parameter byte ranges
|
||||||
|
*/
|
||||||
|
export const CSI_RANGE = {
|
||||||
|
PARAM_START: 0x30,
|
||||||
|
PARAM_END: 0x3f,
|
||||||
|
INTERMEDIATE_START: 0x20,
|
||||||
|
INTERMEDIATE_END: 0x2f,
|
||||||
|
FINAL_START: 0x40,
|
||||||
|
FINAL_END: 0x7e
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** Check if a byte is a CSI parameter byte */
|
||||||
|
export function isCSIParam(byte: number): boolean {
|
||||||
|
return byte >= CSI_RANGE.PARAM_START && byte <= CSI_RANGE.PARAM_END
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a byte is a CSI intermediate byte */
|
||||||
|
export function isCSIIntermediate(byte: number): boolean {
|
||||||
|
return byte >= CSI_RANGE.INTERMEDIATE_START && byte <= CSI_RANGE.INTERMEDIATE_END
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a byte is a CSI final byte (@ through ~) */
|
||||||
|
export function isCSIFinal(byte: number): boolean {
|
||||||
|
return byte >= CSI_RANGE.FINAL_START && byte <= CSI_RANGE.FINAL_END
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a CSI sequence: ESC [ p1;p2;...;pN final
|
||||||
|
* Single arg: treated as raw body
|
||||||
|
* Multiple args: last is final byte, rest are params joined by ;
|
||||||
|
*/
|
||||||
|
export function csi(...args: (string | number)[]): string {
|
||||||
|
if (args.length === 0) {
|
||||||
|
return CSI_PREFIX
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.length === 1) {
|
||||||
|
return `${CSI_PREFIX}${args[0]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = args.slice(0, -1)
|
||||||
|
const final = args[args.length - 1]
|
||||||
|
|
||||||
|
return `${CSI_PREFIX}${params.join(SEP)}${final}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSI final bytes - the command identifier
|
||||||
|
*/
|
||||||
|
export const CSI = {
|
||||||
|
// Cursor movement
|
||||||
|
CUU: 0x41, // A - Cursor Up
|
||||||
|
CUD: 0x42, // B - Cursor Down
|
||||||
|
CUF: 0x43, // C - Cursor Forward
|
||||||
|
CUB: 0x44, // D - Cursor Back
|
||||||
|
CNL: 0x45, // E - Cursor Next Line
|
||||||
|
CPL: 0x46, // F - Cursor Previous Line
|
||||||
|
CHA: 0x47, // G - Cursor Horizontal Absolute
|
||||||
|
CUP: 0x48, // H - Cursor Position
|
||||||
|
CHT: 0x49, // I - Cursor Horizontal Tab
|
||||||
|
VPA: 0x64, // d - Vertical Position Absolute
|
||||||
|
HVP: 0x66, // f - Horizontal Vertical Position
|
||||||
|
|
||||||
|
// Erase
|
||||||
|
ED: 0x4a, // J - Erase in Display
|
||||||
|
EL: 0x4b, // K - Erase in Line
|
||||||
|
ECH: 0x58, // X - Erase Character
|
||||||
|
|
||||||
|
// Insert/Delete
|
||||||
|
IL: 0x4c, // L - Insert Lines
|
||||||
|
DL: 0x4d, // M - Delete Lines
|
||||||
|
ICH: 0x40, // @ - Insert Characters
|
||||||
|
DCH: 0x50, // P - Delete Characters
|
||||||
|
|
||||||
|
// Scroll
|
||||||
|
SU: 0x53, // S - Scroll Up
|
||||||
|
SD: 0x54, // T - Scroll Down
|
||||||
|
|
||||||
|
// Modes
|
||||||
|
SM: 0x68, // h - Set Mode
|
||||||
|
RM: 0x6c, // l - Reset Mode
|
||||||
|
|
||||||
|
// SGR
|
||||||
|
SGR: 0x6d, // m - Select Graphic Rendition
|
||||||
|
|
||||||
|
// Other
|
||||||
|
DSR: 0x6e, // n - Device Status Report
|
||||||
|
DECSCUSR: 0x71, // q - Set Cursor Style (with space intermediate)
|
||||||
|
DECSTBM: 0x72, // r - Set Top and Bottom Margins
|
||||||
|
SCOSC: 0x73, // s - Save Cursor Position
|
||||||
|
SCORC: 0x75, // u - Restore Cursor Position
|
||||||
|
CBT: 0x5a // Z - Cursor Backward Tabulation
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erase in Display regions (ED command parameter)
|
||||||
|
*/
|
||||||
|
export const ERASE_DISPLAY = ['toEnd', 'toStart', 'all', 'scrollback'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erase in Line regions (EL command parameter)
|
||||||
|
*/
|
||||||
|
export const ERASE_LINE_REGION = ['toEnd', 'toStart', 'all'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cursor styles (DECSCUSR)
|
||||||
|
*/
|
||||||
|
export type CursorStyle = 'block' | 'underline' | 'bar'
|
||||||
|
|
||||||
|
export const CURSOR_STYLES: Array<{ style: CursorStyle; blinking: boolean }> = [
|
||||||
|
{ style: 'block', blinking: true }, // 0 - default
|
||||||
|
{ style: 'block', blinking: true }, // 1
|
||||||
|
{ style: 'block', blinking: false }, // 2
|
||||||
|
{ style: 'underline', blinking: true }, // 3
|
||||||
|
{ style: 'underline', blinking: false }, // 4
|
||||||
|
{ style: 'bar', blinking: true }, // 5
|
||||||
|
{ style: 'bar', blinking: false } // 6
|
||||||
|
]
|
||||||
|
|
||||||
|
// Cursor movement generators
|
||||||
|
|
||||||
|
/** Move cursor up n lines (CSI n A) */
|
||||||
|
export function cursorUp(n = 1): string {
|
||||||
|
return n === 0 ? '' : csi(n, 'A')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move cursor down n lines (CSI n B) */
|
||||||
|
export function cursorDown(n = 1): string {
|
||||||
|
return n === 0 ? '' : csi(n, 'B')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move cursor forward n columns (CSI n C) */
|
||||||
|
export function cursorForward(n = 1): string {
|
||||||
|
return n === 0 ? '' : csi(n, 'C')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move cursor back n columns (CSI n D) */
|
||||||
|
export function cursorBack(n = 1): string {
|
||||||
|
return n === 0 ? '' : csi(n, 'D')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move cursor to column n (1-indexed) (CSI n G) */
|
||||||
|
export function cursorTo(col: number): string {
|
||||||
|
return csi(col, 'G')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move cursor to column 1 (CSI G) */
|
||||||
|
export const CURSOR_LEFT = csi('G')
|
||||||
|
|
||||||
|
/** Move cursor to row, col (1-indexed) (CSI row ; col H) */
|
||||||
|
export function cursorPosition(row: number, col: number): string {
|
||||||
|
return csi(row, col, 'H')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move cursor to home position (CSI H) */
|
||||||
|
export const CURSOR_HOME = csi('H')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move cursor relative to current position
|
||||||
|
* Positive x = right, negative x = left
|
||||||
|
* Positive y = down, negative y = up
|
||||||
|
*/
|
||||||
|
export function cursorMove(x: number, y: number): string {
|
||||||
|
let result = ''
|
||||||
|
|
||||||
|
// Horizontal first (matches ansi-escapes behavior)
|
||||||
|
if (x < 0) {
|
||||||
|
result += cursorBack(-x)
|
||||||
|
} else if (x > 0) {
|
||||||
|
result += cursorForward(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then vertical
|
||||||
|
if (y < 0) {
|
||||||
|
result += cursorUp(-y)
|
||||||
|
} else if (y > 0) {
|
||||||
|
result += cursorDown(y)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save/restore cursor position
|
||||||
|
|
||||||
|
/** Save cursor position (CSI s) */
|
||||||
|
export const CURSOR_SAVE = csi('s')
|
||||||
|
|
||||||
|
/** Restore cursor position (CSI u) */
|
||||||
|
export const CURSOR_RESTORE = csi('u')
|
||||||
|
|
||||||
|
// Erase generators
|
||||||
|
|
||||||
|
/** Erase from cursor to end of line (CSI K) */
|
||||||
|
export function eraseToEndOfLine(): string {
|
||||||
|
return csi('K')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erase from cursor to start of line (CSI 1 K) */
|
||||||
|
export function eraseToStartOfLine(): string {
|
||||||
|
return csi(1, 'K')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erase entire line (CSI 2 K) */
|
||||||
|
export function eraseLine(): string {
|
||||||
|
return csi(2, 'K')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erase entire line - constant form */
|
||||||
|
export const ERASE_LINE = csi(2, 'K')
|
||||||
|
|
||||||
|
/** Erase from cursor to end of screen (CSI J) */
|
||||||
|
export function eraseToEndOfScreen(): string {
|
||||||
|
return csi('J')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erase from cursor to start of screen (CSI 1 J) */
|
||||||
|
export function eraseToStartOfScreen(): string {
|
||||||
|
return csi(1, 'J')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erase entire screen (CSI 2 J) */
|
||||||
|
export function eraseScreen(): string {
|
||||||
|
return csi(2, 'J')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erase entire screen - constant form */
|
||||||
|
export const ERASE_SCREEN = csi(2, 'J')
|
||||||
|
|
||||||
|
/** Erase scrollback buffer (CSI 3 J) */
|
||||||
|
export const ERASE_SCROLLBACK = csi(3, 'J')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erase n lines starting from cursor line, moving cursor up
|
||||||
|
* This erases each line and moves up, ending at column 1
|
||||||
|
*/
|
||||||
|
export function eraseLines(n: number): string {
|
||||||
|
if (n <= 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = ''
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
result += ERASE_LINE
|
||||||
|
|
||||||
|
if (i < n - 1) {
|
||||||
|
result += cursorUp(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result += CURSOR_LEFT
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll
|
||||||
|
|
||||||
|
/** Scroll up n lines (CSI n S) */
|
||||||
|
export function scrollUp(n = 1): string {
|
||||||
|
return n === 0 ? '' : csi(n, 'S')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scroll down n lines (CSI n T) */
|
||||||
|
export function scrollDown(n = 1): string {
|
||||||
|
return n === 0 ? '' : csi(n, 'T')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set scroll region (DECSTBM, CSI top;bottom r). 1-indexed, inclusive. */
|
||||||
|
export function setScrollRegion(top: number, bottom: number): string {
|
||||||
|
return csi(top, bottom, 'r')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset scroll region to full screen (DECSTBM, CSI r). Homes the cursor. */
|
||||||
|
export const RESET_SCROLL_REGION = csi('r')
|
||||||
|
|
||||||
|
// Bracketed paste markers (input from terminal, not output)
|
||||||
|
// These are sent by the terminal to delimit pasted content when
|
||||||
|
// bracketed paste mode is enabled (via DEC mode 2004)
|
||||||
|
|
||||||
|
/** Sent by terminal before pasted content (CSI 200 ~) */
|
||||||
|
export const PASTE_START = csi('200~')
|
||||||
|
|
||||||
|
/** Sent by terminal after pasted content (CSI 201 ~) */
|
||||||
|
export const PASTE_END = csi('201~')
|
||||||
|
|
||||||
|
// Focus event markers (input from terminal, not output)
|
||||||
|
// These are sent by the terminal when focus changes while
|
||||||
|
// focus events mode is enabled (via DEC mode 1004)
|
||||||
|
|
||||||
|
/** Sent by terminal when it gains focus (CSI I) */
|
||||||
|
export const FOCUS_IN = csi('I')
|
||||||
|
|
||||||
|
/** Sent by terminal when it loses focus (CSI O) */
|
||||||
|
export const FOCUS_OUT = csi('O')
|
||||||
|
|
||||||
|
// Kitty keyboard protocol (CSI u)
|
||||||
|
// Enables enhanced key reporting with modifier information
|
||||||
|
// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable Kitty keyboard protocol with basic modifier reporting
|
||||||
|
* CSI > 1 u - pushes mode with flags=1 (disambiguate escape codes)
|
||||||
|
* This makes Shift+Enter send CSI 13;2 u instead of just CR
|
||||||
|
*/
|
||||||
|
export const ENABLE_KITTY_KEYBOARD = csi('>1u')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable Kitty keyboard protocol
|
||||||
|
* CSI < u - pops the keyboard mode stack
|
||||||
|
*/
|
||||||
|
export const DISABLE_KITTY_KEYBOARD = csi('<u')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable xterm modifyOtherKeys level 2.
|
||||||
|
* tmux accepts this (not the kitty stack) to enable extended keys — when
|
||||||
|
* extended-keys-format is csi-u, tmux then emits keys in kitty format.
|
||||||
|
*/
|
||||||
|
export const ENABLE_MODIFY_OTHER_KEYS = csi('>4;2m')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable xterm modifyOtherKeys (reset to default).
|
||||||
|
*/
|
||||||
|
export const DISABLE_MODIFY_OTHER_KEYS = csi('>4m')
|
||||||
54
ui-tui/packages/hermes-ink/src/ink/termio/dec.ts
Normal file
54
ui-tui/packages/hermes-ink/src/ink/termio/dec.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
/**
|
||||||
|
* DEC (Digital Equipment Corporation) Private Mode Sequences
|
||||||
|
*
|
||||||
|
* DEC private modes use CSI ? N h (set) and CSI ? N l (reset) format.
|
||||||
|
* These are terminal-specific extensions to the ANSI standard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { csi } from './csi.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEC private mode numbers
|
||||||
|
*/
|
||||||
|
export const DEC = {
|
||||||
|
CURSOR_VISIBLE: 25,
|
||||||
|
ALT_SCREEN: 47,
|
||||||
|
ALT_SCREEN_CLEAR: 1049,
|
||||||
|
MOUSE_NORMAL: 1000,
|
||||||
|
MOUSE_BUTTON: 1002,
|
||||||
|
MOUSE_ANY: 1003,
|
||||||
|
MOUSE_SGR: 1006,
|
||||||
|
FOCUS_EVENTS: 1004,
|
||||||
|
BRACKETED_PASTE: 2004,
|
||||||
|
SYNCHRONIZED_UPDATE: 2026
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** Generate CSI ? N h sequence (set mode) */
|
||||||
|
export function decset(mode: number): string {
|
||||||
|
return csi(`?${mode}h`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate CSI ? N l sequence (reset mode) */
|
||||||
|
export function decreset(mode: number): string {
|
||||||
|
return csi(`?${mode}l`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-generated sequences for common modes
|
||||||
|
export const BSU = decset(DEC.SYNCHRONIZED_UPDATE)
|
||||||
|
export const ESU = decreset(DEC.SYNCHRONIZED_UPDATE)
|
||||||
|
export const EBP = decset(DEC.BRACKETED_PASTE)
|
||||||
|
export const DBP = decreset(DEC.BRACKETED_PASTE)
|
||||||
|
export const EFE = decset(DEC.FOCUS_EVENTS)
|
||||||
|
export const DFE = decreset(DEC.FOCUS_EVENTS)
|
||||||
|
export const SHOW_CURSOR = decset(DEC.CURSOR_VISIBLE)
|
||||||
|
export const HIDE_CURSOR = decreset(DEC.CURSOR_VISIBLE)
|
||||||
|
export const ENTER_ALT_SCREEN = decset(DEC.ALT_SCREEN_CLEAR)
|
||||||
|
export const EXIT_ALT_SCREEN = decreset(DEC.ALT_SCREEN_CLEAR)
|
||||||
|
// Mouse tracking: 1000 reports button press/release/wheel, 1002 adds drag
|
||||||
|
// events (button-motion), 1003 adds all-motion (no button held — for
|
||||||
|
// hover), 1006 uses SGR format (CSI < btn;col;row M/m) instead of legacy
|
||||||
|
// X10 bytes. Combined: wheel + click/drag for selection + hover.
|
||||||
|
export const ENABLE_MOUSE_TRACKING =
|
||||||
|
decset(DEC.MOUSE_NORMAL) + decset(DEC.MOUSE_BUTTON) + decset(DEC.MOUSE_ANY) + decset(DEC.MOUSE_SGR)
|
||||||
|
export const DISABLE_MOUSE_TRACKING =
|
||||||
|
decreset(DEC.MOUSE_SGR) + decreset(DEC.MOUSE_ANY) + decreset(DEC.MOUSE_BUTTON) + decreset(DEC.MOUSE_NORMAL)
|
||||||
69
ui-tui/packages/hermes-ink/src/ink/termio/esc.ts
Normal file
69
ui-tui/packages/hermes-ink/src/ink/termio/esc.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
/**
|
||||||
|
* ESC Sequence Parser
|
||||||
|
*
|
||||||
|
* Handles simple escape sequences: ESC + one or two characters
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Action } from './types.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a simple ESC sequence
|
||||||
|
*
|
||||||
|
* @param chars - Characters after ESC (not including ESC itself)
|
||||||
|
*/
|
||||||
|
export function parseEsc(chars: string): Action | null {
|
||||||
|
if (chars.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = chars[0]!
|
||||||
|
|
||||||
|
// Full reset (RIS)
|
||||||
|
if (first === 'c') {
|
||||||
|
return { type: 'reset' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor save (DECSC)
|
||||||
|
if (first === '7') {
|
||||||
|
return { type: 'cursor', action: { type: 'save' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor restore (DECRC)
|
||||||
|
if (first === '8') {
|
||||||
|
return { type: 'cursor', action: { type: 'restore' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index - move cursor down (IND)
|
||||||
|
if (first === 'D') {
|
||||||
|
return {
|
||||||
|
type: 'cursor',
|
||||||
|
action: { type: 'move', direction: 'down', count: 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse index - move cursor up (RI)
|
||||||
|
if (first === 'M') {
|
||||||
|
return {
|
||||||
|
type: 'cursor',
|
||||||
|
action: { type: 'move', direction: 'up', count: 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next line (NEL)
|
||||||
|
if (first === 'E') {
|
||||||
|
return { type: 'cursor', action: { type: 'nextLine', count: 1 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal tab set (HTS)
|
||||||
|
if (first === 'H') {
|
||||||
|
return null // Tab stop, not commonly needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charset selection (ESC ( X, ESC ) X, etc.) - silently ignore
|
||||||
|
if ('()'.includes(first) && chars.length >= 2) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown
|
||||||
|
return { type: 'unknown', sequence: `\x1b${chars}` }
|
||||||
|
}
|
||||||
554
ui-tui/packages/hermes-ink/src/ink/termio/osc.ts
Normal file
554
ui-tui/packages/hermes-ink/src/ink/termio/osc.ts
Normal file
|
|
@ -0,0 +1,554 @@
|
||||||
|
/**
|
||||||
|
* OSC (Operating System Command) Types and Parser
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Buffer } from 'buffer'
|
||||||
|
|
||||||
|
import { env } from '../../utils/env.js'
|
||||||
|
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
|
||||||
|
|
||||||
|
import { BEL, ESC, ESC_TYPE, SEP } from './ansi.js'
|
||||||
|
import type { Action, Color, TabStatusAction } from './types.js'
|
||||||
|
|
||||||
|
export const OSC_PREFIX = ESC + String.fromCharCode(ESC_TYPE.OSC)
|
||||||
|
|
||||||
|
/** String Terminator (ESC \) - alternative to BEL for terminating OSC */
|
||||||
|
export const ST = ESC + '\\'
|
||||||
|
|
||||||
|
/** Generate an OSC sequence: ESC ] p1;p2;...;pN <terminator>
|
||||||
|
* Uses ST terminator for Kitty (avoids beeps), BEL for others */
|
||||||
|
export function osc(...parts: (string | number)[]): string {
|
||||||
|
const terminator = env.terminal === 'kitty' ? ST : BEL
|
||||||
|
|
||||||
|
return `${OSC_PREFIX}${parts.join(SEP)}${terminator}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap an escape sequence for terminal multiplexer passthrough.
|
||||||
|
* tmux and GNU screen intercept escape sequences; DCS passthrough
|
||||||
|
* tunnels them to the outer terminal unmodified.
|
||||||
|
*
|
||||||
|
* tmux 3.3+ gates this behind `allow-passthrough` (default off). When off,
|
||||||
|
* tmux silently drops the whole DCS — no junk, no worse than unwrapped OSC.
|
||||||
|
* Users who want passthrough set it in their .tmux.conf; we don't mutate it.
|
||||||
|
*
|
||||||
|
* Do NOT wrap BEL: raw \x07 triggers tmux's bell-action (window flag);
|
||||||
|
* wrapped \x07 is opaque DCS payload and tmux never sees the bell.
|
||||||
|
*/
|
||||||
|
export function wrapForMultiplexer(sequence: string): string {
|
||||||
|
if (process.env['TMUX']) {
|
||||||
|
const escaped = sequence.replaceAll('\x1b', '\x1b\x1b')
|
||||||
|
|
||||||
|
return `\x1bPtmux;${escaped}\x1b\\`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env['STY']) {
|
||||||
|
return `\x1bP${sequence}\x1b\\`
|
||||||
|
}
|
||||||
|
|
||||||
|
return sequence
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which path setClipboard() will take, based on env state. Synchronous so
|
||||||
|
* callers can show an honest toast without awaiting the copy itself.
|
||||||
|
*
|
||||||
|
* - 'native': pbcopy (or equivalent) will run — high-confidence system
|
||||||
|
* clipboard write. tmux buffer may also be loaded as a bonus.
|
||||||
|
* - 'tmux-buffer': tmux load-buffer will run, but no native tool — paste
|
||||||
|
* with prefix+] works. System clipboard depends on tmux's set-clipboard
|
||||||
|
* option + outer terminal OSC 52 support; can't know from here.
|
||||||
|
* - 'osc52': only the raw OSC 52 sequence will be written to stdout.
|
||||||
|
* Best-effort; iTerm2 disables OSC 52 by default.
|
||||||
|
*
|
||||||
|
* pbcopy gating uses SSH_CONNECTION specifically, not SSH_TTY — tmux panes
|
||||||
|
* inherit SSH_TTY forever even after local reattach, but SSH_CONNECTION is
|
||||||
|
* in tmux's default update-environment set and gets cleared.
|
||||||
|
*/
|
||||||
|
export type ClipboardPath = 'native' | 'tmux-buffer' | 'osc52'
|
||||||
|
|
||||||
|
export function getClipboardPath(): ClipboardPath {
|
||||||
|
const nativeAvailable = process.platform === 'darwin' && !process.env['SSH_CONNECTION']
|
||||||
|
|
||||||
|
if (nativeAvailable) {
|
||||||
|
return 'native'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env['TMUX']) {
|
||||||
|
return 'tmux-buffer'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'osc52'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a payload in tmux's DCS passthrough: ESC P tmux ; <payload> ESC \
|
||||||
|
* tmux forwards the payload to the outer terminal, bypassing its own parser.
|
||||||
|
* Inner ESCs must be doubled. Requires `set -g allow-passthrough on` in
|
||||||
|
* ~/.tmux.conf; without it, tmux silently drops the whole DCS (no regression).
|
||||||
|
*/
|
||||||
|
function tmuxPassthrough(payload: string): string {
|
||||||
|
return `${ESC}Ptmux;${payload.replaceAll(ESC, ESC + ESC)}${ST}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load text into tmux's paste buffer via `tmux load-buffer`.
|
||||||
|
* -w (tmux 3.2+) propagates to the outer terminal's clipboard via tmux's
|
||||||
|
* own OSC 52 emission. -w is dropped for iTerm2: tmux's OSC 52 emission
|
||||||
|
* crashes the iTerm2 session over SSH.
|
||||||
|
*
|
||||||
|
* Returns true if the buffer was loaded successfully.
|
||||||
|
*/
|
||||||
|
export async function tmuxLoadBuffer(text: string): Promise<boolean> {
|
||||||
|
if (!process.env['TMUX']) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.env['LC_TERMINAL'] === 'iTerm2' ? ['load-buffer', '-'] : ['load-buffer', '-w', '-']
|
||||||
|
|
||||||
|
const { code } = await execFileNoThrow('tmux', args, {
|
||||||
|
input: text,
|
||||||
|
useCwd: false,
|
||||||
|
timeout: 2000
|
||||||
|
})
|
||||||
|
|
||||||
|
return code === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OSC 52 clipboard write: ESC ] 52 ; c ; <base64> BEL/ST
|
||||||
|
* 'c' selects the clipboard (vs 'p' for primary selection on X11).
|
||||||
|
*
|
||||||
|
* When inside tmux ($TMUX set), `tmux load-buffer -w -` is the primary
|
||||||
|
* path. tmux's buffer is always reachable — works over SSH, survives
|
||||||
|
* detach/reattach, immune to stale env vars. The -w flag (tmux 3.2+) tells
|
||||||
|
* tmux to also propagate to the outer terminal via its own OSC 52 path,
|
||||||
|
* which tmux wraps correctly for the attached client. On older tmux, -w is
|
||||||
|
* ignored and the buffer is still loaded. -w is dropped for iTerm2 (#22432)
|
||||||
|
* because tmux's own OSC 52 emission (empty selection param: ESC]52;;b64)
|
||||||
|
* crashes iTerm2 over SSH.
|
||||||
|
*
|
||||||
|
* After load-buffer succeeds, we ALSO return a DCS-passthrough-wrapped
|
||||||
|
* OSC 52 for the caller to write to stdout. Our sequence uses explicit `c`
|
||||||
|
* (not tmux's crashy empty-param variant), so it sidesteps the #22432 path.
|
||||||
|
* With `allow-passthrough on` + an OSC-52-capable outer terminal, selection
|
||||||
|
* reaches the system clipboard; with either off, tmux silently drops the
|
||||||
|
* DCS and prefix+] still works. See Greg Smith's "free pony" in
|
||||||
|
* https://anthropic.slack.com/archives/C07VBSHV7EV/p1773177228548119.
|
||||||
|
*
|
||||||
|
* If load-buffer fails entirely, fall through to raw OSC 52.
|
||||||
|
*
|
||||||
|
* Outside tmux, write raw OSC 52 to stdout (caller handles the write).
|
||||||
|
*
|
||||||
|
* Local (no SSH_CONNECTION): also shell out to a native clipboard utility.
|
||||||
|
* OSC 52 and tmux -w both depend on terminal settings — iTerm2 disables
|
||||||
|
* OSC 52 by default, VS Code shows a permission prompt on first use. Native
|
||||||
|
* utilities (pbcopy/wl-copy/xclip/xsel/clip.exe) always work locally. Over
|
||||||
|
* SSH these would write to the remote clipboard — OSC 52 is the right path there.
|
||||||
|
*
|
||||||
|
* Returns the sequence for the caller to write to stdout (raw OSC 52
|
||||||
|
* outside tmux, DCS-wrapped inside).
|
||||||
|
*/
|
||||||
|
export async function setClipboard(text: string): Promise<string> {
|
||||||
|
const b64 = Buffer.from(text, 'utf8').toString('base64')
|
||||||
|
const raw = osc(OSC.CLIPBOARD, 'c', b64)
|
||||||
|
|
||||||
|
// Native safety net — fire FIRST, before the tmux await, so a quick
|
||||||
|
// focus-switch after selecting doesn't race pbcopy. Previously this ran
|
||||||
|
// AFTER awaiting tmux load-buffer, adding ~50-100ms of subprocess latency
|
||||||
|
// before pbcopy even started — fast cmd+tab → paste would beat it
|
||||||
|
// (https://anthropic.slack.com/archives/C07VBSHV7EV/p1773943921788829).
|
||||||
|
// Gated on SSH_CONNECTION (not SSH_TTY) since tmux panes inherit SSH_TTY
|
||||||
|
// forever but SSH_CONNECTION is in tmux's default update-environment and
|
||||||
|
// clears on local attach. Fire-and-forget.
|
||||||
|
if (!process.env['SSH_CONNECTION']) {
|
||||||
|
copyNative(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmuxBufferLoaded = await tmuxLoadBuffer(text)
|
||||||
|
|
||||||
|
// Inner OSC uses BEL directly (not osc()) — ST's ESC would need doubling
|
||||||
|
// too, and BEL works everywhere for OSC 52.
|
||||||
|
if (tmuxBufferLoaded) {
|
||||||
|
return tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linux clipboard tool: undefined = not yet probed, null = none available.
|
||||||
|
// Probe order: wl-copy (Wayland) → xclip (X11) → xsel (X11 fallback).
|
||||||
|
// Cached after first attempt so repeated mouse-ups skip the probe chain.
|
||||||
|
let linuxCopy: 'wl-copy' | 'xclip' | 'xsel' | null | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shell out to a native clipboard utility as a safety net for OSC 52.
|
||||||
|
* Only called when not in an SSH session (over SSH, these would write to
|
||||||
|
* the remote machine's clipboard — OSC 52 is the right path there).
|
||||||
|
* Fire-and-forget: failures are silent since OSC 52 may have succeeded.
|
||||||
|
*/
|
||||||
|
function copyNative(text: string): void {
|
||||||
|
const opts = { input: text, useCwd: false, timeout: 2000 }
|
||||||
|
|
||||||
|
switch (process.platform) {
|
||||||
|
case 'darwin':
|
||||||
|
void execFileNoThrow('pbcopy', [], opts)
|
||||||
|
|
||||||
|
return
|
||||||
|
case 'linux': {
|
||||||
|
if (linuxCopy === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linuxCopy === 'wl-copy') {
|
||||||
|
void execFileNoThrow('wl-copy', [], opts)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linuxCopy === 'xclip') {
|
||||||
|
void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linuxCopy === 'xsel') {
|
||||||
|
void execFileNoThrow('xsel', ['--clipboard', '--input'], opts)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// First call: probe wl-copy (Wayland) then xclip/xsel (X11), cache winner.
|
||||||
|
void execFileNoThrow('wl-copy', [], opts).then(r => {
|
||||||
|
if (r.code === 0) {
|
||||||
|
linuxCopy = 'wl-copy'
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts).then(r2 => {
|
||||||
|
if (r2.code === 0) {
|
||||||
|
linuxCopy = 'xclip'
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void execFileNoThrow('xsel', ['--clipboard', '--input'], opts).then(r3 => {
|
||||||
|
linuxCopy = r3.code === 0 ? 'xsel' : null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'win32':
|
||||||
|
// clip.exe is always available on Windows. Unicode handling is
|
||||||
|
// imperfect (system locale encoding) but good enough for a fallback.
|
||||||
|
void execFileNoThrow('clip', [], opts)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal test-only */
|
||||||
|
export function _resetLinuxCopyCache(): void {
|
||||||
|
linuxCopy = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OSC command numbers
|
||||||
|
*/
|
||||||
|
export const OSC = {
|
||||||
|
SET_TITLE_AND_ICON: 0,
|
||||||
|
SET_ICON: 1,
|
||||||
|
SET_TITLE: 2,
|
||||||
|
SET_COLOR: 4,
|
||||||
|
SET_CWD: 7,
|
||||||
|
HYPERLINK: 8,
|
||||||
|
ITERM2: 9, // iTerm2 proprietary sequences
|
||||||
|
SET_FG_COLOR: 10,
|
||||||
|
SET_BG_COLOR: 11,
|
||||||
|
SET_CURSOR_COLOR: 12,
|
||||||
|
CLIPBOARD: 52,
|
||||||
|
KITTY: 99, // Kitty notification protocol
|
||||||
|
RESET_COLOR: 104,
|
||||||
|
RESET_FG_COLOR: 110,
|
||||||
|
RESET_BG_COLOR: 111,
|
||||||
|
RESET_CURSOR_COLOR: 112,
|
||||||
|
SEMANTIC_PROMPT: 133,
|
||||||
|
GHOSTTY: 777, // Ghostty notification protocol
|
||||||
|
TAB_STATUS: 21337 // Tab status extension
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an OSC sequence into an action
|
||||||
|
*
|
||||||
|
* @param content - The sequence content (without ESC ] and terminator)
|
||||||
|
*/
|
||||||
|
export function parseOSC(content: string): Action | null {
|
||||||
|
const semicolonIdx = content.indexOf(';')
|
||||||
|
const command = semicolonIdx >= 0 ? content.slice(0, semicolonIdx) : content
|
||||||
|
const data = semicolonIdx >= 0 ? content.slice(semicolonIdx + 1) : ''
|
||||||
|
|
||||||
|
const commandNum = parseInt(command, 10)
|
||||||
|
|
||||||
|
// Window/icon title
|
||||||
|
if (commandNum === OSC.SET_TITLE_AND_ICON) {
|
||||||
|
return { type: 'title', action: { type: 'both', title: data } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandNum === OSC.SET_ICON) {
|
||||||
|
return { type: 'title', action: { type: 'iconName', name: data } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandNum === OSC.SET_TITLE) {
|
||||||
|
return { type: 'title', action: { type: 'windowTitle', title: data } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hyperlinks (OSC 8)
|
||||||
|
if (commandNum === OSC.HYPERLINK) {
|
||||||
|
const parts = data.split(';')
|
||||||
|
const paramsStr = parts[0] ?? ''
|
||||||
|
const url = parts.slice(1).join(';')
|
||||||
|
|
||||||
|
if (url === '') {
|
||||||
|
return { type: 'link', action: { type: 'end' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
|
||||||
|
if (paramsStr) {
|
||||||
|
for (const pair of paramsStr.split(':')) {
|
||||||
|
const eqIdx = pair.indexOf('=')
|
||||||
|
|
||||||
|
if (eqIdx >= 0) {
|
||||||
|
params[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'link',
|
||||||
|
action: {
|
||||||
|
type: 'start',
|
||||||
|
url,
|
||||||
|
params: Object.keys(params).length > 0 ? params : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab status (OSC 21337)
|
||||||
|
if (commandNum === OSC.TAB_STATUS) {
|
||||||
|
return { type: 'tabStatus', action: parseTabStatus(data) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'unknown', sequence: `\x1b]${content}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an XParseColor-style color spec into an RGB Color.
|
||||||
|
* Accepts `#RRGGBB` and `rgb:R/G/B` (1–4 hex digits per component, scaled
|
||||||
|
* to 8-bit). Returns null on parse failure.
|
||||||
|
*/
|
||||||
|
export function parseOscColor(spec: string): Color | null {
|
||||||
|
const hex = spec.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i)
|
||||||
|
|
||||||
|
if (hex) {
|
||||||
|
return {
|
||||||
|
type: 'rgb',
|
||||||
|
r: parseInt(hex[1]!, 16),
|
||||||
|
g: parseInt(hex[2]!, 16),
|
||||||
|
b: parseInt(hex[3]!, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rgb = spec.match(/^rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})$/i)
|
||||||
|
|
||||||
|
if (rgb) {
|
||||||
|
// XParseColor: N hex digits → value / (16^N - 1), scale to 0-255
|
||||||
|
const scale = (s: string) => Math.round((parseInt(s, 16) / (16 ** s.length - 1)) * 255)
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'rgb',
|
||||||
|
r: scale(rgb[1]!),
|
||||||
|
g: scale(rgb[2]!),
|
||||||
|
b: scale(rgb[3]!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse OSC 21337 payload: `key=value;key=value;...` with `\;` and `\\`
|
||||||
|
* escapes inside values. Bare key or `key=` clears that field; unknown
|
||||||
|
* keys are ignored.
|
||||||
|
*/
|
||||||
|
function parseTabStatus(data: string): TabStatusAction {
|
||||||
|
const action: TabStatusAction = {}
|
||||||
|
|
||||||
|
for (const [key, value] of splitTabStatusPairs(data)) {
|
||||||
|
switch (key) {
|
||||||
|
case 'indicator':
|
||||||
|
action.indicator = value === '' ? null : parseOscColor(value)
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'status':
|
||||||
|
action.status = value === '' ? null : value
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'status-color':
|
||||||
|
action.statusColor = value === '' ? null : parseOscColor(value)
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split `k=v;k=v` honoring `\;` and `\\` escapes. Yields [key, unescapedValue]. */
|
||||||
|
function* splitTabStatusPairs(data: string): Generator<[string, string]> {
|
||||||
|
let key = ''
|
||||||
|
let val = ''
|
||||||
|
let inVal = false
|
||||||
|
let esc = false
|
||||||
|
|
||||||
|
for (const c of data) {
|
||||||
|
if (esc) {
|
||||||
|
if (inVal) {
|
||||||
|
val += c
|
||||||
|
} else {
|
||||||
|
key += c
|
||||||
|
}
|
||||||
|
|
||||||
|
esc = false
|
||||||
|
} else if (c === '\\') {
|
||||||
|
esc = true
|
||||||
|
} else if (c === ';') {
|
||||||
|
yield [key, val]
|
||||||
|
key = ''
|
||||||
|
val = ''
|
||||||
|
inVal = false
|
||||||
|
} else if (c === '=' && !inVal) {
|
||||||
|
inVal = true
|
||||||
|
} else if (inVal) {
|
||||||
|
val += c
|
||||||
|
} else {
|
||||||
|
key += c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key || inVal) {
|
||||||
|
yield [key, val]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output generators
|
||||||
|
|
||||||
|
/** Start a hyperlink (OSC 8). Auto-assigns an id= param derived from the URL
|
||||||
|
* so terminals group wrapped lines of the same link together (the spec says
|
||||||
|
* cells with matching URI *and* nonempty id are joined; without an id each
|
||||||
|
* wrapped line is a separate link — inconsistent hover, partial tooltips).
|
||||||
|
* Empty url = close sequence (empty params per spec). */
|
||||||
|
export function link(url: string, params?: Record<string, string>): string {
|
||||||
|
if (!url) {
|
||||||
|
return LINK_END
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = { id: osc8Id(url), ...params }
|
||||||
|
|
||||||
|
const paramStr = Object.entries(p)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join(':')
|
||||||
|
|
||||||
|
return osc(OSC.HYPERLINK, paramStr, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function osc8Id(url: string): string {
|
||||||
|
let h = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < url.length; i++) {
|
||||||
|
h = ((h << 5) - h + url.charCodeAt(i)) | 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return (h >>> 0).toString(36)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End a hyperlink (OSC 8) */
|
||||||
|
export const LINK_END = osc(OSC.HYPERLINK, '', '')
|
||||||
|
|
||||||
|
// iTerm2 OSC 9 subcommands
|
||||||
|
|
||||||
|
/** iTerm2 OSC 9 subcommand numbers */
|
||||||
|
export const ITERM2 = {
|
||||||
|
NOTIFY: 0,
|
||||||
|
BADGE: 2,
|
||||||
|
PROGRESS: 4
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** Progress operation codes (for use with ITERM2.PROGRESS) */
|
||||||
|
export const PROGRESS = {
|
||||||
|
CLEAR: 0,
|
||||||
|
SET: 1,
|
||||||
|
ERROR: 2,
|
||||||
|
INDETERMINATE: 3
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear iTerm2 progress bar sequence (OSC 9;4;0;BEL)
|
||||||
|
* Uses BEL terminator since this is for cleanup (not runtime notification)
|
||||||
|
* and we want to ensure it's always sent regardless of terminal type.
|
||||||
|
*/
|
||||||
|
export const CLEAR_ITERM2_PROGRESS = `${OSC_PREFIX}${OSC.ITERM2};${ITERM2.PROGRESS};${PROGRESS.CLEAR};${BEL}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear terminal title sequence (OSC 0 with empty string + BEL).
|
||||||
|
* Uses BEL terminator for cleanup — safe on all terminals.
|
||||||
|
*/
|
||||||
|
export const CLEAR_TERMINAL_TITLE = `${OSC_PREFIX}${OSC.SET_TITLE_AND_ICON};${BEL}`
|
||||||
|
|
||||||
|
/** Clear all three OSC 21337 tab-status fields. Used on exit. */
|
||||||
|
export const CLEAR_TAB_STATUS = osc(OSC.TAB_STATUS, 'indicator=;status=;status-color=')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate for emitting OSC 21337 (tab-status indicator). Ant-only while the
|
||||||
|
* spec is unstable. Terminals that don't recognize it discard silently, so
|
||||||
|
* emission is safe unconditionally — we don't gate on terminal detection
|
||||||
|
* since support is expected across several terminals.
|
||||||
|
*
|
||||||
|
* Callers must wrap output with wrapForMultiplexer() so tmux/screen
|
||||||
|
* DCS-passthrough carries the sequence to the outer terminal.
|
||||||
|
*/
|
||||||
|
export function supportsTabStatus(): boolean {
|
||||||
|
return process.env.USER_TYPE === 'ant'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit an OSC 21337 tab-status sequence. Omitted fields are left unchanged
|
||||||
|
* by the receiving terminal; `null` sends an empty value to clear.
|
||||||
|
* `;` and `\` in status text are escaped per the spec.
|
||||||
|
*/
|
||||||
|
export function tabStatus(fields: TabStatusAction): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
const rgb = (c: Color) =>
|
||||||
|
c.type === 'rgb' ? `#${[c.r, c.g, c.b].map(n => n.toString(16).padStart(2, '0')).join('')}` : ''
|
||||||
|
|
||||||
|
if ('indicator' in fields) {
|
||||||
|
parts.push(`indicator=${fields.indicator ? rgb(fields.indicator) : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('status' in fields) {
|
||||||
|
parts.push(`status=${fields.status?.replaceAll('\\', '\\\\').replaceAll(';', '\\;') ?? ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('statusColor' in fields) {
|
||||||
|
parts.push(`status-color=${fields.statusColor ? rgb(fields.statusColor) : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return osc(OSC.TAB_STATUS, parts.join(';'))
|
||||||
|
}
|
||||||
467
ui-tui/packages/hermes-ink/src/ink/termio/parser.ts
Normal file
467
ui-tui/packages/hermes-ink/src/ink/termio/parser.ts
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
/**
|
||||||
|
* ANSI Parser - Semantic Action Generator
|
||||||
|
*
|
||||||
|
* A streaming parser for ANSI escape sequences that produces semantic actions.
|
||||||
|
* Uses the tokenizer for escape sequence boundary detection, then interprets
|
||||||
|
* each sequence to produce structured actions.
|
||||||
|
*
|
||||||
|
* Key design decisions:
|
||||||
|
* - Streaming: can process input incrementally
|
||||||
|
* - Semantic output: produces structured actions, not string tokens
|
||||||
|
* - Style tracking: maintains current text style state
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getGraphemeSegmenter } from '../../utils/intl.js'
|
||||||
|
|
||||||
|
import { C0 } from './ansi.js'
|
||||||
|
import { CSI, CURSOR_STYLES, ERASE_DISPLAY, ERASE_LINE_REGION } from './csi.js'
|
||||||
|
import { DEC } from './dec.js'
|
||||||
|
import { parseEsc } from './esc.js'
|
||||||
|
import { parseOSC } from './osc.js'
|
||||||
|
import { applySGR } from './sgr.js'
|
||||||
|
import { createTokenizer, type Token, type Tokenizer } from './tokenize.js'
|
||||||
|
import type { Action, Grapheme, TextStyle } from './types.js'
|
||||||
|
import { defaultStyle } from './types.js'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Grapheme Utilities
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function isEmoji(codePoint: number): boolean {
|
||||||
|
return (
|
||||||
|
(codePoint >= 0x2600 && codePoint <= 0x26ff) ||
|
||||||
|
(codePoint >= 0x2700 && codePoint <= 0x27bf) ||
|
||||||
|
(codePoint >= 0x1f300 && codePoint <= 0x1f9ff) ||
|
||||||
|
(codePoint >= 0x1fa00 && codePoint <= 0x1faff) ||
|
||||||
|
(codePoint >= 0x1f1e0 && codePoint <= 0x1f1ff)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEastAsianWide(codePoint: number): boolean {
|
||||||
|
return (
|
||||||
|
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
|
||||||
|
(codePoint >= 0x2e80 && codePoint <= 0x9fff) ||
|
||||||
|
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
||||||
|
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
||||||
|
(codePoint >= 0xfe10 && codePoint <= 0xfe1f) ||
|
||||||
|
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
||||||
|
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
||||||
|
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
||||||
|
(codePoint >= 0x20000 && codePoint <= 0x2fffd) ||
|
||||||
|
(codePoint >= 0x30000 && codePoint <= 0x3fffd)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMultipleCodepoints(str: string): boolean {
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
for (const _ of str) {
|
||||||
|
count++
|
||||||
|
|
||||||
|
if (count > 1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function graphemeWidth(grapheme: string): 1 | 2 {
|
||||||
|
if (hasMultipleCodepoints(grapheme)) {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const codePoint = grapheme.codePointAt(0)
|
||||||
|
|
||||||
|
if (codePoint === undefined) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmoji(codePoint) || isEastAsianWide(codePoint)) {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function* segmentGraphemes(str: string): Generator<Grapheme> {
|
||||||
|
for (const { segment } of getGraphemeSegmenter().segment(str)) {
|
||||||
|
yield { value: segment, width: graphemeWidth(segment) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Sequence Parsing
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function parseCSIParams(paramStr: string): number[] {
|
||||||
|
if (paramStr === '') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return paramStr.split(/[;:]/).map(s => (s === '' ? 0 : parseInt(s, 10)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a raw CSI sequence (e.g., "\x1b[31m") into an action */
|
||||||
|
function parseCSI(rawSequence: string): Action | null {
|
||||||
|
const inner = rawSequence.slice(2)
|
||||||
|
|
||||||
|
if (inner.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalByte = inner.charCodeAt(inner.length - 1)
|
||||||
|
const beforeFinal = inner.slice(0, -1)
|
||||||
|
|
||||||
|
let privateMode = ''
|
||||||
|
let paramStr = beforeFinal
|
||||||
|
let intermediate = ''
|
||||||
|
|
||||||
|
if (beforeFinal.length > 0 && '?>='.includes(beforeFinal[0]!)) {
|
||||||
|
privateMode = beforeFinal[0]!
|
||||||
|
paramStr = beforeFinal.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const intermediateMatch = paramStr.match(/([^0-9;:]+)$/)
|
||||||
|
|
||||||
|
if (intermediateMatch) {
|
||||||
|
intermediate = intermediateMatch[1]!
|
||||||
|
paramStr = paramStr.slice(0, -intermediate.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = parseCSIParams(paramStr)
|
||||||
|
const p0 = params[0] ?? 1
|
||||||
|
const p1 = params[1] ?? 1
|
||||||
|
|
||||||
|
// SGR (Select Graphic Rendition)
|
||||||
|
if (finalByte === CSI.SGR && privateMode === '') {
|
||||||
|
return { type: 'sgr', params: paramStr }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor movement
|
||||||
|
if (finalByte === CSI.CUU) {
|
||||||
|
return {
|
||||||
|
type: 'cursor',
|
||||||
|
action: { type: 'move', direction: 'up', count: p0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalByte === CSI.CUD) {
|
||||||
|
return {
|
||||||
|
type: 'cursor',
|
||||||
|
action: { type: 'move', direction: 'down', count: p0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalByte === CSI.CUF) {
|
||||||
|
return {
|
||||||
|
type: 'cursor',
|
||||||
|
action: { type: 'move', direction: 'forward', count: p0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalByte === CSI.CUB) {
|
||||||
|
return {
|
||||||
|
type: 'cursor',
|
||||||
|
action: { type: 'move', direction: 'back', count: p0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalByte === CSI.CNL) {
|
||||||
|
return { type: 'cursor', action: { type: 'nextLine', count: p0 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalByte === CSI.CPL) {
|
||||||
|
return { type: 'cursor', action: { type: 'prevLine', count: p0 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalByte === CSI.CHA) {
|
||||||
|
return { type: 'cursor', action: { type: 'column', col: p0 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalByte === CSI.CUP || finalByte === CSI.HVP) {
|
||||||
|
return { type: 'cursor', action: { type: 'position', row: p0, col: p1 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalByte === CSI.VPA) {
|
||||||
|
return { type: 'cursor', action: { type: 'row', row: p0 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erase
|
||||||
|
if (finalByte === CSI.ED) {
|
||||||
|
const region = ERASE_DISPLAY[params[0] ?? 0] ?? 'toEnd'
|
||||||
|
|
||||||
|
return { type: 'erase', action: { type: 'display', region } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalByte === CSI.EL) {
|
||||||
|
const region = ERASE_LINE_REGION[params[0] ?? 0] ?? 'toEnd'
|
||||||
|
|
||||||
|
return { type: 'erase', action: { type: 'line', region } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalByte === CSI.ECH) {
|
||||||
|
return { type: 'erase', action: { type: 'chars', count: p0 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll
|
||||||
|
if (finalByte === CSI.SU) {
|
||||||
|
return { type: 'scroll', action: { type: 'up', count: p0 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalByte === CSI.SD) {
|
||||||
|
return { type: 'scroll', action: { type: 'down', count: p0 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalByte === CSI.DECSTBM) {
|
||||||
|
return {
|
||||||
|
type: 'scroll',
|
||||||
|
action: { type: 'setRegion', top: p0, bottom: p1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor save/restore
|
||||||
|
if (finalByte === CSI.SCOSC) {
|
||||||
|
return { type: 'cursor', action: { type: 'save' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalByte === CSI.SCORC) {
|
||||||
|
return { type: 'cursor', action: { type: 'restore' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor style
|
||||||
|
if (finalByte === CSI.DECSCUSR && intermediate === ' ') {
|
||||||
|
const styleInfo = CURSOR_STYLES[p0] ?? CURSOR_STYLES[0]!
|
||||||
|
|
||||||
|
return { type: 'cursor', action: { type: 'style', ...styleInfo } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private modes
|
||||||
|
if (privateMode === '?' && (finalByte === CSI.SM || finalByte === CSI.RM)) {
|
||||||
|
const enabled = finalByte === CSI.SM
|
||||||
|
|
||||||
|
if (p0 === DEC.CURSOR_VISIBLE) {
|
||||||
|
return {
|
||||||
|
type: 'cursor',
|
||||||
|
action: enabled ? { type: 'show' } : { type: 'hide' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p0 === DEC.ALT_SCREEN_CLEAR || p0 === DEC.ALT_SCREEN) {
|
||||||
|
return { type: 'mode', action: { type: 'alternateScreen', enabled } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p0 === DEC.BRACKETED_PASTE) {
|
||||||
|
return { type: 'mode', action: { type: 'bracketedPaste', enabled } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p0 === DEC.MOUSE_NORMAL) {
|
||||||
|
return {
|
||||||
|
type: 'mode',
|
||||||
|
action: { type: 'mouseTracking', mode: enabled ? 'normal' : 'off' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p0 === DEC.MOUSE_BUTTON) {
|
||||||
|
return {
|
||||||
|
type: 'mode',
|
||||||
|
action: { type: 'mouseTracking', mode: enabled ? 'button' : 'off' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p0 === DEC.MOUSE_ANY) {
|
||||||
|
return {
|
||||||
|
type: 'mode',
|
||||||
|
action: { type: 'mouseTracking', mode: enabled ? 'any' : 'off' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p0 === DEC.FOCUS_EVENTS) {
|
||||||
|
return { type: 'mode', action: { type: 'focusEvents', enabled } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'unknown', sequence: rawSequence }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify the type of escape sequence from its raw form.
|
||||||
|
*/
|
||||||
|
function identifySequence(seq: string): 'csi' | 'osc' | 'esc' | 'ss3' | 'unknown' {
|
||||||
|
if (seq.length < 2) {
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seq.charCodeAt(0) !== C0.ESC) {
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
const second = seq.charCodeAt(1)
|
||||||
|
|
||||||
|
if (second === 0x5b) {
|
||||||
|
return 'csi'
|
||||||
|
} // [
|
||||||
|
|
||||||
|
if (second === 0x5d) {
|
||||||
|
return 'osc'
|
||||||
|
} // ]
|
||||||
|
|
||||||
|
if (second === 0x4f) {
|
||||||
|
return 'ss3'
|
||||||
|
} // O
|
||||||
|
|
||||||
|
return 'esc'
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Main Parser
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parser class - maintains state for streaming/incremental parsing
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```typescript
|
||||||
|
* const parser = new Parser()
|
||||||
|
* const actions1 = parser.feed('partial\x1b[')
|
||||||
|
* const actions2 = parser.feed('31mred') // state maintained internally
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class Parser {
|
||||||
|
private tokenizer: Tokenizer = createTokenizer()
|
||||||
|
|
||||||
|
style: TextStyle = defaultStyle()
|
||||||
|
inLink = false
|
||||||
|
linkUrl: string | undefined
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.tokenizer.reset()
|
||||||
|
this.style = defaultStyle()
|
||||||
|
this.inLink = false
|
||||||
|
this.linkUrl = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed input and get resulting actions */
|
||||||
|
feed(input: string): Action[] {
|
||||||
|
const tokens = this.tokenizer.feed(input)
|
||||||
|
const actions: Action[] = []
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
const tokenActions = this.processToken(token)
|
||||||
|
actions.push(...tokenActions)
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
private processToken(token: Token): Action[] {
|
||||||
|
switch (token.type) {
|
||||||
|
case 'text':
|
||||||
|
return this.processText(token.value)
|
||||||
|
|
||||||
|
case 'sequence':
|
||||||
|
return this.processSequence(token.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private processText(text: string): Action[] {
|
||||||
|
// Handle BEL characters embedded in text
|
||||||
|
const actions: Action[] = []
|
||||||
|
let current = ''
|
||||||
|
|
||||||
|
for (const char of text) {
|
||||||
|
if (char.charCodeAt(0) === C0.BEL) {
|
||||||
|
if (current) {
|
||||||
|
const graphemes = [...segmentGraphemes(current)]
|
||||||
|
|
||||||
|
if (graphemes.length > 0) {
|
||||||
|
actions.push({ type: 'text', graphemes, style: { ...this.style } })
|
||||||
|
}
|
||||||
|
|
||||||
|
current = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push({ type: 'bell' })
|
||||||
|
} else {
|
||||||
|
current += char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
const graphemes = [...segmentGraphemes(current)]
|
||||||
|
|
||||||
|
if (graphemes.length > 0) {
|
||||||
|
actions.push({ type: 'text', graphemes, style: { ...this.style } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
private processSequence(seq: string): Action[] {
|
||||||
|
const seqType = identifySequence(seq)
|
||||||
|
|
||||||
|
switch (seqType) {
|
||||||
|
case 'csi': {
|
||||||
|
const action = parseCSI(seq)
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === 'sgr') {
|
||||||
|
this.style = applySGR(action.params, this.style)
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [action]
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'osc': {
|
||||||
|
// Extract OSC content (between ESC ] and terminator)
|
||||||
|
let content = seq.slice(2)
|
||||||
|
|
||||||
|
// Remove terminator (BEL or ESC \)
|
||||||
|
if (content.endsWith('\x07')) {
|
||||||
|
content = content.slice(0, -1)
|
||||||
|
} else if (content.endsWith('\x1b\\')) {
|
||||||
|
content = content.slice(0, -2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = parseOSC(content)
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
if (action.type === 'link') {
|
||||||
|
if (action.action.type === 'start') {
|
||||||
|
this.inLink = true
|
||||||
|
this.linkUrl = action.action.url
|
||||||
|
} else {
|
||||||
|
this.inLink = false
|
||||||
|
this.linkUrl = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [action]
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'esc': {
|
||||||
|
const escContent = seq.slice(1)
|
||||||
|
const action = parseEsc(escContent)
|
||||||
|
|
||||||
|
return action ? [action] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ss3':
|
||||||
|
// SS3 sequences are typically cursor keys in application mode
|
||||||
|
// For output parsing, treat as unknown
|
||||||
|
return [{ type: 'unknown', sequence: seq }]
|
||||||
|
|
||||||
|
default:
|
||||||
|
return [{ type: 'unknown', sequence: seq }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
362
ui-tui/packages/hermes-ink/src/ink/termio/sgr.ts
Normal file
362
ui-tui/packages/hermes-ink/src/ink/termio/sgr.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
/**
|
||||||
|
* SGR (Select Graphic Rendition) Parser
|
||||||
|
*
|
||||||
|
* Parses SGR parameters and applies them to a TextStyle.
|
||||||
|
* Handles both semicolon (;) and colon (:) separated parameters.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { NamedColor, TextStyle, UnderlineStyle } from './types.js'
|
||||||
|
import { defaultStyle } from './types.js'
|
||||||
|
|
||||||
|
const NAMED_COLORS: NamedColor[] = [
|
||||||
|
'black',
|
||||||
|
'red',
|
||||||
|
'green',
|
||||||
|
'yellow',
|
||||||
|
'blue',
|
||||||
|
'magenta',
|
||||||
|
'cyan',
|
||||||
|
'white',
|
||||||
|
'brightBlack',
|
||||||
|
'brightRed',
|
||||||
|
'brightGreen',
|
||||||
|
'brightYellow',
|
||||||
|
'brightBlue',
|
||||||
|
'brightMagenta',
|
||||||
|
'brightCyan',
|
||||||
|
'brightWhite'
|
||||||
|
]
|
||||||
|
|
||||||
|
const UNDERLINE_STYLES: UnderlineStyle[] = ['none', 'single', 'double', 'curly', 'dotted', 'dashed']
|
||||||
|
|
||||||
|
type Param = { value: number | null; subparams: number[]; colon: boolean }
|
||||||
|
|
||||||
|
function parseParams(str: string): Param[] {
|
||||||
|
if (str === '') {
|
||||||
|
return [{ value: 0, subparams: [], colon: false }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Param[] = []
|
||||||
|
let current: Param = { value: null, subparams: [], colon: false }
|
||||||
|
let num = ''
|
||||||
|
let inSub = false
|
||||||
|
|
||||||
|
for (let i = 0; i <= str.length; i++) {
|
||||||
|
const c = str[i]
|
||||||
|
|
||||||
|
if (c === ';' || c === undefined) {
|
||||||
|
const n = num === '' ? null : parseInt(num, 10)
|
||||||
|
|
||||||
|
if (inSub) {
|
||||||
|
if (n !== null) {
|
||||||
|
current.subparams.push(n)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current.value = n
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(current)
|
||||||
|
current = { value: null, subparams: [], colon: false }
|
||||||
|
num = ''
|
||||||
|
inSub = false
|
||||||
|
} else if (c === ':') {
|
||||||
|
const n = num === '' ? null : parseInt(num, 10)
|
||||||
|
|
||||||
|
if (!inSub) {
|
||||||
|
current.value = n
|
||||||
|
current.colon = true
|
||||||
|
inSub = true
|
||||||
|
} else {
|
||||||
|
if (n !== null) {
|
||||||
|
current.subparams.push(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
num = ''
|
||||||
|
} else if (c >= '0' && c <= '9') {
|
||||||
|
num += c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExtendedColor(
|
||||||
|
params: Param[],
|
||||||
|
idx: number
|
||||||
|
): { r: number; g: number; b: number } | { index: number } | null {
|
||||||
|
const p = params[idx]
|
||||||
|
|
||||||
|
if (!p) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.colon && p.subparams.length >= 1) {
|
||||||
|
if (p.subparams[0] === 5 && p.subparams.length >= 2) {
|
||||||
|
return { index: p.subparams[1]! }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.subparams[0] === 2 && p.subparams.length >= 4) {
|
||||||
|
const off = p.subparams.length >= 5 ? 1 : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: p.subparams[1 + off]!,
|
||||||
|
g: p.subparams[2 + off]!,
|
||||||
|
b: p.subparams[3 + off]!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = params[idx + 1]
|
||||||
|
|
||||||
|
if (!next) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next.value === 5 && params[idx + 2]?.value !== null && params[idx + 2]?.value !== undefined) {
|
||||||
|
return { index: params[idx + 2]!.value! }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next.value === 2) {
|
||||||
|
const r = params[idx + 2]?.value
|
||||||
|
const g = params[idx + 3]?.value
|
||||||
|
const b = params[idx + 4]?.value
|
||||||
|
|
||||||
|
if (r !== null && r !== undefined && g !== null && g !== undefined && b !== null && b !== undefined) {
|
||||||
|
return { r, g, b }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySGR(paramStr: string, style: TextStyle): TextStyle {
|
||||||
|
const params = parseParams(paramStr)
|
||||||
|
let s = { ...style }
|
||||||
|
let i = 0
|
||||||
|
|
||||||
|
while (i < params.length) {
|
||||||
|
const p = params[i]!
|
||||||
|
const code = p.value ?? 0
|
||||||
|
|
||||||
|
if (code === 0) {
|
||||||
|
s = defaultStyle()
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 1) {
|
||||||
|
s.bold = true
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 2) {
|
||||||
|
s.dim = true
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 3) {
|
||||||
|
s.italic = true
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 4) {
|
||||||
|
s.underline = p.colon ? (UNDERLINE_STYLES[p.subparams[0]!] ?? 'single') : 'single'
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 5 || code === 6) {
|
||||||
|
s.blink = true
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 7) {
|
||||||
|
s.inverse = true
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 8) {
|
||||||
|
s.hidden = true
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 9) {
|
||||||
|
s.strikethrough = true
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 21) {
|
||||||
|
s.underline = 'double'
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 22) {
|
||||||
|
s.bold = false
|
||||||
|
s.dim = false
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 23) {
|
||||||
|
s.italic = false
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 24) {
|
||||||
|
s.underline = 'none'
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 25) {
|
||||||
|
s.blink = false
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 27) {
|
||||||
|
s.inverse = false
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 28) {
|
||||||
|
s.hidden = false
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 29) {
|
||||||
|
s.strikethrough = false
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 53) {
|
||||||
|
s.overline = true
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 55) {
|
||||||
|
s.overline = false
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code >= 30 && code <= 37) {
|
||||||
|
s.fg = { type: 'named', name: NAMED_COLORS[code - 30]! }
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 39) {
|
||||||
|
s.fg = { type: 'default' }
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code >= 40 && code <= 47) {
|
||||||
|
s.bg = { type: 'named', name: NAMED_COLORS[code - 40]! }
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 49) {
|
||||||
|
s.bg = { type: 'default' }
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code >= 90 && code <= 97) {
|
||||||
|
s.fg = { type: 'named', name: NAMED_COLORS[code - 90 + 8]! }
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code >= 100 && code <= 107) {
|
||||||
|
s.bg = { type: 'named', name: NAMED_COLORS[code - 100 + 8]! }
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 38) {
|
||||||
|
const c = parseExtendedColor(params, i)
|
||||||
|
|
||||||
|
if (c) {
|
||||||
|
s.fg = 'index' in c ? { type: 'indexed', index: c.index } : { type: 'rgb', ...c }
|
||||||
|
i += p.colon ? 1 : 'index' in c ? 3 : 5
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 48) {
|
||||||
|
const c = parseExtendedColor(params, i)
|
||||||
|
|
||||||
|
if (c) {
|
||||||
|
s.bg = 'index' in c ? { type: 'indexed', index: c.index } : { type: 'rgb', ...c }
|
||||||
|
i += p.colon ? 1 : 'index' in c ? 3 : 5
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 58) {
|
||||||
|
const c = parseExtendedColor(params, i)
|
||||||
|
|
||||||
|
if (c) {
|
||||||
|
s.underlineColor = 'index' in c ? { type: 'indexed', index: c.index } : { type: 'rgb', ...c }
|
||||||
|
i += p.colon ? 1 : 'index' in c ? 3 : 5
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 59) {
|
||||||
|
s.underlineColor = { type: 'default' }
|
||||||
|
i++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue