diff --git a/.changeset/upgrade-ink-v8.md b/.changeset/upgrade-ink-v8.md new file mode 100644 index 000000000..d7fa48b75 --- /dev/null +++ b/.changeset/upgrade-ink-v8.md @@ -0,0 +1,12 @@ +--- +"@nanocollective/nanocoder": minor +--- + +Upgrades Ink from 6.8 to 8.0 and raises the React dependency floor to 19.3. Ink 8 types `useStdout()`'s stream as a plain Node.js `WritableStream` and drops terminal control sequences it cannot map to a key before they reach `useInput`. + +- The stdin proxy rewrites xterm's modifyOtherKeys form of modified Enter (`ESC [27;;13~`, used by VS Code terminal keybindings) into kitty CSI-u (`ESC [13;u`), which Ink parses natively. Fragmented sequences are reassembled before rewriting. +- Preserves the patch-independent raw-input handling introduced by #1670, including its guard against reading `key.raw`. Backspace and forward Delete remain distinct; the upgrade does not revert the packaging fix. + +The startup-time patch importing `es-toolkit/compat/throttle` directly instead of the barrel file is carried over to Ink 8. + +Related input regressions: #1505 (already fixed on main by #1670) and #1664. This upgrade is related follow-up work, not a claim that those reports require Ink 8. See [terminal compatibility checks](../docs/features/terminal-compatibility.md) for automated coverage and the manual smoke-test procedure. diff --git a/docs/features/terminal-compatibility.md b/docs/features/terminal-compatibility.md new file mode 100644 index 000000000..b4fad09da --- /dev/null +++ b/docs/features/terminal-compatibility.md @@ -0,0 +1,47 @@ +--- +title: "Terminal Compatibility Checks" +description: "Reproducing modified Enter and external-editor handoff checks" +--- + +# Terminal Compatibility Checks + +These checks distinguish byte-sequence regression coverage from interaction with a real terminal. An automated parser or Ink rendering test does not establish that VS Code emits the expected bytes or that an external editor receives a usable terminal. + +## Related input issues + +- [#1505](https://github.com/Nano-Collective/nanocoder/issues/1505): Backspace acted as forward Delete on published installations. Main fixed the dependency on patched `key.raw` in [#1670](https://github.com/Nano-Collective/nanocoder/pull/1670). The Ink upgrade must retain that patch-independent behavior and its regression guard. +- [#1664](https://github.com/Nano-Collective/nanocoder/issues/1664): another Backspace report involving Konsole and VS Code. This is related coverage, not a claim that upgrading Ink is required to fix that report. + +## Automated coverage + +Run from the repository root: + +```sh +pnpm run test:types +pnpm run test:ava source/utils/terminal-keypress.spec.ts source/utils/terminal-input.spec.ts source/utils/terminal-paste.spec.ts source/utils/newline-key.spec.ts source/components/text-input-keys.spec.tsx source/components/text-input-newline.spec.tsx source/components/input-swap.spec.tsx source/components/user-input.spec.tsx +pnpm run test:ava source/wizards/base-config-wizard.spec.tsx source/wizards/steps/mcp-step.spec.tsx +``` + +These suites cover modified Enter (including fragmented reads), ordinary Enter, Backspace versus forward Delete, keys interleaved with bracketed paste, focused paste targets, and input after a same-commit component swap. They do not replace either manual check below. Run each command only on the branch that contains the referenced feature and tests; a minimal Ink-upgrade PR need not include the paste/FIFO suites. + +## VS Code integrated-terminal smoke test + +1. Build with `pnpm run build`. Launch `node dist/cli.js` inside the VS Code integrated terminal, not the VS Code extension's separate chat view. Record VS Code version, OS, shell, and Nanocoder commit. +2. Record the existing terminal keybindings. For the xterm rewrite check, temporarily use `workbench.action.terminal.sendSequence` with `"when": "terminalFocus"`, mapping Shift+Enter to `"\u001b[27;2;13~"` and Alt+Enter to `"\u001b[27;3;13~"`. Testing only a binding that sends `"\n"` does not exercise that rewrite. +3. Type `ab`, move the caret between the letters, press Shift+Enter, then type `x`. The result must be `a\nxb`, with no submission and no literal escape-sequence text. Repeat with Alt+Enter and with Ctrl+J. +4. Check Backspace and Delete in the middle of `abc`: Backspace removes the character to the left; Delete removes the character to the right. Repeat beside an emoji. +5. Paste a multiline block between typed characters, then type immediately afterwards. The surrounding text must stay in order and the paste must not submit. Test the MCP wizard's env-vars field separately if the paste-target change is present. +6. Verify plain Enter still submits only when intended, then restore the original keybindings. Repeat in inline mode (`--no-alt-screen`). + +## External-editor handoff smoke test + +Use a disposable workspace and configuration directory without credentials. Select a terminal editor such as `vi` as the editor for the configuration wizard; a GUI editor does not exercise raw/cooked terminal handoff. + +1. In the configuration wizard, reach the review step and choose its external-editor action. Record the editor version, OS, terminal, and Nanocoder commit. +2. Confirm that the editor accepts normal typing, cursor movement, and multiline paste without literal paste/mouse escape sequences. Save a harmless configuration change and quit. +3. Confirm that the wizard repaints and responds to keys after the editor exits. Try a second handoff and an exit without saving; check that each returns control to the wizard. +4. Repeat in fullscreen and inline modes. After leaving Nanocoder, check that the shell echoes input normally and that paste markers and mouse-report bytes do not appear as text. + +## Reporting results + +For each manual check, report the commit, OS, terminal/editor versions, keybinding sequences, screen mode, and observed result in the PR. Mark unperformed checks **not run**. In particular, do not report the external-editor handoff or real VS Code interaction as verified solely because the automated suites passed. diff --git a/package.json b/package.json index eb4150c87..01571b32f 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "diff": "^9.0.0", "dotenv": "^18.0.5", "ignore": "^7.0.5", - "ink": "^6.3.1", + "ink": "8.0.0", "ink-big-text": "^2.0.0", "ink-gradient": "^4.0.0", "ink-select-input": "^6.2.0", @@ -106,7 +106,7 @@ "lru-cache": "^11.5.1", "pino": "^10.1.0", "playwright-core": "^1.63.0", - "react": "^19.0.0", + "react": "^19.3.0", "string-width": "^8.2.2", "tiktoken": "^1.0.22", "undici": "^8.5.0", @@ -122,7 +122,7 @@ "@nanocollective/nanocoder-voice": "workspace:*", "@types/cross-spawn": "^6.0.6", "@types/node": "^26.1.2", - "@types/react": "^19.0.0", + "@types/react": "^19.3.0", "@types/ws": "^8.18.1", "ajv": "^8.20.0", "ava": "^8.0.1", diff --git a/patches/ink@6.8.0.patch b/patches/ink@6.8.0.patch deleted file mode 100644 index ee2f80d07..000000000 --- a/patches/ink@6.8.0.patch +++ /dev/null @@ -1,52 +0,0 @@ -diff --git a/build/ink.js b/build/ink.js -index 30019a073d38ed05dca08f6221af187c429a536b..5254bf400cda5f4d7fdc95c0a0f6ccd8fd5cfd35 100644 ---- a/build/ink.js -+++ b/build/ink.js -@@ -1,6 +1,11 @@ - import process from 'node:process'; - import React from 'react'; --import { throttle } from 'es-toolkit/compat'; -+// Patched by nanocoder: import the deep subpath directly so Node doesn't -+// resolve the entire `es-toolkit/compat` barrel file (which eagerly requires -+// ~1244 individual utility modules just to re-export them). Using the -+// subpath loads only `throttle`, dropping the CLI startup module count by -+// ~1240. -+import throttle from 'es-toolkit/compat/throttle'; - import ansiEscapes from 'ansi-escapes'; - import isInCi from 'is-in-ci'; - import autoBind from 'auto-bind'; -diff --git a/build/hooks/use-input.js b/build/hooks/use-input.js -index 52b4487e..c182b5ed 100644 ---- a/build/hooks/use-input.js -+++ b/build/hooks/use-input.js -@@ -68,6 +68,11 @@ const useInput = (inputHandler, options = {}) => { - capsLock: keypress.capsLock ?? false, - numLock: keypress.numLock ?? false, - eventType: keypress.eventType, -+ // Patched by nanocoder: expose the raw input sequence (e.g. -+ // '\x7f' for physical Backspace vs '\x1b[3~' for forward Delete) -+ // so handlers can disambiguate keys that Ink parses to the same -+ // name (both map to `delete`). -+ raw: keypress.raw, - }; - let input; - if (keypress.isKittyProtocol) { -diff --git a/build/hooks/use-input.d.ts b/build/hooks/use-input.d.ts -index e2a9f07c..2497c9ae 100644 ---- a/build/hooks/use-input.d.ts -+++ b/build/hooks/use-input.d.ts -@@ -96,6 +96,14 @@ export type Key = { - Only available with kitty keyboard protocol. - */ - eventType?: 'press' | 'repeat' | 'release'; -+ /** -+ Raw input sequence that was parsed for this key. Useful to disambiguate -+ keys that Ink maps to the same name — e.g. physical Backspace ('\x7f') vs -+ forward Delete ('\x1b[3~'), which both set `delete: true`. -+ -+ Patched by nanocoder. -+ */ -+ raw?: string; - }; - type Handler = (input: string, key: Key) => void; - type Options = { diff --git a/patches/ink@8.0.0.patch b/patches/ink@8.0.0.patch new file mode 100644 index 000000000..d066c8e38 --- /dev/null +++ b/patches/ink@8.0.0.patch @@ -0,0 +1,17 @@ +diff --git a/build/ink.js b/build/ink.js +index 30019a073d38ed05dca08f6221af187c429a536b..5254bf400cda5f4d7fdc95c0a0f6ccd8fd5cfd35 100644 +--- a/build/ink.js ++++ b/build/ink.js +@@ -1,6 +1,11 @@ + import process from 'node:process'; + import React from 'react'; +-import { throttle } from 'es-toolkit/compat'; ++// Patched by nanocoder: import the deep subpath directly so Node doesn't ++// resolve the entire `es-toolkit/compat` barrel file (which eagerly requires ++// ~1244 individual utility modules just to re-export them). Using the ++// subpath loads only `throttle`, dropping the CLI startup module count by ++// ~1240. ++import throttle from 'es-toolkit/compat/throttle'; + import ansiEscapes from 'ansi-escapes'; + import isInCi from 'is-in-ci'; + import autoBind from 'auto-bind'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b6a504c0..e27c74e4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: patchedDependencies: '@ai-sdk/openai@3.0.71': df300a5d22aecda3490a73885c5d63e34d4cd056d26fc5ba202ea4121d46adbf - ink@6.8.0: 7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430 + ink@8.0.0: ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f importers: @@ -82,23 +82,23 @@ importers: specifier: ^7.0.5 version: 7.0.9 ink: - specifier: ^6.3.1 - version: 6.8.0(patch_hash=7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430)(@types/react@19.3.0)(react@19.3.0) + specifier: 8.0.0 + version: 8.0.0(patch_hash=ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f)(@types/react@19.3.0)(react@19.3.0) ink-big-text: specifier: ^2.0.0 - version: 2.0.0(ink@6.8.0(patch_hash=7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + version: 2.0.0(ink@8.0.0(patch_hash=ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) ink-gradient: specifier: ^4.0.0 - version: 4.0.1(ink@6.8.0(patch_hash=7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + version: 4.0.1(ink@8.0.0(patch_hash=ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) ink-select-input: specifier: ^6.2.0 - version: 6.2.0(ink@6.8.0(patch_hash=7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + version: 6.2.0(ink@8.0.0(patch_hash=ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) ink-spinner: specifier: ^5.0.0 - version: 5.0.0(ink@6.8.0(patch_hash=7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + version: 5.0.0(ink@8.0.0(patch_hash=ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) ink-tab: specifier: ^5.2.0 - version: 5.2.0(@types/react@19.3.0)(ink@6.8.0(patch_hash=7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + version: 5.2.0(@types/react@19.3.0)(ink@8.0.0(patch_hash=ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) llama-tokenizer-js: specifier: ^1.2.2 version: 1.2.2 @@ -112,7 +112,7 @@ importers: specifier: ^1.63.0 version: 1.63.0 react: - specifier: ^19.0.0 + specifier: ^19.3.0 version: 19.3.0 string-width: specifier: ^8.2.2 @@ -155,7 +155,7 @@ importers: specifier: ^26.1.2 version: 26.2.0 '@types/react': - specifier: ^19.0.0 + specifier: ^19.3.0 version: 19.3.0 '@types/ws': specifier: ^8.18.1 @@ -308,8 +308,8 @@ packages: resolution: {integrity: sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA==} engines: {node: '>=18'} - '@alcalzone/ansi-tokenize@0.2.5': - resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} + '@alcalzone/ansi-tokenize@0.3.1': + resolution: {integrity: sha512-cUD0PT0YX+rImRhfn7BfMRduP/tX/Zqt/N0dHlAyn2RlcJssCb5KsCJkhln61eh9vuJDkH8wKn3L8zJzzWXPFg==} engines: {node: '>=18'} '@anthropic-ai/tokenizer@0.0.4': @@ -1982,6 +1982,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + ansi-styles@7.0.0: + resolution: {integrity: sha512-kKvt3m4uwzqL0wlkPd09CmljPJGOZZ4D0fP65sqFSvPkMRKhNi+74MgIJ5QxE6SxqB4t4KyUFGg8+n5zjo6hew==} + engines: {node: '>=22'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -2190,9 +2194,9 @@ packages: ci-parallel-vars@1.0.1: resolution: {integrity: sha512-uvzpYrpmidaoxvIQHM+rKSrigjOe9feHYbw4uOI2gdfe1C3xIlxO+kVXq83WQWNniTf8bAxVpy+cQeFQsMERKg==} - cli-boxes@3.0.0: - resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} - engines: {node: '>=10'} + cli-boxes@4.0.1: + resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} + engines: {node: '>=18.20 <19 || >=20.10'} cli-cursor@4.0.0: resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} @@ -2507,8 +2511,8 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-toolkit@1.47.0: - resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} @@ -2971,12 +2975,12 @@ packages: '@types/react': optional: true - ink@6.8.0: - resolution: {integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==} - engines: {node: '>=20'} + ink@8.0.0: + resolution: {integrity: sha512-M2aFwZOqmkFxCwedy6xrP4E+DGoK57iahAFnfTqNyOyJ54O7DZzu027z/NI8EGy44Sw9O9BcbTF4QEUS2GHAtg==} + engines: {node: '>=22'} peerDependencies: - '@types/react': '>=19.0.0' - react: '>=19.0.0' + '@types/react': '>=19.3.0' + react: '>=19.3.0' react-devtools-core: '>=6.1.2' peerDependenciesMeta: '@types/react': @@ -3773,6 +3777,10 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + quick-lru@7.3.0: + resolution: {integrity: sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==} + engines: {node: '>=18'} + range-parser@1.3.0: resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} @@ -3791,11 +3799,11 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-reconciler@0.33.0: - resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + react-reconciler@0.34.0: + resolution: {integrity: sha512-eVibvB1fc9j5Qf1KRkiM3GzRPBdjg2P6G6QV2f3P6Eb3OGMxRBhdg2rrUwjbV5JrQaJj8nOa8uajmmJA5tJWjQ==} engines: {node: '>=0.10.0'} peerDependencies: - react: ^19.2.0 + react: ^19.3.0 react@19.3.0: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} @@ -3887,8 +3895,8 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} secretlint@10.2.2: resolution: {integrity: sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==} @@ -4050,6 +4058,10 @@ packages: resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} engines: {node: '>=20'} + string-width@8.3.0: + resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} + engines: {node: '>=20'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -4281,8 +4293,8 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} - type-fest@5.7.0: - resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} + type-fest@5.10.0: + resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} engines: {node: '>=20'} type-is@2.1.0: @@ -4444,6 +4456,10 @@ packages: resolution: {integrity: sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==} engines: {node: '>=20'} + wrap-ansi@10.0.2: + resolution: {integrity: sha512-6OLRNZqRntVGBm26ghZ5eZhCuWWyOkRCSn8XsOIuq5stpCE/tjLbdozd93naILZig0lmD8ANfRwnhn0DLeXGlg==} + engines: {node: '>=20'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -4630,7 +4646,7 @@ snapshots: dependencies: json-schema: 0.4.0 - '@alcalzone/ansi-tokenize@0.2.5': + '@alcalzone/ansi-tokenize@0.3.1': dependencies: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 @@ -6028,6 +6044,8 @@ snapshots: ansi-styles@6.2.3: {} + ansi-styles@7.0.0: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -6289,7 +6307,7 @@ snapshots: ci-parallel-vars@1.0.1: {} - cli-boxes@3.0.0: {} + cli-boxes@4.0.1: {} cli-cursor@4.0.0: dependencies: @@ -6577,7 +6595,7 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 - es-toolkit@1.47.0: {} + es-toolkit@1.52.0: {} esbuild@0.28.1: optionalDependencies: @@ -7106,37 +7124,37 @@ snapshots: ini@1.3.8: optional: true - ink-big-text@2.0.0(ink@6.8.0(patch_hash=7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): + ink-big-text@2.0.0(ink@8.0.0(patch_hash=ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): dependencies: cfonts: 3.3.1 - ink: 6.8.0(patch_hash=7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430)(@types/react@19.3.0)(react@19.3.0) + ink: 8.0.0(patch_hash=ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f)(@types/react@19.3.0)(react@19.3.0) prop-types: 15.8.1 react: 19.3.0 - ink-gradient@4.0.1(ink@6.8.0(patch_hash=7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): + ink-gradient@4.0.1(ink@8.0.0(patch_hash=ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): dependencies: '@types/gradient-string': 1.1.6 gradient-string: 3.0.0 - ink: 6.8.0(patch_hash=7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430)(@types/react@19.3.0)(react@19.3.0) + ink: 8.0.0(patch_hash=ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f)(@types/react@19.3.0)(react@19.3.0) react: 19.3.0 strip-ansi: 7.2.0 - ink-select-input@6.2.0(ink@6.8.0(patch_hash=7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): + ink-select-input@6.2.0(ink@8.0.0(patch_hash=ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): dependencies: figures: 6.1.0 - ink: 6.8.0(patch_hash=7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430)(@types/react@19.3.0)(react@19.3.0) + ink: 8.0.0(patch_hash=ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f)(@types/react@19.3.0)(react@19.3.0) react: 19.3.0 to-rotated: 1.0.0 - ink-spinner@5.0.0(ink@6.8.0(patch_hash=7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): + ink-spinner@5.0.0(ink@8.0.0(patch_hash=ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): dependencies: cli-spinners: 2.9.2 - ink: 6.8.0(patch_hash=7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430)(@types/react@19.3.0)(react@19.3.0) + ink: 8.0.0(patch_hash=ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f)(@types/react@19.3.0)(react@19.3.0) react: 19.3.0 - ink-tab@5.2.0(@types/react@19.3.0)(ink@6.8.0(patch_hash=7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): + ink-tab@5.2.0(@types/react@19.3.0)(ink@8.0.0(patch_hash=ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): dependencies: - ink: 6.8.0(patch_hash=7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430)(@types/react@19.3.0)(react@19.3.0) + ink: 8.0.0(patch_hash=ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f)(@types/react@19.3.0)(react@19.3.0) react: 19.3.0 optionalDependencies: '@types/react': 19.3.0 @@ -7145,39 +7163,34 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - ink@6.8.0(patch_hash=7e15be6e336403a9a44ee7061a39ffa483385d69dbb08b750c732f8aaff14430)(@types/react@19.3.0)(react@19.3.0): + ink@8.0.0(patch_hash=ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f)(@types/react@19.3.0)(react@19.3.0): dependencies: - '@alcalzone/ansi-tokenize': 0.2.5 + '@alcalzone/ansi-tokenize': 0.3.1 ansi-escapes: 7.3.0 - ansi-styles: 6.2.3 + ansi-styles: 7.0.0 auto-bind: 5.0.1 - chalk: 5.6.2 - cli-boxes: 3.0.0 + chalk: 6.0.1 + cli-boxes: 4.0.1 cli-cursor: 4.0.0 - cli-truncate: 5.2.0 + cli-truncate: 6.1.1 code-excerpt: 4.0.0 - es-toolkit: 1.47.0 + es-toolkit: 1.52.0 indent-string: 5.0.0 is-in-ci: 2.0.0 patch-console: 2.0.0 + quick-lru: 7.3.0 react: 19.3.0 - react-reconciler: 0.33.0(react@19.3.0) - scheduler: 0.27.0 + react-reconciler: 0.34.0(react@19.3.0) signal-exit: 3.0.7 - slice-ansi: 8.0.0 stack-utils: 2.0.6 - string-width: 8.2.2 + string-width: 8.3.0 terminal-size: 4.0.1 - type-fest: 5.7.0 + type-fest: 5.10.0 widest-line: 6.0.0 - wrap-ansi: 9.0.2 - ws: 8.21.3 + wrap-ansi: 10.0.2 yoga-layout: 3.2.1 optionalDependencies: '@types/react': 19.3.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate ip-address@10.5.0: {} @@ -7917,6 +7930,8 @@ snapshots: quick-format-unescaped@4.0.4: {} + quick-lru@7.3.0: {} + range-parser@1.3.0: {} raw-body@3.0.2: @@ -7945,10 +7960,10 @@ snapshots: react-is@16.13.1: {} - react-reconciler@0.33.0(react@19.3.0): + react-reconciler@0.34.0(react@19.3.0): dependencies: react: 19.3.0 - scheduler: 0.27.0 + scheduler: 0.28.0 react@19.3.0: {} @@ -8035,7 +8050,7 @@ snapshots: sax@1.6.0: {} - scheduler@0.27.0: {} + scheduler@0.28.0: {} secretlint@10.2.2: dependencies: @@ -8219,6 +8234,11 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 + string-width@8.3.0: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -8445,7 +8465,7 @@ snapshots: type-fest@4.41.0: {} - type-fest@5.7.0: + type-fest@5.10.0: dependencies: tagged-tag: 1.0.0 @@ -8604,6 +8624,11 @@ snapshots: ansi-styles: 6.2.3 string-width: 8.2.2 + wrap-ansi@10.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6caf68489..8e701f0c4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -44,4 +44,4 @@ onlyBuiltDependencies: patchedDependencies: '@ai-sdk/openai@3.0.71': patches/@ai-sdk__openai@3.0.71.patch - ink@6.8.0: patches/ink@6.8.0.patch + ink@8.0.0: patches/ink@8.0.0.patch diff --git a/source/cli.tsx b/source/cli.tsx index c2f34d89a..fe097bd83 100644 --- a/source/cli.tsx +++ b/source/cli.tsx @@ -882,9 +882,8 @@ async function main(): Promise { ENABLE_BRACKETED_PASTE, pasteEvents, } = await import('@/utils/terminal-paste'); - const {splitControlKeypresses} = await import( - '@/utils/terminal-keypress' - ); + const {createXtermModifiedEnterRewriter, splitControlKeypresses} = + await import('@/utils/terminal-keypress'); // Bracketed paste in both screen modes. Without it the terminal // sends a paste as bare bytes, so the CR at each line break @@ -927,6 +926,9 @@ async function main(): Promise { const filtered = new PassThrough(); const decodeInput = createUtf8InputDecoder(); const extractPastes = createPasteExtractor(); + const rewriteEnter = createXtermModifiedEnterRewriter(text => { + queueKeypresses(splitControlKeypresses(text)); + }); let carry = ''; const forwardInput = (chunk: Buffer | string) => { const text = decodeInput(chunk); @@ -940,7 +942,7 @@ async function main(): Promise { wheelEvents.emit('wheel', direction); } if (result.clean) { - queueKeypresses(splitControlKeypresses(result.clean)); + rewriteEnter.push(result.clean); } }; // Ink drains everything buffered on each 'readable', so pieces @@ -967,6 +969,7 @@ async function main(): Promise { }; process.stdin.on('data', forwardInput); stopInputForwarding = () => { + rewriteEnter.dispose(); pendingKeypresses.length = 0; process.stdin.off('data', forwardInput); process.stdin.pause(); diff --git a/source/components/input-swap.spec.tsx b/source/components/input-swap.spec.tsx new file mode 100644 index 000000000..48022c66c --- /dev/null +++ b/source/components/input-swap.spec.tsx @@ -0,0 +1,51 @@ +import test from 'ava'; +import {Text, useInput} from 'ink'; +import {render} from 'ink-testing-library'; +import React from 'react'; + +const wait = (ms = 100) => new Promise(resolve => setTimeout(resolve, ms)); + +// A same-commit unmount/mount of useInput must not leave the new step unable +// to receive input. Wait for the rendered step instead of requiring Ink's +// timing-dependent key-loss window to occur. +function Host({seen}: {seen: string[]}) { + const [step, setStep] = React.useState<'a' | 'b'>('a'); + return step === 'a' ? ( + setStep('b')} /> + ) : ( + + ); +} + +function ChildA({onNext}: {onNext: () => void}) { + useInput((input, key) => { + if (key.return) onNext(); + }); + return Step A; +} + +function ChildB({seen}: {seen: string[]}) { + useInput(input => { + seen.push(input); + }); + return Step B; +} + +test.serial('same-commit useInput swap keeps input flowing', async t => { + const seen: string[] = []; + const {stdin, lastFrame, unmount} = render(); + t.teardown(unmount); + await wait(); + t.is(lastFrame(), 'Step A'); + stdin.write('\r'); + for (let attempt = 0; attempt < 50 && lastFrame() !== 'Step B'; attempt++) { + await wait(20); + } + t.is(lastFrame(), 'Step B', 'the input must transition to the new step'); + await wait(150); + stdin.write('q'); + await wait(); + stdin.write('l'); + await wait(); + t.deepEqual(seen, ['q', 'l'], 'input must keep flowing after the step swap'); +}); diff --git a/source/components/text-input-keys.spec.tsx b/source/components/text-input-keys.spec.tsx index 76e1b2d56..fc8114c09 100644 --- a/source/components/text-input-keys.spec.tsx +++ b/source/components/text-input-keys.spec.tsx @@ -122,11 +122,9 @@ test('component Delete with cursor at end does nothing', async t => { }); // --- Backspace (\x7f) --- -// Ink parses the physical Backspace (\x7f — sent by macOS Terminal, iTerm2 -// and essentially all Linux terminals) as `key.delete`, NOT `key.backspace` -// (which Ink reserves for \b/Ctrl+H). So Backspace must be routed to a -// backward delete by matching the raw sequence, distinct from forward Delete -// (\x1b[3~). +// Since Ink 7 the physical Backspace byte (\x7f — sent by macOS Terminal, +// iTerm2 and essentially all Linux terminals) sets `key.backspace`, distinct +// from forward Delete (\x1b[3~) which sets `key.delete`. test('component Backspace (\x7f) removes the character before the cursor', async t => { const valueRef: ValueRef = {current: ''}; @@ -175,8 +173,8 @@ test('component Backspace (\x7f) with cursor at start does nothing', async t => // --- Option/Alt+Backspace (\x1b\x7f) --- // On macOS/Linux, Option/Alt+Backspace sends ESC followed by DEL ('\x1b\x7f'). -// Ink parses it as `key.delete`, exactly like forward Delete ('\x1b[3~'), so -// it must be routed to a backward delete via the raw sequence. +// Ink parses it as `key.backspace` with meta set — same backward-delete +// behaviour as bare Backspace. test('component Alt+Backspace (\x1b\x7f) removes the char before the cursor', async t => { const valueRef: ValueRef = {current: ''}; @@ -202,7 +200,7 @@ test('component Alt+Backspace (\x1b\x7f) removes the char before the cursor', as }); // A forward Delete (\x1b[3~) must NOT be treated as a Backspace even when the -// cursor is mid-line — this pins the raw-sequence disambiguation. +// cursor is mid-line — this pins the delete/backspace disambiguation. test('component Delete (\x1b[3~) still forward-deletes, distinct from Backspace', async t => { const valueRef: ValueRef = {current: ''}; const {stdin, unmount} = render( diff --git a/source/components/text-input-newline.spec.tsx b/source/components/text-input-newline.spec.tsx index 7ee024f64..752bf4832 100644 --- a/source/components/text-input-newline.spec.tsx +++ b/source/components/text-input-newline.spec.tsx @@ -1,6 +1,8 @@ import test from 'ava'; import {render} from 'ink-testing-library'; import React, {useState} from 'react'; +import {createTerminalInputFilter} from '@/utils/terminal-input'; +import {rewriteXtermModifiedEnter} from '@/utils/terminal-keypress'; import TextInput from './text-input'; /** @@ -12,7 +14,9 @@ import TextInput from './text-input'; * * - ESC+CR submitted the prompt instead of inserting a newline * - the xterm modifyOtherKeys form was unparseable by Ink and its ESC-stripped - * sequence was inserted into the prompt as the literal text `[27;2;13~` + * sequence was inserted into the prompt as the literal text `[27;2;13~`; Ink 8 + * drops unrecognised control sequences outright, so cli.tsx now rewrites + * that form into kitty CSI-u before the bytes reach Ink — mirrored below * - the kitty CSI-u form appended '\n' to the end of the value rather than at * the cursor, and left the cursor stranded in front of it, so the next * character typed went on the wrong line @@ -21,7 +25,7 @@ import TextInput from './text-input'; * LF (Ctrl+J) \n * ESC+CR (Option/Alt+Enter) ESC \r * kitty CSI-u Shift+Enter ESC [13;2u - * xterm modifyOtherKeys ESC [27;2;13~ + * xterm modifyOtherKeys ESC [27;2;13~ (rewritten to ESC [13;2u) * bare CR (Enter) \r * Left ESC [D */ @@ -30,7 +34,9 @@ const ESC = '\u001b'; const LF = '\n'; const ESC_CR = `${ESC}\r`; const KITTY_SHIFT_ENTER = `${ESC}[13;2u`; -const XTERM_SHIFT_ENTER = `${ESC}[27;2;13~`; +// What xterm.js actually emits; the stdin proxy in cli.tsx rewrites this into +// KITTY_SHIFT_ENTER before Ink sees it, so mirror that composition here. +const XTERM_SHIFT_ENTER = rewriteXtermModifiedEnter(`${ESC}[27;2;13~`); const LEFT = `${ESC}[D`; interface ValueRef { @@ -59,6 +65,20 @@ const press = (stdin: ReturnType['stdin'], key: string) => const settle = () => new Promise(resolve => setTimeout(resolve, 100)); +// Match the CLI filtering order, but feed each filtered piece directly into +// ink-testing-library's readable stream instead of the CLI's PassThrough queue. +function createInputProxy(stdin: ReturnType['stdin']) { + const pastes: string[] = []; + const filter = createTerminalInputFilter( + segment => { + if (segment.kind === 'paste') pastes.push(segment.payload); + else stdin.write(segment.text); + }, + () => {}, + ); + return {...filter, pastes}; +} + const newlineSequences: Array<[string, string]> = [ ['LF (Ctrl+J)', LF], ['ESC+CR (Option/Alt+Enter)', ESC_CR], @@ -114,6 +134,59 @@ for (const [label, sequence] of newlineSequences) { }); } +for (const [label, modifier] of [ + ['Shift', 2], + ['Alt', 3], +] as const) { + test(`fragmented xterm ${label}+Enter survives the input proxy at every boundary`, async t => { + const valueRef: ValueRef = {current: ''}; + const {stdin, unmount} = render( + , + ); + const proxy = createInputProxy(stdin); + t.teardown(() => { + proxy.dispose(); + unmount(); + }); + await settle(); + const sequence = `${ESC}[27;${modifier};13~`; + let expected = 'aaa'; + for (let boundary = 1; boundary < sequence.length; boundary++) { + proxy.push(Buffer.from(sequence.slice(0, boundary))); + await new Promise(resolve => setImmediate(resolve)); + proxy.push(Buffer.from(sequence.slice(boundary))); + await settle(); + expected += '\n'; + t.is(valueRef.current, expected, `boundary ${boundary}`); + proxy.push('b'); + await settle(); + expected += 'b'; + t.is(valueRef.current, expected, `cursor after boundary ${boundary}`); + } + }); +} + +test('the input proxy leaves bracketed paste payloads opaque', async t => { + const valueRef: ValueRef = {current: ''}; + const {stdin, unmount} = render( + , + ); + const proxy = createInputProxy(stdin); + t.teardown(() => { + proxy.dispose(); + unmount(); + }); + await settle(); + const payload = `pasted\n${ESC}[27;2;13~${ESC}[<64;1;1M`; + proxy.push(`${ESC}[200~${payload.slice(0, 12)}`); + proxy.push(`${payload.slice(12)}${ESC}[201~${ESC}[<64;1;1M`); + proxy.push(`b${ESC}[27;3;`); + proxy.push('13~'); + await settle(); + t.deepEqual(proxy.pastes, [payload]); + t.is(valueRef.current, 'aaab\n'); +}); + test('consecutive newlines stack instead of overwriting each other', async t => { const valueRef: ValueRef = {current: ''}; const {stdin, unmount} = render( diff --git a/source/components/text-input.tsx b/source/components/text-input.tsx index 9e8fc370d..5130305f3 100644 --- a/source/components/text-input.tsx +++ b/source/components/text-input.tsx @@ -1,3 +1,4 @@ +import type {EventEmitter} from 'node:events'; import chalk from 'chalk'; import {type Key, Text, useInput, useStdin} from 'ink'; import { @@ -31,7 +32,12 @@ import { * re-subscribe in. */ export function useRawInput() { - const {internal_eventEmitter: emitter} = useStdin(); + // Ink 8 hides this internal member from its public type, but still returns + // the context containing the same emitter used by useInput. Keep #1670's + // patch-independent capture without a patched Key field or runtime Ink patch. + const {internal_eventEmitter: emitter} = useStdin() as ReturnType< + typeof useStdin + > & {internal_eventEmitter: EventEmitter}; const rawRef = useRef(''); useEffect(() => { @@ -345,6 +351,13 @@ const TextInput = forwardRef(function TextInput( useInput( (input, key) => { + // Kitty keyboard protocol reports key release events; with + // reportEventTypes enabled every press would otherwise be handled + // twice (once for press, once for release). + if (key.eventType === 'release') { + return; + } + if ((key.ctrl && input === 'c') || key.tab || (key.shift && key.tab)) { return; } @@ -570,10 +583,7 @@ const TextInput = forwardRef(function TextInput( nextCursorOffset = start; } } else if (key.delete) { - // Delete removes the character after the cursor (forward delete). - // Only reached for the forward Delete key (\x1b[3~); the - // physical Backspace (\x7f) and Option/Alt+Backspace (\x1b\x7f) - // are handled in the branch above. + // Delete removes the character after the cursor (forward delete, '\x1b[3~'). if (cursorOffsetRef.current < originalValueRef.current.length) { nextValue = originalValueRef.current.slice(0, cursorOffsetRef.current) + @@ -605,6 +615,8 @@ const TextInput = forwardRef(function TextInput( if (nextCursorOffset > nextValue.length) { nextCursorOffset = nextValue.length; } + // Never let the caret sit inside a surrogate pair. + nextCursorOffset = snapToCharBoundary(nextValue, nextCursorOffset); // Update refs immediately so the next event in the same stdin.read() // block sees the correct values (Ink doesn't re-render between events) diff --git a/source/storage/storage-app.tsx b/source/storage/storage-app.tsx index fa05dff45..90a24a66d 100644 --- a/source/storage/storage-app.tsx +++ b/source/storage/storage-app.tsx @@ -30,7 +30,8 @@ function size(bytes: number): string { /** Ink's stdout, not process.stdout, is the surface used by the renderer. */ function useViewport() { - const {stdout} = useStdout(); + const {stdout: stream} = useStdout(); + const stdout = stream as NodeJS.WriteStream; const [viewport, setViewport] = useState(() => ({ columns: stdout.columns || 80, rows: stdout.rows || 24, diff --git a/source/utils/newline-key.spec.ts b/source/utils/newline-key.spec.ts index 0aa27cd05..bb98c2b8a 100644 --- a/source/utils/newline-key.spec.ts +++ b/source/utils/newline-key.spec.ts @@ -7,10 +7,8 @@ import {isNewlineKey} from './newline-key'; * plus `useInput`'s mapping actually produce for each byte sequence, captured * by running the sequence through `ink/build/parse-keypress.js`. Each case * names the sequence and the terminal that sends it. - * - * Note that Ink strips the leading ESC from `input` but leaves `raw` intact, - * which is why the modifyOtherKeys cases below differ between the two. */ +const ESC = '\u001b'; const keyFor = (overrides: Partial): Key => ({ upArrow: false, @@ -32,8 +30,6 @@ const keyFor = (overrides: Partial): Key => ...overrides, }) as Key; -const ESC = '\u001b'; - // --- recognised as newline --- test('LF from Ctrl+J inserts a newline', t => { @@ -53,9 +49,8 @@ test('Shift+Enter in the kitty CSI-u encoding inserts a newline', t => { ); }); -test('ESC+CR (Option+Enter on macOS) inserts a newline', t => { - // ESC CR -> name 'return', option true, which Ink surfaces as meta. Ink - // leaves raw undefined for this one. +test('Alt+Enter in the kitty CSI-u encoding inserts a newline', t => { + // ESC [13;3u -> name 'return', meta true. t.true(isNewlineKey('\r', keyFor({return: true, meta: true}))); }); diff --git a/source/utils/newline-key.ts b/source/utils/newline-key.ts index 78f927af4..6a3051d40 100644 --- a/source/utils/newline-key.ts +++ b/source/utils/newline-key.ts @@ -1,14 +1,6 @@ import type {Key} from 'ink'; -/** - * Shift+Enter and friends, as the sequences terminals actually emit. - * - * `\x1b[27;;13~` is xterm's modifyOtherKeys=2 encoding of Enter with a - * modifier, which xterm.js (VS Code, and anything embedding it) sends. Ink's - * keypress parser does not recognise it at all — `name` comes back empty — so - * without this it lands in TextInput's generic insert branch and the literal - * text `[27;2;13~` ends up in the prompt. Modifier 2 is Shift, 3 is Alt. - */ +// Keep #1670's raw-sequence fallback for callers outside the CLI proxy. const XTERM_MODIFIED_ENTER = /^\x1b\[27;[23];13~$/; /** @@ -25,10 +17,12 @@ const XTERM_MODIFIED_ENTER = /^\x1b\[27;[23];13~$/; * - a literal LF, from Ctrl+J or a `sendSequence`-style keybinding bound to `\n` * - Ctrl+J reported as a modified letter, which is how it arrives under the * kitty keyboard protocol - * - Enter with Shift, the kitty / CSI-u encoding (`\x1b[13;2u`) - * - Enter with Meta, i.e. ESC+CR (`\x1b\r`) — Option+Enter on macOS, and the - * sequence terminals are conventionally configured to send for Shift+Enter - * - Enter with Shift or Alt in xterm's modifyOtherKeys form + * - Enter with Shift or Meta (Alt) — the kitty / CSI-u encoding + * (`\x1b[13;2u`, `\x1b[13;3u`) and ESC+CR (`\x1b\r`, Option+Enter on macOS). + * The xterm modifyOtherKeys form (`\x1b[27;2;13~`, sent by the VS Code + * integrated terminal) is rewritten into the CSI-u form by the stdin proxy + * in cli.tsx before the bytes reach Ink. The raw-sequence fallback remains + * for callers that do not use that proxy. * * Bare Shift+Enter is deliberately absent: most terminals send it as a plain * `\r`, byte-identical to Enter, so it cannot be told apart from submit. Those diff --git a/source/utils/terminal-keypress.spec.ts b/source/utils/terminal-keypress.spec.ts index 2a1792440..2b25d42ee 100644 --- a/source/utils/terminal-keypress.spec.ts +++ b/source/utils/terminal-keypress.spec.ts @@ -1,5 +1,8 @@ import test from 'ava'; -import {splitControlKeypresses} from './terminal-keypress.js'; +import { + createXtermModifiedEnterRewriter, + splitControlKeypresses, +} from './terminal-keypress.js'; test('splits a run of Backspaces into one piece per key', t => { t.deepEqual(splitControlKeypresses('\x7f\x7f\x7f'), ['\x7f', '\x7f', '\x7f']); @@ -32,3 +35,111 @@ test('keeps escape sequences and Meta chords intact', t => { test('returns nothing for empty input', t => { t.deepEqual(splitControlKeypresses(''), []); }); + +for (const modifier of [2, 3, 5, 8]) { + test(`rewrites modified Enter (${modifier}) at every chunk boundary`, t => { + const sequence = `\x1b[27;${modifier};13~`; + for (let boundary = 1; boundary < sequence.length; boundary++) { + const output: string[] = []; + const rewriter = createXtermModifiedEnterRewriter(text => output.push(text)); + rewriter.push(sequence.slice(0, boundary)); + t.deepEqual(output, [], `boundary ${boundary} must stay buffered`); + rewriter.push(sequence.slice(boundary)); + t.deepEqual(output, [`\x1b[13;${modifier}u`], `boundary ${boundary}`); + rewriter.dispose(); + } + }); +} + +test('rewrites byte-by-byte input and coalesced keys in order', t => { + const output: string[] = []; + const rewriter = createXtermModifiedEnterRewriter(text => output.push(text)); + t.teardown(() => rewriter.dispose()); + for (const byte of '\x1b[27;2;13~') rewriter.push(byte); + rewriter.push('a\x1b[27;3;13~\x7f\x1b[27;'); + rewriter.push('2;13~b'); + t.is(output.join(''), '\x1b[13;2ua\x1b[13;3u\x7f\x1b[13;2ub'); +}); + +test('passes text, other escape sequences and malformed candidates unchanged', t => { + const output: string[] = []; + const rewriter = createXtermModifiedEnterRewriter(text => output.push(text)); + t.teardown(() => rewriter.dispose()); + const text = 'hello\r\n\t\x1b[A\x1b\r\x1b\x7f\x1b[13;2u\x1b[27;2;9~'; + rewriter.push(text); + t.deepEqual(output, [text]); + rewriter.push('\x1b[27;2;1'); + rewriter.push('4~tail'); + t.is(output.join(''), `${text}\x1b[27;2;14~tail`); +}); + +// A partial held back by one push is never dropped: push() clears the +// pending timer, then merges the carry into the next chunk (text = carry + +// chunk), so the held-back bytes are re-included and re-examined wholesale. +for (const [first, second, afterFirst, expected] of [ + ['\x1b[27;', 'q', [], ['\x1b[27;q']], + ['\x1b[27;', 'ab\r', [], ['\x1b[27;ab\r']], + ['a\x1b[27;2;13', '\x1b[A', ['a'], ['a', '\x1b[27;2;13\x1b[A']], +] as const) { + test(`re-includes a held-back partial when the next chunk does not extend it (${JSON.stringify(second)})`, t => { + const output: string[] = []; + const rewriter = createXtermModifiedEnterRewriter(text => output.push(text)); + t.teardown(() => rewriter.dispose()); + rewriter.push(first); + t.deepEqual(output, afterFirst); + rewriter.push(second); + t.deepEqual(output, expected); + }); +} + + + +test('flush() emits a held-back partial immediately, unrewritten, and cancels its timer', async t => { + const output: string[] = []; + const rewriter = createXtermModifiedEnterRewriter(text => output.push(text)); + t.teardown(() => rewriter.dispose()); + rewriter.push('before\x1b[27;2;13'); + t.deepEqual(output, ['before']); + rewriter.flush(); + t.deepEqual(output, ['before', '\x1b[27;2;13']); + await new Promise(resolve => setTimeout(resolve, 50)); + // Timer cancelled: no duplicate emit. + t.deepEqual(output, ['before', '\x1b[27;2;13']); + // Empty carry: flush is a no-op, not a stray empty emit. + rewriter.flush(); + t.deepEqual(output, ['before', '\x1b[27;2;13']); + rewriter.push('after'); + t.deepEqual(output, ['before', '\x1b[27;2;13', 'after']); +}); + +test('bounds incomplete sequence buffering', t => { + const output: string[] = []; + const rewriter = createXtermModifiedEnterRewriter(text => output.push(text)); + t.teardown(() => rewriter.dispose()); + const text = `\x1b[27;${'2'.repeat(100)}`; + for (const byte of text) rewriter.push(byte); + t.is(output.join(''), text); +}); + +for (const pending of ['\x1b', '\x1b[', '\x1b[27;2;13']) { + test(`flushes an incomplete sequence ${JSON.stringify(pending)} on timeout`, async t => { + const output: string[] = []; + const rewriter = createXtermModifiedEnterRewriter(text => output.push(text)); + t.teardown(() => rewriter.dispose()); + rewriter.push(`before${pending}`); + t.deepEqual(output, ['before']); + await new Promise(resolve => setTimeout(resolve, 50)); + t.deepEqual(output, ['before', pending]); + rewriter.push('after'); + t.deepEqual(output, ['before', pending, 'after']); + }); +} + +test('disposing cancels pending input without forwarding it after shutdown', async t => { + const output: string[] = []; + const rewriter = createXtermModifiedEnterRewriter(text => output.push(text)); + rewriter.push('\x1b[27;2;'); + rewriter.dispose(); + await new Promise(resolve => setTimeout(resolve, 50)); + t.deepEqual(output, []); +}); diff --git a/source/utils/terminal-keypress.ts b/source/utils/terminal-keypress.ts index b50c39f11..0526259d6 100644 --- a/source/utils/terminal-keypress.ts +++ b/source/utils/terminal-keypress.ts @@ -25,6 +25,89 @@ const isSplittableControl = (code: number): boolean => code !== 0x1b) || // ESC, which Ink's parser already splits on code === 0x7f; // DEL (Backspace on most terminals) +// xterm's modifyOtherKeys=2 encoding of Enter with a modifier — `ESC [27;;13~` +// where is 2 for Shift, 3 for Alt, 5 for Ctrl — which the VS Code integrated +// terminal sends. Ink cannot parse it at all (empty key name) and, since Ink 8, +// drops such unrecognised control sequences before they reach `useInput`. The +// kitty CSI-u form `ESC [13;u` encodes exactly the same key + modifier with +// the same modifier numbering, and Ink parses it natively, so rewrite one into +// the other before the bytes reach Ink. +const XTERM_MODIFIED_ENTER = /\x1b\[27;(\d+);13~/g; + +export function rewriteXtermModifiedEnter(text: string): string { + return text.replaceAll(XTERM_MODIFIED_ENTER, '\x1b[13;$1u'); +} + +const XTERM_ENTER_PREFIX = '\x1b[27;'; +const MAX_PENDING_ENTER_LENGTH = 32; +const ENTER_SEQUENCE_TIMEOUT_MS = 20; + +/** + * Rewrite modified Enter across stdin chunks. Hold only a possible trailing + * sequence, with a size limit and Ink's 20ms escape timeout so a standalone + * Escape (or an incomplete/malformed sequence) cannot stay buffered forever. + * Call after extracting bracketed pastes, whose payloads must remain opaque. + */ +export function createXtermModifiedEnterRewriter( + onText: (text: string) => void, +) { + let carry = ''; + let timeout: ReturnType | undefined; + + return { + push(chunk: string): void { + if (!chunk) return; + clearTimeout(timeout); + timeout = undefined; + const text = carry + chunk; + carry = ''; + const escapeIndex = text.lastIndexOf('\x1b'); + const tail = escapeIndex === -1 ? '' : text.slice(escapeIndex); + const isPartial = + tail.length > 0 && + tail.length <= MAX_PENDING_ENTER_LENGTH && + (XTERM_ENTER_PREFIX.startsWith(tail) || + (tail.startsWith(XTERM_ENTER_PREFIX) && + /^\d+(?:;(?:1(?:3)?)?)?$/.test( + tail.slice(XTERM_ENTER_PREFIX.length), + ))); + + const ready = isPartial ? text.slice(0, escapeIndex) : text; + if (isPartial) { + carry = tail; + timeout = setTimeout(() => { + const pending = carry; + carry = ''; + timeout = undefined; + onText(pending); + }, ENTER_SEQUENCE_TIMEOUT_MS); + timeout.unref(); + } + if (ready) onText(rewriteXtermModifiedEnter(ready)); + }, + /** + * Emit any held-back partial sequence now, unrewritten — the same + * bytes the 20ms timeout would have flushed. Callers use this to + * preserve stream order when something else must be emitted + * immediately: a bracketed paste that arrives after a held-back tail + * must not be reordered ahead of it. + */ + flush(): void { + clearTimeout(timeout); + timeout = undefined; + if (!carry) return; + const pending = carry; + carry = ''; + onText(pending); + }, + dispose(): void { + clearTimeout(timeout); + timeout = undefined; + carry = ''; + }, + }; +} + export function splitControlKeypresses(text: string): string[] { const pieces: string[] = []; let current = '';