clay-server 3.7.0-beta.1 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -254,6 +254,57 @@ function attachHTTP(ctx) {
254
254
  return true;
255
255
  }
256
256
 
257
+ // File browser: download project files
258
+ if (req.method === "GET" && urlPath.startsWith("/api/file/download?")) {
259
+ if (usersModule.isMultiUser()) {
260
+ var downloadUser = req._clayUser;
261
+ var downloadPermissions = downloadUser ? usersModule.getEffectivePermissions(downloadUser, osUsers) : null;
262
+ if (!downloadPermissions || !downloadPermissions.fileBrowser) {
263
+ res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
264
+ res.end("File browser access is not permitted");
265
+ return true;
266
+ }
267
+ }
268
+
269
+ var downloadQueryIndex = urlPath.indexOf("?");
270
+ var downloadParams = new URLSearchParams(urlPath.substring(downloadQueryIndex));
271
+ var downloadPath = downloadParams.get("path");
272
+ if (!downloadPath) { res.writeHead(400); res.end("Missing path"); return true; }
273
+ var downloadFile = safePath(cwd, downloadPath);
274
+ if (!downloadFile && getOsUserInfoForReq(req)) {
275
+ downloadFile = safeAbsPath(downloadPath);
276
+ }
277
+ if (!downloadFile) { res.writeHead(403); res.end("Access denied"); return true; }
278
+
279
+ try {
280
+ var downloadUserInfo = getOsUserInfoForReq(req);
281
+ var downloadContent;
282
+ if (downloadUserInfo) {
283
+ downloadContent = fsAsUser("read_binary", { file: downloadFile }, downloadUserInfo).buffer;
284
+ } else {
285
+ downloadContent = fs.readFileSync(downloadFile);
286
+ }
287
+ var downloadName = path.basename(downloadPath).replace(/[\x00-\x1f\x7f"\\]/g, "_") || "download";
288
+ var asciiDownloadName = downloadName.replace(/[^\x20-\x7e]/g, "_");
289
+ var encodedDownloadName = encodeURIComponent(downloadName).replace(/[!'()*]/g, function (character) {
290
+ return "%" + character.charCodeAt(0).toString(16).toUpperCase();
291
+ });
292
+ var contentDisposition = "attachment; filename=\"" + asciiDownloadName + "\"; filename*=UTF-8''" + encodedDownloadName;
293
+ res.writeHead(200, {
294
+ "Content-Type": "application/octet-stream",
295
+ "Content-Length": downloadContent.length,
296
+ "Content-Disposition": contentDisposition,
297
+ "Cache-Control": "no-store",
298
+ "X-Content-Type-Options": "nosniff",
299
+ });
300
+ res.end(downloadContent);
301
+ } catch (e) {
302
+ res.writeHead(404);
303
+ res.end("Not found");
304
+ }
305
+ return true;
306
+ }
307
+
257
308
  // File browser: serve project images
258
309
  if (req.method === "GET" && urlPath.startsWith("/api/file?")) {
259
310
  var qIdx = urlPath.indexOf("?");
package/lib/project.js CHANGED
@@ -111,6 +111,7 @@ var BINARY_EXTS = new Set([
111
111
  ]);
112
112
  var IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico"]);
113
113
  var FS_MAX_SIZE = 512 * 1024;
114
+ var FS_VIEWER_MAX_SIZE = 5 * 1024 * 1024;
114
115
  function safePath(base, requested) {
115
116
  var resolved = path.resolve(base, requested);
116
117
  if (resolved !== base && !resolved.startsWith(base + path.sep)) return null;
@@ -1438,7 +1439,7 @@ function createProjectContext(opts) {
1438
1439
  IGNORED_DIRS: IGNORED_DIRS,
1439
1440
  BINARY_EXTS: BINARY_EXTS,
1440
1441
  IMAGE_EXTS: IMAGE_EXTS,
1441
- FS_MAX_SIZE: FS_MAX_SIZE,
1442
+ FS_MAX_SIZE: FS_VIEWER_MAX_SIZE,
1442
1443
  });
1443
1444
 
1444
1445
  // --- MCP bridge handler for Codex and session-bound Kiro tools ---
@@ -259,6 +259,11 @@
259
259
 
260
260
  .file-tree-item:hover { background: var(--sidebar-hover); color: var(--text); }
261
261
  .file-tree-item.active { background: var(--sidebar-active); color: var(--text); }
262
+ .file-tree-item.context-open {
263
+ background: var(--sidebar-active);
264
+ color: var(--text);
265
+ box-shadow: inset 0 0 0 1px var(--border);
266
+ }
262
267
  /* Keyboard focus ring (arrow-key navigation) — separate from .active
263
268
  so the currently-opened file stays visually marked even while the
264
269
  user is exploring elsewhere with the arrows. */
@@ -324,6 +329,65 @@
324
329
  color: var(--error);
325
330
  }
326
331
 
332
+ /* --- File tree context menu --- */
333
+ .file-tree-context-menu {
334
+ position: fixed;
335
+ z-index: 10040;
336
+ min-width: 164px;
337
+ padding: 5px;
338
+ border: 1px solid var(--border);
339
+ border-radius: 10px;
340
+ background: var(--sidebar-bg);
341
+ box-shadow: 0 12px 34px rgba(var(--shadow-rgb), 0.34);
342
+ animation: file-tree-context-in 0.12s ease-out both;
343
+ }
344
+
345
+ .file-tree-context-item {
346
+ display: flex;
347
+ align-items: center;
348
+ gap: 9px;
349
+ width: 100%;
350
+ min-height: 34px;
351
+ padding: 7px 10px;
352
+ border: 0;
353
+ border-radius: 7px;
354
+ background: transparent;
355
+ color: var(--text-secondary);
356
+ font: inherit;
357
+ font-size: 13px;
358
+ text-align: left;
359
+ cursor: pointer;
360
+ }
361
+
362
+ .file-tree-context-item:hover,
363
+ .file-tree-context-item:focus-visible {
364
+ outline: none;
365
+ background: rgba(var(--overlay-rgb), 0.06);
366
+ color: var(--text);
367
+ }
368
+
369
+ .file-tree-context-item .lucide {
370
+ width: 15px;
371
+ height: 15px;
372
+ flex-shrink: 0;
373
+ color: var(--text-muted);
374
+ }
375
+
376
+ .file-tree-context-separator {
377
+ height: 1px;
378
+ margin: 4px 5px;
379
+ background: var(--border-subtle);
380
+ }
381
+
382
+ @keyframes file-tree-context-in {
383
+ from { opacity: 0; transform: translateY(-3px) scale(0.98); }
384
+ to { opacity: 1; transform: translateY(0) scale(1); }
385
+ }
386
+
387
+ @media (prefers-reduced-motion: reduce) {
388
+ .file-tree-context-menu { animation: none; }
389
+ }
390
+
327
391
  /* --- Panel fullscreen --- */
328
392
  @media (min-width: 1024px) {
329
393
  #main-column:has(.panel-fullscreen:not(.hidden)) > .title-bar-content { display: none; }
@@ -1695,6 +1759,51 @@
1695
1759
  padding: 0;
1696
1760
  }
1697
1761
 
1762
+ .file-viewer-large-text {
1763
+ min-height: 100%;
1764
+ }
1765
+
1766
+ .file-viewer-large-notice {
1767
+ position: sticky;
1768
+ top: 0;
1769
+ left: 0;
1770
+ z-index: 2;
1771
+ display: flex;
1772
+ align-items: center;
1773
+ gap: 7px;
1774
+ width: fit-content;
1775
+ margin: 10px 12px 0;
1776
+ padding: 6px 9px;
1777
+ color: var(--text-muted);
1778
+ background: color-mix(in srgb, var(--bg) 92%, var(--accent));
1779
+ border: 1px solid var(--border-subtle);
1780
+ border-radius: 7px;
1781
+ font-size: 12px;
1782
+ line-height: 1.35;
1783
+ }
1784
+
1785
+ .file-viewer-large-notice svg {
1786
+ width: 14px;
1787
+ height: 14px;
1788
+ flex-shrink: 0;
1789
+ }
1790
+
1791
+ .file-viewer-large-text pre {
1792
+ width: max-content;
1793
+ min-width: 100%;
1794
+ margin: 0;
1795
+ padding: 12px 14px;
1796
+ box-sizing: border-box;
1797
+ white-space: pre;
1798
+ tab-size: 2;
1799
+ }
1800
+
1801
+ .file-viewer-large-text code {
1802
+ font-family: var(--font-mono);
1803
+ font-size: 12px;
1804
+ line-height: 1.55;
1805
+ }
1806
+
1698
1807
  .file-viewer-binary {
1699
1808
  display: flex;
1700
1809
  align-items: center;
@@ -1948,6 +2057,7 @@
1948
2057
  min-height: 0;
1949
2058
  overflow: hidden;
1950
2059
  position: relative;
2060
+ background: #141412;
1951
2061
  }
1952
2062
 
1953
2063
  .terminal-tab-body {
@@ -1967,8 +2077,8 @@
1967
2077
  align-items: center;
1968
2078
  gap: 6px;
1969
2079
  padding: 6px 8px;
1970
- background: #252525;
1971
- border-bottom: 1px solid #333;
2080
+ background: var(--bg-alt);
2081
+ border-bottom: 1px solid var(--border);
1972
2082
  flex-shrink: 0;
1973
2083
  overflow-x: auto;
1974
2084
  -webkit-overflow-scrolling: touch;
@@ -1984,9 +2094,9 @@
1984
2094
  min-width: 40px;
1985
2095
  padding: 0 10px;
1986
2096
  border-radius: 6px;
1987
- border: 1px solid #444;
1988
- background: #333;
1989
- color: #d4d4d4;
2097
+ border: 1px solid var(--border);
2098
+ background: var(--sidebar-active);
2099
+ color: var(--text-secondary);
1990
2100
  font-family: "Roboto Mono", monospace;
1991
2101
  font-size: 12px;
1992
2102
  font-weight: 500;
@@ -1998,8 +2108,8 @@
1998
2108
  }
1999
2109
 
2000
2110
  .term-key:active {
2001
- background: #555;
2002
- border-color: #666;
2111
+ background: var(--sidebar-hover);
2112
+ border-color: var(--text-dimmer);
2003
2113
  }
2004
2114
 
2005
2115
  .term-key-toggle.active {
@@ -282,7 +282,7 @@ body.tui-suspended #tui-resume-bar {
282
282
  .tui-modal {
283
283
  width: min(960px, 100%);
284
284
  height: min(640px, 100%);
285
- background: #000;
285
+ background: var(--bg);
286
286
  border-radius: 12px;
287
287
  overflow: hidden;
288
288
  display: flex;
@@ -683,4 +683,3 @@ body.tui-suspended #tui-resume-bar {
683
683
  .wna-content { padding: 32px 20px 80px; }
684
684
  .wna-title { font-size: 26px; }
685
685
  }
686
-
@@ -30,7 +30,7 @@
30
30
  (function(){try{var k="clay-theme-vars",v=localStorage.getItem(k),r=document.documentElement;if(v){var o=JSON.parse(v),p;for(p in o)r.style.setProperty(p,o[p]);var vt=localStorage.getItem(k.replace("-vars","-variant"));if(vt==="light"){r.classList.add("light-theme");r.classList.remove("dark-theme")}else{r.classList.add("dark-theme");r.classList.remove("light-theme")}var m=document.querySelector('meta[name="theme-color"]');if(m&&o["--bg"])m.setAttribute("content",o["--bg"])}else{var sl=window.matchMedia&&window.matchMedia("(prefers-color-scheme: light)").matches;if(sl){r.classList.add("light-theme");r.classList.remove("dark-theme")}}}catch(e){}})();
31
31
  </script>
32
32
  <script>if(window.navigator.standalone||window.matchMedia("(display-mode:standalone)").matches){document.documentElement.classList.add("pwa-standalone")}</script>
33
- <link rel="stylesheet" href="style.css?v=20260829-imagegen3">
33
+ <link rel="stylesheet" href="style.css?v=20260829-terminal-canvas1">
34
34
  <style>
35
35
  @media(max-width:768px){
36
36
  /* User messages: vertical stack, avatar on top, right-aligned */
@@ -661,6 +661,7 @@
661
661
  <button class="file-viewer-btn file-viewer-slide-level hidden" id="file-viewer-slide-level" title="Split slides by heading level" aria-haspopup="menu" aria-expanded="false"><span>H1</span><i data-lucide="chevron-down"></i></button>
662
662
  <button class="file-viewer-btn hidden" id="file-viewer-history" title="Edit history"><i data-lucide="clock"></i></button>
663
663
  <button class="file-viewer-btn" id="file-viewer-refresh" title="Refresh"><i data-lucide="refresh-cw"></i></button>
664
+ <button class="file-viewer-btn" id="file-viewer-download" title="Download file" aria-label="Download file"><i data-lucide="download"></i></button>
664
665
  <button class="file-viewer-btn" id="file-viewer-copy" title="Copy contents" aria-label="Copy contents"><i data-lucide="copy"></i></button>
665
666
  <button class="file-viewer-btn hidden" id="file-viewer-copy-formatted" title="Copy Markdown formatting" aria-label="Copy Markdown formatting"><i data-lucide="clipboard-copy"></i></button>
666
667
  </div>
@@ -2281,7 +2282,7 @@
2281
2282
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0/lib/addon-fit.min.js"></script>
2282
2283
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0/lib/addon-web-links.min.js"></script>
2283
2284
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0/lib/addon-webgl.min.js"></script>
2284
- <script type="module" src="app.js?v=20260829-imagegen3"></script>
2285
+ <script type="module" src="app.js?v=20260829-terminal-dark1"></script>
2285
2286
  <div id="pwa-install-modal" class="pwa-modal hidden">
2286
2287
  <div class="pwa-modal-backdrop"></div>
2287
2288
  <div class="pwa-modal-card">
@@ -0,0 +1,148 @@
1
+ // File-tree context menu and shared file download action.
2
+
3
+ import { iconHtml, refreshIcons } from './icons.js';
4
+ import { insertTextAtCursor } from './input.js';
5
+ import { copyToClipboard, showToast } from './utils.js';
6
+
7
+ var MAX_COPY_CONTENT_BYTES = 1024 * 1024;
8
+
9
+ function closeFileContextMenu() {
10
+ var menu = document.getElementById('file-tree-context-menu');
11
+ if (menu) menu.remove();
12
+ var activeRows = document.querySelectorAll('.file-tree-item.context-open');
13
+ for (var i = 0; i < activeRows.length; i++) activeRows[i].classList.remove('context-open');
14
+ }
15
+
16
+ export function downloadProjectFile(filePath) {
17
+ if (!filePath) return;
18
+ var link = document.createElement('a');
19
+ link.href = 'api/file/download?path=' + encodeURIComponent(filePath);
20
+ link.download = filePath.split('/').pop() || 'download';
21
+ document.body.appendChild(link);
22
+ link.click();
23
+ link.remove();
24
+ }
25
+
26
+ function copyProjectFileContents(filePath) {
27
+ var url = 'api/file/download?path=' + encodeURIComponent(filePath);
28
+ fetch(url, { credentials: 'same-origin', cache: 'no-store' })
29
+ .then(function (response) {
30
+ if (!response.ok) throw new Error('Could not read file');
31
+ var contentLength = parseInt(response.headers.get('content-length') || '0', 10);
32
+ if (contentLength > MAX_COPY_CONTENT_BYTES) {
33
+ if (response.body && response.body.cancel) response.body.cancel();
34
+ throw new Error('File is too large to copy');
35
+ }
36
+ return response.arrayBuffer();
37
+ })
38
+ .then(function (buffer) {
39
+ if (buffer.byteLength > MAX_COPY_CONTENT_BYTES) throw new Error('File is too large to copy');
40
+ var bytes = new Uint8Array(buffer);
41
+ for (var i = 0; i < bytes.length; i++) {
42
+ if (bytes[i] === 0) throw new Error('Binary files cannot be copied as text');
43
+ }
44
+ var decoder = new TextDecoder('utf-8', { fatal: true });
45
+ var text;
46
+ try { text = decoder.decode(buffer); } catch (e) { throw new Error('Binary files cannot be copied as text'); }
47
+ return copyToClipboard(text);
48
+ })
49
+ .catch(function (error) {
50
+ var message = error && error.message ? error.message : 'Could not copy file contents';
51
+ showToast(message, 'error');
52
+ });
53
+ }
54
+
55
+ function menuItem(icon, label, handler) {
56
+ var item = document.createElement('button');
57
+ item.type = 'button';
58
+ item.className = 'file-tree-context-item';
59
+ item.setAttribute('role', 'menuitem');
60
+ item.innerHTML = iconHtml(icon) + '<span>' + label + '</span>';
61
+ item.addEventListener('click', handler);
62
+ return item;
63
+ }
64
+
65
+ function positionMenu(menu, clientX, clientY) {
66
+ var edge = 8;
67
+ var rect = menu.getBoundingClientRect();
68
+ var left = Math.max(edge, Math.min(clientX, window.innerWidth - rect.width - edge));
69
+ var top = Math.max(edge, Math.min(clientY, window.innerHeight - rect.height - edge));
70
+ menu.style.left = left + 'px';
71
+ menu.style.top = top + 'px';
72
+ }
73
+
74
+ function showFileContextMenu(event, row) {
75
+ closeFileContextMenu();
76
+ row.classList.add('context-open');
77
+
78
+ var menu = document.createElement('div');
79
+ menu.id = 'file-tree-context-menu';
80
+ menu.className = 'file-tree-context-menu';
81
+ menu.setAttribute('role', 'menu');
82
+ menu.setAttribute('aria-label', 'File actions');
83
+
84
+ var mention = menuItem('at-sign', 'Mention in chat', function (clickEvent) {
85
+ clickEvent.stopPropagation();
86
+ var filePath = row.dataset.path;
87
+ closeFileContextMenu();
88
+ insertTextAtCursor(filePath + ' ');
89
+ });
90
+ menu.appendChild(mention);
91
+
92
+ var copyPath = menuItem('copy', 'Copy path', function (clickEvent) {
93
+ clickEvent.stopPropagation();
94
+ var filePath = row.dataset.path;
95
+ closeFileContextMenu();
96
+ copyToClipboard(filePath).catch(function () { showToast('Could not copy path', 'error'); });
97
+ });
98
+ menu.appendChild(copyPath);
99
+
100
+ var copyContents = menuItem('clipboard-copy', 'Copy contents', function (clickEvent) {
101
+ clickEvent.stopPropagation();
102
+ var filePath = row.dataset.path;
103
+ closeFileContextMenu();
104
+ copyProjectFileContents(filePath);
105
+ });
106
+ menu.appendChild(copyContents);
107
+
108
+ var separator = document.createElement('div');
109
+ separator.className = 'file-tree-context-separator';
110
+ separator.setAttribute('role', 'separator');
111
+ menu.appendChild(separator);
112
+
113
+ var download = menuItem('download', 'Download', function (clickEvent) {
114
+ clickEvent.stopPropagation();
115
+ var filePath = row.dataset.path;
116
+ closeFileContextMenu();
117
+ downloadProjectFile(filePath);
118
+ });
119
+ menu.appendChild(download);
120
+ document.body.appendChild(menu);
121
+ refreshIcons(menu);
122
+ positionMenu(menu, event.clientX, event.clientY);
123
+ try { mention.focus({ preventScroll: true }); } catch (e) { mention.focus(); }
124
+ }
125
+
126
+ export function initFileBrowserContextMenu(treeEl) {
127
+ if (!treeEl || treeEl.dataset.contextMenuReady === 'true') return;
128
+ treeEl.dataset.contextMenuReady = 'true';
129
+
130
+ treeEl.addEventListener('contextmenu', function (event) {
131
+ var row = event.target && event.target.closest ? event.target.closest('.file-tree-item') : null;
132
+ if (!row || !treeEl.contains(row) || row.dataset.entryType !== 'file' || !row.dataset.path) return;
133
+ event.preventDefault();
134
+ event.stopPropagation();
135
+ showFileContextMenu(event, row);
136
+ });
137
+
138
+ document.addEventListener('pointerdown', function (event) {
139
+ var menu = document.getElementById('file-tree-context-menu');
140
+ if (menu && !menu.contains(event.target)) closeFileContextMenu();
141
+ });
142
+ document.addEventListener('keydown', function (event) {
143
+ if (event.key === 'Escape') closeFileContextMenu();
144
+ });
145
+ window.addEventListener('resize', closeFileContextMenu);
146
+ window.addEventListener('blur', closeFileContextMenu);
147
+ treeEl.addEventListener('scroll', closeFileContextMenu, { passive: true });
148
+ }
@@ -10,6 +10,7 @@ import { animateMarkdownChange, beginMarkdownPresentation, cancelMarkdownFollow,
10
10
  import { store } from './store.js';
11
11
  import { enterMarkdownSlides, exitMarkdownSlides, handleMarkdownSlideKey, syncMarkdownSlidesButton, toggleMarkdownSlideLevelMenu } from './markdown-slides.js';
12
12
  import { initFileViewerTabs, openFileViewerTab, previewFileViewerTab, updateFileViewerTab, closeFileViewerTab, focusedFileViewerTab, clearFileViewerTabs } from './filebrowser-tabs.js';
13
+ import { initFileBrowserContextMenu, downloadProjectFile } from './filebrowser-context-menu.js';
13
14
 
14
15
  var ctx;
15
16
  var showDropHint = function () {};
@@ -29,6 +30,7 @@ var gitDiffCache = {}; // hash -> diff text
29
30
  var pendingGitDiff = null; // callback for pending git diff
30
31
  var fileAtCache = {}; // hash -> file content
31
32
  var pendingFileAt = null; // callback for pending file-at
33
+ var FILE_RICH_PREVIEW_MAX_BYTES = 1024 * 1024;
32
34
 
33
35
  export function initFileBrowser(_ctx) {
34
36
  ctx = _ctx;
@@ -55,6 +57,7 @@ export function initFileBrowser(_ctx) {
55
57
  // collapses/expands folders and ascends/descends into them. The tree
56
58
  // gets a tabindex so it can receive focus and keydown events.
57
59
  if (ctx.fileTreeEl) {
60
+ initFileBrowserContextMenu(ctx.fileTreeEl);
58
61
  ctx.fileTreeEl.setAttribute('tabindex', '0');
59
62
  ctx.fileTreeEl.addEventListener('keydown', handleTreeKeyDown);
60
63
  // When the user clicks any tree row, promote it to keyboard focus
@@ -180,6 +183,11 @@ export function initFileBrowser(_ctx) {
180
183
  });
181
184
  });
182
185
 
186
+ document.getElementById("file-viewer-download").addEventListener("click", function () {
187
+ if (!currentFilePath) return;
188
+ downloadProjectFile(currentFilePath);
189
+ });
190
+
183
191
  // Markdown render toggle
184
192
  document.getElementById("file-viewer-render").addEventListener("click", function () {
185
193
  if (!currentContent || (!currentIsMarkdown && !currentIsSvg)) return;
@@ -754,6 +762,7 @@ function renderFilteredTree(container, tree, depth, query) {
754
762
 
755
763
  var row = document.createElement("div");
756
764
  row.className = "file-tree-item" + (isDir ? " expanded" : "");
765
+ row.dataset.entryType = isDir ? "dir" : "file";
757
766
  row.style.paddingLeft = (8 + depth * 16) + "px";
758
767
  if (entry) {
759
768
  row.draggable = true;
@@ -972,6 +981,7 @@ function renderEntries(container, entries, depth) {
972
981
  var entry = sorted[i];
973
982
  var row = document.createElement("div");
974
983
  row.className = "file-tree-item";
984
+ row.dataset.entryType = entry.type;
975
985
  row.style.paddingLeft = (8 + depth * 16) + "px";
976
986
 
977
987
  row.draggable = true;
@@ -1079,6 +1089,7 @@ function showFileContent(msg) {
1079
1089
  var previousContent = currentContent;
1080
1090
  var previousPath = currentFilePath;
1081
1091
  var previousWasMarkdown = currentIsMarkdown;
1092
+ var lightweightPreview = false;
1082
1093
  var refreshSlideIndex = pendingRefresh && store.get('markdownSlidesActive')
1083
1094
  ? store.get('markdownSlideIndex') || 0
1084
1095
  : null;
@@ -1116,8 +1127,10 @@ function showFileContent(msg) {
1116
1127
  } else {
1117
1128
  currentContent = msg.content;
1118
1129
  var ext = requestedExt;
1119
- currentIsMarkdown = (ext === "md" || ext === "mdx");
1120
- currentIsSvg = ext === "svg";
1130
+ lightweightPreview = (msg.size || 0) > FILE_RICH_PREVIEW_MAX_BYTES;
1131
+ currentIsMarkdown = !lightweightPreview && (ext === "md" || ext === "mdx");
1132
+ currentIsSvg = !lightweightPreview && ext === "svg";
1133
+ if (lightweightPreview) isRendered = false;
1121
1134
  if (pendingRenderedOpen && currentIsMarkdown) isRendered = true;
1122
1135
 
1123
1136
  if (currentIsMarkdown || currentIsSvg) {
@@ -1129,7 +1142,9 @@ function showFileContent(msg) {
1129
1142
  }
1130
1143
 
1131
1144
  // Markdown starts as source; SVG starts as a safe image preview.
1132
- if (currentIsMarkdown) {
1145
+ if (lightweightPreview) {
1146
+ renderLargeTextFile(bodyEl, msg.content, msg.size);
1147
+ } else if (currentIsMarkdown) {
1133
1148
  var transitionFrom = keepRenderState && prevRendered && previousWasMarkdown &&
1134
1149
  isFollowingMarkdown(msg.path) && pathsReferToSameFile(previousPath, msg.path)
1135
1150
  ? (previousContent == null ? "" : previousContent)
@@ -1149,7 +1164,9 @@ function showFileContent(msg) {
1149
1164
  refreshIcons();
1150
1165
 
1151
1166
  // If opened with a diff request, show full-file split diff in wide mode
1152
- if (pendingOpenMode && pendingOpenMode.type === "diff" && currentContent != null) {
1167
+ if (pendingOpenMode && lightweightPreview) {
1168
+ pendingOpenMode = null;
1169
+ } else if (pendingOpenMode && pendingOpenMode.type === "diff" && currentContent != null) {
1153
1170
  var diffOpts = pendingOpenMode;
1154
1171
  pendingOpenMode = null;
1155
1172
  historyVisible = false;
@@ -1444,6 +1461,26 @@ function renderCodeWithLineNumbers(bodyEl, content, ext) {
1444
1461
  }
1445
1462
  }
1446
1463
 
1464
+ function renderLargeTextFile(bodyEl, content, size) {
1465
+ var viewer = document.createElement("div");
1466
+ viewer.className = "file-viewer-large-text";
1467
+
1468
+ var notice = document.createElement("div");
1469
+ notice.className = "file-viewer-large-notice";
1470
+ notice.innerHTML = iconHtml("file-text") +
1471
+ "<span>Large file (" + formatSize(size || 0) + ") — showing plain text for performance</span>";
1472
+
1473
+ var codeWrap = document.createElement("pre");
1474
+ var codeEl = document.createElement("code");
1475
+ codeEl.textContent = content;
1476
+ codeWrap.appendChild(codeEl);
1477
+
1478
+ viewer.appendChild(notice);
1479
+ viewer.appendChild(codeWrap);
1480
+ bodyEl.innerHTML = "";
1481
+ bodyEl.appendChild(viewer);
1482
+ }
1483
+
1447
1484
  function formatSize(bytes) {
1448
1485
  if (bytes < 1024) return bytes + " B";
1449
1486
  if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB";
@@ -428,7 +428,7 @@ function extractFilePaths(cd) {
428
428
  }
429
429
 
430
430
  // --- Insert text at cursor in textarea ---
431
- function insertTextAtCursor(text) {
431
+ export function insertTextAtCursor(text) {
432
432
  var el = ctx.inputEl;
433
433
  el.focus();
434
434
  var start = el.selectionStart;
@@ -23,9 +23,8 @@ import { attachTuiGrab, detachTuiGrab } from './tui-grab.js';
23
23
  import { createKeyToolbar, TERMINAL_TOOLBAR_HTML } from './terminal-toolbar.js';
24
24
  import { refreshIcons, iconHtml } from './icons.js';
25
25
 
26
- // Claude TUI session terminal colors follow Clay's active theme via
27
- // getTerminalTheme(). Live theme switches are wired through
28
- // setTuiSessionTheme() below, which theme.js calls from applyTheme.
26
+ // Claude TUI sessions always use Clay Studio Dark via getTerminalTheme().
27
+ // Theme switches reapply that fixed palette through setTuiSessionTheme().
29
28
 
30
29
  var hostEl = null; // container div mounted over #messages
31
30
  var xtermContainerEl = null;
@@ -262,7 +262,7 @@ export function getComputedVar(varName) {
262
262
  }
263
263
 
264
264
  export function getTerminalTheme() {
265
- return computeTerminalTheme(getCurrentTheme());
265
+ return computeTerminalTheme(getTheme(DEFAULT_DARK_THEME_ID) || defaultDarkFallback);
266
266
  }
267
267
 
268
268
  export function getMermaidThemeVars() {
@@ -305,7 +305,9 @@ export function applyTheme(themeId, fromPicker) {
305
305
 
306
306
  try { updateMascotSvgs(vars, isLight); } catch (e) {}
307
307
 
308
- var termTheme = computeTerminalTheme(theme);
308
+ // Terminals deliberately keep Clay Studio Dark for predictable ANSI
309
+ // contrast, even while the surrounding workspace uses a light theme.
310
+ var termTheme = getTerminalTheme();
309
311
  try { setTerminalTheme(termTheme); } catch (e) {}
310
312
  try { setTuiSessionTheme(termTheme); } catch (e) {}
311
313
  try { setTuiAttentionTheme(termTheme); } catch (e) {}
@@ -16,9 +16,9 @@
16
16
  import { getTerminalTheme } from './theme.js';
17
17
  import { getTerminalFontFamily, getTerminalFontSize, onTerminalFontChange } from './terminal-prefs.js';
18
18
 
19
- // TUI attention modal xterm follows Clay's active theme via
20
- // getTerminalTheme() and live-updates through setTuiAttentionTheme()
21
- // which theme.js calls from applyTheme.
19
+ // The TUI attention modal always uses Clay Studio Dark via
20
+ // getTerminalTheme(). Theme switches reapply that fixed palette through
21
+ // setTuiAttentionTheme(), which theme.js calls from applyTheme().
22
22
 
23
23
  var modalEl = null;
24
24
  var modalXterm = null;
@@ -266,9 +266,8 @@ export function tuiModalHandleTermResized() { return false; }
266
266
  export function tuiModalHandleTermExited() { return false; }
267
267
  export function tuiModalHandleTermClosed() { return false; }
268
268
 
269
- // Live theme update for the attention modal. Called by theme.js when
270
- // the user switches themes. Also retints the surrounding modal chrome
271
- // so the frame doesn't stay black when a light theme is active.
269
+ // Theme update hook for the attention modal. The supplied palette remains
270
+ // Clay Studio Dark even when the surrounding workspace switches themes.
272
271
  export function setTuiAttentionTheme(xtermTheme) {
273
272
  if (modalXterm) {
274
273
  try { modalXterm.options.theme = xtermTheme; } catch (e) {}
@@ -276,8 +275,6 @@ export function setTuiAttentionTheme(xtermTheme) {
276
275
  if (modalEl && xtermTheme && xtermTheme.background) {
277
276
  var bodyEl = modalEl.querySelector(".tui-modal-body");
278
277
  if (bodyEl) bodyEl.style.background = xtermTheme.background;
279
- var frameEl = modalEl.querySelector(".tui-modal");
280
- if (frameEl) frameEl.style.background = xtermTheme.background;
281
278
  }
282
279
  }
283
280
 
@@ -11,7 +11,7 @@
11
11
  @import url("css/generated-images.css?v=20260829c");
12
12
  @import url("css/rewind.css");
13
13
  @import url("css/input.css?v=20260828-composer-depth1");
14
- @import url("css/filebrowser.css?v=20260828-workbench4");
14
+ @import url("css/filebrowser.css?v=20260829-terminal-canvas1");
15
15
  @import url("css/git-panel.css");
16
16
  @import url("css/diff.css");
17
17
  @import url("css/highlight.css");
@@ -36,5 +36,5 @@
36
36
  @import url("css/mention.css");
37
37
  @import url("css/debate.css");
38
38
  @import url("css/notifications-center.css");
39
- @import url("css/tui-attention.css");
39
+ @import url("css/tui-attention.css?v=20260829-terminal-canvas1");
40
40
  @import url("css/pwa-mobile.css?v=20260824f");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "3.7.0-beta.1",
3
+ "version": "3.7.0",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",