glad-web 1.0.18 → 1.0.19
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 +2 -3
- package/README.zh-CN.md +2 -3
- package/lib/ai-tools/registry.js +0 -9
- package/lib/commands/web.js +35 -1
- package/lib/session/session-manager.js +126 -1
- package/lib/web/index.html +272 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
Glad is a local-first Web interface for terminal-based AI coding tools.
|
|
7
7
|
|
|
8
|
-
It lets you run interactive CLI tools such as **Claude Code**, **Aider**, **GitHub Copilot CLI**, and **
|
|
8
|
+
It lets you run interactive CLI tools such as **Claude Code**, **Aider**, **GitHub Copilot CLI**, and **Codex** on your machine, then access them through a clean browser UI from desktop or mobile devices on your local network.
|
|
9
9
|
|
|
10
10
|

|
|
11
11
|
|
|
@@ -122,7 +122,7 @@ glad tools detect
|
|
|
122
122
|
|
|
123
123
|
## Supported Tools
|
|
124
124
|
|
|
125
|
-
Glad currently auto-detects the
|
|
125
|
+
Glad currently auto-detects the 20 terminal AI tools defined in the code registry. The names below are the registry `displayName` values used by Glad:
|
|
126
126
|
|
|
127
127
|
| Tool | Detected command |
|
|
128
128
|
| --- | --- |
|
|
@@ -131,7 +131,6 @@ Glad currently auto-detects the 21 terminal AI tools defined in the code registr
|
|
|
131
131
|
| Codex | `codex` |
|
|
132
132
|
| Copilot | `copilot` |
|
|
133
133
|
| Cody | `cody chat` |
|
|
134
|
-
| Gemini | `gemini` |
|
|
135
134
|
| Antigravity | `agy` |
|
|
136
135
|
| Continue | `cn` |
|
|
137
136
|
| Cursor | `cursor-agent` |
|
package/README.zh-CN.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
Glad 是一个面向终端 AI 编码工具的本地优先 Web 界面。
|
|
7
7
|
|
|
8
|
-
它让你可以在自己的机器上运行 **Claude Code**、**Aider**、**GitHub Copilot CLI**、**
|
|
8
|
+
它让你可以在自己的机器上运行 **Claude Code**、**Aider**、**GitHub Copilot CLI**、**Codex** 等交互式命令行工具,并通过一个适合桌面和移动端访问的浏览器界面来使用它们。
|
|
9
9
|
|
|
10
10
|

