glad-web 1.0.17 → 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);
@@ -244,6 +278,15 @@ async function webCommand(options) {
244
278
  res.json({ success: result.success, stdout: result.stdout, stderr: result.stderr });
245
279
  });
246
280
 
281
+ // API: Git Branch Name for Commit
282
+ app.get('/api/sessions/:id/git-branch/:hash', async (req, res) => {
283
+ const session = sessionManager.get(req.params.id);
284
+ if (!session) return res.status(404).json({ error: 'Session not found' });
285
+ const hash = req.params.hash;
286
+ const result = await gitService.nameRev(session.ptyManager.workingDir, hash);
287
+ res.json({ success: result.success, stdout: result.stdout, stderr: result.stderr });
288
+ });
289
+
247
290
  // API: Git Log
248
291
  app.get('/api/sessions/:id/git-log', async (req, res) => {
249
292
  const session = sessionManager.get(req.params.id);
@@ -354,7 +397,7 @@ async function webCommand(options) {
354
397
  // Send catchup output. TUI tools may skip the raw circular buffer, so fall
355
398
  // back to the rendered/text history snapshot instead of reconnecting blank.
356
399
  const catchup = sessionManager.getCatchupOutput(sessionId);
357
- ws.needsTuiRedraw = ['antigravity', 'claude-code', 'codex', 'gemini'].includes(session.tool.key)
400
+ ws.needsTuiRedraw = ['antigravity', 'claude-code', 'codex'].includes(session.tool.key)
358
401
  && (isReconnect || (catchup && catchup.source === 'rendered-history'));
359
402
  if (ws.needsTuiRedraw) {
360
403
  ws.send(JSON.stringify({ type: 'reset' }));
@@ -70,6 +70,10 @@ class GitService {
70
70
  : ['diff', '--no-ext-diff', '--', filePath];
71
71
  return execFilePromise('git', args, cwd);
72
72
  }
73
+
74
+ async nameRev(cwd, hash) {
75
+ return execFilePromise('git', ['name-rev', '--name-only', '--exclude=tags/*', hash], cwd);
76
+ }
73
77
  }
74
78
 
75
79
  module.exports = {
@@ -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,
@@ -175,13 +175,14 @@ class GitGraphRenderer {
175
175
 
176
176
  let detailsHTML = `
177
177
  <div style="display:flex; justify-content:space-between; margin-bottom:12px;">
178
- <div>
179
- <div style="font-weight:600; font-size:14px; margin-bottom:4px;">${commit.subject.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</div>
178
+ <div style="flex: 1; padding-right: 16px; min-width: 0;">
179
+ <div style="font-weight:600; font-size:14px; margin-bottom:4px; word-break: break-word;">${commit.subject.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</div>
180
180
  <div style="color:var(--text-dim);">${commit.author} commited ${commit.time}</div>
181
+ <div id="branch-info-${commit.hash}" style="font-family:monospace; color:var(--primary); margin-top:6px; font-size:12px; font-weight:500;"></div>
181
182
  </div>
182
- <div style="text-align:right;">
183
+ <div style="text-align:right; flex-shrink: 0;">
183
184
  <div style="font-family:monospace; color:var(--text-dim);">Commit: ${commit.hash}</div>
184
- ${commit.parents.length > 0 ? `<div style="font-family:monospace; color:var(--text-dim);">Parents: ${commit.parents.join(', ')}</div>` : ''}
185
+ ${commit.parents.length > 0 ? `<div style="font-family:monospace; color:var(--text-dim); margin-top:2px;">Parents: ${commit.parents.join(', ')}</div>` : ''}
185
186
  </div>
186
187
  </div>
187
188
  `;
@@ -189,13 +190,32 @@ class GitGraphRenderer {
189
190
  // Add diff placeholder
190
191
  detailsHTML += `
191
192
  <div style="border-top:1px solid #333; padding-top:12px; margin-top:12px;">
192
- <button onclick="window.loadCommitDiff('${commit.hash}', this)" style="background:var(--primary); border:none; color:#fff; padding:6px 12px; border-radius:4px; font-size:12px; cursor:pointer;">Load Diff</button>
193
- <div class="diff-container" style="margin-top:12px; font-family:monospace; font-size:12px; white-space:pre-wrap; overflow-x:auto;"></div>
193
+ <button onclick="window.loadCommitDiff('${commit.hash}')" style="background:var(--primary); border:none; color:#fff; padding:6px 12px; border-radius:4px; font-size:12px; cursor:pointer;">View Full Diff</button>
194
194
  </div>
195
195
  `;
196
196
 
197
197
  detailsDiv.innerHTML = detailsHTML;
198
198
  rowDiv.parentNode.insertBefore(detailsDiv, rowDiv.nextSibling);
199
+
200
+ // Fetch branch info
201
+ if (window.activeSessionId) {
202
+ const branchContainer = document.getElementById(`branch-info-${commit.hash}`);
203
+ if (branchContainer) {
204
+ branchContainer.textContent = 'Loading branch...';
205
+ fetch(`/api/sessions/${window.activeSessionId}/git-branch/${commit.hash}`)
206
+ .then(res => res.json())
207
+ .then(data => {
208
+ if (data.success && data.stdout && data.stdout.trim()) {
209
+ branchContainer.textContent = `Branch: ${data.stdout.trim()}`;
210
+ } else {
211
+ branchContainer.textContent = '';
212
+ }
213
+ })
214
+ .catch(() => {
215
+ branchContainer.textContent = '';
216
+ });
217
+ }
218
+ }
199
219
  };
200
220
 
201
221
  rowDiv.appendChild(contentDiv);
@@ -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,11 +960,14 @@
882
960
  }
883
961
 
884
962
  function joinSession(id, sessionName, toolKey = null) {
963
+ stopTimedInputTimers();
885
964
  activeSessionId = id;
965
+ window.activeSessionId = id;
886
966
  activeToolKey = toolKey;
887
967
  clearTimeout(sessionPollTimer);
888
968
  sessionPollTimer = null;
889
969
  markCompletionRead(id);
970
+ loadTimedInputs();
890
971
  document.getElementById('session-title').innerText = sessionName;
891
972
  updateToolShortcuts(activeToolKey);
892
973
  document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
@@ -1151,8 +1232,191 @@
1151
1232
  setTimeout(() => { sendWS('\r'); }, 1000);
1152
1233
  }
1153
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
+ }
1154
1406
 
1155
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
+ });
1156
1420
  inputEl.addEventListener('keydown', (e) => {
1157
1421
  if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); performSend(); }
1158
1422
  else if (e.key === 'Enter' || e.key === 'Backspace' || e.key === 'Delete') {
@@ -1283,34 +1547,90 @@
1283
1547
  }
1284
1548
 
1285
1549
 
1286
- window.loadCommitDiff = async function(hash, btn) {
1550
+ let currentCommitDiffHTML = '';
1551
+ let currentCommitHash = '';
1552
+
1553
+ window.loadCommitDiff = async function(hash) {
1287
1554
  if (!activeSessionId) return;
1288
- const container = btn.nextElementSibling;
1289
- btn.style.display = 'none';
1290
- container.innerHTML = '<span style="color:var(--text-dim);">Loading diff...</span>';
1555
+ const content = document.getElementById('git-content');
1556
+ content.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--text-dim);">Loading diff...</div>';
1557
+ currentCommitHash = hash;
1558
+
1291
1559
  try {
1292
1560
  const res = await fetch(`/api/sessions/${activeSessionId}/git-show/${hash}`);
1293
1561
  const data = await res.json();
1294
1562
  if (data.success) {
1295
- let diffHTML = '';
1563
+ let fileBlocks = [];
1564
+ let currentBlock = { name: 'Commit Details', lines: [] };
1565
+ fileBlocks.push(currentBlock);
1566
+
1296
1567
  const lines = data.stdout.split('\n');
1297
1568
  for (const line of lines) {
1298
- let color = '#ccc';
1299
- if (line.startsWith('+') && !line.startsWith('+++')) color = '#34c759';
1300
- else if (line.startsWith('-') && !line.startsWith('---')) color = '#ff3b30';
1301
- else if (line.startsWith('@@')) color = '#5ac8fa';
1302
- else if (line.startsWith('diff') || line.startsWith('index') || line.startsWith('commit') || line.startsWith('Author') || line.startsWith('Date')) color = '#fff';
1303
- diffHTML += `<div style="color:${color}; padding:0 4px; border-radius:2px;">${line.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>`;
1569
+ const diffMatch = line.match(/^diff --git a\/(.+?) b\//);
1570
+ if (diffMatch) {
1571
+ currentBlock = { name: diffMatch[1], lines: [] };
1572
+ fileBlocks.push(currentBlock);
1573
+ }
1574
+ currentBlock.lines.push(line);
1575
+ }
1576
+
1577
+ let diffHTML = '';
1578
+ for (const block of fileBlocks) {
1579
+ if (block.lines.length === 0 || (block.lines.length === 1 && !block.lines[0])) continue;
1580
+
1581
+ let blockContent = '';
1582
+ let addCount = 0;
1583
+ let subCount = 0;
1584
+ for (const line of block.lines) {
1585
+ let color = '#ccc', bg = 'transparent', borderLeft = '2px solid transparent';
1586
+ if (line.startsWith('+') && !line.startsWith('+++')) { color = '#4ade80'; bg = 'rgba(74, 222, 128, 0.1)'; borderLeft = '2px solid #4ade80'; addCount++; }
1587
+ else if (line.startsWith('-') && !line.startsWith('---')) { color = '#f87171'; bg = 'rgba(248, 113, 113, 0.1)'; borderLeft = '2px solid #f87171'; subCount++; }
1588
+ else if (line.startsWith('@@')) { color = '#60a5fa'; bg = 'rgba(96, 165, 250, 0.1)'; }
1589
+ else if (line.startsWith('diff') || line.startsWith('index') || line.startsWith('commit') || line.startsWith('Author') || line.startsWith('Date')) color = '#fff';
1590
+
1591
+ blockContent += `<div style="color:${color}; background:${bg}; border-left:${borderLeft}; padding:2px 8px; white-space:pre-wrap; word-break:break-all;">${line.replace(/</g, '&lt;').replace(/>/g, '&gt;') || ' '}</div>`;
1592
+ }
1593
+
1594
+ const isOpen = block.name === 'Commit Details';
1595
+ const statHTML = block.name !== 'Commit Details' ? `<span style="margin-left: 12px; font-family: monospace; font-size: 12px;"><span style="color:#4ade80;">+${addCount}</span> <span style="color:#f87171; margin-left:6px;">-${subCount}</span></span>` : '';
1596
+ diffHTML += `
1597
+ <details ${isOpen ? 'open' : ''} style="margin-bottom: 8px; border: 1px solid #333; border-radius: 4px; overflow: hidden;">
1598
+ <summary style="background: #1e1e1e; padding: 6px 10px; cursor: pointer; color: #fff; font-weight: 500; font-size: 13px; outline: none; user-select: none;">
1599
+ ${block.name === 'Commit Details' ? '📝 ' : '📄 '}${block.name.replace(/</g, '&lt;').replace(/>/g, '&gt;')}${statHTML}
1600
+ </summary>
1601
+ <div style="background: #0d0d0d; overflow-x: auto; font-family: monospace; font-size: 12px; line-height: 1.5; padding: 4px 0;">
1602
+ ${blockContent}
1603
+ </div>
1604
+ </details>
1605
+ `;
1304
1606
  }
1305
- container.innerHTML = diffHTML;
1607
+ currentCommitDiffHTML = diffHTML;
1608
+ renderCommitDiffFullView();
1306
1609
  } else {
1307
- container.innerHTML = `<span style="color:#ff3b30;">Error loading diff</span>`;
1610
+ content.innerHTML = `<p style="color:#ff3b30; padding:10px;">Error loading diff</p>`;
1308
1611
  }
1309
1612
  } catch (e) {
1310
- container.innerHTML = `<span style="color:#ff3b30;">Network error</span>`;
1613
+ content.innerHTML = `<p style="color:#ff3b30; padding:10px;">Network error</p>`;
1311
1614
  }
1312
1615
  };
1313
1616
 
1617
+ function renderCommitDiffFullView() {
1618
+ const content = document.getElementById('git-content');
1619
+ let html = `
1620
+ <div style="display:flex; align-items:center; background: var(--card-bg); padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,0.05); position: sticky; top: 0; z-index: 10;">
1621
+ <button class="icon-btn" onclick="switchGitTab('graph')" style="color: var(--primary); margin-right: 12px; font-weight:600; font-size:14px; display:flex; align-items:center;">
1622
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg> Back
1623
+ </button>
1624
+ <div style="flex:1; min-width:0;">
1625
+ <div style="font-weight:600; font-size:15px; font-family: monospace;">Commit: ${currentCommitHash}</div>
1626
+ </div>
1627
+ </div>
1628
+ <div style="padding: 10px;">
1629
+ ${currentCommitDiffHTML}
1630
+ </div>`;
1631
+ content.innerHTML = html;
1632
+ }
1633
+
1314
1634
  async function loadGitGraph() {
1315
1635
 
1316
1636
  if (!activeSessionId) return;
@@ -1396,8 +1716,13 @@
1396
1716
  content.innerHTML = '<p style="text-align:center;color:#888;padding:20px;">Loading...</p>';
1397
1717
  if (!activeSessionId) return;
1398
1718
  try {
1399
- const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/git-status`);
1400
- const data = await res.json();
1719
+ const [resStatus, resUnstaged, resStaged] = await Promise.all([
1720
+ fetchWithTimeout(`/api/sessions/${activeSessionId}/git-status`),
1721
+ fetchWithTimeout(`/api/sessions/${activeSessionId}/git-diff-numstat?staged=false`).catch(() => ({ok:false})),
1722
+ fetchWithTimeout(`/api/sessions/${activeSessionId}/git-diff-numstat?staged=true`).catch(() => ({ok:false}))
1723
+ ]);
1724
+
1725
+ const data = await resStatus.json();
1401
1726
  if (!data.success) {
1402
1727
  content.innerHTML = `<p style="color:#ff3b30; padding:10px;">Git error: ${data.error}</p>`;
1403
1728
  return;
@@ -1409,92 +1734,327 @@
1409
1734
  return;
1410
1735
  }
1411
1736
 
1412
- let html = '<div style="padding:10px;"><div style="background:var(--card-bg); border-radius:12px; overflow:hidden;">';
1737
+ let statsMap = {};
1738
+ const parseNumstat = (output) => {
1739
+ if (!output) return;
1740
+ output.split('\n').forEach(line => {
1741
+ const parts = line.split('\t');
1742
+ if (parts.length >= 3) {
1743
+ const added = parseInt(parts[0]) || 0;
1744
+ const removed = parseInt(parts[1]) || 0;
1745
+ const file = parts.slice(2).join('\t');
1746
+ if (!statsMap[file]) statsMap[file] = { added: 0, removed: 0 };
1747
+ statsMap[file].added += added;
1748
+ statsMap[file].removed += removed;
1749
+ }
1750
+ });
1751
+ };
1752
+
1753
+ if (resUnstaged.ok) {
1754
+ const unstagedData = await resUnstaged.json();
1755
+ if (unstagedData.success) parseNumstat(unstagedData.stdout);
1756
+ }
1757
+ if (resStaged.ok) {
1758
+ const stagedData = await resStaged.json();
1759
+ if (stagedData.success) parseNumstat(stagedData.stdout);
1760
+ }
1761
+
1762
+ let html = '<div style="padding:10px;">';
1413
1763
  files.forEach((f, idx) => {
1414
1764
  const encodedPath = encodePathValue(f.path);
1415
1765
  const escapedPath = escapeHtml(f.path);
1416
- const escapedBaseName = escapeHtml(f.path.split('/').pop() || f.path);
1417
1766
  let color = '#fff';
1418
1767
  let label = f.status;
1419
- if (label.includes('M')) { color = '#f59e0b'; }
1768
+ if (label.includes('M')) { color = '#f59e0b'; }
1420
1769
  else if (label.includes('A') || label === '??') { color = '#4ade80'; if(label === '??') label = 'U'; }
1421
1770
  else if (label.includes('D')) { color = '#f87171'; }
1422
- const borderBottom = idx < files.length - 1 ? 'border-bottom: 1px solid rgba(255,255,255,0.05);' : '';
1423
- html += `<div style="padding:14px; ${borderBottom} display:flex; justify-content:space-between; align-items:center; cursor:pointer;" onclick="showFileDetails(decodePathValue('${encodedPath}'), true)">
1424
- <div style="flex:1; min-width:0; margin-right:10px;">
1425
- <div style="font-size:14px; font-weight:500;">${escapedBaseName}</div>
1426
- <div style="font-size:12px; color:var(--text-dim);">${escapedPath}</div>
1771
+ const isUntracked = f.status === '??';
1772
+ const hasStaged = !isUntracked && f.status[0] && f.status[0] !== ' ';
1773
+ const hasUnstaged = !isUntracked && f.status[1] && f.status[1] !== ' ';
1774
+
1775
+ let statHTML = '';
1776
+ if (statsMap[f.path]) {
1777
+ const { added, removed } = statsMap[f.path];
1778
+ if (added > 0 || removed > 0) {
1779
+ statHTML = `<span style="margin-left: 12px; font-family: monospace; font-size: 12px; white-space: nowrap;"><span style="color:#4ade80;">+${added}</span> <span style="color:#f87171; margin-left:6px;">-${removed}</span></span>`;
1780
+ }
1781
+ }
1782
+
1783
+ html += `<div style="margin-bottom: 8px; border: 1px solid #333; border-radius: 4px; background: #1e1e1e; overflow: hidden;">
1784
+ <div onclick="toggleInlineDiff('${encodedPath}', 'inline-diff-${idx}', ${!!hasStaged}, ${!!hasUnstaged}, ${isUntracked})" style="padding: 6px 10px; cursor: pointer; color: #fff; font-weight: 500; font-size: 13px; outline: none; user-select: none; display: flex; align-items: center;">
1785
+ <div style="flex:1; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">📄 ${escapedPath}${statHTML}</div>
1786
+ <span style="color:${color}; font-weight:700; font-size:10px; border:1px solid ${color}; padding:1px 4px; border-radius:3px; opacity:0.8; flex-shrink: 0; margin-left: 8px;">${label}</span>
1427
1787
  </div>
1428
- <span style="color:${color}; font-weight:700; font-size:12px; border:1px solid ${color}; padding:2px 6px; border-radius:4px; opacity:0.8;">${label}</span>
1788
+ <div id="inline-diff-${idx}" style="display: none;" data-loaded="false"></div>
1429
1789
  </div>`;
1430
1790
  });
1431
- html += '</div></div>';
1791
+ html += '</div>';
1432
1792
  content.innerHTML = html;
1433
1793
  } catch (e) {
1434
1794
  content.innerHTML = `<p style="color:#ff3b30; padding:10px;">${e.message}</p>`;
1435
1795
  }
1436
1796
  }
