From 322b866a5aabd1d42a933bd9c3b1844b1d87a6b0 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Tue, 27 Jan 2026 11:44:55 -0500 Subject: [PATCH] complete --- .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/ui/AppContainer.tsx | 7 ++ packages/cli/src/ui/commands/planCommand.ts | 65 +++++++++++++++ .../cli/src/ui/components/messages/Todo.tsx | 24 ++++-- .../cli/src/ui/contexts/UIStateContext.tsx | 4 +- .../cli/src/ui/hooks/usePlanMonitoring.ts | 83 +++++++++++++++++++ packages/core/src/config/config.ts | 2 +- packages/core/src/core/prompts.ts | 11 +++ packages/core/src/index.ts | 1 + packages/core/src/policy/policies/plan.toml | 1 - packages/core/src/utils/planUtils.test.ts | 60 ++++++++++++++ packages/core/src/utils/planUtils.ts | 74 +++++++++++++++++ 12 files changed, 326 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/ui/commands/planCommand.ts create mode 100644 packages/cli/src/ui/hooks/usePlanMonitoring.ts create mode 100644 packages/core/src/utils/planUtils.test.ts create mode 100644 packages/core/src/utils/planUtils.ts 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; +}