mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-20 00:32:31 -07:00
feat(workspaces): implement Workspaces UI and action primitives
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
type Config,
|
||||
type WorkspaceHubInfo
|
||||
} from '@google-gemini-cli-core';
|
||||
|
||||
import { exitCli } from '../utils.js';
|
||||
import chalk from 'chalk';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
|
||||
import type { SlashCommand, CommandContext } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import type { MessageActionReturn } from '@google/gemini-cli-core';
|
||||
import { WorkspaceHubClient } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type CommandActionReturn,
|
||||
WorkspaceHubClient
|
||||
} from '@google-gemini-cli-core';
|
||||
|
||||
const listAction = async (
|
||||
_context: CommandContext,
|
||||
): Promise<void | MessageActionReturn> => {
|
||||
): Promise<CommandActionReturn> => {
|
||||
const hubUrl =
|
||||
process.env['GEMINI_WORKSPACE_HUB_URL'] || 'http://localhost:8080';
|
||||
const client = new WorkspaceHubClient(hubUrl);
|
||||
@@ -27,21 +29,12 @@ const listAction = async (
|
||||
};
|
||||
}
|
||||
|
||||
let content = 'Active Workspaces:\n';
|
||||
content += '------------------------------------------------------------\n';
|
||||
for (const ws of workspaces) {
|
||||
content += `${ws.name.padEnd(20)} | ${ws.status.padEnd(12)} | ${ws.id}\n`;
|
||||
}
|
||||
content += '------------------------------------------------------------';
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content,
|
||||
type: 'workspaces_list',
|
||||
workspaces,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const message = (error as Error).message;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
@@ -56,7 +49,7 @@ const listCommand: SlashCommand = {
|
||||
description: 'List remote workspaces',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (context) => listAction(context),
|
||||
action: ((context: CommandContext) => listAction(context)) as any,
|
||||
};
|
||||
|
||||
const createCommand: SlashCommand = {
|
||||
@@ -64,10 +57,10 @@ const createCommand: SlashCommand = {
|
||||
description: 'Create a new remote workspace',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (
|
||||
action: (async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
): Promise<CommandActionReturn> => {
|
||||
const name = args.trim();
|
||||
if (!name) {
|
||||
return {
|
||||
@@ -93,15 +86,14 @@ const createCommand: SlashCommand = {
|
||||
content: `✅ Workspace created successfully!\nID: ${ws.id}\nName: ${ws.name}\nGCE: ${ws.instance_name}`,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const message = (error as Error).message;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to create workspace: ${message}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
}) as any,
|
||||
};
|
||||
|
||||
const deleteCommand: SlashCommand = {
|
||||
@@ -110,10 +102,10 @@ const deleteCommand: SlashCommand = {
|
||||
description: 'Delete a remote workspace',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
action: (async (
|
||||
_context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
): Promise<CommandActionReturn> => {
|
||||
const id = args.trim();
|
||||
if (!id) {
|
||||
return {
|
||||
@@ -128,7 +120,8 @@ const deleteCommand: SlashCommand = {
|
||||
const client = new WorkspaceHubClient(hubUrl);
|
||||
|
||||
try {
|
||||
context.ui.addItem({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
(_context.ui as any).addItem({
|
||||
type: 'info',
|
||||
text: `Deleting workspace "${id}"...`,
|
||||
});
|
||||
@@ -139,15 +132,14 @@ const deleteCommand: SlashCommand = {
|
||||
content: `✅ Workspace ${id} deleted successfully.`,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const message = (error as Error).message;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to delete workspace: ${message}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
}) as any,
|
||||
};
|
||||
|
||||
const connectCommand: SlashCommand = {
|
||||
@@ -155,10 +147,10 @@ const connectCommand: SlashCommand = {
|
||||
description: 'Connect to a remote workspace',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (
|
||||
action: (async (
|
||||
_context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
): Promise<CommandActionReturn> => {
|
||||
const id = args.trim();
|
||||
if (!id) {
|
||||
return {
|
||||
@@ -171,7 +163,7 @@ const connectCommand: SlashCommand = {
|
||||
type: 'submit_prompt',
|
||||
content: `I want to connect to remote workspace "${id}". Please run the connect command.`,
|
||||
};
|
||||
},
|
||||
}) as any,
|
||||
};
|
||||
|
||||
export const workspaceSlashCommand: SlashCommand = {
|
||||
@@ -181,5 +173,5 @@ export const workspaceSlashCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
subCommands: [listCommand, createCommand, deleteCommand, connectCommand],
|
||||
action: async (context: CommandContext) => listAction(context),
|
||||
action: (async (context: CommandContext) => listAction(context)) as any,
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ import { SkillsList } from './views/SkillsList.js';
|
||||
import { AgentsStatus } from './views/AgentsStatus.js';
|
||||
import { McpStatus } from './views/McpStatus.js';
|
||||
import { ChatList } from './views/ChatList.js';
|
||||
import { WorkspacesList } from './views/WorkspacesList.js';
|
||||
import { ModelMessage } from './messages/ModelMessage.js';
|
||||
import { ThinkingMessage } from './messages/ThinkingMessage.js';
|
||||
import { HintMessage } from './messages/HintMessage.js';
|
||||
@@ -233,6 +234,9 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
{itemForDisplay.type === 'chat_list' && (
|
||||
<ChatList chats={itemForDisplay.chats} />
|
||||
)}
|
||||
{itemForDisplay.type === 'workspaces_list' && (
|
||||
<WorkspacesList workspaces={itemForDisplay.workspaces} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { WorkspaceHubInfo } from '@google/gemini-cli-core';
|
||||
|
||||
interface WorkspacesListProps {
|
||||
workspaces: readonly WorkspaceHubInfo[];
|
||||
}
|
||||
|
||||
export const WorkspacesList: React.FC<WorkspacesListProps> = ({ workspaces }) => {
|
||||
if (workspaces.length === 0) {
|
||||
return <Text>No active workspaces found.</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold underline>Active Remote Workspaces:</Text>
|
||||
<Box flexDirection="column" paddingLeft={2} marginTop={1}>
|
||||
{workspaces.map((ws) => {
|
||||
const isReady = ws.status === 'READY';
|
||||
const statusColor = isReady ? 'green' : 'yellow';
|
||||
|
||||
return (
|
||||
<Box key={ws.id} flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text color="cyan" bold>{ws.name.padEnd(20)}</Text>
|
||||
<Text> | </Text>
|
||||
<Text color={statusColor}>{ws.status.padEnd(12)}</Text>
|
||||
<Text> | </Text>
|
||||
<Text dimColor>{ws.id}</Text>
|
||||
</Box>
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>Instance: {ws.instance_name} ({ws.zone})</Text>
|
||||
</Box>
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>Project: {ws.project_id}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor italic>Use `/wsr connect {'<name>'}` to teleport into a workspace.</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -662,6 +662,16 @@ export const useSlashCommandProcessor = (
|
||||
setCustomDialog(result.component);
|
||||
return { type: 'handled' };
|
||||
}
|
||||
case 'workspaces_list': {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.WORKSPACES_LIST,
|
||||
workspaces: result.workspaces,
|
||||
} as any,
|
||||
Date.now(),
|
||||
);
|
||||
return { type: 'handled' };
|
||||
}
|
||||
default: {
|
||||
const unhandled: never = result;
|
||||
throw new Error(
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type AgentDefinition,
|
||||
type ApprovalMode,
|
||||
type Kind,
|
||||
type WorkspaceHubInfo,
|
||||
CoreToolCallStatus,
|
||||
checkExhaustive,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -272,6 +273,11 @@ export type HistoryItemChatList = HistoryItemBase & {
|
||||
chats: ChatDetail[];
|
||||
};
|
||||
|
||||
export type HistoryItemWorkspacesList = HistoryItemBase & {
|
||||
type: 'workspaces_list';
|
||||
workspaces: WorkspaceHubInfo[];
|
||||
};
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
displayName: string;
|
||||
@@ -379,6 +385,7 @@ export type HistoryItemWithoutId =
|
||||
| HistoryItemAgentsList
|
||||
| HistoryItemMcpStatus
|
||||
| HistoryItemChatList
|
||||
| HistoryItemWorkspacesList
|
||||
| HistoryItemThinking
|
||||
| HistoryItemHint;
|
||||
|
||||
@@ -404,6 +411,7 @@ export enum MessageType {
|
||||
AGENTS_LIST = 'agents_list',
|
||||
MCP_STATUS = 'mcp_status',
|
||||
CHAT_LIST = 'chat_list',
|
||||
WORKSPACES_LIST = 'workspaces_list',
|
||||
HINT = 'hint',
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
||||
"types": ["node", "vitest/globals"]
|
||||
"types": ["node", "vitest/globals"],
|
||||
"paths": {
|
||||
"@google-gemini-cli-core": ["../core/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"index.ts",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
|
||||
import type { Content, PartListUnion } from '@google/genai';
|
||||
import type { WorkspaceHubInfo } from '../services/workspaceHubClient.js';
|
||||
|
||||
/**
|
||||
* The return type for a command action that results in scheduling a tool call.
|
||||
*/
|
||||
@@ -19,6 +21,11 @@ export interface ToolActionReturn {
|
||||
postSubmitPrompt?: PartListUnion;
|
||||
}
|
||||
|
||||
export interface WorkspacesListActionReturn {
|
||||
type: 'workspaces_list';
|
||||
workspaces: WorkspaceHubInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The return type for a command action that results in a simple message
|
||||
* being displayed to the user.
|
||||
@@ -50,6 +57,7 @@ export interface SubmitPromptActionReturn {
|
||||
|
||||
export type CommandActionReturn<HistoryType = unknown> =
|
||||
| ToolActionReturn
|
||||
| WorkspacesListActionReturn
|
||||
| MessageActionReturn
|
||||
| LoadHistoryActionReturn<HistoryType>
|
||||
| SubmitPromptActionReturn;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import { WorkspaceHubClient } from '../services/workspaceHubClient.js';
|
||||
import type { MessageActionReturn } from './types.js';
|
||||
import type { CommandActionReturn } from './types.js';
|
||||
|
||||
function getHubUrl(config: Config): string {
|
||||
if (process.env['GEMINI_WORKSPACE_HUB_URL']) {
|
||||
@@ -26,7 +26,7 @@ function getHubUrl(config: Config): string {
|
||||
|
||||
export async function listWorkspaces(
|
||||
config: Config,
|
||||
): Promise<MessageActionReturn> {
|
||||
): Promise<CommandActionReturn> {
|
||||
const hubUrl = getHubUrl(config);
|
||||
const client = new WorkspaceHubClient(hubUrl);
|
||||
|
||||
@@ -41,17 +41,9 @@ export async function listWorkspaces(
|
||||
};
|
||||
}
|
||||
|
||||
let content = 'Active Workspaces:\n';
|
||||
content += '------------------------------------------------------------\n';
|
||||
for (const ws of workspaces) {
|
||||
content += `${ws.name.padEnd(20)} | ${ws.status.padEnd(12)} | ${ws.id}\n`;
|
||||
}
|
||||
content += '------------------------------------------------------------';
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content,
|
||||
type: 'workspaces_list',
|
||||
workspaces,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
@@ -68,7 +60,7 @@ export async function createWorkspace(
|
||||
config: Config,
|
||||
name: string,
|
||||
machineType?: string,
|
||||
): Promise<MessageActionReturn> {
|
||||
): Promise<CommandActionReturn> {
|
||||
const hubUrl = getHubUrl(config);
|
||||
const client = new WorkspaceHubClient(hubUrl);
|
||||
|
||||
@@ -93,7 +85,7 @@ export async function createWorkspace(
|
||||
export async function deleteWorkspace(
|
||||
config: Config,
|
||||
id: string,
|
||||
): Promise<MessageActionReturn> {
|
||||
): Promise<CommandActionReturn> {
|
||||
const hubUrl = getHubUrl(config);
|
||||
const client = new WorkspaceHubClient(hubUrl);
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ const CreateWorkspaceSchema = z.object({
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const authReq = req as unknown as AuthenticatedRequest;
|
||||
const workspaces = await workspaceService.listWorkspaces(authReq.user.id);
|
||||
res.json(workspaces);
|
||||
} catch (error) {
|
||||
@@ -35,7 +35,7 @@ router.get('/', async (req, res) => {
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const authReq = req as unknown as AuthenticatedRequest;
|
||||
const validation = CreateWorkspaceSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
res.status(400).json({ error: validation.error.format() });
|
||||
@@ -80,7 +80,7 @@ router.post('/', async (req, res) => {
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const authReq = req as unknown as AuthenticatedRequest;
|
||||
const { id } = req.params;
|
||||
const workspace = await workspaceService.getWorkspace(id);
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Milestone 5 Sub-plan: UI & Advanced Hub Features
|
||||
|
||||
## 1. Objective
|
||||
Provide a polished, interactive dashboard for managing workspaces and enhance the Hub with production-grade management features like TTL-based auto-cleanup and expanded multi-tenancy models.
|
||||
|
||||
## 2. Tasks
|
||||
|
||||
### Task 5.1: Workspaces "Ability" (React UI)
|
||||
Create an interactive dashboard within the `gemini-cli`.
|
||||
- [ ] Create `packages/cli/src/ui/abilities/workspaces/`.
|
||||
- [ ] Implement `WorkspacesView` component using Ink.
|
||||
- [ ] Logic to display a live-updating table of workspaces.
|
||||
- [ ] Implement interactive actions: "Connect", "Start/Stop", "Delete" within the UI.
|
||||
|
||||
### Task 5.2: Hub Auto-Cleanup (TTL)
|
||||
Prevent runaway GCP costs by cleaning up idle workspaces.
|
||||
- [ ] Add `last_connected_at` tracking to `WorkspaceService`.
|
||||
- [ ] Implement a `/cleanup` endpoint in the Hub.
|
||||
- [ ] Logic to identify and delete/stop VMs that have been idle past a configurable TTL.
|
||||
|
||||
### Task 5.3: Expanded Multi-Tenancy (Team/Repo)
|
||||
Enhance the Hub to support shared and automated environments.
|
||||
- [ ] Implement `Team` mode logic where workspaces can be shared within a Google Group.
|
||||
- [ ] Implement `Repo` mode primitives to tie workspaces to specific GitHub PRs.
|
||||
|
||||
## 3. Verification & Success Criteria
|
||||
- **UI:** The user can navigate to the "Workspaces" ability and manage their fleet using keyboard shortcuts or a menu.
|
||||
- **Cleanup:** A workspace not connected to for > TTL is automatically terminated by the Hub.
|
||||
- **Tenancy:** Verified isolation and sharing rules in a simulated multi-user environment.
|
||||
|
||||
## 4. Next Steps
|
||||
- Implement Task 5.1: Scaffold the Workspaces Ability in the CLI.
|
||||
@@ -41,10 +41,10 @@ See [Milestone 4 Sub-plan](./milestone-4-sync-and-identity.md) for details.
|
||||
- [ ] Implement secure GitHub PAT injection via `/dev/shm`.
|
||||
|
||||
### Milestone 5: UI & Advanced Hub Features (Phase 5)
|
||||
|
||||
Polish the developer experience and add enterprise-grade Hub capabilities.
|
||||
|
||||
See [Milestone 5 Sub-plan](./milestone-5-ui-and-advanced.md) for details.
|
||||
- [ ] Implement the "Workspaces Ability" in the CLI (interactive React UI).
|
||||
|
||||
- [ ] Implement multi-tenancy models (User, Team, Repo) in the Hub.
|
||||
- [ ] Add auto-cleanup (TTL) and resource monitoring to the Hub.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user