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

View File

@@ -51,6 +51,7 @@ import {
type HookDefinition,
type HookEventName,
type ResolvedExtensionSetting,
type TrajectoryProvider,
coreEvents,
applyAdminAllowlist,
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 {
name: config.name,
version: config.version,
@@ -980,6 +998,7 @@ Would you like to attempt to install via "git clone" instead?`,
rules,
checkers,
plan: config.plan,
trajectoryProviderModule,
};
} catch (e) {
const extName = path.basename(extensionDir);

View File

@@ -46,6 +46,11 @@ export interface ExtensionConfig {
* Used to migrate an extension to a new repository source.
*/
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 {

View File

@@ -19,12 +19,15 @@ import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js';
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
import { pathToFileURL } from 'node:url';
export interface InitializationResult {
authError: string | null;
accountSuspensionInfo: AccountSuspensionInfo | null;
themeError: string | null;
shouldOpenAuthDialog: boolean;
geminiMdFileCount: number;
recentExternalSession?: { prefix: string; id: string; displayName?: string };
}
/**
@@ -60,11 +63,34 @@ export async function initializeApp(
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 {
authError,
accountSuspensionInfo,
themeError,
shouldOpenAuthDialog,
geminiMdFileCount: config.getGeminiMdFileCount(),
recentExternalSession,
};
}

View File

@@ -469,6 +469,19 @@ export const AppContainer = (props: AppContainerProps) => {
generateSummary(config).catch((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 () => {
// Turn off mouse scroll.

View File

@@ -7,7 +7,6 @@
import * as fsPromises from 'node:fs/promises';
import React from 'react';
import { Text } from 'ink';
import { pathToFileURL } from 'node:url';
import { theme } from '../semantic-colors.js';
import type { Content, Part } from '@google/genai';
import type {
@@ -20,10 +19,7 @@ import {
decodeTagName,
type MessageActionReturn,
INITIAL_HISTORY_LENGTH,
listAgySessions,
loadAgySession,
trajectoryToJson,
convertAgyToCliRecord,
type ConversationRecord,
} from '@google/gemini-cli-core';
import path from 'node:path';
import type {
@@ -70,25 +66,6 @@ const getSavedChatTags = async (
: 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;
} catch (_err) {
return [];
@@ -203,11 +180,32 @@ const resumeCheckpointCommand: SlashCommand = {
let conversation: Content[] = [];
let authType: string | undefined;
const loadAgy = async (id: string): Promise<Content[] | null> => {
const data = await loadAgySession(id);
if (!data) return null;
const agyJson = trajectoryToJson(data);
const record = convertAgyToCliRecord(agyJson);
const loadExternalTrajectory = async (
prefix: string,
id: string,
): Promise<Content[] | null> => {
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[] = [];
// 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 });
// 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[] = [];
for (const tc of m.toolCalls) {
if (tc.result) {
@@ -267,27 +268,30 @@ const resumeCheckpointCommand: SlashCommand = {
return conv;
};
if (tag.startsWith('agy:')) {
const id = tag.slice(4);
const agyConv = await loadAgy(id);
if (!agyConv) {
// Check if tag format is prefix:id
const prefixMatch = tag.match(/^([a-zA-Z0-9_]+):(.*)$/);
if (prefixMatch) {
const prefix = prefixMatch[1] + ':';
const id = prefixMatch[2];
const externalConv = await loadExternalTrajectory(prefix, id);
if (!externalConv) {
return {
type: 'message',
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 {
const checkpoint = await logger.loadCheckpoint(tag);
if (checkpoint.history.length > 0) {
conversation = checkpoint.history;
authType = checkpoint.authType;
} else {
// Fallback: Try to load as AGY session even without prefix
const agyConv = await loadAgy(tag);
if (agyConv) {
conversation = agyConv;
// Fallback: Try to load as AGY session even without prefix just in case (legacy support)
const legacyConv = await loadExternalTrajectory('agy:', tag);
if (legacyConv) {
conversation = legacyConv;
}
}
}

View File

@@ -13,11 +13,7 @@ import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useKeypress } from '../hooks/useKeypress.js';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
listAgySessions,
type Config,
type AgySessionInfo,
} from '@google/gemini-cli-core';
import { type Config } from '@google/gemini-cli-core';
import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js';
import {
formatRelativeTime,
@@ -48,8 +44,8 @@ export interface SessionBrowserState {
// Data state
/** All loaded sessions */
sessions: SessionInfo[];
/** Antigravity sessions */
agySessions: SessionInfo[];
/** Extension tabs */
extensionTabs: Array<{ name: string; sessions: SessionInfo[] }>;
/** Sessions after filtering and sorting */
filteredAndSortedSessions: SessionInfo[];
@@ -94,8 +90,10 @@ export interface SessionBrowserState {
// State setters
/** Update sessions array */
setSessions: React.Dispatch<React.SetStateAction<SessionInfo[]>>;
/** Update agySessions array */
setAgySessions: React.Dispatch<React.SetStateAction<SessionInfo[]>>;
/** Update extensionTabs array */
setExtensionTabs: React.Dispatch<
React.SetStateAction<Array<{ name: string; sessions: SessionInfo[] }>>
>;
/** Update loading state */
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
/** Update error state */
@@ -367,7 +365,9 @@ export const useSessionBrowserState = (
): SessionBrowserState => {
const { columns: terminalWidth } = useTerminalSize();
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 [error, setError] = useState<string | null>(initialError);
const [activeIndex, setActiveIndex] = useState(0);
@@ -390,10 +390,11 @@ export const useSessionBrowserState = (
}, []);
const filteredAndSortedSessions = useMemo(() => {
const currentTabSessions = activeTab === 0 ? sessions : agySessions;
const currentTabSessions =
activeTab === 0 ? sessions : extensionTabs[activeTab - 1]?.sessions || [];
const filtered = filterSessions(currentTabSessions, searchQuery);
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
useEffect(() => {
@@ -411,8 +412,8 @@ export const useSessionBrowserState = (
const state: SessionBrowserState = {
sessions,
setSessions,
agySessions,
setAgySessions,
extensionTabs,
setExtensionTabs,
loading,
setLoading,
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(
agy: AgySessionInfo,
type ExternalSessionInfo = {
id: string;
mtime: string;
name?: string;
displayName?: string;
messageCount?: number;
prefix?: string;
};
function convertExternalToSessionInfo(
ext: ExternalSessionInfo,
index: number,
): SessionInfo {
return {
id: agy.id,
file: agy.id,
fileName: agy.id + '.pb',
startTime: agy.mtime,
lastUpdated: agy.mtime,
messageCount: agy.messageCount || 0,
displayName: agy.displayName || 'Antigravity Session',
firstUserMessage: agy.displayName || '',
id: ext.prefix ? `${ext.prefix}${ext.id}` : ext.id,
file: ext.id,
fileName: ext.id + '.ext',
startTime: ext.mtime,
lastUpdated: ext.mtime,
messageCount: ext.messageCount || 0,
displayName: ext.displayName || ext.name || 'External Session',
firstUserMessage: ext.displayName || ext.name || '',
isCurrentSession: false,
index,
};
@@ -471,7 +481,7 @@ function convertAgyToSessionInfo(
const useLoadSessions = (config: Config, state: SessionBrowserState) => {
const {
setSessions,
setAgySessions,
setExtensionTabs,
setLoading,
setError,
isSearchMode,
@@ -485,19 +495,55 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
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()),
listAgySessions(workspaceUri),
]);
setSessions(sessionData);
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);
setExtensionTabs(externalTabs);
setLoading(false);
} catch (err) {
@@ -510,7 +556,7 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
loadSessions();
}, [config, setSessions, setAgySessions, setLoading, setError]);
}, [config, setSessions, setExtensionTabs, setLoading, setError]);
useEffect(() => {
const loadFullContent = async () => {
@@ -750,8 +796,11 @@ export function SessionBrowserView({
state: SessionBrowserState;
}): React.JSX.Element {
const tabs: Tab[] = [
{ key: 'cli', header: 'CLI Sessions' },
{ key: 'agy', header: 'Antigravity' },
{ key: 'cli', header: 'Gemini CLI' },
...state.extensionTabs.map((ext, i) => ({
key: `ext-${i}`,
header: ext.name,
})),
];
if (state.loading) {
@@ -762,7 +811,7 @@ export function SessionBrowserView({
return <SessionBrowserError state={state} />;
}
if (state.sessions.length === 0 && state.agySessions.length === 0) {
if (state.sessions.length === 0 && state.extensionTabs.length === 0) {
return <SessionBrowserEmpty />;
}
@@ -802,8 +851,9 @@ export function SessionBrowser({
const cycleSortOrder = useCycleSortOrder(state);
useTabbedNavigation({
tabCount: 2,
tabCount: state.extensionTabs.length + 1,
isActive: !state.isSearchMode,
wrapAround: true,
onTabChange: (index) => state.setActiveTab(index),
});

View File

@@ -9,9 +9,6 @@ import type { HistoryItemWithoutId } from '../types.js';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import {
loadAgySession,
trajectoryToJson,
convertAgyToCliRecord,
coreEvents,
convertSessionToClientHistory,
uiTelemetryService,
@@ -57,19 +54,38 @@ export const useSessionBrowser = (
let conversation: ConversationRecord;
let filePath: string;
if (session.fileName.endsWith('.pb')) {
// Antigravity session
const data = await loadAgySession(session.id);
if (!data) {
throw new Error(
`Could not load Antigravity session ${session.id}`,
);
if (session.fileName.endsWith('.ext')) {
// External session
let externalConv: ConversationRecord | null = null;
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) {
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;
}
}
}
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
}
const json = trajectoryToJson(data);
conversation = convertAgyToCliRecord(json);
// Antigravity sessions don't have a local CLI file path yet,
// 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 {
// Regular CLI session
const chatsDir = path.join(