neural-loom 0.3.0 → 0.3.2

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.
Files changed (48) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +3 -3
  3. package/.next/cache/.tsbuildinfo +1 -1
  4. package/.next/diagnostics/route-bundle-stats.json +1 -1
  5. package/.next/fallback-build-manifest.json +3 -3
  6. package/.next/server/app/_global-error.html +1 -1
  7. package/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found.html +1 -1
  14. package/.next/server/app/_not-found.rsc +1 -1
  15. package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  16. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  17. package/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  18. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  19. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  20. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  21. package/.next/server/app/index.html +1 -1
  22. package/.next/server/app/index.rsc +2 -2
  23. package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  24. package/.next/server/app/index.segments/_full.segment.rsc +2 -2
  25. package/.next/server/app/index.segments/_head.segment.rsc +1 -1
  26. package/.next/server/app/index.segments/_index.segment.rsc +1 -1
  27. package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  28. package/.next/server/app/page/react-loadable-manifest.json +2 -2
  29. package/.next/server/app/page_client-reference-manifest.js +1 -1
  30. package/.next/server/chunks/[root-of-the-server]__0li1h1u._.js +1 -1
  31. package/.next/server/chunks/[root-of-the-server]__0li1h1u._.js.map +1 -1
  32. package/.next/server/chunks/[root-of-the-server]__0tq0maq._.js +1 -1
  33. package/.next/server/chunks/[root-of-the-server]__0tq0maq._.js.map +1 -1
  34. package/.next/server/middleware/middleware-manifest.json +1 -1
  35. package/.next/server/middleware-build-manifest.js +3 -3
  36. package/.next/server/middleware-manifest.json +1 -1
  37. package/.next/server/pages/404.html +1 -1
  38. package/.next/server/pages/500.html +1 -1
  39. package/.next/static/chunks/{0uyuco-451j.n.js → 0qc.k.i3-z5fx.js} +1 -1
  40. package/.next/static/chunks/{14~-ojwsxf~oe.js → 0ru16isbveym7.js} +4 -4
  41. package/.next/trace +1 -1
  42. package/.next/trace-build +1 -1
  43. package/package.json +1 -1
  44. package/src/app/components/TerminalConsole.tsx +98 -38
  45. package/src/lib/agents/SessionPersister.ts +24 -8
  46. /package/.next/static/{04j4ht7eCOOXSLQ5Wixq0 → 2ep0r9tVD7fxPpgavvvkt}/_buildManifest.js +0 -0
  47. /package/.next/static/{04j4ht7eCOOXSLQ5Wixq0 → 2ep0r9tVD7fxPpgavvvkt}/_clientMiddlewareManifest.js +0 -0
  48. /package/.next/static/{04j4ht7eCOOXSLQ5Wixq0 → 2ep0r9tVD7fxPpgavvvkt}/_ssgManifest.js +0 -0
@@ -19,22 +19,39 @@ interface LogBufferItem {
19
19
  message?: string;
20
20
  }
21
21
 
