mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 16:20:57 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80ff4bd39c |
@@ -38,7 +38,6 @@ 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
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
# 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.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!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.
|
||||
@@ -98,7 +98,6 @@
|
||||
{ "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": "🔬",
|
||||
|
||||
@@ -32,7 +32,6 @@ import { docsCommand } from '../ui/commands/docsCommand.js';
|
||||
import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { loopCommand } from '../ui/commands/loopCommand.js';
|
||||
import { footerCommand } from '../ui/commands/footerCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js';
|
||||
@@ -156,7 +155,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
]
|
||||
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
|
||||
helpCommand,
|
||||
loopCommand,
|
||||
footerCommand,
|
||||
shortcutsCommand,
|
||||
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
|
||||
|
||||
@@ -534,6 +534,7 @@ export const mockAppState: AppState = {
|
||||
};
|
||||
|
||||
const mockUIActions: UIActions = {
|
||||
toggleAlternateBuffer: vi.fn(),
|
||||
handleThemeSelect: vi.fn(),
|
||||
closeThemeDialog: vi.fn(),
|
||||
handleThemeHighlight: vi.fn(),
|
||||
|
||||
@@ -68,8 +68,10 @@ import {
|
||||
writeToStdout,
|
||||
disableMouseEvents,
|
||||
enterAlternateScreen,
|
||||
exitAlternateScreen,
|
||||
enableMouseEvents,
|
||||
disableLineWrapping,
|
||||
enableLineWrapping,
|
||||
shouldEnterAlternateScreen,
|
||||
startupProfiler,
|
||||
SessionStartSource,
|
||||
@@ -83,8 +85,6 @@ import {
|
||||
logBillingEvent,
|
||||
ApiKeyUpdatedEvent,
|
||||
type InjectionSource,
|
||||
cronSchedulerService,
|
||||
type ScheduledTask,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import process from 'node:process';
|
||||
@@ -215,7 +215,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
});
|
||||
|
||||
useMemoryMonitor(historyManager);
|
||||
const isAlternateBuffer = config.getUseAlternateBuffer();
|
||||
const [isAlternateBuffer, setIsAlternateBuffer] = useState(config.getUseAlternateBuffer());
|
||||
const [corgiMode, setCorgiMode] = useState(false);
|
||||
const [forceRerenderKey, setForceRerenderKey] = useState(0);
|
||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||
@@ -1200,18 +1200,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const { isMcpReady } = useMcpStatus(config);
|
||||
|
||||
const [scheduledTasks, setScheduledTasks] = useState<ScheduledTask[]>([]);
|
||||
|
||||
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,
|
||||
@@ -1225,16 +1213,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isMcpReady,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleTaskDue = (task: ScheduledTask) => {
|
||||
addMessage(task.prompt);
|
||||
};
|
||||
cronSchedulerService.on('task_due', handleTaskDue);
|
||||
return () => {
|
||||
cronSchedulerService.off('task_due', handleTaskDue);
|
||||
};
|
||||
}, [addMessage]);
|
||||
|
||||
cancelHandlerRef.current = useCallback(
|
||||
(shouldRestorePrompt: boolean = true) => {
|
||||
if (isToolAwaitingConfirmation(pendingHistoryItems)) {
|
||||
@@ -1574,6 +1552,23 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
type: TransientMessageType;
|
||||
}>(WARNING_PROMPT_DURATION_MS);
|
||||
|
||||
const [shownBufferToggleHint, setShownBufferToggleHint] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAlternateBuffer) return;
|
||||
|
||||
const isLongHistory = historyManager.history.length > 15;
|
||||
const isComplexPrompt = buffer.text.length > 200 || buffer.text.includes('\n');
|
||||
|
||||
if ((isLongHistory || isComplexPrompt) && !shownBufferToggleHint) {
|
||||
showTransientMessage({
|
||||
text: 'Tip: Press Alt+T to toggle full-screen mode for better scrolling/editing',
|
||||
type: TransientMessageType.Hint
|
||||
});
|
||||
setShownBufferToggleHint(true);
|
||||
}
|
||||
}, [historyManager.history.length, buffer.text, isAlternateBuffer, shownBufferToggleHint, showTransientMessage]);
|
||||
|
||||
const {
|
||||
isFolderTrustDialogOpen,
|
||||
discoveryResults: folderDiscoveryResults,
|
||||
@@ -1724,6 +1719,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BUFFER_MODE](key)) {
|
||||
toggleAlternateBuffer();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
|
||||
// This should happen regardless of the count.
|
||||
@@ -2228,8 +2228,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
};
|
||||
}, [config, refreshStatic]);
|
||||
|
||||
const showIsAlternateBufferHint = (historyManager.history.length > 15 || buffer.text.length > 200 || buffer.text.includes('\n')) && !isAlternateBuffer;
|
||||
|
||||
const uiState: UIState = useMemo(
|
||||
() => ({
|
||||
isAlternateBuffer,
|
||||
history: historyManager.history,
|
||||
historyManager,
|
||||
isThemeDialogOpen,
|
||||
@@ -2349,13 +2352,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
terminalBackgroundColor: config.getTerminalBackground(),
|
||||
settingsNonce,
|
||||
backgroundTasks,
|
||||
scheduledTasks,
|
||||
activeBackgroundTaskPid,
|
||||
backgroundTaskHeight,
|
||||
isBackgroundTaskListOpen,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
showIsAlternateBufferHint,
|
||||
hintMode:
|
||||
config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems),
|
||||
hintBuffer: '',
|
||||
@@ -2477,12 +2480,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
settingsNonce,
|
||||
backgroundTaskHeight,
|
||||
isBackgroundTaskListOpen,
|
||||
scheduledTasks,
|
||||
activeBackgroundTaskPid,
|
||||
backgroundTasks,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
showIsAlternateBufferHint,
|
||||
isAlternateBuffer,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2491,6 +2495,31 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
[setShowPrivacyNotice],
|
||||
);
|
||||
|
||||
const toggleAlternateBuffer = useCallback(() => {
|
||||
setIsAlternateBuffer(prev => {
|
||||
const next = !prev;
|
||||
if (next) {
|
||||
enterAlternateScreen();
|
||||
enableMouseEvents();
|
||||
disableLineWrapping();
|
||||
} else {
|
||||
exitAlternateScreen();
|
||||
disableMouseEvents();
|
||||
enableLineWrapping();
|
||||
writeToStdout('\x1b[2J\x1b[H');
|
||||
}
|
||||
process.stdout.emit('resize');
|
||||
|
||||
// Give a tick for resize to process, then trigger remount to force full redraw
|
||||
setImmediate(() => {
|
||||
refreshStatic();
|
||||
setForceRerenderKey((prev) => prev + 1);
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
}, [setIsAlternateBuffer, refreshStatic, setForceRerenderKey]);
|
||||
|
||||
const uiActions: UIActions = useMemo(
|
||||
() => ({
|
||||
handleThemeSelect,
|
||||
@@ -2502,6 +2531,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleEditorSelect,
|
||||
exitEditorDialog,
|
||||
exitPrivacyNotice,
|
||||
toggleAlternateBuffer,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
openAgentConfigDialog,
|
||||
@@ -2640,6 +2670,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
config,
|
||||
historyManager,
|
||||
getPreferredEditor,
|
||||
toggleAlternateBuffer,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { loopCommand } from './loopCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { cronSchedulerService } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
cronSchedulerService: {
|
||||
scheduleTask: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('loopCommand', () => {
|
||||
let mockContext: ReturnType<typeof createMockCommandContext>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should print an error if no args are provided', async () => {
|
||||
mockContext.invocation!.args = ' ';
|
||||
await loopCommand.action!(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining('Please provide a prompt'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should default to 10m if no interval is provided', async () => {
|
||||
mockContext.invocation!.args = 'check the build';
|
||||
vi.mocked(cronSchedulerService.scheduleTask).mockReturnValue('abc12345');
|
||||
|
||||
await loopCommand.action!(mockContext, '');
|
||||
|
||||
expect(cronSchedulerService.scheduleTask).toHaveBeenCalledWith(
|
||||
'10m',
|
||||
'check the build',
|
||||
true,
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('every 10m'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse leading interval', async () => {
|
||||
mockContext.invocation!.args = '5m check the build';
|
||||
vi.mocked(cronSchedulerService.scheduleTask).mockReturnValue('def56789');
|
||||
|
||||
await loopCommand.action!(mockContext, '');
|
||||
|
||||
expect(cronSchedulerService.scheduleTask).toHaveBeenCalledWith(
|
||||
'5m',
|
||||
'check the build',
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle scheduling errors', async () => {
|
||||
mockContext.invocation!.args = 'invalid check the build';
|
||||
vi.mocked(cronSchedulerService.scheduleTask).mockImplementation(() => {
|
||||
throw new Error('Invalid format');
|
||||
});
|
||||
|
||||
await loopCommand.action!(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining(
|
||||
'Failed to schedule task: Invalid format',
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import { cronSchedulerService } from '@google/gemini-cli-core';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
export const loopCommand: SlashCommand = {
|
||||
name: 'loop',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Schedules a repeating prompt (e.g., /loop 5m check the build)',
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
const args = context.invocation?.args?.trim() || '';
|
||||
if (!args) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: 'Please provide a prompt to loop. Example: /loop 5m check the build',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Default to 10 minutes if no interval is provided
|
||||
let intervalString = '10m';
|
||||
|
||||
// Check if the first word is an interval
|
||||
const match = args.match(/^(\d+[smhd])\s+(.*)/i);
|
||||
let prompt = args;
|
||||
|
||||
if (match) {
|
||||
intervalString = match[1].toLowerCase();
|
||||
prompt = match[2].trim();
|
||||
}
|
||||
|
||||
try {
|
||||
const id = cronSchedulerService.scheduleTask(
|
||||
intervalString,
|
||||
prompt,
|
||||
true,
|
||||
);
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Scheduled recurring task \`${id}\` to run \`${prompt}\` every ${intervalString}.`,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Failed to schedule task: ${message}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -149,7 +149,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -170,7 +169,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -191,7 +189,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -212,7 +209,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -233,7 +229,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={100}
|
||||
height={30}
|
||||
@@ -257,7 +252,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -278,7 +272,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -310,7 +303,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -343,7 +335,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -369,7 +360,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={shell2.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
@@ -401,7 +391,6 @@ describe('<BackgroundTaskDisplay />', () => {
|
||||
<ScrollProvider>
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
scheduledTasks={[]}
|
||||
activePid={exitedShell.pid}
|
||||
width={width}
|
||||
height={24}
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
type AnsiOutput,
|
||||
type AnsiLine,
|
||||
type AnsiToken,
|
||||
type ScheduledTask,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||
@@ -37,7 +36,6 @@ import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
|
||||
interface BackgroundTaskDisplayProps {
|
||||
shells: Map<number, BackgroundTask>;
|
||||
scheduledTasks: ScheduledTask[];
|
||||
activePid: number;
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -65,7 +63,6 @@ const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
|
||||
|
||||
export const BackgroundTaskDisplay = ({
|
||||
shells,
|
||||
scheduledTasks,
|
||||
activePid,
|
||||
width,
|
||||
height,
|
||||
@@ -338,74 +335,60 @@ export const BackgroundTaskDisplay = ({
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width="100%">
|
||||
{shells.size > 0 && (
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
initialIndex={initialIndex >= 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 <Text>{item.label}</Text>;
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
initialIndex={initialIndex >= 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.
|
||||
|
||||
const truncatedCommand = formatShellCommandForDisplay(
|
||||
shell.command,
|
||||
maxCommandLength,
|
||||
);
|
||||
// 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 <Text>{item.label}</Text>;
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{truncatedCommand} (PID: {shell.pid})
|
||||
{shell.status === 'exited' ? (
|
||||
<Text
|
||||
color={
|
||||
shell.exitCode === 0
|
||||
? theme.status.success
|
||||
: theme.status.error
|
||||
}
|
||||
>
|
||||
{' '}
|
||||
(Exit Code: {shell.exitCode})
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{scheduledTasks.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={shells.size > 0 ? 1 : 0}>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
paddingTop={1}
|
||||
marginBottom={1}
|
||||
>
|
||||
<Text bold>Scheduled Loops</Text>
|
||||
</Box>
|
||||
{scheduledTasks.map((task) => (
|
||||
<Text key={task.id}>
|
||||
{` ● [${task.id}] every ${task.intervalMs! / 1000}s: "${task.prompt}"`}
|
||||
const truncatedCommand = formatShellCommandForDisplay(
|
||||
shell.command,
|
||||
maxCommandLength,
|
||||
);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{truncatedCommand} (PID: {shell.pid})
|
||||
{shell.status === 'exited' ? (
|
||||
<Text
|
||||
color={
|
||||
shell.exitCode === 0
|
||||
? theme.status.success
|
||||
: theme.status.error
|
||||
}
|
||||
>
|
||||
{' '}
|
||||
(Exit Code: {shell.exitCode})
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -142,4 +142,38 @@ describe('<StatusRow />', () => {
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Tip: Test Tip');
|
||||
});
|
||||
|
||||
it('renders buffer toggle hint when showIsAlternateBufferHint is true', async () => {
|
||||
(useComposerStatus as Mock).mockReturnValue({
|
||||
isInteractiveShellWaiting: false,
|
||||
showLoadingIndicator: false,
|
||||
showTips: false,
|
||||
showWit: false,
|
||||
modeContentObj: null,
|
||||
showMinimalContext: false,
|
||||
});
|
||||
|
||||
const uiState: Partial<UIState> = {
|
||||
...defaultUiState,
|
||||
showIsAlternateBufferHint: true,
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<StatusRow
|
||||
showUiDetails={false}
|
||||
isNarrow={false}
|
||||
terminalWidth={100}
|
||||
hideContextSummary={false}
|
||||
hideUiDetailsForSuggestions={false}
|
||||
hasPendingActionRequired={false}
|
||||
/>,
|
||||
{
|
||||
width: 100,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('[Alt+T] Switch to Full Screen');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,7 +206,12 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
return uiState.currentTip;
|
||||
}
|
||||
|
||||
// 2. Shortcut Hint (Fallback)
|
||||
// 2. Buffer Toggle Hint
|
||||
if (uiState.showIsAlternateBufferHint) {
|
||||
return '[Alt+T] Switch to Full Screen';
|
||||
}
|
||||
|
||||
// 3. Shortcut Hint (Fallback)
|
||||
if (
|
||||
settings.merged.ui.showShortcutsHint &&
|
||||
!hideUiDetailsForSuggestions &&
|
||||
|
||||
@@ -205,6 +205,8 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
|
||||
'\u03A9': 'z', // "Ω" Option+z
|
||||
'\u00B8': 'Z', // "¸" Option+Shift+z
|
||||
'\u2202': 'd', // "∂" delete word forward
|
||||
'\u2020': 't', // "†" toggle full screen buffer
|
||||
'\u00E5': 'a', // "å" Option+a for alternate buffer
|
||||
};
|
||||
|
||||
function nonKeyboardEventFilter(
|
||||
|
||||
@@ -78,6 +78,7 @@ export interface UIActions {
|
||||
setShortcutsHelpVisible: (visible: boolean) => void;
|
||||
setCleanUiDetailsVisible: (visible: boolean) => void;
|
||||
toggleCleanUiDetailsVisible: () => void;
|
||||
toggleAlternateBuffer: () => void;
|
||||
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
|
||||
handleWarning: (message: string) => void;
|
||||
setEmbeddedShellFocused: (value: boolean) => void;
|
||||
|
||||
@@ -29,7 +29,6 @@ import type {
|
||||
AgentDefinition,
|
||||
FolderDiscoveryResults,
|
||||
PolicyUpdateConfirmationRequest,
|
||||
ScheduledTask,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type TransientMessageType } from '../../utils/events.js';
|
||||
import type { DOMElement } from 'ink';
|
||||
@@ -119,6 +118,8 @@ export interface UIState {
|
||||
isEditorDialogOpen: boolean;
|
||||
showPrivacyNotice: boolean;
|
||||
corgiMode: boolean;
|
||||
isAlternateBuffer: boolean;
|
||||
showIsAlternateBufferHint: boolean;
|
||||
debugMessage: string;
|
||||
quittingMessages: HistoryItem[] | null;
|
||||
isSettingsDialogOpen: boolean;
|
||||
@@ -217,7 +218,6 @@ export interface UIState {
|
||||
terminalBackgroundColor: TerminalBackgroundColor;
|
||||
settingsNonce: number;
|
||||
backgroundTasks: Map<number, BackgroundTask>;
|
||||
scheduledTasks: ScheduledTask[];
|
||||
activeBackgroundTaskPid: number | null;
|
||||
backgroundTaskHeight: number;
|
||||
isBackgroundTaskListOpen: boolean;
|
||||
|
||||
@@ -11,49 +11,47 @@ import {
|
||||
isAlternateBufferEnabled,
|
||||
} from './useAlternateBuffer.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
vi.mock('../contexts/ConfigContext.js', () => ({
|
||||
useConfig: vi.fn(),
|
||||
}));
|
||||
vi.mock('../contexts/UIStateContext.js');
|
||||
|
||||
const mockUseConfig = vi.mocked(
|
||||
await import('../contexts/ConfigContext.js').then((m) => m.useConfig),
|
||||
);
|
||||
const mockUseUIState = vi.mocked(useUIState);
|
||||
|
||||
describe('useAlternateBuffer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return false when config.getUseAlternateBuffer returns false', async () => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
getUseAlternateBuffer: () => false,
|
||||
} as unknown as ReturnType<typeof mockUseConfig>);
|
||||
it('should return false when uiState.isAlternateBuffer is false', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: false,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
|
||||
const { result } = await renderHook(() => useAlternateBuffer());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when config.getUseAlternateBuffer returns true', async () => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
getUseAlternateBuffer: () => true,
|
||||
} as unknown as ReturnType<typeof mockUseConfig>);
|
||||
it('should return true when uiState.isAlternateBuffer is true', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: true,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
|
||||
const { result } = await renderHook(() => useAlternateBuffer());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return the immutable config value, not react to settings changes', async () => {
|
||||
const mockConfig = {
|
||||
getUseAlternateBuffer: () => true,
|
||||
} as unknown as ReturnType<typeof mockUseConfig>;
|
||||
|
||||
mockUseConfig.mockReturnValue(mockConfig);
|
||||
it('should react to state changes', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: false,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
|
||||
const { result, rerender } = await renderHook(() => useAlternateBuffer());
|
||||
|
||||
// Value should remain true even after rerender
|
||||
expect(result.current).toBe(true);
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: true,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
|
||||
rerender();
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
export const isAlternateBufferEnabled = (config: Config): boolean =>
|
||||
config.getUseAlternateBuffer();
|
||||
|
||||
// This is read from Config so that the UI reads the same value per application session
|
||||
// This is read from UIState so that the UI can toggle dynamically
|
||||
export const useAlternateBuffer = (): boolean => {
|
||||
const config = useConfig();
|
||||
return isAlternateBufferEnabled(config);
|
||||
const uiState = useUIState();
|
||||
return uiState.isAlternateBuffer;
|
||||
};
|
||||
|
||||
@@ -95,6 +95,7 @@ export enum Command {
|
||||
RESTART_APP = 'app.restart',
|
||||
SUSPEND_APP = 'app.suspend',
|
||||
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning',
|
||||
TOGGLE_BUFFER_MODE = 'app.toggleBufferMode',
|
||||
|
||||
// Background Shell Controls
|
||||
BACKGROUND_SHELL_ESCAPE = 'background.escape',
|
||||
@@ -392,6 +393,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
||||
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
|
||||
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
|
||||
[Command.TOGGLE_BUFFER_MODE, [new KeyBinding('alt+a')]],
|
||||
|
||||
// Background Shell Controls
|
||||
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
|
||||
@@ -609,6 +611,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
|
||||
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
|
||||
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
|
||||
[Command.TOGGLE_BUFFER_MODE]: 'Toggle between regular and full screen (alternate buffer) mode.',
|
||||
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
|
||||
[Command.CYCLE_APPROVAL_MODE]:
|
||||
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',
|
||||
|
||||
@@ -40,15 +40,14 @@ export const DefaultAppLayout: React.FC = () => {
|
||||
<MainContent />
|
||||
|
||||
{uiState.isBackgroundTaskVisible &&
|
||||
(uiState.backgroundTasks.size > 0 ||
|
||||
uiState.scheduledTasks.length > 0) &&
|
||||
uiState.backgroundTasks.size > 0 &&
|
||||
uiState.activeBackgroundTaskPid &&
|
||||
uiState.backgroundTaskHeight > 0 &&
|
||||
uiState.streamingState !== StreamingState.WaitingForConfirmation && (
|
||||
<Box height={uiState.backgroundTaskHeight} flexShrink={0}>
|
||||
<BackgroundTaskDisplay
|
||||
shells={uiState.backgroundTasks}
|
||||
scheduledTasks={uiState.scheduledTasks}
|
||||
activePid={uiState.activeBackgroundTaskPid ?? 0}
|
||||
activePid={uiState.activeBackgroundTaskPid}
|
||||
width={uiState.terminalWidth}
|
||||
height={uiState.backgroundTaskHeight}
|
||||
isFocused={
|
||||
|
||||
@@ -78,11 +78,6 @@ 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 {
|
||||
ScheduleTaskTool,
|
||||
ListTasksTool,
|
||||
CancelTaskTool,
|
||||
} from '../tools/cronTools.js';
|
||||
import {
|
||||
StandardFileSystemService,
|
||||
type FileSystemService,
|
||||
@@ -3444,15 +3439,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
maybeRegister(AskUserTool, () =>
|
||||
registry.registerTool(new AskUserTool(this.messageBus)),
|
||||
);
|
||||
maybeRegister(ScheduleTaskTool, () =>
|
||||
registry.registerTool(new ScheduleTaskTool(this.messageBus)),
|
||||
);
|
||||
maybeRegister(ListTasksTool, () =>
|
||||
registry.registerTool(new ListTasksTool(this.messageBus)),
|
||||
);
|
||||
maybeRegister(CancelTaskTool, () =>
|
||||
registry.registerTool(new CancelTaskTool(this.messageBus)),
|
||||
);
|
||||
if (this.getUseWriteTodos()) {
|
||||
maybeRegister(WriteTodosTool, () =>
|
||||
registry.registerTool(new WriteTodosTool(this.messageBus)),
|
||||
|
||||
@@ -137,7 +137,6 @@ export * from './services/sessionSummaryUtils.js';
|
||||
export * from './services/contextManager.js';
|
||||
export * from './services/trackerService.js';
|
||||
export * from './services/trackerTypes.js';
|
||||
export * from './services/cronSchedulerService.js';
|
||||
export * from './services/keychainService.js';
|
||||
export * from './services/keychainTypes.js';
|
||||
export * from './skills/skillManager.js';
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
CronSchedulerService,
|
||||
type ScheduledTask,
|
||||
} from './cronSchedulerService.js';
|
||||
|
||||
describe('CronSchedulerService', () => {
|
||||
let service: CronSchedulerService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
service = new CronSchedulerService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should parse intervals and schedule a task', () => {
|
||||
const id = service.scheduleTask('5m', 'test prompt');
|
||||
const tasks = service.listTasks();
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].id).toBe(id);
|
||||
expect(tasks[0].prompt).toBe('test prompt');
|
||||
expect(tasks[0].intervalMs).toBe(5 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('should throw on invalid interval', () => {
|
||||
expect(() => service.scheduleTask('invalid', 'test prompt')).toThrow(
|
||||
/Invalid interval format/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit event when task is due', () => {
|
||||
const callback = vi.fn();
|
||||
service.on('task_due', callback);
|
||||
|
||||
service.scheduleTask('10s', 'test prompt');
|
||||
|
||||
// Advance 9 seconds, shouldn't fire
|
||||
vi.advanceTimersByTime(9000);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
// Advance 1 more second, should fire
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
const taskArg = callback.mock.calls[0][0] as ScheduledTask;
|
||||
expect(taskArg.prompt).toBe('test prompt');
|
||||
});
|
||||
|
||||
it('should handle recurring tasks correctly', () => {
|
||||
const callback = vi.fn();
|
||||
service.on('task_due', callback);
|
||||
|
||||
service.scheduleTask('10s', 'test prompt', true);
|
||||
|
||||
// First run
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second run
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle one-shot tasks correctly', () => {
|
||||
const callback = vi.fn();
|
||||
service.on('task_due', callback);
|
||||
|
||||
service.scheduleTask('10s', 'test prompt', false);
|
||||
|
||||
// First run
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(service.listTasks()).toHaveLength(0); // Task should be removed
|
||||
|
||||
// Advance again, shouldn't fire
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should cancel a task', () => {
|
||||
const id = service.scheduleTask('10s', 'test prompt');
|
||||
expect(service.listTasks()).toHaveLength(1);
|
||||
|
||||
service.cancelTask(id);
|
||||
expect(service.listTasks()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
export interface ScheduledTask {
|
||||
id: string;
|
||||
intervalMs: number | null; // null if one-shot (though this basic implementation focuses on recurring)
|
||||
prompt: string;
|
||||
createdAt: number;
|
||||
nextRunAt: number;
|
||||
isRecurring: boolean;
|
||||
}
|
||||
|
||||
export class CronSchedulerService extends EventEmitter {
|
||||
private tasks: Map<string, ScheduledTask> = new Map();
|
||||
private tickInterval: NodeJS.Timeout | null = null;
|
||||
private isRunning = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the background interval that checks for due tasks.
|
||||
*/
|
||||
start() {
|
||||
if (this.isRunning) return;
|
||||
this.isRunning = true;
|
||||
this.tickInterval = setInterval(() => this.tick(), 1000);
|
||||
// Don't prevent process exit if only the scheduler is running
|
||||
this.tickInterval.unref();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the background interval.
|
||||
*/
|
||||
stop() {
|
||||
this.isRunning = false;
|
||||
if (this.tickInterval) {
|
||||
clearInterval(this.tickInterval);
|
||||
this.tickInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a new task.
|
||||
* @param intervalString An interval string like "5m", "10s", "2h".
|
||||
* @param prompt The prompt to execute.
|
||||
* @param isRecurring Whether it's a recurring task or one-shot.
|
||||
* @returns The generated task ID.
|
||||
*/
|
||||
scheduleTask(
|
||||
intervalString: string,
|
||||
prompt: string,
|
||||
isRecurring: boolean = true,
|
||||
): string {
|
||||
const intervalMs = this.parseInterval(intervalString);
|
||||
if (!intervalMs) {
|
||||
throw new Error(
|
||||
`Invalid interval format: ${intervalString}. Supported formats: 10s, 5m, 2h.`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = Math.random().toString(36).substring(2, 10); // 8-character ID
|
||||
const now = Date.now();
|
||||
|
||||
const task: ScheduledTask = {
|
||||
id,
|
||||
intervalMs,
|
||||
prompt,
|
||||
createdAt: now,
|
||||
nextRunAt: now + intervalMs,
|
||||
isRecurring,
|
||||
};
|
||||
|
||||
this.tasks.set(id, task);
|
||||
|
||||
if (!this.isRunning) {
|
||||
this.start();
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all active scheduled tasks.
|
||||
*/
|
||||
listTasks(): ScheduledTask[] {
|
||||
return Array.from(this.tasks.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a scheduled task by ID.
|
||||
*/
|
||||
cancelTask(id: string): boolean {
|
||||
return this.tasks.delete(id);
|
||||
}
|
||||
|
||||
private tick() {
|
||||
const now = Date.now();
|
||||
for (const [id, task] of this.tasks.entries()) {
|
||||
if (now >= task.nextRunAt) {
|
||||
// Emit the event so the REPL can pick it up
|
||||
this.emit('task_due', task);
|
||||
|
||||
if (task.isRecurring && task.intervalMs) {
|
||||
// Calculate next run time
|
||||
task.nextRunAt = now + task.intervalMs;
|
||||
} else {
|
||||
// One-shot, remove it
|
||||
this.tasks.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-stop if no tasks
|
||||
if (this.tasks.size === 0) {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses strings like "10s", "5m", "2h", "1d" into milliseconds.
|
||||
*/
|
||||
private parseInterval(interval: string): number | null {
|
||||
const match = interval.match(/^(\d+)([smhd])$/);
|
||||
if (!match) return null;
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
|
||||
switch (unit) {
|
||||
case 's':
|
||||
return value * 1000;
|
||||
case 'm':
|
||||
return value * 60 * 1000;
|
||||
case 'h':
|
||||
return value * 60 * 60 * 1000;
|
||||
case 'd':
|
||||
return value * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const cronSchedulerService = new CronSchedulerService();
|
||||
@@ -1,254 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
type ToolResult,
|
||||
} from './tools.js';
|
||||
import { cronSchedulerService } from '../services/cronSchedulerService.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { Type, type FunctionDeclaration } from '@google/genai';
|
||||
|
||||
// --- Declarations ---
|
||||
|
||||
export const SCHEDULE_TASK_TOOL_NAME = 'schedule_task';
|
||||
export const LIST_TASKS_TOOL_NAME = 'list_scheduled_tasks';
|
||||
export const CANCEL_TASK_TOOL_NAME = 'cancel_scheduled_task';
|
||||
|
||||
export const SCHEDULE_TASK_DECLARATION: FunctionDeclaration = {
|
||||
name: SCHEDULE_TASK_TOOL_NAME,
|
||||
description: 'Schedules a prompt to run after a specified interval.',
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
interval: {
|
||||
type: Type.STRING,
|
||||
description: 'Interval string like "10s", "5m", "1h".',
|
||||
},
|
||||
prompt: {
|
||||
type: Type.STRING,
|
||||
description: 'The prompt to run when the task triggers.',
|
||||
},
|
||||
recurring: {
|
||||
type: Type.BOOLEAN,
|
||||
description: 'Whether the task should run repeatedly.',
|
||||
},
|
||||
},
|
||||
required: ['interval', 'prompt'],
|
||||
},
|
||||
};
|
||||
|
||||
export const LIST_TASKS_DECLARATION: FunctionDeclaration = {
|
||||
name: LIST_TASKS_TOOL_NAME,
|
||||
description: 'Lists all currently scheduled tasks.',
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {},
|
||||
},
|
||||
};
|
||||
|
||||
export const CANCEL_TASK_DECLARATION: FunctionDeclaration = {
|
||||
name: CANCEL_TASK_TOOL_NAME,
|
||||
description: 'Cancels a scheduled task by ID.',
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
id: {
|
||||
type: Type.STRING,
|
||||
description: 'The ID of the task to cancel.',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
};
|
||||
|
||||
// --- Invocations ---
|
||||
|
||||
interface ScheduleTaskParams {
|
||||
interval: string;
|
||||
prompt: string;
|
||||
recurring?: boolean;
|
||||
}
|
||||
|
||||
class ScheduleTaskInvocation extends BaseToolInvocation<
|
||||
ScheduleTaskParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
params: ScheduleTaskParams,
|
||||
messageBus: MessageBus,
|
||||
toolName: string,
|
||||
) {
|
||||
super(params, messageBus, toolName);
|
||||
}
|
||||
|
||||
override getDescription(): string {
|
||||
return `Schedule task: ${this.params.prompt} (Interval: ${this.params.interval})`;
|
||||
}
|
||||
|
||||
async execute(): Promise<ToolResult> {
|
||||
try {
|
||||
const isRecurring = this.params.recurring !== false;
|
||||
const id = cronSchedulerService.scheduleTask(
|
||||
this.params.interval,
|
||||
this.params.prompt,
|
||||
isRecurring,
|
||||
);
|
||||
return {
|
||||
llmContent: `Task scheduled successfully. ID: ${id}`,
|
||||
returnDisplay: `Task scheduled successfully. ID: ${id}`,
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
llmContent: `Error scheduling task: ${message}`,
|
||||
returnDisplay: `Error scheduling task: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
override async getConfirmationDetails() {
|
||||
return false as const;
|
||||
}
|
||||
}
|
||||
|
||||
class ListTasksInvocation extends BaseToolInvocation<object, ToolResult> {
|
||||
constructor(params: object, messageBus: MessageBus, toolName: string) {
|
||||
super(params, messageBus, toolName);
|
||||
}
|
||||
|
||||
override getDescription(): string {
|
||||
return 'List scheduled tasks';
|
||||
}
|
||||
|
||||
async execute(): Promise<ToolResult> {
|
||||
const tasks = cronSchedulerService.listTasks();
|
||||
if (tasks.length === 0) {
|
||||
return {
|
||||
llmContent: 'No scheduled tasks.',
|
||||
returnDisplay: 'No scheduled tasks.',
|
||||
};
|
||||
}
|
||||
const lines = tasks.map(
|
||||
(t) =>
|
||||
`- ID: ${t.id}, Interval: ${t.intervalMs}ms, Prompt: "${t.prompt}", Recurring: ${t.isRecurring}`,
|
||||
);
|
||||
return { llmContent: lines.join('\n'), returnDisplay: lines.join('\n') };
|
||||
}
|
||||
|
||||
override async getConfirmationDetails() {
|
||||
return false as const;
|
||||
}
|
||||
}
|
||||
|
||||
interface CancelTaskParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
class CancelTaskInvocation extends BaseToolInvocation<
|
||||
CancelTaskParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
params: CancelTaskParams,
|
||||
messageBus: MessageBus,
|
||||
toolName: string,
|
||||
) {
|
||||
super(params, messageBus, toolName);
|
||||
}
|
||||
|
||||
override getDescription(): string {
|
||||
return `Cancel task ID: ${this.params.id}`;
|
||||
}
|
||||
|
||||
async execute(): Promise<ToolResult> {
|
||||
const success = cronSchedulerService.cancelTask(this.params.id);
|
||||
if (success) {
|
||||
return {
|
||||
llmContent: `Task ${this.params.id} cancelled successfully.`,
|
||||
returnDisplay: `Task ${this.params.id} cancelled successfully.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
llmContent: `Task ${this.params.id} not found.`,
|
||||
returnDisplay: `Task ${this.params.id} not found.`,
|
||||
};
|
||||
}
|
||||
|
||||
override async getConfirmationDetails() {
|
||||
return false as const;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tools ---
|
||||
|
||||
export class ScheduleTaskTool extends BaseDeclarativeTool<
|
||||
ScheduleTaskParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(messageBus: MessageBus) {
|
||||
super(
|
||||
SCHEDULE_TASK_TOOL_NAME,
|
||||
'ScheduleTask',
|
||||
SCHEDULE_TASK_DECLARATION.description ?? '',
|
||||
Kind.Other,
|
||||
SCHEDULE_TASK_DECLARATION.parameters,
|
||||
messageBus,
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: ScheduleTaskParams,
|
||||
messageBus: MessageBus,
|
||||
): ScheduleTaskInvocation {
|
||||
return new ScheduleTaskInvocation(params, messageBus, this.name);
|
||||
}
|
||||
}
|
||||
|
||||
export class ListTasksTool extends BaseDeclarativeTool<object, ToolResult> {
|
||||
constructor(messageBus: MessageBus) {
|
||||
super(
|
||||
LIST_TASKS_TOOL_NAME,
|
||||
'ListTasks',
|
||||
LIST_TASKS_DECLARATION.description ?? '',
|
||||
Kind.Other,
|
||||
LIST_TASKS_DECLARATION.parameters,
|
||||
messageBus,
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: object,
|
||||
messageBus: MessageBus,
|
||||
): ListTasksInvocation {
|
||||
return new ListTasksInvocation(params, messageBus, this.name);
|
||||
}
|
||||
}
|
||||
|
||||
export class CancelTaskTool extends BaseDeclarativeTool<
|
||||
CancelTaskParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(messageBus: MessageBus) {
|
||||
super(
|
||||
CANCEL_TASK_TOOL_NAME,
|
||||
'CancelTask',
|
||||
CANCEL_TASK_DECLARATION.description ?? '',
|
||||
Kind.Other,
|
||||
CANCEL_TASK_DECLARATION.parameters,
|
||||
messageBus,
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: CancelTaskParams,
|
||||
messageBus: MessageBus,
|
||||
): CancelTaskInvocation {
|
||||
return new CancelTaskInvocation(params, messageBus, this.name);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user