diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts
index c7f94d02cb..9a9cb3d259 100644
--- a/packages/cli/src/services/BuiltinCommandLoader.ts
+++ b/packages/cli/src/services/BuiltinCommandLoader.ts
@@ -35,6 +35,7 @@ import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { modelCommand } from '../ui/commands/modelCommand.js';
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
+import { planCommand } from '../ui/commands/planCommand.js';
import { privacyCommand } from '../ui/commands/privacyCommand.js';
import { policiesCommand } from '../ui/commands/policiesCommand.js';
import { profileCommand } from '../ui/commands/profileCommand.js';
@@ -131,6 +132,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
: [mcpCommand]),
memoryCommand,
modelCommand,
+ ...(this.config?.isPlanEnabled() ? [planCommand] : []),
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
privacyCommand,
policiesCommand,
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 45ccd33ad0..5badba64b9 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -97,6 +97,7 @@ import { useFocus } from './hooks/useFocus.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { keyMatchers, Command } from './keyMatchers.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
+import { usePlanMonitoring } from './hooks/usePlanMonitoring.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
@@ -1334,6 +1335,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
retryStatus,
});
+ const { planTodos, planFileName } = usePlanMonitoring(config);
+
const handleGlobalKeypress = useCallback(
(key: Key) => {
if (copyModeEnabled) {
@@ -1688,6 +1691,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
constrainHeight,
showErrorDetails,
showFullTodos,
+ planTodos,
+ planFileName,
filteredConsoleMessages,
ideContextState,
renderMarkdown,
@@ -1786,6 +1791,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
constrainHeight,
showErrorDetails,
showFullTodos,
+ planTodos,
+ planFileName,
filteredConsoleMessages,
ideContextState,
renderMarkdown,
diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts
new file mode 100644
index 0000000000..3575561b52
--- /dev/null
+++ b/packages/cli/src/ui/commands/planCommand.ts
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import type { SlashCommand } from './types.js';
+import { CommandKind } from './types.js';
+import { MessageType } from '../types.js';
+
+export const planCommand: SlashCommand = {
+ name: 'plan',
+ description: 'Manage implementation plans',
+ kind: CommandKind.BUILT_IN,
+ subCommands: [
+ {
+ name: 'discard',
+ description: 'Discard all plan files in the current session',
+ kind: CommandKind.BUILT_IN,
+ autoExecute: true,
+ action: async (context) => {
+ const config = context.services.config;
+ if (!config) return;
+
+ const plansDir = config.storage.getProjectTempPlansDir();
+ try {
+ if (fs.existsSync(plansDir)) {
+ const files = await fs.promises.readdir(plansDir);
+ const mdFiles = files.filter((f) => f.endsWith('.md'));
+
+ for (const file of mdFiles) {
+ await fs.promises.unlink(path.join(plansDir, file));
+ }
+
+ context.ui.addItem(
+ {
+ type: MessageType.INFO,
+ text: `Discarded ${mdFiles.length} plan file(s).`,
+ },
+ Date.now(),
+ );
+ } else {
+ context.ui.addItem(
+ {
+ type: MessageType.INFO,
+ text: 'No plan files found to discard.',
+ },
+ Date.now(),
+ );
+ }
+ } catch (error) {
+ context.ui.addItem(
+ {
+ type: MessageType.ERROR,
+ text: `Failed to discard plans: ${(error as Error).message}`,
+ },
+ Date.now(),
+ );
+ }
+ },
+ },
+ ],
+};
diff --git a/packages/cli/src/ui/components/messages/Todo.tsx b/packages/cli/src/ui/components/messages/Todo.tsx
index fcbc92aafd..f6680573e2 100644
--- a/packages/cli/src/ui/components/messages/Todo.tsx
+++ b/packages/cli/src/ui/components/messages/Todo.tsx
@@ -16,7 +16,10 @@ import { useUIState } from '../../contexts/UIStateContext.js';
import { useMemo } from 'react';
import type { HistoryItemToolGroup } from '../../types.js';
-const TodoTitleDisplay: React.FC<{ todos: TodoList }> = ({ todos }) => {
+const TodoTitleDisplay: React.FC<{
+ todos: TodoList;
+ fileName?: string | null;
+}> = ({ todos, fileName }) => {
const score = useMemo(() => {
let total = 0;
let completed = 0;
@@ -34,7 +37,7 @@ const TodoTitleDisplay: React.FC<{ todos: TodoList }> = ({ todos }) => {
return (
- Todo
+ {fileName ? `Plan: ${fileName}` : 'Todo'}
{score} (ctrl+t to toggle)
@@ -105,6 +108,9 @@ export const TodoTray: React.FC = () => {
const uiState = useUIState();
const todos: TodoList | null = useMemo(() => {
+ if (uiState.planTodos && uiState.planTodos.length > 0) {
+ return { todos: uiState.planTodos };
+ }
// Find the most recent todo list written by the WriteTodosTool
for (let i = uiState.history.length - 1; i >= 0; i--) {
const entry = uiState.history[i];
@@ -123,7 +129,7 @@ export const TodoTray: React.FC = () => {
}
}
return null;
- }, [uiState.history]);
+ }, [uiState.history, uiState.planTodos]);
const inProgress: Todo | null = useMemo(() => {
if (todos === null) {
@@ -148,6 +154,8 @@ export const TodoTray: React.FC = () => {
return null;
}
+ const isPlan = uiState.planTodos && uiState.planTodos.length > 0;
+
return (
{
>
{uiState.showFullTodos ? (
-
+
) : (
-
+
{inProgress && (
diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx
index 6d10d76bda..d912bbb66d 100644
--- a/packages/cli/src/ui/contexts/UIStateContext.tsx
+++ b/packages/cli/src/ui/contexts/UIStateContext.tsx
@@ -25,7 +25,7 @@ import type {
FallbackIntent,
ValidationIntent,
AgentDefinition,
-} from '@google/gemini-cli-core';
+ Todo } from '@google/gemini-cli-core';
import type { DOMElement } from 'ink';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import type { ExtensionUpdateState } from '../state/extensions.js';
@@ -144,6 +144,8 @@ export interface UIState {
embeddedShellFocused: boolean;
showDebugProfiler: boolean;
showFullTodos: boolean;
+ planTodos: Todo[] | null;
+ planFileName: string | null;
copyModeEnabled: boolean;
warningMessage: string | null;
bannerData: {
diff --git a/packages/cli/src/ui/hooks/usePlanMonitoring.ts b/packages/cli/src/ui/hooks/usePlanMonitoring.ts
new file mode 100644
index 0000000000..6aa4414487
--- /dev/null
+++ b/packages/cli/src/ui/hooks/usePlanMonitoring.ts
@@ -0,0 +1,83 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useState, useEffect, useRef } from 'react';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import {
+ parseMarkdownTodos,
+ type Todo,
+ type Config,
+ debugLogger,
+} from '@google/gemini-cli-core';
+
+export function usePlanMonitoring(config: Config) {
+ const [planTodos, setPlanTodos] = useState(null);
+ const [planFileName, setPlanFileName] = useState(null);
+ const lastModifiedRef = useRef(0);
+
+ useEffect(() => {
+ const plansDir = config.storage.getProjectTempPlansDir();
+
+ const updatePlan = async () => {
+ try {
+ if (!fs.existsSync(plansDir)) {
+ return;
+ }
+
+ const files = await fs.promises.readdir(plansDir);
+ const mdFiles = files.filter((f) => f.endsWith('.md'));
+
+ if (mdFiles.length === 0) {
+ setPlanTodos(null);
+ setPlanFileName(null);
+ return;
+ }
+
+ // Find the most recently modified file
+ let latestFile = '';
+ let latestMtime = 0;
+
+ for (const file of mdFiles) {
+ const filePath = path.join(plansDir, file);
+ const stats = await fs.promises.stat(filePath);
+ if (stats.mtimeMs > latestMtime) {
+ latestMtime = stats.mtimeMs;
+ latestFile = file;
+ }
+ }
+
+ if (
+ latestMtime > lastModifiedRef.current ||
+ latestFile !== planFileName
+ ) {
+ const content = await fs.promises.readFile(
+ path.join(plansDir, latestFile),
+ 'utf8',
+ );
+ const todos = parseMarkdownTodos(content);
+ setPlanTodos(todos);
+ setPlanFileName(latestFile);
+ lastModifiedRef.current = latestMtime;
+ }
+ } catch (error) {
+ debugLogger.error('File operation for updating plan failed', error);
+ }
+ };
+
+ // Initial check
+ void updatePlan();
+
+ // Poll every 2 seconds for updates to the plan directory
+ const interval = setInterval(() => {
+ void updatePlan();
+ }, 2000);
+
+ return () => clearInterval(interval);
+ }, [config, planFileName]);
+
+ return { planTodos, planFileName };
+}
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index b4be2cbdfc..d72c36a4bb 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -57,7 +57,6 @@ import {
import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
import { ideContextStore } from '../ide/ideContext.js';
-import { WriteTodosTool } from '../tools/write-todos.js';
import type { FileSystemService } from '../services/fileSystemService.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { logRipgrepFallback, logFlashFallback } from '../telemetry/loggers.js';
@@ -220,6 +219,7 @@ import {
import { McpClientManager } from '../tools/mcp-client-manager.js';
import type { EnvironmentSanitizationConfig } from '../services/environmentSanitization.js';
import { getErrorMessage } from '../utils/errors.js';
+import { WriteTodosTool } from 'src/tools/write-todos.js';
export type { FileFilteringOptions };
export {
diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts
index 83e346f368..9df1cc3354 100644
--- a/packages/core/src/core/prompts.ts
+++ b/packages/core/src/core/prompts.ts
@@ -161,6 +161,12 @@ ${planModeToolsList}
## Plan Storage
- Save your plans as Markdown (.md) files directly to: \`${plansDir}/\`
- Use descriptive filenames: \`feature-name.md\` or \`bugfix-description.md\`
+- **Source of Truth:** The UI is based on these markdown files. To show progress in the CLI, you MUST update the plan file with the status markers below.
+- **Task Status Markers:** Use the following markers in your task lists to update the UI:
+ - \`- [ ] Task\` : Pending
+ - \`- [/] Task\` : In Progress
+ - \`- [x] Task\` : Completed
+ - \`- [-] Task\` : Cancelled
## Workflow Phases
@@ -271,6 +277,11 @@ When requested to perform tasks like fixing bugs, adding features, refactoring,
5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards.${interactiveMode ? " If unsure about these commands, you can ask the user if they'd like you to run them and if so how to." : ''}
6. **Finalize:** After all verification passes, consider the task complete. Do not remove or revert any changes or created files (like tests). Await the user's next instruction.
+**Monitoring Progress:**
+- **Plans Directory:** \`${config.storage.getProjectTempPlansDir()}/\`
+- If a plan file exists in this directory, you MUST update it to reflect your progress using the \`write_file\` or \`replace\` tools.
+- Use markers: \`- [ ]\` (Pending), \`- [/]\` (In Progress), \`- [x]\` (Completed), \`- [-]\` (Cancelled).
+
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are '${WRITE_FILE_TOOL_NAME}', '${EDIT_TOOL_NAME}' and '${SHELL_TOOL_NAME}'.
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 348df878d5..90896c8dcb 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -76,6 +76,7 @@ export * from './utils/tool-utils.js';
export * from './utils/terminalSerializer.js';
export * from './utils/systemEncoding.js';
export * from './utils/textUtils.js';
+export * from './utils/planUtils.js';
export * from './utils/formatters.js';
export * from './utils/generateContentResponseUtilities.js';
export * from './utils/filesearch/fileSearch.js';
diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml
index 308465e20c..008fd1e692 100644
--- a/packages/core/src/policy/policies/plan.toml
+++ b/packages/core/src/policy/policies/plan.toml
@@ -69,5 +69,4 @@ modes = ["plan"]
toolName = "write_file"
decision = "allow"
priority = 50
-modes = ["plan"]
argsPattern = "\"file_path\":\"[^\"]+/\\.gemini/tmp/[a-f0-9]{64}/plans/[a-zA-Z0-9_-]+\\.md\""
diff --git a/packages/core/src/utils/planUtils.test.ts b/packages/core/src/utils/planUtils.test.ts
new file mode 100644
index 0000000000..a6a7408d93
--- /dev/null
+++ b/packages/core/src/utils/planUtils.test.ts
@@ -0,0 +1,60 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { parseMarkdownTodos } from './planUtils.js';
+
+describe('parseMarkdownTodos', () => {
+ it('parses basic task list', () => {
+ const markdown = `
+# Plan
+- [ ] Task 1
+- [x] Task 2
+- [/] Task 3
+- [-] Task 4
+ `;
+ const todos = parseMarkdownTodos(markdown);
+ expect(todos).toEqual([
+ { description: 'Task 1', status: 'pending' },
+ { description: 'Task 2', status: 'completed' },
+ { description: 'Task 3', status: 'in_progress' },
+ { description: 'Task 4', status: 'cancelled' },
+ ]);
+ });
+
+ it('parses alternate in-progress markers', () => {
+ const markdown = `
+- [>] Task 5
+- [ / ] Task 6
+ `;
+ const todos = parseMarkdownTodos(markdown);
+ expect(todos).toEqual([
+ { description: 'Task 5', status: 'in_progress' },
+ { description: 'Task 6', status: 'in_progress' },
+ ]);
+ });
+
+ it('handles nested lists', () => {
+ const markdown = `
+- [ ] Outer
+ - [x] Inner
+ `;
+ const todos = parseMarkdownTodos(markdown);
+ expect(todos).toEqual([
+ { description: 'Outer', status: 'pending' },
+ { description: 'Inner', status: 'completed' },
+ ]);
+ });
+
+ it('ignores non-task list items', () => {
+ const markdown = `
+- Just a bullet
+- [ ] A task
+ `;
+ const todos = parseMarkdownTodos(markdown);
+ expect(todos).toEqual([{ description: 'A task', status: 'pending' }]);
+ });
+});
diff --git a/packages/core/src/utils/planUtils.ts b/packages/core/src/utils/planUtils.ts
new file mode 100644
index 0000000000..897619bd51
--- /dev/null
+++ b/packages/core/src/utils/planUtils.ts
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { marked } from 'marked';
+import { type Todo, type TodoStatus } from '../tools/tools.js';
+
+interface MarkedToken {
+ type: string;
+ raw: string;
+ tokens?: MarkedToken[];
+ items?: MarkedToken[];
+}
+
+/**
+ * Parses markdown content to extract task lists and convert them to Todo items.
+ *
+ * Supported markers:
+ * - [ ] -> pending
+ * - [x], [X] -> completed
+ * - [/], [>] -> in_progress
+ * - [-] -> cancelled
+ *
+ * @param content The markdown content to parse.
+ * @returns An array of Todo items.
+ */
+export function parseMarkdownTodos(content: string): Todo[] {
+ const tokens = marked.lexer(content) as unknown as MarkedToken[];
+ const todos: Todo[] = [];
+
+ const walk = (token: MarkedToken) => {
+ if (token.type === 'list_item') {
+ const raw = token.raw.trim();
+ // Check for task marker manually since marked only supports [ ] and [x]
+ // Support [ ], [x], [/], [>], [-] with optional spaces
+ const taskMarkerMatch = raw.match(
+ /^[-*+]\s+\[\s*([xX/\\>-]?)\s*\]\s+(.*)/s,
+ );
+
+ if (taskMarkerMatch) {
+ const marker = taskMarkerMatch[1].toLowerCase();
+ const description = taskMarkerMatch[2].split('\n')[0].trim(); // Take only the first line as description
+
+ let status: TodoStatus = 'pending';
+ if (marker === 'x') {
+ status = 'completed';
+ } else if (marker === '/' || marker === '>') {
+ status = 'in_progress';
+ } else if (marker === '-') {
+ status = 'cancelled';
+ } else if (marker === '') {
+ status = 'pending';
+ }
+
+ todos.push({
+ description,
+ status,
+ });
+ }
+ }
+
+ if (token.tokens) {
+ token.tokens.forEach(walk);
+ }
+ if (token.items) {
+ token.items.forEach(walk);
+ }
+ };
+
+ tokens.forEach(walk);
+ return todos;
+}