Compare commits

..

27 Commits

Author SHA1 Message Date
cynthialong0-0 0f509fbf77 Remove unused import of startMemoryService 2026-04-21 14:15:17 -07:00
cynthialong0-0 320ec2c78c Merge branch 'main' into mshanware/feat/btw 2026-04-21 14:08:39 -07:00
Jack Wotherspoon 4ce7968fa0 Merge branch 'main' into mshanware/feat/btw 2026-04-09 10:03:26 -04:00
Jack Wotherspoon 2aa95c4d01 chore: update spinner for /btw 2026-04-09 10:03:11 -04:00
Mahima Shanware ae433fbef7 docs: regenerate settings.md to fix CI 2026-04-08 18:48:07 +00:00
Mahima Shanware c1c8ecef3c fix(cli): address code review feedback regarding btw rendering and throttle cleanup 2026-04-08 17:08:46 +00:00
Mahima Shanware 96798e32b2 fix(cli): resolve memory leak in btw hook cleanup 2026-04-08 17:08:46 +00:00
Mahima Shanware ddeacbd33b fix(cli): address code review feedback for btw command
- Only register the `/btw` command if `experimental.btw` is enabled in settings.
- Explicitly clear the throttling timer in `useBtw.ts` when the display is dismissed to prevent delayed updates.
- Update the `/btw` placeholder example to use less technical language ("what does this function do?").
2026-04-08 17:08:46 +00:00
Mahima Shanware ed4c17e03e fix(cli): resolve btw command truncation in alternate buffer mode
The btw command's response was previously being rendered outside the `ScrollableList` component in `MainContent.tsx`. This caused its output to be severely truncated when the response was lengthy and the user had alternate buffer mode enabled, as the root container restricts height strictly to the terminal lines.

This commit incorporates the btw output as a dynamic item inside the `virtualizedData` fed to `ScrollableList` when the alternate buffer is active. This ensures the output is scrollable and not arbitrarily cut off. It also patches `useBtw` to fix a React testing warning regarding `act(...)` updates and a bug where a dismissed btw query could overwrite state if a delayed API callback arrived after dismissal.
2026-04-08 17:08:45 +00:00
Mahima Shanware 19698ca4ac refactor(core): address code review feedback on async state and tools 2026-04-08 17:08:45 +00:00
Mahima Shanware 12c4d27506 fix(cli): implement robust error extraction logic and tests for useBtw 2026-04-08 17:08:45 +00:00
Mahima Shanware 909c35c2d5 fix(core): address PR feedback regarding hooks, unused promises, and UI double-render 2026-04-08 17:08:45 +00:00
Mahima Shanware cc09cfd7a4 feat(telemetry): add usage metric for /btw command 2026-04-08 17:08:45 +00:00
Mahima Shanware 800edce6b1 docs: update settings.schema.json with experimental.btw 2026-04-08 17:08:45 +00:00
Mahima Shanware cbe297bc97 feat(btw): add experimental.btw setting and documentation
- Added `experimental.btw` setting to the settings schema.
- Updated `/btw` command to check if `experimental.btw` is enabled.
- Added documentation for `/btw` in `docs/reference/commands.md`.
- Regenerated settings documentation in `docs/reference/configuration.md` and `docs/cli/settings.md`.
- Updated unit tests for `/btw` command to provide mock context and test enablement flag.
2026-04-08 17:08:45 +00:00
Mahima Shanware 55a7a22471 feat(ui): automatically dismiss ephemeral /btw display on input typing
By intercepting text input when the `/btw` query results are
visible (but not actively streaming), we can dismiss the ephemeral
BtwDisplay before the new text wraps to the next line. This fixes
the UI jumpiness and "ghost space" scrolling that occurs when the
terminal recalculates the tall rendered height of the previous
query dynamically. Also, includes test updates to mock the
spinner to eliminate `act(...)` asynchronous test warnings.
2026-04-08 17:08:45 +00:00
Mahima Shanware ee1bc7e209 perf(cli): throttle btw streaming updates to prevent layout shakiness 2026-04-08 17:08:45 +00:00
Mahima Shanware 23441126b4 fix(cli): update package-lock.json dependencies after preflight run 2026-04-08 17:08:44 +00:00
Mahima Shanware 86487af7ff test(cli): add unit tests for /btw command functionality and components 2026-04-08 17:08:44 +00:00
Mahima Shanware dcde43b031 fix(cli): separate BtwDisplay from pendingItems in MainContent to prevent jerky scroll rendering 2026-04-08 17:08:44 +00:00
Mahima Shanware d912a58f81 fix(cli): remove row flex direction from BtwDisplay to prevent horizontal layout 2026-04-08 17:08:44 +00:00
Mahima Shanware da5af386e0 fix(cli): move btw layout into main content to prevent jerky scroll 2026-04-08 17:08:44 +00:00
Mahima Shanware 50a22245f8 fix(cli): update DefaultAppLayout mock to include btwState 2026-04-08 17:08:44 +00:00
Mahima Shanware 17b40b31b8 feat(cli): add UI integration for /btw command 2026-04-08 17:08:44 +00:00
Mahima Shanware 4bc7e2554f feat(cli): add useBtw hook and slash command processing 2026-04-08 17:08:44 +00:00
Mahima Shanware 09774da43c feat(cli): add /btw command architecture and types 2026-04-08 17:08:44 +00:00
Mahima Shanware 0bd797a2be feat(core): add sendBtwStream for tool-less side inquiries 2026-04-08 17:08:44 +00:00
44 changed files with 1665 additions and 157 deletions
+1
View File
@@ -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
+7
View File
@@ -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
+5 -5
View File
@@ -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):
+31 -3
View File
@@ -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"
},
+1 -1
View File
@@ -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 ??
+10 -10
View File
@@ -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,
+8
View File
@@ -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(),
+35 -2
View File
@@ -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,
};
},
};
+3 -1
View File
@@ -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);
+97 -14
View File
@@ -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(
+202
View File
@@ -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('');
});
});
+243
View File
@@ -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', () => ({
+7 -1
View File
@@ -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;
+15
View File
@@ -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);
+7 -7
View File
@@ -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;
}
+45 -1
View File
@@ -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' }] }];
+82 -3
View File
@@ -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.
*/
+134
View File
@@ -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
}
});
});
});
+80 -11
View File
@@ -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(
+7 -2
View File
@@ -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;
+2
View File
@@ -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;
}
/**
+2
View File
@@ -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',
+16
View File
@@ -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
*/
-66
View File
@@ -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' });
+2 -24
View File
@@ -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,
);
+7 -6
View File
@@ -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