Compare commits

...

15 Commits

Author SHA1 Message Date
Alisa 91fc6c748f Merge branch 'main' into package-lock-update 2026-01-29 13:11:26 -08:00
Jack Wotherspoon 567761fbc0 chore: revert IDE specific ASCII logo (#17887) 2026-01-29 20:44:02 +00:00
Sehoon Shon f2591aada6 fix(cli): use correct setting key for Cloud Shell auth (#17884) 2026-01-29 20:21:17 +00:00
Sehoon Shon 74468928e8 Revert "chore: refresh package-lock.json to resolve inconsistencies"
This reverts commit d561b995c0.
2026-01-29 15:29:26 -05:00
Sehoon Shon d561b995c0 chore: refresh package-lock.json to resolve inconsistencies 2026-01-29 15:28:40 -05:00
Alisa b44e432f55 Disable mouse tracking e2e (#17880)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-29 19:25:11 +00:00
Alisa 690735c5d2 Merge branch 'main' into package-lock-update 2026-01-29 11:09:51 -08:00
Coco Sheng bcc6ee596a fix(core): use GIT_CONFIG_GLOBAL to isolate shadow git repo configuration - Fixes #17877 (#17803) 2026-01-29 19:08:34 +00:00
Alisa Novikova 3a9338b404 Updating package-lock file 2026-01-29 11:06:30 -08:00
Christian Gunderman bc258eba4c Cleanup post delegate_to_agent removal (#17875) 2026-01-29 18:24:35 +00:00
Adib234 0e30055ae4 feat(plan): create generic Checklist component and refactor Todo (#17741) 2026-01-29 18:13:18 +00:00
Atharva Ralegankar ac7687c1dd fix(core): resolve DEP0040 punycode deprecation via patch-package (#17692)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
2026-01-29 16:04:26 +00:00
Dev Randalpura f9da18e0cc feat(ux): update truncate dots to be shorter in tables (#17825) 2026-01-29 15:56:56 +00:00
Shreya Keshive f619cd8750 fix(admin): Rename secureModeEnabled to strictModeDisabled (#17789) 2026-01-29 15:31:00 +00:00
christine betts 42eedc9184 Resolve error thrown for sensitive values (#17826) 2026-01-29 14:59:26 +00:00
31 changed files with 1013 additions and 316 deletions
+3 -3
View File
@@ -295,9 +295,9 @@ The Gemini CLI ships with a set of default policies to provide a safe
out-of-the-box experience.
- **Read-only tools** (like `read_file`, `glob`) are generally **allowed**.
- **Agent delegation** (like `delegate_to_agent`) defaults to **`ask_user`** to
ensure remote agents can prompt for confirmation, but local sub-agent actions
are executed silently and checked individually.
- **Agent delegation** defaults to **`ask_user`** to ensure remote agents can
prompt for confirmation, but local sub-agent actions are executed silently and
checked individually.
- **Write tools** (like `write_file`, `run_shell_command`) default to
**`ask_user`**.
- In **`yolo`** mode, a high-priority rule allows all tools.
+2 -2
View File
@@ -24,11 +24,11 @@ describe('generalist_agent', () => {
prompt:
'Please use the generalist agent to create a file called "generalist_test_file.txt" containing exactly the following text: success',
assert: async (rig) => {
// 1) Verify the generalist agent was invoked via delegate_to_agent
// 1) Verify the generalist agent was invoked
const foundToolCall = await rig.waitForToolCall('generalist');
expect(
foundToolCall,
'Expected to find a delegate_to_agent tool call for generalist agent',
'Expected to find a tool call for generalist agent',
).toBeTruthy();
// 2) Verify the file was created as expected
+155
View File
@@ -0,0 +1,155 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import { GitService, Storage } from '@google/gemini-cli-core';
describe('Checkpointing Integration', () => {
let tmpDir: string;
let projectRoot: string;
let fakeHome: string;
let originalEnv: NodeJS.ProcessEnv;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'gemini-checkpoint-test-'),
);
projectRoot = path.join(tmpDir, 'project');
fakeHome = path.join(tmpDir, 'home');
await fs.mkdir(projectRoot, { recursive: true });
await fs.mkdir(fakeHome, { recursive: true });
// Save original env
originalEnv = { ...process.env };
// Simulate environment with NO global gitconfig
process.env['HOME'] = fakeHome;
delete process.env['GIT_CONFIG_GLOBAL'];
delete process.env['GIT_CONFIG_SYSTEM'];
});
afterEach(async () => {
// Restore env
process.env = originalEnv;
// Cleanup
try {
await fs.rm(tmpDir, { recursive: true, force: true });
} catch (e) {
console.error('Failed to cleanup temp dir', e);
}
});
it('should successfully create and restore snapshots without global git config', async () => {
const storage = new Storage(projectRoot);
const gitService = new GitService(projectRoot, storage);
// 1. Initialize
await gitService.initialize();
// Verify system config empty file creation
// We need to access getHistoryDir logic or replicate it.
// Since we don't have access to private getHistoryDir, we can infer it or just trust the functional test.
// 2. Create initial state
await fs.writeFile(path.join(projectRoot, 'file1.txt'), 'version 1');
await fs.writeFile(path.join(projectRoot, 'file2.txt'), 'permanent file');
// 3. Create Snapshot
const snapshotHash = await gitService.createFileSnapshot('Checkpoint 1');
expect(snapshotHash).toBeDefined();
// 4. Modify files
await fs.writeFile(
path.join(projectRoot, 'file1.txt'),
'version 2 (BAD CHANGE)',
);
await fs.writeFile(
path.join(projectRoot, 'file3.txt'),
'new file (SHOULD BE GONE)',
);
await fs.rm(path.join(projectRoot, 'file2.txt'));
// 5. Restore
await gitService.restoreProjectFromSnapshot(snapshotHash);
// 6. Verify state
const file1Content = await fs.readFile(
path.join(projectRoot, 'file1.txt'),
'utf-8',
);
expect(file1Content).toBe('version 1');
const file2Exists = await fs
.stat(path.join(projectRoot, 'file2.txt'))
.then(() => true)
.catch(() => false);
expect(file2Exists).toBe(true);
const file2Content = await fs.readFile(
path.join(projectRoot, 'file2.txt'),
'utf-8',
);
expect(file2Content).toBe('permanent file');
const file3Exists = await fs
.stat(path.join(projectRoot, 'file3.txt'))
.then(() => true)
.catch(() => false);
expect(file3Exists).toBe(false);
});
it('should ignore user global git config and use isolated identity', async () => {
// 1. Create a fake global gitconfig with a specific user
const globalConfigPath = path.join(fakeHome, '.gitconfig');
const globalConfigContent = `[user]
name = Global User
email = global@example.com
`;
await fs.writeFile(globalConfigPath, globalConfigContent);
// Point HOME to fakeHome so git picks up this global config (if we didn't isolate it)
process.env['HOME'] = fakeHome;
// Ensure GIT_CONFIG_GLOBAL is NOT set for the process initially,
// so it would default to HOME/.gitconfig if GitService didn't override it.
delete process.env['GIT_CONFIG_GLOBAL'];
const storage = new Storage(projectRoot);
const gitService = new GitService(projectRoot, storage);
await gitService.initialize();
// 2. Create a file and snapshot
await fs.writeFile(path.join(projectRoot, 'test.txt'), 'content');
await gitService.createFileSnapshot('Snapshot with global config present');
// 3. Verify the commit author in the shadow repo
const historyDir = storage.getHistoryDir();
const { execFileSync } = await import('node:child_process');
const logOutput = execFileSync(
'git',
['log', '-1', '--pretty=format:%an <%ae>'],
{
cwd: historyDir,
env: {
...process.env,
GIT_DIR: path.join(historyDir, '.git'),
GIT_CONFIG_GLOBAL: path.join(historyDir, '.gitconfig'),
GIT_CONFIG_SYSTEM: path.join(historyDir, '.gitconfig_system_empty'),
},
encoding: 'utf-8',
},
);
expect(logOutput).toBe('Gemini CLI <gemini-cli@google.com>');
expect(logOutput).not.toContain('Global User');
});
});
+7
View File
@@ -72,6 +72,13 @@ export async function setup() {
}
export async function teardown() {
// Disable mouse tracking
if (process.stdout.isTTY) {
process.stdout.write(
'\x1b[?1000l\x1b[?1003l\x1b[?1015l\x1b[?1006l\x1b[?1002l',
);
}
// Cleanup the test run directory unless KEEP_OUTPUT is set
if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) {
try {
+211 -24
View File
@@ -7,13 +7,16 @@
"": {
"name": "@google/gemini-cli",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"hasInstallScript": true,
"workspaces": [
"packages/*"
],
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.8",
"latest-version": "^9.0.0",
"simple-git": "^3.28.0"
"punycode": "^2.3.1",
"simple-git": "^3.28.0",
"whatwg-url": "^5.0.0"
},
"bin": {
"gemini": "bundle/gemini.js"
@@ -53,6 +56,7 @@
"mock-fs": "^5.5.0",
"msw": "^2.10.4",
"npm-run-all": "^4.1.5",
"patch-package": "^8.0.1",
"prettier": "^3.5.3",
"react-devtools-core": "^6.1.2",
"semver": "^7.7.2",
@@ -5361,6 +5365,13 @@
"integrity": "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==",
"license": "MIT"
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -6635,6 +6646,22 @@
"node": ">=18"
}
},
"node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cjs-module-lexer": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",
@@ -9287,6 +9314,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/find-yarn-workspace-root": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"micromatch": "^4.0.2"
}
},
"node_modules/findup-sync": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-5.0.0.tgz",
@@ -11511,6 +11548,26 @@
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"isarray": "^2.0.5",
"jsonify": "^0.0.1",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -11559,6 +11616,16 @@
"node": ">= 10.0.0"
}
},
"node_modules/jsonify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"dev": true,
"license": "Public Domain",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
@@ -11663,6 +11730,16 @@
"json-buffer": "3.0.1"
}
},
"node_modules/klaw-sync": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.11"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -12772,28 +12849,6 @@
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
@@ -13687,6 +13742,117 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/patch-package": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
"chalk": "^4.1.2",
"ci-info": "^3.7.0",
"cross-spawn": "^7.0.3",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^10.0.0",
"json-stable-stringify": "^1.0.2",
"klaw-sync": "^6.0.0",
"minimist": "^1.2.6",
"open": "^7.4.2",
"semver": "^7.5.3",
"slash": "^2.0.0",
"tmp": "^0.2.4",
"yaml": "^2.2.2"
},
"bin": {
"patch-package": "index.js"
},
"engines": {
"node": ">=14",
"npm": ">5"
}
},
"node_modules/patch-package/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/patch-package/node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"dev": true,
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/patch-package/node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/patch-package/node_modules/open": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0",
"is-wsl": "^2.1.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/patch-package/node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/patch-package/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -14133,7 +14299,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -16621,6 +16786,12 @@
"node": ">=0.6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tree-dump": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.3.tgz",
@@ -17382,6 +17553,12 @@
}
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
@@ -17405,6 +17582,16 @@
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+6 -2
View File
@@ -61,7 +61,8 @@
"telemetry": "node scripts/telemetry.js",
"check:lockfile": "node scripts/check-lockfile.js",
"clean": "node scripts/clean.js",
"pre-commit": "node scripts/pre-commit.js"
"pre-commit": "node scripts/pre-commit.js",
"postinstall": "patch-package"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.4.8",
@@ -113,6 +114,7 @@
"mock-fs": "^5.5.0",
"msw": "^2.10.4",
"npm-run-all": "^4.1.5",
"patch-package": "^8.0.1",
"prettier": "^3.5.3",
"react-devtools-core": "^6.1.2",
"semver": "^7.7.2",
@@ -126,7 +128,9 @@
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.8",
"latest-version": "^9.0.0",
"simple-git": "^3.28.0"
"punycode": "^2.3.1",
"simple-git": "^3.28.0",
"whatwg-url": "^5.0.0"
},
"optionalDependencies": {
"@lydell/node-pty": "1.1.0",
@@ -398,6 +398,35 @@ describe('extensionSettings', () => {
expect(actualContent).toBe('VAR1="a value with spaces"\n');
});
it('should not set sensitive settings if the value is empty during initial setup', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{
name: 's1',
description: 'd1',
envVar: 'SENSITIVE_VAR',
sensitive: true,
},
],
};
mockRequestSetting.mockResolvedValue('');
await maybePromptForSettings(
config,
'12345',
mockRequestSetting,
undefined,
undefined,
);
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext 12345`,
);
expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
});
it('should not attempt to clear secrets if keychain is unavailable', async () => {
// Arrange
const mockIsAvailable = vi.fn().mockResolvedValue(false);
@@ -738,5 +767,42 @@ describe('extensionSettings', () => {
const lines = actualContent.split('\n').filter((line) => line.length > 0);
expect(lines).toHaveLength(3); // Should only have the three variables
});
it('should delete a sensitive setting if the new value is empty', async () => {
mockRequestSetting.mockResolvedValue('');
await updateSetting(
config,
'12345',
'VAR2',
mockRequestSetting,
ExtensionSettingScope.USER,
tempWorkspaceDir,
);
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext 12345`,
);
expect(await userKeychain.getSecret('VAR2')).toBeNull();
});
it('should not throw if deleting a non-existent sensitive setting with empty value', async () => {
mockRequestSetting.mockResolvedValue('');
// Ensure it doesn't exist first
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext 12345`,
);
await userKeychain.deleteSecret('VAR2');
await updateSetting(
config,
'12345',
'VAR2',
mockRequestSetting,
ExtensionSettingScope.USER,
tempWorkspaceDir,
);
// Should complete without error
});
});
});
@@ -112,7 +112,7 @@ export async function maybePromptForSettings(
const nonSensitiveSettings: Record<string, string> = {};
for (const setting of settings) {
const value = allSettings[setting.envVar];
if (value === undefined) {
if (value === undefined || value === '') {
continue;
}
if (setting.sensitive) {
@@ -230,7 +230,15 @@ export async function updateSetting(
);
if (settingToUpdate.sensitive) {
await keychain.setSecret(settingToUpdate.envVar, newValue);
if (newValue) {
await keychain.setSecret(settingToUpdate.envVar, newValue);
} else {
try {
await keychain.deleteSecret(settingToUpdate.envVar);
} catch {
// Ignore if secret does not exist
}
}
return;
}
+38 -14
View File
@@ -2156,7 +2156,7 @@ describe('Settings Loading and Merging', () => {
// 2. Now, set remote admin settings.
loadedSettings.setRemoteAdminSettings({
secureModeEnabled: true,
strictModeDisabled: false,
mcpSetting: { mcpEnabled: false },
cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } },
});
@@ -2197,7 +2197,7 @@ describe('Settings Loading and Merging', () => {
expect(loadedSettings.merged.ui?.theme).toBe('initial-theme');
const newRemoteSettings = {
secureModeEnabled: true,
strictModeDisabled: false,
mcpSetting: { mcpEnabled: false },
cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } },
};
@@ -2213,7 +2213,7 @@ describe('Settings Loading and Merging', () => {
// Verify that calling setRemoteAdminSettings with partial data overwrites previous remote settings
// and missing properties revert to schema defaults.
loadedSettings.setRemoteAdminSettings({ secureModeEnabled: false });
loadedSettings.setRemoteAdminSettings({ strictModeDisabled: true });
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); // Defaulting to false if missing
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); // Defaulting to false if missing
@@ -2271,9 +2271,9 @@ describe('Settings Loading and Merging', () => {
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true);
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true);
// Set remote settings with only secureModeEnabled
// Set remote settings with only strictModeDisabled (false -> secureModeEnabled: true)
loadedSettings.setRemoteAdminSettings({
secureModeEnabled: true,
strictModeDisabled: false,
});
// Verify secureModeEnabled is updated, others default to false
@@ -2286,8 +2286,8 @@ describe('Settings Loading and Merging', () => {
mcpSetting: { mcpEnabled: false },
});
// Verify mcpEnabled is updated, others remain defaults (secureModeEnabled reverts to default:false)
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
// Verify mcpEnabled is updated, others remain defaults (secureModeEnabled defaults to true if strictModeDisabled is missing)
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
@@ -2297,9 +2297,33 @@ describe('Settings Loading and Merging', () => {
});
// Verify extensionsEnabled is updated, others remain defaults
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
// Verify that missing strictModeDisabled falls back to secureModeEnabled
loadedSettings.setRemoteAdminSettings({
secureModeEnabled: false,
});
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
loadedSettings.setRemoteAdminSettings({
secureModeEnabled: true,
});
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
// Verify strictModeDisabled takes precedence over secureModeEnabled
loadedSettings.setRemoteAdminSettings({
strictModeDisabled: false,
secureModeEnabled: false,
});
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
loadedSettings.setRemoteAdminSettings({
strictModeDisabled: true,
secureModeEnabled: true,
});
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
});
it('should set skills based on unmanagedCapabilitiesEnabled', () => {
@@ -2337,12 +2361,12 @@ describe('Settings Loading and Merging', () => {
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
});
it('should force secureModeEnabled to false if undefined, overriding schema defaults', () => {
// Mock schema to have secureModeEnabled default to true to verify the override
it('should force secureModeEnabled to true if undefined, overriding schema defaults', () => {
// Mock schema to have secureModeEnabled default to false to verify the override
const originalSchema = getSettingsSchema();
const modifiedSchema = JSON.parse(JSON.stringify(originalSchema));
if (modifiedSchema.admin?.properties?.secureModeEnabled) {
modifiedSchema.admin.properties.secureModeEnabled.default = true;
modifiedSchema.admin.properties.secureModeEnabled.default = false;
}
vi.mocked(getSettingsSchema).mockReturnValue(modifiedSchema);
@@ -2352,13 +2376,13 @@ describe('Settings Loading and Merging', () => {
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
// Pass a non-empty object that doesn't have secureModeEnabled
// Pass a non-empty object that doesn't have strictModeDisabled
loadedSettings.setRemoteAdminSettings({
mcpSetting: {},
});
// It should be forced to false by the logic, overriding the mock default of true
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
// It should be forced to true by the logic (default secure), overriding the mock default of false
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
} finally {
vi.mocked(getSettingsSchema).mockReturnValue(originalSchema);
}
+13 -2
View File
@@ -348,7 +348,12 @@ export class LoadedSettings {
setRemoteAdminSettings(remoteSettings: FetchAdminControlsResponse): void {
const admin: Settings['admin'] = {};
const { secureModeEnabled, mcpSetting, cliFeatureSetting } = remoteSettings;
const {
secureModeEnabled,
strictModeDisabled,
mcpSetting,
cliFeatureSetting,
} = remoteSettings;
if (Object.keys(remoteSettings).length === 0) {
this._remoteAdminSettings = { admin };
@@ -356,7 +361,13 @@ export class LoadedSettings {
return;
}
admin.secureModeEnabled = secureModeEnabled ?? false;
if (strictModeDisabled !== undefined) {
admin.secureModeEnabled = !strictModeDisabled;
} else if (secureModeEnabled !== undefined) {
admin.secureModeEnabled = secureModeEnabled;
} else {
admin.secureModeEnabled = true;
}
admin.mcp = { enabled: mcpSetting?.mcpEnabled ?? false };
admin.extensions = {
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled ?? false,
+1 -1
View File
@@ -367,7 +367,7 @@ export async function main() {
) {
settings.setValue(
SettingScope.User,
'selectedAuthType',
'security.auth.selectedType',
AuthType.COMPUTE_ADC,
);
}
@@ -36,42 +36,3 @@ export const tinyAsciiLogo = `
███░ ░░█████████
░░░ ░░░░░░░░░
`;
export const shortAsciiLogoIde = `
░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░
░░░ ░░░ ░░░ ░░░░░░ ░░░░░░ ░░░ ░░░░░░ ░░░░░ ░░░
░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░
█████████░░██████████ ██████ ░░██████░█████░██████ ░░█████ █████░
███░░ ███░███░░ ██████ ░██████░░███░░██████ ░█████ ███░░
███░░ ░░███░░ ███░███ ███ ███░░███░░███░███ ███░░ ███░░
███░░░░████░██████░░░░░███░░█████ ███░░███░░███░░███ ███░░░ ███░░░
███ ███ ███ ███ ███ ███ ███ ███ ██████ ███
███ ███ ███ ███ ███ ███ ███ █████ ███
█████████ ██████████ ███ ███ █████ ███ █████ █████
`;
export const longAsciiLogoIde = `
░░░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░
░░░ ░░░ ░░░ ░░░ ░░░░░░ ░░░░░░ ░░░ ░░░░░░ ░░░░░ ░░░
░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░
███ ░░░ █████████░░██████████ ██████ ░░██████░█████░██████ ░░█████ █████░
███ ░░░ ███░ ███░███░░ ██████ ░██████░░███░░██████ ░█████ ███░░
███ ███░░░ ░░███░░ ███░███ ███ ███░░███░░███░███ ███░░ ███░░
░░░ ███ ███ ░░░█████░██████░░░░░███░░█████ ███░░███░░███░░███ ███░░░ ███░░░
███ ███ ███ ███ ███ ███ ███ ███ ███ ██████ ███
███ ███ ███ ███ ███ ███ ███ ███ █████ ███
███ █████████ ██████████ ███ ███ █████ ███ █████ █████
`;
export const tinyAsciiLogoIde = `
░░░ ░░░░░░░░░
░░░ ░░░ ░░░
░░░ ░░░
███ ░░░ █████████░░░
███ ░░░ ███░░ ███░░
███ ███░░ ░░░
░░░ ███ ███░░░░████░
███ ███ ███
███ ███ ███
███ █████████
`;
@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { Checklist } from './Checklist.js';
import type { ChecklistItemData } from './ChecklistItem.js';
describe('<Checklist />', () => {
const items: ChecklistItemData[] = [
{ status: 'completed', label: 'Task 1' },
{ status: 'in_progress', label: 'Task 2' },
{ status: 'pending', label: 'Task 3' },
{ status: 'cancelled', label: 'Task 4' },
];
it('renders nothing when list is empty', () => {
const { lastFrame } = render(
<Checklist title="Test List" items={[]} isExpanded={true} />,
);
expect(lastFrame()).toBe('');
});
it('renders nothing when collapsed and no active items', () => {
const inactiveItems: ChecklistItemData[] = [
{ status: 'completed', label: 'Task 1' },
{ status: 'cancelled', label: 'Task 2' },
];
const { lastFrame } = render(
<Checklist title="Test List" items={inactiveItems} isExpanded={false} />,
);
expect(lastFrame()).toBe('');
});
it('renders summary view correctly (collapsed)', () => {
const { lastFrame } = render(
<Checklist
title="Test List"
items={items}
isExpanded={false}
toggleHint="toggle me"
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders expanded view correctly', () => {
const { lastFrame } = render(
<Checklist
title="Test List"
items={items}
isExpanded={true}
toggleHint="toggle me"
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders summary view without in-progress item if none exists', () => {
const pendingItems: ChecklistItemData[] = [
{ status: 'completed', label: 'Task 1' },
{ status: 'pending', label: 'Task 2' },
];
const { lastFrame } = render(
<Checklist title="Test List" items={pendingItems} isExpanded={false} />,
);
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -0,0 +1,126 @@
/**
* @license
* Copyright 2025 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 { useMemo } from 'react';
import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js';
export interface ChecklistProps {
title: string;
items: ChecklistItemData[];
isExpanded: boolean;
toggleHint?: string;
}
const ChecklistTitleDisplay: React.FC<{
title: string;
items: ChecklistItemData[];
toggleHint?: string;
}> = ({ title, items, toggleHint }) => {
const score = useMemo(() => {
let total = 0;
let completed = 0;
for (const item of items) {
if (item.status !== 'cancelled') {
total += 1;
if (item.status === 'completed') {
completed += 1;
}
}
}
return `${completed}/${total} completed`;
}, [items]);
return (
<Box flexDirection="row" columnGap={2} height={1}>
<Text color={theme.text.primary} bold aria-label={`${title} list`}>
{title}
</Text>
<Text color={theme.text.secondary}>
{score}
{toggleHint ? ` (${toggleHint})` : ''}
</Text>
</Box>
);
};
const ChecklistListDisplay: React.FC<{ items: ChecklistItemData[] }> = ({
items,
}) => (
<Box flexDirection="column" aria-role="list">
{items.map((item, index) => (
<ChecklistItem
item={item}
key={`${index}-${item.label}`}
role="listitem"
/>
))}
</Box>
);
export const Checklist: React.FC<ChecklistProps> = ({
title,
items,
isExpanded,
toggleHint,
}) => {
const inProgress: ChecklistItemData | null = useMemo(
() => items.find((item) => item.status === 'in_progress') || null,
[items],
);
const hasActiveItems = useMemo(
() =>
items.some(
(item) => item.status === 'pending' || item.status === 'in_progress',
),
[items],
);
if (items.length === 0 || (!isExpanded && !hasActiveItems)) {
return null;
}
return (
<Box
borderStyle="single"
borderBottom={false}
borderRight={false}
borderLeft={false}
borderColor={theme.border.default}
paddingLeft={1}
paddingRight={1}
>
{isExpanded ? (
<Box flexDirection="column" rowGap={1}>
<ChecklistTitleDisplay
title={title}
items={items}
toggleHint={toggleHint}
/>
<ChecklistListDisplay items={items} />
</Box>
) : (
<Box flexDirection="row" columnGap={1} height={1}>
<Box flexShrink={0} flexGrow={0}>
<ChecklistTitleDisplay
title={title}
items={items}
toggleHint={toggleHint}
/>
</Box>
{inProgress && (
<Box flexShrink={1} flexGrow={1}>
<ChecklistItem item={inProgress} wrap="truncate" />
</Box>
)}
</Box>
)}
</Box>
);
};
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js';
import { Box } from 'ink';
describe('<ChecklistItem />', () => {
it.each([
{ status: 'pending', label: 'Do this' },
{ status: 'in_progress', label: 'Doing this' },
{ status: 'completed', label: 'Done this' },
{ status: 'cancelled', label: 'Skipped this' },
] as ChecklistItemData[])('renders %s item correctly', (item) => {
const { lastFrame } = render(<ChecklistItem item={item} />);
expect(lastFrame()).toMatchSnapshot();
});
it('truncates long text when wrap="truncate"', () => {
const item: ChecklistItemData = {
status: 'in_progress',
label:
'This is a very long text that should be truncated because the wrap prop is set to truncate',
};
const { lastFrame } = render(
<Box width={30}>
<ChecklistItem item={item} wrap="truncate" />
</Box>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('wraps long text by default', () => {
const item: ChecklistItemData = {
status: 'in_progress',
label:
'This is a very long text that should wrap because the default behavior is wrapping',
};
const { lastFrame } = render(
<Box width={30}>
<ChecklistItem item={item} />
</Box>,
);
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -0,0 +1,92 @@
/**
* @license
* Copyright 2025 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 { checkExhaustive } from '../../utils/checks.js';
export type ChecklistStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'cancelled';
export interface ChecklistItemData {
status: ChecklistStatus;
label: string;
}
const ChecklistStatusDisplay: React.FC<{ status: ChecklistStatus }> = ({
status,
}) => {
switch (status) {
case 'completed':
return (
<Text color={theme.status.success} aria-label="Completed">
</Text>
);
case 'in_progress':
return (
<Text color={theme.text.accent} aria-label="In Progress">
»
</Text>
);
case 'pending':
return (
<Text color={theme.text.secondary} aria-label="Pending">
</Text>
);
case 'cancelled':
return (
<Text color={theme.status.error} aria-label="Cancelled">
</Text>
);
default:
checkExhaustive(status);
}
};
export interface ChecklistItemProps {
item: ChecklistItemData;
wrap?: 'truncate';
role?: 'listitem';
}
export const ChecklistItem: React.FC<ChecklistItemProps> = ({
item,
wrap,
role: ariaRole,
}) => {
const textColor = (() => {
switch (item.status) {
case 'in_progress':
return theme.text.accent;
case 'completed':
case 'cancelled':
return theme.text.secondary;
case 'pending':
return theme.text.primary;
default:
checkExhaustive(item.status);
}
})();
const strikethrough = item.status === 'cancelled';
return (
<Box flexDirection="row" columnGap={1} aria-role={ariaRole}>
<ChecklistStatusDisplay status={item.status} />
<Box flexShrink={1}>
<Text color={textColor} wrap={wrap} strikethrough={strikethrough}>
{item.label}
</Text>
</Box>
</Box>
);
};
+5 -37
View File
@@ -8,9 +8,8 @@ import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { Header } from './Header.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import { longAsciiLogo, longAsciiLogoIde } from './AsciiArt.js';
import { longAsciiLogo } from './AsciiArt.js';
import * as semanticColors from '../semantic-colors.js';
import * as terminalSetup from '../utils/terminalSetup.js';
import { Text } from 'ink';
import type React from 'react';
@@ -18,9 +17,6 @@ vi.mock('../hooks/useTerminalSize.js');
vi.mock('../hooks/useSnowfall.js', () => ({
useSnowfall: vi.fn((art) => art),
}));
vi.mock('../utils/terminalSetup.js', () => ({
getTerminalProgram: vi.fn(),
}));
vi.mock('ink-gradient', () => {
const MockGradient = ({ children }: { children: React.ReactNode }) => (
<>{children}</>
@@ -41,7 +37,6 @@ vi.mock('ink', async () => {
describe('<Header />', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue(null);
});
it('renders the long logo on a wide terminal', () => {
@@ -58,22 +53,6 @@ describe('<Header />', () => {
);
});
it('uses the IDE logo when running in an IDE', () => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
columns: 120,
rows: 20,
});
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue('vscode');
render(<Header version="1.0.0" nightly={false} />);
expect(Text).toHaveBeenCalledWith(
expect.objectContaining({
children: longAsciiLogoIde,
}),
undefined,
);
});
it('renders custom ASCII art when provided', () => {
const customArt = 'CUSTOM ART';
render(
@@ -87,24 +66,13 @@ describe('<Header />', () => {
);
});
it('renders custom ASCII art as is when running in an IDE', () => {
const customArt = 'CUSTOM ART';
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue('vscode');
render(
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
);
expect(Text).toHaveBeenCalledWith(
expect.objectContaining({
children: customArt,
}),
undefined,
);
});
it('displays the version number when nightly is true', () => {
render(<Header version="1.0.0" nightly={true} />);
const textCalls = (Text as Mock).mock.calls;
expect(textCalls[1][0].children.join('')).toBe('v1.0.0');
const versionText = Array.isArray(textCalls[1][0].children)
? textCalls[1][0].children.join('')
: textCalls[1][0].children;
expect(versionText).toBe('v1.0.0');
});
it('does not display the version number when nightly is false', () => {
+4 -13
View File
@@ -7,17 +7,9 @@
import type React from 'react';
import { Box } from 'ink';
import { ThemedGradient } from './ThemedGradient.js';
import {
shortAsciiLogo,
longAsciiLogo,
tinyAsciiLogo,
shortAsciiLogoIde,
longAsciiLogoIde,
tinyAsciiLogoIde,
} from './AsciiArt.js';
import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { getTerminalProgram } from '../utils/terminalSetup.js';
import { useSnowfall } from '../hooks/useSnowfall.js';
interface HeaderProps {
@@ -32,7 +24,6 @@ export const Header: React.FC<HeaderProps> = ({
nightly,
}) => {
const { columns: terminalWidth } = useTerminalSize();
const isIde = getTerminalProgram();
let displayTitle;
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
@@ -40,11 +31,11 @@ export const Header: React.FC<HeaderProps> = ({
if (customAsciiArt) {
displayTitle = customAsciiArt;
} else if (terminalWidth >= widthOfLongLogo) {
displayTitle = isIde ? longAsciiLogoIde : longAsciiLogo;
displayTitle = longAsciiLogo;
} else if (terminalWidth >= widthOfShortLogo) {
displayTitle = isIde ? shortAsciiLogoIde : shortAsciiLogo;
displayTitle = shortAsciiLogo;
} else {
displayTitle = isIde ? tinyAsciiLogoIde : tinyAsciiLogo;
displayTitle = tinyAsciiLogo;
}
const artWidth = getAsciiArtWidth(displayTitle);
@@ -0,0 +1,21 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Checklist /> > renders expanded view correctly 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Test List 1/3 completed (toggle me)
✓ Task 1
» Task 2
☐ Task 3
✗ Task 4"
`;
exports[`<Checklist /> > renders summary view correctly (collapsed) 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Test List 1/3 completed (toggle me) » Task 2"
`;
exports[`<Checklist /> > renders summary view without in-progress item if none exists 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Test List 1/2 completed"
`;
@@ -0,0 +1,17 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ChecklistItem /> > renders { status: 'cancelled', label: 'Skipped this' } item correctly 1`] = `"✗ Skipped this"`;
exports[`<ChecklistItem /> > renders { status: 'completed', label: 'Done this' } item correctly 1`] = `"✓ Done this"`;
exports[`<ChecklistItem /> > renders { status: 'in_progress', label: 'Doing this' } item correctly 1`] = `"» Doing this"`;
exports[`<ChecklistItem /> > renders { status: 'pending', label: 'Do this' } item correctly 1`] = `"☐ Do this"`;
exports[`<ChecklistItem /> > truncates long text when wrap="truncate" 1`] = `"» This is a very long text th…"`;
exports[`<ChecklistItem /> > wraps long text by default 1`] = `
"» This is a very long text
that should wrap because the
default behavior is wrapping"
`;
+17 -148
View File
@@ -5,101 +5,12 @@
*/
import type React from 'react';
import { Box, Text } from 'ink';
import {
type Todo,
type TodoList,
type TodoStatus,
} from '@google/gemini-cli-core';
import { theme } from '../../semantic-colors.js';
import { type TodoList } from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useMemo } from 'react';
import type { HistoryItemToolGroup } from '../../types.js';
const TodoTitleDisplay: React.FC<{ todos: TodoList }> = ({ todos }) => {
const score = useMemo(() => {
let total = 0;
let completed = 0;
for (const todo of todos.todos) {
if (todo.status !== 'cancelled') {
total += 1;
if (todo.status === 'completed') {
completed += 1;
}
}
}
return `${completed}/${total} completed`;
}, [todos]);
return (
<Box flexDirection="row" columnGap={2} height={1}>
<Text color={theme.text.primary} bold aria-label="Todo list">
Todo
</Text>
<Text color={theme.text.secondary}>{score} (ctrl+t to toggle)</Text>
</Box>
);
};
const TodoStatusDisplay: React.FC<{ status: TodoStatus }> = ({ status }) => {
switch (status) {
case 'completed':
return (
<Text color={theme.status.success} aria-label="Completed">
</Text>
);
case 'in_progress':
return (
<Text color={theme.text.accent} aria-label="In Progress">
»
</Text>
);
case 'pending':
return (
<Text color={theme.text.secondary} aria-label="Pending">
</Text>
);
case 'cancelled':
default:
return (
<Text color={theme.status.error} aria-label="Cancelled">
</Text>
);
}
};
const TodoItemDisplay: React.FC<{
todo: Todo;
wrap?: 'truncate';
role?: 'listitem';
}> = ({ todo, wrap, role: ariaRole }) => {
const textColor = (() => {
switch (todo.status) {
case 'in_progress':
return theme.text.accent;
case 'completed':
case 'cancelled':
return theme.text.secondary;
default:
return theme.text.primary;
}
})();
const strikethrough = todo.status === 'cancelled';
return (
<Box flexDirection="row" columnGap={1} aria-role={ariaRole}>
<TodoStatusDisplay status={todo.status} />
<Box flexShrink={1}>
<Text color={textColor} wrap={wrap} strikethrough={strikethrough}>
{todo.description}
</Text>
</Box>
</Box>
);
};
import { Checklist } from '../Checklist.js';
import type { ChecklistItemData } from '../ChecklistItem.js';
export const TodoTray: React.FC = () => {
const uiState = useUIState();
@@ -125,68 +36,26 @@ export const TodoTray: React.FC = () => {
return null;
}, [uiState.history]);
const inProgress: Todo | null = useMemo(() => {
if (todos === null) {
return null;
const checklistItems: ChecklistItemData[] = useMemo(() => {
if (!todos || !todos.todos) {
return [];
}
return todos.todos.find((todo) => todo.status === 'in_progress') || null;
return todos.todos.map((todo) => ({
status: todo.status,
label: todo.description,
}));
}, [todos]);
const hasActiveTodos = useMemo(() => {
if (!todos || !todos.todos) return false;
return todos.todos.some(
(todo) => todo.status === 'pending' || todo.status === 'in_progress',
);
}, [todos]);
if (
todos === null ||
!todos.todos ||
todos.todos.length === 0 ||
(!uiState.showFullTodos && !hasActiveTodos)
) {
if (!todos || !todos.todos) {
return null;
}
return (
<Box
borderStyle="single"
borderBottom={false}
borderRight={false}
borderLeft={false}
borderColor={theme.border.default}
paddingLeft={1}
paddingRight={1}
>
{uiState.showFullTodos ? (
<Box flexDirection="column" rowGap={1}>
<TodoTitleDisplay todos={todos} />
<TodoListDisplay todos={todos} />
</Box>
) : (
<Box flexDirection="row" columnGap={1} height={1}>
<Box flexShrink={0} flexGrow={0}>
<TodoTitleDisplay todos={todos} />
</Box>
{inProgress && (
<Box flexShrink={1} flexGrow={1}>
<TodoItemDisplay todo={inProgress} wrap="truncate" />
</Box>
)}
</Box>
)}
</Box>
<Checklist
title="Todo"
items={checklistItems}
isExpanded={uiState.showFullTodos}
toggleHint="ctrl+t to toggle"
/>
);
};
interface TodoListDisplayProps {
todos: TodoList;
}
const TodoListDisplay: React.FC<TodoListDisplayProps> = ({ todos }) => (
<Box flexDirection="column" aria-role="list">
{todos.todos.map((todo: Todo, index: number) => (
<TodoItemDisplay todo={todo} key={index} role="listitem" />
))}
</Box>
);
@@ -45,6 +45,7 @@ describe('handleAtCommand', () => {
}
beforeEach(async () => {
vi.restoreAllMocks();
vi.resetAllMocks();
testRootDir = await fsPromises.mkdtemp(
@@ -1403,4 +1404,32 @@ describe('handleAtCommand', () => {
134,
);
});
it('should include agent nudge when agents are found', async () => {
const agentName = 'my-agent';
const otherAgent = 'other-agent';
// Mock getAgentRegistry on the config
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
getDefinition: (name: string) =>
name === agentName || name === otherAgent ? { name } : undefined,
});
const query = `@${agentName} @${otherAgent}`;
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 600,
signal: abortController.signal,
});
const expectedNudge = `\n<system_note>\nThe user has explicitly selected the following agent(s): ${agentName}, ${otherAgent}. Please use the following tool(s) to delegate the task: '${agentName}', '${otherAgent}'.\n</system_note>\n`;
expect(result.processedQuery).toContainEqual(
expect.objectContaining({ text: expectedNudge }),
);
});
});
@@ -457,9 +457,10 @@ export async function handleAtCommand({
const processedQueryParts: PartListUnion = [{ text: initialQueryText }];
if (agentsFound.length > 0) {
const toolsList = agentsFound.map((agent) => `'${agent}'`).join(', ');
const agentNudge = `\n<system_note>\nThe user has explicitly selected the following agent(s): ${agentsFound.join(
', ',
)}. Please use the 'delegate_to_agent' tool to delegate the task to the selected agent(s).\n</system_note>\n`;
)}. Please use the following tool(s) to delegate the task: ${toolsList}.\n</system_note>\n`;
processedQueryParts.push({ text: agentNudge });
}
+2 -2
View File
@@ -83,7 +83,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
const candidate = content.substring(0, mid);
const candidateWidth = getPlainTextLength(candidate);
if (candidateWidth <= contentWidth - 3) {
if (candidateWidth <= contentWidth - 1) {
bestTruncated = candidate;
left = mid + 1;
} else {
@@ -91,7 +91,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
}
}
cellContent = bestTruncated + '...';
cellContent = bestTruncated + '';
}
}
@@ -57,7 +57,7 @@ exports[`TableRenderer > renders a simple table correctly 1`] = `
exports[`TableRenderer > renders a table with long headers and 4 columns correctly 1`] = `
"
┌──────────────────┬──────────────────┬───────────────────┬──────────────────┐
│ Very Long Col... │ Very Long Col... │ Very Long Colu... │ Very Long Col...
│ Very Long Colum… │ Very Long Colum… │ Very Long Column… │ Very Long Colum…
├──────────────────┼──────────────────┼───────────────────┼──────────────────┤
│ Data 1.1 │ Data 1.2 │ Data 1.3 │ Data 1.4 │
│ Data 2.1 │ Data 2.2 │ Data 2.3 │ Data 2.4 │
@@ -44,7 +44,7 @@ describe('Admin Controls', () => {
describe('sanitizeAdminSettings', () => {
it('should strip unknown fields', () => {
const input = {
secureModeEnabled: true,
strictModeDisabled: false,
extraField: 'should be removed',
mcpSetting: {
mcpEnabled: false,
@@ -55,7 +55,7 @@ describe('Admin Controls', () => {
const result = sanitizeAdminSettings(input);
expect(result).toEqual({
secureModeEnabled: true,
strictModeDisabled: false,
mcpSetting: {
mcpEnabled: false,
},
@@ -104,7 +104,7 @@ describe('Admin Controls', () => {
});
it('should use cachedSettings and start polling if provided', async () => {
const cachedSettings = { secureModeEnabled: true };
const cachedSettings = { strictModeDisabled: false };
const result = await fetchAdminControls(
mockServer,
cachedSettings,
@@ -117,7 +117,7 @@ describe('Admin Controls', () => {
// Should still start polling
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
secureModeEnabled: false,
strictModeDisabled: true,
});
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
@@ -136,7 +136,7 @@ describe('Admin Controls', () => {
});
it('should fetch from server if no cachedSettings provided', async () => {
const serverResponse = { secureModeEnabled: true };
const serverResponse = { strictModeDisabled: false };
(mockServer.fetchAdminControls as Mock).mockResolvedValue(serverResponse);
const result = await fetchAdminControls(
@@ -164,7 +164,7 @@ describe('Admin Controls', () => {
// Polling should have been started and should retry
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
secureModeEnabled: true,
strictModeDisabled: false,
});
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(2); // Initial + poll
@@ -191,7 +191,7 @@ describe('Admin Controls', () => {
it('should sanitize server response', async () => {
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
secureModeEnabled: true,
strictModeDisabled: false,
unknownField: 'bad',
});
@@ -201,7 +201,7 @@ describe('Admin Controls', () => {
true,
mockOnSettingsChanged,
);
expect(result).toEqual({ secureModeEnabled: true });
expect(result).toEqual({ strictModeDisabled: false });
expect(
(result as Record<string, unknown>)['unknownField'],
).toBeUndefined();
@@ -245,7 +245,7 @@ describe('Admin Controls', () => {
it('should poll and emit changes', async () => {
// Initial fetch
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
secureModeEnabled: false,
strictModeDisabled: true,
});
await fetchAdminControls(
mockServer,
@@ -256,19 +256,19 @@ describe('Admin Controls', () => {
// Update for next poll
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
secureModeEnabled: true,
strictModeDisabled: false,
});
// Fast forward
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
expect(mockOnSettingsChanged).toHaveBeenCalledWith({
secureModeEnabled: true,
strictModeDisabled: false,
});
});
it('should NOT emit if settings are deeply equal but not the same instance', async () => {
const settings = { secureModeEnabled: true };
const settings = { strictModeDisabled: false };
(mockServer.fetchAdminControls as Mock).mockResolvedValue(settings);
await fetchAdminControls(
@@ -282,7 +282,7 @@ describe('Admin Controls', () => {
// Next poll returns a different object with the same values
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
secureModeEnabled: true,
strictModeDisabled: false,
});
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
@@ -293,7 +293,7 @@ describe('Admin Controls', () => {
it('should continue polling after a fetch error', async () => {
// Initial fetch is successful
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
secureModeEnabled: false,
strictModeDisabled: true,
});
await fetchAdminControls(
mockServer,
@@ -313,19 +313,19 @@ describe('Admin Controls', () => {
// Subsequent poll succeeds with new data
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
secureModeEnabled: true,
strictModeDisabled: false,
});
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(3);
expect(mockOnSettingsChanged).toHaveBeenCalledWith({
secureModeEnabled: true,
strictModeDisabled: false,
});
});
it('should STOP polling if server returns 403', async () => {
// Initial fetch is successful
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
secureModeEnabled: false,
strictModeDisabled: true,
});
await fetchAdminControls(
mockServer,
+2
View File
@@ -317,7 +317,9 @@ const McpSettingSchema = z.object({
});
export const FetchAdminControlsResponseSchema = z.object({
// TODO: deprecate once backend stops sending this field
secureModeEnabled: z.boolean().optional(),
strictModeDisabled: z.boolean().optional(),
mcpSetting: McpSettingSchema.optional(),
cliFeatureSetting: CliFeatureSettingSchema.optional(),
});
@@ -25,7 +25,3 @@
# 50: Read-only tools (becomes 1.050 in default tier)
# 999: YOLO mode allow-all (becomes 1.999 in default tier)
[[rule]]
toolName = "delegate_to_agent"
decision = "ask_user"
priority = 50
@@ -283,6 +283,25 @@ describe('GitService', () => {
expect.stringContaining('checkIsRepo failed'),
);
});
it('should configure git environment to use local gitconfig', async () => {
hoistedMockCheckIsRepo.mockResolvedValue(false);
const service = new GitService(projectRoot, storage);
await service.setupShadowGitRepository();
expect(hoistedMockEnv).toHaveBeenCalledWith(
expect.objectContaining({
GIT_CONFIG_GLOBAL: gitConfigPath,
GIT_CONFIG_SYSTEM: path.join(repoDir, '.gitconfig_system_empty'),
}),
);
const systemConfigContent = await fs.readFile(
path.join(repoDir, '.gitconfig_system_empty'),
'utf-8',
);
expect(systemConfigContent).toBe('');
});
});
describe('createFileSnapshot', () => {
+14 -4
View File
@@ -51,6 +51,16 @@ export class GitService {
}
}
private getShadowRepoEnv(repoDir: string) {
const gitConfigPath = path.join(repoDir, '.gitconfig');
const systemConfigPath = path.join(repoDir, '.gitconfig_system_empty');
return {
// Prevent git from using the user's global git config.
GIT_CONFIG_GLOBAL: gitConfigPath,
GIT_CONFIG_SYSTEM: systemConfigPath,
};
}
/**
* Creates a hidden git repository in the project root.
* The Git repository is used to support checkpointing.
@@ -67,7 +77,9 @@ export class GitService {
'[user]\n name = Gemini CLI\n email = gemini-cli@google.com\n[commit]\n gpgsign = false\n';
await fs.writeFile(gitConfigPath, gitConfigContent);
const repo = simpleGit(repoDir);
const shadowRepoEnv = this.getShadowRepoEnv(repoDir);
await fs.writeFile(shadowRepoEnv.GIT_CONFIG_SYSTEM, '');
const repo = simpleGit(repoDir).env(shadowRepoEnv);
let isRepoDefined = false;
try {
isRepoDefined = await repo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
@@ -107,9 +119,7 @@ export class GitService {
return simpleGit(this.projectRoot).env({
GIT_DIR: path.join(repoDir, '.git'),
GIT_WORK_TREE: this.projectRoot,
// Prevent git from using the user's global git config.
HOME: repoDir,
XDG_CONFIG_HOME: repoDir,
...this.getShadowRepoEnv(repoDir),
});
}
+11
View File
@@ -0,0 +1,11 @@
diff --git a/node_modules/whatwg-url/lib/url-state-machine.js b/node_modules/whatwg-url/lib/url-state-machine.js
index 27d977a..2f5633a 100644
--- a/node_modules/whatwg-url/lib/url-state-machine.js
+++ b/node_modules/whatwg-url/lib/url-state-machine.js
@@ -1,5 +1,5 @@
"use strict";
-const punycode = require("punycode");
+const punycode = require("punycode/"); // Trailing slash ensures userland package usage
const tr46 = require("tr46");
const specialSchemes = {