diff --git a/docs/cli/settings.md b/docs/cli/settings.md index ba6e0ed316..af308bc21a 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -81,6 +81,7 @@ they appear in the UI. | Terminal Buffer | `ui.terminalBuffer` | Use the new terminal buffer architecture for rendering. | `false` | | Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` | | Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` | +| Max Scrollback Length | `ui.maxScrollbackLength` | Maximum number of lines to keep in the terminal scrollback buffer. | `1000` | | Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` | | Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, all, or off. | `"off"` | | Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 293d99313c..dadf837856 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -447,6 +447,12 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes +- **`ui.maxScrollbackLength`** (number): + - **Description:** Maximum number of lines to keep in the terminal scrollback + buffer. + - **Default:** `1000` + - **Requires restart:** Yes + - **`ui.showSpinner`** (boolean): - **Description:** Show the spinner during operations. diff --git a/package-lock.json b/package-lock.json index efffd49396..cd2f4fcc7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "packages/*" ], "dependencies": { - "ink": "npm:@jrichman/ink@6.6.9", + "ink": "npm:@jrichman/ink@7.1.0", "latest-version": "9.0.0", "node-fetch-native": "1.6.7", "proper-lockfile": "4.1.2", @@ -449,8 +449,7 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", - "license": "(Apache-2.0 AND BSD-3-Clause)", - "peer": true + "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@bundled-es-modules/cookie": { "version": "2.0.1", @@ -1517,7 +1516,6 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -1568,6 +1566,7 @@ "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=18.14.1" }, @@ -1654,6 +1653,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } @@ -2084,6 +2084,7 @@ "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -2125,6 +2126,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2141,7 +2143,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@mswjs/interceptors": { "version": "0.39.5", @@ -2240,7 +2243,6 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2421,7 +2423,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2471,7 +2472,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2822,7 +2822,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2857,7 +2856,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" @@ -2913,7 +2911,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", @@ -4140,7 +4137,6 @@ "integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4256,6 +4252,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/node": "*" } @@ -5117,7 +5114,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7818,7 +7814,6 @@ "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8426,7 +8421,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -8471,6 +8465,7 @@ "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ip-address": "^10.2.0" }, @@ -8610,7 +8605,8 @@ "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/fast-string-width": { "version": "3.0.2", @@ -8619,6 +8615,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "fast-string-truncated-width": "^3.0.2" } @@ -8646,6 +8643,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "fast-string-width": "^3.0.2" } @@ -9998,11 +9996,10 @@ }, "node_modules/ink": { "name": "@jrichman/ink", - "version": "6.6.9", - "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz", - "integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-7.1.0.tgz", + "integrity": "sha512-OM49V37BUVfbG77zyT3YIDvwKosEOz1fBIBY5FI00DefrJGcfDc4GUVynb9GdjwwJwSNMe39VLH3VdG4bJ718A==", "license": "MIT", - "peer": true, "dependencies": { "ansi-escapes": "^7.0.0", "ansi-styles": "^6.2.3", @@ -10902,6 +10899,7 @@ "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", "license": "MIT", "optional": true, + "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -10994,7 +10992,8 @@ "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause", - "optional": true + "optional": true, + "peer": true }, "node_modules/json-stable-stringify": { "version": "1.3.0", @@ -11996,7 +11995,6 @@ "devOptional": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@bundled-es-modules/cookie": "^2.0.1", "@bundled-es-modules/statuses": "^1.0.1", @@ -13660,7 +13658,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13671,7 +13668,6 @@ "integrity": "sha512-ldFwzufLletzCikNJVYaxlxMLu7swJ3T2VrGfzXlMsVhZhPDKXA38DEROidaYZVgMAmQnIjymrmqto5pyfrwPA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -14092,7 +14088,8 @@ "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/reusify": { "version": "1.1.0", @@ -14519,7 +14516,8 @@ "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/set-function-length": { "version": "1.2.2", @@ -15555,6 +15553,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=20" }, @@ -15834,7 +15833,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15886,6 +15884,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tldts-core": "^7.4.3" }, @@ -15899,7 +15898,8 @@ "integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/tmp": { "version": "0.2.5", @@ -15940,6 +15940,7 @@ "dev": true, "license": "BSD-3-Clause", "optional": true, + "peer": true, "dependencies": { "tldts": "^7.0.5" }, @@ -16093,8 +16094,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -16102,7 +16102,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16268,7 +16267,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16336,7 +16334,6 @@ "integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.30.1", "@typescript-eslint/types": "8.30.1", @@ -16633,6 +16630,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "funding": { "url": "https://github.com/sponsors/kettanaito" } @@ -16727,7 +16725,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -17298,7 +17295,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17311,7 +17307,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17813,7 +17808,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -17960,7 +17954,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -18178,7 +18171,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -18254,7 +18246,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -18316,7 +18307,6 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -18503,7 +18493,7 @@ "fzf": "0.5.2", "glob": "12.0.0", "highlight.js": "11.11.1", - "ink": "npm:@jrichman/ink@6.6.9", + "ink": "npm:@jrichman/ink@7.1.0", "ink-gradient": "3.0.0", "ink-spinner": "5.0.0", "latest-version": "9.0.0", @@ -18830,7 +18820,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -18917,7 +18906,6 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -19590,6 +19578,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=18" }, @@ -19602,8 +19591,7 @@ "version": "0.0.1367902", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz", "integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "packages/core/node_modules/dotenv": { "version": "17.2.4", @@ -19766,7 +19754,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.1.tgz", "integrity": "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -19934,7 +19921,6 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -20117,6 +20103,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" @@ -20140,6 +20127,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/figures": "^2.0.7", @@ -20168,6 +20156,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } @@ -20179,6 +20168,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, @@ -20198,6 +20188,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", @@ -20244,6 +20235,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/set-cookie-parser": "^2.4.10", "set-cookie-parser": "^3.0.1" @@ -20257,6 +20249,7 @@ "hasInstallScript": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", @@ -20301,7 +20294,8 @@ "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "packages/core/node_modules/vitest/node_modules/mute-stream": { "version": "3.0.0", @@ -20310,6 +20304,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -20321,6 +20316,7 @@ "dev": true, "license": "(MIT OR CC0-1.0)", "optional": true, + "peer": true, "dependencies": { "tagged-tag": "^1.0.0" }, @@ -20559,7 +20555,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -20583,7 +20578,6 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -21335,7 +21329,6 @@ "integrity": "sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.31.1", "@typescript-eslint/types": "8.31.1", @@ -21557,7 +21550,6 @@ "integrity": "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -21642,7 +21634,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -21786,7 +21777,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 6ed55e598b..d248688efe 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "pre-commit": "node scripts/pre-commit.js" }, "overrides": { - "ink": "npm:@jrichman/ink@6.6.9", + "ink": "npm:@jrichman/ink@7.1.0", "wrap-ansi": "9.0.2", "cliui": { "wrap-ansi": "7.0.0" @@ -145,7 +145,7 @@ "yargs": "17.7.2" }, "dependencies": { - "ink": "npm:@jrichman/ink@6.6.9", + "ink": "npm:@jrichman/ink@7.1.0", "latest-version": "9.0.0", "node-fetch-native": "1.6.7", "proper-lockfile": "4.1.2", diff --git a/packages/cli/GEMINI.md b/packages/cli/GEMINI.md index 8bad8f0721..1afb7a35aa 100644 --- a/packages/cli/GEMINI.md +++ b/packages/cli/GEMINI.md @@ -12,6 +12,10 @@ `MaxSizedBox.tsx`) to ensure size measurements are captured as soon as the element is available, avoiding potential rendering timing issues. - Avoid prop drilling when at all possible. +- **StaticRender**: Unlike Ink's native `` (which is printed above the + application layout and takes no space in the flex container), the custom + `` component preserves its layout and _does_ take up its + measured height in the active flex container. ## Testing diff --git a/packages/cli/package.json b/packages/cli/package.json index 12e0ddcb8a..89067c672f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,7 +49,7 @@ "fzf": "0.5.2", "glob": "12.0.0", "highlight.js": "11.11.1", - "ink": "npm:@jrichman/ink@6.6.9", + "ink": "npm:@jrichman/ink@7.1.0", "ink-gradient": "3.0.0", "ink-spinner": "5.0.0", "latest-version": "9.0.0", diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 3a87cba3ab..ae6ad437d0 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -842,6 +842,16 @@ const SETTINGS_SCHEMA = { 'Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.', showInDialog: true, }, + maxScrollbackLength: { + type: 'number', + label: 'Max Scrollback Length', + category: 'UI', + requiresRestart: true, + default: 1000, + description: + 'Maximum number of lines to keep in the terminal scrollback buffer.', + showInDialog: true, + }, showSpinner: { type: 'boolean', label: 'Show Spinner', diff --git a/packages/cli/src/interactiveCli.tsx b/packages/cli/src/interactiveCli.tsx index 266788745e..2408b0f9c2 100644 --- a/packages/cli/src/interactiveCli.tsx +++ b/packages/cli/src/interactiveCli.tsx @@ -167,6 +167,7 @@ export async function startInteractiveUI( useAlternateBuffer && !isShpool, debugRainbow: settings.merged.ui.debugRainbow === true, + maxScrollbackLength: settings.merged.ui.maxScrollbackLength, }, ); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 2c3e424ae4..46270b7e61 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import React from 'react'; import { useIsScreenReaderEnabled } from 'ink'; import { useUIState } from './contexts/UIStateContext.js'; import { StreamingContext } from './contexts/StreamingContext.js'; @@ -13,7 +14,7 @@ import { DefaultAppLayout } from './layouts/DefaultAppLayout.js'; import { AlternateBufferQuittingDisplay } from './components/AlternateBufferQuittingDisplay.js'; import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; -export const App = () => { +export const App = React.memo(() => { const uiState = useUIState(); const isAlternateBuffer = useAlternateBuffer(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); @@ -35,4 +36,6 @@ export const App = () => { {isScreenReaderEnabled ? : } ); -}; +}); + +App.displayName = 'App'; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index e11282788b..9b14818955 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1557,6 +1557,13 @@ Logging in with Google... Restarting Gemini CLI to continue. terminalHeight - stableControlsHeight - backgroundTaskHeight - 1, ); + // In terminalBuffer mode, we return terminalHeight - 1 to prevent frequent + // invalidation of UIState. This value is correct for the few cases where a + // fixed terminal height must be respected. + const uiStateAvailableTerminalHeight = config.getUseTerminalBuffer() + ? terminalHeight - 1 + : availableTerminalHeight; + config.setShellExecutionConfig({ terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), terminalHeight: Math.max( @@ -2488,7 +2495,6 @@ Logging in with Google... Restarting Gemini CLI to continue. ctrlDPressedOnce: ctrlDPressCount >= 1, shortcutsHelpVisible, cleanUiDetailsVisible, - isFocused, elapsedTime, currentLoadingPhrase, currentTip, @@ -2502,7 +2508,7 @@ Logging in with Google... Restarting Gemini CLI to continue. currentModel, contextFileNames, errorCount, - availableTerminalHeight, + availableTerminalHeight: uiStateAvailableTerminalHeight, stableControlsHeight, mainAreaWidth, staticAreaMaxItemHeight, @@ -2601,7 +2607,6 @@ Logging in with Google... Restarting Gemini CLI to continue. ctrlDPressCount, shortcutsHelpVisible, cleanUiDetailsVisible, - isFocused, elapsedTime, currentLoadingPhrase, currentTip, @@ -2614,7 +2619,7 @@ Logging in with Google... Restarting Gemini CLI to continue. allowPlanMode, contextFileNames, errorCount, - availableTerminalHeight, + uiStateAvailableTerminalHeight, stableControlsHeight, mainAreaWidth, staticAreaMaxItemHeight, diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap index 611f2e0908..b40f0b7304 100644 --- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap @@ -61,6 +61,28 @@ Tips for getting started: 2. /help for more information 3. Ask coding questions, edit code or run commands 4. Be specific for the best results + + + + + + + + + + + + + + + + + + + + + + Composer " `; @@ -109,42 +131,42 @@ DialogManager `; exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = ` -" - ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ - ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ - ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ +" ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ - Gemini CLI v1.2.3 - - - -Tips for getting started: -1. Create GEMINI.md files to customize your interactions -2. /help for more information -3. Ask coding questions, edit code or run commands -4. Be specific for the best results -HistoryItemDisplay -╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Action Required │ -│ │ -│ ? ls list directory │ -│ │ -│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ -│ │ ls │ │ -│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ -│ Allow execution of [ls]? │ -│ │ -│ ● 1. Allow once │ -│ 2. Allow for this session │ -│ 3. No, suggest changes (esc) │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ - - - - - - + Gemini CLI v1.2.3 █ + █ + █ + █ +Tips for getting started: █ +1. Create GEMINI.md files to customize your interactions █ +2. /help for more information █ +3. Ask coding questions, edit code or run commands █ +4. Be specific for the best results █ + █ + █ + █ + █ + █ + █ + █ + █ + █ +HistoryItemDisplay █ +╭──────────────────────────────────────────────────────────────────────────────────────────────────█ +│ Action Required █ +│ █ +│ ? ls list directory █ +│ █ +│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ █ +│ │ ls │ █ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ █ +│ Allow execution of [ls]? █ +│ █ +│ ● 1. Allow once █ +│ 2. Allow for this session █ +│ 3. No, suggest changes (esc) █ +╰──────────────────────────────────────────────────────────────────────────────────────────────────█ Notifications Composer diff --git a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg index ac1af5663e..7c9891d5a4 100644 --- a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg +++ b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg @@ -4,13 +4,6 @@ - - - > - - Can you edit InputPrompt.tsx for me? - - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ ? Edit @@ -73,7 +66,7 @@ true ; - │▄ + 48 @@ -83,7 +76,7 @@ true ; - │█ + 49 @@ -93,7 +86,7 @@ true ; - │█ + 50 @@ -103,7 +96,7 @@ true ; - │█ + 51 @@ -113,7 +106,7 @@ true ; - │█ + 52 @@ -123,7 +116,7 @@ true ; - │█ + 53 @@ -133,7 +126,7 @@ true ; - │█ + 54 diff --git a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap index 0eae00fab2..be3f22bddd 100644 --- a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap @@ -1,8 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = ` -" > Can you edit InputPrompt.tsx for me? -▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +" + ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ? Edit packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto… │ @@ -12,13 +12,13 @@ exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation bo │ │ 44 const line44 = true; │ │ │ │ 45 const line45 = true; │ │ │ │ 46 const line46 = true; │ │ -│ │ 47 const line47 = true; │ │▄ -│ │ 48 const line48 = true; │ │█ -│ │ 49 const line49 = true; │ │█ -│ │ 50 const line50 = true; │ │█ -│ │ 51 const line51 = true; │ │█ -│ │ 52 const line52 = true; │ │█ -│ │ 53 const line53 = true; │ │█ +│ │ 47 const line47 = true; │ │ +│ │ 48 const line48 = true; │ │ +│ │ 49 const line49 = true; │ │ +│ │ 50 const line50 = true; │ │ +│ │ 51 const line51 = true; │ │ +│ │ 52 const line52 = true; │ │ +│ │ 53 const line53 = true; │ │ │ │ 54 const line54 = true; │ │█ │ │ 55 const line55 = true; │ │█ │ │ 56 const line56 = true; │ │█ diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 0594f2ed3a..311bdd5af2 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -44,6 +44,7 @@ import { useSettings } from '../contexts/SettingsContext.js'; interface HistoryItemDisplayProps { item: HistoryItem; + itemKey?: string; availableTerminalHeight?: number; terminalWidth: number; isPending: boolean; @@ -57,6 +58,7 @@ interface HistoryItemDisplayProps { export const HistoryItemDisplay: React.FC = ({ item, + itemKey, availableTerminalHeight, terminalWidth, isPending, @@ -102,6 +104,7 @@ export const HistoryItemDisplay: React.FC = ({ )} {itemForDisplay.type === 'gemini' && ( = ({ )} {itemForDisplay.type === 'gemini_content' && ( = ({ )} {itemForDisplay.type === 'tool_group' && ( = ({ const pasteTimeoutRef = useRef(null); const innerBoxRef = useRef(null); const hasUserNavigatedSuggestions = useRef(false); - const listRef = useRef>(null); + const listRef = useRef>(null); const { isRecording, handleVoiceInput, resetTurnBaseline } = useVoiceMode({ buffer, @@ -1869,14 +1869,13 @@ export const InputPrompt: React.FC = ({ height={Math.min(buffer.viewportHeight, scrollableData.length)} width="100%" > - {config.getUseTerminalBuffer() ? ( - 1} - fixedItemHeight={true} + itemHeight={1} keyExtractor={(item) => item.type === 'visualLine' ? `line-${item.absoluteVisualIdx}` @@ -1884,7 +1883,7 @@ export const InputPrompt: React.FC = ({ } width={inputWidth + SCROLLBAR_GUTTER_WIDTH} backgroundColor={listBackgroundColor} - containerHeight={Math.min( + maxHeight={Math.min( buffer.viewportHeight, scrollableData.length, )} diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 0aea3236ce..c34d95e732 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -358,6 +358,7 @@ describe('MainContent', () => { bannerVisible: false, copyModeEnabled: false, terminalWidth: 100, + mouseMode: true, }; beforeEach(() => { @@ -803,7 +804,6 @@ describe('MainContent', () => { expect(output).toContain('Planning execution'); expect(output).toContain('Refining approach'); expect(output).toMatchSnapshot(); - await expect(renderResult).toMatchSvgSnapshot(); renderResult.unmount(); }); diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 046550de51..f4990e8cec 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -22,6 +22,7 @@ import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js'; import { useConfirmingTool } from '../hooks/useConfirmingTool.js'; import { ToolConfirmationQueue } from './ToolConfirmationQueue.js'; import { appEvents, AppEvent } from '../../utils/events.js'; +import { useInputState } from '../contexts/InputContext.js'; const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay); const MemoizedAppHeader = memo(AppHeader); @@ -37,6 +38,7 @@ export const MainContent = () => { const config = useConfig(); const useTerminalBuffer = config.getUseTerminalBuffer(); const isAlternateBuffer = config.getUseAlternateBuffer(); + const { copyModeEnabled } = useInputState(); const confirmingTool = useConfirmingTool(); const showConfirmationQueue = confirmingTool !== null; @@ -114,6 +116,7 @@ export const MainContent = () => { isToolGroupBoundary, }) => ( { ], ); - const virtualizedData = useMemo( - () => [ - { type: 'header' as const }, - ...augmentedHistory.map((data, index) => ({ + const headerItem = useMemo(() => ({ type: 'header' as const }), []); + + const historyVirtualizedItems = useMemo( + () => + augmentedHistory.map((data, index) => ({ type: 'history' as const, item: data.item, element: historyItems[index], })), - { type: 'pending' as const }, - ], [augmentedHistory, historyItems], ); + const virtualizedData = useMemo( + () => [ + headerItem, + ...historyVirtualizedItems, + { type: 'pending' as const, pendingHistoryItems }, + ], + [headerItem, historyVirtualizedItems, pendingHistoryItems], + ); + const renderItem = useCallback( ({ item }: { item: (typeof virtualizedData)[number] }) => { if (item.type === 'header') { @@ -234,7 +245,7 @@ export const MainContent = () => { [showHeaderDetails, version, pendingItems], ); - const estimatedItemHeight = useCallback(() => 100, []); + const estimatedItemHeight = useCallback(() => 10, []); const keyExtractor = useCallback( (item: (typeof virtualizedData)[number], _index: number) => { @@ -249,7 +260,7 @@ export const MainContent = () => { // interactive. Gemini messages and Tool results that are not scrollable, // collapsible, or clickable should also be tagged as static in the future. const isStaticItem = useCallback( - (item: (typeof virtualizedData)[number]) => item.type === 'header', + (item: (typeof virtualizedData)[number]) => item.type !== 'pending', [], ); @@ -271,7 +282,7 @@ export const MainContent = () => { renderStatic={useTerminalBuffer} isStaticItem={useTerminalBuffer ? isStaticItem : undefined} overflowToBackbuffer={useTerminalBuffer && !isAlternateBuffer} - scrollbar={mouseMode} + scrollbar={mouseMode && !copyModeEnabled} /> // TODO(jacobr): consider adding stableScrollback={!config.getUseAlternateBuffer()} // as that will reduce the # of cases where we will have to clear the @@ -295,6 +306,7 @@ export const MainContent = () => { isStaticItem, mouseMode, isAlternateBuffer, + copyModeEnabled, ]); if (!uiState.isConfigInitialized) { diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap index 9090335b03..ffbb9666d4 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap @@ -213,24 +213,3 @@ AppHeader(full) │ refine the solution. " `; - -exports[`MainContent > renders multiple thinking messages sequentially correctly 2`] = ` -"ScrollableList -AppHeader(full) -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - > Plan a solution -▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - Thinking... - │ - │ Initial analysis - │ This is a multiple line paragraph for the first thinking message of how the - │ model analyzes the problem. - │ - │ Planning execution - │ This a second multiple line paragraph for the second thinking message - │ explaining the plan in detail so that it wraps around the terminal display. - │ - │ Refining approach - │ And finally a third multiple line paragraph for the third thinking message to - │ refine the solution." -`; diff --git a/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx b/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx index 586ce89ab2..52d3543593 100644 --- a/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { renderWithProviders } from '../../../test-utils/render.js'; import { createMockSettings } from '../../../test-utils/settings.js'; import { waitFor } from '../../../test-utils/async.js'; @@ -22,6 +22,7 @@ import type { SerializableConfirmationDetails, ToolResultDisplay, } from '../../types.js'; +import { VirtualizedListContext } from '../shared/VirtualizedList.js'; describe('DenseToolMessage', () => { const defaultProps = { @@ -563,6 +564,114 @@ describe('DenseToolMessage', () => { // Verify it shows the diff when expanded expect(lastFrame()).toContain('new line'); }); + + it('shows diff content when globally expanded inside a VirtualizedList context', async () => { + const mockListContext = { + registerInteractivity: vi.fn(), + setItemState: vi.fn(), + getItemState: vi.fn(), + isItemToggled: vi.fn().mockReturnValue(false), + toggleItem: vi.fn(), + registerClickCallback: vi.fn(), + unregisterClickCallback: vi.fn(), + registerClickableArea: vi.fn(), + unregisterClickableArea: vi.fn(), + }; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + + } + > + + , + { + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + toolActions: { + isExpanded: () => true, + }, + }, + ); + await waitUntilReady(); + + expect(lastFrame()).toContain('new line'); + }); + + it('toggles expansion when header is clicked', async () => { + const toggleExpansion = vi.fn(); + const toggleItem = vi.fn(); + let registeredCallback: (() => void) | undefined; + + const MockVirtualizedListWrapper = ({ + children, + }: { + children: React.ReactNode; + }) => { + const itemKey = 'item-1'; + const mockListContext = { + toggleItem, + registerClickCallback: vi.fn((key, id, cb) => { + if (key === itemKey && id === 'toggle-call-1') { + registeredCallback = cb; + } + }), + unregisterClickCallback: vi.fn(), + registerInteractivity: vi.fn(), + setItemState: vi.fn(), + getItemState: vi.fn(), + isItemToggled: vi.fn().mockReturnValue(false), + registerClickableArea: vi.fn(), + unregisterClickableArea: vi.fn(), + }; + + return ( + + } + > + {children} + + ); + }; + + const { waitUntilReady } = await renderWithProviders( + + + , + { + toolActions: { + toggleExpansion, + }, + }, + ); + + await waitUntilReady(); + + await waitFor(() => expect(registeredCallback).toBeDefined()); + + // Trigger the registered callback manually (simulating VirtualizedList behavior) + if (registeredCallback) { + registeredCallback(); + } + + expect(toggleItem).toHaveBeenCalledWith('item-1'); + }); }); describe('Visual Regression', () => { diff --git a/packages/cli/src/ui/components/messages/DenseToolMessage.tsx b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx index f5e4b31c66..1d9dadee38 100644 --- a/packages/cli/src/ui/components/messages/DenseToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx @@ -5,8 +5,8 @@ */ import type React from 'react'; -import { useMemo, useState, useRef } from 'react'; -import { Box, Text, type DOMElement } from 'ink'; +import { useMemo, useContext, useCallback, useEffect } from 'react'; +import { Box, Text } from 'ink'; import { CoreToolCallStatus, type FileDiff, @@ -32,13 +32,14 @@ import { isNewFile, parseDiffWithLineNumbers, } from './DiffRenderer.js'; -import { useMouseClick } from '../../hooks/useMouseClick.js'; import { ScrollableList } from '../shared/ScrollableList.js'; import { COMPACT_TOOL_SUBVIEW_MAX_LINES } from '../../constants.js'; import { useSettings } from '../../contexts/SettingsContext.js'; import { colorizeCode } from '../../utils/CodeColorizer.js'; import { useToolActions } from '../../contexts/ToolActionsContext.js'; import { getFileExtension } from '../../utils/fileUtils.js'; +import { VirtualizedListContext } from '../shared/VirtualizedList.js'; +import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.js'; const PAYLOAD_MARGIN_LEFT = 6; const PAYLOAD_BORDER_CHROME_WIDTH = 4; // paddingX=1 (2 cols) + borders (2 cols) @@ -46,6 +47,8 @@ const PAYLOAD_SCROLL_GUTTER = 4; const PAYLOAD_MAX_WIDTH = 120 + PAYLOAD_SCROLL_GUTTER; interface DenseToolMessageProps extends IndividualToolCallDisplay { + itemKey?: string; + groupKey?: string; terminalWidth: number; availableTerminalHeight?: number; } @@ -260,6 +263,8 @@ function getGenericSuccessData( export const DenseToolMessage: React.FC = (props) => { const { + itemKey, + groupKey, callId, name, status, @@ -274,15 +279,45 @@ export const DenseToolMessage: React.FC = (props) => { const settings = useSettings(); const isAlternateBuffer = useAlternateBuffer(); const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions(); + const virtualizedListContext = useContext(VirtualizedListContext); - // Handle optional context members - const [localIsExpanded, setLocalIsExpanded] = useState(false); - const isExpanded = isExpandedInContext - ? isExpandedInContext(callId) - : localIsExpanded; + const effectiveItemKey = groupKey ?? itemKey; - const [isFocused, setIsFocused] = useState(false); - const toggleRef = useRef(null); + // Determine expansion state based on list context or fallback to tool actions + const isExpanded = useMemo(() => { + const isExpandedGlobally = isExpandedInContext + ? isExpandedInContext(callId) + : false; + if (effectiveItemKey && virtualizedListContext) { + return ( + virtualizedListContext.isItemToggled(effectiveItemKey) || + isExpandedGlobally + ); + } + return isExpandedGlobally; + }, [effectiveItemKey, virtualizedListContext, isExpandedInContext, callId]); + + const handleToggle = useCallback(() => { + if (effectiveItemKey && virtualizedListContext?.toggleItem) { + virtualizedListContext.toggleItem(effectiveItemKey); + } else if (toggleExpansion) { + toggleExpansion(callId); + } + }, [effectiveItemKey, virtualizedListContext, toggleExpansion, callId]); + + useEffect(() => { + if (virtualizedListContext && effectiveItemKey) { + virtualizedListContext.registerInteractivity(effectiveItemKey, { + click: true, + }); + } + }, [virtualizedListContext, effectiveItemKey]); + + const clickableProps = useVirtualizedListClick( + effectiveItemKey, + `toggle-${callId}`, + handleToggle, + ); // Unified File Data Extraction (Safely bridge resultDisplay and confirmationDetails) const diff = useMemo((): FileDiff | undefined => { @@ -301,25 +336,6 @@ export const DenseToolMessage: React.FC = (props) => { return undefined; }, [resultDisplay, confirmationDetails]); - const handleToggle = () => { - const next = !isExpanded; - if (!next) { - setIsFocused(false); - } else { - setIsFocused(true); - } - - if (toggleExpansion) { - toggleExpansion(callId); - } else { - setLocalIsExpanded(next); - } - }; - - useMouseClick(toggleRef, handleToggle, { - isActive: isAlternateBuffer && !!diff, - }); - // State-to-View Coordination const viewParts = useMemo((): ViewParts => { if (diff) { @@ -449,7 +465,12 @@ export const DenseToolMessage: React.FC = (props) => { return ( - + @@ -463,12 +484,7 @@ export const DenseToolMessage: React.FC = (props) => { {summary && ( - + {summary} )} @@ -489,23 +505,18 @@ export const DenseToolMessage: React.FC = (props) => { borderColor={theme.border.default} borderDimColor={true} maxWidth={Math.min( - PAYLOAD_MAX_WIDTH, + PAYLOAD_MAX_WIDTH + PAYLOAD_BORDER_CHROME_WIDTH, terminalWidth - PAYLOAD_MARGIN_LEFT, )} > 1} - hasFocus={isFocused} - width={Math.min( - PAYLOAD_MAX_WIDTH, - terminalWidth - - PAYLOAD_MARGIN_LEFT - - PAYLOAD_BORDER_CHROME_WIDTH - - PAYLOAD_SCROLL_GUTTER, - )} + hasFocus={false} + width="100%" /> )} diff --git a/packages/cli/src/ui/components/messages/DenseToolMessageInteractivity.test.tsx b/packages/cli/src/ui/components/messages/DenseToolMessageInteractivity.test.tsx new file mode 100644 index 0000000000..a2a7915da1 --- /dev/null +++ b/packages/cli/src/ui/components/messages/DenseToolMessageInteractivity.test.tsx @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../../test-utils/render.js'; +import { waitFor } from '../../../test-utils/async.js'; +import { VirtualizedList } from '../shared/VirtualizedList.js'; +import { DenseToolMessage } from './DenseToolMessage.js'; +import { Box } from 'ink'; +import { CoreToolCallStatus, makeFakeConfig } from '@google/gemini-cli-core'; +import { createMockSettings } from '../../../test-utils/settings.js'; +import { describe, it, expect } from 'vitest'; + +describe('DenseToolMessage Interactivity in VirtualizedList', () => { + const keyExtractor = (item: { id: string }) => item.id; + + it('toggles expansion when header is clicked in a VirtualizedList', async () => { + const data = [{ id: '1' }]; + const diffResult = { + fileName: 'test.ts', + filePath: 'test.ts', + fileDiff: '--- test.ts\n+++ test.ts\n@@ -1,1 +1,1 @@\n-old\n+new', + diffStat: { model_added_lines: 1, model_removed_lines: 1 }, + originalContent: 'old', + newContent: 'new', + }; + + // We need to monitor if toggleItem is called on the list context + // Actually, VirtualizedList handles its own state. + // We can verify that it renders the payload after click. + + const { simulateClick, waitUntilReady, lastFrame } = + await renderWithProviders( + + 1} + renderItem={() => ( + ['resultDisplay'] + } + terminalWidth={80} + description="test" + confirmationDetails={undefined} + /> + )} + /> + , + { + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + mouseEventsEnabled: true, + }, + ); + + await waitUntilReady(); + + // Initially it should be collapsed (no payload shown because of alternate buffer mode) + expect(lastFrame()).toContain('edit'); + expect(lastFrame()).toContain('test.ts'); + expect(lastFrame()).not.toContain('new'); + + // Click on the first line (the header), avoiding the left margin + await simulateClick(10, 1); + + // Now it should be expanded and show the diff payload + await waitFor(() => expect(lastFrame()).toContain('new'), { + timeout: 5000, + }); + }); + + it('wakes up static DenseToolMessage and toggles on click', async () => { + const data = [{ id: '1' }]; + const diffResult = { + fileName: 'test.ts', + filePath: 'test.ts', + fileDiff: '--- test.ts\n+++ test.ts\n@@ -1,1 +1,1 @@\n-old\n+new', + diffStat: { model_added_lines: 1, model_removed_lines: 1 }, + originalContent: 'old', + newContent: 'new', + }; + + const { simulateClick, waitUntilReady, lastFrame } = + await renderWithProviders( + + 1} + renderItem={() => ( + ['resultDisplay'] + } + terminalWidth={80} + description="test" + confirmationDetails={undefined} + /> + )} + isStaticItem={() => true} // Force static rendering + /> + , + { + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + mouseEventsEnabled: true, + }, + ); + + await waitUntilReady(); + + // Static item should still show the header + expect(lastFrame()).toContain('edit'); + expect(lastFrame()).not.toContain('new'); + + // Click to wake up and toggle + await simulateClick(10, 1); + + // Should wake up and expand + await waitFor(() => expect(lastFrame()).toContain('new'), { + timeout: 5000, + }); + }); +}); diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx index bc2246d23f..55d52314f2 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx @@ -13,6 +13,7 @@ import { useUIState } from '../../contexts/UIStateContext.js'; interface GeminiMessageProps { text: string; + itemKey?: string; isPending: boolean; availableTerminalHeight?: number; terminalWidth: number; @@ -20,6 +21,7 @@ interface GeminiMessageProps { export const GeminiMessage: React.FC = ({ text, + itemKey, isPending, availableTerminalHeight, terminalWidth, @@ -37,6 +39,7 @@ export const GeminiMessage: React.FC = ({ = ({ text, + itemKey, isPending, availableTerminalHeight, terminalWidth, @@ -35,6 +37,7 @@ export const GeminiMessageContent: React.FC = ({ return ( { }; interface ToolGroupMessageProps { + itemKey?: string; item: HistoryItem | HistoryItemWithoutId; toolCalls: IndividualToolCallDisplay[]; availableTerminalHeight?: number; @@ -108,6 +110,7 @@ interface ToolGroupMessageProps { const TOOL_MESSAGE_HORIZONTAL_MARGIN = 4; export const ToolGroupMessage: React.FC = ({ + itemKey, item, toolCalls: allToolCalls, availableTerminalHeight, @@ -141,6 +144,11 @@ export const ToolGroupMessage: React.FC = ({ } = useUIState(); const config = useConfig(); + const { registerInteractivity } = useContext(VirtualizedListContext) ?? {}; + + if (itemKey && registerInteractivity) { + registerInteractivity(itemKey, { click: true, scroll: true }); + } const { borderColor, borderDimColor } = useMemo( () => @@ -425,13 +433,18 @@ export const ToolGroupMessage: React.FC = ({ const tool = group; const isShellToolCall = isShellTool(tool.name); + const uniqueItemKey = itemKey + ? `${itemKey}-tool-${tool.callId}` + : undefined; const commonProps = { ...tool, + itemKey: uniqueItemKey, + groupKey: itemKey, availableTerminalHeight: availableTerminalHeightPerToolMessage, terminalWidth: contentWidth, emphasis: 'medium' as const, - isFirst: isCompact ? false : isFirstProp, + isFirst: isFirstProp, borderColor, borderDimColor, isExpandable, diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 269f00b12d..6281ad686d 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -29,6 +29,7 @@ import { useToolActions } from '../../contexts/ToolActionsContext.js'; export type { TextEmphasis }; export interface ToolMessageProps extends IndividualToolCallDisplay { + itemKey?: string; availableTerminalHeight?: number; terminalWidth: number; emphasis?: TextEmphasis; @@ -44,6 +45,7 @@ export interface ToolMessageProps extends IndividualToolCallDisplay { export const ToolMessage: React.FC = ({ callId, + itemKey, name, description, resultDisplay, @@ -139,6 +141,7 @@ export const ToolMessage: React.FC = ({ /> )} = ({ + itemKey, resultDisplay, availableTerminalHeight, terminalWidth, @@ -194,6 +196,7 @@ export const ToolResultDisplay: React.FC = ({ return ( = ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const data = resultDisplay as AnsiOutput; - // Calculate list height: if not constrained, use full data length. - // If constrained (e.g. alternate buffer), limit to available height - // to ensure virtualization works and fits within the viewport. - const listHeight = !constrainHeight - ? data.length - : Math.min(data.length, limit); + // In alternate buffer, always constrain to limit to ensure virtualization works and fits viewport. + const listHeight = isAlternateBuffer + ? Math.min(data.length, limit) + : !constrainHeight + ? data.length + : Math.min(data.length, limit); if (isAlternateBuffer) { const initialScrollIndex = @@ -226,13 +229,13 @@ export const ToolResultDisplay: React.FC = ({ return ( - 1} - fixedItemHeight={true} + itemHeight={1} keyExtractor={keyExtractor} initialScrollIndex={initialScrollIndex} hasFocus={hasFocus} diff --git a/packages/cli/src/ui/components/messages/ToolStickyHeaderRegression.test.tsx b/packages/cli/src/ui/components/messages/ToolStickyHeaderRegression.test.tsx index 137aa901f9..73ad4544b0 100644 --- a/packages/cli/src/ui/components/messages/ToolStickyHeaderRegression.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolStickyHeaderRegression.test.tsx @@ -130,7 +130,7 @@ describe('ToolMessage Sticky Header Regression', () => { // Scroll further so tool-1 is completely gone and tool-2's header should be stuck await act(async () => { - listRef?.scrollBy(17); + listRef?.scrollBy(15); }); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/messages/TopicMessage.test.tsx b/packages/cli/src/ui/components/messages/TopicMessage.test.tsx index 5da630cb86..acc078716c 100644 --- a/packages/cli/src/ui/components/messages/TopicMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/TopicMessage.test.tsx @@ -14,6 +14,11 @@ import { CoreToolCallStatus, UPDATE_TOPIC_TOOL_NAME, } from '@google/gemini-cli-core'; +import { VirtualizedListContext } from '../shared/VirtualizedList.js'; +import { Box, type DOMElement, getBoundingBox } from 'ink'; +import { useMouse } from '../../contexts/MouseContext.js'; +import { useCallback, useRef } from 'react'; +import type React from 'react'; describe('', () => { const baseArgs = { @@ -30,21 +35,86 @@ describe('', () => { isExpanded?: (callId: string) => boolean; toggleExpansion?: (callId: string) => void; }, - ) => - renderWithProviders( - , + virtualizedListProps?: { + itemKey?: string; + }, + ) => { + const defaultItemKey = virtualizedListProps?.itemKey || 'test-topic-key'; + + const MockVirtualizedListWrapper: React.FC<{ + children: React.ReactNode; + }> = ({ children }) => { + const callbacks = useRef(new Map void>()); + + const mockListContext = { + registerInteractivity: vi.fn(), + setItemState: vi.fn(), + getItemState: vi.fn(), + isItemToggled: vi.fn().mockReturnValue(false), + toggleItem: vi.fn(), + registerClickCallback: vi.fn((key, id, cb) => { + if (key === defaultItemKey) callbacks.current.set(id, cb); + }), + unregisterClickCallback: vi.fn((key, id) => { + if (key === defaultItemKey) callbacks.current.delete(id); + }), + registerClickableArea: vi.fn(), + unregisterClickableArea: vi.fn(), + toggledKeys: new Set(), + }; + + const containerRef = useRef(null); + const handleMouse = useCallback( + (event: { name: string; col: number; row: number }) => { + if (event.name === 'left-press' && containerRef.current) { + const { + x, + y, + width, + height: elHeight, + } = getBoundingBox(containerRef.current); + const mouseX = event.col - 1; + const mouseY = event.row - 1; + if ( + mouseX >= x && + mouseX < x + width && + mouseY >= y && + mouseY < y + elHeight + ) { + const cb = callbacks.current.get('toggle'); + if (cb) cb(); + } + } + }, + [callbacks], + ); + useMouse(handleMouse, { isActive: true }); + + return ( + + {children} + + ); + }; + + return renderWithProviders( + + + , { toolActions, mouseEventsEnabled: true }, ); + }; it('renders title and intent by default (collapsed)', async () => { const { lastFrame } = await renderTopic(baseArgs, 40); diff --git a/packages/cli/src/ui/components/messages/TopicMessage.tsx b/packages/cli/src/ui/components/messages/TopicMessage.tsx index e58e60f6e1..e51f421d47 100644 --- a/packages/cli/src/ui/components/messages/TopicMessage.tsx +++ b/packages/cli/src/ui/components/messages/TopicMessage.tsx @@ -5,8 +5,8 @@ */ import type React from 'react'; -import { useEffect, useId, useRef, useCallback } from 'react'; -import { Box, Text, type DOMElement } from 'ink'; +import { useEffect, useId, useCallback } from 'react'; +import { Box, Text } from 'ink'; import { UPDATE_TOPIC_TOOL_NAME, UPDATE_TOPIC_DISPLAY_NAME, @@ -18,12 +18,14 @@ import type { IndividualToolCallDisplay } from '../../types.js'; import { theme } from '../../semantic-colors.js'; import { useOverflowActions } from '../../contexts/OverflowContext.js'; import { useToolActions } from '../../contexts/ToolActionsContext.js'; -import { useMouseClick } from '../../hooks/useMouseClick.js'; +import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.js'; interface TopicMessageProps extends IndividualToolCallDisplay { terminalWidth: number; availableTerminalHeight?: number; isExpandable?: boolean; + // TopicMessage is only interactive when rendered inside VirtualizedList. + itemKey?: string; } export const isTopicTool = (name: string): boolean => @@ -34,6 +36,7 @@ export const TopicMessage: React.FC = ({ args, availableTerminalHeight, isExpandable = true, + itemKey, }) => { const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions(); @@ -47,7 +50,6 @@ export const TopicMessage: React.FC = ({ const overflowActions = useOverflowActions(); const uniqueId = useId(); const overflowId = `topic-${uniqueId}`; - const containerRef = useRef(null); const rawTitle = args?.[TOPIC_PARAM_TITLE]; const title = typeof rawTitle === 'string' ? rawTitle : undefined; @@ -75,9 +77,14 @@ export const TopicMessage: React.FC = ({ } }, [toggleExpansion, hasExtraSummary, callId]); - useMouseClick(containerRef, handleToggle, { - isActive: isExpandable && hasExtraSummary, - }); + const clickableProps = useVirtualizedListClick( + itemKey, + 'toggle', + handleToggle, + { + isActive: isExpandable && hasExtraSummary, + }, + ); useEffect(() => { // Only register if there is more content (summary) and it's currently hidden @@ -95,7 +102,7 @@ export const TopicMessage: React.FC = ({ }, [isExpandable, hasExtraSummary, isExpanded, overflowActions, overflowId]); return ( - + {title || 'Topic'} diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap index dda93c1c21..f43a671adc 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap @@ -2,7 +2,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = ` "╭────────────────────────────────────────────────────────────────────────╮ █ -│ ✓ Shell Command Description for Shell Command │ █ +│ ✓ Shell Command Description for Shell Command │ │ │ │ shell-01 │ │ shell-02 │ @@ -10,10 +10,10 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage i `; exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = ` -"╭────────────────────────────────────────────────────────────────────────╮ -│ ✓ Shell Command Description for Shell Command │ ▄ -│────────────────────────────────────────────────────────────────────────│ █ -│ shell-06 │ ▀ +"╭────────────────────────────────────────────────────────────────────────╮ ▄ +│ ✓ Shell Command Description for Shell Command │ ▀ +│────────────────────────────────────────────────────────────────────────│ +│ shell-06 │ │ shell-07 │ " `; @@ -28,8 +28,8 @@ exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessa `; exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 2`] = ` -"╭────────────────────────────────────────────────────────────────────────╮ -│ ✓ tool-1 Description for tool-1 │ █ +"╭────────────────────────────────────────────────────────────────────────╮ ▄ +│ ✓ tool-1 Description for tool-1 │ ▀ │────────────────────────────────────────────────────────────────────────│ │ c1-06 │ │ c1-07 │ @@ -38,9 +38,9 @@ exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessa exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 3`] = ` "│ │ -│ ✓ tool-2 Description for tool-2 │ -│────────────────────────────────────────────────────────────────────────│ -│ c2-10 │ -╰────────────────────────────────────────────────────────────────────────╯ █ +│ ✓ tool-2 Description for tool-2 │ ▄ +│────────────────────────────────────────────────────────────────────────│ ▀ +│ c2-08 │ +│ c2-09 │ " `; diff --git a/packages/cli/src/ui/components/shared/FixedScrollableList.tsx b/packages/cli/src/ui/components/shared/FixedScrollableList.tsx new file mode 100644 index 0000000000..1d21ff4ed8 --- /dev/null +++ b/packages/cli/src/ui/components/shared/FixedScrollableList.tsx @@ -0,0 +1,330 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + useRef, + forwardRef, + useImperativeHandle, + useCallback, + useMemo, + useEffect, + useContext, + useLayoutEffect, +} from 'react'; +import type React from 'react'; +import { + FixedVirtualizedList, + type FixedVirtualizedListRef, + type FixedVirtualizedListProps, + SCROLL_TO_ITEM_END, +} from './FixedVirtualizedList.js'; +import { useScrollable } from '../../contexts/ScrollProvider.js'; +import { Box, type DOMElement } from 'ink'; +import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js'; +import { useKeypress, type Key } from '../../hooks/useKeypress.js'; +import { Command } from '../../key/keyMatchers.js'; +import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; +import { useSettings } from '../../contexts/SettingsContext.js'; +import { VirtualizedListContext } from './VirtualizedList.js'; + +const ANIMATION_FRAME_DURATION_MS = 33; + +interface FixedScrollableListProps extends FixedVirtualizedListProps { + itemKey?: string; + hasFocus: boolean; + width: number; + scrollbar?: boolean; + stableScrollback?: boolean; + isStatic?: boolean; + fixedItemHeight?: boolean; + targetScrollIndex?: number; + scrollbarThumbColor?: string; +} + +export type FixedScrollableListRef = FixedVirtualizedListRef; + +function FixedScrollableList( + props: FixedScrollableListProps, + ref: React.Ref>, +) { + const keyMatchers = useKeyMatchers(); + const settings = useSettings(); + const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength; + const { + itemKey, + hasFocus, + width, + maxHeight, + scrollbar = true, + stableScrollback, + } = props; + const fixedVirtualizedListRef = useRef>(null); + const containerRef = useRef(null); + + const virtualizedListContext = useContext(VirtualizedListContext); + + useLayoutEffect(() => { + if (itemKey && virtualizedListContext) { + const restoredTop = virtualizedListContext.getItemState( + itemKey, + 'scrollTop', + ); + if (typeof restoredTop === 'number') { + fixedVirtualizedListRef.current?.scrollTo(restoredTop); + } + } + }, [itemKey, virtualizedListContext]); + + useEffect( + () => () => { + if (itemKey && virtualizedListContext) { + const top = fixedVirtualizedListRef.current?.getScrollState().scrollTop; + if (top !== undefined) { + virtualizedListContext.setItemState(itemKey, 'scrollTop', top); + } + } + }, + [itemKey, virtualizedListContext], + ); + + useImperativeHandle( + ref, + () => ({ + scrollBy: (delta) => fixedVirtualizedListRef.current?.scrollBy(delta), + scrollTo: (offset) => fixedVirtualizedListRef.current?.scrollTo(offset), + scrollToEnd: () => fixedVirtualizedListRef.current?.scrollToEnd(), + scrollToIndex: (params) => + fixedVirtualizedListRef.current?.scrollToIndex(params), + scrollToItem: (params) => + fixedVirtualizedListRef.current?.scrollToItem(params), + getScrollIndex: () => + fixedVirtualizedListRef.current?.getScrollIndex() ?? 0, + getScrollState: () => + fixedVirtualizedListRef.current?.getScrollState() ?? { + scrollTop: 0, + scrollHeight: 0, + innerHeight: 0, + }, + }), + [], + ); + + const getScrollState = useCallback( + () => + fixedVirtualizedListRef.current?.getScrollState() ?? { + scrollTop: 0, + scrollHeight: 0, + innerHeight: 0, + }, + [], + ); + + const scrollBy = useCallback((delta: number) => { + fixedVirtualizedListRef.current?.scrollBy(delta); + }, []); + + const { scrollbarColor, flashScrollbar, scrollByWithAnimation } = + useAnimatedScrollbar(hasFocus, scrollBy); + + const smoothScrollState = useRef<{ + active: boolean; + start: number; + from: number; + to: number; + duration: number; + timer: NodeJS.Timeout | null; + }>({ active: false, start: 0, from: 0, to: 0, duration: 0, timer: null }); + + const stopSmoothScroll = useCallback(() => { + if (smoothScrollState.current.timer) { + clearInterval(smoothScrollState.current.timer); + smoothScrollState.current.timer = null; + } + smoothScrollState.current.active = false; + }, []); + + useEffect(() => stopSmoothScroll, [stopSmoothScroll]); + + const smoothScrollTo = useCallback( + ( + targetScrollTop: number, + duration: number = process.env['NODE_ENV'] === 'test' ? 0 : 200, + ) => { + stopSmoothScroll(); + + const scrollState = fixedVirtualizedListRef.current?.getScrollState() ?? { + scrollTop: 0, + scrollHeight: 0, + innerHeight: 0, + }; + const { + scrollTop: rawStartScrollTop, + scrollHeight, + innerHeight, + } = scrollState; + + const maxScrollTop = Math.max(0, scrollHeight - innerHeight); + const startScrollTop = Math.min(rawStartScrollTop, maxScrollTop); + + let effectiveTarget = targetScrollTop; + if ( + targetScrollTop === SCROLL_TO_ITEM_END || + targetScrollTop >= maxScrollTop + ) { + effectiveTarget = maxScrollTop; + } + + const clampedTarget = Math.max( + 0, + Math.min(maxScrollTop, effectiveTarget), + ); + + if (duration === 0) { + if ( + targetScrollTop === SCROLL_TO_ITEM_END || + targetScrollTop >= maxScrollTop + ) { + fixedVirtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER); + } else { + fixedVirtualizedListRef.current?.scrollTo(Math.round(clampedTarget)); + } + flashScrollbar(); + return; + } + + smoothScrollState.current = { + active: true, + start: Date.now(), + from: startScrollTop, + to: clampedTarget, + duration, + timer: setInterval(() => { + const now = Date.now(); + const elapsed = now - smoothScrollState.current.start; + const progress = Math.min(elapsed / duration, 1); + + // Ease-in-out + const t = progress; + const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; + + const current = + smoothScrollState.current.from + + (smoothScrollState.current.to - smoothScrollState.current.from) * + ease; + + if (progress >= 1) { + if ( + targetScrollTop === SCROLL_TO_ITEM_END || + targetScrollTop >= maxScrollTop + ) { + fixedVirtualizedListRef.current?.scrollTo( + Number.MAX_SAFE_INTEGER, + ); + } else { + fixedVirtualizedListRef.current?.scrollTo(Math.round(current)); + } + stopSmoothScroll(); + flashScrollbar(); + } else { + fixedVirtualizedListRef.current?.scrollTo(Math.round(current)); + } + }, ANIMATION_FRAME_DURATION_MS), + }; + }, + [stopSmoothScroll, flashScrollbar], + ); + + useKeypress( + (key: Key) => { + if (keyMatchers[Command.SCROLL_UP](key)) { + stopSmoothScroll(); + scrollByWithAnimation(-1); + return true; + } else if (keyMatchers[Command.SCROLL_DOWN](key)) { + stopSmoothScroll(); + scrollByWithAnimation(1); + return true; + } else if ( + keyMatchers[Command.PAGE_UP](key) || + keyMatchers[Command.PAGE_DOWN](key) + ) { + const direction = keyMatchers[Command.PAGE_UP](key) ? -1 : 1; + const scrollState = getScrollState(); + const maxScroll = Math.max( + 0, + scrollState.scrollHeight - scrollState.innerHeight, + ); + const current = smoothScrollState.current.active + ? smoothScrollState.current.to + : Math.min(scrollState.scrollTop, maxScroll); + const innerHeight = scrollState.innerHeight; + smoothScrollTo(current + direction * innerHeight); + return true; + } else if (keyMatchers[Command.SCROLL_HOME](key)) { + smoothScrollTo(0); + return true; + } else if (keyMatchers[Command.SCROLL_END](key)) { + smoothScrollTo(SCROLL_TO_ITEM_END); + return true; + } + return false; + }, + { isActive: hasFocus }, + ); + + const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]); + + const scrollableEntry = useMemo( + () => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + ref: containerRef as React.RefObject, + getScrollState, + scrollBy: scrollByWithAnimation, + scrollTo: smoothScrollTo, + hasFocus: hasFocusCallback, + flashScrollbar, + }), + [ + getScrollState, + hasFocusCallback, + flashScrollbar, + scrollByWithAnimation, + smoothScrollTo, + ], + ); + + useScrollable(scrollableEntry, true); + + return ( + + + + ); +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const FixedScrollableListWithForwardRef = forwardRef(FixedScrollableList) as < + T, +>( + props: FixedScrollableListProps & { + ref?: React.Ref>; + }, +) => React.ReactElement; + +export { FixedScrollableListWithForwardRef as FixedScrollableList }; diff --git a/packages/cli/src/ui/components/shared/FixedVirtualizedList.test.tsx b/packages/cli/src/ui/components/shared/FixedVirtualizedList.test.tsx new file mode 100644 index 0000000000..363eec1501 --- /dev/null +++ b/packages/cli/src/ui/components/shared/FixedVirtualizedList.test.tsx @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act } from 'react'; +import { Box, Text } from 'ink'; +import { describe, expect, it } from 'vitest'; +import { renderWithProviders as render } from '../../../test-utils/render.js'; +import { + FixedVirtualizedList, + SCROLL_TO_ITEM_END, +} from './FixedVirtualizedList.js'; + +describe('', () => { + const renderList = (data: string[]) => ( + + ( + + {item} + + )} + itemHeight={1} + keyExtractor={(item) => item} + initialScrollIndex={SCROLL_TO_ITEM_END} + initialScrollOffsetInIndex={SCROLL_TO_ITEM_END} + width={80} + maxHeight={5} + /> + + ); + + it('sticks to the bottom when data grows', async () => { + const initialData = Array.from({ length: 10 }, (_, i) => `Item ${i}`); + const { lastFrame, rerender, waitUntilReady, unmount } = await render( + renderList(initialData), + ); + await waitUntilReady(); + + expect(lastFrame()).toContain('Item 9'); + + const newData = [...initialData, 'Item 10', 'Item 11']; + await act(async () => { + rerender(renderList(newData)); + }); + await waitUntilReady(); + + expect(lastFrame()).toContain('Item 11'); + expect(lastFrame()).not.toContain('Item 0'); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/shared/FixedVirtualizedList.tsx b/packages/cli/src/ui/components/shared/FixedVirtualizedList.tsx new file mode 100644 index 0000000000..c6a21735e6 --- /dev/null +++ b/packages/cli/src/ui/components/shared/FixedVirtualizedList.tsx @@ -0,0 +1,616 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + useState, + useRef, + forwardRef, + useImperativeHandle, + useMemo, + useCallback, + memo, +} from 'react'; +import type React from 'react'; +import { theme } from '../../semantic-colors.js'; +import { useBatchedScroll } from '../../hooks/useBatchedScroll.js'; + +import { Box, StaticRender } from 'ink'; + +export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER; + +export type FixedVirtualizedListProps = { + data: T[]; + renderItem: (info: { item: T; index: number }) => React.ReactElement; + itemHeight: number; + keyExtractor: (item: T, index: number) => string; + initialScrollIndex?: number; + initialScrollOffsetInIndex?: number; + targetScrollIndex?: number; + backgroundColor?: string; + scrollbarThumbColor?: string; + renderStatic?: boolean; + isStaticItem?: (item: T, index: number) => boolean; + width: number; + overflowToBackbuffer?: boolean; + scrollbar?: boolean; + stableScrollback?: boolean; + maxHeight: number; + maxScrollbackLength?: number; +}; + +export type FixedVirtualizedListRef = { + scrollBy: (delta: number) => void; + scrollTo: (offset: number) => void; + scrollToEnd: () => void; + scrollToIndex: (params: { + index: number; + viewOffset?: number; + viewPosition?: number; + }) => void; + scrollToItem: (params: { + item: T; + viewOffset?: number; + viewPosition?: number; + }) => void; + getScrollIndex: () => number; + getScrollState: () => { + scrollTop: number; + scrollHeight: number; + innerHeight: number; + }; +}; + +const FixedVirtualizedListItem = memo( + ({ + content, + shouldBeStatic, + width, + itemKey, + }: { + content: React.ReactElement; + shouldBeStatic: boolean; + width: number; + itemKey: string; + }) => ( + + {shouldBeStatic ? ( + + {() => content} + + ) : ( + content + )} + + ), +); + +FixedVirtualizedListItem.displayName = 'FixedVirtualizedListItem'; + +function FixedVirtualizedList( + props: FixedVirtualizedListProps, + ref: React.Ref>, +) { + const { + data, + renderItem, + itemHeight, + keyExtractor, + initialScrollIndex, + initialScrollOffsetInIndex, + renderStatic, + isStaticItem, + width, + overflowToBackbuffer, + scrollbar = true, + stableScrollback, + maxScrollbackLength, + maxHeight, + } = props; + + const [scrollAnchor, setScrollAnchor] = useState(() => { + const scrollToEnd = + initialScrollIndex === SCROLL_TO_ITEM_END || + (typeof initialScrollIndex === 'number' && + initialScrollIndex >= data.length - 1 && + initialScrollOffsetInIndex === SCROLL_TO_ITEM_END); + + if (scrollToEnd) { + return { + index: data.length > 0 ? data.length - 1 : 0, + offset: SCROLL_TO_ITEM_END, + }; + } + + if (typeof initialScrollIndex === 'number') { + return { + index: Math.max(0, Math.min(data.length - 1, initialScrollIndex)), + offset: initialScrollOffsetInIndex ?? 0, + }; + } + + if (typeof props.targetScrollIndex === 'number') { + return { + index: props.targetScrollIndex, + offset: 0, + }; + } + + return { index: 0, offset: 0 }; + }); + + const [isStickingToBottom, setIsStickingToBottom] = useState(() => { + const scrollToEnd = + initialScrollIndex === SCROLL_TO_ITEM_END || + (typeof initialScrollIndex === 'number' && + initialScrollIndex >= data.length - 1 && + initialScrollOffsetInIndex === SCROLL_TO_ITEM_END); + return scrollToEnd; + }); + + const totalHeight = data.length * itemHeight; + const scrollableContainerHeight = maxHeight; + const isInitialScrollSet = useRef(false); + + const getAnchorForScrollTop = useCallback( + (scrollTop: number): { index: number; offset: number } => { + const index = Math.max( + 0, + Math.min(data.length - 1, Math.floor(scrollTop / itemHeight)), + ); + if (data.length === 0) { + return { index: 0, offset: 0 }; + } + return { index, offset: scrollTop - index * itemHeight }; + }, + [data.length, itemHeight], + ); + + const [prevTargetScrollIndex, setPrevTargetScrollIndex] = useState( + props.targetScrollIndex, + ); + const prevDataLength = useRef(data.length); + const previousDataLength = prevDataLength.current; + + if ( + (props.targetScrollIndex !== undefined && + props.targetScrollIndex !== prevTargetScrollIndex && + data.length > 0) || + (props.targetScrollIndex !== undefined && + previousDataLength === 0 && + data.length > 0) + ) { + if (props.targetScrollIndex !== prevTargetScrollIndex) { + setPrevTargetScrollIndex(props.targetScrollIndex); + } + setIsStickingToBottom(false); + setScrollAnchor({ index: props.targetScrollIndex, offset: 0 }); + } + + const rawStateActualScrollTop = (() => { + const offset = scrollAnchor.index * itemHeight; + if (scrollAnchor.offset === SCROLL_TO_ITEM_END) { + return offset + itemHeight - scrollableContainerHeight; + } + return offset + scrollAnchor.offset; + })(); + const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight); + const stateActualScrollTop = Math.max( + 0, + Math.min(maxScroll, rawStateActualScrollTop), + ); + + const prevTotalHeight = useRef(totalHeight); + const prevScrollTop = useRef(rawStateActualScrollTop); + const prevContainerHeight = useRef(scrollableContainerHeight); + + // Render-time state derivation to avoid useEffect for static rendering + let currentScrollAnchor = scrollAnchor; + let currentIsStickingToBottom = isStickingToBottom; + + const contentPreviouslyFit = + prevTotalHeight.current <= prevContainerHeight.current; + const wasScrolledToBottomPixels = + prevScrollTop.current >= + prevTotalHeight.current - prevContainerHeight.current - 1; + + // Crucial fix: we were previously only evaluating wasAtBottom against rawStateActualScrollTop *if* it was at bottom *last* frame. + // But if the content just exceeded the container height, wasScrolledToBottomPixels is false, but contentPreviouslyFit is true. + // If it previously fit, it implicitly means we should stick to the bottom if the new height exceeds the container. + const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels; + + if ( + wasAtBottom && + (rawStateActualScrollTop >= prevScrollTop.current || contentPreviouslyFit) + ) { + if (!currentIsStickingToBottom) { + currentIsStickingToBottom = true; + if (scrollAnchor === currentScrollAnchor) { + // Avoid infinite loop if we already updated state + setIsStickingToBottom(true); + } + } + } + + const listGrew = data.length > previousDataLength; + const containerChanged = + prevContainerHeight.current !== scrollableContainerHeight; + const shouldAutoScroll = props.targetScrollIndex === undefined; + + if ( + shouldAutoScroll && + ((listGrew && (currentIsStickingToBottom || wasAtBottom)) || + (currentIsStickingToBottom && containerChanged)) + ) { + const newIndex = data.length > 0 ? data.length - 1 : 0; + if ( + currentScrollAnchor.index !== newIndex || + currentScrollAnchor.offset !== SCROLL_TO_ITEM_END + ) { + currentScrollAnchor = { + index: newIndex, + offset: SCROLL_TO_ITEM_END, + }; + setScrollAnchor(currentScrollAnchor); + } + if (!currentIsStickingToBottom) { + currentIsStickingToBottom = true; + setIsStickingToBottom(true); + } + } else if ( + (currentScrollAnchor.index >= data.length || + stateActualScrollTop > totalHeight - scrollableContainerHeight) && + data.length > 0 + ) { + const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight); + const newAnchor = getAnchorForScrollTop(newScrollTop); + if ( + currentScrollAnchor.index !== newAnchor.index || + currentScrollAnchor.offset !== newAnchor.offset + ) { + currentScrollAnchor = newAnchor; + setScrollAnchor(newAnchor); + } + } else if (data.length === 0) { + if (currentScrollAnchor.index !== 0 || currentScrollAnchor.offset !== 0) { + currentScrollAnchor = { index: 0, offset: 0 }; + setScrollAnchor(currentScrollAnchor); + } + } + + // Initial scroll setup during render + if ( + !isInitialScrollSet.current && + data.length > 0 && + totalHeight > 0 && + scrollableContainerHeight > 0 + ) { + if (props.targetScrollIndex !== undefined) { + isInitialScrollSet.current = true; + } else if (typeof initialScrollIndex === 'number') { + const scrollToEnd = + initialScrollIndex === SCROLL_TO_ITEM_END || + (initialScrollIndex >= data.length - 1 && + initialScrollOffsetInIndex === SCROLL_TO_ITEM_END); + + if (scrollToEnd) { + currentScrollAnchor = { + index: data.length - 1, + offset: SCROLL_TO_ITEM_END, + }; + setScrollAnchor(currentScrollAnchor); + currentIsStickingToBottom = true; + setIsStickingToBottom(true); + isInitialScrollSet.current = true; + } else { + const index = Math.max( + 0, + Math.min(data.length - 1, initialScrollIndex), + ); + const offset = initialScrollOffsetInIndex ?? 0; + const newScrollTop = index * itemHeight + offset; + + const clampedScrollTop = Math.max( + 0, + Math.min(totalHeight - scrollableContainerHeight, newScrollTop), + ); + + currentScrollAnchor = getAnchorForScrollTop(clampedScrollTop); + setScrollAnchor(currentScrollAnchor); + isInitialScrollSet.current = true; + } + } + } + + // After all derived state updates, update refs for the next render + prevDataLength.current = data.length; + prevTotalHeight.current = totalHeight; + + const rawDerivedActualScrollTop = (() => { + const offset = currentScrollAnchor.index * itemHeight; + if (currentScrollAnchor.offset === SCROLL_TO_ITEM_END) { + return offset + itemHeight - scrollableContainerHeight; + } + return offset + currentScrollAnchor.offset; + })(); + const derivedActualScrollTop = Math.max( + 0, + Math.min(maxScroll, rawDerivedActualScrollTop), + ); + + prevScrollTop.current = rawDerivedActualScrollTop; + prevContainerHeight.current = scrollableContainerHeight; + + const startIndex = Math.max( + 0, + Math.floor(derivedActualScrollTop / itemHeight) - 1, + ); + const viewHeightForEndIndex = + scrollableContainerHeight > 0 ? scrollableContainerHeight : 50; + + const maxEndIndex = data.length - 1; + const endIndex = Math.min( + maxEndIndex, + Math.ceil((derivedActualScrollTop + viewHeightForEndIndex) / itemHeight), + ); + + const culledHeight = useMemo(() => { + if ( + overflowToBackbuffer && + typeof maxScrollbackLength === 'number' && + maxScrollbackLength > 0 + ) { + // Keep maxScrollbackLength items before the viewport. + // We add 1 to startIndex to account for the 1-item overscan it includes. + const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength); + return targetIndex * itemHeight; + } + return 0; + }, [overflowToBackbuffer, maxScrollbackLength, startIndex, itemHeight]); + + const scrollTop = currentIsStickingToBottom + ? Number.MAX_SAFE_INTEGER + : Math.max(0, derivedActualScrollTop - culledHeight); + + const renderRangeStart = (() => { + if (renderStatic) return 0; + if (overflowToBackbuffer) { + if (typeof maxScrollbackLength === 'number' && maxScrollbackLength > 0) { + // Render from the culled boundary. + const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength); + return targetIndex; + } + return 0; + } + return startIndex; + })(); + + const renderRangeEnd = renderStatic ? maxEndIndex : endIndex; + + const topSpacerHeight = Math.max( + 0, + renderRangeStart * itemHeight - culledHeight, + ); + const bottomSpacerHeight = renderStatic + ? 0 + : totalHeight - (renderRangeEnd + 1) * itemHeight; + + const renderedItems = useMemo(() => { + const items = []; + for (let i = renderRangeStart; i <= renderRangeEnd; i++) { + const item = data[i]; + if (item) { + const isOutsideViewport = i < startIndex || i > endIndex; + const shouldBeStatic = + (renderStatic === true && isOutsideViewport) || + isStaticItem?.(item, i) === true; + + const content = renderItem({ item, index: i }); + const key = keyExtractor(item, i); + + items.push( + , + ); + } + } + return items; + }, [ + renderRangeStart, + renderRangeEnd, + data, + startIndex, + endIndex, + renderStatic, + isStaticItem, + renderItem, + keyExtractor, + width, + ]); + + const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop); + + useImperativeHandle( + ref, + () => ({ + scrollBy: (delta: number) => { + if (delta < 0) { + setIsStickingToBottom(false); + } + const currentScrollTop = getScrollTop(); + const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight); + const actualCurrent = Math.min(currentScrollTop, maxScroll); + let newScrollTop = Math.max(0, actualCurrent + delta); + if (newScrollTop >= maxScroll) { + setIsStickingToBottom(true); + newScrollTop = Number.MAX_SAFE_INTEGER; + } + setPendingScrollTop(newScrollTop); + setScrollAnchor( + getAnchorForScrollTop(Math.min(newScrollTop, maxScroll)), + ); + }, + scrollTo: (offset: number) => { + const effectiveTotalHeight = totalHeight - culledHeight; + const maxScroll = Math.max( + 0, + effectiveTotalHeight - scrollableContainerHeight, + ); + if (offset >= maxScroll || offset === SCROLL_TO_ITEM_END) { + setIsStickingToBottom(true); + setPendingScrollTop(Number.MAX_SAFE_INTEGER); + if (data.length > 0) { + setScrollAnchor({ + index: data.length - 1, + offset: SCROLL_TO_ITEM_END, + }); + } + } else { + setIsStickingToBottom(false); + const newScrollTop = Math.max(0, offset + culledHeight); + setPendingScrollTop(newScrollTop); + setScrollAnchor(getAnchorForScrollTop(newScrollTop)); + } + }, + scrollToEnd: () => { + setIsStickingToBottom(true); + setPendingScrollTop(Number.MAX_SAFE_INTEGER); + if (data.length > 0) { + setScrollAnchor({ + index: data.length - 1, + offset: SCROLL_TO_ITEM_END, + }); + } + }, + scrollToIndex: ({ + index, + viewOffset = 0, + viewPosition = 0, + }: { + index: number; + viewOffset?: number; + viewPosition?: number; + }) => { + setIsStickingToBottom(false); + const offset = index * itemHeight; + if (index >= 0 && index < data.length) { + const maxScroll = Math.max( + 0, + totalHeight - scrollableContainerHeight, + ); + const newScrollTop = Math.max( + 0, + Math.min( + maxScroll, + offset - viewPosition * scrollableContainerHeight + viewOffset, + ), + ); + setPendingScrollTop(newScrollTop); + setScrollAnchor(getAnchorForScrollTop(newScrollTop)); + } + }, + scrollToItem: ({ + item, + viewOffset = 0, + viewPosition = 0, + }: { + item: T; + viewOffset?: number; + viewPosition?: number; + }) => { + setIsStickingToBottom(false); + const index = data.indexOf(item); + if (index !== -1) { + const offset = index * itemHeight; + const maxScroll = Math.max( + 0, + totalHeight - scrollableContainerHeight, + ); + const newScrollTop = Math.max( + 0, + Math.min( + maxScroll, + offset - viewPosition * scrollableContainerHeight + viewOffset, + ), + ); + setPendingScrollTop(newScrollTop); + setScrollAnchor(getAnchorForScrollTop(newScrollTop)); + } + }, + getScrollIndex: () => scrollAnchor.index, + getScrollState: () => { + const effectiveTotalHeight = totalHeight - culledHeight; + const maxScroll = Math.max( + 0, + effectiveTotalHeight - scrollableContainerHeight, + ); + return { + scrollTop: Math.min( + Math.max(0, getScrollTop() - culledHeight), + maxScroll, + ), + scrollHeight: effectiveTotalHeight, + innerHeight: scrollableContainerHeight, + }; + }, + }), + [ + scrollAnchor, + totalHeight, + getAnchorForScrollTop, + data, + scrollableContainerHeight, + getScrollTop, + setPendingScrollTop, + itemHeight, + culledHeight, + ], + ); + + return ( + + + + {renderedItems} + + + + ); +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const FixedVirtualizedListWithForwardRef = forwardRef(FixedVirtualizedList) as < + T, +>( + props: FixedVirtualizedListProps & { + ref?: React.Ref>; + }, +) => React.ReactElement; + +export { FixedVirtualizedListWithForwardRef as FixedVirtualizedList }; + +FixedVirtualizedList.displayName = 'FixedVirtualizedList'; diff --git a/packages/cli/src/ui/components/shared/Scrollable.tsx b/packages/cli/src/ui/components/shared/Scrollable.tsx index d9c3fb8c7a..e13d87a7d4 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.tsx @@ -13,6 +13,7 @@ import { useLayoutEffect, useEffect, useId, + useContext, } from 'react'; import { Box, ResizeObserver, type DOMElement } from 'ink'; import { useKeypress, type Key } from '../../hooks/useKeypress.js'; @@ -22,9 +23,11 @@ import { useBatchedScroll } from '../../hooks/useBatchedScroll.js'; import { Command } from '../../key/keyMatchers.js'; import { useOverflowActions } from '../../contexts/OverflowContext.js'; import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; +import { VirtualizedListContext } from './VirtualizedList.js'; interface ScrollableProps { children?: React.ReactNode; + itemKey?: string; width?: number; height?: number | string; maxWidth?: number; @@ -40,6 +43,7 @@ interface ScrollableProps { export const Scrollable: React.FC = ({ children, + itemKey, width, height, maxWidth, @@ -53,7 +57,16 @@ export const Scrollable: React.FC = ({ stableScrollback, }) => { const keyMatchers = useKeyMatchers(); - const [scrollTop, setScrollTop] = useState(0); + const virtualizedListContext = useContext(VirtualizedListContext); + + const [scrollTop, setScrollTop] = useState(() => { + if (itemKey && virtualizedListContext) { + const state = virtualizedListContext.getItemState(itemKey, 'scrollTop'); + return typeof state === 'number' ? state : 0; + } + return 0; + }); + const viewportRef = useRef(null); const contentRef = useRef(null); const overflowActions = useOverflowActions(); @@ -73,6 +86,19 @@ export const Scrollable: React.FC = ({ scrollTopRef.current = scrollTop; }, [scrollTop]); + useEffect( + () => () => { + if (itemKey && virtualizedListContext) { + virtualizedListContext.setItemState( + itemKey, + 'scrollTop', + scrollTopRef.current, + ); + } + }, + [itemKey, virtualizedListContext], + ); + useEffect(() => { if (reportOverflow && size.scrollHeight > size.innerHeight) { overflowActions?.addOverflowingId?.(id); diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx index c857e97b70..f3b936bb01 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -11,6 +11,8 @@ import { useCallback, useMemo, useLayoutEffect, + useEffect, + useContext, } from 'react'; import type React from 'react'; import { @@ -25,17 +27,18 @@ import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js'; import { useKeypress, type Key } from '../../hooks/useKeypress.js'; import { Command } from '../../key/keyMatchers.js'; import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; +import { useSettings } from '../../contexts/SettingsContext.js'; +import { VirtualizedListContext } from './VirtualizedList.js'; const ANIMATION_FRAME_DURATION_MS = 33; interface ScrollableListProps extends VirtualizedListProps { + itemKey?: string; hasFocus: boolean; width?: string | number; scrollbar?: boolean; stableScrollback?: boolean; - copyModeEnabled?: boolean; isStatic?: boolean; - fixedItemHeight?: boolean; targetScrollIndex?: number; containerHeight?: number; scrollbarThumbColor?: string; @@ -48,10 +51,44 @@ function ScrollableList( ref: React.Ref>, ) { const keyMatchers = useKeyMatchers(); - const { hasFocus, width, scrollbar = true, stableScrollback } = props; + const settings = useSettings(); + const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength; + const { + hasFocus, + width, + scrollbar = true, + stableScrollback, + itemKey, + } = props; const virtualizedListRef = useRef>(null); const containerRef = useRef(null); + const virtualizedListContext = useContext(VirtualizedListContext); + + useLayoutEffect(() => { + if (itemKey && virtualizedListContext) { + const restoredTop = virtualizedListContext.getItemState( + itemKey, + 'scrollTop', + ); + if (typeof restoredTop === 'number') { + virtualizedListRef.current?.scrollTo(restoredTop); + } + } + }, [itemKey, virtualizedListContext]); + + useEffect( + () => () => { + if (itemKey && virtualizedListContext) { + const top = virtualizedListRef.current?.getScrollState().scrollTop; + if (top !== undefined) { + virtualizedListContext.setItemState(itemKey, 'scrollTop', top); + } + } + }, + [itemKey, virtualizedListContext], + ); + useImperativeHandle( ref, () => ({ @@ -265,6 +302,7 @@ function ScrollableList( scrollbar={scrollbar} scrollbarThumbColor={scrollbarColor} stableScrollback={stableScrollback} + maxScrollbackLength={maxScrollbackLength} /> ); diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.backbuffer.repro.test.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.backbuffer.repro.test.tsx new file mode 100644 index 0000000000..43a951786c --- /dev/null +++ b/packages/cli/src/ui/components/shared/VirtualizedList.backbuffer.repro.test.tsx @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders as render } from '../../../test-utils/render.js'; +import { VirtualizedList } from './VirtualizedList.js'; +import type { VirtualizedListRef } from './VirtualizedList.js'; +import { Text, Box } from 'ink'; +import { describe, it, expect } from 'vitest'; +import { createRef } from 'react'; + +describe(' backbuffer regression', () => { + const keyExtractor = (item: string) => item; + + it('provides a sufficient history buffer regardless of height estimation', async () => { + // 1000 items, each 1 line high. + const data = Array.from( + { length: 1000 }, + (_, i) => `Item ${String(i).padStart(3, '0')}`, + ); + const ref = createRef>(); + + const { waitUntilReady, unmount } = await render( + + ( + + {item} + + )} + keyExtractor={keyExtractor} + estimatedItemHeight={() => 10} + initialScrollIndex={999} + overflowToBackbuffer={true} + renderStatic={true} + maxScrollbackLength={150} + /> + , + ); + + await waitUntilReady(); + + try { + const state = ref.current?.getScrollState(); + // Viewport is 50, backbuffer is 150. + // Total scrollHeight should be AT LEAST 200 lines. + // Since our fix is item-based, and items are 1 line high, it should be + // exactly or very close to 200. + expect(state?.scrollHeight).toBeGreaterThanOrEqual(200); + expect(state?.innerHeight).toBe(50); + } finally { + unmount(); + } + }); +}); diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.fallback.test.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.fallback.test.tsx new file mode 100644 index 0000000000..45e7f69d32 --- /dev/null +++ b/packages/cli/src/ui/components/shared/VirtualizedList.fallback.test.tsx @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders as render } from '../../../test-utils/render.js'; +import { VirtualizedList } from './VirtualizedList.js'; +import { Text, Box } from 'ink'; +import { describe, it, expect } from 'vitest'; + +describe(' fallback', () => { + const keyExtractor = (item: string) => item; + + it('uses default maxScrollbackLength of 1000 when not provided', async () => { + const longData = Array.from({ length: 2000 }, (_, i) => `Item ${i}`); + const renderedIndices = new Set(); + const renderItem1px = ({ + item, + index, + }: { + item: string; + index: number; + }) => { + renderedIndices.add(index); + return ( + + {item} + + ); + }; + + const { unmount } = await render( + + 1} + initialScrollIndex={1999} + overflowToBackbuffer={true} + // maxScrollbackLength is NOT provided + /> + , + ); + + // Viewport height is 10. + // initialScrollIndex is 1999. + // actualScrollTop = 2000 - 10 = 1990. + // Default fallback maxScrollbackLength = 1000. + // targetOffset = 1990 - 1000 = 990. + // renderRangeStart should be around 989/990. + // Items below 980 should NOT be rendered. + // Items around 1000 SHOULD be rendered. + + // Check viewport items are rendered + expect(renderedIndices.has(1995)).toBe(true); + expect(renderedIndices.has(1999)).toBe(true); + + // Check items in maxScrollbackLength (1000) are rendered + expect(renderedIndices.has(1000)).toBe(true); + expect(renderedIndices.has(1100)).toBe(true); + + // Check items beyond maxScrollbackLength are NOT rendered + expect(renderedIndices.has(0)).toBe(false); + expect(renderedIndices.has(500)).toBe(false); + expect(renderedIndices.has(900)).toBe(false); + + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx index 98e7790538..4fc0d302a8 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx @@ -4,9 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../../test-utils/render.js'; +import { renderWithProviders as render } from '../../../test-utils/render.js'; import { waitFor } from '../../../test-utils/async.js'; -import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js'; +import { + SCROLL_TO_ITEM_END, + VirtualizedList, + type VirtualizedListRef, +} from './VirtualizedList.js'; import { Text, Box } from 'ink'; import { createRef, @@ -115,6 +119,41 @@ describe('', () => { unmount(); }); + it('rerenders cached items when renderItem changes', async () => { + const data = ['Item 0']; + const renderWithLabel = (label: string) => ( + + ( + + + {label} {item} + + + )} + keyExtractor={keyExtractor} + estimatedItemHeight={() => itemHeight} + /> + + ); + + const { lastFrame, rerender, waitUntilReady, unmount } = await render( + renderWithLabel('Initial'), + ); + await waitUntilReady(); + expect(lastFrame()).toContain('Initial Item 0'); + + await act(async () => { + rerender(renderWithLabel('Updated')); + }); + await waitUntilReady(); + + expect(lastFrame()).toContain('Updated Item 0'); + expect(lastFrame()).not.toContain('Initial Item 0'); + unmount(); + }); + it('scrolls down to show new items when requested via ref', async () => { const ref = createRef>(); const { lastFrame, waitUntilReady, unmount } = await render( @@ -170,7 +209,7 @@ describe('', () => { (_, i) => `Item ${i}`, ); - const { lastFrame, unmount } = await render( + const { lastFrame, unmount, waitUntilReady } = await render( ', () => { , ); + await waitUntilReady(); + await waitFor(() => { + expect(mountedCount).toBe(expectedMountedCount); + }); + const frame = lastFrame(); - expect(mountedCount).toBe(expectedMountedCount); expect(frame).toMatchSnapshot(); unmount(); }, @@ -316,12 +359,69 @@ describe('', () => { unmount(); }); - it('renders correctly in copyModeEnabled when scrolled', async () => { + it('culls items that exceed maxScrollbackLength when overflowToBackbuffer is true', async () => { const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`); - // Use copy mode - const { lastFrame, unmount } = await render( + const renderedIndices = new Set(); + const renderItem1px = ({ + item, + index, + }: { + item: string; + index: number; + }) => { + renderedIndices.add(index); + return ( + + {item} + + ); + }; + + const { unmount } = await render( + + item} + estimatedItemHeight={() => 1} + initialScrollIndex={99} + overflowToBackbuffer={true} + maxScrollbackLength={10} + /> + , + ); + + // Viewport height is 10, total items = 100. + // actualScrollTop = 92 (due to top/bottom borders taking 2 lines out of 10, inner height 8). + // wait, if height is 10 with round border, inner height is 8. + // actualScrollTop = 100 - 8 = 92. + // maxScrollbackLength = 10. + // targetOffset = 92 - 10 = 82. + // So renderRangeStart should be 81 (or 82). + // Items 0 to 80 should not be rendered! + + // Check viewport items are rendered + expect(renderedIndices.has(95)).toBe(true); + expect(renderedIndices.has(99)).toBe(true); + + // Check items in maxScrollbackLength are rendered + expect(renderedIndices.has(85)).toBe(true); + + // Check items beyond maxScrollbackLength are NOT rendered + expect(renderedIndices.has(0)).toBe(false); + expect(renderedIndices.has(50)).toBe(false); + expect(renderedIndices.has(75)).toBe(false); + + unmount(); + }); + + it('crops the document height when maxScrollbackLength is exceeded', async () => { + const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`); + const ref = createRef>(); + const { unmount, waitUntilReady } = await render( ( @@ -330,18 +430,243 @@ describe('', () => { )} keyExtractor={(item) => item} estimatedItemHeight={() => 1} - initialScrollIndex={50} - copyModeEnabled={true} + initialScrollIndex={99} + overflowToBackbuffer={true} + maxScrollbackLength={10} /> , ); - // Item 50 should be visible - expect(lastFrame()).toContain('Item 50'); - // And surrounding items - expect(lastFrame()).toContain('Item 59'); - // But far away items should not be (ensures we are actually scrolled) - expect(lastFrame()).not.toContain('Item 0'); + await waitUntilReady(); + + // Viewport height is 10. + // maxScrollbackLength = 10. + // Total expected scrollHeight = 10 + 10 = 20. + const state = ref.current?.getScrollState(); + expect(state?.scrollHeight).toBe(20); + + // The top of the projected document (offset 0) should correspond to absolute offset 80. + // getAnchorForScrollTop(80) will return index 90 because it's near the bottom and uses a bottom anchor. + await act(async () => { + ref.current?.scrollTo(0); + }); + expect(ref.current?.getScrollIndex()).toBe(90); + + unmount(); + }); + + it('culls the backbuffer by measured row height instead of item count', async () => { + const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`); + const renderedIndices = new Set(); + const ref = createRef>(); + const { unmount, waitUntilReady } = await render( + + { + renderedIndices.add(index); + return ( + + {item} + + ); + }} + keyExtractor={(item) => item} + estimatedItemHeight={() => 2} + initialScrollIndex={99} + overflowToBackbuffer={true} + maxScrollbackLength={10} + /> + , + ); + + await waitUntilReady(); + + const state = ref.current?.getScrollState(); + expect(state?.scrollHeight).toBe(20); + expect(state?.innerHeight).toBe(10); + expect(renderedIndices.has(90)).toBe(true); + expect(renderedIndices.has(85)).toBe(false); + + unmount(); + }); + + it('keeps keyboard scrolling in logical history coordinates after culling', async () => { + const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`); + const ref = createRef>(); + const { lastFrame, unmount, waitUntilReady } = await render( + + ( + + {item} + + )} + keyExtractor={(item) => item} + estimatedItemHeight={() => 1} + initialScrollIndex={99} + overflowToBackbuffer={true} + maxScrollbackLength={10} + /> + , + ); + + await waitUntilReady(); + + expect(ref.current?.getScrollState().scrollTop).toBe(10); + + await act(async () => { + ref.current?.scrollBy(-1); + }); + await waitUntilReady(); + + const state = ref.current?.getScrollState(); + expect(state?.scrollTop).toBeGreaterThan(0); + expect(lastFrame()).not.toContain('Item 79'); + expect(lastFrame()).not.toContain('Item 80'); + + unmount(); + }); + + it('measures mounted zero-height items instead of keeping their estimate', async () => { + const ref = createRef>(); + const data = ['Item 0', 'Item 1', 'pending']; + const { unmount, waitUntilReady } = await render( + + + item === 'pending' ? ( + + ) : ( + + {item} + + ) + } + keyExtractor={(item) => item} + estimatedItemHeight={() => 10} + initialScrollIndex={2} + initialScrollOffsetInIndex={SCROLL_TO_ITEM_END} + /> + , + ); + + await waitUntilReady(); + + expect(ref.current?.getScrollState()).toEqual({ + scrollTop: 0, + scrollHeight: 2, + innerHeight: 50, + }); + + unmount(); + }); + + it('does not forget item heights when items are prepended', async () => { + const ref = createRef>(); + const data = ['Item 1', 'Item 2']; + const { rerender, waitUntilReady, unmount } = await render( + + ( + + {item} + + )} + keyExtractor={(item) => item} + estimatedItemHeight={() => 1000} + /> + , + ); + + await waitUntilReady(); + await waitFor(() => { + // Item 1 and 2 measured. totalHeight = 2. + expect(ref.current?.getScrollState().scrollHeight).toBe(2); + }); + + // Prepend Item 0 + const newData = ['Item 0', 'Item 1', 'Item 2']; + await act(async () => { + rerender( + + ( + + {item} + + )} + keyExtractor={(item) => item} + estimatedItemHeight={() => 1000} + /> + , + ); + }); + // With the Map-based cache, Item 1 and 2 heights (1 each) should be preserved + // even though their indices changed. + // Item 0 is new and uses estimate 1000. + // So totalHeight should be 1002 (before Item 0 is measured). + // Note: It might already be 3 if Item 0 was measured immediately, but it + // definitely shouldn't be 3000 (which it would be if Item 1 and 2 were forgotten). + const scrollHeight = ref.current?.getScrollState().scrollHeight; + expect(scrollHeight).toBeGreaterThan(0); + expect(scrollHeight).toBeLessThan(3000); + + await waitFor(() => { + expect(ref.current?.getScrollState().scrollHeight).toBe(3); + }); + + unmount(); + }); + + it('updates totalHeight correctly when estimated height differs from real height and scrolled up', async () => { + const ref = createRef>(); + const longData = Array.from({ length: 10 }, (_, i) => `Item ${i}`); + const itemHeight = 1; + const renderItem1px = ({ item }: { item: string }) => ( + + {item} + + ); + const keyExtractor = (item: string) => item; + + const { unmount, waitUntilReady } = await render( + + 1000} + /> + , + ); + + for (let i = 1; i <= 10; i++) { + await act(async () => { + ref.current?.scrollTo(i * 1000); + }); + await waitUntilReady(); + } + + await act(async () => { + ref.current?.scrollTo(0); + }); + // Wait for the final scroll top to settle and height to be correct + await waitFor(() => { + expect(ref.current?.getScrollState().scrollTop).toBe(0); + expect(ref.current?.getScrollState().scrollHeight).toBe(10); + }); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx index c3f888ba5f..2566d28d86 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.tsx @@ -13,15 +13,59 @@ import { useMemo, useCallback, memo, + useEffect, + createContext, } from 'react'; import type React from 'react'; import { theme } from '../../semantic-colors.js'; import { useBatchedScroll } from '../../hooks/useBatchedScroll.js'; -import { type DOMElement, Box, ResizeObserver, StaticRender } from 'ink'; +import { + type DOMElement, + Box, + ResizeObserver, + StaticRender, + getBoundingBox, + getScrollTop as getInkScrollTop, + useApp, +} from 'ink'; +import { + useMouse, + useMouseContext, + type MouseEvent, +} from '../../contexts/MouseContext.js'; + +import { debugLogger } from '@google/gemini-cli-core'; export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER; +export interface ClickableArea { + id: string; + box: { x: number; y: number; width: number; height: number }; +} + +export interface VirtualizedListContextValue { + registerInteractivity: ( + itemKey: string, + options: { scroll?: boolean; click?: boolean }, + ) => void; + setItemState: (itemKey: string, stateKey: string, value: unknown) => void; + getItemState: (itemKey: string, stateKey: string) => unknown; + isItemToggled: (itemKey: string) => boolean; + toggleItem: (itemKey: string) => void; + registerClickCallback: ( + itemKey: string, + areaId: string, + callback: () => void, + ) => void; + unregisterClickCallback: (itemKey: string, areaId: string) => void; + registerClickableArea: (el: DOMElement, areaId: string) => void; + unregisterClickableArea: (el: DOMElement) => void; +} + +export const VirtualizedListContext = + createContext(null); + export type VirtualizedListProps = { data: T[]; renderItem: (info: { item: T; index: number }) => React.ReactElement; @@ -39,9 +83,9 @@ export type VirtualizedListProps = { overflowToBackbuffer?: boolean; scrollbar?: boolean; stableScrollback?: boolean; - copyModeEnabled?: boolean; fixedItemHeight?: boolean; containerHeight?: number; + maxScrollbackLength?: number; }; export type VirtualizedListRef = { @@ -78,47 +122,98 @@ function findLastIndex( return -1; } +function findOffsetIndexAtOrBefore(offsets: number[], target: number): number { + let low = 0; + let high = offsets.length - 1; + let result = 0; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const offset = offsets[mid] ?? 0; + if (offset <= target) { + result = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + + return Math.min(result, Math.max(0, offsets.length - 2)); +} + +const isDOMElement = (node: unknown): node is DOMElement => + Boolean( + node && + typeof node === 'object' && + 'nodeName' in node && + (node as { nodeName?: unknown }).nodeName && + (node as { nodeName?: unknown }).nodeName !== '#text', + ); + +const extractClickableAreas = ( + rootNode: DOMElement, + clickableAreaMap: Map, +): ClickableArea[] => { + const rootBox = getBoundingBox(rootNode); + const results: ClickableArea[] = []; + + const traverse = (current: DOMElement) => { + const clickableId = clickableAreaMap.get(current); + + if (clickableId) { + const childBox = getBoundingBox(current); + + results.push({ + id: clickableId, + box: { + x: (childBox.x ?? 0) - (rootBox.x ?? 0), + y: (childBox.y ?? 0) - (rootBox.y ?? 0), + width: childBox.width ?? 0, + height: childBox.height ?? 0, + }, + }); + } + + for (const child of current.childNodes || []) { + if (isDOMElement(child)) { + traverse(child); + } + } + }; + + traverse(rootNode); + return results; +}; + const VirtualizedListItem = memo( ({ content, - shouldBeStatic, - width, - containerWidth, itemKey, index, onSetRef, }: { content: React.ReactElement; - shouldBeStatic: boolean; - width: number | string | undefined; - containerWidth: number; itemKey: string; index: number; - onSetRef: (index: number, el: DOMElement | null) => void; + onSetRef: (index: number, itemKey: string, el: DOMElement | null) => void; }) => { const itemRef = useCallback( (el: DOMElement | null) => { - onSetRef(index, el); + onSetRef(index, itemKey, el); }, - [index, onSetRef], + [index, itemKey, onSetRef], ); return ( - - {shouldBeStatic ? ( - - {content} - - ) : ( - content - )} + + {content} ); }, @@ -126,6 +221,20 @@ const VirtualizedListItem = memo( VirtualizedListItem.displayName = 'VirtualizedListItem'; +interface VirtualizedListInternalState { + container: DOMElement | null; + itemRefs: Array; + measuredHeights: number[]; + measuredKeys: string[]; + isInitialScrollSet: boolean; + containerObserver: ResizeObserver | null; + prevOffsetsLength: number; + prevDataLength: number; + prevTotalHeight: number; + prevScrollTop: number; + prevContainerHeight: number; +} + function VirtualizedList( props: VirtualizedListProps, ref: React.Ref>, @@ -144,15 +253,18 @@ function VirtualizedList( overflowToBackbuffer, scrollbar = true, stableScrollback, - copyModeEnabled = false, - fixedItemHeight = false, + maxScrollbackLength: maxScrollbackLengthProp, } = props; - const dataRef = useRef(data); - useLayoutEffect(() => { - dataRef.current = data; - }, [data]); - const [scrollAnchor, setScrollAnchor] = useState(() => { + const app = useApp(); + const maxScrollbackLength = + maxScrollbackLengthProp ?? app.options.maxScrollbackLength ?? 1000; + + const [scrollAnchor, setScrollAnchor] = useState<{ + index: number; + offset: number; + isBottom?: boolean; + }>(() => { const scrollToEnd = initialScrollIndex === SCROLL_TO_ITEM_END || (typeof initialScrollIndex === 'number' && @@ -196,23 +308,241 @@ function VirtualizedList( return scrollToEnd; }); - const containerRef = useRef(null); const [containerHeight, setContainerHeight] = useState(0); const [containerWidth, setContainerWidth] = useState(0); - const itemRefs = useRef>([]); - const [heights, setHeights] = useState>({}); - const isInitialScrollSet = useRef(false); + const [measurementVersion, setMeasurementVersion] = useState(0); - const containerObserverRef = useRef(null); - const nodeToKeyRef = useRef(new WeakMap()); + const interactiveKeys = useRef( + new Map(), + ); + const itemStates = useRef(new Map>()); + const [toggledKeys, setToggledKeys] = useState(() => new Set()); + const toggledKeysRef = useRef(toggledKeys); + toggledKeysRef.current = toggledKeys; + const [temporarilyInteractiveIndexes, setTemporarilyInteractiveIndexes] = + useState(() => new Set()); + const renderedAsStatic = useRef([]); + const maxRenderRangeEnd = useRef(0); + const [pendingReplayEvent, setPendingReplayEvent] = useState<{ + index: number; + event: MouseEvent; + } | null>(null); - const onSetRef = useCallback((index: number, el: DOMElement | null) => { - itemRefs.current[index] = el; - }, []); + const pendingReplayEventRef = useRef(pendingReplayEvent); + pendingReplayEventRef.current = pendingReplayEvent; + + const itemClickableAreas = useRef>(new Map()); + const clickableAreaMap = useRef>(new Map()); + const itemMetaMap = useRef( + new WeakMap(), + ); + const clickCallbacks = useRef void>>>( + new Map(), + ); + + const { broadcast } = useMouseContext(); + const broadcastRef = useRef(broadcast); + broadcastRef.current = broadcast; + + const virtualizedListContextValue = useMemo( + () => ({ + registerInteractivity: (itemKey, options) => { + interactiveKeys.current.set(itemKey, options); + }, + setItemState: (itemKey, stateKey, value) => { + let stateMap = itemStates.current.get(itemKey); + if (!stateMap) { + stateMap = new Map(); + itemStates.current.set(itemKey, stateMap); + } + stateMap.set(stateKey, value); + }, + getItemState: (itemKey, stateKey) => + itemStates.current.get(itemKey)?.get(stateKey), + isItemToggled: (itemKey) => toggledKeys.has(itemKey), + toggleItem: (itemKey) => { + setToggledKeys((prev) => { + const next = new Set(prev); + if (next.has(itemKey)) { + next.delete(itemKey); + } else { + next.add(itemKey); + } + return next; + }); + }, + registerClickCallback: (itemKey, areaId, callback) => { + let itemMap = clickCallbacks.current.get(itemKey); + if (!itemMap) { + itemMap = new Map(); + clickCallbacks.current.set(itemKey, itemMap); + } + itemMap.set(areaId, callback); + }, + unregisterClickCallback: (itemKey, areaId) => { + const itemMap = clickCallbacks.current.get(itemKey); + if (itemMap) { + itemMap.delete(areaId); + if (itemMap.size === 0) { + clickCallbacks.current.delete(itemKey); + } + } + }, + registerClickableArea: (el, areaId) => { + clickableAreaMap.current.set(el, areaId); + }, + unregisterClickableArea: (el) => { + clickableAreaMap.current.delete(el); + }, + toggledKeys, + }), + [toggledKeys], + ); + + const state = useRef({ + container: null, + itemRefs: [], + measuredHeights: [], + measuredKeys: [], + isInitialScrollSet: false, + containerObserver: null, + prevOffsetsLength: -1, + prevDataLength: -1, + prevTotalHeight: -1, + prevScrollTop: -1, + prevContainerHeight: -1, + }); + + const onStaticRender = useCallback( + (index: number, key: string, node: DOMElement) => { + const height = Math.round(getBoundingBox(node).height ?? 0); + if ( + state.current.measuredHeights[index] !== height || + state.current.measuredKeys[index] !== key + ) { + state.current.measuredHeights[index] = height; + state.current.measuredKeys[index] = key; + setMeasurementVersion((v) => v + 1); + } + + if (itemClickableAreas.current.has(key)) { + // If we already have areas for this item, don't re-extract. + // This is especially important for static items because children might + // have null dimensions in some environments (like tests) or might be + // cleared from the DOM after caching. + return; + } + + const areas = extractClickableAreas(node, clickableAreaMap.current); + if (areas.length > 0) { + // In some test environments, dimensions might be null/0. + // We only overwrite if we get valid dimensions or if we don't have areas yet. + const hasValidDimensions = areas.some( + (a) => a.box.width > 0 || a.box.height > 0, + ); + if (hasValidDimensions || !itemClickableAreas.current.has(key)) { + itemClickableAreas.current.set(key, areas); + } + } + }, + [], + ); + + const itemsObserver = useMemo( + () => + new ResizeObserver((entries) => { + let changed = false; + for (const entry of entries) { + if (!isDOMElement(entry.target)) continue; + const meta = itemMetaMap.current.get(entry.target); + if (meta) { + const { index, key } = meta; + const height = Math.round(entry.contentRect.height); + if ( + height >= 0 && + state.current.itemRefs[index] === entry.target && + (state.current.measuredHeights[index] !== height || + state.current.measuredKeys[index] !== key) + ) { + state.current.measuredHeights[index] = height; + state.current.measuredKeys[index] = key; + changed = true; + } + if (height > 0) { + const areas = extractClickableAreas( + entry.target, + clickableAreaMap.current, + ); + if (areas.length > 0) { + itemClickableAreas.current.set(key, areas); + + const pending = pendingReplayEventRef.current; + if (pending && pending.index === index) { + debugLogger.log( + `[Mouse] Replaying event index=${index} from observer`, + ); + broadcastRef.current(pending.event); + setPendingReplayEvent(null); + } + } else { + itemClickableAreas.current.delete(key); + } + } + } + } + if (changed) { + setMeasurementVersion((v) => v + 1); + } + }), + [], + ); + + const onSetRef = useCallback( + (index: number, itemKey: string, el: DOMElement | null) => { + const oldEl = state.current.itemRefs[index]; + if (oldEl && oldEl !== el) { + if (!isStatic) { + itemsObserver.unobserve(oldEl); + itemMetaMap.current.delete(oldEl); + } + } + + state.current.itemRefs[index] = el; + + if (el) { + itemMetaMap.current.set(el, { index, key: itemKey }); + + if (!isStatic) { + itemsObserver.observe(el); + } + + // Try to extract clickable areas immediately if dimensions are already available + const areas = extractClickableAreas(el, clickableAreaMap.current); + if (areas.length > 0) { + const hasValidDimensions = areas.some( + (a) => a.box.width > 0 || a.box.height > 0, + ); + if (hasValidDimensions) { + itemClickableAreas.current.set(itemKey, areas); + + const pending = pendingReplayEventRef.current; + if (pending && pending.index === index) { + debugLogger.log( + `[Mouse] Replaying event index=${index} immediately`, + ); + broadcastRef.current(pending.event); + setPendingReplayEvent(null); + } + } + } + } + }, + [itemsObserver, isStatic], + ); const containerRefCallback = useCallback((node: DOMElement | null) => { - containerObserverRef.current?.disconnect(); - containerRef.current = node; + state.current.containerObserver?.disconnect(); + state.current.container = node; if (node) { const observer = new ResizeObserver((entries) => { const entry = entries[0]; @@ -224,52 +554,35 @@ function VirtualizedList( } }); observer.observe(node); - containerObserverRef.current = observer; + state.current.containerObserver = observer; } }, []); - const itemsObserver = useMemo( - () => - new ResizeObserver((entries) => { - setHeights((prev) => { - let next: Record | null = null; - for (const entry of entries) { - const key = nodeToKeyRef.current.get(entry.target); - if (key !== undefined) { - const height = Math.round(entry.contentRect.height); - if (prev[key] !== height) { - if (!next) { - next = { ...prev }; - } - next[key] = height; - } - } - } - return next ?? prev; - }); - }), - [], - ); - - useLayoutEffect( + useEffect( () => () => { - containerObserverRef.current?.disconnect(); + state.current.containerObserver?.disconnect(); itemsObserver.disconnect(); }, [itemsObserver], ); const { totalHeight, offsets } = useMemo(() => { + // measurementVersion is used to trigger re-calculation when measurements change + void measurementVersion; const offsets: number[] = [0]; let totalHeight = 0; for (let i = 0; i < data.length; i++) { const key = keyExtractor(data[i], i); - const height = heights[key] ?? estimatedItemHeight(i); + const cachedHeight = + state.current.measuredKeys[i] === key + ? state.current.measuredHeights[i] + : undefined; + const height = cachedHeight ?? estimatedItemHeight(i); totalHeight += height; offsets.push(totalHeight); } return { totalHeight, offsets }; - }, [heights, data, estimatedItemHeight, keyExtractor]); + }, [data, keyExtractor, estimatedItemHeight, measurementVersion]); const scrollableContainerHeight = props.containerHeight ?? containerHeight; @@ -277,11 +590,35 @@ function VirtualizedList( ( scrollTop: number, offsets: number[], - ): { index: number; offset: number } => { - const index = findLastIndex(offsets, (offset) => offset <= scrollTop); - if (index === -1) { + totalHeight: number, + scrollableContainerHeight: number, + ): { index: number; offset: number; isBottom?: boolean } => { + const isNearBottom = + totalHeight > 0 && + scrollTop > (totalHeight - scrollableContainerHeight) / 2; + + if (isNearBottom) { + const scrollBottom = scrollTop + scrollableContainerHeight; + const rawIndex = findLastIndex( + offsets, + (offset) => offset <= scrollBottom, + ); + if (rawIndex === -1) { + return { index: 0, offset: 0, isBottom: true }; + } + const index = Math.min(rawIndex, Math.max(0, offsets.length - 2)); + return { + index, + offset: scrollBottom - offsets[index], + isBottom: true, + }; + } + + const rawIndex = findLastIndex(offsets, (offset) => offset <= scrollTop); + if (rawIndex === -1) { return { index: 0, offset: 0 }; } + const index = Math.min(rawIndex, Math.max(0, offsets.length - 2)); return { index, offset: scrollTop - offsets[index] }; }, @@ -291,7 +628,6 @@ function VirtualizedList( const [prevTargetScrollIndex, setPrevTargetScrollIndex] = useState( props.targetScrollIndex, ); - const prevOffsetsLength = useRef(offsets.length); // NOTE: If targetScrollIndex is provided, and we haven't rendered items yet (offsets.length <= 1), // we do NOT set scrollAnchor yet, because actualScrollTop wouldn't know the real offset! @@ -301,17 +637,17 @@ function VirtualizedList( props.targetScrollIndex !== prevTargetScrollIndex && offsets.length > 1) || (props.targetScrollIndex !== undefined && - prevOffsetsLength.current <= 1 && + state.current.prevOffsetsLength <= 1 && offsets.length > 1) ) { if (props.targetScrollIndex !== prevTargetScrollIndex) { setPrevTargetScrollIndex(props.targetScrollIndex); } - prevOffsetsLength.current = offsets.length; + state.current.prevOffsetsLength = offsets.length; setIsStickingToBottom(false); setScrollAnchor({ index: props.targetScrollIndex, offset: 0 }); - } else { - prevOffsetsLength.current = offsets.length; + } else if (offsets.length > 1) { + state.current.prevOffsetsLength = offsets.length; } const actualScrollTop = useMemo(() => { @@ -323,46 +659,91 @@ function VirtualizedList( if (scrollAnchor.offset === SCROLL_TO_ITEM_END) { const item = data[scrollAnchor.index]; const key = item ? keyExtractor(item, scrollAnchor.index) : ''; - const itemHeight = heights[key] ?? 0; + const cachedHeight = + state.current.measuredKeys[scrollAnchor.index] === key + ? state.current.measuredHeights[scrollAnchor.index] + : undefined; + const itemHeight = + cachedHeight ?? estimatedItemHeight(scrollAnchor.index) ?? 0; return offset + itemHeight - scrollableContainerHeight; } + if (scrollAnchor.isBottom) { + return offset + scrollAnchor.offset - scrollableContainerHeight; + } + return offset + scrollAnchor.offset; }, [ scrollAnchor, offsets, - heights, scrollableContainerHeight, data, keyExtractor, + estimatedItemHeight, ]); - const scrollTop = isStickingToBottom + const startIndex = Math.max( + 0, + findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1, + ); + const viewHeightForEndIndex = + scrollableContainerHeight > 0 ? scrollableContainerHeight : 50; + const endIndexOffset = offsets.findIndex( + (offset) => offset > actualScrollTop + viewHeightForEndIndex, + ); + const endIndex = + endIndexOffset === -1 + ? data.length - 1 + : Math.min(data.length - 1, endIndexOffset); + + const backbufferStartIndex = useMemo(() => { + if ( + overflowToBackbuffer && + typeof maxScrollbackLength === 'number' && + maxScrollbackLength > 0 + ) { + // Cull at measured item boundaries. If the target line falls inside a + // tall item, keep that whole item so the backbuffer has no blank gap. + const targetOffset = Math.max(0, actualScrollTop - maxScrollbackLength); + return findOffsetIndexAtOrBefore(offsets, targetOffset); + } + return 0; + }, [overflowToBackbuffer, maxScrollbackLength, actualScrollTop, offsets]); + + const culledHeight = + overflowToBackbuffer && maxScrollbackLength > 0 + ? (offsets[backbufferStartIndex] ?? 0) + : 0; + + const logicalScrollTop = isStickingToBottom ? Number.MAX_SAFE_INTEGER : actualScrollTop; - const prevDataLength = useRef(data.length); - const prevTotalHeight = useRef(totalHeight); - const prevScrollTop = useRef(actualScrollTop); - const prevContainerHeight = useRef(scrollableContainerHeight); - useLayoutEffect(() => { + if (state.current.prevDataLength === -1) { + state.current.prevDataLength = data.length; + state.current.prevTotalHeight = totalHeight; + state.current.prevScrollTop = actualScrollTop; + state.current.prevContainerHeight = scrollableContainerHeight; + return; + } + const contentPreviouslyFit = - prevTotalHeight.current <= prevContainerHeight.current; + state.current.prevTotalHeight <= state.current.prevContainerHeight; const wasScrolledToBottomPixels = - prevScrollTop.current >= - prevTotalHeight.current - prevContainerHeight.current - 1; + state.current.prevScrollTop >= + state.current.prevTotalHeight - state.current.prevContainerHeight - 1; const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels; - if (wasAtBottom && actualScrollTop >= prevScrollTop.current) { + if (wasAtBottom && actualScrollTop >= state.current.prevScrollTop) { if (!isStickingToBottom) { setIsStickingToBottom(true); } } - const listGrew = data.length > prevDataLength.current; + const listGrew = data.length > state.current.prevDataLength; const containerChanged = - prevContainerHeight.current !== scrollableContainerHeight; + state.current.prevContainerHeight !== scrollableContainerHeight; // If targetScrollIndex is provided, we NEVER auto-snap to the bottom // because the parent is explicitly managing the scroll position. @@ -393,23 +774,33 @@ function VirtualizedList( ) { // We still clamp the scroll top if it's completely out of bounds const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight); - const newAnchor = getAnchorForScrollTop(newScrollTop, offsets); + const newAnchor = getAnchorForScrollTop( + newScrollTop, + offsets, + totalHeight, + scrollableContainerHeight, + ); if ( scrollAnchor.index !== newAnchor.index || - scrollAnchor.offset !== newAnchor.offset + scrollAnchor.offset !== newAnchor.offset || + scrollAnchor.isBottom !== newAnchor.isBottom ) { setScrollAnchor(newAnchor); } } else if (data.length === 0) { - if (scrollAnchor.index !== 0 || scrollAnchor.offset !== 0) { + if ( + scrollAnchor.index !== 0 || + scrollAnchor.offset !== 0 || + scrollAnchor.isBottom !== undefined + ) { setScrollAnchor({ index: 0, offset: 0 }); } } - prevDataLength.current = data.length; - prevTotalHeight.current = totalHeight; - prevScrollTop.current = actualScrollTop; - prevContainerHeight.current = scrollableContainerHeight; + state.current.prevDataLength = data.length; + state.current.prevTotalHeight = totalHeight; + state.current.prevScrollTop = actualScrollTop; + state.current.prevContainerHeight = scrollableContainerHeight; }, [ data.length, totalHeight, @@ -417,6 +808,7 @@ function VirtualizedList( scrollableContainerHeight, scrollAnchor.index, scrollAnchor.offset, + scrollAnchor.isBottom, getAnchorForScrollTop, offsets, isStickingToBottom, @@ -425,7 +817,7 @@ function VirtualizedList( useLayoutEffect(() => { if ( - isInitialScrollSet.current || + state.current.isInitialScrollSet || offsets.length <= 1 || totalHeight <= 0 || scrollableContainerHeight <= 0 @@ -435,7 +827,7 @@ function VirtualizedList( if (props.targetScrollIndex !== undefined) { // If we are strictly driving from targetScrollIndex, do not apply initialScrollIndex - isInitialScrollSet.current = true; + state.current.isInitialScrollSet = true; return; } @@ -451,7 +843,7 @@ function VirtualizedList( offset: SCROLL_TO_ITEM_END, }); setIsStickingToBottom(true); - isInitialScrollSet.current = true; + state.current.isInitialScrollSet = true; return; } @@ -464,8 +856,15 @@ function VirtualizedList( Math.min(totalHeight - scrollableContainerHeight, newScrollTop), ); - setScrollAnchor(getAnchorForScrollTop(clampedScrollTop, offsets)); - isInitialScrollSet.current = true; + setScrollAnchor( + getAnchorForScrollTop( + clampedScrollTop, + offsets, + totalHeight, + scrollableContainerHeight, + ), + ); + state.current.isInitialScrollSet = true; } }, [ initialScrollIndex, @@ -475,66 +874,69 @@ function VirtualizedList( scrollableContainerHeight, getAnchorForScrollTop, data.length, - heights, + measurementVersion, props.targetScrollIndex, ]); - const startIndex = Math.max( - 0, - findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1, - ); - const viewHeightForEndIndex = - scrollableContainerHeight > 0 ? scrollableContainerHeight : 50; - const endIndexOffset = offsets.findIndex( - (offset) => offset > actualScrollTop + viewHeightForEndIndex, - ); - const endIndex = - endIndexOffset === -1 - ? data.length - 1 - : Math.min(data.length - 1, endIndexOffset); + useEffect(() => { + setTemporarilyInteractiveIndexes((prev) => { + if (prev.size === 0) return prev; + let changed = false; + const next = new Set(prev); + for (const index of prev) { + if (index > endIndex) { + next.delete(index); + changed = true; + } + } + return changed ? next : prev; + }); + }, [endIndex]); - const topSpacerHeight = - renderStatic === true || overflowToBackbuffer === true - ? 0 - : (offsets[startIndex] ?? 0); - const bottomSpacerHeight = renderStatic - ? 0 - : totalHeight - (offsets[endIndex + 1] ?? totalHeight); + const renderRangeStart = useMemo(() => { + if (overflowToBackbuffer) { + if (typeof maxScrollbackLength === 'number' && maxScrollbackLength > 0) { + return backbufferStartIndex; + } + return 0; + } + return startIndex; + }, [ + overflowToBackbuffer, + maxScrollbackLength, + backbufferStartIndex, + startIndex, + ]); - // Maintain a stable set of observed nodes using useLayoutEffect - const observedNodes = useRef>(new Set()); - useLayoutEffect(() => { - const currentNodes = new Set(); - const observeStart = renderStatic || overflowToBackbuffer ? 0 : startIndex; - const observeEnd = renderStatic ? data.length - 1 : endIndex; + const topSpacerHeight = Math.max(0, offsets[renderRangeStart] - culledHeight); - for (let i = observeStart; i <= observeEnd; i++) { - const node = itemRefs.current[i]; + let renderRangeEnd = endIndex; + if (maxRenderRangeEnd.current > endIndex) { + let allStatic = true; + const currentMax = Math.min( + maxRenderRangeEnd.current, + data.length > 0 ? data.length - 1 : 0, + ); + for (let i = endIndex + 1; i <= currentMax; i++) { const item = data[i]; - if (node && item) { - currentNodes.add(node); - const key = keyExtractor(item, i); - // Always update the key mapping because React can reuse nodes at different indices/keys - nodeToKeyRef.current.set(node, key); - if (!isStatic && !fixedItemHeight && !observedNodes.current.has(node)) { - itemsObserver.observe(node); - } + if (!item) continue; + const isStaticByDefault = + renderStatic === true || isStaticItem?.(item, i) === true; + if (!isStaticByDefault) { + allStatic = false; + break; } } - for (const node of observedNodes.current) { - if (!currentNodes.has(node)) { - if (!isStatic && !fixedItemHeight) { - itemsObserver.unobserve(node); - } - nodeToKeyRef.current.delete(node); - } + if (allStatic) { + renderRangeEnd = currentMax; } - observedNodes.current = currentNodes; - }); + } + maxRenderRangeEnd.current = renderRangeEnd; - const renderRangeStart = - renderStatic || overflowToBackbuffer ? 0 : startIndex; - const renderRangeEnd = renderStatic ? data.length - 1 : endIndex; + const bottomSpacerHeight = Math.max( + 0, + totalHeight - (offsets[renderRangeEnd + 1] ?? totalHeight), + ); // Always evaluate shouldBeStatic, width, etc. if we have a known width from the prop. // If containerHeight or containerWidth is 0 we defer rendering unless a static render or defined width overrides. @@ -542,10 +944,24 @@ function VirtualizedList( // BUT the initial render MUST render *something* with a width if width prop is provided to avoid layout shifts. // We MUST wait for containerHeight > 0 before rendering, especially if renderStatic is true. // If containerHeight is 0, we will misclassify items as isOutsideViewport and permanently print them to StaticRender! + const itemCache = useRef( + new Map< + string, + { + item: T; + element: React.ReactElement; + shouldBeStatic: boolean; + width: number | string | undefined; + containerWidth: number; + index: number; + isToggled: boolean; + renderItem: typeof renderItem; + } + >(), + ); + const isReady = - containerHeight > 0 || - process.env['NODE_ENV'] === 'test' || - (width !== undefined && typeof width === 'number'); + containerHeight > 0 || (width !== undefined && typeof width === 'number'); const renderedItems = useMemo(() => { if (!isReady) { @@ -557,27 +973,110 @@ function VirtualizedList( const item = data[i]; if (item) { const isOutsideViewport = i < startIndex || i > endIndex; - const shouldBeStatic = + const isStaticByDefault = (renderStatic === true && isOutsideViewport) || isStaticItem?.(item, i) === true; - const content = renderItem({ item, index: i }); - const key = keyExtractor(item, i); + const isTemporarilyInteractive = + temporarilyInteractiveIndexes.has(i) && i <= endIndex; + const shouldBeStatic = isStaticByDefault && !isTemporarilyInteractive; + renderedAsStatic.current[i] = shouldBeStatic; - items.push( - , - ); + const key = keyExtractor(item, i); + const cached = itemCache.current.get(key); + + const isToggled = toggledKeys.has(key); + + let contentElement: React.ReactElement; + if ( + cached && + cached.item === item && + cached.shouldBeStatic === shouldBeStatic && + cached.width === width && + cached.containerWidth === containerWidth && + cached.index === i && + cached.isToggled === isToggled && + cached.renderItem === renderItem + ) { + contentElement = cached.element; + } else { + if (shouldBeStatic) { + contentElement = ( + onStaticRender(i, key, node)} + > + {() => renderItem({ item, index: i })} + + ); + } else { + contentElement = ( + + ); + } + itemCache.current.set(key, { + item, + element: contentElement, + shouldBeStatic, + width, + containerWidth, + index: i, + isToggled, + renderItem, + }); + } + + items.push(contentElement); + + if ( + !renderStatic && + state.current.measuredKeys[i] !== key && + !shouldBeStatic + ) { + const fillerHeight = Math.max(0, estimatedItemHeight(i) - 1); + if (fillerHeight > 0) { + items.push( + , + ); + } + } } } + + // Cleanup cache to avoid memory leaks + if ( + itemCache.current.size > + Math.max(100, (renderRangeEnd - renderRangeStart + 1) * 3) + ) { + const keysToKeep = new Set(); + for ( + let i = Math.max(0, renderRangeStart - 50); + i <= Math.min(data.length - 1, renderRangeEnd + 50); + i++ + ) { + const item = data[i]; + if (item) { + keysToKeep.add(keyExtractor(item, i)); + } + } + for (const key of itemCache.current.keys()) { + if (!keysToKeep.has(key)) { + itemCache.current.delete(key); + } + } + } + return items; }, [ isReady, @@ -593,9 +1092,157 @@ function VirtualizedList( width, containerWidth, onSetRef, + estimatedItemHeight, + temporarilyInteractiveIndexes, + onStaticRender, + toggledKeys, ]); - const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop); + const { getScrollTop, setPendingScrollTop } = + useBatchedScroll(logicalScrollTop); + + useLayoutEffect(() => { + // This effect is now just for cases where items are already mounted but not yet replayed + if (pendingReplayEvent) { + const { index, event } = pendingReplayEvent; + const key = keyExtractor(data[index], index); + const areas = itemClickableAreas.current.get(key); + if (areas && areas.length > 0) { + debugLogger.log(`[Mouse] Replaying event index=${index} from effect`); + broadcast(event); + setPendingReplayEvent(null); + } + } + }, [pendingReplayEvent, broadcast, data, keyExtractor]); + + const handleMouse = useCallback( + (event: MouseEvent) => { + if (!state.current.container) return; + + const isClick = event.name === 'left-press'; + const isScroll = + event.name === 'scroll-up' || event.name === 'scroll-down'; + if (!isClick && !isScroll) return; + + const { x, y, width, height } = getBoundingBox(state.current.container); + const mouseX = event.col - 1; + const mouseY = event.row - 1; + + const relativeX = mouseX - x; + const relativeY = mouseY - y; + + if ( + relativeX >= 0 && + relativeX < width && + relativeY >= 0 && + relativeY < height + ) { + // getScrollTop() might return MAX_SAFE_INTEGER if stuck to bottom. + // We need the true rendered layout scroll top which ink exposes directly via getScrollTop. + const trueScrollTop = + getInkScrollTop(state.current.container) + culledHeight; + const absoluteY = trueScrollTop + relativeY; + + const index = findLastIndex(offsets, (offset) => offset <= absoluteY); + + if (index !== -1) { + const item = data[index]; + if (item) { + const itemKey = keyExtractor(item, index); + const options = interactiveKeys.current.get(itemKey); + + // Determine if the click was exactly on the first line of the item + const itemStartY = offsets[index] ?? 0; + const isFirstLineClick = isClick && absoluteY === itemStartY; + + // Hit-test against explicitly defined clickable areas inside the item + if (isClick && itemClickableAreas.current.has(itemKey)) { + const mouseRelativeY = absoluteY - itemStartY; + const areas = itemClickableAreas.current.get(itemKey) ?? []; + + for (const area of areas) { + if ( + relativeX >= area.box.x && + relativeX < area.box.x + area.box.width && + mouseRelativeY >= area.box.y && + mouseRelativeY < area.box.y + area.box.height + ) { + debugLogger.log( + `[Mouse] Clicked inside tagged area: ${area.id} in itemKey: ${itemKey}`, + ); + + if (renderedAsStatic.current[index]) { + debugLogger.log( + `[Mouse] Waking up static item index=${index} due to click on area=${area.id}`, + ); + setTemporarilyInteractiveIndexes((prev) => { + const next = new Set(prev); + next.add(index); + return next; + }); + setPendingReplayEvent({ index, event }); + return; + } + + const callback = clickCallbacks.current + .get(itemKey) + ?.get(area.id); + if (callback) { + debugLogger.log( + `[Mouse] Dispatching click callback for area=${area.id} in itemKey=${itemKey}`, + ); + callback(); + return; + } + + break; + } + } + } + + debugLogger.log( + `[Mouse] itemKey=${itemKey} options=${JSON.stringify(options)}`, + ); + if (options) { + if (isFirstLineClick && options.click) { + if (renderedAsStatic.current[index]) { + debugLogger.log( + `[Mouse] Waking up static item index=${index} due to first-line click`, + ); + setTemporarilyInteractiveIndexes((prev) => { + const next = new Set(prev); + next.add(index); + return next; + }); + setPendingReplayEvent({ index, event }); + return; + } + debugLogger.log( + `[Mouse] First line click detected. Toggling itemKey=${itemKey}.`, + ); + virtualizedListContextValue.toggleItem(itemKey); + } else if ( + renderedAsStatic.current[index] && + isScroll && + options.scroll + ) { + // Only wake up the item for scroll events + setTemporarilyInteractiveIndexes((prev) => { + const next = new Set(prev); + next.add(index); + return next; + }); + setPendingReplayEvent({ index, event }); + } + } + } + } + } + }, + [offsets, data, keyExtractor, virtualizedListContextValue, culledHeight], + ); + + useMouse(handleMouse, { isActive: true }); useImperativeHandle( ref, @@ -614,11 +1261,20 @@ function VirtualizedList( } setPendingScrollTop(newScrollTop); setScrollAnchor( - getAnchorForScrollTop(Math.min(newScrollTop, maxScroll), offsets), + getAnchorForScrollTop( + Math.min(newScrollTop, maxScroll), + offsets, + totalHeight, + scrollableContainerHeight, + ), ); }, scrollTo: (offset: number) => { - const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight); + const effectiveTotalHeight = totalHeight - culledHeight; + const maxScroll = Math.max( + 0, + effectiveTotalHeight - scrollableContainerHeight, + ); if (offset >= maxScroll || offset === SCROLL_TO_ITEM_END) { setIsStickingToBottom(true); setPendingScrollTop(Number.MAX_SAFE_INTEGER); @@ -630,9 +1286,16 @@ function VirtualizedList( } } else { setIsStickingToBottom(false); - const newScrollTop = Math.max(0, offset); + const newScrollTop = Math.max(0, offset + culledHeight); setPendingScrollTop(newScrollTop); - setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets)); + setScrollAnchor( + getAnchorForScrollTop( + newScrollTop, + offsets, + totalHeight, + scrollableContainerHeight, + ), + ); } }, scrollToEnd: () => { @@ -669,7 +1332,14 @@ function VirtualizedList( ), ); setPendingScrollTop(newScrollTop); - setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets)); + setScrollAnchor( + getAnchorForScrollTop( + newScrollTop, + offsets, + totalHeight, + scrollableContainerHeight, + ), + ); } }, scrollToItem: ({ @@ -698,16 +1368,30 @@ function VirtualizedList( ), ); setPendingScrollTop(newScrollTop); - setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets)); + setScrollAnchor( + getAnchorForScrollTop( + newScrollTop, + offsets, + totalHeight, + scrollableContainerHeight, + ), + ); } } }, getScrollIndex: () => scrollAnchor.index, getScrollState: () => { - const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight); + const effectiveTotalHeight = totalHeight - culledHeight; + const maxScroll = Math.max( + 0, + effectiveTotalHeight - scrollableContainerHeight, + ); return { - scrollTop: Math.min(getScrollTop(), maxScroll), - scrollHeight: totalHeight, + scrollTop: Math.min( + Math.max(0, getScrollTop() - culledHeight), + maxScroll, + ), + scrollHeight: effectiveTotalHeight, innerHeight: scrollableContainerHeight, }; }, @@ -721,36 +1405,42 @@ function VirtualizedList( scrollableContainerHeight, getScrollTop, setPendingScrollTop, + culledHeight, ], ); return ( - + - - {renderedItems} - + + {topSpacerHeight > 0 ? ( + + ) : null} + {renderedItems} + {bottomSpacerHeight > 0 ? ( + + ) : null} + - + ); } diff --git a/packages/cli/src/ui/components/shared/VirtualizedListInteractivity.test.tsx b/packages/cli/src/ui/components/shared/VirtualizedListInteractivity.test.tsx new file mode 100644 index 0000000000..53dddf3eb0 --- /dev/null +++ b/packages/cli/src/ui/components/shared/VirtualizedListInteractivity.test.tsx @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../../test-utils/render.js'; +import { waitFor } from '../../../test-utils/async.js'; +import { VirtualizedList } from './VirtualizedList.js'; +import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.js'; +import { Box, Text } from 'ink'; +import { useState } from 'react'; +import { describe, it, expect, vi } from 'vitest'; + +describe('VirtualizedList Interactivity', () => { + const keyExtractor = (item: { id: string }) => item.id; + + const InteractiveItem = ({ + id, + onToggle, + }: { + id: string; + onToggle: () => void; + }) => { + const { ref } = useVirtualizedListClick(id, 'toggle', onToggle); + return ( + + Item {id} + + ); + }; + + it('triggers callback when tagged area is clicked', async () => { + const onToggle = vi.fn(); + const data = [{ id: '1' }]; + + const { simulateClick, waitUntilReady, lastFrame } = + await renderWithProviders( + + 1} + renderItem={({ item }) => ( + + )} + /> + , + { mouseEventsEnabled: true }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('Item 1'); + + // Simulate click on the first line (Item 1) + // VirtualizedList is at (0,0) and Item 1 is at (0,0) relative to list. + // simulateClick expects absolute coordinates. + // In renderWithProviders, the wrapper Box is at (0,0)? + // Actually getBoundingBox(state.current.container) in VirtualizedList will give absolute coords. + await simulateClick(1, 1); + + await waitFor(() => expect(onToggle).toHaveBeenCalled()); + }); + + it('wakes up static item and triggers callback on click', async () => { + const onToggle = vi.fn(); + const data = [{ id: '1' }]; + + const TestComponent = () => { + const [isStatic, setIsStatic] = useState(false); + return ( + + 1} + renderItem={({ item }) => ( + + )} + isStaticItem={() => isStatic} + /> + { + if (el) { + setTimeout(() => setIsStatic(true), 100); + } + }} + /> + + ); + }; + + const { simulateClick, waitUntilReady, lastFrame } = + await renderWithProviders(, { + mouseEventsEnabled: true, + }); + + await waitUntilReady(); + // Wait for the transition to static to happen and be recorded + await new Promise((r) => setTimeout(r, 200)); + + expect(lastFrame()).toContain('Item 1'); + + // Click to wake up and trigger + await simulateClick(1, 1); + + await waitFor(() => expect(onToggle).toHaveBeenCalled()); + }); +}); diff --git a/packages/cli/src/ui/components/shared/__snapshots__/VirtualizedList.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/VirtualizedList.test.tsx.snap index 1df8316b89..f74d437a1f 100644 --- a/packages/cli/src/ui/components/shared/__snapshots__/VirtualizedList.test.tsx.snap +++ b/packages/cli/src/ui/components/shared/__snapshots__/VirtualizedList.test.tsx.snap @@ -7,41 +7,41 @@ exports[` > with 10px height and 100 items > mounts only visi │ │ │ │ │ │ +│ │ +│ │ +│ │ +│ │ │Item 1 │ │ │ │ │ │ │ │ │ -│Item 2 │ │ │ │ │ │ │ │ │ -│Item 3 │ -│ │ -│ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " `; exports[` > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: 500) 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ │Item 500 │ │ │ │ │ │ │ +│ ▄│ +│ ▀│ +│ │ +│ │ │ │ │Item 501 │ │ │ │ │ -│ ▄│ -│ ▀│ -│Item 502 │ -│ │ -│ │ -│ │ -│ │ -│Item 503 │ │ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -53,7 +53,7 @@ exports[` > with 10px height and 100 items > mounts only visi │ │ │ │ │ │ -│Item 997 │ +│ │ │ │ │ │ │ │ diff --git a/packages/cli/src/ui/contexts/MouseContext.tsx b/packages/cli/src/ui/contexts/MouseContext.tsx index 15ebd33ff8..46493d8760 100644 --- a/packages/cli/src/ui/contexts/MouseContext.tsx +++ b/packages/cli/src/ui/contexts/MouseContext.tsx @@ -11,6 +11,7 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, useMemo, useRef, } from 'react'; @@ -35,6 +36,7 @@ const MAX_MOUSE_BUFFER_SIZE = 4096; interface MouseContextValue { subscribe: (handler: MouseHandler) => void; unsubscribe: (handler: MouseHandler) => void; + broadcast: (event: MouseEvent) => void; } const MouseContext = createContext(undefined); @@ -50,7 +52,7 @@ export function useMouseContext() { export function useMouse(handler: MouseHandler, { isActive = true } = {}) { const { subscribe, unsubscribe } = useMouseContext(); - useEffect(() => { + useLayoutEffect(() => { if (!isActive) { return; } @@ -92,14 +94,8 @@ export function MouseProvider({ [subscribers], ); - useEffect(() => { - if (!mouseEventsEnabled) { - return; - } - - let mouseBuffer = ''; - - const broadcast = (event: MouseEvent) => { + const broadcast = useCallback( + (event: MouseEvent) => { let handled = false; for (const handler of subscribers) { if (handler(event) === true) { @@ -143,7 +139,16 @@ export function MouseProvider({ // events not the terminal. appEvents.emit(AppEvent.SelectionWarning); } - }; + }, + [subscribers], + ); + + useEffect(() => { + if (!mouseEventsEnabled) { + return; + } + + let mouseBuffer = ''; const handleData = (data: Buffer | string) => { mouseBuffer += typeof data === 'string' ? data : data.toString('utf-8'); @@ -190,11 +195,11 @@ export function MouseProvider({ return () => { stdin.removeListener('data', handleData); }; - }, [stdin, mouseEventsEnabled, subscribers, debugKeystrokeLogging]); + }, [stdin, mouseEventsEnabled, broadcast, debugKeystrokeLogging]); const contextValue = useMemo( - () => ({ subscribe, unsubscribe }), - [subscribe, unsubscribe], + () => ({ subscribe, unsubscribe, broadcast }), + [subscribe, unsubscribe, broadcast], ); return ( diff --git a/packages/cli/src/ui/hooks/useMouseClick.ts b/packages/cli/src/ui/hooks/useMouseClick.ts index 5fd7509470..1509eee40f 100644 --- a/packages/cli/src/ui/hooks/useMouseClick.ts +++ b/packages/cli/src/ui/hooks/useMouseClick.ts @@ -30,6 +30,7 @@ export const useMouseClick = ( (event: MouseEvent) => { const eventName = name ?? (button === 'left' ? 'left-press' : 'right-release'); + if (event.name === eventName && containerRef.current) { const { x, y, width, height } = getBoundingBox(containerRef.current); // Terminal mouse events are 1-based, Ink layout is 0-based. diff --git a/packages/cli/src/ui/hooks/useVirtualizedListClick.ts b/packages/cli/src/ui/hooks/useVirtualizedListClick.ts new file mode 100644 index 0000000000..89d8018377 --- /dev/null +++ b/packages/cli/src/ui/hooks/useVirtualizedListClick.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useContext, useLayoutEffect, useCallback, useRef } from 'react'; +import { VirtualizedListContext } from '../components/shared/VirtualizedList.js'; +import type { DOMElement } from 'ink'; + +/** + * A hook to register a clickable area within a VirtualizedList item. + * This works seamlessly with both static and dynamic rendering. + * + * @param itemKey The unique key for the list item. + * @param areaId A unique identifier for this clickable area within the list item. + * @param callback The function to execute when the area is clicked. + * @param options Configuration options. + * @returns Props to spread onto the clickable component. + */ +export const useVirtualizedListClick = ( + itemKey: string | undefined, + areaId: string, + callback: () => void, + options: { isActive?: boolean } = {}, +) => { + const { isActive = true } = options; + const context = useContext(VirtualizedListContext); + const elementRef = useRef(null); + + useLayoutEffect(() => { + if (isActive && context && itemKey) { + context.registerClickCallback(itemKey, areaId, callback); + return () => { + context.unregisterClickCallback(itemKey, areaId); + }; + } + return undefined; + }, [isActive, context, itemKey, areaId, callback]); + + useLayoutEffect(() => { + if (!isActive || !context || !elementRef.current) return; + context.registerClickableArea(elementRef.current, areaId); + return () => { + if (elementRef.current) { + context.unregisterClickableArea(elementRef.current); + } + }; + }, [isActive, context, areaId]); + + const ref = useCallback( + (el: DOMElement | null) => { + if (elementRef.current && context) { + context.unregisterClickableArea(elementRef.current); + } + elementRef.current = el; + if (el && context && isActive) { + context.registerClickableArea(el, areaId); + } + }, + [isActive, context, areaId], + ); + + return { ref }; +}; diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx index d88f3f1fb2..cbbc552d75 100644 --- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx +++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx @@ -23,8 +23,8 @@ export const ScreenReaderAppLayout: React.FC = () => { return ( diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index b3e88d9a01..6ec64ade34 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -15,6 +15,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; interface MarkdownDisplayProps { text: string; + itemKey?: string; isPending: boolean; availableTerminalHeight?: number; terminalWidth: number; diff --git a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-pending-search-dialog-google_web_search-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-pending-search-dialog-google_web_search-.snap.svg index f52f42f205..bdc3f90f82 100644 --- a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-pending-search-dialog-google_web_search-.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-pending-search-dialog-google_web_search-.snap.svg @@ -1,8 +1,8 @@ - + - + @@ -30,16 +30,16 @@ for more information 3. Ask coding questions, edit code or run commands 4. Be specific for the best results - ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ - - - google_web_search - - - - - Searching... - - ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + + + google_web_search + + + + + Searching... + + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-shell-tool.snap.svg b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-shell-tool.snap.svg index 32f2849814..7f9579b228 100644 --- a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-shell-tool.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-shell-tool.snap.svg @@ -1,8 +1,8 @@ - + - + @@ -30,16 +30,16 @@ for more information 3. Ask coding questions, edit code or run commands 4. Be specific for the best results - ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ - - - run_shell_command - - - - - Running command... - - ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + + + run_shell_command + + + + + Running command... + + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-an-empty-slice-following-a-search-tool.snap.svg b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-an-empty-slice-following-a-search-tool.snap.svg index f52f42f205..bdc3f90f82 100644 --- a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-an-empty-slice-following-a-search-tool.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-an-empty-slice-following-a-search-tool.snap.svg @@ -1,8 +1,8 @@ - + - + @@ -30,16 +30,16 @@ for more information 3. Ask coding questions, edit code or run commands 4. Be specific for the best results - ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ - - - google_web_search - - - - - Searching... - - ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + + + google_web_search + + + + + Searching... + + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/borderStyles.test.tsx.snap b/packages/cli/src/ui/utils/__snapshots__/borderStyles.test.tsx.snap index 31da966437..9300f7d4e0 100644 --- a/packages/cli/src/ui/utils/__snapshots__/borderStyles.test.tsx.snap +++ b/packages/cli/src/ui/utils/__snapshots__/borderStyles.test.tsx.snap @@ -16,6 +16,15 @@ Tips for getting started: 3. Ask coding questions, edit code or run commands 4. Be specific for the best results + + + + + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊶ google_web_search │ │ │ @@ -39,6 +48,15 @@ Tips for getting started: 3. Ask coding questions, edit code or run commands 4. Be specific for the best results + + + + + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊶ run_shell_command │ │ │ @@ -62,6 +80,15 @@ Tips for getting started: 3. Ask coding questions, edit code or run commands 4. Be specific for the best results + + + + + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊶ google_web_search │ │ │ diff --git a/packages/core/src/policy/core-tools-mapping.test.ts b/packages/core/src/policy/core-tools-mapping.test.ts index 8ef042d6bb..7a96087757 100644 --- a/packages/core/src/policy/core-tools-mapping.test.ts +++ b/packages/core/src/policy/core-tools-mapping.test.ts @@ -24,7 +24,7 @@ describe('PolicyEngine - Core Tools Mapping', () => { vi.restoreAllMocks(); }); - it('should allow tools explicitly listed in settings.tools.core', async () => { + it('should map tools listed in settings.tools.core to ALLOW with correct priority and fallback to default policies', async () => { const settings = { tools: { core: ['run_shell_command(ls)', 'run_shell_command(git status)'], @@ -63,7 +63,7 @@ describe('PolicyEngine - Core Tools Mapping', () => { expect(result3.decision).toBe(PolicyDecision.DENY); }); - it('should allow tools in tools.core even if they are restricted by default policies', async () => { + it('should map tools in tools.core with higher priority than default policies', async () => { // By default run_shell_command is ASK_USER. // Putting it in tools.core should make it ALLOW. const settings = { diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index f78ee6b924..3201ed47ff 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -537,6 +537,13 @@ "default": true, "type": "boolean" }, + "maxScrollbackLength": { + "title": "Max Scrollback Length", + "description": "Maximum number of lines to keep in the terminal scrollback buffer.", + "markdownDescription": "Maximum number of lines to keep in the terminal scrollback buffer.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `1000`", + "default": 1000, + "type": "number" + }, "showSpinner": { "title": "Show Spinner", "description": "Show the spinner during operations.", diff --git a/test-types.ts b/test-types.ts new file mode 100644 index 0000000000..40a9ac002e --- /dev/null +++ b/test-types.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { DOMElement } from 'ink'; + +export const isDOMElement = (node: unknown): node is DOMElement => + Boolean( + node && + typeof node === 'object' && + 'nodeName' in node && + (node as { nodeName?: unknown }).nodeName !== '#text', + );