feat(ui): add solid background color option for input prompt (#16563)

Co-authored-by: Alexander Farber <farber72@outlook.de>
This commit is contained in:
Jacob Richman
2026-01-26 15:23:54 -08:00
committed by GitHub
parent 7fbf470373
commit b5fe372b5b
40 changed files with 898 additions and 420 deletions
+1 -1
View File
@@ -57,8 +57,8 @@ they appear in the UI.
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` | | Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` | | Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` | | Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
| Use Full Width | `ui.useFullWidth` | Use the entire width of the terminal for output. | `true` |
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` | | Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `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` | | 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` |
| Enable Loading Phrases | `ui.accessibility.enableLoadingPhrases` | Enable loading phrases during operations. | `true` | | Enable Loading Phrases | `ui.accessibility.enableLoadingPhrases` | Enable loading phrases during operations. | `true` |
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` | | Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
+4 -4
View File
@@ -244,16 +244,16 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Show the model name in the chat for each model turn. - **Description:** Show the model name in the chat for each model turn.
- **Default:** `false` - **Default:** `false`
- **`ui.useFullWidth`** (boolean):
- **Description:** Use the entire width of the terminal for output.
- **Default:** `true`
- **`ui.useAlternateBuffer`** (boolean): - **`ui.useAlternateBuffer`** (boolean):
- **Description:** Use an alternate screen buffer for the UI, preserving shell - **Description:** Use an alternate screen buffer for the UI, preserving shell
history. history.
- **Default:** `false` - **Default:** `false`
- **Requires restart:** Yes - **Requires restart:** Yes
- **`ui.useBackgroundColor`** (boolean):
- **Description:** Whether to use background colors in the UI.
- **Default:** `true`
- **`ui.incrementalRendering`** (boolean): - **`ui.incrementalRendering`** (boolean):
- **Description:** Enable incremental rendering for the UI. This option will - **Description:** Enable incremental rendering for the UI. This option will
reduce flickering but may cause rendering artifacts. Only supported when reduce flickering but may cause rendering artifacts. Only supported when
+29 -6
View File
@@ -11,7 +11,7 @@
"packages/*" "packages/*"
], ],
"dependencies": { "dependencies": {
"ink": "npm:@jrichman/ink@6.4.7", "ink": "npm:@jrichman/ink@6.4.8",
"latest-version": "^9.0.0", "latest-version": "^9.0.0",
"simple-git": "^3.28.0" "simple-git": "^3.28.0"
}, },
@@ -2251,6 +2251,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@octokit/auth-token": "^6.0.0", "@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2", "@octokit/graphql": "^9.0.2",
@@ -2431,6 +2432,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": ">=8.0.0" "node": ">=8.0.0"
} }
@@ -2464,6 +2466,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0" "@opentelemetry/semantic-conventions": "^1.29.0"
}, },
@@ -2832,6 +2835,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@opentelemetry/core": "2.0.1", "@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0" "@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2865,6 +2869,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@opentelemetry/core": "2.0.1", "@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1" "@opentelemetry/resources": "2.0.1"
@@ -2917,6 +2922,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@opentelemetry/core": "2.0.1", "@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1", "@opentelemetry/resources": "2.0.1",
@@ -4122,6 +4128,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -4399,6 +4406,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0", "@typescript-eslint/types": "8.35.0",
@@ -5391,6 +5399,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -8400,6 +8409,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -8940,6 +8950,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.1", "body-parser": "^2.2.1",
@@ -10537,10 +10548,11 @@
}, },
"node_modules/ink": { "node_modules/ink": {
"name": "@jrichman/ink", "name": "@jrichman/ink",
"version": "6.4.7", "version": "6.4.8",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz", "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
"integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==", "integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1", "@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0", "ansi-escapes": "^7.0.0",
@@ -14299,6 +14311,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -14309,6 +14322,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"shell-quote": "^1.6.1", "shell-quote": "^1.6.1",
"ws": "^7" "ws": "^7"
@@ -16545,6 +16559,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -16768,7 +16783,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "dev": true,
"license": "0BSD" "license": "0BSD",
"peer": true
}, },
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.20.3", "version": "4.20.3",
@@ -16776,6 +16792,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "~0.25.0", "esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5" "get-tsconfig": "^4.7.5"
@@ -16948,6 +16965,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -17155,6 +17173,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -17268,6 +17287,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -17280,6 +17300,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4", "@vitest/expect": "3.2.4",
@@ -17984,6 +18005,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -18075,7 +18097,7 @@
"fzf": "^0.5.2", "fzf": "^0.5.2",
"glob": "^12.0.0", "glob": "^12.0.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.7", "ink": "npm:@jrichman/ink@6.4.8",
"ink-gradient": "^3.0.0", "ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0", "ink-spinner": "^5.0.0",
"latest-version": "^9.0.0", "latest-version": "^9.0.0",
@@ -18278,6 +18300,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
+2 -2
View File
@@ -64,7 +64,7 @@
"pre-commit": "node scripts/pre-commit.js" "pre-commit": "node scripts/pre-commit.js"
}, },
"overrides": { "overrides": {
"ink": "npm:@jrichman/ink@6.4.7", "ink": "npm:@jrichman/ink@6.4.8",
"wrap-ansi": "9.0.2", "wrap-ansi": "9.0.2",
"cliui": { "cliui": {
"wrap-ansi": "7.0.0" "wrap-ansi": "7.0.0"
@@ -124,7 +124,7 @@
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
"dependencies": { "dependencies": {
"ink": "npm:@jrichman/ink@6.4.7", "ink": "npm:@jrichman/ink@6.4.8",
"latest-version": "^9.0.0", "latest-version": "^9.0.0",
"simple-git": "^3.28.0" "simple-git": "^3.28.0"
}, },
+1 -1
View File
@@ -46,7 +46,7 @@
"fzf": "^0.5.2", "fzf": "^0.5.2",
"glob": "^12.0.0", "glob": "^12.0.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.7", "ink": "npm:@jrichman/ink@6.4.8",
"ink-gradient": "^3.0.0", "ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0", "ink-spinner": "^5.0.0",
"latest-version": "^9.0.0", "latest-version": "^9.0.0",
+1
View File
@@ -766,6 +766,7 @@ export async function loadCliConfig(
folderTrust, folderTrust,
interactive, interactive,
trustedFolder, trustedFolder,
useBackgroundColor: settings.ui?.useBackgroundColor,
useRipgrep: settings.tools?.useRipgrep, useRipgrep: settings.tools?.useRipgrep,
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell, enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout, shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
+9 -9
View File
@@ -526,15 +526,6 @@ const SETTINGS_SCHEMA = {
description: 'Show the model name in the chat for each model turn.', description: 'Show the model name in the chat for each model turn.',
showInDialog: true, showInDialog: true,
}, },
useFullWidth: {
type: 'boolean',
label: 'Use Full Width',
category: 'UI',
requiresRestart: false,
default: true,
description: 'Use the entire width of the terminal for output.',
showInDialog: true,
},
useAlternateBuffer: { useAlternateBuffer: {
type: 'boolean', type: 'boolean',
label: 'Use Alternate Screen Buffer', label: 'Use Alternate Screen Buffer',
@@ -545,6 +536,15 @@ const SETTINGS_SCHEMA = {
'Use an alternate screen buffer for the UI, preserving shell history.', 'Use an alternate screen buffer for the UI, preserving shell history.',
showInDialog: true, showInDialog: true,
}, },
useBackgroundColor: {
type: 'boolean',
label: 'Use Background Color',
category: 'UI',
requiresRestart: false,
default: true,
description: 'Whether to use background colors in the UI.',
showInDialog: true,
},
incrementalRendering: { incrementalRendering: {
type: 'boolean', type: 'boolean',
label: 'Incremental Rendering', label: 'Incremental Rendering',
+6 -3
View File
@@ -16,7 +16,6 @@ import { SettingsContext } from '../ui/contexts/SettingsContext.js';
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js'; import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
import { UIStateContext, type UIState } from '../ui/contexts/UIStateContext.js'; import { UIStateContext, type UIState } from '../ui/contexts/UIStateContext.js';
import { ConfigContext } from '../ui/contexts/ConfigContext.js'; import { ConfigContext } from '../ui/contexts/ConfigContext.js';
import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js';
import { VimModeProvider } from '../ui/contexts/VimModeContext.js'; import { VimModeProvider } from '../ui/contexts/VimModeContext.js';
import { MouseProvider } from '../ui/contexts/MouseContext.js'; import { MouseProvider } from '../ui/contexts/MouseContext.js';
import { ScrollProvider } from '../ui/contexts/ScrollProvider.js'; import { ScrollProvider } from '../ui/contexts/ScrollProvider.js';
@@ -38,6 +37,11 @@ vi.mock('../utils/persistentState.js', () => ({
persistentState: persistentStateMock, persistentState: persistentStateMock,
})); }));
vi.mock('../ui/utils/terminalUtils.js', () => ({
isLowColorDepth: vi.fn(() => false),
getColorDepth: vi.fn(() => 24),
}));
// Wrapper around ink-testing-library's render that ensures act() is called // Wrapper around ink-testing-library's render that ensures act() is called
export const render = ( export const render = (
tree: React.ReactElement, tree: React.ReactElement,
@@ -147,7 +151,6 @@ export const createMockSettings = (
const baseMockUiState = { const baseMockUiState = {
renderMarkdown: true, renderMarkdown: true,
streamingState: StreamingState.Idle, streamingState: StreamingState.Idle,
mainAreaWidth: 100,
terminalWidth: 120, terminalWidth: 120,
terminalHeight: 40, terminalHeight: 40,
currentModel: 'gemini-pro', currentModel: 'gemini-pro',
@@ -269,7 +272,7 @@ export const renderWithProviders = (
}); });
} }
const mainAreaWidth = calculateMainAreaWidth(terminalWidth, finalSettings); const mainAreaWidth = terminalWidth;
const finalUiState = { const finalUiState = {
...baseState, ...baseState,
@@ -34,7 +34,7 @@ vi.mock('../components/shared/text-buffer.js', () => ({
vi.mock('../contexts/UIStateContext.js', () => ({ vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({ useUIState: vi.fn(() => ({
mainAreaWidth: 80, terminalWidth: 80,
})), })),
})); }));
+2 -2
View File
@@ -28,8 +28,8 @@ export function ApiAuthDialog({
error, error,
defaultValue = '', defaultValue = '',
}: ApiAuthDialogProps): React.JSX.Element { }: ApiAuthDialogProps): React.JSX.Element {
const { mainAreaWidth } = useUIState(); const { terminalWidth } = useUIState();
const viewportWidth = mainAreaWidth - 8; const viewportWidth = terminalWidth - 8;
const pendingPromise = useRef<{ cancel: () => void } | null>(null); const pendingPromise = useRef<{ cancel: () => void } | null>(null);
+2 -2
View File
@@ -21,7 +21,7 @@ interface AppHeaderProps {
export const AppHeader = ({ version }: AppHeaderProps) => { export const AppHeader = ({ version }: AppHeaderProps) => {
const settings = useSettings(); const settings = useSettings();
const config = useConfig(); const config = useConfig();
const { nightly, mainAreaWidth, bannerData, bannerVisible } = useUIState(); const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState();
const { bannerText } = useBanner(bannerData, config); const { bannerText } = useBanner(bannerData, config);
const { showTips } = useTips(); const { showTips } = useTips();
@@ -33,7 +33,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
<Header version={version} nightly={nightly} /> <Header version={version} nightly={nightly} />
{bannerVisible && bannerText && ( {bannerVisible && bannerText && (
<Banner <Banner
width={mainAreaWidth} width={terminalWidth}
bannerText={bannerText} bannerText={bannerText}
isWarning={bannerData.warningText !== ''} isWarning={bannerData.warningText !== ''}
/> />
+2 -2
View File
@@ -50,7 +50,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
return ( return (
<Box <Box
flexDirection="column" flexDirection="column"
width={uiState.mainAreaWidth} width={uiState.terminalWidth}
flexGrow={0} flexGrow={0}
flexShrink={0} flexShrink={0}
> >
@@ -113,7 +113,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
maxHeight={ maxHeight={
uiState.constrainHeight ? debugConsoleMaxHeight : undefined uiState.constrainHeight ? debugConsoleMaxHeight : undefined
} }
width={uiState.mainAreaWidth} width={uiState.terminalWidth}
hasFocus={uiState.showErrorDetails} hasFocus={uiState.showErrorDetails}
/> />
<ShowMoreLines constrainHeight={uiState.constrainHeight} /> <ShowMoreLines constrainHeight={uiState.constrainHeight} />
@@ -72,7 +72,7 @@ describe('DialogManager', () => {
constrainHeight: false, constrainHeight: false,
terminalHeight: 24, terminalHeight: 24,
staticExtraHeight: 0, staticExtraHeight: 0,
mainAreaWidth: 80, terminalWidth: 80,
confirmUpdateExtensionRequests: [], confirmUpdateExtensionRequests: [],
showIdeRestartPrompt: false, showIdeRestartPrompt: false,
proQuotaRequest: null, proQuotaRequest: null,
@@ -50,8 +50,12 @@ export const DialogManager = ({
const uiState = useUIState(); const uiState = useUIState();
const uiActions = useUIActions(); const uiActions = useUIActions();
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } = const {
uiState; constrainHeight,
terminalHeight,
staticExtraHeight,
terminalWidth: uiTerminalWidth,
} = uiState;
if (uiState.adminSettingsChanged) { if (uiState.adminSettingsChanged) {
return <AdminSettingsChangedDialog />; return <AdminSettingsChangedDialog />;
@@ -147,7 +151,7 @@ export const DialogManager = ({
availableTerminalHeight={ availableTerminalHeight={
constrainHeight ? terminalHeight - staticExtraHeight : undefined constrainHeight ? terminalHeight - staticExtraHeight : undefined
} }
terminalWidth={mainAreaWidth} terminalWidth={uiTerminalWidth}
/> />
</Box> </Box>
); );
+6 -6
View File
@@ -42,7 +42,7 @@ export const Footer: React.FC = () => {
promptTokenCount, promptTokenCount,
nightly, nightly,
isTrustedFolder, isTrustedFolder,
mainAreaWidth, terminalWidth,
} = { } = {
model: uiState.currentModel, model: uiState.currentModel,
targetDir: config.getTargetDir(), targetDir: config.getTargetDir(),
@@ -55,7 +55,7 @@ export const Footer: React.FC = () => {
promptTokenCount: uiState.sessionStats.lastPromptTokenCount, promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
nightly: uiState.nightly, nightly: uiState.nightly,
isTrustedFolder: uiState.isTrustedFolder, isTrustedFolder: uiState.isTrustedFolder,
mainAreaWidth: uiState.mainAreaWidth, terminalWidth: uiState.terminalWidth,
}; };
const showMemoryUsage = const showMemoryUsage =
@@ -65,7 +65,7 @@ export const Footer: React.FC = () => {
const hideModelInfo = settings.merged.ui.footer.hideModelInfo; const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
const hideContextPercentage = settings.merged.ui.footer.hideContextPercentage; const hideContextPercentage = settings.merged.ui.footer.hideContextPercentage;
const pathLength = Math.max(20, Math.floor(mainAreaWidth * 0.25)); const pathLength = Math.max(20, Math.floor(terminalWidth * 0.25));
const displayPath = shortenPath(tildeifyPath(targetDir), pathLength); const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between'; const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
@@ -76,7 +76,7 @@ export const Footer: React.FC = () => {
return ( return (
<Box <Box
justifyContent={justifyContent} justifyContent={justifyContent}
width={mainAreaWidth} width={terminalWidth}
flexDirection="row" flexDirection="row"
alignItems="center" alignItems="center"
paddingX={1} paddingX={1}
@@ -134,7 +134,7 @@ export const Footer: React.FC = () => {
) : ( ) : (
<Text color={theme.status.error}> <Text color={theme.status.error}>
no sandbox no sandbox
{mainAreaWidth >= 100 && ( {terminalWidth >= 100 && (
<Text color={theme.text.secondary}> (see /docs)</Text> <Text color={theme.text.secondary}> (see /docs)</Text>
)} )}
</Text> </Text>
@@ -155,7 +155,7 @@ export const Footer: React.FC = () => {
<ContextUsageDisplay <ContextUsageDisplay
promptTokenCount={promptTokenCount} promptTokenCount={promptTokenCount}
model={model} model={model}
terminalWidth={mainAreaWidth} terminalWidth={terminalWidth}
/> />
</> </>
)} )}
@@ -67,7 +67,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
<UserMessage text={itemForDisplay.text} width={terminalWidth} /> <UserMessage text={itemForDisplay.text} width={terminalWidth} />
)} )}
{itemForDisplay.type === 'user_shell' && ( {itemForDisplay.type === 'user_shell' && (
<UserShellMessage text={itemForDisplay.text} /> <UserShellMessage text={itemForDisplay.text} width={terminalWidth} />
)} )}
{itemForDisplay.type === 'gemini' && ( {itemForDisplay.type === 'gemini' && (
<GeminiMessage <GeminiMessage
@@ -42,6 +42,8 @@ import stripAnsi from 'strip-ansi';
import chalk from 'chalk'; import chalk from 'chalk';
import { StreamingState } from '../types.js'; import { StreamingState } from '../types.js';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
import type { UIState } from '../contexts/UIStateContext.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
vi.mock('../hooks/useShellHistory.js'); vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCommandCompletion.js'); vi.mock('../hooks/useCommandCompletion.js');
@@ -50,6 +52,9 @@ vi.mock('../hooks/useReverseSearchCompletion.js');
vi.mock('clipboardy'); vi.mock('clipboardy');
vi.mock('../utils/clipboardUtils.js'); vi.mock('../utils/clipboardUtils.js');
vi.mock('../hooks/useKittyKeyboardProtocol.js'); vi.mock('../hooks/useKittyKeyboardProtocol.js');
vi.mock('../utils/terminalUtils.js', () => ({
isLowColorDepth: vi.fn(() => false),
}));
const mockSlashCommands: SlashCommand[] = [ const mockSlashCommands: SlashCommand[] = [
{ {
@@ -260,6 +265,8 @@ describe('InputPrompt', () => {
getProjectRoot: () => path.join('test', 'project'), getProjectRoot: () => path.join('test', 'project'),
getTargetDir: () => path.join('test', 'project', 'src'), getTargetDir: () => path.join('test', 'project', 'src'),
getVimMode: () => false, getVimMode: () => false,
getUseBackgroundColor: () => true,
getTerminalBackground: () => undefined,
getWorkspaceContext: () => ({ getWorkspaceContext: () => ({
getDirectories: () => ['/test/project/src'], getDirectories: () => ['/test/project/src'],
}), }),
@@ -1320,6 +1327,168 @@ describe('InputPrompt', () => {
unmount(); unmount();
}); });
describe('Background Color Styles', () => {
beforeEach(() => {
vi.mocked(isLowColorDepth).mockReturnValue(false);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should render with background color by default', async () => {
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await waitFor(() => {
const frame = stdout.lastFrame();
expect(frame).toContain('▀');
expect(frame).toContain('▄');
});
unmount();
});
it.each([
{ color: 'black', name: 'black' },
{ color: '#000000', name: '#000000' },
{ color: '#000', name: '#000' },
{ color: undefined, name: 'default (black)' },
{ color: 'white', name: 'white' },
{ color: '#ffffff', name: '#ffffff' },
{ color: '#fff', name: '#fff' },
])(
'should render with safe grey background but NO side borders in 8-bit mode when background is $name',
async ({ color }) => {
vi.mocked(isLowColorDepth).mockReturnValue(true);
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: color,
} as Partial<UIState>,
},
);
const isWhite =
color === 'white' || color === '#ffffff' || color === '#fff';
const expectedBgColor = isWhite ? '#eeeeee' : '#1c1c1c';
await waitFor(() => {
const frame = stdout.lastFrame();
// Use chalk to get the expected background color escape sequence
const bgCheck = chalk.bgHex(expectedBgColor)(' ');
const bgCode = bgCheck.substring(0, bgCheck.indexOf(' '));
// Background color code should be present
expect(frame).toContain(bgCode);
// Background characters should be rendered
expect(frame).toContain('▀');
expect(frame).toContain('▄');
// Side borders should STILL be removed
expect(frame).not.toContain('│');
});
unmount();
},
);
it('should NOT render with background color but SHOULD render horizontal lines when color depth is < 24 and background is NOT black', async () => {
vi.mocked(isLowColorDepth).mockReturnValue(true);
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: '#333333',
} as Partial<UIState>,
},
);
await waitFor(() => {
const frame = stdout.lastFrame();
expect(frame).not.toContain('▀');
expect(frame).not.toContain('▄');
// It SHOULD have horizontal fallback lines
expect(frame).toContain('─');
// It SHOULD NOT have vertical side borders (standard Box borders have │)
expect(frame).not.toContain('│');
});
unmount();
});
it('should handle 4-bit color mode (16 colors) as low color depth', async () => {
vi.mocked(isLowColorDepth).mockReturnValue(true);
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await waitFor(() => {
const frame = stdout.lastFrame();
expect(frame).toContain('▀');
expect(frame).not.toContain('│');
});
unmount();
});
it('should render horizontal lines (but NO background) in 8-bit mode when background is blue', async () => {
vi.mocked(isLowColorDepth).mockReturnValue(true);
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: 'blue',
} as Partial<UIState>,
},
);
await waitFor(() => {
const frame = stdout.lastFrame();
// Should NOT have background characters
expect(frame).not.toContain('▀');
expect(frame).not.toContain('▄');
// Should HAVE horizontal lines from the fallback Box borders
// Box style "round" uses these for top/bottom
expect(frame).toContain('─');
// Should NOT have vertical side borders
expect(frame).not.toContain('│');
});
unmount();
});
it('should render with plain borders when useBackgroundColor is false', async () => {
props.config.getUseBackgroundColor = () => false;
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await waitFor(() => {
const frame = stdout.lastFrame();
expect(frame).not.toContain('▀');
expect(frame).not.toContain('▄');
// Check for Box borders (round style uses unicode box chars)
expect(frame).toMatch(/[─│┐└┘┌]/);
});
unmount();
});
});
describe('cursor-based completion trigger', () => { describe('cursor-based completion trigger', () => {
it.each([ it.each([
{ {
@@ -1564,11 +1733,11 @@ describe('InputPrompt', () => {
mockBuffer.lines = [text]; mockBuffer.lines = [text];
mockBuffer.viewportVisualLines = [text]; mockBuffer.viewportVisualLines = [text];
mockBuffer.visualCursor = visualCursor as [number, number]; mockBuffer.visualCursor = visualCursor as [number, number];
props.config.getUseBackgroundColor = () => false;
const { stdout, unmount } = renderWithProviders( const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />, <InputPrompt {...props} />,
); );
await waitFor(() => { await waitFor(() => {
const frame = stdout.lastFrame(); const frame = stdout.lastFrame();
expect(frame).toContain(expected); expect(frame).toContain(expected);
@@ -1621,11 +1790,11 @@ describe('InputPrompt', () => {
mockBuffer.visualToLogicalMap = visualToLogicalMap as Array< mockBuffer.visualToLogicalMap = visualToLogicalMap as Array<
[number, number] [number, number]
>; >;
props.config.getUseBackgroundColor = () => false;
const { stdout, unmount } = renderWithProviders( const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />, <InputPrompt {...props} />,
); );
await waitFor(() => { await waitFor(() => {
const frame = stdout.lastFrame(); const frame = stdout.lastFrame();
expect(frame).toContain(expected); expect(frame).toContain(expected);
@@ -1645,11 +1814,11 @@ describe('InputPrompt', () => {
[1, 0], [1, 0],
[2, 0], [2, 0],
]; ];
props.config.getUseBackgroundColor = () => false;
const { stdout, unmount } = renderWithProviders( const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />, <InputPrompt {...props} />,
); );
await waitFor(() => { await waitFor(() => {
const frame = stdout.lastFrame(); const frame = stdout.lastFrame();
const lines = frame!.split('\n'); const lines = frame!.split('\n');
@@ -1673,15 +1842,15 @@ describe('InputPrompt', () => {
mockBuffer.visualCursor = [2, 5]; // cursor at the end of "world" mockBuffer.visualCursor = [2, 5]; // cursor at the end of "world"
// Provide a visual-to-logical mapping for each visual line // Provide a visual-to-logical mapping for each visual line
mockBuffer.visualToLogicalMap = [ mockBuffer.visualToLogicalMap = [
[0, 0], // 'hello' starts at col 0 of logical line 0 [0, 0],
[1, 0], // '' (blank) is logical line 1, col 0 [1, 0],
[2, 0], // 'world' is logical line 2, col 0 [2, 0],
]; ];
props.config.getUseBackgroundColor = () => false;
const { stdout, unmount } = renderWithProviders( const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />, <InputPrompt {...props} />,
); );
await waitFor(() => { await waitFor(() => {
const frame = stdout.lastFrame(); const frame = stdout.lastFrame();
// Check that all lines, including the empty one, are rendered. // Check that all lines, including the empty one, are rendered.
@@ -2505,20 +2674,23 @@ describe('InputPrompt', () => {
stdin.write('\x12'); stdin.write('\x12');
}); });
await waitFor(() => { await waitFor(() => {
expect(stdout.lastFrame()).toContain('(r:)');
});
expect(stdout.lastFrame()).toMatchSnapshot( expect(stdout.lastFrame()).toMatchSnapshot(
'command-search-render-collapsed-match', 'command-search-render-collapsed-match',
); );
});
await act(async () => { await act(async () => {
stdin.write('\u001B[C'); stdin.write('\u001B[C');
}); });
await waitFor(() => { await waitFor(() => {
// Just wait for any update to ensure it is stable.
// We could also wait for specific text if we knew it.
expect(stdout.lastFrame()).toContain('(r:)');
});
expect(stdout.lastFrame()).toMatchSnapshot( expect(stdout.lastFrame()).toMatchSnapshot(
'command-search-render-expanded-match', 'command-search-render-expanded-match',
); );
});
unmount(); unmount();
}); });
@@ -2637,28 +2809,28 @@ describe('InputPrompt', () => {
name: 'first line, first char', name: 'first line, first char',
relX: 0, relX: 0,
relY: 0, relY: 0,
mouseCol: 5, mouseCol: 4,
mouseRow: 2, mouseRow: 2,
}, },
{ {
name: 'first line, middle char', name: 'first line, middle char',
relX: 6, relX: 6,
relY: 0, relY: 0,
mouseCol: 11, mouseCol: 10,
mouseRow: 2, mouseRow: 2,
}, },
{ {
name: 'second line, first char', name: 'second line, first char',
relX: 0, relX: 0,
relY: 1, relY: 1,
mouseCol: 5, mouseCol: 4,
mouseRow: 3, mouseRow: 3,
}, },
{ {
name: 'second line, end char', name: 'second line, end char',
relX: 5, relX: 5,
relY: 1, relY: 1,
mouseCol: 10, mouseCol: 9,
mouseRow: 3, mouseRow: 3,
}, },
])( ])(
@@ -2685,7 +2857,7 @@ describe('InputPrompt', () => {
}); });
// Simulate left mouse press at calculated coordinates. // Simulate left mouse press at calculated coordinates.
// Assumes inner box is at x=4, y=1 based on border(1)+padding(1)+prompt(2) and border-top(1). // Without left border: inner box is at x=3, y=1 based on padding(1)+prompt(2) and border-top(1).
await act(async () => { await act(async () => {
stdin.write(`\x1b[<0;${mouseCol};${mouseRow}M`); stdin.write(`\x1b[<0;${mouseCol};${mouseRow}M`);
}); });
@@ -2727,6 +2899,37 @@ describe('InputPrompt', () => {
unmount(); unmount();
}); });
it('should move cursor on mouse click with plain borders', async () => {
props.config.getUseBackgroundColor = () => false;
props.buffer.text = 'hello world';
props.buffer.lines = ['hello world'];
props.buffer.viewportVisualLines = ['hello world'];
props.buffer.visualToLogicalMap = [[0, 0]];
props.buffer.visualCursor = [0, 11];
props.buffer.visualScrollRow = 0;
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{ mouseEventsEnabled: true, uiActions },
);
// Wait for initial render
await waitFor(() => {
expect(stdout.lastFrame()).toContain('hello world');
});
// With plain borders: 1(border) + 1(padding) + 2(prompt) = 4 offset (x=4, col=5)
await act(async () => {
stdin.write(`\x1b[<0;5;2M`); // Click at col 5, row 2
});
await waitFor(() => {
expect(props.buffer.moveToVisualPosition).toHaveBeenCalledWith(0, 0);
});
unmount();
});
}); });
describe('queued message editing', () => { describe('queued message editing', () => {
@@ -2889,7 +3092,8 @@ describe('InputPrompt', () => {
const { stdout, unmount } = renderWithProviders( const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />, <InputPrompt {...props} />,
); );
await waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot()); await waitFor(() => expect(stdout.lastFrame()).toContain('!'));
expect(stdout.lastFrame()).toMatchSnapshot();
unmount(); unmount();
}); });
@@ -2898,7 +3102,8 @@ describe('InputPrompt', () => {
const { stdout, unmount } = renderWithProviders( const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />, <InputPrompt {...props} />,
); );
await waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot()); await waitFor(() => expect(stdout.lastFrame()).toContain('>'));
expect(stdout.lastFrame()).toMatchSnapshot();
unmount(); unmount();
}); });
@@ -2907,10 +3112,10 @@ describe('InputPrompt', () => {
const { stdout, unmount } = renderWithProviders( const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />, <InputPrompt {...props} />,
); );
await waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot()); await waitFor(() => expect(stdout.lastFrame()).toContain('*'));
expect(stdout.lastFrame()).toMatchSnapshot();
unmount(); unmount();
}); });
it('should not show inverted cursor when shell is focused', async () => { it('should not show inverted cursor when shell is focused', async () => {
props.isEmbeddedShellFocused = true; props.isEmbeddedShellFocused = true;
props.focus = false; props.focus = false;
@@ -2919,8 +3124,8 @@ describe('InputPrompt', () => {
); );
await waitFor(() => { await waitFor(() => {
expect(stdout.lastFrame()).not.toContain(`{chalk.inverse(' ')}`); expect(stdout.lastFrame()).not.toContain(`{chalk.inverse(' ')}`);
expect(stdout.lastFrame()).toMatchSnapshot();
}); });
expect(stdout.lastFrame()).toMatchSnapshot();
unmount(); unmount();
}); });
}); });
@@ -3022,8 +3227,9 @@ describe('InputPrompt', () => {
<InputPrompt {...props} />, <InputPrompt {...props} />,
); );
await waitFor(() => { await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot(); expect(stdout.lastFrame()).toContain('[Image');
}); });
expect(stdout.lastFrame()).toMatchSnapshot();
unmount(); unmount();
}); });
@@ -3040,8 +3246,9 @@ describe('InputPrompt', () => {
<InputPrompt {...props} />, <InputPrompt {...props} />,
); );
await waitFor(() => { await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot(); expect(stdout.lastFrame()).toContain('@/path/to/screenshots');
}); });
expect(stdout.lastFrame()).toMatchSnapshot();
unmount(); unmount();
}); });
}); });
+78 -12
View File
@@ -6,11 +6,12 @@
import type React from 'react'; import type React from 'react';
import clipboardy from 'clipboardy'; import clipboardy from 'clipboardy';
import { useCallback, useEffect, useState, useRef } from 'react'; import { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import { Box, Text, useStdout, type DOMElement } from 'ink'; import { Box, Text, useStdout, type DOMElement } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js'; import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.js'; import { useInputHistory } from '../hooks/useInputHistory.js';
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
import type { TextBuffer } from './shared/text-buffer.js'; import type { TextBuffer } from './shared/text-buffer.js';
import { import {
logicalPosToOffset, logicalPosToOffset,
@@ -47,6 +48,9 @@ import {
} from '../utils/commandUtils.js'; } from '../utils/commandUtils.js';
import * as path from 'node:path'; import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js'; import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { DEFAULT_BACKGROUND_OPACITY } from '../constants.js';
import { getSafeLowColorBackground } from '../themes/color-utils.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js'; import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js'; import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js'; import { useSettings } from '../contexts/SettingsContext.js';
@@ -141,7 +145,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const kittyProtocol = useKittyKeyboardProtocol(); const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState(); const isShellFocused = useShellFocusState();
const { setEmbeddedShellFocused } = useUIActions(); const { setEmbeddedShellFocused } = useUIActions();
const { mainAreaWidth, activePtyId, history } = useUIState(); const { terminalWidth, activePtyId, history, terminalBackgroundColor } =
useUIState();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const escPressCount = useRef(0); const escPressCount = useRef(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false); const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@@ -321,6 +326,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const allMessages = popAllMessages(); const allMessages = popAllMessages();
if (allMessages) { if (allMessages) {
buffer.setText(allMessages); buffer.setText(allMessages);
return true;
} else { } else {
// No queued messages, proceed with input history // No queued messages, proceed with input history
inputHistory.navigateUp(); inputHistory.navigateUp();
@@ -1033,6 +1039,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const activeCompletion = getActiveCompletion(); const activeCompletion = getActiveCompletion();
const shouldShowSuggestions = activeCompletion.showSuggestions; const shouldShowSuggestions = activeCompletion.showSuggestions;
const useBackgroundColor = config.getUseBackgroundColor();
const isLowColor = isLowColorDepth();
const terminalBg = terminalBackgroundColor || 'black';
// We should fallback to lines if the background color is disabled OR if it is
// enabled but we are in a low color depth terminal where we don't have a safe
// background color to use.
const useLineFallback = useMemo(() => {
if (!useBackgroundColor) {
return true;
}
if (isLowColor) {
return !getSafeLowColorBackground(terminalBg);
}
return false;
}, [useBackgroundColor, isLowColor, terminalBg]);
useEffect(() => { useEffect(() => {
if (onSuggestionsVisibilityChange) { if (onSuggestionsVisibilityChange) {
onSuggestionsVisibilityChange(shouldShowSuggestions); onSuggestionsVisibilityChange(shouldShowSuggestions);
@@ -1085,21 +1108,47 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
</Box> </Box>
) : null; ) : null;
const borderColor =
isShellFocused && !isEmbeddedShellFocused
? (statusColor ?? theme.border.focused)
: theme.border.default;
return ( return (
<> <>
{suggestionsPosition === 'above' && suggestionsNode} {suggestionsPosition === 'above' && suggestionsNode}
{useLineFallback ? (
<Box <Box
borderStyle="round" borderStyle="round"
borderColor={ borderTop={true}
isShellFocused && !isEmbeddedShellFocused borderBottom={false}
? (statusColor ?? theme.border.focused) borderLeft={false}
: theme.border.default borderRight={false}
} borderColor={borderColor}
paddingX={1} width={terminalWidth}
width={mainAreaWidth}
flexDirection="row" flexDirection="row"
alignItems="flex-start" alignItems="flex-start"
minHeight={3} height={0}
/>
) : null}
<HalfLinePaddedBox
backgroundBaseColor={
isShellFocused && !isEmbeddedShellFocused
? theme.border.focused
: theme.border.default
}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor}
>
<Box
flexGrow={1}
flexDirection="row"
paddingX={1}
borderColor={borderColor}
borderStyle={useLineFallback ? 'round' : undefined}
borderTop={false}
borderBottom={false}
borderLeft={!useBackgroundColor}
borderRight={!useBackgroundColor}
> >
<Text <Text
color={statusColor ?? theme.text.accent} color={statusColor ?? theme.text.accent}
@@ -1129,14 +1178,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
showCursor ? ( showCursor ? (
<Text> <Text>
{chalk.inverse(placeholder.slice(0, 1))} {chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text> <Text color={theme.text.secondary}>
{placeholder.slice(1)}
</Text>
</Text> </Text>
) : ( ) : (
<Text color={theme.text.secondary}>{placeholder}</Text> <Text color={theme.text.secondary}>{placeholder}</Text>
) )
) : ( ) : (
linesToRender linesToRender
.map((lineText, visualIdxInRenderedSet) => { .map((lineText: string, visualIdxInRenderedSet: number) => {
const absoluteVisualIdx = const absoluteVisualIdx =
scrollVisualRow + visualIdxInRenderedSet; scrollVisualRow + visualIdxInRenderedSet;
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx]; const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
@@ -1277,6 +1328,21 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
)} )}
</Box> </Box>
</Box> </Box>
</HalfLinePaddedBox>
{useLineFallback ? (
<Box
borderStyle="round"
borderTop={false}
borderBottom={true}
borderLeft={false}
borderRight={false}
borderColor={borderColor}
width={terminalWidth}
flexDirection="row"
alignItems="flex-start"
height={0}
/>
) : null}
{suggestionsPosition === 'below' && suggestionsNode} {suggestionsPosition === 'below' && suggestionsNode}
</> </>
); );
@@ -129,6 +129,7 @@ export const MainContent = () => {
return ( return (
<ScrollableList <ScrollableList
hasFocus={!uiState.isEditorDialogOpen} hasFocus={!uiState.isEditorDialogOpen}
width={uiState.terminalWidth}
data={virtualizedData} data={virtualizedData}
renderItem={renderItem} renderItem={renderItem}
estimatedItemHeight={() => 100} estimatedItemHeight={() => 100}
@@ -43,7 +43,7 @@ const mockSetVimMode = vi.fn();
vi.mock('../contexts/UIStateContext.js', () => ({ vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: () => ({ useUIState: () => ({
mainAreaWidth: 100, // Fixed width for consistent snapshots terminalWidth: 100, // Fixed width for consistent snapshots
}), }),
})); }));
@@ -39,14 +39,14 @@ Tips for getting started:
2. Be specific for the best results. 2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini. 3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information. 4. /help for more information.
╭─────────────────────────────────────────────────────────────────────────────╮ ╭─────────────────────────────────────────────────────────────────────────────
│ ✓ tool1 Description for tool 1 │ │ ✓ tool1 Description for tool 1 │
│ │ │ │
╰─────────────────────────────────────────────────────────────────────────────╯ ╰─────────────────────────────────────────────────────────────────────────────
╭─────────────────────────────────────────────────────────────────────────────╮ ╭─────────────────────────────────────────────────────────────────────────────
│ ✓ tool2 Description for tool 2 │ │ ✓ tool2 Description for tool 2 │
│ │ │ │
╰─────────────────────────────────────────────────────────────────────────────╯" ╰─────────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = ` exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = `
@@ -83,14 +83,14 @@ Tips for getting started:
2. Be specific for the best results. 2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini. 3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information. 4. /help for more information.
╭─────────────────────────────────────────────────────────────────────────────╮ ╭─────────────────────────────────────────────────────────────────────────────
│ ✓ tool1 Description for tool 1 │ │ ✓ tool1 Description for tool 1 │
│ │ │ │
╰─────────────────────────────────────────────────────────────────────────────╯ ╰─────────────────────────────────────────────────────────────────────────────
╭─────────────────────────────────────────────────────────────────────────────╮ ╭─────────────────────────────────────────────────────────────────────────────
│ ✓ tool2 Description for tool 2 │ │ ✓ tool2 Description for tool 2 │
│ │ │ │
╰─────────────────────────────────────────────────────────────────────────────╯" ╰─────────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = ` exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = `
@@ -127,8 +127,8 @@ Tips for getting started:
2. Be specific for the best results. 2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini. 3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information. 4. /help for more information.
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Hello Gemini > Hello Gemini
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
✦ Hello User!" ✦ Hello User!"
`; `;
@@ -65,9 +65,9 @@ exports[`<AppHeader /> > should render the banner when previewFeatures is disabl
███░ ░░█████████ ███░ ░░█████████
░░░ ░░░░░░░░░ ░░░ ░░░░░░░░░
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ This is the default banner │ │ This is the default banner │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Tips for getting started: Tips for getting started:
1. Ask questions, edit files, or run commands. 1. Ask questions, edit files, or run commands.
2. Be specific for the best results. 2. Be specific for the best results.
@@ -86,9 +86,9 @@ exports[`<AppHeader /> > should render the banner with default text 1`] = `
███░ ░░█████████ ███░ ░░█████████
░░░ ░░░░░░░░░ ░░░ ░░░░░░░░░
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ This is the default banner │ │ This is the default banner │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Tips for getting started: Tips for getting started:
1. Ask questions, edit files, or run commands. 1. Ask questions, edit files, or run commands.
2. Be specific for the best results. 2. Be specific for the best results.
@@ -107,9 +107,9 @@ exports[`<AppHeader /> > should render the banner with warning text 1`] = `
███░ ░░█████████ ███░ ░░█████████
░░░ ░░░░░░░░░ ░░░ ░░░░░░░░░
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ There are capacity issues │ │ There are capacity issues │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Tips for getting started: Tips for getting started:
1. Ask questions, edit files, or run commands. 1. Ask questions, edit files, or run commands.
2. Be specific for the best results. 2. Be specific for the best results.
@@ -2,10 +2,10 @@
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox gemini-pro /model (100%)"`; exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox gemini-pro /model (100%)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...irectories/to/make/it/long no sandbox (see /docs) gemini-pro /model (100% context left)"`; exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) gemini-pro /model (100% context left)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`; exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`; exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `" ...irectories/to/make/it/long no sandbox (see /docs)"`; exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `" ...directories/to/make/it/long no sandbox (see /docs)"`;
@@ -1,69 +1,69 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-collapsed-match 1`] = ` exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-collapsed-match 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) Type your message or @path/to/file (r:) Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll → lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll →
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
..." ..."
`; `;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-expanded-match 1`] = ` exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-expanded-match 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) Type your message or @path/to/file (r:) Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ← lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ←
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
llllllllllllllllllllllllllllllllllllllllllllllllll" llllllllllllllllllllllllllllllllllllllllllllllllll"
`; `;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = ` exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) commit (r:) commit
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
git commit -m "feat: add search" in src/app" git commit -m "feat: add search" in src/app"
`; `;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = ` exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) commit (r:) commit
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
git commit -m "feat: add search" in src/app" git commit -m "feat: add search" in src/app"
`; `;
exports[`InputPrompt > image path transformation snapshots > should snapshot collapsed image path 1`] = ` exports[`InputPrompt > image path transformation snapshots > should snapshot collapsed image path 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Image ...reenshot2x.png] > [Image ...reenshot2x.png]
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`; `;
exports[`InputPrompt > image path transformation snapshots > should snapshot expanded image path when cursor is on it 1`] = ` exports[`InputPrompt > image path transformation snapshots > should snapshot expanded image path when cursor is on it 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> @/path/to/screenshots/screenshot2x.png > @/path/to/screenshots/screenshot2x.png
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`; `;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file > Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`; `;
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = ` exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
! Type your message or @path/to/file ! Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`; `;
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = ` exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
* Type your message or @path/to/file * Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`; `;
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = ` exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file > Type your message or @path/to/file
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`; `;
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { render } from '../../../test-utils/render.js'; import { renderWithProviders } from '../../../test-utils/render.js';
import { UserMessage } from './UserMessage.js'; import { UserMessage } from './UserMessage.js';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
@@ -15,8 +15,9 @@ vi.mock('../../utils/commandUtils.js', () => ({
describe('UserMessage', () => { describe('UserMessage', () => {
it('renders normal user message with correct prefix', () => { it('renders normal user message with correct prefix', () => {
const { lastFrame } = render( const { lastFrame } = renderWithProviders(
<UserMessage text="Hello Gemini" width={80} />, <UserMessage text="Hello Gemini" width={80} />,
{ width: 80 },
); );
const output = lastFrame(); const output = lastFrame();
@@ -24,7 +25,10 @@ describe('UserMessage', () => {
}); });
it('renders slash command message', () => { it('renders slash command message', () => {
const { lastFrame } = render(<UserMessage text="/help" width={80} />); const { lastFrame } = renderWithProviders(
<UserMessage text="/help" width={80} />,
{ width: 80 },
);
const output = lastFrame(); const output = lastFrame();
expect(output).toMatchSnapshot(); expect(output).toMatchSnapshot();
@@ -32,7 +36,10 @@ describe('UserMessage', () => {
it('renders multiline user message', () => { it('renders multiline user message', () => {
const message = 'Line 1\nLine 2'; const message = 'Line 1\nLine 2';
const { lastFrame } = render(<UserMessage text={message} width={80} />); const { lastFrame } = renderWithProviders(
<UserMessage text={message} width={80} />,
{ width: 80 },
);
const output = lastFrame(); const output = lastFrame();
expect(output).toMatchSnapshot(); expect(output).toMatchSnapshot();
@@ -9,6 +9,9 @@ import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_USER_PREFIX } from '../../textConstants.js'; import { SCREEN_READER_USER_PREFIX } from '../../textConstants.js';
import { isSlashCommand as checkIsSlashCommand } from '../../utils/commandUtils.js'; import { isSlashCommand as checkIsSlashCommand } from '../../utils/commandUtils.js';
import { HalfLinePaddedBox } from '../shared/HalfLinePaddedBox.js';
import { DEFAULT_BACKGROUND_OPACITY } from '../../constants.js';
import { useConfig } from '../../contexts/ConfigContext.js';
interface UserMessageProps { interface UserMessageProps {
text: string; text: string;
@@ -19,19 +22,30 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
const prefix = '> '; const prefix = '> ';
const prefixWidth = prefix.length; const prefixWidth = prefix.length;
const isSlashCommand = checkIsSlashCommand(text); const isSlashCommand = checkIsSlashCommand(text);
const config = useConfig();
const useBackgroundColor = config.getUseBackgroundColor();
const textColor = isSlashCommand ? theme.text.accent : theme.text.secondary; const textColor = isSlashCommand ? theme.text.accent : theme.text.secondary;
return ( return (
<HalfLinePaddedBox
backgroundBaseColor={theme.border.default}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor}
>
<Box <Box
flexDirection="row" flexDirection="row"
paddingY={0} paddingY={0}
marginY={1} marginY={useBackgroundColor ? 0 : 1}
paddingX={useBackgroundColor ? 1 : 0}
alignSelf="flex-start" alignSelf="flex-start"
width={width} width={width}
> >
<Box width={prefixWidth} flexShrink={0}> <Box width={prefixWidth} flexShrink={0}>
<Text color={theme.text.accent} aria-label={SCREEN_READER_USER_PREFIX}> <Text
color={theme.text.accent}
aria-label={SCREEN_READER_USER_PREFIX}
>
{prefix} {prefix}
</Text> </Text>
</Box> </Box>
@@ -41,5 +55,6 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
</Text> </Text>
</Box> </Box>
</Box> </Box>
</HalfLinePaddedBox>
); );
}; };
@@ -7,19 +7,40 @@
import type React from 'react'; import type React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../semantic-colors.js';
import { HalfLinePaddedBox } from '../shared/HalfLinePaddedBox.js';
import { DEFAULT_BACKGROUND_OPACITY } from '../../constants.js';
import { useConfig } from '../../contexts/ConfigContext.js';
interface UserShellMessageProps { interface UserShellMessageProps {
text: string; text: string;
width: number;
} }
export const UserShellMessage: React.FC<UserShellMessageProps> = ({ text }) => { export const UserShellMessage: React.FC<UserShellMessageProps> = ({
text,
width,
}) => {
const config = useConfig();
const useBackgroundColor = config.getUseBackgroundColor();
// Remove leading '!' if present, as App.tsx adds it for the processor. // Remove leading '!' if present, as App.tsx adds it for the processor.
const commandToDisplay = text.startsWith('!') ? text.substring(1) : text; const commandToDisplay = text.startsWith('!') ? text.substring(1) : text;
return ( return (
<Box> <HalfLinePaddedBox
backgroundBaseColor={theme.border.default}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor}
>
<Box
paddingY={0}
marginY={useBackgroundColor ? 0 : 1}
paddingX={useBackgroundColor ? 1 : 0}
width={width}
>
<Text color={theme.ui.symbol}>$ </Text> <Text color={theme.ui.symbol}>$ </Text>
<Text color={theme.text.primary}>{commandToDisplay}</Text> <Text color={theme.text.primary}>{commandToDisplay}</Text>
</Box> </Box>
</HalfLinePaddedBox>
); );
}; };
@@ -1,20 +1,20 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UserMessage > renders multiline user message 1`] = ` exports[`UserMessage > renders multiline user message 1`] = `
" "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Line 1 > Line 1
Line 2 Line 2
" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`; `;
exports[`UserMessage > renders normal user message with correct prefix 1`] = ` exports[`UserMessage > renders normal user message with correct prefix 1`] = `
" "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Hello Gemini > Hello Gemini
" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`; `;
exports[`UserMessage > renders slash command message 1`] = ` exports[`UserMessage > renders slash command message 1`] = `
" "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> /help > /help
" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`; `;
@@ -0,0 +1,102 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { useUIState } from '../../contexts/UIStateContext.js';
import {
interpolateColor,
resolveColor,
getSafeLowColorBackground,
} from '../../themes/color-utils.js';
import { isLowColorDepth } from '../../utils/terminalUtils.js';
export interface HalfLinePaddedBoxProps {
/**
* The base color to blend with the terminal background.
*/
backgroundBaseColor: string;
/**
* The opacity (0-1) for blending the backgroundBaseColor onto the terminal background.
*/
backgroundOpacity: number;
/**
* Whether to render the solid background color.
*/
useBackgroundColor?: boolean;
children: React.ReactNode;
}
/**
* A container component that renders a solid background with half-line padding
* at the top and bottom using block characters (/).
*/
export const HalfLinePaddedBox: React.FC<HalfLinePaddedBoxProps> = (props) => {
if (props.useBackgroundColor === false) {
return <>{props.children}</>;
}
return <HalfLinePaddedBoxInternal {...props} />;
};
const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
backgroundBaseColor,
backgroundOpacity,
children,
}) => {
const { terminalWidth, terminalBackgroundColor } = useUIState();
const terminalBg = terminalBackgroundColor || 'black';
const isLowColor = isLowColorDepth();
const backgroundColor = useMemo(() => {
// Interpolated background colors often look bad in 256-color terminals
if (isLowColor) {
return getSafeLowColorBackground(terminalBg);
}
const resolvedBase =
resolveColor(backgroundBaseColor) || backgroundBaseColor;
const resolvedTerminalBg = resolveColor(terminalBg) || terminalBg;
return interpolateColor(
resolvedTerminalBg,
resolvedBase,
backgroundOpacity,
);
}, [backgroundBaseColor, backgroundOpacity, terminalBg, isLowColor]);
if (!backgroundColor) {
return <>{children}</>;
}
return (
<Box
width={terminalWidth}
flexDirection="column"
alignItems="stretch"
minHeight={1}
flexShrink={0}
backgroundColor={backgroundColor}
>
<Box width={terminalWidth} flexDirection="row">
<Text backgroundColor={backgroundColor} color={terminalBg}>
{'▀'.repeat(terminalWidth)}
</Text>
</Box>
{children}
<Box width={terminalWidth} flexDirection="row">
<Text color={terminalBg} backgroundColor={backgroundColor}>
{'▄'.repeat(terminalWidth)}
</Text>
</Box>
</Box>
);
};
@@ -374,4 +374,37 @@ describe('ScrollableList Demo Behavior', () => {
}); });
}); });
}); });
describe('Width Prop', () => {
it('should apply the width prop to the container', async () => {
const items = [{ id: '1', title: 'Item 1' }];
let lastFrame: () => string | undefined;
await act(async () => {
const result = render(
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box width={100} height={20}>
<ScrollableList
data={items}
renderItem={({ item }) => <Text>{item.title}</Text>}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
width={50}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>,
);
lastFrame = result.lastFrame;
});
await waitFor(() => {
expect(lastFrame()).toContain('Item 1');
});
});
});
}); });
@@ -37,6 +37,7 @@ type VirtualizedListProps<T> = {
interface ScrollableListProps<T> extends VirtualizedListProps<T> { interface ScrollableListProps<T> extends VirtualizedListProps<T> {
hasFocus: boolean; hasFocus: boolean;
width?: string | number;
} }
export type ScrollableListRef<T> = VirtualizedListRef<T>; export type ScrollableListRef<T> = VirtualizedListRef<T>;
@@ -45,7 +46,7 @@ function ScrollableList<T>(
props: ScrollableListProps<T>, props: ScrollableListProps<T>,
ref: React.Ref<ScrollableListRef<T>>, ref: React.Ref<ScrollableListRef<T>>,
) { ) {
const { hasFocus } = props; const { hasFocus, width } = props;
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null); const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
const containerRef = useRef<DOMElement>(null); const containerRef = useRef<DOMElement>(null);
@@ -236,6 +237,7 @@ function ScrollableList<T>(
flexGrow={1} flexGrow={1}
flexDirection="column" flexDirection="column"
overflow="hidden" overflow="hidden"
width={width}
> >
<VirtualizedList <VirtualizedList
ref={virtualizedListRef} ref={virtualizedListRef}
+2
View File
@@ -34,6 +34,8 @@ export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000; export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000;
export const SHELL_SILENT_WORKING_TITLE_DELAY_MS = 120000; export const SHELL_SILENT_WORKING_TITLE_DELAY_MS = 120000;
export const DEFAULT_BACKGROUND_OPACITY = 0.08;
export const KEYBOARD_SHORTCUTS_URL = export const KEYBOARD_SHORTCUTS_URL =
'https://geminicli.com/docs/cli/keyboard-shortcuts/'; 'https://geminicli.com/docs/cli/keyboard-shortcuts/';
export const LRU_BUFFER_PERF_CACHE_LIMIT = 20000; export const LRU_BUFFER_PERF_CACHE_LIMIT = 20000;
@@ -34,13 +34,10 @@ export const DefaultAppLayout: React.FC = () => {
useFlickerDetector(rootUiRef, terminalHeight); useFlickerDetector(rootUiRef, terminalHeight);
// If in alternate buffer mode, need to leave room to draw the scrollbar on // If in alternate buffer mode, need to leave room to draw the scrollbar on
// the right side of the terminal. // the right side of the terminal.
const width = isAlternateBuffer
? uiState.terminalWidth
: uiState.mainAreaWidth;
return ( return (
<Box <Box
flexDirection="column" flexDirection="column"
width={width} width={uiState.terminalWidth}
height={isAlternateBuffer ? terminalHeight : undefined} height={isAlternateBuffer ? terminalHeight : undefined}
paddingBottom={isAlternateBuffer ? 1 : undefined} paddingBottom={isAlternateBuffer ? 1 : undefined}
flexShrink={0} flexShrink={0}
@@ -55,6 +52,7 @@ export const DefaultAppLayout: React.FC = () => {
ref={uiState.mainControlsRef} ref={uiState.mainControlsRef}
flexShrink={0} flexShrink={0}
flexGrow={0} flexGrow={0}
width={uiState.terminalWidth}
> >
<Notifications /> <Notifications />
<CopyModeWarning /> <CopyModeWarning />
@@ -63,7 +61,7 @@ export const DefaultAppLayout: React.FC = () => {
uiState.customDialog uiState.customDialog
) : uiState.dialogsVisible ? ( ) : uiState.dialogsVisible ? (
<DialogManager <DialogManager
terminalWidth={uiState.mainAreaWidth} terminalWidth={uiState.terminalWidth}
addItem={uiState.historyManager.addItem} addItem={uiState.historyManager.addItem}
/> />
) : ( ) : (
+27
View File
@@ -233,6 +233,33 @@ export function resolveColor(colorValue: string): string | undefined {
return undefined; return undefined;
} }
/**
* Returns a "safe" background color to use in low-color terminals if the
* terminal background is a standard black or white.
* Returns undefined if no safe background color is available for the given
* terminal background.
*/
export function getSafeLowColorBackground(
terminalBg: string,
): string | undefined {
const resolvedTerminalBg = resolveColor(terminalBg) || terminalBg;
if (
resolvedTerminalBg === 'black' ||
resolvedTerminalBg === '#000000' ||
resolvedTerminalBg === '#000'
) {
return '#1c1c1c';
}
if (
resolvedTerminalBg === 'white' ||
resolvedTerminalBg === '#ffffff' ||
resolvedTerminalBg === '#fff'
) {
return '#eeeeee';
}
return undefined;
}
export function interpolateColor( export function interpolateColor(
color1: string, color1: string,
color2: string, color2: string,
@@ -1,20 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ui-sizing > calculateMainAreaWidth > should match snapshot for interpolation range 1`] = `
{
"100": 95,
"104": 98,
"108": 101,
"112": 104,
"116": 107,
"120": 110,
"124": 113,
"128": 116,
"132": 119,
"80": 78,
"84": 82,
"88": 85,
"92": 88,
"96": 92,
}
`;
@@ -0,0 +1,22 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
/**
* Returns the color depth of the current terminal.
* Returns 24 (TrueColor) if unknown or not a TTY.
*/
export function getColorDepth(): number {
return process.stdout.getColorDepth ? process.stdout.getColorDepth() : 24;
}
/**
* Returns true if the terminal has low color depth (less than 24-bit).
*/
export function isLowColorDepth(): boolean {
return getColorDepth() < 24;
}
+8 -32
View File
@@ -29,43 +29,19 @@ describe('ui-sizing', () => {
describe('calculateMainAreaWidth', () => { describe('calculateMainAreaWidth', () => {
it.each([ it.each([
// width, useFullWidth, alternateBuffer, expected // expected, width, altBuffer
[80, true, false, 80], [80, 80, false],
[100, true, false, 100], [100, 100, false],
[80, true, true, 79], // -1 for alternate buffer [79, 80, true],
[100, true, true, 99], [99, 100, true],
// Default behavior (useFullWidth true)
[100, true, false, 100],
// useFullWidth: false (Smart sizing)
[80, false, false, 78], // 98% of 80
[132, false, false, 119], // 90% of 132
[200, false, false, 180], // 90% of 200 (>= 132)
// Interpolation check
[106, false, false, 100], // Approx middle
])( ])(
'should return %i when width=%i, useFullWidth=%s, altBuffer=%s', 'should return %i when width=%i and altBuffer=%s',
(width, useFullWidth, altBuffer, expected) => { (expected, width, altBuffer) => {
mocks.isAlternateBufferEnabled.mockReturnValue(altBuffer); mocks.isAlternateBufferEnabled.mockReturnValue(altBuffer);
const settings = createSettings(useFullWidth); const settings = createSettings();
expect(calculateMainAreaWidth(width, settings)).toBe(expected); expect(calculateMainAreaWidth(width, settings)).toBe(expected);
}, },
); );
it('should match snapshot for interpolation range', () => {
mocks.isAlternateBufferEnabled.mockReturnValue(false);
const settings = createSettings(false);
const results: Record<number, number> = {};
// Test range from 80 to 132
for (let w = 80; w <= 132; w += 4) {
results[w] = calculateMainAreaWidth(w, settings);
}
expect(results).toMatchSnapshot();
});
}); });
}); });
-19
View File
@@ -4,34 +4,15 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { lerp } from '../../utils/math.js';
import { type LoadedSettings } from '../../config/settings.js'; import { type LoadedSettings } from '../../config/settings.js';
import { isAlternateBufferEnabled } from '../hooks/useAlternateBuffer.js'; import { isAlternateBufferEnabled } from '../hooks/useAlternateBuffer.js';
const getMainAreaWidthInternal = (terminalWidth: number): number => {
if (terminalWidth <= 80) {
return Math.round(0.98 * terminalWidth);
}
if (terminalWidth >= 132) {
return Math.round(0.9 * terminalWidth);
}
// Linearly interpolate between 80 columns (98%) and 132 columns (90%).
const t = (terminalWidth - 80) / (132 - 80);
const percentage = lerp(98, 90, t);
return Math.round(percentage * terminalWidth * 0.01);
};
export const calculateMainAreaWidth = ( export const calculateMainAreaWidth = (
terminalWidth: number, terminalWidth: number,
settings: LoadedSettings, settings: LoadedSettings,
): number => { ): number => {
if (settings.merged.ui.useFullWidth) {
if (isAlternateBufferEnabled(settings)) { if (isAlternateBufferEnabled(settings)) {
return terminalWidth - 1; return terminalWidth - 1;
} }
return terminalWidth; return terminalWidth;
}
return getMainAreaWidthInternal(terminalWidth);
}; };
+7
View File
@@ -356,6 +356,7 @@ export interface ConfigParameters {
compressionThreshold?: number; compressionThreshold?: number;
interactive?: boolean; interactive?: boolean;
trustedFolder?: boolean; trustedFolder?: boolean;
useBackgroundColor?: boolean;
useRipgrep?: boolean; useRipgrep?: boolean;
enableInteractiveShell?: boolean; enableInteractiveShell?: boolean;
skipNextSpeakerCheck?: boolean; skipNextSpeakerCheck?: boolean;
@@ -500,6 +501,7 @@ export class Config {
private readonly useRipgrep: boolean; private readonly useRipgrep: boolean;
private readonly enableInteractiveShell: boolean; private readonly enableInteractiveShell: boolean;
private readonly skipNextSpeakerCheck: boolean; private readonly skipNextSpeakerCheck: boolean;
private readonly useBackgroundColor: boolean;
private shellExecutionConfig: ShellExecutionConfig; private shellExecutionConfig: ShellExecutionConfig;
private readonly extensionManagement: boolean = true; private readonly extensionManagement: boolean = true;
private readonly enablePromptCompletion: boolean = false; private readonly enablePromptCompletion: boolean = false;
@@ -666,6 +668,7 @@ export class Config {
this.ptyInfo = params.ptyInfo ?? 'child_process'; this.ptyInfo = params.ptyInfo ?? 'child_process';
this.trustedFolder = params.trustedFolder; this.trustedFolder = params.trustedFolder;
this.useRipgrep = params.useRipgrep ?? true; this.useRipgrep = params.useRipgrep ?? true;
this.useBackgroundColor = params.useBackgroundColor ?? true;
this.enableInteractiveShell = params.enableInteractiveShell ?? false; this.enableInteractiveShell = params.enableInteractiveShell ?? false;
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true; this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true;
this.shellExecutionConfig = { this.shellExecutionConfig = {
@@ -1823,6 +1826,10 @@ export class Config {
return this.useRipgrep; return this.useRipgrep;
} }
getUseBackgroundColor(): boolean {
return this.useBackgroundColor;
}
getEnableInteractiveShell(): boolean { getEnableInteractiveShell(): boolean {
return this.enableInteractiveShell; return this.enableInteractiveShell;
} }
+7 -7
View File
@@ -302,13 +302,6 @@
"default": false, "default": false,
"type": "boolean" "type": "boolean"
}, },
"useFullWidth": {
"title": "Use Full Width",
"description": "Use the entire width of the terminal for output.",
"markdownDescription": "Use the entire width of the terminal for output.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"useAlternateBuffer": { "useAlternateBuffer": {
"title": "Use Alternate Screen Buffer", "title": "Use Alternate Screen Buffer",
"description": "Use an alternate screen buffer for the UI, preserving shell history.", "description": "Use an alternate screen buffer for the UI, preserving shell history.",
@@ -316,6 +309,13 @@
"default": false, "default": false,
"type": "boolean" "type": "boolean"
}, },
"useBackgroundColor": {
"title": "Use Background Color",
"description": "Whether to use background colors in the UI.",
"markdownDescription": "Whether to use background colors in the UI.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"incrementalRendering": { "incrementalRendering": {
"title": "Incremental Rendering", "title": "Incremental Rendering",
"description": "Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.", "description": "Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.",