22
- async function copyTextToClipboard(text: string): Promise<boolean> {
23
- if (typeof window === "undefined" || !text) return false;
22
+ interface CopyResult {
23
+ ok: boolean;
24
+ method?: "clipboard-api" | "execCommand";
25
+ error?: string;
26
+ }
24
27
 
25
- // Preferred path: async Clipboard API. Reliable in a secure context, which
26
- // includes localhost (NeuralLoom's default bind).
27
- if (window.isSecureContext && navigator.clipboard?.writeText) {
28
+ async function copyTextToClipboard(text: string): Promise<CopyResult> {
29
+ if (typeof window === "undefined") return { ok: false, error: "no window" };
30
+ if (!text) return { ok: false, error: "empty selection" };
31
+
32
+ // Secure context (localhost, PWA, https): trust ONLY the async Clipboard API.
33
+ // It is reliable when it resolves — and we deliberately don't fall back to
34
+ // execCommand here, because execCommand can return a false-positive `true`
35
+ // (reporting success while copying nothing). Surfacing the real rejection is
36
+ // more useful than a fake "Copied".
37
+ if (window.isSecureContext) {
38
+ if (!navigator.clipboard?.writeText) {
39
+ return { ok: false, error: "Clipboard API unavailable in this browser" };
40
+ }
28
41
  try {
29
42
  await navigator.clipboard.writeText(text);
30
- return true;
31
- } catch {
32
- // Fall through to the execCommand fallback (e.g. permission denied).
43
+ return { ok: true, method: "clipboard-api" };
44
+ } catch (err) {
45
+ return {
46
+ ok: false,
47
+ method: "clipboard-api",
48
+ error: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
49
+ };
33
50
  }
34
51
  }
35
52
 
36
- // Fallback: hidden textarea + execCommand("copy") for non-secure contexts
37
- // (e.g. accessed over a plain-HTTP LAN address).
53
+ // Non-secure context (e.g. plain-HTTP LAN access): execCommand is the only
54
+ // option. Note its success value is not fully reliable.
38
55
  try {
39
56
  const textArea = document.createElement("textarea");
40
57
  textArea.value = text;
@@ -47,10 +64,11 @@ async function copyTextToClipboard(text: string): Promise<boolean> {
47
64
  textArea.select();
48
65
  const successful = document.execCommand("copy");
49
66
  document.body.removeChild(textArea);
50
- return successful;
67
+ if (successful) return { ok: true, method: "execCommand" };
68
+ return { ok: false, method: "execCommand", error: "execCommand returned false (use http://localhost for the Clipboard API)" };
51
69
  } catch (err) {
52
- console.warn("Clipboard copy failed:", err);
53
- return false;
70
+ const ex = err instanceof Error ? err.message : String(err);
71
+ return { ok: false, method: "execCommand", error: ex };
54
72
  }
55
73
  }
56
74
 
@@ -72,21 +90,40 @@ export default function TerminalConsole({ sessionId, height = "400px", wsPort =
72
90
  const [caseSensitive, setCaseSensitive] = useState(false);
73
91
  const searchAddonRef = useRef<SearchAddon | null>(null);
74
92
 
75
- // Copy feedback ("Copied!" indicator)
93
+ // Copy feedback ("Copied!" indicator) and last error (surfaced for diagnosis)
76
94
  const [copied, setCopied] = useState(false);
95
+ const [copyError, setCopyError] = useState<string | null>(null);
77
96
  const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
97
+ // Last non-empty terminal selection. The toolbar/right-click read this because
98
+ // clicking a button/menu clears the live xterm selection before the handler runs.
99
+ const lastSelectionRef = useRef("");
78
100
 
79
- // Copy the given text (or the current terminal selection) to the clipboard.
101
+ // Copy the given text (or the last/current terminal selection) to the clipboard.
102
+ // IMPORTANT: must be invoked synchronously inside a user gesture (mouseup, click,
103
+ // keydown) so the Clipboard API write is permitted — otherwise it silently fails.
80
104
  // Stable identity so the terminal effect can use it without re-initializing.
81
- const copySelection = useCallback((text?: string): void => {
105
+ // `explicit` = triggered by a button/shortcut (surface "nothing selected").
106
+ const copySelection = useCallback((text?: string, explicit = false): void => {
82
107
  const term = xtermRef.current;
83
- const selection = (text ?? term?.getSelection() ?? "").trim();
84
- if (!selection) return;
85
- copyTextToClipboard(selection).then((ok) => {
86
- if (!ok) return;
87
- setCopied(true);
88
- if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
89
- copiedTimerRef.current = setTimeout(() => setCopied(false), 1200);
108
+ const live = term?.getSelection() ?? "";
109
+ const selection = (text ?? (live || lastSelectionRef.current) ?? "").trim();
110
+ if (!selection) {
111
+ if (explicit) {
112
+ setCopyError("Nothing selected to copy");
113
+ setTimeout(() => setCopyError(null), 2500);
114
+ }
115
+ return;
116
+ }
117
+ copyTextToClipboard(selection).then((result) => {
118
+ if (result.ok) {
119
+ setCopyError(null);
120
+ setCopied(true);
121
+ if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
122
+ copiedTimerRef.current = setTimeout(() => setCopied(false), 1200);
123
+ } else {
124
+ console.error("[TerminalConsole] copy failed:", result.error);
125
+ setCopyError(result.error || "copy failed");
126
+ }
90
127
  });
91
128
  }, []);
92
129
 
@@ -220,31 +257,39 @@ export default function TerminalConsole({ sessionId, height = "400px", wsPort =
220
257
  const handleMouseDown = (e: MouseEvent) => {
221
258
  if (e.button === 2 && term.hasSelection()) e.stopPropagation();
222
259
  };
260
+ // Copy-on-select. This runs on the bubble phase AFTER xterm has finalized the
261
+ // selection, and — crucially — synchronously inside the mouseup user gesture,
262
+ // so the Clipboard API write is allowed (the previous setTimeout ran outside
263
+ // the gesture and silently failed). Left-button only.
223
264
  const handleMouseUp = (e: MouseEvent) => {
224
- if (e.button === 2 && term.hasSelection()) e.stopPropagation();
265
+ if (e.button === 2 && term.hasSelection()) {
266
+ e.stopPropagation();
267
+ return;
268
+ }
269
+ if (e.button === 0) {
270
+ const sel = term.getSelection();
271
+ if (sel && sel.trim()) copySelection(sel);
272
+ }
225
273
  };
226
274
  const handleContextMenu = (e: MouseEvent) => {
227
- if (term.hasSelection()) {
275
+ const sel = term.getSelection() || lastSelectionRef.current;
276
+ if (sel && sel.trim()) {
228
277
  e.preventDefault();
229
- copySelection(term.getSelection());
230
- term.clearSelection();
278
+ copySelection(sel);
231
279
  }
232
280
  };
233
281
  if (container) {
234
282
  container.addEventListener("mousedown", handleMouseDown, true);
235
- container.addEventListener("mouseup", handleMouseUp, true);
283
+ // bubble phase: xterm has already committed the selection by now
284
+ container.addEventListener("mouseup", handleMouseUp);
236
285
  container.addEventListener("contextmenu", handleContextMenu);
237
286
  }
238
287
 
239
- // Copy-on-select: capture the selection the moment it is made. Claude Code's
240
- // TUI redraws constantly, which clears the xterm selection — so we copy
241
- // immediately (debounced) rather than waiting for a later Ctrl+C/right-click.
242
- let selTimer: ReturnType<typeof setTimeout> | null = null;
288
+ // Track the latest non-empty selection so button / shortcut / right-click can
289
+ // copy it even after a click or redraw clears the live xterm selection.
243
290
  const selDisposable = term.onSelectionChange(() => {
244
291
  const sel = term.getSelection();
245
- if (!sel || !sel.trim()) return;
246
- if (selTimer) clearTimeout(selTimer);
247
- selTimer = setTimeout(() => copySelection(sel), 150);
292
+ if (sel && sel.trim()) lastSelectionRef.current = sel;
248
293
  });
249
294
 
250
295
  // Attach search key handler and clipboard copy handler
@@ -451,13 +496,12 @@ export default function TerminalConsole({ sessionId, height = "400px", wsPort =
451
496
  return () => {
452
497
  isDisposed = true;
453
498
  clearTimeout(fitTimeout);
454
- if (selTimer) clearTimeout(selTimer);
455
499
  selDisposable.dispose();
456
500
  resizeObserver.disconnect();
457
501
  ws.close();
458
502
  if (container) {
459
503
  container.removeEventListener("mousedown", handleMouseDown, true);
460
- container.removeEventListener("mouseup", handleMouseUp, true);
504
+ container.removeEventListener("mouseup", handleMouseUp);
461
505
  container.removeEventListener("contextmenu", handleContextMenu);
462
506
  }
463
507
  try {
@@ -578,8 +622,24 @@ export default function TerminalConsole({ sessionId, height = "400px", wsPort =
578
622
  ✓ Copied
579
623
  </span>
580
624
  )}
625
+ {copyError && (
626
+ <span
627
+ title={copyError}
628
+ style={{
629
+ color: "#ff6b6b",
630
+ fontSize: "0.65rem",
631
+ fontWeight: 600,
632
+ maxWidth: "260px",
633
+ overflow: "hidden",
634
+ textOverflow: "ellipsis",
635
+ whiteSpace: "nowrap",
636
+ }}
637
+ >
638
+ ⚠ Copy failed: {copyError}
639
+ </span>
640
+ )}
581
641
  <button
582
- onClick={() => copySelection()}
642
+ onClick={() => copySelection(undefined, true)}
583
643
  style={{
584
644
  padding: "2px 6px",
585
645
  borderRadius: "3px",
@@ -75,20 +75,36 @@ export class SessionPersister {
75
75
  try {
76
76
  const dir = this.getSessionsDir();
77
77
  const filePath = path.join(dir, `${id}.json`);
78
+ const json = JSON.stringify(data, null, 2);
78
79
  // Write to a temp file then rename, so a crash mid-write can't leave a
79
80
  // truncated/corrupt session file (rename is atomic on the same filesystem).
80
81
  const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
81
- fs.writeFile(tmpPath, JSON.stringify(data, null, 2), "utf8", (err) => {
82
+ fs.writeFile(tmpPath, json, "utf8", (err) => {
82
83
  if (err) {
83
- console.error(`[SessionPersister] Failed to save session ${id} asynchronously:`, err);
84
+ console.error(`[SessionPersister] Failed to save session ${id}:`, err);
84
85
  return;
85
86
  }
86
- fs.rename(tmpPath, filePath, (renameErr) => {
87
- if (renameErr) {
88
- console.error(`[SessionPersister] Failed to finalize session ${id}:`, renameErr);
89
- fs.unlink(tmpPath, () => {});
90
- }
91
- });
87
+ // On Windows the rename can transiently fail with EPERM/EACCES/EBUSY when
88
+ // the destination is momentarily locked (AV scanners, the search indexer,
89
+ // or another open handle). Retry briefly, then fall back to overwriting
90
+ // the destination directly so the save still lands.
91
+ const tryRename = (attempts: number) => {
92
+ fs.rename(tmpPath, filePath, (renameErr) => {
93
+ if (!renameErr) return;
94
+ const code = (renameErr as NodeJS.ErrnoException).code;
95
+ if ((code === "EPERM" || code === "EACCES" || code === "EBUSY") && attempts > 0) {
96
+ setTimeout(() => tryRename(attempts - 1), 60);
97
+ return;
98
+ }
99
+ fs.writeFile(filePath, json, "utf8", (writeErr) => {
100
+ if (writeErr) {
101
+ console.error(`[SessionPersister] Failed to save session ${id}:`, writeErr);
102
+ }
103
+ fs.unlink(tmpPath, () => {});
104
+ });
105
+ });
106
+ };
107
+ tryRename(5);
92
108
  });
93
109
  } catch (err) {
94
110
  console.error(`[SessionPersister] Failed to save session ${id}:`, err);