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 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 **Gemini CLI** on your machine, then access them through a clean browser UI from desktop or mobile devices on your local network.
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
  ![Glad AI mobile interface](./assets/demo.jpg)
11
11
 
@@ -122,7 +122,7 @@ glad tools detect
122
122
 
123
123
  ## Supported Tools
124
124
 
125
- Glad currently auto-detects the 21 terminal AI tools defined in the code registry. The names below are the registry `displayName` values used by Glad:
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**、**Gemini CLI** 等交互式命令行工具,并通过一个适合桌面和移动端访问的浏览器界面来使用它们。
8
+ 它让你可以在自己的机器上运行 **Claude Code**、**Aider**、**GitHub Copilot CLI**、**Codex** 等交互式命令行工具,并通过一个适合桌面和移动端访问的浏览器界面来使用它们。
9
9
 
10
10
  ![Glad AI 移动端界面](./assets/demo.jpg)
11
11
 
@@ -122,7 +122,7 @@ glad tools detect
122
122
 
123
123
  ## 支持的工具
124
124
 
125
- Glad 当前会自动检测代码注册表中定义的 21 个终端 AI 工具。下面的名称严格使用 Glad 注册表里的 `displayName`:
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` |
@@ -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',
@@ -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', 'gemini'].includes(session.tool.key)
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,
@@ -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
- <p title="${escapeHtml(workingDirectory)}" style="font-family: monospace; overflow-wrap: anywhere;">${escapeHtml(workingDirectory)}</p>
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') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glad-web",
3
- "version": "1.0.18",
3
+ "version": "1.0.19",
4
4
  "description": "Glad transforms terminal-based AI coding tools into a polished, mobile-friendly local Web interface.",
5
5
  "main": "index.js",
6
6
  "bin": {