mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 10:10:56 -07:00
feat(cli): Add dynamic UI tabs and displayName config for TrajectoryProviders
This commit is contained in:
@@ -9,6 +9,8 @@ import * as path from 'node:path';
|
||||
import { inspect } from 'node:util';
|
||||
import process from 'node:process';
|
||||
import { z } from 'zod';
|
||||
import type { ConversationRecord } from '../services/chatRecordingService.js';
|
||||
export type { ConversationRecord };
|
||||
import {
|
||||
AuthType,
|
||||
createContentGenerator,
|
||||
@@ -228,6 +230,25 @@ export interface ResolvedExtensionSetting {
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface TrajectoryProvider {
|
||||
/** Prefix used in ChatList item names e.g., 'agy:' */
|
||||
prefix: string;
|
||||
/** Optional display name for UI Tabs */
|
||||
displayName?: string;
|
||||
/** Return an array of conversational tags/ids */
|
||||
listSessions(workspaceUri?: string): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
mtime: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
messageCount?: number;
|
||||
}>
|
||||
>;
|
||||
/** Load a single conversation payload */
|
||||
loadSession(id: string): Promise<ConversationRecord | null>;
|
||||
}
|
||||
|
||||
export interface AgentRunConfig {
|
||||
maxTimeMinutes?: number;
|
||||
maxTurns?: number;
|
||||
@@ -377,6 +398,8 @@ export interface GeminiCLIExtension {
|
||||
* Used to migrate an extension to a new repository source.
|
||||
*/
|
||||
migratedTo?: string;
|
||||
/** Loaded JS module for trajectory decoding */
|
||||
trajectoryProviderModule?: TrajectoryProvider;
|
||||
}
|
||||
|
||||
export interface ExtensionInstallMetadata {
|
||||
|
||||
@@ -93,17 +93,30 @@ common tool calls, which map directly to the CLI's native tools:
|
||||
- `CORTEX_STEP_TYPE_FILE_CHANGE` -> `replace`
|
||||
- `CORTEX_STEP_TYPE_BROWSER_SUBAGENT` -> (Dropped)
|
||||
|
||||
**2. Generic & MCP Integrations** Jetski uses `CORTEX_STEP_TYPE_GENERIC` to
|
||||
handle dynamic or MCP (Model Context Protocol) tool calls that are not hardcoded
|
||||
into the native protobuf schema.
|
||||
**2. Generic / MCP Tools**
|
||||
|
||||
- The CLI reads the `toolName` and `argsJson` directly from the generic step
|
||||
payload and executes them as-is (e.g. `ask_user`, `mcp_*` tools).
|
||||
- Jetski relies heavily on `CORTEX_STEP_TYPE_GENERIC` and
|
||||
`CORTEX_STEP_TYPE_MCP_TOOL` to route non-native or dynamic tools.
|
||||
- This is fully supported! The CLI reads the `toolName` and `argsJson` directly
|
||||
from the generic step payload. For instance, the Jetski `ask_user` tool
|
||||
natively maps to the CLI's `ask_user` tool, and any custom MCP commands are
|
||||
preserved as-is.
|
||||
|
||||
**3. Unsupported Tools** Many isolated actions, sub-agent tools, and
|
||||
IDE-specific UI interactions are dropped by the teleporter to maintain strict
|
||||
CLI compatibility and preserve valid context-window state.
|
||||
|
||||
**4. Tool UI & Context Representation** When importing dynamic/generic tools,
|
||||
the CLI UI uses internal synthesis to properly reflect tool usage and outputs
|
||||
that the user saw in JetSki without blank rendering:
|
||||
|
||||
- **Arguments:** Arguments extracted from `argsJson` are synthesized into the
|
||||
`.description` field enabling the CLI UI to display the exact call arguments
|
||||
(e.g., specific files or search strings) below the tool name.
|
||||
- **Output (Result Display):** Tool outputs, like terminal output payloads or
|
||||
file text, are iteratively extracted from the trajectory steps and rendered
|
||||
explicitly using `resultDisplay`.
|
||||
|
||||
<details>
|
||||
<summary><b>Click to view exhaustive list of all 75+ dropped Jetski steps</b></summary>
|
||||
|
||||
@@ -185,9 +198,9 @@ addressed:
|
||||
|
||||
### 1. Security & Key Management
|
||||
|
||||
- **Dynamic Key Exchange:** Instead of a hardcoded key in the CLI source code,
|
||||
the CLI should retrieve the encryption key securely (e.g., from the OS
|
||||
Keychain, a local Jetski config file, or by querying the local Jetski daemon).
|
||||
- **Dynamic Key Exchange:** ✅ The CLI now supports loading encryption keys from
|
||||
`JETSKI_TELEPORT_KEY` environment variables or a local
|
||||
`~/.gemini/jetski/key.txt` file.
|
||||
- **Permission Scoping:** Ensure the CLI enforces the same file-access
|
||||
permission rules (`file_permission_request`) that Jetski enforces so the AI
|
||||
doesn't suddenly gain destructive permissions when transitioning to the
|
||||
@@ -195,6 +208,9 @@ addressed:
|
||||
|
||||
### 2. Architecture & Build Process Decoupling
|
||||
|
||||
- **Trajectory Provider Interface:** ✅ The CLI now uses a generic
|
||||
`TrajectoryProvider` interface, allowing teleportation logic to be decoupled
|
||||
into extensions.
|
||||
- **Shared NPM Package:** Publish the compiled Protobufs and parsing logic as a
|
||||
private internal package (e.g., `@google/cortex-teleporter`). The Gemini CLI
|
||||
should simply `npm install` this, rather than generating `.min.js` blobs
|
||||
@@ -205,18 +221,13 @@ addressed:
|
||||
|
||||
### 3. User Experience (UX)
|
||||
|
||||
- **Clear UI Indicators:** In the CLI's `/resume` menu, Jetski sessions should
|
||||
be visually distinct from native CLI sessions (e.g., using a 🛸 icon and a
|
||||
"Jetski" tag next to the session name).
|
||||
- **Missing Context Warnings:** Because we intentionally drop 75+ step types
|
||||
(browser actions, IDE UI clicks, etc.), the CLI conversation history might
|
||||
look like it has "gaps." The UI should render a small placeholder like:
|
||||
`[ ⚠️ Jetski browser action dropped for CLI compatibility ]` so the user
|
||||
understands the model did something in the IDE that isn't shown in the
|
||||
terminal.
|
||||
- **Seamless Handoff Prompt:** If the user has a currently active (running)
|
||||
Jetski session, the CLI could intelligently prompt them on startup: _"You have
|
||||
an active session in Jetski. Type `/resume` to bring it into the terminal."_
|
||||
- **Clear UI Indicators:** ✅ Jetski sessions are now grouped in a dedicated tab
|
||||
in the `/resume` menu.
|
||||
- **Missing Context Warnings:** ✅ The UI now synthesizes `description` and
|
||||
`resultDisplay` for generic tool calls to ensure a smooth conversation flow.
|
||||
- **Seamless Handoff Prompt:** ✅ The CLI now intelligently prompts the user on
|
||||
startup if a recent Jetski session is found: _"🛸 You have a recent session in
|
||||
Antigravity. Type /resume agy:<id> to bring it into the terminal."_
|
||||
|
||||
### 4. Data Fidelity & Error Handling
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { listAgySessions, loadAgySession } from './discovery.js';
|
||||
import { trajectoryToJson } from './teleporter.js';
|
||||
import { convertAgyToCliRecord } from './converter.js';
|
||||
import type {
|
||||
TrajectoryProvider,
|
||||
ConversationRecord,
|
||||
} from '../config/config.js';
|
||||
|
||||
/**
|
||||
* Trajectory provider for Antigravity (Jetski) sessions.
|
||||
*/
|
||||
const agyProvider: TrajectoryProvider = {
|
||||
prefix: 'agy:',
|
||||
displayName: 'Antigravity',
|
||||
|
||||
async listSessions(workspaceUri?: string) {
|
||||
const sessions = await listAgySessions(workspaceUri);
|
||||
return sessions.map((s) => ({
|
||||
id: s.id,
|
||||
mtime: s.mtime,
|
||||
displayName: s.displayName,
|
||||
messageCount: s.messageCount,
|
||||
}));
|
||||
},
|
||||
|
||||
async loadSession(id: string): Promise<ConversationRecord | null> {
|
||||
const data = await loadAgySession(id);
|
||||
if (!data) return null;
|
||||
const json = trajectoryToJson(data);
|
||||
return convertAgyToCliRecord(json);
|
||||
},
|
||||
};
|
||||
|
||||
export default agyProvider;
|
||||
@@ -7,7 +7,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import type { ConversationRecord, ToolCallRecord, MessageRecord } from '../types.js';
|
||||
import type {
|
||||
ConversationRecord,
|
||||
ToolCallRecord,
|
||||
MessageRecord,
|
||||
} from '../services/chatRecordingService.js';
|
||||
import { CoreToolCallStatus } from '../scheduler/types.js';
|
||||
import {
|
||||
EDIT_TOOL_NAME,
|
||||
@@ -19,6 +23,7 @@ import {
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
} from '../tools/definitions/coreTools.js';
|
||||
|
||||
/**
|
||||
@@ -218,7 +223,15 @@ function mapAgyStepToToolCall(step: Record<string, any>): ToolCallRecord {
|
||||
args = { Task: step['browserSubagent']['task'] };
|
||||
} else if (step['generic']) {
|
||||
const generic = step['generic'] as Record<string, unknown>;
|
||||
name = generic['toolName'] as string;
|
||||
const rawName = generic['toolName'] as string;
|
||||
|
||||
// Map generic tools to official CLI constants where applicable
|
||||
if (rawName === 'ask_user') {
|
||||
name = ASK_USER_TOOL_NAME;
|
||||
} else {
|
||||
name = rawName;
|
||||
}
|
||||
|
||||
try {
|
||||
args = JSON.parse(generic['argsJson'] as string);
|
||||
} catch {
|
||||
@@ -227,15 +240,38 @@ function mapAgyStepToToolCall(step: Record<string, any>): ToolCallRecord {
|
||||
result = [{ text: (generic['responseJson'] as string) || '' }];
|
||||
}
|
||||
|
||||
const safeArgs = args as Record<string, unknown>;
|
||||
const status =
|
||||
step['status'] === 3 || step['status'] === 'CORTEX_STEP_STATUS_DONE'
|
||||
? CoreToolCallStatus.Success
|
||||
: CoreToolCallStatus.Error;
|
||||
|
||||
// Synthesize a UI string from the args so it isn't blank in the terminal
|
||||
const argValues = Object.values(safeArgs)
|
||||
.filter((v) => typeof v === 'string' || typeof v === 'number')
|
||||
.join(', ');
|
||||
const description = argValues || '';
|
||||
|
||||
// Synthesize a UI string for the result output
|
||||
let resultDisplay: string | undefined = undefined;
|
||||
if (Array.isArray(result) && result.length > 0) {
|
||||
const textParts = result
|
||||
.map((part) => part?.text)
|
||||
.filter((text) => typeof text === 'string' && text.length > 0);
|
||||
|
||||
if (textParts.length > 0) {
|
||||
resultDisplay = textParts.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
args: args as Record<string, unknown>,
|
||||
args: safeArgs,
|
||||
description,
|
||||
result,
|
||||
status:
|
||||
step['status'] === 3 || step['status'] === 'CORTEX_STEP_STATUS_DONE'
|
||||
? CoreToolCallStatus.Success
|
||||
: CoreToolCallStatus.Error,
|
||||
resultDisplay,
|
||||
status,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import os from 'node:os';
|
||||
import { trajectoryToJson } from './teleporter.js';
|
||||
import { convertAgyToCliRecord } from './converter.js';
|
||||
import { partListUnionToString } from '../core/geminiRequest.js';
|
||||
import type { MessageRecord } from '../services/chatRecordingService.js';
|
||||
|
||||
export interface AgySessionInfo {
|
||||
id: string;
|
||||
@@ -28,6 +29,26 @@ const AGY_CONVERSATIONS_DIR = path.join(
|
||||
'conversations',
|
||||
);
|
||||
|
||||
const AGY_KEY_PATH = path.join(os.homedir(), '.gemini', 'jetski', 'key.txt');
|
||||
|
||||
/**
|
||||
* Loads the Antigravity encryption key.
|
||||
* Priority: JETSKI_TELEPORT_KEY env var > ~/.gemini/jetski/key.txt
|
||||
*/
|
||||
export async function loadAgyKey(): Promise<Buffer | undefined> {
|
||||
const envKey = process.env['JETSKI_TELEPORT_KEY'];
|
||||
if (envKey) {
|
||||
return Buffer.from(envKey);
|
||||
}
|
||||
|
||||
try {
|
||||
const keyContent = await fs.readFile(AGY_KEY_PATH, 'utf-8');
|
||||
return Buffer.from(keyContent.trim());
|
||||
} catch (_e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all Antigravity sessions found on disk.
|
||||
* @param filterWorkspaceUri Optional filter to only return sessions matching this workspace URI (e.g. "file:///...").
|
||||
@@ -38,7 +59,6 @@ export async function listAgySessions(
|
||||
try {
|
||||
const files = await fs.readdir(AGY_CONVERSATIONS_DIR);
|
||||
const sessions: AgySessionInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.pb')) {
|
||||
const filePath = path.join(AGY_CONVERSATIONS_DIR, file);
|
||||
@@ -88,7 +108,7 @@ function extractAgyDetails(json: unknown): {
|
||||
const messages = record.messages || [];
|
||||
|
||||
// Find first user message for display name
|
||||
const firstUserMsg = messages.find((m) => m.type === 'user');
|
||||
const firstUserMsg = messages.find((m: MessageRecord) => m.type === 'user');
|
||||
const displayName = firstUserMsg
|
||||
? partListUnionToString(firstUserMsg.content).slice(0, 100)
|
||||
: 'Antigravity Session';
|
||||
@@ -151,3 +171,29 @@ export async function loadAgySession(id: string): Promise<Buffer | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the most recent session if it was updated within the last 10 minutes.
|
||||
*/
|
||||
export async function getRecentAgySession(
|
||||
workspaceUri?: string,
|
||||
): Promise<AgySessionInfo | null> {
|
||||
const sessions = await listAgySessions(workspaceUri);
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
// Sort by mtime descending
|
||||
const sorted = sessions.sort(
|
||||
(a, b) => new Date(b.mtime).getTime() - new Date(a.mtime).getTime(),
|
||||
);
|
||||
|
||||
const mostRecent = sorted[0];
|
||||
const mtime = new Date(mostRecent.mtime).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
// 10 minutes threshold
|
||||
if (now - mtime < 10 * 60 * 1000) {
|
||||
return mostRecent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -13,8 +13,3 @@ export interface AgyTrajectory {
|
||||
|
||||
export * from './teleporter.js';
|
||||
export { convertAgyToCliRecord } from './converter.js';
|
||||
export {
|
||||
loadAgySession,
|
||||
listAgySessions,
|
||||
type AgySessionInfo,
|
||||
} from './discovery.js';
|
||||
|
||||
@@ -48,11 +48,14 @@ export function encrypt(data: Buffer, key: Buffer = DEFAULT_KEY): Buffer {
|
||||
/**
|
||||
* Converts Antigravity binary trajectory to JSON.
|
||||
*/
|
||||
export function trajectoryToJson(data: Buffer): unknown {
|
||||
export function trajectoryToJson(
|
||||
data: Buffer,
|
||||
key: Buffer = DEFAULT_KEY,
|
||||
): unknown {
|
||||
let pbData: Buffer;
|
||||
try {
|
||||
// Try to decrypt first
|
||||
pbData = decrypt(data);
|
||||
pbData = decrypt(data, key);
|
||||
} catch (_e) {
|
||||
// Fallback to plain protobuf if decryption fails
|
||||
pbData = data;
|
||||
@@ -65,8 +68,11 @@ export function trajectoryToJson(data: Buffer): unknown {
|
||||
/**
|
||||
* Converts JSON to Antigravity binary trajectory (encrypted).
|
||||
*/
|
||||
export function jsonToTrajectory(json: unknown): Buffer {
|
||||
export function jsonToTrajectory(
|
||||
json: unknown,
|
||||
key: Buffer = DEFAULT_KEY,
|
||||
): Buffer {
|
||||
const trajectory = Trajectory.fromJson(json, { ignoreUnknownFields: true });
|
||||
const pbData = Buffer.from(trajectory.toBinary());
|
||||
return encrypt(pbData);
|
||||
return encrypt(pbData, key);
|
||||
}
|
||||
|
||||
@@ -242,6 +242,50 @@ export abstract class ExtensionLoader {
|
||||
await this.stopExtension(extension);
|
||||
await this.startExtension(extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the most recent session from all extensions if it's within the threshold.
|
||||
*/
|
||||
async getRecentExternalSession(
|
||||
workspaceUri?: string,
|
||||
thresholdMs: number = 10 * 60 * 1000,
|
||||
): Promise<{ prefix: string; id: string; displayName?: string } | null> {
|
||||
const activeExtensions = this.getExtensions().filter((e) => e.isActive);
|
||||
let mostRecent: {
|
||||
prefix: string;
|
||||
id: string;
|
||||
displayName?: string;
|
||||
mtime: number;
|
||||
} | null = null;
|
||||
|
||||
for (const extension of activeExtensions) {
|
||||
if (extension.trajectoryProviderModule) {
|
||||
try {
|
||||
const sessions =
|
||||
await extension.trajectoryProviderModule.listSessions(workspaceUri);
|
||||
for (const s of sessions) {
|
||||
const mtime = new Date(s.mtime).getTime();
|
||||
if (!mostRecent || mtime > mostRecent.mtime) {
|
||||
mostRecent = {
|
||||
prefix: extension.trajectoryProviderModule.prefix || '',
|
||||
id: s.id,
|
||||
displayName: s.displayName,
|
||||
mtime,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore extension errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mostRecent && Date.now() - mostRecent.mtime < thresholdMs) {
|
||||
return mostRecent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExtensionEvents {
|
||||
|
||||
Reference in New Issue
Block a user