opencode-scheduled 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -0
- package/index.ts +1 -0
- package/package.json +53 -0
- package/src/constants.ts +3 -0
- package/src/format.ts +14 -0
- package/src/scheduler-events.ts +14 -0
- package/src/scheduler.ts +149 -0
- package/src/server.ts +41 -0
- package/src/store.ts +125 -0
- package/src/time.ts +136 -0
- package/src/tui/SchedulerManagerDialog.tsx +340 -0
- package/src/tui/SidebarScheduledPrompts.tsx +92 -0
- package/src/tui/dialogs.tsx +108 -0
- package/src/tui/ui.ts +11 -0
- package/src/tui.tsx +47 -0
- package/src/types.ts +25 -0
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# OpenCode Scheduled
|
|
2
|
+
|
|
3
|
+
Schedule prompts from inside OpenCode with `/schedule`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Recommended:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
opencode plugin -g opencode-scheduled
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Manual install:
|
|
14
|
+
|
|
15
|
+
For a global manual install, add the plugin to both `~/.config/opencode/opencode.json` and `~/.config/opencode/tui.json`.
|
|
16
|
+
|
|
17
|
+
`~/.config/opencode/opencode.json`
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"$schema": "https://opencode.ai/config.json",
|
|
22
|
+
"plugin": ["opencode-scheduled"]
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`~/.config/opencode/tui.json`
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"$schema": "https://opencode.ai/tui.json",
|
|
31
|
+
"plugin": ["opencode-scheduled"]
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
For local development, point both plugin lists at this checkout path.
|
|
36
|
+
|
|
37
|
+
## Use It
|
|
38
|
+
|
|
39
|
+
Open the scheduler with either:
|
|
40
|
+
|
|
41
|
+
- `/schedule`
|
|
42
|
+
- `/schedule <prompt>`
|
|
43
|
+
- `/schedule in 30m <prompt>`
|
|
44
|
+
- `/schedule at 3:30am <prompt>`
|
|
45
|
+
- `/schedule today 14:00 <prompt>`
|
|
46
|
+
- `/schedule tomorrow 9am <prompt>`
|
|
47
|
+
- Command palette -> `Schedule Prompt`
|
|
48
|
+
|
|
49
|
+
Inside the dialog, choose when the prompt should be sent, review pending items, and cancel schedules. Pending items are shown by task ID and date.
|
|
50
|
+
|
|
51
|
+
The sidebar also shows pending prompts for the current session by task ID and date. Use the scheduler dialog to pause or resume delivery. When paused, due prompts stay pending until the scheduler is resumed.
|
|
52
|
+
|
|
53
|
+
## Notes
|
|
54
|
+
|
|
55
|
+
- Schedules are stored locally in the platform state directory. Set `OPENCODE_SCHEDULED_HOME` to override the storage directory.
|
|
56
|
+
- Delivery targets the OpenCode session that was active when the prompt was scheduled.
|
|
57
|
+
- Delivery requires OpenCode to be running. If OpenCode is closed at the scheduled time, the prompt will not send until OpenCode is opened again and the plugin timer runs.
|
|
58
|
+
- Failed deliveries are marked as failed rather than retried automatically.
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/server.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-scheduled",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode plugin for scheduling prompts from the TUI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.ts",
|
|
7
|
+
"module": "./index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./index.ts"
|
|
11
|
+
},
|
|
12
|
+
"./server": {
|
|
13
|
+
"import": "./src/server.ts"
|
|
14
|
+
},
|
|
15
|
+
"./tui": {
|
|
16
|
+
"import": "./src/tui.tsx"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"index.ts",
|
|
21
|
+
"src",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/Dylan-Liew/opencode-scheduled.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/Dylan-Liew/opencode-scheduled#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/Dylan-Liew/opencode-scheduled/issues"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"opencode",
|
|
34
|
+
"plugin",
|
|
35
|
+
"schedule",
|
|
36
|
+
"prompt",
|
|
37
|
+
"tui"
|
|
38
|
+
],
|
|
39
|
+
"author": "Dylan Liew",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "tsc -p tsconfig.json --noEmit"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@opencode-ai/plugin": "^1.17.4",
|
|
46
|
+
"@opentui/core": "^0.3.4",
|
|
47
|
+
"@opentui/keymap": "^0.3.4",
|
|
48
|
+
"@opentui/solid": "^0.3.4",
|
|
49
|
+
"@types/bun": "latest",
|
|
50
|
+
"@types/node": "^24.3.0",
|
|
51
|
+
"typescript": "^5.9.3"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/constants.ts
ADDED
package/src/format.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ScheduledPrompt } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export function taskID(job: ScheduledPrompt): string {
|
|
4
|
+
return `#${job.id.slice(0, 8)}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function formatCompactDate(runAt: number): string {
|
|
8
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
9
|
+
month: "short",
|
|
10
|
+
day: "numeric",
|
|
11
|
+
hour: "numeric",
|
|
12
|
+
minute: "2-digit",
|
|
13
|
+
}).format(new Date(runAt));
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type SchedulerChangeListener = () => void;
|
|
2
|
+
|
|
3
|
+
const listeners = new Set<SchedulerChangeListener>();
|
|
4
|
+
|
|
5
|
+
export function onSchedulerChange(listener: SchedulerChangeListener): () => void {
|
|
6
|
+
listeners.add(listener);
|
|
7
|
+
return () => listeners.delete(listener);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function emitSchedulerChange(): void {
|
|
11
|
+
for (const listener of listeners) {
|
|
12
|
+
listener();
|
|
13
|
+
}
|
|
14
|
+
}
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
|
|
2
|
+
import { emitSchedulerChange } from "./scheduler-events.ts";
|
|
3
|
+
import { createScheduledPrompt, readStore, updateStore } from "./store.ts";
|
|
4
|
+
import { formatRunAt } from "./time.ts";
|
|
5
|
+
import type { ScheduledPrompt } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export const TICK_MS = 15_000;
|
|
8
|
+
export const SIDEBAR_MAX_JOBS = 6;
|
|
9
|
+
|
|
10
|
+
export interface ScheduleDialogState {
|
|
11
|
+
jobs: ScheduledPrompt[];
|
|
12
|
+
paused: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function currentSessionID(api: TuiPluginApi): string | undefined {
|
|
16
|
+
if (api.route.current.name !== "session") {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sessionID = api.route.current.params?.sessionID;
|
|
21
|
+
return typeof sessionID === "string" ? sessionID : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function sortPending(left: ScheduledPrompt, right: ScheduledPrompt): number {
|
|
25
|
+
return left.runAt - right.runAt || left.createdAt - right.createdAt;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function loadSchedulerState(): Promise<ScheduleDialogState> {
|
|
29
|
+
const store = await readStore();
|
|
30
|
+
return {
|
|
31
|
+
jobs: store.jobs.filter((job) => job.status === "pending").sort(sortPending),
|
|
32
|
+
paused: store.settings.paused,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function addScheduledPrompt(api: TuiPluginApi, prompt: string, runAt: number): Promise<void> {
|
|
37
|
+
const sessionID = currentSessionID(api);
|
|
38
|
+
if (!sessionID) {
|
|
39
|
+
api.ui.toast({ message: "Open a session before scheduling a prompt", variant: "error", duration: 3000 });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const job = createScheduledPrompt({ prompt, runAt, sessionID });
|
|
44
|
+
await updateStore((store) => ({
|
|
45
|
+
...store,
|
|
46
|
+
jobs: [job, ...store.jobs],
|
|
47
|
+
}));
|
|
48
|
+
emitSchedulerChange();
|
|
49
|
+
api.ui.toast({ message: `Scheduled for ${formatRunAt(runAt)}`, variant: "success", duration: 3000 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function toggleSchedulerPause(api: TuiPluginApi): Promise<boolean> {
|
|
53
|
+
const store = await updateStore((current) => ({
|
|
54
|
+
...current,
|
|
55
|
+
settings: {
|
|
56
|
+
paused: !current.settings.paused,
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
api.ui.toast({
|
|
61
|
+
message: store.settings.paused ? "Scheduler paused" : "Scheduler resumed",
|
|
62
|
+
variant: store.settings.paused ? "warning" : "success",
|
|
63
|
+
duration: 2500,
|
|
64
|
+
});
|
|
65
|
+
emitSchedulerChange();
|
|
66
|
+
|
|
67
|
+
return store.settings.paused;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function cancelScheduledPrompt(api: TuiPluginApi, job: ScheduledPrompt): Promise<void> {
|
|
71
|
+
await updateStore((store) => ({
|
|
72
|
+
...store,
|
|
73
|
+
jobs: store.jobs.map((current) =>
|
|
74
|
+
current.id === job.id
|
|
75
|
+
? {
|
|
76
|
+
...current,
|
|
77
|
+
status: "canceled",
|
|
78
|
+
canceledAt: Date.now(),
|
|
79
|
+
}
|
|
80
|
+
: current,
|
|
81
|
+
),
|
|
82
|
+
}));
|
|
83
|
+
emitSchedulerChange();
|
|
84
|
+
api.ui.toast({ message: "Scheduled prompt canceled", variant: "success", duration: 2500 });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function deliverJob(api: TuiPluginApi, job: ScheduledPrompt): Promise<void> {
|
|
88
|
+
const sessionID = job.sessionID ?? currentSessionID(api);
|
|
89
|
+
if (!sessionID) {
|
|
90
|
+
throw new Error("No session available for schedule");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const result = await api.client.session.promptAsync({
|
|
94
|
+
sessionID,
|
|
95
|
+
parts: [{ type: "text", text: job.prompt }],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (result.error) {
|
|
99
|
+
throw new Error("Failed to send schedule");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function deliverDuePrompts(api: TuiPluginApi): Promise<void> {
|
|
104
|
+
const store = await readStore();
|
|
105
|
+
if (store.settings.paused) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
const due = store.jobs.filter((job) => job.status === "pending" && job.runAt <= now);
|
|
111
|
+
for (const job of due) {
|
|
112
|
+
try {
|
|
113
|
+
await deliverJob(api, job);
|
|
114
|
+
await updateStore((current) => ({
|
|
115
|
+
...current,
|
|
116
|
+
jobs: current.jobs.map((item) =>
|
|
117
|
+
item.id === job.id
|
|
118
|
+
? {
|
|
119
|
+
...item,
|
|
120
|
+
status: "sent",
|
|
121
|
+
sentAt: Date.now(),
|
|
122
|
+
lastAttemptAt: Date.now(),
|
|
123
|
+
error: undefined,
|
|
124
|
+
}
|
|
125
|
+
: item,
|
|
126
|
+
),
|
|
127
|
+
}));
|
|
128
|
+
emitSchedulerChange();
|
|
129
|
+
api.ui.toast({ message: "Scheduled prompt sent", variant: "success", duration: 2500 });
|
|
130
|
+
void api.attention.notify({ message: "Scheduled prompt sent", sound: { name: "done", when: "blurred" }, notification: { when: "blurred" } });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
await updateStore((current) => ({
|
|
133
|
+
...current,
|
|
134
|
+
jobs: current.jobs.map((item) =>
|
|
135
|
+
item.id === job.id
|
|
136
|
+
? {
|
|
137
|
+
...item,
|
|
138
|
+
status: "failed",
|
|
139
|
+
lastAttemptAt: Date.now(),
|
|
140
|
+
error: error instanceof Error ? error.message : String(error),
|
|
141
|
+
}
|
|
142
|
+
: item,
|
|
143
|
+
),
|
|
144
|
+
}));
|
|
145
|
+
emitSchedulerChange();
|
|
146
|
+
api.ui.toast({ message: "Scheduled prompt failed", variant: "error", duration: 3000 });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Hooks, PluginInput, PluginModule } from "@opencode-ai/plugin";
|
|
2
|
+
import { HANDLED_SENTINEL, PLUGIN_ID, SCHEDULE_COMMAND_OPEN } from "./constants.ts";
|
|
3
|
+
import { saveDraftPrompt } from "./store.ts";
|
|
4
|
+
|
|
5
|
+
function isScheduleCommand(command: string): boolean {
|
|
6
|
+
return command.replace(/^\//, "") === "schedule";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function openScheduler(input: PluginInput, prompt: string): Promise<void> {
|
|
10
|
+
if (prompt.trim()) {
|
|
11
|
+
await saveDraftPrompt(prompt);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const result = await input.client.tui.executeCommand({
|
|
15
|
+
body: { command: SCHEDULE_COMMAND_OPEN },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (result.error || result.data !== true) {
|
|
19
|
+
throw new Error("Scheduled prompt dialog unavailable. Ensure the TUI plugin is loaded.");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function ScheduledPromptPlugin(pluginInput: PluginInput): Promise<Hooks> {
|
|
24
|
+
return {
|
|
25
|
+
"command.execute.before": async (input) => {
|
|
26
|
+
if (!isScheduleCommand(input.command)) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await openScheduler(pluginInput, input.arguments);
|
|
31
|
+
throw new Error(HANDLED_SENTINEL);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const module: PluginModule & { id: string } = {
|
|
37
|
+
id: PLUGIN_ID,
|
|
38
|
+
server: ScheduledPromptPlugin,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export default module;
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import type { ScheduledPrompt, SchedulerStore } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
const STORE_FILE = "schedules.json";
|
|
7
|
+
|
|
8
|
+
function stateRoot(): string {
|
|
9
|
+
if (process.env.OPENCODE_SCHEDULED_HOME) {
|
|
10
|
+
return process.env.OPENCODE_SCHEDULED_HOME;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (process.platform === "win32" && process.env.APPDATA) {
|
|
14
|
+
return join(process.env.APPDATA, "opencode-scheduled");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (process.env.XDG_STATE_HOME) {
|
|
18
|
+
return join(process.env.XDG_STATE_HOME, "opencode-scheduled");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return join(process.env.HOME ?? process.cwd(), ".local", "state", "opencode-scheduled");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function storePath(): string {
|
|
25
|
+
return join(stateRoot(), STORE_FILE);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function emptyStore(): SchedulerStore {
|
|
29
|
+
return {
|
|
30
|
+
version: 1,
|
|
31
|
+
jobs: [],
|
|
32
|
+
settings: {
|
|
33
|
+
paused: false,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeStore(value: unknown): SchedulerStore {
|
|
39
|
+
const fallback = emptyStore();
|
|
40
|
+
if (!value || typeof value !== "object") {
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const record = value as Partial<SchedulerStore>;
|
|
45
|
+
const jobs = Array.isArray(record.jobs)
|
|
46
|
+
? record.jobs.filter((job): job is ScheduledPrompt => {
|
|
47
|
+
return (
|
|
48
|
+
Boolean(job) &&
|
|
49
|
+
typeof job === "object" &&
|
|
50
|
+
typeof job.id === "string" &&
|
|
51
|
+
typeof job.prompt === "string" &&
|
|
52
|
+
typeof job.runAt === "number" &&
|
|
53
|
+
typeof job.createdAt === "number" &&
|
|
54
|
+
(job.status === "pending" || job.status === "sent" || job.status === "canceled" || job.status === "failed")
|
|
55
|
+
);
|
|
56
|
+
})
|
|
57
|
+
: [];
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
version: 1,
|
|
61
|
+
draftPrompt: typeof record.draftPrompt === "string" ? record.draftPrompt : undefined,
|
|
62
|
+
jobs,
|
|
63
|
+
settings: {
|
|
64
|
+
paused: record.settings?.paused === true,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function readStore(): Promise<SchedulerStore> {
|
|
70
|
+
try {
|
|
71
|
+
const raw = await readFile(storePath(), "utf8");
|
|
72
|
+
return normalizeStore(JSON.parse(raw));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
75
|
+
return emptyStore();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function writeStore(store: SchedulerStore): Promise<void> {
|
|
83
|
+
const path = storePath();
|
|
84
|
+
await mkdir(dirname(path), { recursive: true });
|
|
85
|
+
const tempPath = `${path}.${process.pid}.tmp`;
|
|
86
|
+
await writeFile(tempPath, `${JSON.stringify(store, null, 2)}\n`, "utf8");
|
|
87
|
+
await rename(tempPath, path);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function updateStore(update: (store: SchedulerStore) => SchedulerStore): Promise<SchedulerStore> {
|
|
91
|
+
const next = update(await readStore());
|
|
92
|
+
await writeStore(next);
|
|
93
|
+
return next;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function saveDraftPrompt(prompt: string): Promise<void> {
|
|
97
|
+
await updateStore((store) => ({
|
|
98
|
+
...store,
|
|
99
|
+
draftPrompt: prompt.trim() || undefined,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function takeDraftPrompt(): Promise<string | undefined> {
|
|
104
|
+
let draft: string | undefined;
|
|
105
|
+
await updateStore((store) => {
|
|
106
|
+
draft = store.draftPrompt;
|
|
107
|
+
return {
|
|
108
|
+
...store,
|
|
109
|
+
draftPrompt: undefined,
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return draft;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createScheduledPrompt(input: { prompt: string; runAt: number; sessionID?: string }): ScheduledPrompt {
|
|
117
|
+
return {
|
|
118
|
+
id: randomUUID(),
|
|
119
|
+
prompt: input.prompt,
|
|
120
|
+
runAt: input.runAt,
|
|
121
|
+
createdAt: Date.now(),
|
|
122
|
+
status: "pending",
|
|
123
|
+
sessionID: input.sessionID,
|
|
124
|
+
};
|
|
125
|
+
}
|
package/src/time.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
export interface ParsedScheduleRequest {
|
|
2
|
+
prompt: string;
|
|
3
|
+
runAt?: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const MINUTE_MS = 60_000;
|
|
7
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
8
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
9
|
+
|
|
10
|
+
function setClock(date: Date, hour: number, minute: number): Date {
|
|
11
|
+
const next = new Date(date);
|
|
12
|
+
next.setHours(hour, minute, 0, 0);
|
|
13
|
+
return next;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseClock(value: string): { hour: number; minute: number } | undefined {
|
|
17
|
+
const match = value.trim().match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i);
|
|
18
|
+
if (!match) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let hour = Number(match[1]);
|
|
23
|
+
const minute = match[2] ? Number(match[2]) : 0;
|
|
24
|
+
const meridiem = match[3]?.toLowerCase();
|
|
25
|
+
if (hour > 23 || minute > 59) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (meridiem) {
|
|
30
|
+
if (hour < 1 || hour > 12) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
hour = hour % 12;
|
|
35
|
+
if (meridiem === "pm") {
|
|
36
|
+
hour += 12;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { hour, minute };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function capture(match: RegExpMatchArray, index: number): string {
|
|
44
|
+
return match[index] ?? "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function formatRunAt(runAt: number): string {
|
|
48
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
49
|
+
dateStyle: "medium",
|
|
50
|
+
timeStyle: "short",
|
|
51
|
+
}).format(new Date(runAt));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseTimeSpec(spec: string, now = new Date()): number | undefined {
|
|
55
|
+
const input = spec.trim().replace(/\s+/g, " ");
|
|
56
|
+
if (!input) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const relative = input.match(/^in\s+(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$/i);
|
|
61
|
+
if (relative) {
|
|
62
|
+
const amount = Number(capture(relative, 1));
|
|
63
|
+
const unit = capture(relative, 2).toLowerCase();
|
|
64
|
+
const multiplier = unit.startsWith("s") ? 1000 : unit.startsWith("m") ? MINUTE_MS : unit.startsWith("h") ? HOUR_MS : DAY_MS;
|
|
65
|
+
return now.getTime() + amount * multiplier;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const dayClock = input.match(/^(today|tomorrow)\s+(.+)$/i);
|
|
69
|
+
if (dayClock) {
|
|
70
|
+
const clock = parseClock(capture(dayClock, 2));
|
|
71
|
+
if (!clock) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const base = new Date(now);
|
|
76
|
+
if (capture(dayClock, 1).toLowerCase() === "tomorrow") {
|
|
77
|
+
base.setDate(base.getDate() + 1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const target = setClock(base, clock.hour, clock.minute);
|
|
81
|
+
return target.getTime() > now.getTime() ? target.getTime() : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const clock = parseClock(input.replace(/^at\s+/i, ""));
|
|
85
|
+
if (clock) {
|
|
86
|
+
let target = setClock(now, clock.hour, clock.minute);
|
|
87
|
+
if (target.getTime() <= now.getTime()) {
|
|
88
|
+
target = new Date(target.getTime() + DAY_MS);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return target.getTime();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const isoLike = input.match(/^(\d{4}-\d{2}-\d{2})(?:[ t](\d{1,2}:\d{2})(?:\s*(am|pm))?)?$/i);
|
|
95
|
+
if (isoLike) {
|
|
96
|
+
const suffix = isoLike[2] ? ` ${isoLike[2]}${isoLike[3] ? ` ${isoLike[3]}` : ""}` : "";
|
|
97
|
+
const parsed = new Date(`${capture(isoLike, 1)}${suffix}`);
|
|
98
|
+
return Number.isFinite(parsed.getTime()) && parsed.getTime() > now.getTime() ? parsed.getTime() : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const parsed = new Date(input);
|
|
102
|
+
return Number.isFinite(parsed.getTime()) && parsed.getTime() > now.getTime() ? parsed.getTime() : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parseScheduleRequest(raw: string, now = new Date()): ParsedScheduleRequest {
|
|
106
|
+
const input = raw.trim();
|
|
107
|
+
if (!input) {
|
|
108
|
+
return { prompt: "" };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const relative = input.match(/^(in\s+\d+\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days))\s+(.+)$/i);
|
|
112
|
+
if (relative) {
|
|
113
|
+
return {
|
|
114
|
+
runAt: parseTimeSpec(capture(relative, 1), now),
|
|
115
|
+
prompt: capture(relative, 2).trim(),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const dayClock = input.match(/^((?:today|tomorrow)\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\s+(.+)$/i);
|
|
120
|
+
if (dayClock) {
|
|
121
|
+
return {
|
|
122
|
+
runAt: parseTimeSpec(capture(dayClock, 1), now),
|
|
123
|
+
prompt: capture(dayClock, 2).trim(),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const atClock = input.match(/^at\s+(\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\s+(.+)$/i);
|
|
128
|
+
if (atClock) {
|
|
129
|
+
return {
|
|
130
|
+
runAt: parseTimeSpec(capture(atClock, 1), now),
|
|
131
|
+
prompt: capture(atClock, 2).trim(),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { prompt: input };
|
|
136
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { TextAttributes, type KeyEvent, type MouseEvent, type Renderable } from "@opentui/core";
|
|
3
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/solid";
|
|
4
|
+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
|
|
5
|
+
import { createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js";
|
|
6
|
+
import { SCHEDULE_COMMAND_OPEN } from "../constants.ts";
|
|
7
|
+
import { formatCompactDate, taskID } from "../format.ts";
|
|
8
|
+
import type { ScheduledPrompt } from "../types.ts";
|
|
9
|
+
import { clickPrimary } from "./ui.ts";
|
|
10
|
+
|
|
11
|
+
export interface SchedulerManagerDialogProps {
|
|
12
|
+
api: TuiPluginApi;
|
|
13
|
+
initialJobs: ScheduledPrompt[];
|
|
14
|
+
initialPaused: boolean;
|
|
15
|
+
loadJobs: () => Promise<{ jobs: ScheduledPrompt[]; paused: boolean }>;
|
|
16
|
+
onAdd: () => void;
|
|
17
|
+
onCancel: (job: ScheduledPrompt) => void;
|
|
18
|
+
onTogglePause: () => Promise<boolean>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function SchedulerManagerDialog(props: SchedulerManagerDialogProps) {
|
|
22
|
+
const theme = props.api.theme.current;
|
|
23
|
+
const dimensions = useTerminalDimensions();
|
|
24
|
+
const [jobs, setJobs] = createSignal(props.initialJobs);
|
|
25
|
+
const [paused, setPaused] = createSignal(props.initialPaused);
|
|
26
|
+
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
|
27
|
+
const [busy, setBusy] = createSignal(false);
|
|
28
|
+
const [hoveredAction, setHoveredAction] = createSignal<string | undefined>();
|
|
29
|
+
const handledKeys = new WeakSet<KeyEvent>();
|
|
30
|
+
let root: Renderable | undefined;
|
|
31
|
+
|
|
32
|
+
const selected = () => jobs()[selectedIndex()];
|
|
33
|
+
|
|
34
|
+
const moveSelection = (direction: number) => {
|
|
35
|
+
setSelectedIndex((current) => Math.max(0, Math.min(current + direction, Math.max(0, jobs().length - 1))));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const loadCurrentJobs = async () => {
|
|
39
|
+
const state = await props.loadJobs();
|
|
40
|
+
setJobs(state.jobs);
|
|
41
|
+
setPaused(state.paused);
|
|
42
|
+
setSelectedIndex((current) => Math.max(0, Math.min(current, Math.max(0, state.jobs.length - 1))));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const addPrompt = () => {
|
|
46
|
+
if (!busy()) {
|
|
47
|
+
props.onAdd();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const toggleDialogPause = () => {
|
|
52
|
+
if (busy()) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setBusy(true);
|
|
57
|
+
void props
|
|
58
|
+
.onTogglePause()
|
|
59
|
+
.then((nextPaused) => setPaused(nextPaused))
|
|
60
|
+
.finally(() => setBusy(false));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const cancelSelected = () => {
|
|
64
|
+
const job = selected();
|
|
65
|
+
if (job && !busy()) {
|
|
66
|
+
props.onCancel(job);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
onMount(() => {
|
|
71
|
+
void loadCurrentJobs();
|
|
72
|
+
setTimeout(() => {
|
|
73
|
+
if (!root || root.isDestroyed) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
root.focus();
|
|
78
|
+
}, 25);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
createEffect(() => {
|
|
82
|
+
props.api.ui.dialog.setSize(dimensions().width >= 120 ? "large" : "medium");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const handleKeyDown = (event: KeyEvent) => {
|
|
86
|
+
if (handledKeys.has(event) || event.eventType !== "press" || busy()) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const key = event.name.toLowerCase();
|
|
91
|
+
|
|
92
|
+
if (!event.ctrl && !event.meta && !event.option && key === "a") {
|
|
93
|
+
if (event.repeated) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
handledKeys.add(event);
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
event.stopPropagation();
|
|
100
|
+
addPrompt();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!event.ctrl && !event.meta && !event.option && key === "p") {
|
|
105
|
+
if (event.repeated) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
handledKeys.add(event);
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
event.stopPropagation();
|
|
112
|
+
toggleDialogPause();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (event.ctrl && key === "d") {
|
|
117
|
+
if (event.repeated) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
handledKeys.add(event);
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
event.stopPropagation();
|
|
124
|
+
cancelSelected();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (event.defaultPrevented) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (key === "down" || key === "arrowdown") {
|
|
133
|
+
handledKeys.add(event);
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
event.stopPropagation();
|
|
136
|
+
moveSelection(1);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (key === "up" || key === "arrowup") {
|
|
141
|
+
handledKeys.add(event);
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
event.stopPropagation();
|
|
144
|
+
moveSelection(-1);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (key === "return" || key === "enter") {
|
|
149
|
+
if (event.repeated) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
handledKeys.add(event);
|
|
154
|
+
event.preventDefault();
|
|
155
|
+
event.stopPropagation();
|
|
156
|
+
cancelSelected();
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const disposeKeybinds = props.api.keymap.registerLayer({
|
|
161
|
+
priority: 10,
|
|
162
|
+
commands: [
|
|
163
|
+
{
|
|
164
|
+
name: "dialog.select.prev",
|
|
165
|
+
title: "Previous scheduled prompt",
|
|
166
|
+
category: "Dialog",
|
|
167
|
+
run: () => moveSelection(-1),
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: "dialog.select.next",
|
|
171
|
+
title: "Next scheduled prompt",
|
|
172
|
+
category: "Dialog",
|
|
173
|
+
run: () => moveSelection(1),
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: "dialog.select.submit",
|
|
177
|
+
title: "Cancel scheduled prompt",
|
|
178
|
+
category: "Dialog",
|
|
179
|
+
run: cancelSelected,
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: `${SCHEDULE_COMMAND_OPEN}.add`,
|
|
183
|
+
title: "Add scheduled prompt",
|
|
184
|
+
category: "Dialog",
|
|
185
|
+
run: addPrompt,
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: `${SCHEDULE_COMMAND_OPEN}.pause`,
|
|
189
|
+
title: "Pause scheduled prompts",
|
|
190
|
+
category: "Dialog",
|
|
191
|
+
run: toggleDialogPause,
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: `${SCHEDULE_COMMAND_OPEN}.delete`,
|
|
195
|
+
title: "Cancel scheduled prompt",
|
|
196
|
+
category: "Dialog",
|
|
197
|
+
run: cancelSelected,
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
bindings: [
|
|
201
|
+
...props.api.tuiConfig.keybinds.gather("dialog.select", [
|
|
202
|
+
"dialog.select.prev",
|
|
203
|
+
"dialog.select.next",
|
|
204
|
+
"dialog.select.submit",
|
|
205
|
+
]),
|
|
206
|
+
{ key: "a", cmd: `${SCHEDULE_COMMAND_OPEN}.add`, desc: "Add scheduled prompt" },
|
|
207
|
+
{ key: "p", cmd: `${SCHEDULE_COMMAND_OPEN}.pause`, desc: "Pause scheduled prompts" },
|
|
208
|
+
{ key: "ctrl+d", cmd: `${SCHEDULE_COMMAND_OPEN}.delete`, desc: "Cancel scheduled prompt" },
|
|
209
|
+
],
|
|
210
|
+
});
|
|
211
|
+
onCleanup(disposeKeybinds);
|
|
212
|
+
|
|
213
|
+
useKeyboard(handleKeyDown);
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<box width="100%" flexDirection="column" gap={0} focusable focused onKeyDown={handleKeyDown} ref={(value) => (root = value)}>
|
|
217
|
+
<box paddingLeft={4} paddingRight={4} paddingBottom={1} flexDirection="column" gap={1}>
|
|
218
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
219
|
+
<box flexDirection="column" gap={0}>
|
|
220
|
+
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
|
221
|
+
Scheduled Prompts
|
|
222
|
+
</text>
|
|
223
|
+
<text fg={theme.textMuted}>enter cancel · a add · p {paused() ? "resume" : "pause"}</text>
|
|
224
|
+
</box>
|
|
225
|
+
<box flexDirection="row" gap={2}>
|
|
226
|
+
<text
|
|
227
|
+
fg={hoveredAction() === "add" ? theme.text : theme.primary}
|
|
228
|
+
onMouseOver={() => setHoveredAction("add")}
|
|
229
|
+
onMouseOut={() => setHoveredAction(undefined)}
|
|
230
|
+
onMouseUp={(event: MouseEvent) => {
|
|
231
|
+
if (clickPrimary(event)) {
|
|
232
|
+
addPrompt();
|
|
233
|
+
}
|
|
234
|
+
}}
|
|
235
|
+
>
|
|
236
|
+
add
|
|
237
|
+
</text>
|
|
238
|
+
<text
|
|
239
|
+
fg={hoveredAction() === "pause" ? theme.text : paused() ? theme.success : theme.warning}
|
|
240
|
+
onMouseOver={() => setHoveredAction("pause")}
|
|
241
|
+
onMouseOut={() => setHoveredAction(undefined)}
|
|
242
|
+
onMouseUp={(event: MouseEvent) => {
|
|
243
|
+
if (clickPrimary(event)) {
|
|
244
|
+
toggleDialogPause();
|
|
245
|
+
}
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
{paused() ? "resume" : "pause"}
|
|
249
|
+
</text>
|
|
250
|
+
<text
|
|
251
|
+
fg={hoveredAction() === "esc" ? theme.text : theme.textMuted}
|
|
252
|
+
onMouseOver={() => setHoveredAction("esc")}
|
|
253
|
+
onMouseOut={() => setHoveredAction(undefined)}
|
|
254
|
+
onMouseUp={() => props.api.ui.dialog.clear()}
|
|
255
|
+
>
|
|
256
|
+
esc
|
|
257
|
+
</text>
|
|
258
|
+
</box>
|
|
259
|
+
</box>
|
|
260
|
+
|
|
261
|
+
<Show when={busy()}>
|
|
262
|
+
<text fg={theme.warning}>Working...</text>
|
|
263
|
+
</Show>
|
|
264
|
+
</box>
|
|
265
|
+
|
|
266
|
+
<scrollbox paddingLeft={4} paddingRight={4} maxHeight={Math.max(10, Math.floor(dimensions().height * 0.45))}>
|
|
267
|
+
<Show
|
|
268
|
+
when={jobs().length > 0}
|
|
269
|
+
fallback={
|
|
270
|
+
<box width="100%" paddingTop={2} paddingBottom={2} flexDirection="row" justifyContent="center">
|
|
271
|
+
<text fg={theme.textMuted}>No scheduled prompts</text>
|
|
272
|
+
</box>
|
|
273
|
+
}
|
|
274
|
+
>
|
|
275
|
+
<box flexDirection="column" gap={1}>
|
|
276
|
+
<For each={jobs()}>
|
|
277
|
+
{(job, index) => {
|
|
278
|
+
const isSelected = () => index() === selectedIndex();
|
|
279
|
+
const cardBackground = () => (isSelected() ? theme.backgroundElement : theme.backgroundPanel);
|
|
280
|
+
return (
|
|
281
|
+
<box
|
|
282
|
+
paddingLeft={0}
|
|
283
|
+
paddingRight={0}
|
|
284
|
+
paddingTop={0}
|
|
285
|
+
paddingBottom={0}
|
|
286
|
+
flexDirection="row"
|
|
287
|
+
onMouseMove={() => setSelectedIndex(index())}
|
|
288
|
+
onMouseOver={() => setSelectedIndex(index())}
|
|
289
|
+
onMouseDown={(event: MouseEvent) => {
|
|
290
|
+
if (clickPrimary(event)) {
|
|
291
|
+
setSelectedIndex(index());
|
|
292
|
+
}
|
|
293
|
+
}}
|
|
294
|
+
onMouseUp={(event: MouseEvent) => {
|
|
295
|
+
if (clickPrimary(event)) {
|
|
296
|
+
setSelectedIndex(index());
|
|
297
|
+
props.onCancel(job);
|
|
298
|
+
}
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
<box width={1} backgroundColor={isSelected() ? theme.primary : theme.borderSubtle} />
|
|
302
|
+
<box
|
|
303
|
+
paddingLeft={2}
|
|
304
|
+
paddingRight={2}
|
|
305
|
+
paddingTop={1}
|
|
306
|
+
paddingBottom={1}
|
|
307
|
+
flexDirection="row"
|
|
308
|
+
justifyContent="space-between"
|
|
309
|
+
alignItems="center"
|
|
310
|
+
flexGrow={1}
|
|
311
|
+
backgroundColor={cardBackground()}
|
|
312
|
+
>
|
|
313
|
+
<text fg={isSelected() ? theme.text : theme.textMuted}>{taskID(job)}</text>
|
|
314
|
+
<box flexDirection="row" gap={2}>
|
|
315
|
+
<text fg={isSelected() ? theme.text : theme.textMuted}>{formatCompactDate(job.runAt)}</text>
|
|
316
|
+
<text
|
|
317
|
+
fg={hoveredAction() === `delete:${job.id}` ? theme.text : theme.textMuted}
|
|
318
|
+
onMouseOver={() => setHoveredAction(`delete:${job.id}`)}
|
|
319
|
+
onMouseOut={() => setHoveredAction(undefined)}
|
|
320
|
+
onMouseUp={(event: MouseEvent) => {
|
|
321
|
+
if (clickPrimary(event)) {
|
|
322
|
+
setSelectedIndex(index());
|
|
323
|
+
props.onCancel(job);
|
|
324
|
+
}
|
|
325
|
+
}}
|
|
326
|
+
>
|
|
327
|
+
cancel
|
|
328
|
+
</text>
|
|
329
|
+
</box>
|
|
330
|
+
</box>
|
|
331
|
+
</box>
|
|
332
|
+
);
|
|
333
|
+
}}
|
|
334
|
+
</For>
|
|
335
|
+
</box>
|
|
336
|
+
</Show>
|
|
337
|
+
</scrollbox>
|
|
338
|
+
</box>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { TextAttributes, type MouseEvent } from "@opentui/core";
|
|
3
|
+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
|
|
4
|
+
import { createSignal, For, onCleanup, onMount, Show } from "solid-js";
|
|
5
|
+
import { formatCompactDate, taskID } from "../format.ts";
|
|
6
|
+
import { onSchedulerChange } from "../scheduler-events.ts";
|
|
7
|
+
import { readStore } from "../store.ts";
|
|
8
|
+
import type { ScheduledPrompt } from "../types.ts";
|
|
9
|
+
import { SIDEBAR_MAX_JOBS, sortPending, TICK_MS } from "../scheduler.ts";
|
|
10
|
+
import { clickPrimary } from "./ui.ts";
|
|
11
|
+
|
|
12
|
+
export function SidebarScheduledPrompts(props: { api: TuiPluginApi; sessionID: string; onOpenManager: () => void }) {
|
|
13
|
+
const theme = props.api.theme.current;
|
|
14
|
+
const [jobs, setJobs] = createSignal<ScheduledPrompt[]>([]);
|
|
15
|
+
const [paused, setPaused] = createSignal(false);
|
|
16
|
+
const [expanded, setExpanded] = createSignal(true);
|
|
17
|
+
|
|
18
|
+
const refresh = async () => {
|
|
19
|
+
const store = await readStore();
|
|
20
|
+
setJobs(
|
|
21
|
+
store.jobs
|
|
22
|
+
.filter((job) => job.status === "pending" && job.sessionID === props.sessionID)
|
|
23
|
+
.sort(sortPending)
|
|
24
|
+
.slice(0, SIDEBAR_MAX_JOBS),
|
|
25
|
+
);
|
|
26
|
+
setPaused(store.settings.paused);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
onMount(() => {
|
|
30
|
+
void refresh();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const timer = setInterval(() => {
|
|
34
|
+
void refresh();
|
|
35
|
+
}, TICK_MS);
|
|
36
|
+
const disposeSchedulerChange = onSchedulerChange(() => {
|
|
37
|
+
void refresh();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
onCleanup(() => {
|
|
41
|
+
clearInterval(timer);
|
|
42
|
+
disposeSchedulerChange();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<box flexDirection="column" gap={0} paddingTop={1} paddingBottom={1}>
|
|
47
|
+
<box
|
|
48
|
+
flexDirection="row"
|
|
49
|
+
gap={1}
|
|
50
|
+
alignItems="center"
|
|
51
|
+
onMouseDown={(event: MouseEvent) => {
|
|
52
|
+
if (clickPrimary(event)) {
|
|
53
|
+
setExpanded((value) => !value);
|
|
54
|
+
}
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<text fg={theme.text}>{expanded() ? "▼" : "▶"}</text>
|
|
58
|
+
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
|
59
|
+
Scheduled
|
|
60
|
+
</text>
|
|
61
|
+
<Show when={paused()}>
|
|
62
|
+
<text fg={theme.warning}>paused</text>
|
|
63
|
+
</Show>
|
|
64
|
+
</box>
|
|
65
|
+
|
|
66
|
+
<Show when={expanded()}>
|
|
67
|
+
<Show when={jobs().length > 0} fallback={<text fg={theme.textMuted}> none</text>}>
|
|
68
|
+
<scrollbox maxHeight={SIDEBAR_MAX_JOBS}>
|
|
69
|
+
<box flexDirection="column" gap={0}>
|
|
70
|
+
<For each={jobs()}>
|
|
71
|
+
{(job) => (
|
|
72
|
+
<box
|
|
73
|
+
flexDirection="row"
|
|
74
|
+
gap={1}
|
|
75
|
+
onMouseDown={(event: MouseEvent) => {
|
|
76
|
+
if (clickPrimary(event)) {
|
|
77
|
+
props.onOpenManager();
|
|
78
|
+
}
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<text fg={theme.primary}>{` ${taskID(job)}`}</text>
|
|
82
|
+
<text fg={theme.textMuted}>{formatCompactDate(job.runAt)}</text>
|
|
83
|
+
</box>
|
|
84
|
+
)}
|
|
85
|
+
</For>
|
|
86
|
+
</box>
|
|
87
|
+
</scrollbox>
|
|
88
|
+
</Show>
|
|
89
|
+
</Show>
|
|
90
|
+
</box>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
|
|
3
|
+
import { formatRunAt, parseScheduleRequest, parseTimeSpec } from "../time.ts";
|
|
4
|
+
import { takeDraftPrompt } from "../store.ts";
|
|
5
|
+
import type { ScheduledPrompt } from "../types.ts";
|
|
6
|
+
import { taskID } from "../format.ts";
|
|
7
|
+
import { addScheduledPrompt, cancelScheduledPrompt, loadSchedulerState, toggleSchedulerPause } from "../scheduler.ts";
|
|
8
|
+
import { SchedulerManagerDialog } from "./SchedulerManagerDialog.tsx";
|
|
9
|
+
|
|
10
|
+
function openTimePrompt(api: TuiPluginApi, prompt: string): void {
|
|
11
|
+
const DialogPrompt = api.ui.DialogPrompt;
|
|
12
|
+
api.ui.dialog.replace(() => (
|
|
13
|
+
<DialogPrompt
|
|
14
|
+
title="Schedule Prompt"
|
|
15
|
+
placeholder="in 30m, at 3:30am, today 14:00, tomorrow 9am"
|
|
16
|
+
onCancel={() => openManager(api)}
|
|
17
|
+
onConfirm={async (value) => {
|
|
18
|
+
const runAt = parseTimeSpec(value);
|
|
19
|
+
if (!runAt) {
|
|
20
|
+
api.ui.toast({ message: "Could not understand that time", variant: "error", duration: 3000 });
|
|
21
|
+
openTimePrompt(api, prompt);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await addScheduledPrompt(api, prompt, runAt);
|
|
26
|
+
await openManager(api);
|
|
27
|
+
}}
|
|
28
|
+
description={() => (
|
|
29
|
+
<box flexDirection="column" gap={1}>
|
|
30
|
+
<text fg={api.theme.current.textMuted}>Prompt captured. Choose when it should send.</text>
|
|
31
|
+
</box>
|
|
32
|
+
)}
|
|
33
|
+
/>
|
|
34
|
+
));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function openPromptInput(api: TuiPluginApi, value = ""): void {
|
|
38
|
+
const DialogPrompt = api.ui.DialogPrompt;
|
|
39
|
+
api.ui.dialog.replace(() => (
|
|
40
|
+
<DialogPrompt
|
|
41
|
+
title="Schedule Prompt"
|
|
42
|
+
value={value}
|
|
43
|
+
placeholder="Prompt to send later"
|
|
44
|
+
onCancel={() => openManager(api)}
|
|
45
|
+
onConfirm={(prompt) => {
|
|
46
|
+
const parsed = parseScheduleRequest(prompt);
|
|
47
|
+
if (!parsed.prompt) {
|
|
48
|
+
api.ui.toast({ message: "Prompt cannot be empty", variant: "error", duration: 2500 });
|
|
49
|
+
openPromptInput(api, prompt);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (parsed.runAt) {
|
|
54
|
+
void addScheduledPrompt(api, parsed.prompt, parsed.runAt).then(() => openManager(api));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
openTimePrompt(api, parsed.prompt);
|
|
59
|
+
}}
|
|
60
|
+
/>
|
|
61
|
+
));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function confirmCancel(api: TuiPluginApi, job: ScheduledPrompt): void {
|
|
65
|
+
const DialogConfirm = api.ui.DialogConfirm;
|
|
66
|
+
api.ui.dialog.replace(() => (
|
|
67
|
+
<DialogConfirm
|
|
68
|
+
title="Cancel Prompt"
|
|
69
|
+
message={`${taskID(job)} at ${formatRunAt(job.runAt)}`}
|
|
70
|
+
onCancel={() => openManager(api)}
|
|
71
|
+
onConfirm={() => {
|
|
72
|
+
void cancelScheduledPrompt(api, job).then(() => openManager(api));
|
|
73
|
+
}}
|
|
74
|
+
/>
|
|
75
|
+
));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function openManager(api: TuiPluginApi): Promise<void> {
|
|
79
|
+
const state = await loadSchedulerState();
|
|
80
|
+
api.ui.dialog.replace(() => (
|
|
81
|
+
<SchedulerManagerDialog
|
|
82
|
+
api={api}
|
|
83
|
+
initialJobs={state.jobs}
|
|
84
|
+
initialPaused={state.paused}
|
|
85
|
+
loadJobs={loadSchedulerState}
|
|
86
|
+
onAdd={() => openPromptInput(api)}
|
|
87
|
+
onCancel={(job) => confirmCancel(api, job)}
|
|
88
|
+
onTogglePause={() => toggleSchedulerPause(api)}
|
|
89
|
+
/>
|
|
90
|
+
));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function openScheduler(api: TuiPluginApi): Promise<void> {
|
|
94
|
+
const draft = await takeDraftPrompt();
|
|
95
|
+
if (draft) {
|
|
96
|
+
const parsed = parseScheduleRequest(draft);
|
|
97
|
+
if (parsed.prompt && parsed.runAt) {
|
|
98
|
+
await addScheduledPrompt(api, parsed.prompt, parsed.runAt);
|
|
99
|
+
await openManager(api);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
openPromptInput(api, parsed.prompt || draft);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await openManager(api);
|
|
108
|
+
}
|
package/src/tui/ui.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { MouseButton, type MouseEvent } from "@opentui/core";
|
|
2
|
+
|
|
3
|
+
export function clickPrimary(event: MouseEvent): boolean {
|
|
4
|
+
if (event.button !== MouseButton.LEFT) {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
event.preventDefault();
|
|
9
|
+
event.stopPropagation();
|
|
10
|
+
return true;
|
|
11
|
+
}
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
|
|
3
|
+
import { PLUGIN_ID, SCHEDULE_COMMAND_OPEN } from "./constants.ts";
|
|
4
|
+
import { deliverDuePrompts, TICK_MS } from "./scheduler.ts";
|
|
5
|
+
import { openManager, openScheduler } from "./tui/dialogs.tsx";
|
|
6
|
+
import { SidebarScheduledPrompts } from "./tui/SidebarScheduledPrompts.tsx";
|
|
7
|
+
|
|
8
|
+
const tui: TuiPlugin = async (api) => {
|
|
9
|
+
const disposeCommands = api.keymap.registerLayer({
|
|
10
|
+
commands: [
|
|
11
|
+
{
|
|
12
|
+
namespace: "palette",
|
|
13
|
+
name: SCHEDULE_COMMAND_OPEN,
|
|
14
|
+
title: "Schedule Prompt",
|
|
15
|
+
category: "Plugin",
|
|
16
|
+
slashName: "schedule",
|
|
17
|
+
run: () => {
|
|
18
|
+
void openScheduler(api);
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
api.slots.register({
|
|
25
|
+
order: 180,
|
|
26
|
+
slots: {
|
|
27
|
+
sidebar_content(_ctx, props) {
|
|
28
|
+
return <SidebarScheduledPrompts api={api} sessionID={props.session_id} onOpenManager={() => void openManager(api)} />;
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const timer = setInterval(() => {
|
|
34
|
+
void deliverDuePrompts(api);
|
|
35
|
+
}, TICK_MS);
|
|
36
|
+
|
|
37
|
+
void deliverDuePrompts(api);
|
|
38
|
+
api.lifecycle.onDispose(disposeCommands);
|
|
39
|
+
api.lifecycle.onDispose(() => clearInterval(timer));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const module: TuiPluginModule & { id: string } = {
|
|
43
|
+
id: PLUGIN_ID,
|
|
44
|
+
tui,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export default module;
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type ScheduleStatus = "pending" | "sent" | "canceled" | "failed";
|
|
2
|
+
|
|
3
|
+
export interface ScheduledPrompt {
|
|
4
|
+
id: string;
|
|
5
|
+
prompt: string;
|
|
6
|
+
runAt: number;
|
|
7
|
+
createdAt: number;
|
|
8
|
+
status: ScheduleStatus;
|
|
9
|
+
sessionID?: string;
|
|
10
|
+
sentAt?: number;
|
|
11
|
+
canceledAt?: number;
|
|
12
|
+
lastAttemptAt?: number;
|
|
13
|
+
error?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SchedulerSettings {
|
|
17
|
+
paused: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SchedulerStore {
|
|
21
|
+
version: 1;
|
|
22
|
+
draftPrompt?: string;
|
|
23
|
+
jobs: ScheduledPrompt[];
|
|
24
|
+
settings: SchedulerSettings;
|
|
25
|
+
}
|