mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-01 12:41:00 -07:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f509fbf77 | |||
| 320ec2c78c | |||
| 4ce7968fa0 | |||
| 2aa95c4d01 | |||
| ae433fbef7 | |||
| c1c8ecef3c | |||
| 96798e32b2 | |||
| ddeacbd33b | |||
| ed4c17e03e | |||
| 19698ca4ac | |||
| 12c4d27506 | |||
| 909c35c2d5 | |||
| cc09cfd7a4 | |||
| 800edce6b1 | |||
| cbe297bc97 | |||
| 55a7a22471 | |||
| ee1bc7e209 | |||
| 23441126b4 | |||
| 86487af7ff | |||
| dcde43b031 | |||
| d912a58f81 | |||
| da5af386e0 | |||
| 50a22245f8 | |||
| 17b40b31b8 | |||
| 4bc7e2554f | |||
| 09774da43c | |||
| 0bd797a2be |
@@ -174,6 +174,7 @@ they appear in the UI.
|
||||
| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
|
||||
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
|
||||
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
|
||||
| Enable /btw Side Inquiries | `experimental.btw` | Enable the experimental /btw side inquiry command for ephemeral, non-persisted chat turns. | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -49,6 +49,13 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
behavior can be modified using the `advanced.bugCommand` setting in your
|
||||
`.gemini/settings.json` files.
|
||||
|
||||
### `/btw`
|
||||
|
||||
- **Description:** Ask a side question without affecting history (ephemeral).
|
||||
- **Note:** This command is experimental and requires `experimental.btw: true`
|
||||
in your `settings.json`.
|
||||
- **Usage:** `/btw <question>`
|
||||
|
||||
### `/chat`
|
||||
|
||||
- **Description:** Alias for `/resume`. Both commands now expose the same
|
||||
|
||||
@@ -1452,11 +1452,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
performance.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.shell.rcFile`** (string):
|
||||
- **Description:** The path to a bash file (e.g., .bashrc) to source before
|
||||
executing shell commands.
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`tools.core`** (array):
|
||||
- **Description:** Restrict the set of built-in tools with an allowlist. Match
|
||||
semantics mirror tools.allowed; see the built-in tools documentation for
|
||||
@@ -1786,6 +1781,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Deprecated: Use general.topicUpdateNarration instead.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`experimental.btw`** (boolean):
|
||||
- **Description:** Enable the experimental /btw side inquiry command for
|
||||
ephemeral, non-persisted chat turns.
|
||||
- **Default:** `false`
|
||||
|
||||
#### `skills`
|
||||
|
||||
- **`skills.enabled`** (boolean):
|
||||
|
||||
Generated
+31
-3
@@ -449,7 +449,8 @@
|
||||
"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)"
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@bundled-es-modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
@@ -1473,6 +1474,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
|
||||
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"@js-sdsl/ordered-map": "^4.4.2"
|
||||
@@ -2150,6 +2152,7 @@
|
||||
"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",
|
||||
@@ -2330,6 +2333,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -2379,6 +2383,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
|
||||
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2753,6 +2758,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
|
||||
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2786,6 +2792,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
|
||||
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0"
|
||||
@@ -2840,6 +2847,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
|
||||
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0",
|
||||
@@ -4046,6 +4054,7 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4319,6 +4328,7 @@
|
||||
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.58.2",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
@@ -5113,6 +5123,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -7190,7 +7201,8 @@
|
||||
"version": "0.0.1581282",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
|
||||
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
@@ -7775,6 +7787,7 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8292,6 +8305,7 @@
|
||||
"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",
|
||||
@@ -9558,6 +9572,7 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
|
||||
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -9817,6 +9832,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz",
|
||||
"integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-escapes": "^7.0.0",
|
||||
"ansi-styles": "^6.2.3",
|
||||
@@ -13530,6 +13546,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -13540,6 +13557,7 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -15659,6 +15677,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15881,7 +15900,8 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -15889,6 +15909,7 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -16054,6 +16075,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16507,6 +16529,7 @@
|
||||
"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",
|
||||
@@ -17077,6 +17100,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17089,6 +17113,7 @@
|
||||
"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",
|
||||
@@ -17727,6 +17752,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -18162,6 +18188,7 @@
|
||||
"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"
|
||||
@@ -18280,6 +18307,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -925,7 +925,6 @@ export async function loadCliConfig(
|
||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
mcpServerCommand,
|
||||
shellToolRcFile: settings.tools?.shell?.rcFile,
|
||||
mcpServers,
|
||||
mcpEnablementCallbacks,
|
||||
mcpEnabled,
|
||||
@@ -996,6 +995,7 @@ export async function loadCliConfig(
|
||||
experimentalMemoryManager: settings.experimental?.memoryManager,
|
||||
experimentalAutoMemory: settings.experimental?.autoMemory,
|
||||
contextManagement,
|
||||
experimentalBtw: settings.experimental?.btw,
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
topicUpdateNarration:
|
||||
settings.general?.topicUpdateNarration ??
|
||||
|
||||
@@ -1637,16 +1637,6 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable shell output efficiency optimizations for better performance.',
|
||||
showInDialog: false,
|
||||
},
|
||||
rcFile: {
|
||||
type: 'string',
|
||||
label: 'Shell Tool RC File',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'The path to a bash file (e.g., .bashrc) to source before executing shell commands.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2332,6 +2322,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Deprecated: Use general.topicUpdateNarration instead.',
|
||||
showInDialog: false,
|
||||
},
|
||||
btw: {
|
||||
type: 'boolean',
|
||||
label: 'Enable /btw Side Inquiries',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Enable the experimental /btw side inquiry command for ephemeral, non-persisted chat turns.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
extensions: {
|
||||
|
||||
@@ -165,6 +165,7 @@ describe('BuiltinCommandLoader', () => {
|
||||
getExtensionsEnabled: vi.fn().mockReturnValue(true),
|
||||
isSkillsSupportEnabled: vi.fn().mockReturnValue(true),
|
||||
isAgentsEnabled: vi.fn().mockReturnValue(false),
|
||||
isBtwEnabled: vi.fn().mockReturnValue(false),
|
||||
getMcpEnabled: vi.fn().mockReturnValue(true),
|
||||
getSkillManager: vi.fn().mockReturnValue({
|
||||
getAllSkills: vi.fn().mockReturnValue([]),
|
||||
@@ -301,6 +302,22 @@ describe('BuiltinCommandLoader', () => {
|
||||
expect(planCmd).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include btw command when btw is enabled', async () => {
|
||||
mockConfig.isBtwEnabled = vi.fn().mockReturnValue(true);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const btwCmd = commands.find((c) => c.name === 'btw');
|
||||
expect(btwCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should exclude btw command when btw is disabled', async () => {
|
||||
mockConfig.isBtwEnabled = vi.fn().mockReturnValue(false);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const btwCmd = commands.find((c) => c.name === 'btw');
|
||||
expect(btwCmd).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should exclude agents command when agents are disabled', async () => {
|
||||
mockConfig.isAgentsEnabled = vi.fn().mockReturnValue(false);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
@@ -391,6 +408,7 @@ describe('BuiltinCommandLoader profile', () => {
|
||||
getExtensionsEnabled: vi.fn().mockReturnValue(true),
|
||||
isSkillsSupportEnabled: vi.fn().mockReturnValue(true),
|
||||
isAgentsEnabled: vi.fn().mockReturnValue(false),
|
||||
isBtwEnabled: vi.fn().mockReturnValue(false),
|
||||
getMcpEnabled: vi.fn().mockReturnValue(true),
|
||||
getSkillManager: vi.fn().mockReturnValue({
|
||||
getAllSkills: vi.fn().mockReturnValue([]),
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
||||
import { authCommand } from '../ui/commands/authCommand.js';
|
||||
import { btwCommand } from '../ui/commands/btwCommand.js';
|
||||
import { bugCommand } from '../ui/commands/bugCommand.js';
|
||||
import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
@@ -121,6 +122,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
aboutCommand,
|
||||
...(this.config?.isAgentsEnabled() ? [agentsCommand] : []),
|
||||
authCommand,
|
||||
...(this.config?.isBtwEnabled() ? [btwCommand] : []),
|
||||
bugCommand,
|
||||
{
|
||||
...chatCommand,
|
||||
|
||||
@@ -523,6 +523,13 @@ const baseMockUiState = {
|
||||
},
|
||||
hintMode: false,
|
||||
hintBuffer: '',
|
||||
btwState: {
|
||||
isActive: false,
|
||||
query: '',
|
||||
response: '',
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
},
|
||||
bannerData: {
|
||||
defaultText: '',
|
||||
warningText: '',
|
||||
@@ -589,6 +596,7 @@ const mockUIActions: UIActions = {
|
||||
dismissBackgroundTask: vi.fn(),
|
||||
setActiveBackgroundTaskPid: vi.fn(),
|
||||
setIsBackgroundTaskListOpen: vi.fn(),
|
||||
dismissBtw: vi.fn(),
|
||||
setAuthContext: vi.fn(),
|
||||
onHintInput: vi.fn(),
|
||||
onHintBackspace: vi.fn(),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useLayoutEffect,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { type PartListUnion } from '@google/genai';
|
||||
import {
|
||||
type DOMElement,
|
||||
ResizeObserver,
|
||||
@@ -92,6 +93,7 @@ import {
|
||||
ApiKeyUpdatedEvent,
|
||||
LegacyAgentProtocol,
|
||||
type InjectionSource,
|
||||
partListUnionToString,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import process from 'node:process';
|
||||
@@ -163,6 +165,7 @@ import { isWorkspaceTrusted } from '../config/trustedFolders.js';
|
||||
import { useSettings } from './contexts/SettingsContext.js';
|
||||
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
|
||||
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||
import { useBtw } from './hooks/useBtw.js';
|
||||
import { useBanner } from './hooks/useBanner.js';
|
||||
import { useTerminalSetupPrompt } from './utils/terminalSetup.js';
|
||||
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||
@@ -236,6 +239,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const historyManager = useHistory({
|
||||
chatRecordingService: config.getGeminiClient()?.getChatRecordingService(),
|
||||
});
|
||||
const btw = useBtw(config.getGeminiClient());
|
||||
|
||||
useMemoryMonitor(historyManager);
|
||||
const isAlternateBuffer = config.getUseAlternateBuffer();
|
||||
@@ -1042,6 +1046,21 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setCustomDialog,
|
||||
);
|
||||
|
||||
const handleSlashCommandWrapper = useCallback(
|
||||
async (cmd: PartListUnion) => {
|
||||
const submittedValue = partListUnionToString(cmd);
|
||||
const result = await handleSlashCommand(submittedValue);
|
||||
if (result && result.type === 'btw') {
|
||||
void btw.submitBtw(result.query);
|
||||
return {
|
||||
type: 'handled' as const,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[handleSlashCommand, btw],
|
||||
);
|
||||
|
||||
const [authConsentRequest, setAuthConsentRequest] =
|
||||
useState<ConfirmationRequest | null>(null);
|
||||
const [permissionConfirmationRequest, setPermissionConfirmationRequest] =
|
||||
@@ -1391,7 +1410,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
slashCommands ?? [],
|
||||
);
|
||||
if (commandToExecute?.isSafeConcurrent) {
|
||||
void handleSlashCommand(submittedValue);
|
||||
void handleSlashCommandWrapper(submittedValue);
|
||||
addInput(submittedValue);
|
||||
return;
|
||||
}
|
||||
@@ -1443,7 +1462,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
addMessage,
|
||||
addInput,
|
||||
submitQuery,
|
||||
handleSlashCommand,
|
||||
handleSlashCommandWrapper,
|
||||
slashCommands,
|
||||
isMcpReady,
|
||||
streamingState,
|
||||
@@ -2539,6 +2558,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
hintMode:
|
||||
config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems),
|
||||
hintBuffer: '',
|
||||
btwState: {
|
||||
isActive: btw.isActive,
|
||||
query: btw.query,
|
||||
response: btw.response,
|
||||
isStreaming: btw.isStreaming,
|
||||
error: btw.error,
|
||||
},
|
||||
}),
|
||||
[
|
||||
isThemeDialogOpen,
|
||||
@@ -2650,6 +2676,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
btw.isActive,
|
||||
btw.query,
|
||||
btw.response,
|
||||
btw.isStreaming,
|
||||
btw.error,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2709,6 +2740,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
dismissBackgroundTask,
|
||||
setActiveBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen,
|
||||
dismissBtw: btw.dismissBtw,
|
||||
setAuthContext,
|
||||
onHintInput: () => {},
|
||||
onHintBackspace: () => {},
|
||||
@@ -2803,6 +2835,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setIsBackgroundTaskListOpen,
|
||||
setAuthContext,
|
||||
setAccountSuspensionInfo,
|
||||
btw.dismissBtw,
|
||||
newAgents,
|
||||
config,
|
||||
historyManager,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { btwCommand } from './btwCommand.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
|
||||
describe('btwCommand', () => {
|
||||
it('has the correct metadata', () => {
|
||||
expect(btwCommand.name).toBe('btw');
|
||||
expect(btwCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||
expect(btwCommand.autoExecute).toBe(true);
|
||||
expect(btwCommand.isSafeConcurrent).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an error message when btw is not enabled in settings', () => {
|
||||
const context = {
|
||||
services: {
|
||||
settings: {
|
||||
merged: {
|
||||
experimental: {
|
||||
btw: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
const result = btwCommand.action!(context, 'question');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'/btw is an experimental feature. To enable it, run `gemini settings set experimental.btw true`.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error message when args are empty and btw is enabled', () => {
|
||||
const context = {
|
||||
services: {
|
||||
settings: {
|
||||
merged: {
|
||||
experimental: {
|
||||
btw: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
const result = btwCommand.action!(context, ' ');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'Please provide a question, e.g. /btw what does this function do?',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a btw action when query is provided and btw is enabled', () => {
|
||||
const context = {
|
||||
services: {
|
||||
settings: {
|
||||
merged: {
|
||||
experimental: {
|
||||
btw: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
const result = btwCommand.action!(context, ' what is this regex doing? ');
|
||||
expect(result).toEqual({
|
||||
type: 'btw',
|
||||
query: 'what is this regex doing?',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
|
||||
export const btwCommand: SlashCommand = {
|
||||
name: 'btw',
|
||||
description: 'Ask a side question without affecting history (ephemeral)',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: (context, args) => {
|
||||
const isBtwEnabled = context.services.settings.merged.experimental?.btw;
|
||||
if (!isBtwEnabled) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'/btw is an experimental feature. To enable it, run `gemini settings set experimental.btw true`.',
|
||||
};
|
||||
}
|
||||
|
||||
const query = args.trim();
|
||||
if (!query) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'Please provide a question, e.g. /btw what does this function do?',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'btw',
|
||||
query,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
HistoryItemWithoutId,
|
||||
HistoryItem,
|
||||
ConfirmationRequest,
|
||||
BtwActionReturn,
|
||||
} from '../types.js';
|
||||
import type {
|
||||
GitService,
|
||||
@@ -173,7 +174,8 @@ export type SlashCommandActionReturn =
|
||||
| ConfirmShellCommandsActionReturn
|
||||
| ConfirmActionReturn
|
||||
| OpenCustomDialogActionReturn
|
||||
| LogoutActionReturn;
|
||||
| LogoutActionReturn
|
||||
| BtwActionReturn;
|
||||
|
||||
export enum CommandKind {
|
||||
BUILT_IN = 'built-in',
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { BtwDisplay } from './BtwDisplay.js';
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
describe('BtwDisplay', () => {
|
||||
const defaultMockUiState = {
|
||||
renderMarkdown: true,
|
||||
} as unknown as Partial<UIState>;
|
||||
|
||||
it('renders nothing when query is empty', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<BtwDisplay
|
||||
query=""
|
||||
response=""
|
||||
isStreaming={false}
|
||||
error={null}
|
||||
terminalWidth={100}
|
||||
/>,
|
||||
{ uiState: defaultMockUiState },
|
||||
);
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders query and response', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<BtwDisplay
|
||||
query="What is life?"
|
||||
response="Life is 42."
|
||||
isStreaming={false}
|
||||
error={null}
|
||||
terminalWidth={100}
|
||||
/>,
|
||||
{ uiState: defaultMockUiState },
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('What is life?');
|
||||
expect(frame).toContain('Life is 42.');
|
||||
expect(frame).toContain('BY THE WAY');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders error message when error is provided', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<BtwDisplay
|
||||
query="What is life?"
|
||||
response="Life is 42."
|
||||
isStreaming={false}
|
||||
error="An API error occurred."
|
||||
terminalWidth={100}
|
||||
/>,
|
||||
{ uiState: defaultMockUiState },
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('An API error occurred.');
|
||||
expect(frame).not.toContain('Life is 42.');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders a spinner and "Answering..." when streaming', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<BtwDisplay
|
||||
query="What is life?"
|
||||
response="Life is 42."
|
||||
isStreaming={true}
|
||||
error={null}
|
||||
terminalWidth={100}
|
||||
/>,
|
||||
{
|
||||
uiState: defaultMockUiState,
|
||||
},
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Answering...');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { CliSpinner } from './CliSpinner.js';
|
||||
|
||||
interface BtwDisplayProps {
|
||||
query: string;
|
||||
response: string;
|
||||
isStreaming: boolean;
|
||||
error: string | null;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
export const BtwDisplay: React.FC<BtwDisplayProps> = ({
|
||||
query,
|
||||
response,
|
||||
isStreaming,
|
||||
error,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const { renderMarkdown } = useUIState();
|
||||
|
||||
if (!query) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.text.accent}
|
||||
paddingX={1}
|
||||
width={terminalWidth}
|
||||
marginBottom={1}
|
||||
>
|
||||
<Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
|
||||
<Box>
|
||||
<Text color={theme.text.accent} bold>
|
||||
BY THE WAY
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.secondary} italic>
|
||||
Press Esc, Enter or Space to dismiss
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.secondary}>Q: </Text>
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{query}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
{error ? (
|
||||
<Text color={theme.status.error}>{error}</Text>
|
||||
) : (
|
||||
<MarkdownDisplay
|
||||
text={response}
|
||||
isPending={isStreaming}
|
||||
terminalWidth={terminalWidth - 6}
|
||||
renderMarkdown={renderMarkdown}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isStreaming && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.accent}>
|
||||
<CliSpinner type="dots" /> Answering...
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -5097,6 +5097,150 @@ describe('InputPrompt', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Btw dismiss behavior', () => {
|
||||
it('dismisses Btw on ESC key press', async () => {
|
||||
const dismissBtw = vi.fn();
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
{
|
||||
uiState: {
|
||||
btwState: {
|
||||
isActive: true,
|
||||
query: '',
|
||||
response: '',
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
uiActions: { dismissBtw },
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x1B'); // ESC
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dismissBtw).toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('dismisses Btw on Enter key press', async () => {
|
||||
const dismissBtw = vi.fn();
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
{
|
||||
uiState: {
|
||||
btwState: {
|
||||
isActive: true,
|
||||
query: '',
|
||||
response: '',
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
uiActions: { dismissBtw },
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dismissBtw).toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('dismisses Btw on Space key press when buffer is empty', async () => {
|
||||
const dismissBtw = vi.fn();
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
{
|
||||
uiState: {
|
||||
btwState: {
|
||||
isActive: true,
|
||||
query: '',
|
||||
response: '',
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
uiActions: { dismissBtw },
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write(' '); // Space
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dismissBtw).toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('dismisses Btw and accepts input on typing when not streaming', async () => {
|
||||
const dismissBtw = vi.fn();
|
||||
const { stdin, stdout, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
{
|
||||
uiState: {
|
||||
btwState: {
|
||||
isActive: true,
|
||||
query: '',
|
||||
response: '',
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
uiActions: { dismissBtw },
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('a');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dismissBtw).toHaveBeenCalled();
|
||||
expect(clean(stdout.lastFrameRaw())).toContain('a');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('blocks typing when Btw is streaming', async () => {
|
||||
const dismissBtw = vi.fn();
|
||||
const { stdin, stdout, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
{
|
||||
uiState: {
|
||||
btwState: {
|
||||
isActive: true,
|
||||
query: '',
|
||||
response: '',
|
||||
isStreaming: true,
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
uiActions: { dismissBtw },
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('Z');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dismissBtw).not.toHaveBeenCalled();
|
||||
expect(clean(stdout.lastFrameRaw())).not.toContain('Z');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function clean(str: string | undefined): string {
|
||||
|
||||
@@ -240,6 +240,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setEmbeddedShellFocused,
|
||||
setShortcutsHelpVisible,
|
||||
toggleCleanUiDetailsVisible,
|
||||
dismissBtw,
|
||||
} = useUIActions();
|
||||
const {
|
||||
terminalWidth,
|
||||
@@ -248,6 +249,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
backgroundTasks,
|
||||
backgroundTaskHeight,
|
||||
shortcutsHelpVisible,
|
||||
btwState,
|
||||
} = useUIState();
|
||||
const [suppressCompletion, setSuppressCompletion] = useState(false);
|
||||
const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } =
|
||||
@@ -682,6 +684,39 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
keyMatchers[Command.COLLAPSE_SUGGESTION](key) ||
|
||||
keyMatchers[Command.ACCEPT_SUGGESTION](key));
|
||||
|
||||
// Handle BTW dismissal
|
||||
if (btwState.isActive) {
|
||||
if (
|
||||
keyMatchers[Command.ESCAPE](key) ||
|
||||
keyMatchers[Command.SUBMIT](key) ||
|
||||
(key.sequence === ' ' && buffer.text.length === 0)
|
||||
) {
|
||||
dismissBtw();
|
||||
return true;
|
||||
}
|
||||
|
||||
const isPrintable =
|
||||
key.sequence &&
|
||||
!key.ctrl &&
|
||||
!key.cmd &&
|
||||
!key.alt &&
|
||||
key.sequence.length === 1;
|
||||
|
||||
const isEditingKey =
|
||||
isPrintable ||
|
||||
key.name === 'backspace' ||
|
||||
key.name === 'delete' ||
|
||||
key.name === 'paste';
|
||||
|
||||
if (isEditingKey) {
|
||||
if (btwState.isStreaming) {
|
||||
return true;
|
||||
} else {
|
||||
dismissBtw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset completion suppression if the user performs any action other than
|
||||
// history navigation or cursor movement.
|
||||
// We explicitly skip this if we are currently navigating suggestions.
|
||||
@@ -1371,6 +1406,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
keyMatchers,
|
||||
isHelpDismissKey,
|
||||
settings,
|
||||
btwState.isActive,
|
||||
btwState.isStreaming,
|
||||
dismissBtw,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -344,6 +344,13 @@ describe('MainContent', () => {
|
||||
bannerVisible: false,
|
||||
copyModeEnabled: false,
|
||||
terminalWidth: 100,
|
||||
btwState: {
|
||||
isActive: false,
|
||||
query: '',
|
||||
response: '',
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -386,6 +393,33 @@ describe('MainContent', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders BtwDisplay within ScrollableList in alternate buffer mode when btw is active', async () => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(true);
|
||||
const uiStateWithBtw = {
|
||||
...defaultMockUiState,
|
||||
btwState: {
|
||||
isActive: true,
|
||||
query: 'test query',
|
||||
response: 'test response',
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
const { lastFrame, unmount } = await renderWithProviders(<MainContent />, {
|
||||
uiState: uiStateWithBtw as Partial<UIState>,
|
||||
});
|
||||
const output = lastFrame();
|
||||
// Verify ScrollableList is rendered (from our mock)
|
||||
expect(output).toContain('ScrollableList');
|
||||
// Verify btw response is rendered
|
||||
expect(output).toContain('test query');
|
||||
expect(output).toContain('test response');
|
||||
|
||||
expect(output).toMatchSnapshot();
|
||||
// Verify it rendered inside ScrollableList's items in the mock
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders minimal header in minimal mode (alternate buffer)', async () => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(true);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AppHeader } from './AppHeader.js';
|
||||
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { BtwDisplay } from './BtwDisplay.js';
|
||||
import {
|
||||
SCROLL_TO_ITEM_END,
|
||||
type VirtualizedListRef,
|
||||
@@ -45,10 +46,10 @@ export const MainContent = () => {
|
||||
const scrollableListRef = useRef<VirtualizedListRef<unknown>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (showConfirmationQueue) {
|
||||
if (showConfirmationQueue || uiState.btwState.isActive) {
|
||||
scrollableListRef.current?.scrollToEnd();
|
||||
}
|
||||
}, [showConfirmationQueue, confirmingToolCallId]);
|
||||
}, [showConfirmationQueue, confirmingToolCallId, uiState.btwState.isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -202,19 +203,67 @@ export const MainContent = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const virtualizedData = useMemo(
|
||||
() => [
|
||||
{ type: 'header' as const },
|
||||
...augmentedHistory.map((data, index) => ({
|
||||
type: 'history' as const,
|
||||
item: data.item,
|
||||
element: historyItems[index],
|
||||
})),
|
||||
{ type: 'pending' as const },
|
||||
const btwDisplayNode = useMemo(
|
||||
() =>
|
||||
uiState.btwState.isActive ? (
|
||||
<BtwDisplay
|
||||
key="btw-display"
|
||||
query={uiState.btwState.query}
|
||||
response={uiState.btwState.response}
|
||||
isStreaming={uiState.btwState.isStreaming}
|
||||
error={uiState.btwState.error}
|
||||
terminalWidth={uiState.terminalWidth}
|
||||
/>
|
||||
) : null,
|
||||
[
|
||||
uiState.btwState.isActive,
|
||||
uiState.btwState.query,
|
||||
uiState.btwState.response,
|
||||
uiState.btwState.isStreaming,
|
||||
uiState.btwState.error,
|
||||
uiState.terminalWidth,
|
||||
],
|
||||
[augmentedHistory, historyItems],
|
||||
);
|
||||
|
||||
const virtualizedData = useMemo(() => {
|
||||
const data: Array<
|
||||
| { type: 'header' }
|
||||
| { type: 'pending' }
|
||||
| { type: 'btw' }
|
||||
| {
|
||||
type: 'history';
|
||||
item: (typeof augmentedHistory)[0]['item'];
|
||||
isExpandable: boolean;
|
||||
isFirstThinking: boolean;
|
||||
isFirstAfterThinking: boolean;
|
||||
suppressNarration: boolean;
|
||||
}
|
||||
> = [
|
||||
{ type: 'header' as const },
|
||||
...augmentedHistory.map(
|
||||
({
|
||||
item,
|
||||
isExpandable,
|
||||
isFirstThinking,
|
||||
isFirstAfterThinking,
|
||||
suppressNarration,
|
||||
}) => ({
|
||||
type: 'history' as const,
|
||||
item,
|
||||
isExpandable,
|
||||
isFirstThinking,
|
||||
isFirstAfterThinking,
|
||||
suppressNarration,
|
||||
}),
|
||||
),
|
||||
{ type: 'pending' as const },
|
||||
];
|
||||
if (uiState.btwState.isActive) {
|
||||
data.push({ type: 'btw' as const });
|
||||
}
|
||||
return data;
|
||||
}, [augmentedHistory, uiState.btwState.isActive]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: (typeof virtualizedData)[number] }) => {
|
||||
if (item.type === 'header') {
|
||||
@@ -226,12 +275,41 @@ export const MainContent = () => {
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'history') {
|
||||
return item.element;
|
||||
return (
|
||||
<MemoizedHistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight || !item.isExpandable
|
||||
? staticAreaMaxItemHeight
|
||||
: undefined
|
||||
}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={item.item.id}
|
||||
item={item.item}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
isExpandable={item.isExpandable}
|
||||
isFirstThinking={item.isFirstThinking}
|
||||
isFirstAfterThinking={item.isFirstAfterThinking}
|
||||
suppressNarration={item.suppressNarration}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'btw') {
|
||||
return <>{btwDisplayNode}</>;
|
||||
} else {
|
||||
return pendingItems;
|
||||
}
|
||||
},
|
||||
[showHeaderDetails, version, pendingItems],
|
||||
[
|
||||
showHeaderDetails,
|
||||
version,
|
||||
mainAreaWidth,
|
||||
uiState.slashCommands,
|
||||
pendingItems,
|
||||
uiState.constrainHeight,
|
||||
staticAreaMaxItemHeight,
|
||||
btwDisplayNode,
|
||||
],
|
||||
);
|
||||
|
||||
const estimatedItemHeight = useCallback(() => 100, []);
|
||||
@@ -240,6 +318,7 @@ export const MainContent = () => {
|
||||
(item: (typeof virtualizedData)[number], _index: number) => {
|
||||
if (item.type === 'header') return 'header';
|
||||
if (item.type === 'history') return item.item.id.toString();
|
||||
if (item.type === 'btw') return 'btw';
|
||||
return 'pending';
|
||||
},
|
||||
[],
|
||||
@@ -318,6 +397,10 @@ export const MainContent = () => {
|
||||
{(item) => item}
|
||||
</Static>
|
||||
{pendingItems}
|
||||
{/* In alternate buffer mode, the btwDisplayNode is rendered within the ScrollableList
|
||||
(via virtualizedData) to ensure proper scrolling. In standard mode, it is rendered
|
||||
here at the bottom of the layout. */}
|
||||
{btwDisplayNode}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -92,6 +92,23 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`MainContent > renders BtwDisplay within ScrollableList in alternate buffer mode when btw is active 1`] = `
|
||||
"ScrollableList
|
||||
AppHeader(full)
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Hello
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
✦ Hi there
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ BY THE WAY Press Esc, Enter or Space to dismiss │
|
||||
│ │
|
||||
│ Q: test query │
|
||||
│ │
|
||||
│ test response │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`MainContent > renders a ToolConfirmationQueue without an extra line when preceded by hidden tools 1`] = `
|
||||
"AppHeader(full)
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface UIActions {
|
||||
dismissBackgroundTask: (pid: number) => Promise<void>;
|
||||
setActiveBackgroundTaskPid: (pid: number) => void;
|
||||
setIsBackgroundTaskListOpen: (isOpen: boolean) => void;
|
||||
dismissBtw: () => void;
|
||||
setAuthContext: (context: { requiresRestart?: boolean }) => void;
|
||||
onHintInput: (char: string) => void;
|
||||
onHintBackspace: () => void;
|
||||
|
||||
@@ -204,6 +204,13 @@ export interface UIState {
|
||||
showIsExpandableHint: boolean;
|
||||
hintMode: boolean;
|
||||
hintBuffer: string;
|
||||
btwState: {
|
||||
isActive: boolean;
|
||||
query: string;
|
||||
response: string;
|
||||
isStreaming: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
transientMessage: {
|
||||
text: string;
|
||||
type: TransientMessageType;
|
||||
|
||||
@@ -644,6 +644,31 @@ describe('useSlashCommandProcessor', () => {
|
||||
|
||||
expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
|
||||
});
|
||||
|
||||
it('should handle a "btw" action', async () => {
|
||||
const btwAction = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ type: 'btw', query: 'some query' });
|
||||
const command = createTestCommand({
|
||||
name: 'side_question',
|
||||
action: btwAction,
|
||||
});
|
||||
const result = await setupProcessorHook({
|
||||
builtinCommands: [command],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||
|
||||
let processedResult;
|
||||
await act(async () => {
|
||||
processedResult = await result.current.handleSlashCommand(
|
||||
'/side_question some args',
|
||||
);
|
||||
});
|
||||
|
||||
expect(processedResult).toEqual({ type: 'btw', query: 'some query' });
|
||||
});
|
||||
|
||||
it('should handle "submit_prompt" action returned from a file-based command', async () => {
|
||||
const fileCommand = createTestCommand(
|
||||
{
|
||||
|
||||
@@ -665,6 +665,9 @@ export const useSlashCommandProcessor = (
|
||||
setCustomDialog(result.component);
|
||||
return { type: 'handled' };
|
||||
}
|
||||
case 'btw': {
|
||||
return result;
|
||||
}
|
||||
default: {
|
||||
const unhandled: never = result;
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { useBtw } from './useBtw.js';
|
||||
import { type GeminiClient, GeminiEventType } from '@google/gemini-cli-core';
|
||||
|
||||
describe('useBtw', () => {
|
||||
let mockGeminiClient: {
|
||||
sendBtwStream: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockGeminiClient = {
|
||||
sendBtwStream: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should initialize with inactive state', async () => {
|
||||
const { result } = await renderHook(() =>
|
||||
useBtw(mockGeminiClient as unknown as GeminiClient),
|
||||
);
|
||||
expect(result.current.isActive).toBe(false);
|
||||
expect(result.current.query).toBe('');
|
||||
expect(result.current.response).toBe('');
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it('should update state during streaming', async () => {
|
||||
let resolveStream: (value: void) => void;
|
||||
const streamGate = new Promise<void>((resolve) => {
|
||||
resolveStream = resolve;
|
||||
});
|
||||
|
||||
const mockStream = (async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'Hello' };
|
||||
await streamGate;
|
||||
yield { type: GeminiEventType.Content, value: ' world' };
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
})();
|
||||
mockGeminiClient.sendBtwStream.mockReturnValue(mockStream);
|
||||
|
||||
const { result } = await renderHook(() =>
|
||||
useBtw(mockGeminiClient as unknown as GeminiClient),
|
||||
);
|
||||
|
||||
let submitPromise!: Promise<void>;
|
||||
await act(async () => {
|
||||
submitPromise = result.current.submitBtw('test query');
|
||||
});
|
||||
|
||||
// Check immediate state (it should be streaming because of streamGate)
|
||||
expect(result.current.isActive).toBe(true);
|
||||
expect(result.current.query).toBe('test query');
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.response).toBe('Hello');
|
||||
|
||||
await act(async () => {
|
||||
resolveStream();
|
||||
await submitPromise;
|
||||
});
|
||||
|
||||
// Check final state
|
||||
expect(result.current.response).toBe('Hello world');
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
const mockStream = (async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.Error,
|
||||
value: { error: { message: 'API Error' } },
|
||||
};
|
||||
})();
|
||||
mockGeminiClient.sendBtwStream.mockReturnValue(mockStream);
|
||||
|
||||
const { result } = await renderHook(() =>
|
||||
useBtw(mockGeminiClient as unknown as GeminiClient),
|
||||
);
|
||||
|
||||
let submitPromise!: Promise<void>;
|
||||
await act(async () => {
|
||||
submitPromise = result.current.submitBtw('test query');
|
||||
await submitPromise;
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('API Error');
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle string errors', async () => {
|
||||
const mockStream = (async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.Error,
|
||||
value: { error: 'Direct string error' },
|
||||
};
|
||||
})();
|
||||
mockGeminiClient.sendBtwStream.mockReturnValue(mockStream);
|
||||
|
||||
const { result } = await renderHook(() =>
|
||||
useBtw(mockGeminiClient as unknown as GeminiClient),
|
||||
);
|
||||
|
||||
let submitPromise!: Promise<void>;
|
||||
await act(async () => {
|
||||
submitPromise = result.current.submitBtw('test query');
|
||||
await submitPromise;
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Direct string error');
|
||||
});
|
||||
|
||||
it('should handle whitespace errors by falling back to Unknown error', async () => {
|
||||
const mockStream = (async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.Error,
|
||||
value: { error: { message: ' ' } },
|
||||
};
|
||||
})();
|
||||
mockGeminiClient.sendBtwStream.mockReturnValue(mockStream);
|
||||
|
||||
const { result } = await renderHook(() =>
|
||||
useBtw(mockGeminiClient as unknown as GeminiClient),
|
||||
);
|
||||
|
||||
let submitPromise!: Promise<void>;
|
||||
await act(async () => {
|
||||
submitPromise = result.current.submitBtw('test query');
|
||||
await submitPromise;
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Unknown error');
|
||||
});
|
||||
|
||||
it('should handle unknown error shapes', async () => {
|
||||
const mockStream = (async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.Error,
|
||||
value: 'Just some raw string value',
|
||||
};
|
||||
})();
|
||||
mockGeminiClient.sendBtwStream.mockReturnValue(mockStream);
|
||||
|
||||
const { result } = await renderHook(() =>
|
||||
useBtw(mockGeminiClient as unknown as GeminiClient),
|
||||
);
|
||||
|
||||
let submitPromise!: Promise<void>;
|
||||
await act(async () => {
|
||||
submitPromise = result.current.submitBtw('test query');
|
||||
await submitPromise;
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Just some raw string value');
|
||||
});
|
||||
|
||||
it('should reset state on dismiss', async () => {
|
||||
let resolveStream: (value: void) => void;
|
||||
const streamGate = new Promise<void>((resolve) => {
|
||||
resolveStream = resolve;
|
||||
});
|
||||
|
||||
const mockStream = (async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'partial' };
|
||||
// Hang
|
||||
await streamGate;
|
||||
})();
|
||||
mockGeminiClient.sendBtwStream.mockReturnValue(mockStream);
|
||||
|
||||
const { result } = await renderHook(() =>
|
||||
useBtw(mockGeminiClient as unknown as GeminiClient),
|
||||
);
|
||||
|
||||
let submitPromise!: Promise<void>;
|
||||
await act(async () => {
|
||||
submitPromise = result.current.submitBtw('test query');
|
||||
});
|
||||
|
||||
expect(result.current.isActive).toBe(true);
|
||||
expect(result.current.query).toBe('test query');
|
||||
|
||||
await act(async () => {
|
||||
result.current.dismissBtw();
|
||||
resolveStream();
|
||||
// wait for the catch/finally blocks inside submitBtw to finish
|
||||
try {
|
||||
await submitPromise;
|
||||
} catch {
|
||||
// ignore AbortError
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.current.isActive).toBe(false);
|
||||
expect(result.current.query).toBe('');
|
||||
expect(result.current.response).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useEffect, useReducer } from 'react';
|
||||
import { type GeminiClient, GeminiEventType } from '@google/gemini-cli-core';
|
||||
|
||||
export interface UseBtwReturn {
|
||||
isActive: boolean;
|
||||
query: string;
|
||||
response: string;
|
||||
isStreaming: boolean;
|
||||
error: string | null;
|
||||
submitBtw: (query: string) => Promise<void>;
|
||||
dismissBtw: () => void;
|
||||
}
|
||||
|
||||
interface BtwState {
|
||||
isActive: boolean;
|
||||
query: string;
|
||||
response: string;
|
||||
isStreaming: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type BtwAction =
|
||||
| { type: 'SUBMIT'; query: string }
|
||||
| { type: 'SET_RESPONSE'; content: string }
|
||||
| { type: 'ERROR'; error: string }
|
||||
| { type: 'FINISHED' }
|
||||
| { type: 'DISMISS' };
|
||||
|
||||
const initialState: BtwState = {
|
||||
isActive: false,
|
||||
query: '',
|
||||
response: '',
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const btwReducer = (state: BtwState, action: BtwAction): BtwState => {
|
||||
switch (action.type) {
|
||||
case 'SUBMIT':
|
||||
return {
|
||||
...state,
|
||||
isActive: true,
|
||||
query: action.query,
|
||||
response: '',
|
||||
isStreaming: true,
|
||||
error: null,
|
||||
};
|
||||
case 'SET_RESPONSE':
|
||||
return {
|
||||
...state,
|
||||
response: action.content,
|
||||
};
|
||||
case 'ERROR':
|
||||
return {
|
||||
...state,
|
||||
error: action.error,
|
||||
isStreaming: false,
|
||||
};
|
||||
case 'FINISHED':
|
||||
return {
|
||||
...state,
|
||||
isStreaming: false,
|
||||
};
|
||||
case 'DISMISS':
|
||||
return initialState;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const useBtw = (
|
||||
geminiClient: GeminiClient | undefined,
|
||||
): UseBtwReturn => {
|
||||
const [state, dispatch] = useReducer(btwReducer, initialState);
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const requestIdRef = useRef<number>(0);
|
||||
const flushTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const dismissBtw = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
requestIdRef.current++;
|
||||
dispatch({ type: 'DISMISS' });
|
||||
}, []);
|
||||
|
||||
const submitBtw = useCallback(
|
||||
async (newQuery: string) => {
|
||||
if (!geminiClient) return;
|
||||
|
||||
// Abort any ongoing BTW stream
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
const requestId = ++requestIdRef.current;
|
||||
|
||||
dispatch({ type: 'SUBMIT', query: newQuery });
|
||||
|
||||
let accumulatedResponse = '';
|
||||
let lastDispatchTime = 0;
|
||||
|
||||
const flushResponse = () => {
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
dispatch({ type: 'SET_RESPONSE', content: accumulatedResponse });
|
||||
lastDispatchTime = Date.now();
|
||||
};
|
||||
|
||||
try {
|
||||
const stream = geminiClient.sendBtwStream(
|
||||
[{ text: newQuery }],
|
||||
abortController.signal,
|
||||
`btw-${Date.now()}`,
|
||||
);
|
||||
|
||||
for await (const event of stream) {
|
||||
if (abortController.signal.aborted) break;
|
||||
|
||||
switch (event.type) {
|
||||
case GeminiEventType.Content: {
|
||||
accumulatedResponse += event.value ?? '';
|
||||
const now = Date.now();
|
||||
if (!flushTimerRef.current) {
|
||||
const timeSinceLastDispatch = now - lastDispatchTime;
|
||||
if (timeSinceLastDispatch >= 50) {
|
||||
flushResponse();
|
||||
} else {
|
||||
flushTimerRef.current = setTimeout(() => {
|
||||
flushResponse();
|
||||
flushTimerRef.current = null;
|
||||
}, 50 - timeSinceLastDispatch);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GeminiEventType.Error: {
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
flushResponse();
|
||||
|
||||
const value = event.value;
|
||||
let errorMessage = 'Unknown error';
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'error' in value
|
||||
) {
|
||||
const errorObj = value.error;
|
||||
const extracted =
|
||||
typeof errorObj === 'object' &&
|
||||
errorObj !== null &&
|
||||
'message' in errorObj
|
||||
? String(errorObj.message)
|
||||
: String(errorObj);
|
||||
errorMessage = extracted.trim() || 'Unknown error';
|
||||
} else {
|
||||
errorMessage = String(value).trim() || 'Unknown error';
|
||||
}
|
||||
dispatch({
|
||||
type: 'ERROR',
|
||||
error: errorMessage,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case GeminiEventType.Finished:
|
||||
case GeminiEventType.UserCancelled:
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
flushResponse();
|
||||
dispatch({ type: 'FINISHED' });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
flushResponse();
|
||||
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
// Ignore aborts
|
||||
} else if (requestIdRef.current === requestId) {
|
||||
dispatch({
|
||||
type: 'ERROR',
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
flushResponse();
|
||||
|
||||
if (requestIdRef.current === requestId) {
|
||||
dispatch({ type: 'FINISHED' });
|
||||
}
|
||||
}
|
||||
},
|
||||
[geminiClient],
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
submitBtw,
|
||||
dismissBtw,
|
||||
};
|
||||
};
|
||||
@@ -969,6 +969,9 @@ export const useGeminiStream = (
|
||||
case 'handled': {
|
||||
return { queryToSend: null, shouldProceed: false };
|
||||
}
|
||||
case 'btw': {
|
||||
return { queryToSend: null, shouldProceed: false };
|
||||
}
|
||||
default: {
|
||||
const unreachable: never = slashCommandResult;
|
||||
throw new Error(
|
||||
|
||||
@@ -38,6 +38,13 @@ const mockUIState = {
|
||||
availableTerminalHeight: 20,
|
||||
activePtyId: null,
|
||||
isBackgroundTaskVisible: true,
|
||||
btwState: {
|
||||
isActive: false,
|
||||
query: '',
|
||||
response: '',
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
},
|
||||
} as unknown as UIState;
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||
|
||||
@@ -517,6 +517,11 @@ export interface SubmitPromptResult {
|
||||
content: PartListUnion;
|
||||
}
|
||||
|
||||
export interface BtwActionReturn {
|
||||
type: 'btw';
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the result of the slash command processor for its consumer (useGeminiStream).
|
||||
*/
|
||||
@@ -530,7 +535,8 @@ export type SlashCommandProcessorResult =
|
||||
| {
|
||||
type: 'handled'; // Indicates the command was processed and no further action is needed.
|
||||
}
|
||||
| SubmitPromptResult;
|
||||
| SubmitPromptResult
|
||||
| BtwActionReturn;
|
||||
|
||||
export interface ConfirmationRequest {
|
||||
prompt: ReactNode;
|
||||
|
||||
@@ -3394,6 +3394,21 @@ describe('Config Quota & Preview Model Access', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBtwEnabled', () => {
|
||||
it('should return false for isBtwEnabled by default', () => {
|
||||
const config = new Config(baseParams);
|
||||
expect(config.isBtwEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for isBtwEnabled when experimentalBtw is true', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
experimentalBtw: true,
|
||||
});
|
||||
expect(config.isBtwEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlanModeRoutingEnabled', () => {
|
||||
it('should default to true when not provided', async () => {
|
||||
const config = new Config(baseParams);
|
||||
|
||||
@@ -602,7 +602,6 @@ export interface ConfigParameters {
|
||||
toolDiscoveryCommand?: string;
|
||||
toolCallCommand?: string;
|
||||
mcpServerCommand?: string;
|
||||
shellToolRcFile?: string;
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
mcpEnablementCallbacks?: McpEnablementCallbacks;
|
||||
userMemory?: string | HierarchicalMemory;
|
||||
@@ -713,6 +712,7 @@ export interface ConfigParameters {
|
||||
experimentalAgentHistoryTruncationThreshold?: number;
|
||||
experimentalAgentHistoryRetainedMessages?: number;
|
||||
experimentalAgentHistorySummarization?: boolean;
|
||||
experimentalBtw?: boolean;
|
||||
memoryBoundaryMarkers?: string[];
|
||||
topicUpdateNarration?: boolean;
|
||||
|
||||
@@ -780,7 +780,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly toolDiscoveryCommand: string | undefined;
|
||||
private readonly toolCallCommand: string | undefined;
|
||||
private readonly mcpServerCommand: string | undefined;
|
||||
private readonly shellToolRcFile: string | undefined;
|
||||
private readonly mcpEnabled: boolean;
|
||||
private readonly extensionsEnabled: boolean;
|
||||
private mcpServers: Record<string, MCPServerConfig> | undefined;
|
||||
@@ -955,6 +954,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly experimentalMemoryManager: boolean;
|
||||
private readonly experimentalAutoMemory: boolean;
|
||||
private readonly experimentalContextManagementConfig?: string;
|
||||
private readonly experimentalBtw: boolean;
|
||||
private readonly memoryBoundaryMarkers: readonly string[];
|
||||
private readonly topicUpdateNarration: boolean;
|
||||
private readonly disableLLMCorrection: boolean;
|
||||
@@ -1049,7 +1049,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.toolDiscoveryCommand = params.toolDiscoveryCommand;
|
||||
this.toolCallCommand = params.toolCallCommand;
|
||||
this.mcpServerCommand = params.mcpServerCommand;
|
||||
this.shellToolRcFile = params.shellToolRcFile;
|
||||
this.mcpServers = params.mcpServers;
|
||||
this.mcpEnablementCallbacks = params.mcpEnablementCallbacks;
|
||||
this.mcpEnabled = params.mcpEnabled ?? true;
|
||||
@@ -1173,6 +1172,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.experimentalJitContext = params.experimentalJitContext ?? false;
|
||||
this.experimentalMemoryManager = params.experimentalMemoryManager ?? false;
|
||||
this.experimentalAutoMemory = params.experimentalAutoMemory ?? false;
|
||||
this.experimentalBtw = params.experimentalBtw ?? false;
|
||||
this.experimentalContextManagementConfig =
|
||||
params.experimentalContextManagementConfig;
|
||||
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
|
||||
@@ -2321,10 +2321,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.mcpServerCommand;
|
||||
}
|
||||
|
||||
getShellToolRcFile(): string | undefined {
|
||||
return this.shellToolRcFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user configured MCP servers (via gemini settings files).
|
||||
*
|
||||
@@ -2958,6 +2954,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.planEnabled;
|
||||
}
|
||||
|
||||
isBtwEnabled(): boolean {
|
||||
return this.experimentalBtw;
|
||||
}
|
||||
|
||||
isTrackerEnabled(): boolean {
|
||||
return this.trackerEnabled;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type ContentGenerator,
|
||||
type ContentGeneratorConfig,
|
||||
} from './contentGenerator.js';
|
||||
import { GeminiChat } from './geminiChat.js';
|
||||
import { GeminiChat, StreamEventType, type StreamEvent } from './geminiChat.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import {
|
||||
@@ -50,6 +50,7 @@ import type {
|
||||
import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js';
|
||||
import * as policyCatalog from '../availability/policyCatalog.js';
|
||||
import { LlmRole, LoopType } from '../telemetry/types.js';
|
||||
import { recordBtwUsageMetrics } from '../telemetry/index.js';
|
||||
import { partToString } from '../utils/partUtils.js';
|
||||
import { coreEvents, CoreEvent } from '../utils/events.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
@@ -134,6 +135,7 @@ vi.mock('../telemetry/index.js', () => ({
|
||||
logApiRequest: vi.fn(),
|
||||
logApiResponse: vi.fn(),
|
||||
logApiError: vi.fn(),
|
||||
recordBtwUsageMetrics: vi.fn(),
|
||||
}));
|
||||
vi.mock('../ide/ideContext.js');
|
||||
vi.mock('../telemetry/uiTelemetry.js', () => ({
|
||||
@@ -3298,6 +3300,48 @@ ${JSON.stringify(
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendBtwStream', () => {
|
||||
it('should record telemetry and call chat.sendBtwStream', async () => {
|
||||
// Arrange
|
||||
const request = [{ text: 'btw, how does this work?' }];
|
||||
const abortSignal = new AbortController().signal;
|
||||
const promptId = 'test-prompt-id';
|
||||
|
||||
const mockBtwStream = (async function* (): AsyncGenerator<
|
||||
StreamEvent,
|
||||
void,
|
||||
unknown
|
||||
> {
|
||||
yield {
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
text: () => 'this works fine',
|
||||
} as unknown as GenerateContentResponse,
|
||||
};
|
||||
})();
|
||||
|
||||
const sendBtwStreamSpy = vi
|
||||
.spyOn(client.getChat(), 'sendBtwStream')
|
||||
.mockReturnValue(mockBtwStream);
|
||||
|
||||
// Act
|
||||
const stream = client.sendBtwStream(request, abortSignal, promptId);
|
||||
for await (const _ of stream) {
|
||||
// Consume stream
|
||||
}
|
||||
|
||||
// Assert
|
||||
expect(recordBtwUsageMetrics).toHaveBeenCalledWith(mockConfig);
|
||||
expect(sendBtwStreamSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object), // modelConfigKey
|
||||
request,
|
||||
promptId,
|
||||
abortSignal,
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateContent', () => {
|
||||
it('should call generateContent with the correct parameters', async () => {
|
||||
const contents = [{ role: 'user', parts: [{ text: 'hello' }] }];
|
||||
|
||||
@@ -29,7 +29,7 @@ import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { getCoreSystemPrompt } from './prompts.js';
|
||||
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
|
||||
import { reportError } from '../utils/errorReporting.js';
|
||||
import { GeminiChat } from './geminiChat.js';
|
||||
import { GeminiChat, StreamEventType } from './geminiChat.js';
|
||||
import {
|
||||
retryWithBackoff,
|
||||
type RetryAvailabilityContext,
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
logContentRetryFailure,
|
||||
logNextSpeakerCheck,
|
||||
} from '../telemetry/loggers.js';
|
||||
import { recordBtwUsageMetrics } from '../telemetry/index.js';
|
||||
import type {
|
||||
DefaultHookOutput,
|
||||
AfterAgentHookOutput,
|
||||
@@ -57,7 +58,7 @@ import type {
|
||||
import {
|
||||
ContentRetryFailureEvent,
|
||||
NextSpeakerCheckEvent,
|
||||
type LlmRole,
|
||||
LlmRole,
|
||||
} from '../telemetry/types.js';
|
||||
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
||||
import type { IdeContext, File } from '../ide/types.js';
|
||||
@@ -72,7 +73,8 @@ import {
|
||||
createAvailabilityContextProvider,
|
||||
} from '../availability/policyHelpers.js';
|
||||
import { getDisplayString, resolveModel } from '../config/models.js';
|
||||
import { partToString } from '../utils/partUtils.js';
|
||||
import { getResponseText, partToString } from '../utils/partUtils.js';
|
||||
import { parseThought } from '../utils/thoughtUtils.js';
|
||||
import { coreEvents, CoreEvent } from '../utils/events.js';
|
||||
|
||||
const MAX_TURNS = 100;
|
||||
@@ -1229,6 +1231,83 @@ export class GeminiClient {
|
||||
return info;
|
||||
}
|
||||
|
||||
async *sendBtwStream(
|
||||
request: PartListUnion,
|
||||
signal: AbortSignal,
|
||||
prompt_id: string,
|
||||
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
|
||||
const turn = new Turn(this.getChat(), prompt_id);
|
||||
|
||||
// Availability/Routing logic simplified for BTW
|
||||
const modelToUse = this._getActiveModelForCurrentTurn();
|
||||
const modelConfigKey: ModelConfigKey = {
|
||||
model: modelToUse,
|
||||
isChatModel: true,
|
||||
};
|
||||
|
||||
yield { type: GeminiEventType.ModelInfo, value: modelToUse };
|
||||
|
||||
recordBtwUsageMetrics(this.config);
|
||||
|
||||
// Use a custom role for BTW to avoid side-effects in telemetry if needed,
|
||||
// but for now LlmRole.MAIN is fine as it's the primary model talking.
|
||||
const btwStream = this.getChat().sendBtwStream(
|
||||
modelConfigKey,
|
||||
request,
|
||||
prompt_id,
|
||||
signal,
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
for await (const streamEvent of btwStream) {
|
||||
if (signal?.aborted) {
|
||||
yield { type: GeminiEventType.UserCancelled };
|
||||
return turn;
|
||||
}
|
||||
|
||||
if (streamEvent.type === 'retry') {
|
||||
yield { type: GeminiEventType.Retry };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (streamEvent.type === StreamEventType.CHUNK) {
|
||||
const resp = streamEvent.value;
|
||||
if (!resp) continue;
|
||||
|
||||
const traceId = resp.responseId;
|
||||
const parts = resp.candidates?.[0]?.content?.parts ?? [];
|
||||
for (const part of parts) {
|
||||
if (part.thought) {
|
||||
const thought = parseThought(part.text ?? '');
|
||||
yield {
|
||||
type: GeminiEventType.Thought,
|
||||
value: thought,
|
||||
traceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const text = getResponseText(resp);
|
||||
if (text) {
|
||||
yield { type: GeminiEventType.Content, value: text, traceId };
|
||||
}
|
||||
|
||||
const finishReason = resp.candidates?.[0]?.finishReason;
|
||||
if (finishReason) {
|
||||
yield {
|
||||
type: GeminiEventType.Finished,
|
||||
value: {
|
||||
reason: finishReason,
|
||||
usageMetadata: resp.usageMetadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return turn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks bulky tool outputs to save context window space.
|
||||
*/
|
||||
|
||||
@@ -2626,4 +2626,138 @@ describe('GeminiChat', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendBtwStream', () => {
|
||||
it('should reuse history but not update it', async () => {
|
||||
// 1. Setup initial history
|
||||
const initialHistory: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Main question' }] },
|
||||
{ role: 'model', parts: [{ text: 'Main answer' }] },
|
||||
];
|
||||
chat.setHistory(initialHistory);
|
||||
|
||||
// 2. Mock API response for BTW
|
||||
const btwResponse = (async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'Side answer' }], role: 'model' },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})();
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
btwResponse,
|
||||
);
|
||||
|
||||
// 3. Call sendBtwStream
|
||||
const stream = chat.sendBtwStream(
|
||||
{ model: 'test-model' },
|
||||
'Side question',
|
||||
'btw-prompt-id',
|
||||
new AbortController().signal,
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
/* consume */
|
||||
}
|
||||
|
||||
// 4. Verify API was called with current history + side question
|
||||
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contents: [
|
||||
...initialHistory,
|
||||
{ role: 'user', parts: [{ text: 'Side question' }] },
|
||||
],
|
||||
config: expect.objectContaining({
|
||||
tools: [], // Should be empty for BTW
|
||||
}),
|
||||
}),
|
||||
'btw-prompt-id',
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
// 5. CRITICAL: Verify persistent history was NOT updated
|
||||
const persistentHistory = chat.getHistory();
|
||||
expect(persistentHistory.length).toBe(2);
|
||||
expect(persistentHistory).toEqual(initialHistory);
|
||||
});
|
||||
|
||||
it('should not block or be blocked by a main sendMessageStream call if called concurrently', async () => {
|
||||
// This is a simplified test for concurrency logic within GeminiChat
|
||||
// Since sendBtwStream does not await this.sendPromise (unlike sendMessageStream),
|
||||
// it should be able to start even if a sendMessageStream call is in progress.
|
||||
|
||||
let resolveMain: (value: unknown) => void;
|
||||
const mainPromise = new Promise((resolve) => {
|
||||
resolveMain = resolve;
|
||||
});
|
||||
|
||||
// Mock main stream to hang
|
||||
vi.mocked(mockContentGenerator.generateContentStream)
|
||||
.mockImplementationOnce(async () =>
|
||||
(async function* () {
|
||||
await mainPromise;
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'Main done' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as GenerateContentResponse;
|
||||
})(),
|
||||
)
|
||||
.mockImplementationOnce(async () =>
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'BTW done' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as GenerateContentResponse;
|
||||
})(),
|
||||
);
|
||||
|
||||
// Start main stream (will hang during iteration)
|
||||
const mainStreamGen = await chat.sendMessageStream(
|
||||
{ model: 'test-model' },
|
||||
'Main prompt',
|
||||
'main-id',
|
||||
new AbortController().signal,
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
const mainStreamNextPromise = mainStreamGen.next();
|
||||
|
||||
// Attempt BTW stream immediately - it should NOT block on mainPromise
|
||||
const btwStream = chat.sendBtwStream(
|
||||
{ model: 'test-model' },
|
||||
'BTW prompt',
|
||||
'btw-id',
|
||||
new AbortController().signal,
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
const btwEvents = [];
|
||||
for await (const event of btwStream) {
|
||||
btwEvents.push(event);
|
||||
}
|
||||
|
||||
// Assert BTW finished while Main is still hanging
|
||||
expect(btwEvents.length).toBeGreaterThan(0);
|
||||
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
|
||||
// Clean up
|
||||
resolveMain!(null);
|
||||
await mainStreamNextPromise; // Finish the one we started
|
||||
while (!(await mainStreamGen.next()).done) {
|
||||
// drain rest
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -489,12 +489,71 @@ export class GeminiChat {
|
||||
return streamWithRetries.call(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a side inquiry (BTW) that reuses current context but doesn't affect history.
|
||||
*/
|
||||
sendBtwStream(
|
||||
modelConfigKey: ModelConfigKey,
|
||||
message: PartListUnion,
|
||||
prompt_id: string,
|
||||
signal: AbortSignal,
|
||||
role: LlmRole,
|
||||
): AsyncGenerator<StreamEvent> {
|
||||
const requestContents = [
|
||||
...this.getHistory(true),
|
||||
createUserContent(message),
|
||||
];
|
||||
|
||||
const streamWithRetries = async function* (
|
||||
this: GeminiChat,
|
||||
): AsyncGenerator<StreamEvent, void, void> {
|
||||
try {
|
||||
const stream = await this.makeApiCallAndProcessStream(
|
||||
modelConfigKey,
|
||||
requestContents,
|
||||
prompt_id,
|
||||
signal,
|
||||
role,
|
||||
[],
|
||||
true, // isBtw flag
|
||||
);
|
||||
for await (const chunk of stream) {
|
||||
yield { type: StreamEventType.CHUNK, value: chunk };
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AgentExecutionStoppedError) {
|
||||
yield {
|
||||
type: StreamEventType.AGENT_EXECUTION_STOPPED,
|
||||
reason: error.reason,
|
||||
};
|
||||
} else if (error instanceof AgentExecutionBlockedError) {
|
||||
yield {
|
||||
type: StreamEventType.AGENT_EXECUTION_BLOCKED,
|
||||
reason: error.reason,
|
||||
};
|
||||
if (error.syntheticResponse) {
|
||||
yield {
|
||||
type: StreamEventType.CHUNK,
|
||||
value: error.syntheticResponse,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return streamWithRetries.call(this);
|
||||
}
|
||||
|
||||
private async makeApiCallAndProcessStream(
|
||||
modelConfigKey: ModelConfigKey,
|
||||
requestContents: readonly Content[],
|
||||
prompt_id: string,
|
||||
abortSignal: AbortSignal,
|
||||
role: LlmRole,
|
||||
toolsOverride?: Tool[],
|
||||
isBtw?: boolean,
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
const contentsForPreviewModel =
|
||||
this.ensureActiveLoopHasThoughtSignatures(requestContents);
|
||||
@@ -565,7 +624,7 @@ export class GeminiChat {
|
||||
// TODO(12622): Ensure we don't overrwrite these when they are
|
||||
// passed via config.
|
||||
systemInstruction: this.systemInstruction,
|
||||
tools: this.tools,
|
||||
tools: toolsOverride !== undefined ? toolsOverride : this.tools,
|
||||
abortSignal,
|
||||
};
|
||||
|
||||
@@ -575,11 +634,14 @@ export class GeminiChat {
|
||||
|
||||
const hookSystem = this.context.config.getHookSystem();
|
||||
if (hookSystem) {
|
||||
const beforeModelResult = await hookSystem.fireBeforeModelEvent({
|
||||
model: modelToUse,
|
||||
config,
|
||||
contents: contentsToUse,
|
||||
});
|
||||
const beforeModelResult = await hookSystem.fireBeforeModelEvent(
|
||||
{
|
||||
model: modelToUse,
|
||||
config,
|
||||
contents: contentsToUse,
|
||||
},
|
||||
isBtw,
|
||||
);
|
||||
|
||||
if (beforeModelResult.stopped) {
|
||||
throw new AgentExecutionStoppedError(
|
||||
@@ -647,7 +709,7 @@ export class GeminiChat {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.onModelChanged) {
|
||||
if (this.onModelChanged && !isBtw) {
|
||||
this.tools = await this.onModelChanged(modelToUse);
|
||||
}
|
||||
|
||||
@@ -719,6 +781,7 @@ export class GeminiChat {
|
||||
lastModelToUse,
|
||||
streamResponse,
|
||||
originalRequest,
|
||||
isBtw,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -878,6 +941,7 @@ export class GeminiChat {
|
||||
model: string,
|
||||
streamResponse: AsyncGenerator<GenerateContentResponse>,
|
||||
originalRequest: GenerateContentParameters,
|
||||
isBtw?: boolean,
|
||||
): AsyncGenerator<GenerateContentResponse> {
|
||||
const modelResponseParts: Part[] = [];
|
||||
|
||||
@@ -900,7 +964,9 @@ export class GeminiChat {
|
||||
if (content.parts.some((part) => part.thought)) {
|
||||
// Record thoughts
|
||||
hasThoughts = true;
|
||||
this.recordThoughtFromContent(content);
|
||||
if (!isBtw) {
|
||||
this.recordThoughtFromContent(content);
|
||||
}
|
||||
}
|
||||
if (content.parts.some((part) => part.functionCall)) {
|
||||
hasToolCall = true;
|
||||
@@ -913,7 +979,7 @@ export class GeminiChat {
|
||||
}
|
||||
|
||||
// Record token usage if this chunk has usageMetadata
|
||||
if (chunk.usageMetadata) {
|
||||
if (chunk.usageMetadata && !isBtw) {
|
||||
this.chatRecordingService.recordMessageTokens(chunk.usageMetadata);
|
||||
if (chunk.usageMetadata.promptTokenCount !== undefined) {
|
||||
this.lastPromptTokenCount = chunk.usageMetadata.promptTokenCount;
|
||||
@@ -925,6 +991,7 @@ export class GeminiChat {
|
||||
const hookResult = await hookSystem.fireAfterModelEvent(
|
||||
originalRequest,
|
||||
chunk,
|
||||
isBtw,
|
||||
);
|
||||
|
||||
if (hookResult.stopped) {
|
||||
@@ -970,7 +1037,7 @@ export class GeminiChat {
|
||||
// Record model response text from the collected parts.
|
||||
// Also flush when there are thoughts or a tool call (even with no text)
|
||||
// so that BeforeTool hooks always see the latest transcript state.
|
||||
if (responseText || hasThoughts || hasToolCall) {
|
||||
if (!isBtw && (responseText || hasThoughts || hasToolCall)) {
|
||||
this.chatRecordingService.recordMessage({
|
||||
model,
|
||||
type: 'gemini',
|
||||
@@ -1013,7 +1080,9 @@ export class GeminiChat {
|
||||
}
|
||||
}
|
||||
|
||||
this.history.push({ role: 'model', parts: consolidatedParts });
|
||||
if (!isBtw) {
|
||||
this.history.push({ role: 'model', parts: consolidatedParts });
|
||||
}
|
||||
}
|
||||
|
||||
getLastPromptTokenCount(): number {
|
||||
|
||||
@@ -220,10 +220,12 @@ export class HookEventHandler {
|
||||
*/
|
||||
async fireBeforeModelEvent(
|
||||
llmRequest: GenerateContentParameters,
|
||||
isBtw?: boolean,
|
||||
): Promise<AggregatedHookResult> {
|
||||
const input: BeforeModelInput = {
|
||||
...this.createBaseInput(HookEventName.BeforeModel),
|
||||
llm_request: defaultHookTranslator.toHookLLMRequest(llmRequest),
|
||||
isBtw,
|
||||
};
|
||||
|
||||
return this.executeHooks(
|
||||
@@ -241,11 +243,13 @@ export class HookEventHandler {
|
||||
async fireAfterModelEvent(
|
||||
llmRequest: GenerateContentParameters,
|
||||
llmResponse: GenerateContentResponse,
|
||||
isBtw?: boolean,
|
||||
): Promise<AggregatedHookResult> {
|
||||
const input: AfterModelInput = {
|
||||
...this.createBaseInput(HookEventName.AfterModel),
|
||||
llm_request: defaultHookTranslator.toHookLLMRequest(llmRequest),
|
||||
llm_response: defaultHookTranslator.toHookLLMResponse(llmResponse),
|
||||
isBtw,
|
||||
};
|
||||
|
||||
return this.executeHooks(
|
||||
|
||||
@@ -260,10 +260,13 @@ export class HookSystem {
|
||||
|
||||
async fireBeforeModelEvent(
|
||||
llmRequest: GenerateContentParameters,
|
||||
isBtw?: boolean,
|
||||
): Promise<BeforeModelHookResult> {
|
||||
try {
|
||||
const result =
|
||||
await this.hookEventHandler.fireBeforeModelEvent(llmRequest);
|
||||
const result = await this.hookEventHandler.fireBeforeModelEvent(
|
||||
llmRequest,
|
||||
isBtw,
|
||||
);
|
||||
const hookOutput = result.finalOutput;
|
||||
|
||||
if (hookOutput?.shouldStopExecution()) {
|
||||
@@ -310,11 +313,13 @@ export class HookSystem {
|
||||
async fireAfterModelEvent(
|
||||
originalRequest: GenerateContentParameters,
|
||||
chunk: GenerateContentResponse,
|
||||
isBtw?: boolean,
|
||||
): Promise<AfterModelHookResult> {
|
||||
try {
|
||||
const result = await this.hookEventHandler.fireAfterModelEvent(
|
||||
originalRequest,
|
||||
chunk,
|
||||
isBtw,
|
||||
);
|
||||
const hookOutput = result.finalOutput;
|
||||
|
||||
|
||||
@@ -673,6 +673,7 @@ export interface PreCompressOutput {
|
||||
*/
|
||||
export interface BeforeModelInput extends HookInput {
|
||||
llm_request: LLMRequest;
|
||||
isBtw?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -692,6 +693,7 @@ export interface BeforeModelOutput extends HookOutput {
|
||||
export interface AfterModelInput extends HookInput {
|
||||
llm_request: LLMRequest;
|
||||
llm_response: LLMResponse;
|
||||
isBtw?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -135,6 +135,8 @@ export {
|
||||
recordGenAiClientTokenUsage,
|
||||
recordGenAiClientOperationDuration,
|
||||
getConventionAttributes,
|
||||
// Btw Metrics
|
||||
recordBtwUsageMetrics,
|
||||
// Performance monitoring functions
|
||||
recordStartupPerformance,
|
||||
recordMemoryUsage,
|
||||
|
||||
@@ -104,6 +104,7 @@ describe('Telemetry Metrics', () => {
|
||||
let recordLinesChangedModule: typeof import('./metrics.js').recordLinesChanged;
|
||||
let recordSlowRenderModule: typeof import('./metrics.js').recordSlowRender;
|
||||
let recordPlanExecutionModule: typeof import('./metrics.js').recordPlanExecution;
|
||||
let recordBtwUsageMetricsModule: typeof import('./metrics.js').recordBtwUsageMetrics;
|
||||
let recordKeychainAvailabilityModule: typeof import('./metrics.js').recordKeychainAvailability;
|
||||
let recordTokenStorageInitializationModule: typeof import('./metrics.js').recordTokenStorageInitialization;
|
||||
let recordInvalidChunkModule: typeof import('./metrics.js').recordInvalidChunk;
|
||||
@@ -158,6 +159,7 @@ describe('Telemetry Metrics', () => {
|
||||
recordLinesChangedModule = metricsJsModule.recordLinesChanged;
|
||||
recordSlowRenderModule = metricsJsModule.recordSlowRender;
|
||||
recordPlanExecutionModule = metricsJsModule.recordPlanExecution;
|
||||
recordBtwUsageMetricsModule = metricsJsModule.recordBtwUsageMetrics;
|
||||
recordKeychainAvailabilityModule =
|
||||
metricsJsModule.recordKeychainAvailability;
|
||||
recordTokenStorageInitializationModule =
|
||||
@@ -273,6 +275,28 @@ describe('Telemetry Metrics', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordBtwUsageMetrics', () => {
|
||||
it('does not record metrics if not initialized', () => {
|
||||
const config = makeFakeConfig({});
|
||||
recordBtwUsageMetricsModule(config);
|
||||
expect(mockCounterAddFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records a btw usage event when initialized', () => {
|
||||
const config = makeFakeConfig({});
|
||||
initializeMetricsModule(config);
|
||||
recordBtwUsageMetricsModule(config);
|
||||
|
||||
// Called for session, then for btw usage
|
||||
expect(mockCounterAddFn).toHaveBeenCalledTimes(2);
|
||||
expect(mockCounterAddFn).toHaveBeenNthCalledWith(2, 1, {
|
||||
'session.id': 'test-session-id',
|
||||
'installation.id': 'test-installation-id',
|
||||
'user.email': 'test@example.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeMetrics', () => {
|
||||
const mockConfig = {
|
||||
getSessionId: () => 'test-session-id',
|
||||
|
||||
@@ -102,6 +102,7 @@ const FLICKER_FRAME_COUNT = 'gemini_cli.ui.flicker.count';
|
||||
const SLOW_RENDER_LATENCY = 'gemini_cli.ui.slow_render.latency';
|
||||
const EXIT_FAIL_COUNT = 'gemini_cli.exit.fail.count';
|
||||
const PLAN_EXECUTION_COUNT = 'gemini_cli.plan.execution.count';
|
||||
const BTW_USAGE_COUNT = 'gemini_cli.btw.usage.count';
|
||||
|
||||
const baseMetricDefinition = {
|
||||
getCommonAttributes,
|
||||
@@ -270,6 +271,12 @@ const COUNTER_DEFINITIONS = {
|
||||
approval_mode: string;
|
||||
},
|
||||
},
|
||||
[BTW_USAGE_COUNT]: {
|
||||
description: 'Counts the usage of the /btw side inquiry command.',
|
||||
valueType: ValueType.INT,
|
||||
assign: (c: Counter) => (btwUsageCounter = c),
|
||||
attributes: {} as Record<string, never>,
|
||||
},
|
||||
[EVENT_HOOK_CALL_COUNT]: {
|
||||
description: 'Counts hook calls, tagged by hook event name and success.',
|
||||
valueType: ValueType.INT,
|
||||
@@ -789,6 +796,7 @@ let agentRecoveryAttemptDurationHistogram: Histogram | undefined;
|
||||
let flickerFrameCounter: Counter | undefined;
|
||||
let exitFailCounter: Counter | undefined;
|
||||
let planExecutionCounter: Counter | undefined;
|
||||
let btwUsageCounter: Counter | undefined;
|
||||
let slowRenderHistogram: Histogram | undefined;
|
||||
let hookCallCounter: Counter | undefined;
|
||||
let hookCallLatencyHistogram: Histogram | undefined;
|
||||
@@ -1047,6 +1055,14 @@ export function recordPlanExecution(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a metric for when the /btw side inquiry command is used
|
||||
*/
|
||||
export function recordBtwUsageMetrics(config: Config): void {
|
||||
if (!btwUsageCounter || !isMetricsInitialized) return;
|
||||
btwUsageCounter.add(1, baseMetricDefinition.getCommonAttributes(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a metric for when a UI frame is slow in rendering
|
||||
*/
|
||||
|
||||
@@ -164,8 +164,6 @@ describe('ShellTool', () => {
|
||||
addPersistentApproval: vi.fn(),
|
||||
addSessionApproval: vi.fn(),
|
||||
},
|
||||
getShellToolRcFile: vi.fn().mockReturnValue(undefined),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
const bus = createMockMessageBus();
|
||||
@@ -488,70 +486,6 @@ EOF`;
|
||||
expect(mockShellExecutionService.mock.calls[0][0]).toMatch(/\nEOF\n\)\n/);
|
||||
});
|
||||
|
||||
it('should source rcfile when shellToolRcFile setting is present and folder is trusted', async () => {
|
||||
const rcFilePath = '~/.geminirc';
|
||||
(mockConfig.getShellToolRcFile as Mock).mockReturnValue(rcFilePath);
|
||||
(mockConfig.isTrustedFolder as Mock).mockReturnValue(true);
|
||||
|
||||
const invocation = shellTool.build({ command: 'my-command' });
|
||||
const promise = invocation.execute({ abortSignal: mockAbortSignal });
|
||||
resolveShellExecution();
|
||||
await promise;
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`source '${rcFilePath.replace('~', '/home/user')}'; export PAGER=cat`,
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT source rcfile when shellToolRcFile setting is present but folder is untrusted', async () => {
|
||||
const rcFilePath = '~/.geminirc';
|
||||
(mockConfig.getShellToolRcFile as Mock).mockReturnValue(rcFilePath);
|
||||
(mockConfig.isTrustedFolder as Mock).mockReturnValue(false);
|
||||
|
||||
const invocation = shellTool.build({ command: 'my-command' });
|
||||
const promise = invocation.execute({ abortSignal: mockAbortSignal });
|
||||
resolveShellExecution();
|
||||
await promise;
|
||||
|
||||
expect(mockShellExecutionService).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining(`source '${rcFilePath}'`),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should properly escape quotes in rcFilePath', async () => {
|
||||
const rcFilePath = "/path/with/'quotes'/rc";
|
||||
(mockConfig.getShellToolRcFile as Mock).mockReturnValue(rcFilePath);
|
||||
(mockConfig.isTrustedFolder as Mock).mockReturnValue(true);
|
||||
|
||||
const invocation = shellTool.build({ command: 'my-command' });
|
||||
const promise = invocation.execute({ abortSignal: mockAbortSignal });
|
||||
resolveShellExecution();
|
||||
await promise;
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`source '/path/with/'\\''quotes'\\''/rc'; export PAGER=cat`,
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should format error messages correctly', async () => {
|
||||
const error = new Error('wrapped command failed');
|
||||
const invocation = shellTool.build({ command: 'user-command' });
|
||||
|
||||
@@ -50,12 +50,7 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { getShellDefinition } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import {
|
||||
homedir,
|
||||
toPathKey,
|
||||
isSubpath,
|
||||
resolveToRealPath,
|
||||
} from '../utils/paths.js';
|
||||
import { toPathKey, isSubpath, resolveToRealPath } from '../utils/paths.js';
|
||||
import {
|
||||
getProactiveToolSuggestions,
|
||||
isNetworkReliantCommand,
|
||||
@@ -467,30 +462,13 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
const combinedController = new AbortController();
|
||||
|
||||
const onAbort = () => combinedController.abort();
|
||||
let strippedCommandWithRc = strippedCommand;
|
||||
if (!isWindows) {
|
||||
strippedCommandWithRc = `export PAGER=cat GIT_PAGER=cat; ${strippedCommand}`;
|
||||
const rcFilePath = this.context.config.getShellToolRcFile();
|
||||
if (rcFilePath && this.context.config.isTrustedFolder()) {
|
||||
let resolvedRcFilePath = rcFilePath;
|
||||
if (rcFilePath === '~' || rcFilePath.startsWith('~/')) {
|
||||
resolvedRcFilePath = homedir() + rcFilePath.substring(1);
|
||||
}
|
||||
const escapedRcFilePath = resolvedRcFilePath.replace(
|
||||
/'/g,
|
||||
() => "'\\''",
|
||||
);
|
||||
strippedCommandWithRc = `source '${escapedRcFilePath}'; ${strippedCommandWithRc}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-shell-'));
|
||||
tempFilePath = path.join(tempDir, 'pgrep.tmp');
|
||||
|
||||
// pgrep is not available on Windows, so we can't get background PIDs
|
||||
const commandToExecute = this.wrapCommandForPgrep(
|
||||
strippedCommandWithRc,
|
||||
strippedCommand,
|
||||
tempFilePath,
|
||||
isWindows,
|
||||
);
|
||||
|
||||
@@ -2509,12 +2509,6 @@
|
||||
"markdownDescription": "Enable shell output efficiency optimizations for better performance.\n\n- Category: `Tools`\n- Requires restart: `no`\n- Default: `true`",
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"rcFile": {
|
||||
"title": "Shell Tool RC File",
|
||||
"description": "The path to a bash file (e.g., .bashrc) to source before executing shell commands.",
|
||||
"markdownDescription": "The path to a bash file (e.g., .bashrc) to source before executing shell commands.\n\n- Category: `Tools`\n- Requires restart: `no`",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -3031,6 +3025,13 @@
|
||||
"markdownDescription": "Deprecated: Use general.topicUpdateNarration instead.\n\n- Category: `Experimental`\n- Requires restart: `no`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"btw": {
|
||||
"title": "Enable /btw Side Inquiries",
|
||||
"description": "Enable the experimental /btw side inquiry command for ephemeral, non-persisted chat turns.",
|
||||
"markdownDescription": "Enable the experimental /btw side inquiry command for ephemeral, non-persisted chat turns.\n\n- Category: `Experimental`\n- Requires restart: `no`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
Reference in New Issue
Block a user