|
|
11
11
|
|
|
@@ -122,7 +122,7 @@ glad tools detect
|
|
|
122
122
|
|
|
123
123
|
## 支持的工具
|
|
124
124
|
|
|
125
|
-
Glad 当前会自动检测代码注册表中定义的
|
|
125
|
+
Glad 当前会自动检测代码注册表中定义的 20 个终端 AI 工具。下面的名称严格使用 Glad 注册表里的 `displayName`:
|
|
126
126
|
|
|
127
127
|
| 工具 | 检测命令 |
|
|
128
128
|
| --- | --- |
|
|
@@ -131,7 +131,6 @@ Glad 当前会自动检测代码注册表中定义的 21 个终端 AI 工具。
|
|
|
131
131
|
| Codex | `codex` |
|
|
132
132
|
| Copilot | `copilot` |
|
|
133
133
|
| Cody | `cody chat` |
|
|
134
|
-
| Gemini | `gemini` |
|
|
135
134
|
| Antigravity | `agy` |
|
|
136
135
|
| Continue | `cn` |
|
|
137
136
|
| Cursor | `cursor-agent` |
|
package/lib/ai-tools/registry.js
CHANGED
|
@@ -59,15 +59,6 @@ const AI_TOOLS = {
|
|
|
59
59
|
website: 'https://sourcegraph.com/cody',
|
|
60
60
|
checkInstalled: async () => await commandExists('cody')
|
|
61
61
|
},
|
|
62
|
-
'gemini': {
|
|
63
|
-
key: 'gemini',
|
|
64
|
-
command: 'gemini',
|
|
65
|
-
args: [],
|
|
66
|
-
displayName: 'Gemini',
|
|
67
|
-
description: 'Official Google Gemini CLI with 1M token context',
|
|
68
|
-
website: 'https://developers.google.com/gemini-code-assist',
|
|
69
|
-
checkInstalled: async () => await commandExists('gemini')
|
|
70
|
-
},
|
|
71
62
|
'antigravity': {
|
|
72
63
|
key: 'antigravity',
|
|
73
64
|
command: 'agy',
|
package/lib/commands/web.js
CHANGED
|
@@ -217,6 +217,40 @@ async function webCommand(options) {
|
|
|
217
217
|
res.json({ success: true });
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
+
app.get('/api/sessions/:id/timed-inputs', (req, res) => {
|
|
221
|
+
const items = sessionManager.listTimedInputs(req.params.id);
|
|
222
|
+
if (!items) return res.status(404).json({ error: 'Session not found' });
|
|
223
|
+
res.json({ success: true, items });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
app.post('/api/sessions/:id/timed-inputs', (req, res) => {
|
|
227
|
+
try {
|
|
228
|
+
const item = sessionManager.scheduleTimedInput(req.params.id, req.body || {});
|
|
229
|
+
if (!item) return res.status(404).json({ error: 'Session not found' });
|
|
230
|
+
res.json({ success: true, item });
|
|
231
|
+
} catch (e) {
|
|
232
|
+
res.status(e.statusCode || 500).json({ error: e.message });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
app.patch('/api/sessions/:id/timed-inputs/:inputId', (req, res) => {
|
|
237
|
+
try {
|
|
238
|
+
const item = sessionManager.updateTimedInput(req.params.id, req.params.inputId, req.body || {});
|
|
239
|
+
if (item === null) return res.status(404).json({ error: 'Session not found' });
|
|
240
|
+
if (!item) return res.status(404).json({ error: 'Timed input not found' });
|
|
241
|
+
res.json({ success: true, item });
|
|
242
|
+
} catch (e) {
|
|
243
|
+
res.status(e.statusCode || 500).json({ error: e.message });
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
app.delete('/api/sessions/:id/timed-inputs/:inputId', (req, res) => {
|
|
248
|
+
const cancelled = sessionManager.cancelTimedInput(req.params.id, req.params.inputId);
|
|
249
|
+
if (cancelled === null) return res.status(404).json({ error: 'Session not found' });
|
|
250
|
+
if (!cancelled) return res.status(404).json({ error: 'Timed input not found' });
|
|
251
|
+
res.json({ success: true });
|
|
252
|
+
});
|
|
253
|
+
|
|
220
254
|
// API: Delete/Kill session
|
|
221
255
|
app.delete('/api/sessions/:id', (req, res) => {
|
|
222
256
|
sessionManager.kill(req.params.id);
|
|
@@ -363,7 +397,7 @@ async function webCommand(options) {
|
|
|
363
397
|
// Send catchup output. TUI tools may skip the raw circular buffer, so fall
|
|
364
398
|
// back to the rendered/text history snapshot instead of reconnecting blank.
|
|
365
399
|
const catchup = sessionManager.getCatchupOutput(sessionId);
|
|
366
|
-
ws.needsTuiRedraw = ['antigravity', 'claude-code', 'codex'
|
|
400
|
+
ws.needsTuiRedraw = ['antigravity', 'claude-code', 'codex'].includes(session.tool.key)
|
|
367
401
|
&& (isReconnect || (catchup && catchup.source === 'rendered-history'));
|
|
368
402
|
if (ws.needsTuiRedraw) {
|
|
369
403
|
ws.send(JSON.stringify({ type: 'reset' }));
|
|
@@ -37,7 +37,10 @@ class SessionManager extends EventEmitter {
|
|
|
37
37
|
startTime: session.startTime,
|
|
38
38
|
toolKey: session.tool.key,
|
|
39
39
|
workingDirectory: session.ptyManager.workingDir,
|
|
40
|
-
hasUnreadCompletion: Boolean(session.hasUnreadCompletion)
|
|
40
|
+
hasUnreadCompletion: Boolean(session.hasUnreadCompletion),
|
|
41
|
+
timedInputCount: session.timedInputs
|
|
42
|
+
? Array.from(session.timedInputs.values()).filter(item => item.sendAt > Date.now()).length
|
|
43
|
+
: 0
|
|
41
44
|
}));
|
|
42
45
|
}
|
|
43
46
|
|
|
@@ -99,6 +102,7 @@ class SessionManager extends EventEmitter {
|
|
|
99
102
|
resizeOwner: null,
|
|
100
103
|
hasConnectedWebClient: false,
|
|
101
104
|
hasUnreadCompletion: false,
|
|
105
|
+
timedInputs: new Map(),
|
|
102
106
|
write: data => this.write(id, data),
|
|
103
107
|
isRunning: () => this.has(id) && ptyManager.isRunning(),
|
|
104
108
|
kill: () => this.kill(id)
|
|
@@ -167,10 +171,121 @@ class SessionManager extends EventEmitter {
|
|
|
167
171
|
return session;
|
|
168
172
|
}
|
|
169
173
|
|
|
174
|
+
listTimedInputs(id) {
|
|
175
|
+
const session = this.get(id);
|
|
176
|
+
if (!session) return null;
|
|
177
|
+
return Array.from(session.timedInputs.values()).map(item => ({
|
|
178
|
+
id: item.id,
|
|
179
|
+
text: item.text,
|
|
180
|
+
sendAt: item.sendAt,
|
|
181
|
+
createdAt: item.createdAt
|
|
182
|
+
})).sort((a, b) => a.sendAt - b.sendAt);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
scheduleTimedInput(id, input = {}) {
|
|
186
|
+
const session = this.get(id);
|
|
187
|
+
if (!session) return null;
|
|
188
|
+
|
|
189
|
+
const { text, sendAt, delay } = this.validateTimedInput(input);
|
|
190
|
+
|
|
191
|
+
const item = {
|
|
192
|
+
id: uuidv4(),
|
|
193
|
+
text,
|
|
194
|
+
sendAt,
|
|
195
|
+
createdAt: Date.now(),
|
|
196
|
+
timer: null
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
item.timer = setTimeout(() => {
|
|
200
|
+
this.executeTimedInput(session.id, item.id);
|
|
201
|
+
}, delay);
|
|
202
|
+
session.timedInputs.set(item.id, item);
|
|
203
|
+
return {
|
|
204
|
+
id: item.id,
|
|
205
|
+
text: item.text,
|
|
206
|
+
sendAt: item.sendAt,
|
|
207
|
+
createdAt: item.createdAt
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
updateTimedInput(id, inputId, input = {}) {
|
|
212
|
+
const session = this.get(id);
|
|
213
|
+
if (!session) return null;
|
|
214
|
+
const item = session.timedInputs.get(inputId);
|
|
215
|
+
if (!item) return false;
|
|
216
|
+
|
|
217
|
+
const { text, sendAt, delay } = this.validateTimedInput(input);
|
|
218
|
+
clearTimeout(item.timer);
|
|
219
|
+
item.text = text;
|
|
220
|
+
item.sendAt = sendAt;
|
|
221
|
+
item.updatedAt = Date.now();
|
|
222
|
+
item.timer = setTimeout(() => {
|
|
223
|
+
this.executeTimedInput(session.id, item.id);
|
|
224
|
+
}, delay);
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
id: item.id,
|
|
228
|
+
text: item.text,
|
|
229
|
+
sendAt: item.sendAt,
|
|
230
|
+
createdAt: item.createdAt,
|
|
231
|
+
updatedAt: item.updatedAt
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
validateTimedInput(input = {}) {
|
|
236
|
+
const text = String(input.text || '');
|
|
237
|
+
const sendAt = Number(input.sendAt);
|
|
238
|
+
if (!text.trim()) {
|
|
239
|
+
const err = new Error('Text is required');
|
|
240
|
+
err.statusCode = 400;
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
if (!Number.isFinite(sendAt) || sendAt <= Date.now()) {
|
|
244
|
+
const err = new Error('Send time must be in the future');
|
|
245
|
+
err.statusCode = 400;
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const maxDelay = 30 * 24 * 60 * 60 * 1000;
|
|
250
|
+
const delay = sendAt - Date.now();
|
|
251
|
+
if (delay > maxDelay) {
|
|
252
|
+
const err = new Error('Send time must be within 30 days');
|
|
253
|
+
err.statusCode = 400;
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { text, sendAt, delay };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
cancelTimedInput(id, inputId) {
|
|
261
|
+
const session = this.get(id);
|
|
262
|
+
if (!session) return null;
|
|
263
|
+
const item = session.timedInputs.get(inputId);
|
|
264
|
+
if (!item) return false;
|
|
265
|
+
clearTimeout(item.timer);
|
|
266
|
+
session.timedInputs.delete(inputId);
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
executeTimedInput(id, inputId) {
|
|
271
|
+
const session = this.get(id);
|
|
272
|
+
if (!session) return false;
|
|
273
|
+
const item = session.timedInputs.get(inputId);
|
|
274
|
+
if (!item) return false;
|
|
275
|
+
session.timedInputs.delete(inputId);
|
|
276
|
+
const formatted = item.text.replace(/\n/g, '\r');
|
|
277
|
+
this.write(id, formatted);
|
|
278
|
+
setTimeout(() => {
|
|
279
|
+
if (this.has(id)) this.write(id, '\r');
|
|
280
|
+
}, 1000);
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
170
284
|
kill(id) {
|
|
171
285
|
const session = this.get(id);
|
|
172
286
|
if (!session) return false;
|
|
173
287
|
clearTimeout(session.completionTimer);
|
|
288
|
+
this.clearTimedInputs(session);
|
|
174
289
|
this.logSessionDiagnostics('session-deleted', session, {}, { compact: true });
|
|
175
290
|
this.disposeSessionHistory(session);
|
|
176
291
|
session.ptyManager.kill();
|
|
@@ -182,6 +297,7 @@ class SessionManager extends EventEmitter {
|
|
|
182
297
|
killAll() {
|
|
183
298
|
for (const session of this.sessions.values()) {
|
|
184
299
|
clearTimeout(session.completionTimer);
|
|
300
|
+
this.clearTimedInputs(session);
|
|
185
301
|
this.disposeSessionHistory(session);
|
|
186
302
|
session.ptyManager.kill();
|
|
187
303
|
}
|
|
@@ -332,6 +448,7 @@ class SessionManager extends EventEmitter {
|
|
|
332
448
|
if (!this.sessions.has(session.id)) return;
|
|
333
449
|
this.logger.info(`Session ${session.id} (${session.name}) exited.`);
|
|
334
450
|
clearTimeout(session.completionTimer);
|
|
451
|
+
this.clearTimedInputs(session);
|
|
335
452
|
this.disposeSessionHistory(session);
|
|
336
453
|
this.sessions.delete(session.id);
|
|
337
454
|
this.emit('exit', { sessionId: session.id, session });
|
|
@@ -357,6 +474,14 @@ class SessionManager extends EventEmitter {
|
|
|
357
474
|
}
|
|
358
475
|
}
|
|
359
476
|
|
|
477
|
+
clearTimedInputs(session) {
|
|
478
|
+
if (!session || !session.timedInputs) return;
|
|
479
|
+
for (const item of session.timedInputs.values()) {
|
|
480
|
+
clearTimeout(item.timer);
|
|
481
|
+
}
|
|
482
|
+
session.timedInputs.clear();
|
|
483
|
+
}
|
|
484
|
+
|
|
360
485
|
getSessionDiagnostics(session, extra = {}) {
|
|
361
486
|
return {
|
|
362
487
|
sessionId: session.id,
|
package/lib/web/index.html
CHANGED
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
.session-info { flex: 1; min-width: 0; }
|
|
27
27
|
.session-info h3 { margin: 0 0 4px 0; font-size: 17px; display: flex; align-items: center; gap: 8px; }
|
|
28
28
|
.session-info p { margin: 0; font-size: 13px; color: var(--text-dim); }
|
|
29
|
+
.timer-count-badge { display: inline-flex; align-items: center; gap: 3px; color: #fff; background: rgba(0,122,255,0.22); border: 1px solid rgba(0,122,255,0.34); border-radius: 999px; padding: 2px 7px; font-size: 11px; font-weight: 800; flex-shrink: 0; }
|
|
30
|
+
.session-dir-row { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
|
|
31
|
+
.session-dir-row p { flex: 1; min-width: 0; font-family: monospace; overflow-wrap: anywhere; }
|
|
32
|
+
.copy-dir-btn { color: var(--text-dim); background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; width: 28px; height: 28px; padding: 0; display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; }
|
|
33
|
+
.copy-dir-btn:active { color: #fff; background: rgba(255,255,255,0.12); }
|
|
29
34
|
.session-actions { display: flex; gap: 12px; align-items: center; margin-left: 10px; }
|
|
30
35
|
.btn-join { background: rgba(255,255,255,0.1); border: none; color: var(--primary); padding: 8px 14px; border-radius: 18px; font-weight: 600; font-size: 14px; cursor: pointer; }
|
|
31
36
|
.icon-btn { color: var(--text-dim); background: none; border: none; padding: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
|
|
@@ -47,7 +52,19 @@
|
|
|
47
52
|
#cmd-input { flex: 1; min-height: 38px; max-height: 150px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); border-radius: 19px; color: #fff; padding: 9px 16px; font-size: 16px; outline: none; resize: none; overflow-y: auto; line-height: 20px; box-sizing: border-box; transition: background 0.18s ease, border-color 0.18s ease; }
|
|
48
53
|
#cmd-input::placeholder { color: rgba(255,255,255,0.45); }
|
|
49
54
|
#cmd-input:focus { background: rgba(255,255,255,0.1); border-color: rgba(0,122,255,0.42); color: #fff; }
|
|
55
|
+
#timer-btn { width: 38px; height: 38px; margin-left: 8px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); border-radius: 19px; color: #d1d5db; display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; }
|
|
56
|
+
#timer-btn.active { color: #fff; border-color: rgba(0,122,255,0.45); background: rgba(0,122,255,0.22); }
|
|
50
57
|
#send-btn { width: 44px; height: 38px; margin-left: 10px; background: #007aff; border: none; border-radius: 19px; color: #fff; display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; }
|
|
58
|
+
#timed-send-panel { display: none; margin: 0 14px 10px 14px; padding: 12px; border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; background: rgba(28,28,30,0.98); box-sizing: border-box; }
|
|
59
|
+
#timed-send-panel.active { display: block; }
|
|
60
|
+
.timed-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; align-items: center; }
|
|
61
|
+
.timed-row select { min-width: 0; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); color: #fff; border-radius: 8px; padding: 8px 9px; font-size: 13px; outline: none; box-sizing: border-box; }
|
|
62
|
+
.timed-preview { margin-top: 8px; color: #d1d5db; font-size: 12px; line-height: 1.4; }
|
|
63
|
+
.timed-editor-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 10px; }
|
|
64
|
+
#timed-tag-rail { position: absolute; left: 4px; top: 58px; display: flex; flex-direction: column; gap: 6px; z-index: 2400; pointer-events: none; }
|
|
65
|
+
.timed-tag { width: 54px; min-height: 34px; border: 1px solid rgba(255,255,255,0.14); border-left: 0; border-radius: 0 11px 11px 0; background: rgba(0,122,255,0.24); color: #fff; backdrop-filter: blur(12px); font-size: 12px; font-weight: 800; letter-spacing: 0; cursor: pointer; display: flex; align-items: center; justify-content: center; text-align: center; line-height: 1.08; white-space: pre-line; box-shadow: 0 8px 20px rgba(0,0,0,0.25); pointer-events: auto; }
|
|
66
|
+
.timed-tag.active { background: rgba(0,122,255,0.42); border-color: rgba(255,255,255,0.28); }
|
|
67
|
+
.timed-tag:active { transform: scale(0.96); }
|
|
51
68
|
#shortcut-rail { display: flex; gap: 28px; overflow-x: auto; overscroll-behavior-x: contain; -webkit-overflow-scrolling: touch; scrollbar-width: none; scroll-snap-type: x mandatory; padding: 0 14px 12px 14px; box-sizing: border-box; scroll-padding-left: 14px; scroll-padding-right: 14px; }
|
|
52
69
|
#shortcut-rail::-webkit-scrollbar { display: none; }
|
|
53
70
|
.shortcut-group { flex: 0 0 calc(100vw - 28px); display: grid; gap: 8px; scroll-snap-align: start; }
|
|
@@ -109,6 +126,7 @@
|
|
|
109
126
|
.schedule-row { flex-direction: column; }
|
|
110
127
|
.schedule-actions { justify-content: flex-start; }
|
|
111
128
|
.step-grid { grid-template-columns: 1fr; }
|
|
129
|
+
.timed-row { grid-template-columns: 1fr; }
|
|
112
130
|
}
|
|
113
131
|
</style>
|
|
114
132
|
</head>
|
|
@@ -159,12 +177,28 @@
|
|
|
159
177
|
</div>
|
|
160
178
|
</div>
|
|
161
179
|
<div id="terminal-controls">
|
|
180
|
+
<div id="timed-tag-rail"></div>
|
|
162
181
|
<div id="input-row">
|
|
163
182
|
<textarea id="cmd-input" rows="1" placeholder="Type a message..."></textarea>
|
|
183
|
+
<button id="timer-btn" title="Schedule send">
|
|
184
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
|
|
185
|
+
</button>
|
|
164
186
|
<button id="send-btn">
|
|
165
187
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
|
|
166
188
|
</button>
|
|
167
189
|
</div>
|
|
190
|
+
<div id="timed-send-panel">
|
|
191
|
+
<div class="timed-row">
|
|
192
|
+
<select id="timed-hours" onchange="updateTimedSendPreview()" title="Hours"></select>
|
|
193
|
+
<select id="timed-minutes" onchange="updateTimedSendPreview()" title="Minutes"></select>
|
|
194
|
+
<button id="timed-save-btn" class="small-btn primary" onclick="saveTimedSend()">Add Timer</button>
|
|
195
|
+
</div>
|
|
196
|
+
<div id="timed-preview" class="timed-preview"></div>
|
|
197
|
+
<div class="timed-editor-actions">
|
|
198
|
+
<button id="timed-cancel-edit-btn" class="small-btn" onclick="resetTimedEditor()" style="display:none;">Cancel Edit</button>
|
|
199
|
+
<button id="timed-delete-btn" class="small-btn danger" onclick="deleteEditingTimedInput()" style="display:none;">Delete</button>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
168
202
|
<div id="shortcut-rail">
|
|
169
203
|
<div class="shortcut-group simple-shortcuts">
|
|
170
204
|
<div class="key-btn special-key" data-key="up">↑</div>
|
|
@@ -317,6 +351,9 @@
|
|
|
317
351
|
let editingScheduleId = null;
|
|
318
352
|
let editingSteps = [];
|
|
319
353
|
let selectedWeekdays = [1, 2, 3, 4, 5];
|
|
354
|
+
let timedSendRefreshTimer = null;
|
|
355
|
+
let timedTagTimer = null;
|
|
356
|
+
let editingTimedInputId = null;
|
|
320
357
|
const modifiers = { ctrl: false };
|
|
321
358
|
|
|
322
359
|
function log(msg) {
|
|
@@ -360,6 +397,39 @@
|
|
|
360
397
|
return decodeURIComponent(path);
|
|
361
398
|
}
|
|
362
399
|
|
|
400
|
+
async function copyTextToClipboard(text) {
|
|
401
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
402
|
+
await navigator.clipboard.writeText(text);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const el = document.createElement('textarea');
|
|
406
|
+
el.value = text;
|
|
407
|
+
el.setAttribute('readonly', '');
|
|
408
|
+
el.style.position = 'fixed';
|
|
409
|
+
el.style.opacity = '0';
|
|
410
|
+
document.body.appendChild(el);
|
|
411
|
+
el.select();
|
|
412
|
+
document.execCommand('copy');
|
|
413
|
+
document.body.removeChild(el);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function copySessionDirectory(directory, event) {
|
|
417
|
+
event.stopPropagation();
|
|
418
|
+
const btn = event.currentTarget;
|
|
419
|
+
try {
|
|
420
|
+
await copyTextToClipboard(directory);
|
|
421
|
+
const oldTitle = btn.title;
|
|
422
|
+
btn.title = 'Copied';
|
|
423
|
+
btn.style.color = '#fff';
|
|
424
|
+
setTimeout(() => {
|
|
425
|
+
btn.title = oldTitle || 'Copy directory';
|
|
426
|
+
btn.style.color = '';
|
|
427
|
+
}, 1200);
|
|
428
|
+
} catch (e) {
|
|
429
|
+
alert('Copy failed');
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
363
433
|
function formatWeekdays(days = []) {
|
|
364
434
|
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
365
435
|
return days.map(day => labels[day]).filter(Boolean).join(', ') || 'No days';
|
|
@@ -443,12 +513,25 @@
|
|
|
443
513
|
sessions.forEach(s => {
|
|
444
514
|
const workingDirectory = s.workingDirectory || 'Unknown directory';
|
|
445
515
|
const encodedName = encodePathValue(s.name);
|
|
516
|
+
const encodedDir = encodePathValue(workingDirectory);
|
|
517
|
+
const timedInputCount = Number(s.timedInputCount) || 0;
|
|
518
|
+
const timerBadge = timedInputCount > 0
|
|
519
|
+
? `<span class="timer-count-badge" title="${timedInputCount} scheduled timer${timedInputCount > 1 ? 's' : ''}">
|
|
520
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
|
|
521
|
+
${timedInputCount}
|
|
522
|
+
</span>`
|
|
523
|
+
: '';
|
|
446
524
|
html += `<div class="session-card">
|
|
447
525
|
<div class="session-info">
|
|
448
|
-
<h3>${escapeHtml(s.name)}${s.hasUnreadCompletion ? '<span class="completion-dot" title="Completed"></span>' : ''} <button class="icon-btn" onclick="renameSession('${s.id}', decodePathValue('${encodedName}'), event)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button></h3>
|
|
526
|
+
<h3>${escapeHtml(s.name)}${s.hasUnreadCompletion ? '<span class="completion-dot" title="Completed"></span>' : ''}${timerBadge} <button class="icon-btn" onclick="renameSession('${s.id}', decodePathValue('${encodedName}'), event)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button></h3>
|
|
449
527
|
<p>${escapeHtml(s.tool)}</p>
|
|
450
528
|
<p>${new Date(s.startTime).toLocaleTimeString()}</p>
|
|
451
|
-
<
|
|
529
|
+
<div class="session-dir-row">
|
|
530
|
+
<button class="copy-dir-btn" title="Copy directory" onclick="copySessionDirectory(decodePathValue('${encodedDir}'), event)">
|
|
531
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
|
532
|
+
</button>
|
|
533
|
+
<p title="${escapeHtml(workingDirectory)}">${escapeHtml(workingDirectory)}</p>
|
|
534
|
+
</div>
|
|
452
535
|
</div>
|
|
453
536
|
<div class="session-actions">
|
|
454
537
|
<button class="btn-join" onclick="joinSession('${s.id}', decodePathValue('${encodedName}'), '${escapeHtml(s.toolKey || '')}')">Connect</button>
|
|
@@ -826,6 +909,8 @@
|
|
|
826
909
|
|
|
827
910
|
function showLobby() {
|
|
828
911
|
if (currentSocket) { currentSocket.close(); currentSocket = null; }
|
|
912
|
+
closeTimedSendPanel();
|
|
913
|
+
stopTimedInputTimers();
|
|
829
914
|
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
|
830
915
|
document.getElementById('lobby-view').classList.add('active');
|
|
831
916
|
refreshSessionsNow();
|
|
@@ -849,13 +934,6 @@
|
|
|
849
934
|
{ command: '/model' },
|
|
850
935
|
{ command: '/resume' },
|
|
851
936
|
{ command: '/stat' }
|
|
852
|
-
],
|
|
853
|
-
gemini: [
|
|
854
|
-
{ key: 'ctrl-c', label: 'Ctrl+C' },
|
|
855
|
-
{ key: 'ctrl-y', label: 'Ctrl+Y', title: 'Send Ctrl+Y' },
|
|
856
|
-
{ command: '/model' },
|
|
857
|
-
{ command: '/resume' },
|
|
858
|
-
{ command: '/stat' }
|
|
859
937
|
]
|
|
860
938
|
};
|
|
861
939
|
const shortcuts = toolShortcuts[toolKey] || defaultShortcuts;
|
|
@@ -882,12 +960,14 @@
|
|
|
882
960
|
}
|
|
883
961
|
|
|
884
962
|
function joinSession(id, sessionName, toolKey = null) {
|
|
963
|
+
stopTimedInputTimers();
|
|
885
964
|
activeSessionId = id;
|
|
886
965
|
window.activeSessionId = id;
|
|
887
966
|
activeToolKey = toolKey;
|
|
888
967
|
clearTimeout(sessionPollTimer);
|
|
889
968
|
sessionPollTimer = null;
|
|
890
969
|
markCompletionRead(id);
|
|
970
|
+
loadTimedInputs();
|
|
891
971
|
document.getElementById('session-title').innerText = sessionName;
|
|
892
972
|
updateToolShortcuts(activeToolKey);
|
|
893
973
|
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
|
@@ -1152,8 +1232,191 @@
|
|
|
1152
1232
|
setTimeout(() => { sendWS('\r'); }, 1000);
|
|
1153
1233
|
}
|
|
1154
1234
|
}
|
|
1235
|
+
|
|
1236
|
+
let timedInputs = [];
|
|
1237
|
+
|
|
1238
|
+
function initTimedDelaySelectors() {
|
|
1239
|
+
const hours = document.getElementById('timed-hours');
|
|
1240
|
+
const minutes = document.getElementById('timed-minutes');
|
|
1241
|
+
if (hours.options.length) return;
|
|
1242
|
+
for (let i = 0; i <= 23; i++) {
|
|
1243
|
+
hours.add(new Option(`${i} hr`, String(i)));
|
|
1244
|
+
}
|
|
1245
|
+
for (let i = 0; i <= 59; i++) {
|
|
1246
|
+
minutes.add(new Option(`${i} min`, String(i)));
|
|
1247
|
+
}
|
|
1248
|
+
hours.value = '0';
|
|
1249
|
+
minutes.value = '5';
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function getTimedDelayMs() {
|
|
1253
|
+
const hours = Number(document.getElementById('timed-hours').value);
|
|
1254
|
+
const minutes = Number(document.getElementById('timed-minutes').value);
|
|
1255
|
+
const delayMs = ((hours * 60) + minutes) * 60 * 1000;
|
|
1256
|
+
return Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function parseSendAt() {
|
|
1260
|
+
const delayMs = getTimedDelayMs();
|
|
1261
|
+
return delayMs > 0 ? Date.now() + delayMs : null;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function setTimedDelayFromMs(ms) {
|
|
1265
|
+
const totalMinutes = Math.max(1, Math.ceil(ms / 60000));
|
|
1266
|
+
const hours = Math.min(23, Math.floor(totalMinutes / 60));
|
|
1267
|
+
const minutes = Math.min(59, totalMinutes - hours * 60);
|
|
1268
|
+
document.getElementById('timed-hours').value = String(hours);
|
|
1269
|
+
document.getElementById('timed-minutes').value = String(minutes);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function updateTimedSendPreview() {
|
|
1273
|
+
const preview = document.getElementById('timed-preview');
|
|
1274
|
+
const sendAt = parseSendAt();
|
|
1275
|
+
preview.textContent = sendAt
|
|
1276
|
+
? `Will run at ${new Date(sendAt).toLocaleString()}`
|
|
1277
|
+
: 'Choose at least 1 minute.';
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function formatCountdown(sendAt) {
|
|
1281
|
+
const remaining = Math.max(0, sendAt - Date.now());
|
|
1282
|
+
const totalSeconds = Math.ceil(remaining / 1000);
|
|
1283
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
1284
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
1285
|
+
const seconds = totalSeconds % 60;
|
|
1286
|
+
if (hours > 0) return `${hours}h\n${String(minutes).padStart(2, '0')}m`;
|
|
1287
|
+
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function renderTimedTags() {
|
|
1291
|
+
const rail = document.getElementById('timed-tag-rail');
|
|
1292
|
+
const now = Date.now();
|
|
1293
|
+
const activeItems = timedInputs.filter(item => item.sendAt > now);
|
|
1294
|
+
rail.innerHTML = activeItems.map(item => {
|
|
1295
|
+
const encodedId = encodePathValue(item.id);
|
|
1296
|
+
return `<button class="timed-tag${item.id === editingTimedInputId ? ' active' : ''}" title="${escapeHtml(item.text)}" onclick="editTimedInput(decodePathValue('${encodedId}'))">${escapeHtml(formatCountdown(item.sendAt))}</button>`;
|
|
1297
|
+
}).join('');
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function closeTimedSendPanel() {
|
|
1301
|
+
document.getElementById('timed-send-panel').classList.remove('active');
|
|
1302
|
+
document.getElementById('timer-btn').classList.remove('active');
|
|
1303
|
+
editingTimedInputId = null;
|
|
1304
|
+
renderTimedTags();
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function stopTimedInputTimers() {
|
|
1308
|
+
clearTimeout(timedSendRefreshTimer);
|
|
1309
|
+
timedSendRefreshTimer = null;
|
|
1310
|
+
if (timedTagTimer) clearInterval(timedTagTimer);
|
|
1311
|
+
timedTagTimer = null;
|
|
1312
|
+
timedInputs = [];
|
|
1313
|
+
renderTimedTags();
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function resetTimedEditor(options = {}) {
|
|
1317
|
+
editingTimedInputId = null;
|
|
1318
|
+
document.getElementById('timed-save-btn').textContent = 'Add Timer';
|
|
1319
|
+
document.getElementById('timed-cancel-edit-btn').style.display = 'none';
|
|
1320
|
+
document.getElementById('timed-delete-btn').style.display = 'none';
|
|
1321
|
+
if (!options.keepInput) {
|
|
1322
|
+
inputEl.value = '';
|
|
1323
|
+
inputEl.style.height = '38px';
|
|
1324
|
+
}
|
|
1325
|
+
setTimedDelayFromMs(5 * 60 * 1000);
|
|
1326
|
+
updateTimedSendPreview();
|
|
1327
|
+
renderTimedTags();
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
async function loadTimedInputs() {
|
|
1331
|
+
clearTimeout(timedSendRefreshTimer);
|
|
1332
|
+
timedSendRefreshTimer = null;
|
|
1333
|
+
if (!activeSessionId) return;
|
|
1334
|
+
|
|
1335
|
+
try {
|
|
1336
|
+
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/timed-inputs`);
|
|
1337
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
1338
|
+
const data = await res.json();
|
|
1339
|
+
timedInputs = data.items || [];
|
|
1340
|
+
renderTimedTags();
|
|
1341
|
+
if (!timedTagTimer) timedTagTimer = setInterval(renderTimedTags, 1000);
|
|
1342
|
+
timedSendRefreshTimer = setTimeout(loadTimedInputs, 15000);
|
|
1343
|
+
} catch (e) {
|
|
1344
|
+
timedInputs = [];
|
|
1345
|
+
renderTimedTags();
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function editTimedInput(id) {
|
|
1350
|
+
const item = timedInputs.find(value => value.id === id);
|
|
1351
|
+
if (!item) return;
|
|
1352
|
+
initTimedDelaySelectors();
|
|
1353
|
+
editingTimedInputId = id;
|
|
1354
|
+
inputEl.value = item.text || '';
|
|
1355
|
+
inputEl.style.height = 'auto';
|
|
1356
|
+
inputEl.style.height = Math.min(inputEl.scrollHeight, 150) + 'px';
|
|
1357
|
+
setTimedDelayFromMs(item.sendAt - Date.now());
|
|
1358
|
+
document.getElementById('timed-save-btn').textContent = 'Update Timer';
|
|
1359
|
+
document.getElementById('timed-cancel-edit-btn').style.display = '';
|
|
1360
|
+
document.getElementById('timed-delete-btn').style.display = '';
|
|
1361
|
+
document.getElementById('timed-send-panel').classList.add('active');
|
|
1362
|
+
document.getElementById('timer-btn').classList.add('active');
|
|
1363
|
+
updateTimedSendPreview();
|
|
1364
|
+
renderTimedTags();
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
async function saveTimedSend() {
|
|
1368
|
+
if (!activeSessionId) return alert('No active session');
|
|
1369
|
+
const text = inputEl.value;
|
|
1370
|
+
if (!text.trim()) return alert('Type a message first');
|
|
1371
|
+
const sendAt = parseSendAt();
|
|
1372
|
+
if (!sendAt) return alert('Choose at least 1 minute');
|
|
1373
|
+
|
|
1374
|
+
try {
|
|
1375
|
+
const url = editingTimedInputId
|
|
1376
|
+
? `/api/sessions/${activeSessionId}/timed-inputs/${editingTimedInputId}`
|
|
1377
|
+
: `/api/sessions/${activeSessionId}/timed-inputs`;
|
|
1378
|
+
const res = await fetchWithTimeout(url, {
|
|
1379
|
+
method: editingTimedInputId ? 'PATCH' : 'POST',
|
|
1380
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1381
|
+
body: JSON.stringify({ text, sendAt })
|
|
1382
|
+
});
|
|
1383
|
+
const data = await res.json();
|
|
1384
|
+
if (!res.ok || !data.success) throw new Error(data.error || 'Failed to save timer');
|
|
1385
|
+
resetTimedEditor();
|
|
1386
|
+
await loadTimedInputs();
|
|
1387
|
+
} catch (e) {
|
|
1388
|
+
alert('Save failed: ' + e.message);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
async function cancelTimedInput(id) {
|
|
1393
|
+
if (!activeSessionId) return;
|
|
1394
|
+
try {
|
|
1395
|
+
await fetchWithTimeout(`/api/sessions/${activeSessionId}/timed-inputs/${id}`, { method: 'DELETE' });
|
|
1396
|
+
if (editingTimedInputId === id) resetTimedEditor();
|
|
1397
|
+
loadTimedInputs();
|
|
1398
|
+
} catch (e) {
|
|
1399
|
+
alert('Delete failed');
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function deleteEditingTimedInput() {
|
|
1404
|
+
if (editingTimedInputId) cancelTimedInput(editingTimedInputId);
|
|
1405
|
+
}
|
|
1155
1406
|
|
|
1156
1407
|
document.getElementById('send-btn').addEventListener('click', performSend);
|
|
1408
|
+
document.getElementById('timer-btn').addEventListener('click', () => {
|
|
1409
|
+
const panel = document.getElementById('timed-send-panel');
|
|
1410
|
+
const isOpen = panel.classList.toggle('active');
|
|
1411
|
+
document.getElementById('timer-btn').classList.toggle('active', isOpen);
|
|
1412
|
+
if (isOpen) {
|
|
1413
|
+
initTimedDelaySelectors();
|
|
1414
|
+
resetTimedEditor({ keepInput: true });
|
|
1415
|
+
updateTimedSendPreview();
|
|
1416
|
+
loadTimedInputs();
|
|
1417
|
+
}
|
|
1418
|
+
else closeTimedSendPanel();
|
|
1419
|
+
});
|
|
1157
1420
|
inputEl.addEventListener('keydown', (e) => {
|
|
1158
1421
|
if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); performSend(); }
|
|
1159
1422
|
else if (e.key === 'Enter' || e.key === 'Backspace' || e.key === 'Delete') {
|