feat(cli): Add dynamic UI tabs and displayName config for TrajectoryProviders

This commit is contained in:
Sehoon Shon
2026-03-09 13:55:36 -04:00
parent 9b15e410a8
commit c69bf4a4b4
15 changed files with 467 additions and 133 deletions
@@ -51,6 +51,7 @@ import {
type HookDefinition, type HookDefinition,
type HookEventName, type HookEventName,
type ResolvedExtensionSetting, type ResolvedExtensionSetting,
type TrajectoryProvider,
coreEvents, coreEvents,
applyAdminAllowlist, applyAdminAllowlist,
getAdminBlockedMcpServersMessage, getAdminBlockedMcpServersMessage,
@@ -957,6 +958,23 @@ Would you like to attempt to install via "git clone" instead?`,
); );
} }
let trajectoryProviderModule: TrajectoryProvider | undefined;
if (config.trajectoryProvider) {
try {
const expectedPath = path.resolve(
effectiveExtensionPath,
config.trajectoryProvider,
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
trajectoryProviderModule = (await import(expectedPath))
.default as TrajectoryProvider;
} catch (e) {
debugLogger.warn(
`Failed to import trajectoryProvider at ${config.trajectoryProvider} for extension ${config.name}: ${getErrorMessage(e)}`,
);
}
}
return { return {
name: config.name, name: config.name,
version: config.version, version: config.version,
@@ -980,6 +998,7 @@ Would you like to attempt to install via "git clone" instead?`,
rules, rules,
checkers, checkers,
plan: config.plan, plan: config.plan,
trajectoryProviderModule,
}; };
} catch (e) { } catch (e) {
const extName = path.basename(extensionDir); const extName = path.basename(extensionDir);
+5
View File
@@ -46,6 +46,11 @@ export interface ExtensionConfig {
* Used to migrate an extension to a new repository source. * Used to migrate an extension to a new repository source.
*/ */
migratedTo?: string; migratedTo?: string;
/**
* Path to a module that implements the TrajectoryProvider interface.
* Used for importing binary chat histories like Jetski Teleportation.
*/
trajectoryProvider?: string;
} }
export interface ExtensionUpdateInfo { export interface ExtensionUpdateInfo {
+26
View File
@@ -19,12 +19,15 @@ import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js'; import { validateTheme } from './theme.js';
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js'; import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
import { pathToFileURL } from 'node:url';
export interface InitializationResult { export interface InitializationResult {
authError: string | null; authError: string | null;
accountSuspensionInfo: AccountSuspensionInfo | null; accountSuspensionInfo: AccountSuspensionInfo | null;
themeError: string | null; themeError: string | null;
shouldOpenAuthDialog: boolean; shouldOpenAuthDialog: boolean;
geminiMdFileCount: number; geminiMdFileCount: number;
recentExternalSession?: { prefix: string; id: string; displayName?: string };
} }
/** /**
@@ -60,11 +63,34 @@ export async function initializeApp(
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START)); logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
} }
// Check for recent external sessions to prompt for resume
let recentExternalSession:
| { prefix: string; id: string; displayName?: string }
| undefined;
if (config.getEnableExtensionReloading() !== false) {
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
const extensionLoader = (config as any)._extensionLoader;
if (extensionLoader) {
const workspaceUri = pathToFileURL(process.cwd()).toString();
const recent =
await extensionLoader.getRecentExternalSession(workspaceUri);
if (recent) {
recentExternalSession = {
prefix: recent.prefix,
id: recent.id,
displayName: recent.displayName,
};
}
}
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
}
return { return {
authError, authError,
accountSuspensionInfo, accountSuspensionInfo,
themeError, themeError,
shouldOpenAuthDialog, shouldOpenAuthDialog,
geminiMdFileCount: config.getGeminiMdFileCount(), geminiMdFileCount: config.getGeminiMdFileCount(),
recentExternalSession,
}; };
} }
+13
View File
@@ -469,6 +469,19 @@ export const AppContainer = (props: AppContainerProps) => {
generateSummary(config).catch((e) => { generateSummary(config).catch((e) => {
debugLogger.warn('Background summary generation failed:', e); debugLogger.warn('Background summary generation failed:', e);
}); });
// Show handoff prompt for recent external sessions
if (initializationResult.recentExternalSession) {
const { prefix, id, displayName } =
initializationResult.recentExternalSession;
historyManager.addItem(
{
type: MessageType.INFO,
text: `🛸 You have a recent session in ${displayName || 'an external tool'}. Type "/resume ${prefix}${id}" to bring it into the terminal.`,
},
Date.now(),
);
}
})(); })();
registerCleanup(async () => { registerCleanup(async () => {
// Turn off mouse scroll. // Turn off mouse scroll.
+44 -40
View File
@@ -7,7 +7,6 @@
import * as fsPromises from 'node:fs/promises'; import * as fsPromises from 'node:fs/promises';
import React from 'react'; import React from 'react';
import { Text } from 'ink'; import { Text } from 'ink';
import { pathToFileURL } from 'node:url';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import type { Content, Part } from '@google/genai'; import type { Content, Part } from '@google/genai';
import type { import type {
@@ -20,10 +19,7 @@ import {
decodeTagName, decodeTagName,
type MessageActionReturn, type MessageActionReturn,
INITIAL_HISTORY_LENGTH, INITIAL_HISTORY_LENGTH,
listAgySessions, type ConversationRecord,
loadAgySession,
trajectoryToJson,
convertAgyToCliRecord,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import path from 'node:path'; import path from 'node:path';
import type { import type {
@@ -70,25 +66,6 @@ const getSavedChatTags = async (
: a.mtime.localeCompare(b.mtime), : a.mtime.localeCompare(b.mtime),
); );
// Also look for Antigravity sessions matching the current workspace
const workspaceUri = pathToFileURL(process.cwd()).toString();
const agySessions = await listAgySessions(workspaceUri);
for (const agy of agySessions) {
chatDetails.push({
name: `agy:${agy.id}`,
mtime: agy.mtime,
});
}
// Re-sort if we added AGY sessions
if (agySessions.length > 0) {
chatDetails.sort((a, b) =>
mtSortDesc
? b.mtime.localeCompare(a.mtime)
: a.mtime.localeCompare(b.mtime),
);
}
return chatDetails; return chatDetails;
} catch (_err) { } catch (_err) {
return []; return [];
@@ -203,11 +180,32 @@ const resumeCheckpointCommand: SlashCommand = {
let conversation: Content[] = []; let conversation: Content[] = [];
let authType: string | undefined; let authType: string | undefined;
const loadAgy = async (id: string): Promise<Content[] | null> => { const loadExternalTrajectory = async (
const data = await loadAgySession(id); prefix: string,
if (!data) return null; id: string,
const agyJson = trajectoryToJson(data); ): Promise<Content[] | null> => {
const record = convertAgyToCliRecord(agyJson); let record: ConversationRecord | null = null;
if (config && config.getEnableExtensionReloading() !== false) {
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
const extensions = (config as any)._extensionLoader?.getExtensions
? (config as any)._extensionLoader.getExtensions()
: [];
for (const extension of extensions) {
if (
extension.trajectoryProviderModule &&
extension.trajectoryProviderModule.prefix === prefix
) {
try {
record = await extension.trajectoryProviderModule.loadSession(id);
if (record) break;
} catch (_err) {
// Ignore failure
}
}
}
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
}
if (!record) return null;
const conv: Content[] = []; const conv: Content[] = [];
// Add a dummy system message so slice(INITIAL_HISTORY_LENGTH) works correctly // Add a dummy system message so slice(INITIAL_HISTORY_LENGTH) works correctly
@@ -245,7 +243,10 @@ const resumeCheckpointCommand: SlashCommand = {
conv.push({ role: 'model', parts: modelParts }); conv.push({ role: 'model', parts: modelParts });
// 2. User turn: Function Responses (if any) // 2. User turn: Function Responses (if any)
if (m.toolCalls && m.toolCalls.some((tc) => tc.result)) { if (
m.toolCalls &&
m.toolCalls.some((tc: { result?: unknown }) => tc.result)
) {
const responseParts: Part[] = []; const responseParts: Part[] = [];
for (const tc of m.toolCalls) { for (const tc of m.toolCalls) {
if (tc.result) { if (tc.result) {
@@ -267,27 +268,30 @@ const resumeCheckpointCommand: SlashCommand = {
return conv; return conv;
}; };
if (tag.startsWith('agy:')) { // Check if tag format is prefix:id
const id = tag.slice(4); const prefixMatch = tag.match(/^([a-zA-Z0-9_]+):(.*)$/);
const agyConv = await loadAgy(id); if (prefixMatch) {
if (!agyConv) { const prefix = prefixMatch[1] + ':';
const id = prefixMatch[2];
const externalConv = await loadExternalTrajectory(prefix, id);
if (!externalConv) {
return { return {
type: 'message', type: 'message',
messageType: 'error', messageType: 'error',
content: `No Antigravity session found with id: ${id}`, content: `No external session found with prefix ${prefix} and id: ${id}`,
}; };
} }
conversation = agyConv; conversation = externalConv;
} else { } else {
const checkpoint = await logger.loadCheckpoint(tag); const checkpoint = await logger.loadCheckpoint(tag);
if (checkpoint.history.length > 0) { if (checkpoint.history.length > 0) {
conversation = checkpoint.history; conversation = checkpoint.history;
authType = checkpoint.authType; authType = checkpoint.authType;
} else { } else {
// Fallback: Try to load as AGY session even without prefix // Fallback: Try to load as AGY session even without prefix just in case (legacy support)
const agyConv = await loadAgy(tag); const legacyConv = await loadExternalTrajectory('agy:', tag);
if (agyConv) { if (legacyConv) {
conversation = agyConv; conversation = legacyConv;
} }
} }
} }
@@ -13,11 +13,7 @@ import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
import path from 'node:path'; import path from 'node:path';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { import { type Config } from '@google/gemini-cli-core';
listAgySessions,
type Config,
type AgySessionInfo,
} from '@google/gemini-cli-core';
import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js'; import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js';
import { import {
formatRelativeTime, formatRelativeTime,
@@ -48,8 +44,8 @@ export interface SessionBrowserState {
// Data state // Data state
/** All loaded sessions */ /** All loaded sessions */
sessions: SessionInfo[]; sessions: SessionInfo[];
/** Antigravity sessions */ /** Extension tabs */
agySessions: SessionInfo[]; extensionTabs: Array<{ name: string; sessions: SessionInfo[] }>;
/** Sessions after filtering and sorting */ /** Sessions after filtering and sorting */
filteredAndSortedSessions: SessionInfo[]; filteredAndSortedSessions: SessionInfo[];
@@ -94,8 +90,10 @@ export interface SessionBrowserState {
// State setters // State setters
/** Update sessions array */ /** Update sessions array */
setSessions: React.Dispatch<React.SetStateAction<SessionInfo[]>>; setSessions: React.Dispatch<React.SetStateAction<SessionInfo[]>>;
/** Update agySessions array */ /** Update extensionTabs array */
setAgySessions: React.Dispatch<React.SetStateAction<SessionInfo[]>>; setExtensionTabs: React.Dispatch<
React.SetStateAction<Array<{ name: string; sessions: SessionInfo[] }>>
>;
/** Update loading state */ /** Update loading state */
setLoading: React.Dispatch<React.SetStateAction<boolean>>; setLoading: React.Dispatch<React.SetStateAction<boolean>>;
/** Update error state */ /** Update error state */
@@ -367,7 +365,9 @@ export const useSessionBrowserState = (
): SessionBrowserState => { ): SessionBrowserState => {
const { columns: terminalWidth } = useTerminalSize(); const { columns: terminalWidth } = useTerminalSize();
const [sessions, setSessions] = useState<SessionInfo[]>(initialSessions); const [sessions, setSessions] = useState<SessionInfo[]>(initialSessions);
const [agySessions, setAgySessions] = useState<SessionInfo[]>([]); const [extensionTabs, setExtensionTabs] = useState<
Array<{ name: string; sessions: SessionInfo[] }>
>([]);
const [loading, setLoading] = useState(initialLoading); const [loading, setLoading] = useState(initialLoading);
const [error, setError] = useState<string | null>(initialError); const [error, setError] = useState<string | null>(initialError);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
@@ -390,10 +390,11 @@ export const useSessionBrowserState = (
}, []); }, []);
const filteredAndSortedSessions = useMemo(() => { const filteredAndSortedSessions = useMemo(() => {
const currentTabSessions = activeTab === 0 ? sessions : agySessions; const currentTabSessions =
activeTab === 0 ? sessions : extensionTabs[activeTab - 1]?.sessions || [];
const filtered = filterSessions(currentTabSessions, searchQuery); const filtered = filterSessions(currentTabSessions, searchQuery);
return sortSessions(filtered, sortOrder, sortReverse); return sortSessions(filtered, sortOrder, sortReverse);
}, [sessions, agySessions, activeTab, searchQuery, sortOrder, sortReverse]); }, [sessions, extensionTabs, activeTab, searchQuery, sortOrder, sortReverse]);
// Reset full content flag when search is cleared // Reset full content flag when search is cleared
useEffect(() => { useEffect(() => {
@@ -411,8 +412,8 @@ export const useSessionBrowserState = (
const state: SessionBrowserState = { const state: SessionBrowserState = {
sessions, sessions,
setSessions, setSessions,
agySessions, extensionTabs,
setAgySessions, setExtensionTabs,
loading, loading,
setLoading, setLoading,
error, error,
@@ -445,21 +446,30 @@ export const useSessionBrowserState = (
}; };
/** /**
* Converts Antigravity session info to CLI SessionInfo format. * Converts Antigravity or external session info to CLI SessionInfo format.
*/ */
function convertAgyToSessionInfo( type ExternalSessionInfo = {
agy: AgySessionInfo, id: string;
mtime: string;
name?: string;
displayName?: string;
messageCount?: number;
prefix?: string;
};
function convertExternalToSessionInfo(
ext: ExternalSessionInfo,
index: number, index: number,
): SessionInfo { ): SessionInfo {
return { return {
id: agy.id, id: ext.prefix ? `${ext.prefix}${ext.id}` : ext.id,
file: agy.id, file: ext.id,
fileName: agy.id + '.pb', fileName: ext.id + '.ext',
startTime: agy.mtime, startTime: ext.mtime,
lastUpdated: agy.mtime, lastUpdated: ext.mtime,
messageCount: agy.messageCount || 0, messageCount: ext.messageCount || 0,
displayName: agy.displayName || 'Antigravity Session', displayName: ext.displayName || ext.name || 'External Session',
firstUserMessage: agy.displayName || '', firstUserMessage: ext.displayName || ext.name || '',
isCurrentSession: false, isCurrentSession: false,
index, index,
}; };
@@ -471,7 +481,7 @@ function convertAgyToSessionInfo(
const useLoadSessions = (config: Config, state: SessionBrowserState) => { const useLoadSessions = (config: Config, state: SessionBrowserState) => {
const { const {
setSessions, setSessions,
setAgySessions, setExtensionTabs,
setLoading, setLoading,
setError, setError,
isSearchMode, isSearchMode,
@@ -485,19 +495,55 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats'); const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
const workspaceUri = pathToFileURL(process.cwd()).toString(); const workspaceUri = pathToFileURL(process.cwd()).toString();
const [sessionData, agyData] = await Promise.all([ const externalTabs: Array<{ name: string; sessions: SessionInfo[] }> =
[];
if (config.getEnableExtensionReloading() !== false) {
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
const extensions = (config as any)._extensionLoader?.getExtensions
? (config as any)._extensionLoader.getExtensions()
: [];
for (const extension of extensions) {
if (extension.trajectoryProviderModule) {
try {
const sessions =
await extension.trajectoryProviderModule.listSessions(
workspaceUri,
);
const normalizedExt = sessions
.map((ext: any) => ({
...ext,
prefix: extension.trajectoryProviderModule.prefix,
}))
.sort(
(a: any, b: any) =>
new Date(a.mtime).getTime() - new Date(b.mtime).getTime(),
)
.map((ext: any, i: number) =>
convertExternalToSessionInfo(ext, i + 1),
);
if (normalizedExt.length > 0) {
externalTabs.push({
name:
extension.trajectoryProviderModule.displayName ||
extension.name,
sessions: normalizedExt,
});
}
} catch (_e) {
// Ignore loader errors
}
}
}
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
}
const [sessionData] = await Promise.all([
getSessionFiles(chatsDir, config.getSessionId()), getSessionFiles(chatsDir, config.getSessionId()),
listAgySessions(workspaceUri),
]); ]);
setSessions(sessionData); setSessions(sessionData);
setExtensionTabs(externalTabs);
const normalizedAgy = agyData
.sort(
(a, b) => new Date(a.mtime).getTime() - new Date(b.mtime).getTime(),
)
.map((agy, i) => convertAgyToSessionInfo(agy, i + 1));
setAgySessions(normalizedAgy);
setLoading(false); setLoading(false);
} catch (err) { } catch (err) {
@@ -510,7 +556,7 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
loadSessions(); loadSessions();
}, [config, setSessions, setAgySessions, setLoading, setError]); }, [config, setSessions, setExtensionTabs, setLoading, setError]);
useEffect(() => { useEffect(() => {
const loadFullContent = async () => { const loadFullContent = async () => {
@@ -750,8 +796,11 @@ export function SessionBrowserView({
state: SessionBrowserState; state: SessionBrowserState;
}): React.JSX.Element { }): React.JSX.Element {
const tabs: Tab[] = [ const tabs: Tab[] = [
{ key: 'cli', header: 'CLI Sessions' }, { key: 'cli', header: 'Gemini CLI' },
{ key: 'agy', header: 'Antigravity' }, ...state.extensionTabs.map((ext, i) => ({
key: `ext-${i}`,
header: ext.name,
})),
]; ];
if (state.loading) { if (state.loading) {
@@ -762,7 +811,7 @@ export function SessionBrowserView({
return <SessionBrowserError state={state} />; return <SessionBrowserError state={state} />;
} }
if (state.sessions.length === 0 && state.agySessions.length === 0) { if (state.sessions.length === 0 && state.extensionTabs.length === 0) {
return <SessionBrowserEmpty />; return <SessionBrowserEmpty />;
} }
@@ -802,8 +851,9 @@ export function SessionBrowser({
const cycleSortOrder = useCycleSortOrder(state); const cycleSortOrder = useCycleSortOrder(state);
useTabbedNavigation({ useTabbedNavigation({
tabCount: 2, tabCount: state.extensionTabs.length + 1,
isActive: !state.isSearchMode, isActive: !state.isSearchMode,
wrapAround: true,
onTabChange: (index) => state.setActiveTab(index), onTabChange: (index) => state.setActiveTab(index),
}); });
+30 -14
View File
@@ -9,9 +9,6 @@ import type { HistoryItemWithoutId } from '../types.js';
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { import {
loadAgySession,
trajectoryToJson,
convertAgyToCliRecord,
coreEvents, coreEvents,
convertSessionToClientHistory, convertSessionToClientHistory,
uiTelemetryService, uiTelemetryService,
@@ -57,19 +54,38 @@ export const useSessionBrowser = (
let conversation: ConversationRecord; let conversation: ConversationRecord;
let filePath: string; let filePath: string;
if (session.fileName.endsWith('.pb')) { if (session.fileName.endsWith('.ext')) {
// Antigravity session // External session
const data = await loadAgySession(session.id); let externalConv: ConversationRecord | null = null;
if (!data) { if (config.getEnableExtensionReloading() !== false) {
throw new Error( /* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
`Could not load Antigravity session ${session.id}`, const extensions = (config as any)._extensionLoader?.getExtensions
? (config as any)._extensionLoader.getExtensions()
: [];
for (const extension of extensions) {
if (extension.trajectoryProviderModule) {
const prefix =
extension.trajectoryProviderModule.prefix || '';
if (session.id.startsWith(prefix)) {
const originalId = prefix
? session.id.slice(prefix.length)
: session.id;
externalConv =
await extension.trajectoryProviderModule.loadSession(
originalId,
); );
if (externalConv) break;
} }
const json = trajectoryToJson(data); }
conversation = convertAgyToCliRecord(json); }
// Antigravity sessions don't have a local CLI file path yet, /* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
// but we'll use the .pb path for reference in resumedSessionData }
filePath = session.id + '.pb';
if (!externalConv) {
throw new Error(`Could not load external session ${session.id}`);
}
conversation = externalConv;
filePath = session.id + '.ext';
} else { } else {
// Regular CLI session // Regular CLI session
const chatsDir = path.join( const chatsDir = path.join(
+23
View File
@@ -9,6 +9,8 @@ import * as path from 'node:path';
import { inspect } from 'node:util'; import { inspect } from 'node:util';
import process from 'node:process'; import process from 'node:process';
import { z } from 'zod'; import { z } from 'zod';
import type { ConversationRecord } from '../services/chatRecordingService.js';
export type { ConversationRecord };
import { import {
AuthType, AuthType,
createContentGenerator, createContentGenerator,
@@ -228,6 +230,25 @@ export interface ResolvedExtensionSetting {
source?: string; 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 { export interface AgentRunConfig {
maxTimeMinutes?: number; maxTimeMinutes?: number;
maxTurns?: number; maxTurns?: number;
@@ -377,6 +398,8 @@ export interface GeminiCLIExtension {
* Used to migrate an extension to a new repository source. * Used to migrate an extension to a new repository source.
*/ */
migratedTo?: string; migratedTo?: string;
/** Loaded JS module for trajectory decoding */
trajectoryProviderModule?: TrajectoryProvider;
} }
export interface ExtensionInstallMetadata { 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_FILE_CHANGE` -> `replace`
- `CORTEX_STEP_TYPE_BROWSER_SUBAGENT` -> (Dropped) - `CORTEX_STEP_TYPE_BROWSER_SUBAGENT` -> (Dropped)
**2. Generic & MCP Integrations** Jetski uses `CORTEX_STEP_TYPE_GENERIC` to **2. Generic / MCP Tools**
handle dynamic or MCP (Model Context Protocol) tool calls that are not hardcoded
into the native protobuf schema.
- The CLI reads the `toolName` and `argsJson` directly from the generic step - Jetski relies heavily on `CORTEX_STEP_TYPE_GENERIC` and
payload and executes them as-is (e.g. `ask_user`, `mcp_*` tools). `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 **3. Unsupported Tools** Many isolated actions, sub-agent tools, and
IDE-specific UI interactions are dropped by the teleporter to maintain strict IDE-specific UI interactions are dropped by the teleporter to maintain strict
CLI compatibility and preserve valid context-window state. 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> <details>
<summary><b>Click to view exhaustive list of all 75+ dropped Jetski steps</b></summary> <summary><b>Click to view exhaustive list of all 75+ dropped Jetski steps</b></summary>
@@ -185,9 +198,9 @@ addressed:
### 1. Security & Key Management ### 1. Security & Key Management
- **Dynamic Key Exchange:** Instead of a hardcoded key in the CLI source code, - **Dynamic Key Exchange:** ✅ The CLI now supports loading encryption keys from
the CLI should retrieve the encryption key securely (e.g., from the OS `JETSKI_TELEPORT_KEY` environment variables or a local
Keychain, a local Jetski config file, or by querying the local Jetski daemon). `~/.gemini/jetski/key.txt` file.
- **Permission Scoping:** Ensure the CLI enforces the same file-access - **Permission Scoping:** Ensure the CLI enforces the same file-access
permission rules (`file_permission_request`) that Jetski enforces so the AI permission rules (`file_permission_request`) that Jetski enforces so the AI
doesn't suddenly gain destructive permissions when transitioning to the doesn't suddenly gain destructive permissions when transitioning to the
@@ -195,6 +208,9 @@ addressed:
### 2. Architecture & Build Process Decoupling ### 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 - **Shared NPM Package:** Publish the compiled Protobufs and parsing logic as a
private internal package (e.g., `@google/cortex-teleporter`). The Gemini CLI private internal package (e.g., `@google/cortex-teleporter`). The Gemini CLI
should simply `npm install` this, rather than generating `.min.js` blobs should simply `npm install` this, rather than generating `.min.js` blobs
@@ -205,18 +221,13 @@ addressed:
### 3. User Experience (UX) ### 3. User Experience (UX)
- **Clear UI Indicators:** In the CLI's `/resume` menu, Jetski sessions should - **Clear UI Indicators:** ✅ Jetski sessions are now grouped in a dedicated tab
be visually distinct from native CLI sessions (e.g., using a 🛸 icon and a in the `/resume` menu.
"Jetski" tag next to the session name). - **Missing Context Warnings:** ✅ The UI now synthesizes `description` and
- **Missing Context Warnings:** Because we intentionally drop 75+ step types `resultDisplay` for generic tool calls to ensure a smooth conversation flow.
(browser actions, IDE UI clicks, etc.), the CLI conversation history might - **Seamless Handoff Prompt:** ✅ The CLI now intelligently prompts the user on
look like it has "gaps." The UI should render a small placeholder like: startup if a recent Jetski session is found: _"🛸 You have a recent session in
`[ ⚠️ Jetski browser action dropped for CLI compatibility ]` so the user Antigravity. Type /resume agy:<id> to bring it into the terminal."_
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."_
### 4. Data Fidelity & Error Handling ### 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;
+43 -7
View File
@@ -7,7 +7,11 @@
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
/* eslint-disable @typescript-eslint/no-explicit-any */ /* 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 { CoreToolCallStatus } from '../scheduler/types.js';
import { import {
EDIT_TOOL_NAME, EDIT_TOOL_NAME,
@@ -19,6 +23,7 @@ import {
WEB_FETCH_TOOL_NAME, WEB_FETCH_TOOL_NAME,
WEB_SEARCH_TOOL_NAME, WEB_SEARCH_TOOL_NAME,
WRITE_FILE_TOOL_NAME, WRITE_FILE_TOOL_NAME,
ASK_USER_TOOL_NAME,
} from '../tools/definitions/coreTools.js'; } from '../tools/definitions/coreTools.js';
/** /**
@@ -218,7 +223,15 @@ function mapAgyStepToToolCall(step: Record<string, any>): ToolCallRecord {
args = { Task: step['browserSubagent']['task'] }; args = { Task: step['browserSubagent']['task'] };
} else if (step['generic']) { } else if (step['generic']) {
const generic = step['generic'] as Record<string, unknown>; 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 { try {
args = JSON.parse(generic['argsJson'] as string); args = JSON.parse(generic['argsJson'] as string);
} catch { } catch {
@@ -227,15 +240,38 @@ function mapAgyStepToToolCall(step: Record<string, any>): ToolCallRecord {
result = [{ text: (generic['responseJson'] as string) || '' }]; 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 { return {
id, id,
name, name,
args: args as Record<string, unknown>, args: safeArgs,
description,
result, result,
status: resultDisplay,
step['status'] === 3 || step['status'] === 'CORTEX_STEP_STATUS_DONE' status,
? CoreToolCallStatus.Success
: CoreToolCallStatus.Error,
timestamp, timestamp,
}; };
} }
+48 -2
View File
@@ -11,6 +11,7 @@ import os from 'node:os';
import { trajectoryToJson } from './teleporter.js'; import { trajectoryToJson } from './teleporter.js';
import { convertAgyToCliRecord } from './converter.js'; import { convertAgyToCliRecord } from './converter.js';
import { partListUnionToString } from '../core/geminiRequest.js'; import { partListUnionToString } from '../core/geminiRequest.js';
import type { MessageRecord } from '../services/chatRecordingService.js';
export interface AgySessionInfo { export interface AgySessionInfo {
id: string; id: string;
@@ -28,6 +29,26 @@ const AGY_CONVERSATIONS_DIR = path.join(
'conversations', '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. * Lists all Antigravity sessions found on disk.
* @param filterWorkspaceUri Optional filter to only return sessions matching this workspace URI (e.g. "file:///..."). * @param filterWorkspaceUri Optional filter to only return sessions matching this workspace URI (e.g. "file:///...").
@@ -38,7 +59,6 @@ export async function listAgySessions(
try { try {
const files = await fs.readdir(AGY_CONVERSATIONS_DIR); const files = await fs.readdir(AGY_CONVERSATIONS_DIR);
const sessions: AgySessionInfo[] = []; const sessions: AgySessionInfo[] = [];
for (const file of files) { for (const file of files) {
if (file.endsWith('.pb')) { if (file.endsWith('.pb')) {
const filePath = path.join(AGY_CONVERSATIONS_DIR, file); const filePath = path.join(AGY_CONVERSATIONS_DIR, file);
@@ -88,7 +108,7 @@ function extractAgyDetails(json: unknown): {
const messages = record.messages || []; const messages = record.messages || [];
// Find first user message for display name // 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 const displayName = firstUserMsg
? partListUnionToString(firstUserMsg.content).slice(0, 100) ? partListUnionToString(firstUserMsg.content).slice(0, 100)
: 'Antigravity Session'; : 'Antigravity Session';
@@ -151,3 +171,29 @@ export async function loadAgySession(id: string): Promise<Buffer | null> {
return 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;
}
-5
View File
@@ -13,8 +13,3 @@ export interface AgyTrajectory {
export * from './teleporter.js'; export * from './teleporter.js';
export { convertAgyToCliRecord } from './converter.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. * 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; let pbData: Buffer;
try { try {
// Try to decrypt first // Try to decrypt first
pbData = decrypt(data); pbData = decrypt(data, key);
} catch (_e) { } catch (_e) {
// Fallback to plain protobuf if decryption fails // Fallback to plain protobuf if decryption fails
pbData = data; pbData = data;
@@ -65,8 +68,11 @@ export function trajectoryToJson(data: Buffer): unknown {
/** /**
* Converts JSON to Antigravity binary trajectory (encrypted). * 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 trajectory = Trajectory.fromJson(json, { ignoreUnknownFields: true });
const pbData = Buffer.from(trajectory.toBinary()); 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.stopExtension(extension);
await this.startExtension(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 { export interface ExtensionEvents {