diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index bc8f8b44ce..2548b40cd4 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -38,6 +38,7 @@ These commands are available within the interactive REPL. | `/mcp reload` | Restart and reload MCP servers | | `/extensions reload` | Reload all active extensions | | `/help` | Show help for all commands | +| `/loop` | Schedule a recurring task | | `/quit` | Exit the interactive session | ## CLI Options diff --git a/docs/cli/scheduled-tasks.md b/docs/cli/scheduled-tasks.md new file mode 100644 index 0000000000..f570368a12 --- /dev/null +++ b/docs/cli/scheduled-tasks.md @@ -0,0 +1,106 @@ +# Scheduled tasks + +Scheduled tasks let you run prompts repeatedly, poll for status, or set one-time +reminders within a Gemini CLI session. You can use them to check the status of a +deployment, monitor a long-running build, or remind yourself to perform a task +later in your workflow. + + +> [!NOTE] +> Scheduled tasks are session-scoped. They only run while your Gemini CLI +> session is open and are cleared when you exit the CLI. For persistent +> scheduling, use your operating system's native scheduling tools (like `cron` +> or Task Scheduler). + +## Schedule a recurring prompt with /loop + +The `/loop` command is the fastest way to schedule a recurring prompt. You can +provide an optional interval and a prompt, and Gemini CLI schedules the task to +run in the background. + +```text +/loop 5m check if the deployment finished +``` + +If you don't provide an interval, the command defaults to 10 minutes. + +### Interval syntax + +Intervals use a simple numeric value followed by a unit character. Supported +units are `s` for seconds, `m` for minutes, `h` for hours, and `d` for days. + +| Form | Example | Interval | +| :------------ | :-------------------------- | :--------------- | +| Leading token | `/loop 30m check the build` | Every 30 minutes | +| No interval | `/loop check the build` | Every 10 minutes | + +### Loop over another command + +A scheduled prompt can be a slash command or a skill invocation. This lets you +automate existing workflows. + +```text +/loop 20m /git:status +``` + +Gemini CLI executes the command as if you had typed it yourself. + +## Set a one-time reminder + +To set a single reminder, describe what you want in natural language. Gemini CLI +uses its internal scheduling tools to set a one-time task that removes itself +after firing. + +```text +In 15 minutes, remind me to push my changes +``` + +```text +Remind me in 1h to check the integration tests +``` + +## Manage scheduled tasks + +You can ask Gemini CLI to list or cancel your active tasks using natural +language. + +```text +What scheduled tasks do I have? +``` + +```text +Cancel the deployment check task +``` + +Under the hood, the agent uses these tools to manage your schedule: + +| Tool | Purpose | +| :---------------------- | :------------------------------------------------- | +| `schedule_task` | Schedules a new recurring or one-time task. | +| `list_scheduled_tasks` | Lists all active tasks with their IDs and prompts. | +| `cancel_scheduled_task` | Cancels a specific task using its ID. | + +## How scheduled tasks run + +Scheduled tasks trigger only when Gemini CLI is idle. + +- **Non-disruptive:** If a task becomes due while the agent is busy generating a + response or executing a tool, the prompt is queued. +- **Sequential:** The queued prompt executes immediately after the current turn + finishes. +- **Shared context:** Scheduled tasks run within your active session and have + access to the full conversation history and any files you have added to the + context. + +## Limitations + +Session-scoped scheduling has the following constraints: + +- **Active session required:** Tasks only fire while Gemini CLI is running. + Closing your terminal or exiting the session cancels all tasks. +- **Shared context window:** Every task execution adds to your session's history + and consumes tokens in the context window. High-frequency loops may trigger + [context compression](./token-caching.md) sooner. +- **No persistence:** Restarting Gemini CLI clears all tasks. +- **Simple intervals:** Only simple time intervals are supported (e.g., `5m`). + Complex cron expressions (e.g., `0 9 * * 1-5`) are not supported. diff --git a/docs/sidebar.json b/docs/sidebar.json index ea82a64481..5270fcf2a2 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -98,6 +98,7 @@ { "label": "Agent Skills", "slug": "docs/cli/skills" }, { "label": "Checkpointing", "slug": "docs/cli/checkpointing" }, { "label": "Headless mode", "slug": "docs/cli/headless" }, + { "label": "Scheduled tasks", "slug": "docs/cli/scheduled-tasks" }, { "label": "Git worktrees", "badge": "🔬", diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b78052bbf2..ccd242e891 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -83,9 +83,9 @@ import { logBillingEvent, ApiKeyUpdatedEvent, type InjectionSource, - cronSchedulerService, - type ScheduledTask} from '@google/gemini-cli-core'; + type ScheduledTask, +} from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; import { useHistory } from './hooks/useHistoryManager.js'; @@ -1200,6 +1200,18 @@ Logging in with Google... Restarting Gemini CLI to continue. const { isMcpReady } = useMcpStatus(config); + const [scheduledTasks, setScheduledTasks] = useState([]); + + useEffect(() => { + const updateTasks = () => { + setScheduledTasks(cronSchedulerService.listTasks()); + }; + updateTasks(); + // Poor man's sync: polling for list updates since it's mostly local + const interval = setInterval(updateTasks, 2000); + return () => clearInterval(interval); + }, []); + const { messageQueue, addMessage, @@ -2337,6 +2349,7 @@ Logging in with Google... Restarting Gemini CLI to continue. terminalBackgroundColor: config.getTerminalBackground(), settingsNonce, backgroundTasks, + scheduledTasks, activeBackgroundTaskPid, backgroundTaskHeight, isBackgroundTaskListOpen, @@ -2464,6 +2477,7 @@ Logging in with Google... Restarting Gemini CLI to continue. settingsNonce, backgroundTaskHeight, isBackgroundTaskListOpen, + scheduledTasks, activeBackgroundTaskPid, backgroundTasks, adminSettingsChanged, diff --git a/packages/cli/src/ui/components/BackgroundTaskDisplay.test.tsx b/packages/cli/src/ui/components/BackgroundTaskDisplay.test.tsx index 6083a0e569..c1eda64889 100644 --- a/packages/cli/src/ui/components/BackgroundTaskDisplay.test.tsx +++ b/packages/cli/src/ui/components/BackgroundTaskDisplay.test.tsx @@ -149,6 +149,7 @@ describe('', () => { ', () => { ', () => { ', () => { ', () => { ', () => { ', () => { ', () => { ', () => { ', () => { ', () => { ; + scheduledTasks: ScheduledTask[]; activePid: number; width: number; height: number; @@ -63,6 +65,7 @@ const formatShellCommandForDisplay = (command: string, maxWidth: number) => { export const BackgroundTaskDisplay = ({ shells, + scheduledTasks, activePid, width, height, @@ -335,60 +338,74 @@ export const BackgroundTaskDisplay = ({ - = 0 ? initialIndex : 0} - onSelect={(pid) => { - setActiveBackgroundTaskPid(pid); - setIsBackgroundTaskListOpen(false); - }} - onHighlight={(pid) => setHighlightedPid(pid)} - isFocused={isFocused} - maxItemsToShow={Math.max( - 1, - height - TOTAL_OVERHEAD_HEIGHT - PROCESS_LIST_HEADER_HEIGHT, - )} - renderItem={( - item, - { isSelected: _isSelected, titleColor: _titleColor }, - ) => { - // Custom render to handle exit code coloring if needed, - // or just use default. The default RadioButtonSelect renderer - // handles standard label. - // But we want to color exit code differently? - // The previous implementation colored exit code green/red. - // Let's reimplement that. + {shells.size > 0 && ( + = 0 ? initialIndex : 0} + onSelect={(pid) => { + setActiveBackgroundTaskPid(pid); + setIsBackgroundTaskListOpen(false); + }} + onHighlight={(pid) => setHighlightedPid(pid)} + isFocused={isFocused} + maxItemsToShow={Math.max( + 1, + height - + TOTAL_OVERHEAD_HEIGHT - + PROCESS_LIST_HEADER_HEIGHT - + (scheduledTasks.length > 0 ? scheduledTasks.length + 2 : 0), + )} + renderItem={( + item, + { isSelected: _isSelected, titleColor: _titleColor }, + ) => { + const shell = shells.get(item.value); + if (!shell) return {item.label}; - // We need access to shell details here. - // We can put shell details in the item or lookup. - // Lookup from shells map. - const shell = shells.get(item.value); - if (!shell) return {item.label}; + const truncatedCommand = formatShellCommandForDisplay( + shell.command, + maxCommandLength, + ); - const truncatedCommand = formatShellCommandForDisplay( - shell.command, - maxCommandLength, - ); - - return ( - - {truncatedCommand} (PID: {shell.pid}) - {shell.status === 'exited' ? ( - - {' '} - (Exit Code: {shell.exitCode}) - - ) : null} + return ( + + {truncatedCommand} (PID: {shell.pid}) + {shell.status === 'exited' ? ( + + {' '} + (Exit Code: {shell.exitCode}) + + ) : null} + + ); + }} + /> + )} + {scheduledTasks.length > 0 && ( + 0 ? 1 : 0}> + + Scheduled Loops + + {scheduledTasks.map((task) => ( + + {` ● [${task.id}] every ${task.intervalMs! / 1000}s: "${task.prompt}"`} - ); - }} - /> + ))} + + )} ); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index a5d10820b2..a7e8f0bb92 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -29,7 +29,7 @@ import type { AgentDefinition, FolderDiscoveryResults, PolicyUpdateConfirmationRequest, -} from '@google/gemini-cli-core'; + type ScheduledTask } from '@google/gemini-cli-core'; import { type TransientMessageType } from '../../utils/events.js'; import type { DOMElement } from 'ink'; import type { SessionStatsState } from '../contexts/SessionContext.js'; @@ -216,6 +216,7 @@ export interface UIState { terminalBackgroundColor: TerminalBackgroundColor; settingsNonce: number; backgroundTasks: Map; + scheduledTasks: ScheduledTask[]; activeBackgroundTaskPid: number | null; backgroundTaskHeight: number; isBackgroundTaskListOpen: boolean; diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index aaa9e04632..c8cdec3d9e 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -40,14 +40,15 @@ export const DefaultAppLayout: React.FC = () => { {uiState.isBackgroundTaskVisible && - uiState.backgroundTasks.size > 0 && - uiState.activeBackgroundTaskPid && + (uiState.backgroundTasks.size > 0 || + uiState.scheduledTasks.length > 0) && uiState.backgroundTaskHeight > 0 && uiState.streamingState !== StreamingState.WaitingForConfirmation && (