1437
1797
 
1438
- let currentFilePath = '', currentFileDiff = '', currentFileContent = '', currentFileMode = 'diff';
1798
+ function buildFileDiffUrl(path, staged) {
1799
+ return `/api/sessions/${activeSessionId}/git-diff-file?path=${encodeURIComponent(path)}&staged=${staged ? 'true' : 'false'}`;
1800
+ }
1801
+
1802
+ async function loadFileChangeData(path, options = {}) {
1803
+ const hasStaged = !!options.hasStaged;
1804
+ const hasUnstaged = !!options.hasUnstaged;
1805
+ const isUntracked = !!options.isUntracked;
1806
+ const diffRequests = [];
1807
+
1808
+ if (hasStaged) {
1809
+ diffRequests.push({
1810
+ label: 'Staged changes',
1811
+ promise: fetchWithTimeout(buildFileDiffUrl(path, true)).catch(() => ({ok:false}))
1812
+ });
1813
+ }
1814
+ if (hasUnstaged || (!hasStaged && !isUntracked)) {
1815
+ diffRequests.push({
1816
+ label: 'Unstaged changes',
1817
+ promise: fetchWithTimeout(buildFileDiffUrl(path, false)).catch(() => ({ok:false}))
1818
+ });
1819
+ }
1820
+
1821
+ const [diffResponses, fileRes] = await Promise.all([
1822
+ Promise.all(diffRequests.map(item => item.promise)),
1823
+ fetchWithTimeout(`/api/sessions/${activeSessionId}/file?path=${encodeURIComponent(path)}`).catch(() => ({ok:false}))
1824
+ ]);
1825
+
1826
+ const diffParts = [];
1827
+ for (let i = 0; i < diffResponses.length; i++) {
1828
+ const res = diffResponses[i];
1829
+ const data = res.ok ? await res.json() : { success: false };
1830
+ if (data.success && data.stdout) {
1831
+ diffParts.push({ label: diffRequests[i].label, stdout: data.stdout });
1832
+ }
1833
+ }
1834
+
1835
+ const fileData = fileRes.ok ? await fileRes.json() : { success: false };
1836
+ const content = fileData.success ? fileData.content : '';
1837
+ let diff = diffParts.map(part => (
1838
+ diffParts.length > 1 ? `# ${part.label}\n${part.stdout}` : part.stdout
1839
+ )).join('\n');
1840
+ if (!diff && isUntracked && content) diff = 'Untracked file:\n\n' + content;
1841
+
1842
+ return {
1843
+ diff,
1844
+ content,
1845
+ renderRawDiff: diffParts.length > 1
1846
+ };
1847
+ }
1848
+
1849
+ window.toggleInlineDiff = async function(encodedPath, containerId, hasStaged = false, hasUnstaged = true, isUntracked = false) {
1850
+ const container = document.getElementById(containerId);
1851
+ if (container.style.display === 'block') {
1852
+ container.style.display = 'none';
1853
+ return;
1854
+ }
1855
+ container.style.display = 'block';
1856
+ if (container.dataset.loaded === 'true') return;
1857
+
1858
+ const path = decodePathValue(encodedPath);
1859
+ container.innerHTML = '<div style="padding: 10px; color: var(--text-dim); text-align: center; font-size: 12px;">Loading...</div>';
1860
+
1861
+ try {
1862
+ const { diff: currentFileDiff, content: currentFileContent } = await loadFileChangeData(path, {
1863
+ hasStaged,
1864
+ hasUnstaged,
1865
+ isUntracked
1866
+ });
1867
+
1868
+ if (!currentFileDiff && !currentFileContent) {
1869
+ container.innerHTML = `<div style="padding: 10px; color: #f87171; font-size: 12px; text-align:center;">No diff available</div>`;
1870
+ return;
1871
+ }
1872
+
1873
+ let blockContent = '';
1874
+ currentFileDiff.split('\n').forEach(line => {
1875
+ let color = '#ccc', bg = 'transparent', borderLeft = '2px solid transparent';
1876
+ if (line.startsWith('+') && !line.startsWith('+++')) { color = '#4ade80'; bg = 'rgba(74, 222, 128, 0.1)'; borderLeft = '2px solid #4ade80'; }
1877
+ else if (line.startsWith('-') && !line.startsWith('---')) { color = '#f87171'; bg = 'rgba(248, 113, 113, 0.1)'; borderLeft = '2px solid #f87171'; }
1878
+ else if (line.startsWith('@@')) { color = '#60a5fa'; bg = 'rgba(96, 165, 250, 0.1)'; }
1879
+
1880
+ blockContent += `<div style="color:${color}; background:${bg}; border-left:${borderLeft}; padding:2px 8px; white-space:pre-wrap; word-break:break-all;">${escapeHtml(line) || ' '}</div>`;
1881
+ });
1882
+
1883
+ container.dataset.loaded = 'true';
1884
+ container.innerHTML = `
1885
+ <div style="background: #0d0d0d; overflow-x: auto; font-family: monospace; font-size: 12px; line-height: 1.5; padding: 4px 0; border-top: 1px solid #333; max-height: 400px;">
1886
+ ${blockContent}
1887
+ </div>
1888
+ <div style="padding: 8px; background: #1a1a1a; border-top: 1px solid #333; text-align: center;">
1889
+ <button onclick="showFileDetails(decodePathValue('${encodedPath}'), true, ${!!hasStaged}, ${!!hasUnstaged}, ${!!isUntracked})" style="background: var(--primary); border: none; color: #fff; padding: 6px 12px; border-radius: 4px; font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px;">
1890
+ <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="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>
1891
+ View Full File
1892
+ </button>
1893
+ </div>
1894
+ `;
1895
+ } catch (e) {
1896
+ container.innerHTML = `<div style="padding: 10px; color: #f87171; font-size: 12px; text-align:center;">Error: ${e.message}</div>`;
1897
+ }
1898
+ };
1899
+
1900
+ let currentFilePath = '', currentFileDiff = '', currentFileContent = '', currentFileMode = 'diff', currentFileWrap = false, currentFontSize = 12, currentFileRenderRawDiff = false;
1439
1901
 
1440
- async function showFileDetails(path, isFromChanges = true) {
1902
+ function changeFontSize(delta) {
1903
+ if (delta === 0) currentFontSize = 12;
1904
+ else currentFontSize = Math.max(8, Math.min(32, currentFontSize + delta));
1905
+ renderFileDetails();
1906
+ }
1907
+
1908
+ async function showFileDetails(path, isFromChanges = true, hasStaged = false, hasUnstaged = true, isUntracked = false) {
1441
1909
  currentFilePath = path;
1442
1910
  currentFileMode = isFromChanges ? 'diff' : 'file';
1911
+ currentFileRenderRawDiff = false;
1443
1912
  const content = document.getElementById('git-content');
1444
1913
  content.innerHTML = '<p style="text-align:center;color:#888;padding:20px;">Loading details...</p>';
1445
1914
  try {
1446
- const [diffRes, fileRes] = await Promise.all([
1447
- fetchWithTimeout(`/api/sessions/${activeSessionId}/git-diff-file?path=${encodeURIComponent(path)}`).catch(()=>({ok:false})),
1448
- fetchWithTimeout(`/api/sessions/${activeSessionId}/file?path=${encodeURIComponent(path)}`).catch(()=>({ok:false}))
1449
- ]);
1450
- let diffData = diffRes.ok ? await diffRes.json() : { success: false };
1451
- let fileData = fileRes.ok ? await fileRes.json() : { success: false };
1452
- currentFileDiff = (diffData.success && diffData.stdout) ? diffData.stdout : '';
1453
- currentFileContent = fileData.success ? fileData.content : '';
1454
- if (!currentFileDiff && isFromChanges && currentFileContent) currentFileDiff = 'Untracked file:\n\n' + currentFileContent;
1915
+ const detailData = await loadFileChangeData(path, {
1916
+ hasStaged: isFromChanges ? hasStaged : false,
1917
+ hasUnstaged: isFromChanges ? hasUnstaged : true,
1918
+ isUntracked: isFromChanges ? isUntracked : false
1919
+ });
1920
+ currentFileDiff = detailData.diff;
1921
+ currentFileContent = detailData.content;
1922
+ currentFileRenderRawDiff = detailData.renderRawDiff;
1455
1923
  if (!currentFileDiff && !currentFileContent) {
1456
1924
  content.innerHTML = `<p style="color:#ff3b30; padding:10px;">Failed to load details.</p>`;
1457
1925
  return;
1458
1926
  }
1459
- if (!diffData.stdout && !isFromChanges) currentFileMode = 'file';
1927
+ if (!currentFileDiff && !isFromChanges) currentFileMode = 'file';
1460
1928
  renderFileDetails();
1461
1929
  } catch (e) { content.innerHTML = `<p style="color:#ff3b30; padding:10px;">${e.message}</p>`; }
1462
1930
  }
1463
1931
 
1464
- function setFileMode(mode) { currentFileMode = mode; renderFileDetails(); }
1932
+ function toggleFileMode() { currentFileMode = currentFileMode === 'diff' ? 'file' : 'diff'; renderFileDetails(); }
1933
+ function toggleFileWrap() { currentFileWrap = !currentFileWrap; renderFileDetails(); }
1465
1934
 
1466
1935
  function renderFileDetails() {
1467
1936
  const content = document.getElementById('git-content');
1468
1937
  let html = `
1469
- <div style="display:flex; align-items:center; background: var(--card-bg); padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,0.05);">
1470
- <button class="icon-btn" onclick="switchGitTab(currentGitTab)" style="color: var(--primary); margin-right: 12px; font-weight:600; font-size:14px; display:flex; align-items:center;">
1938
+ <div style="display:flex; align-items:center; background: var(--card-bg); padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,0.05); position: sticky; top: 0; z-index: 10;">
1939
+ <button class="icon-btn" onclick="switchGitTab(currentGitTab)" style="color: var(--primary); margin-right: 12px; font-weight:600; font-size:14px; display:flex; align-items:center; flex-shrink: 0;">
1471
1940
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg> Back
1472
1941
  </button>
1473
- <div style="flex:1; min-width:0;">
1474
- <div style="font-weight:600; font-size:15px;">${escapeHtml(currentFilePath.split('/').pop() || currentFilePath)}</div>
1475
- </div>
1476
- </div>`;
1942
+ <div style="flex:1; min-width:0; margin-right: 12px;">
1943
+ <div style="font-weight:600; font-size:15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(currentFilePath)}">${escapeHtml(currentFilePath.split('/').pop() || currentFilePath)}</div>
1944
+ </div>`;
1945
+
1477
1946
  if (currentFileDiff && currentFileContent) {
1478
- html += `<div class="btn-toggle">
1479
- <button class="${currentFileMode === 'diff' ? 'active' : ''}" onclick="setFileMode('diff')">Diff</button>
1480
- <button class="${currentFileMode === 'file' ? 'active' : ''}" onclick="setFileMode('file')">File</button>
1947
+ html += `
1948
+ <div style="display: flex; gap: 6px; flex-shrink: 0; align-items: center;">
1949
+ <div style="display: flex; gap: 2px; margin-right: 8px; background: rgba(0,0,0,0.3); padding: 2px; border-radius: 4px;">
1950
+ <button onclick="changeFontSize(-1)" style="background: transparent; border: none; color: #aaa; padding: 4px 8px; font-size: 12px; cursor: pointer; outline: none; border-radius: 3px;" title="Zoom Out" onmouseover="this.style.background='rgba(255,255,255,0.1)';this.style.color='#fff'" onmouseout="this.style.background='transparent';this.style.color='#aaa'">A-</button>
1951
+ <button onclick="changeFontSize(0)" style="background: transparent; border: none; color: #aaa; padding: 4px 8px; font-size: 12px; cursor: pointer; outline: none; border-radius: 3px;" title="Reset Size" onmouseover="this.style.background='rgba(255,255,255,0.1)';this.style.color='#fff'" onmouseout="this.style.background='transparent';this.style.color='#aaa'">${currentFontSize}</button>
1952
+ <button onclick="changeFontSize(1)" style="background: transparent; border: none; color: #aaa; padding: 4px 8px; font-size: 12px; cursor: pointer; outline: none; border-radius: 3px;" title="Zoom In" onmouseover="this.style.background='rgba(255,255,255,0.1)';this.style.color='#fff'" onmouseout="this.style.background='transparent';this.style.color='#aaa'">A+</button>
1953
+ </div>
1954
+ <button onclick="toggleFileMode()" style="background: ${currentFileMode === 'diff' ? 'var(--primary)' : 'rgba(255,255,255,0.1)'}; border: 1px solid rgba(255,255,255,0.2); color: #fff; padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer; outline: none;">
1955
+ Diff
1956
+ </button>
1957
+ <button onclick="toggleFileWrap()" style="background: ${currentFileWrap ? 'var(--primary)' : 'rgba(255,255,255,0.1)'}; border: 1px solid rgba(255,255,255,0.2); color: #fff; padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer; outline: none;">
1958
+ Wrap
1959
+ </button>
1960
+ </div>`;
1961
+ } else {
1962
+ html += `
1963
+ <div style="display: flex; gap: 6px; flex-shrink: 0; align-items: center;">
1964
+ <div style="display: flex; gap: 2px; margin-right: 8px; background: rgba(0,0,0,0.3); padding: 2px; border-radius: 4px;">
1965
+ <button onclick="changeFontSize(-1)" style="background: transparent; border: none; color: #aaa; padding: 4px 8px; font-size: 12px; cursor: pointer; outline: none; border-radius: 3px;" title="Zoom Out" onmouseover="this.style.background='rgba(255,255,255,0.1)';this.style.color='#fff'" onmouseout="this.style.background='transparent';this.style.color='#aaa'">A-</button>
1966
+ <button onclick="changeFontSize(0)" style="background: transparent; border: none; color: #aaa; padding: 4px 8px; font-size: 12px; cursor: pointer; outline: none; border-radius: 3px;" title="Reset Size" onmouseover="this.style.background='rgba(255,255,255,0.1)';this.style.color='#fff'" onmouseout="this.style.background='transparent';this.style.color='#aaa'">${currentFontSize}</button>
1967
+ <button onclick="changeFontSize(1)" style="background: transparent; border: none; color: #aaa; padding: 4px 8px; font-size: 12px; cursor: pointer; outline: none; border-radius: 3px;" title="Zoom In" onmouseover="this.style.background='rgba(255,255,255,0.1)';this.style.color='#fff'" onmouseout="this.style.background='transparent';this.style.color='#aaa'">A+</button>
1968
+ </div>
1969
+ <button onclick="toggleFileWrap()" style="background: ${currentFileWrap ? 'var(--primary)' : 'rgba(255,255,255,0.1)'}; border: 1px solid rgba(255,255,255,0.2); color: #fff; padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer; outline: none;">
1970
+ Wrap
1971
+ </button>
1481
1972
  </div>`;
1482
1973
  }
1974
+
1975
+ html += `</div>`;
1976
+
1977
+ const wrapStyle = currentFileWrap ? 'white-space: pre-wrap; word-break: break-all;' : 'white-space: pre;';
1978
+ const innerWrapStyle = currentFileWrap ? '' : 'min-width: max-content;';
1483
1979
  html += `<div style="padding: 10px;">`;
1484
- if (currentFileMode === 'diff' && currentFileDiff) {
1485
- html += `<div style="background:#0d0d0d; border-radius:8px; overflow-x:auto; font-family:monospace; font-size:12px; line-height:1.5;">`;
1486
- currentFileDiff.split('\n').forEach(line => {
1487
- let color = '#ccc', bg = 'transparent', borderLeft = '2px solid transparent';
1488
- if (line.startsWith('+') && !line.startsWith('+++')) { color = '#4ade80'; bg = 'rgba(74, 222, 128, 0.1)'; borderLeft = '2px solid #4ade80'; }
1489
- else if (line.startsWith('-') && !line.startsWith('---')) { color = '#f87171'; bg = 'rgba(248, 113, 113, 0.1)'; borderLeft = '2px solid #f87171'; }
1490
- else if (line.startsWith('@@')) { color = '#60a5fa'; bg = 'rgba(96, 165, 250, 0.1)'; }
1491
- html += `<div style="color:${color}; background:${bg}; border-left:${borderLeft}; white-space:pre-wrap; padding: 2px 8px;">${escapeHtml(line) || ' '}</div>`;
1492
- });
1493
- html += `</div>`;
1980
+ html += `<div style="background:#0d0d0d; border-radius:8px; overflow-x:auto; font-family:monospace; font-size:${currentFontSize}px; line-height:1.5; padding: 12px 0; margin: 0; -webkit-text-size-adjust: 100%; text-size-adjust: 100%;">`;
1981
+ html += `<div style="${innerWrapStyle}">`;
1982
+
1983
+ let mergedLines = [];
1984
+ const lines = currentFileContent ? currentFileContent.replace(/\r\n/g, '\n').split('\n') : [];
1985
+
1986
+ if (currentFileMode === 'diff' && currentFileDiff && !currentFileDiff.startsWith('Untracked file:')) {
1987
+ let diffHunks = [];
1988
+ let currentHunk = null;
1989
+ const diffLines = currentFileDiff.replace(/\r\n/g, '\n').split('\n');
1990
+ for (const dl of diffLines) {
1991
+ if (dl.startsWith('---') || dl.startsWith('+++')) continue;
1992
+ let match = dl.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
1993
+ if (match) {
1994
+ currentHunk = {
1995
+ newStart: parseInt(match[2], 10),
1996
+ lines: []
1997
+ };
1998
+ diffHunks.push(currentHunk);
1999
+ } else if (currentHunk) {
2000
+ currentHunk.lines.push(dl);
2001
+ }
2002
+ }
2003
+
2004
+ if (currentFileRenderRawDiff || diffHunks.length === 0) {
2005
+ currentFileDiff.replace(/\r\n/g, '\n').split('\n').forEach(line => {
2006
+ mergedLines.push({ type: 'raw', text: line });
2007
+ });
2008
+ } else {
2009
+ let contentLineIdx = 1;
2010
+ for (let hunk of diffHunks) {
2011
+ while (contentLineIdx < hunk.newStart && contentLineIdx <= lines.length) {
2012
+ mergedLines.push({ type: 'normal', text: lines[contentLineIdx - 1], lineNum: contentLineIdx });
2013
+ contentLineIdx++;
2014
+ }
2015
+ for (let dl of hunk.lines) {
2016
+ if (dl.startsWith('-')) {
2017
+ mergedLines.push({ type: 'deleted', text: dl.substring(1) });
2018
+ } else if (dl.startsWith('+')) {
2019
+ mergedLines.push({ type: 'added', text: dl.substring(1), lineNum: contentLineIdx });
2020
+ contentLineIdx++;
2021
+ } else if (dl.startsWith(' ')) {
2022
+ mergedLines.push({ type: 'normal', text: dl.substring(1), lineNum: contentLineIdx });
2023
+ contentLineIdx++;
2024
+ }
2025
+ }
2026
+ }
2027
+ while (contentLineIdx <= lines.length) {
2028
+ mergedLines.push({ type: 'normal', text: lines[contentLineIdx - 1], lineNum: contentLineIdx });
2029
+ contentLineIdx++;
2030
+ }
2031
+ }
1494
2032
  } else {
1495
- html += `<pre style="background:#0d0d0d; border-radius:8px; overflow-x:auto; font-family:monospace; font-size:12px; line-height:1.5; padding: 12px; color: #ccc; margin: 0; white-space: pre-wrap; word-break: break-all;"><code>${escapeHtml(currentFileContent)}</code></pre>`;
2033
+ lines.forEach((line, idx) => {
2034
+ mergedLines.push({ type: 'normal', text: line, lineNum: idx + 1 });
2035
+ });
1496
2036
  }
1497
- html += `</div>`;
2037
+
2038
+ mergedLines.forEach(item => {
2039
+ let color = '#ccc', bg = 'transparent', borderLeft = '2px solid transparent';
2040
+ let prefix = ' ';
2041
+ if (item.type === 'added') { color = '#4ade80'; bg = 'rgba(74, 222, 128, 0.1)'; borderLeft = '2px solid #4ade80'; prefix = '+'; }
2042
+ else if (item.type === 'deleted') { color = '#f87171'; bg = 'rgba(248, 113, 113, 0.1)'; borderLeft = '2px solid #f87171'; prefix = '-'; }
2043
+ else if (item.type === 'raw') {
2044
+ if (item.text.startsWith('+') && !item.text.startsWith('+++')) { color = '#4ade80'; bg = 'rgba(74, 222, 128, 0.1)'; borderLeft = '2px solid #4ade80'; }
2045
+ else if (item.text.startsWith('-') && !item.text.startsWith('---')) { color = '#f87171'; bg = 'rgba(248, 113, 113, 0.1)'; borderLeft = '2px solid #f87171'; }
2046
+ else if (item.text.startsWith('@@')) { color = '#60a5fa'; bg = 'rgba(96, 165, 250, 0.1)'; }
2047
+ prefix = '';
2048
+ }
2049
+
2050
+ const renderedText = item.type === 'raw' ? item.text : `${prefix} ${item.text}`;
2051
+ html += `<div style="color:${color}; background:${bg}; border-left:${borderLeft}; padding: 0 12px 0 8px; display:flex;">`;
2052
+ html += `<div style="color:#555; margin-right:16px; user-select:none; flex-shrink:0; width: 36px; text-align:right;">${item.lineNum || ''}</div>`;
2053
+ html += `<div style="flex:1; min-width:0; ${wrapStyle}">${escapeHtml(renderedText) || ' '}</div>`;
2054
+ html += `</div>`;
2055
+ });
2056
+
2057
+ html += `</div></div></div>`;
1498
2058
  content.innerHTML = html;
1499
2059
  }
1500
2060
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glad-web",
3
- "version": "1.0.17",
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": {