openclacky 1.2.12 → 1.2.13

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +1 -1
  3. data/.clacky/skills/gem-release/scripts/release.sh +4 -1
  4. data/CHANGELOG.md +23 -0
  5. data/lib/clacky/agent/llm_caller.rb +40 -25
  6. data/lib/clacky/agent/memory_updater.rb +12 -0
  7. data/lib/clacky/agent/skill_auto_creator.rb +7 -4
  8. data/lib/clacky/agent/skill_evolution.rb +23 -5
  9. data/lib/clacky/agent/skill_manager.rb +86 -1
  10. data/lib/clacky/agent/skill_reflector.rb +18 -23
  11. data/lib/clacky/agent.rb +9 -1
  12. data/lib/clacky/agent_config.rb +59 -15
  13. data/lib/clacky/cli.rb +55 -0
  14. data/lib/clacky/default_skills/persist-memory/SKILL.md +4 -3
  15. data/lib/clacky/default_skills/search-skills/SKILL.md +61 -0
  16. data/lib/clacky/idle_compression_timer.rb +1 -1
  17. data/lib/clacky/message_format/open_ai.rb +7 -1
  18. data/lib/clacky/openai_stream_aggregator.rb +4 -1
  19. data/lib/clacky/providers.rb +40 -12
  20. data/lib/clacky/server/http_server.rb +117 -3
  21. data/lib/clacky/server/session_registry.rb +30 -8
  22. data/lib/clacky/server/web_ui_controller.rb +24 -1
  23. data/lib/clacky/session_manager.rb +120 -0
  24. data/lib/clacky/tools/web_search.rb +59 -8
  25. data/lib/clacky/ui2/layout_manager.rb +15 -5
  26. data/lib/clacky/ui2/progress_handle.rb +7 -1
  27. data/lib/clacky/ui2/ui_controller.rb +27 -0
  28. data/lib/clacky/ui_interface.rb +22 -0
  29. data/lib/clacky/utils/model_pricing.rb +96 -0
  30. data/lib/clacky/version.rb +1 -1
  31. data/lib/clacky/web/app.css +209 -4
  32. data/lib/clacky/web/app.js +6 -5
  33. data/lib/clacky/web/i18n.js +18 -4
  34. data/lib/clacky/web/index.html +2 -1
  35. data/lib/clacky/web/sessions.js +408 -80
  36. data/lib/clacky/web/settings.js +213 -51
  37. data/lib/clacky/web/skills.js +5 -14
  38. data/lib/clacky/web/utils.js +57 -0
  39. data/lib/clacky/web/ws-dispatcher.js +136 -0
  40. metadata +4 -2
@@ -19,12 +19,17 @@ const Sessions = (() => {
19
19
  const _sessions = []; // [{ id, name, status, total_tasks, total_cost }]
20
20
  const _historyState = {}; // { [session_id]: { hasMore, oldestCreatedAt, loading, loaded } }
21
21
  const _renderedCreatedAt = {}; // { [session_id]: Set<number> } — dedup by created_at
22
+ const _drafts = new Map(); // { [session_id]: composer textarea draft }
22
23
  let _activeId = null;
23
24
  let _hasMore = false; // unified pagination: are there older sessions to load?
24
25
  let _loadingMore = false;
25
26
  // Search state
26
27
  const _filter = { q: "", date: "", type: "" }; // committed filter (applied to server)
27
28
  let _searchOpen = false; // is the search panel visible?
29
+ // Active search result split when _filter.q is non-empty:
30
+ // { nameIds: Set<id>, contentIds: Set<id>, contentLoaded: bool }
31
+ let _searchSplit = null;
32
+ let _searchToken = 0; // monotonic counter; in-flight requests check against this
28
33
  let _cronView = false; // are we in the cron sub-view?
29
34
  let _cronCount = 0; // total cron sessions from server
30
35
  // ── Cron sub-view independent pagination (commit 2) ──────────────────────
@@ -225,7 +230,7 @@ const Sessions = (() => {
225
230
 
226
231
  function _restoreMessages(id) {
227
232
  // Clear the pane and dedup state; history will be re-fetched from API.
228
- $("messages").innerHTML = "";
233
+ RenderTarget.outer().innerHTML = "";
229
234
  delete _renderedCreatedAt[id];
230
235
  if (_historyState[id]) {
231
236
  _historyState[id].oldestCreatedAt = null;
@@ -315,7 +320,7 @@ const Sessions = (() => {
315
320
  }
316
321
 
317
322
  function _updateEmptyHint() {
318
- const messages = $("messages");
323
+ const messages = RenderTarget.outer();
319
324
  if (!messages) return;
320
325
  // Check if there's any real content besides the hint itself
321
326
  const hasReal = Array.from(messages.children).some(
@@ -339,7 +344,7 @@ const Sessions = (() => {
339
344
  }
340
345
 
341
346
  function _initEmptyHint() {
342
- const messages = $("messages");
347
+ const messages = RenderTarget.outer();
343
348
  if (!messages) return;
344
349
  // Re-evaluate whenever children change (append/insertBefore/innerHTML="")
345
350
  const observer = new MutationObserver(() => _updateEmptyHint());
@@ -355,7 +360,7 @@ const Sessions = (() => {
355
360
 
356
361
  function _initNewMessageBanner() {
357
362
  const banner = $("new-message-banner");
358
- const messages = $("messages");
363
+ const messages = RenderTarget.outer();
359
364
  if (!banner || !messages) return;
360
365
 
361
366
  // Click to scroll to bottom
@@ -530,7 +535,8 @@ const Sessions = (() => {
530
535
  }
531
536
 
532
537
  // Compress an image File/Blob to a data URL within MAX_IMAGE_BYTES_SEND.
533
- // Strategy: scale down to MAX_IMAGE_LONG_EDGE, then reduce JPEG quality until small enough.
538
+ // PNG: keep as PNG to preserve alpha/transparency; scale down if too large.
539
+ // Other formats (JPEG/GIF/WEBP): scale down, then reduce JPEG quality until small enough.
534
540
  // GIF is not compressible via Canvas — rendered as JPEG (LLMs only see first frame anyway).
535
541
  function _compressImage(file) {
536
542
  return new Promise((resolve, reject) => {
@@ -554,14 +560,32 @@ const Sessions = (() => {
554
560
  const ctx = canvas.getContext("2d");
555
561
  ctx.drawImage(img, 0, 0, width, height);
556
562
 
557
- // Try decreasing quality until under limit
558
- let quality = 0.85;
559
- let dataUrl = canvas.toDataURL("image/jpeg", quality);
560
- while (dataUrl.length * 0.75 > MAX_IMAGE_BYTES_SEND && quality > 0.2) {
561
- quality -= 0.1;
562
- dataUrl = canvas.toDataURL("image/jpeg", quality);
563
+ // PNG: keep as PNG to preserve alpha/transparency.
564
+ // Other formats (JPEG/GIF/WEBP): convert to JPEG (no alpha needed).
565
+ const isPNG = file.type === "image/png";
566
+ if (isPNG) {
567
+ let dataUrl = canvas.toDataURL("image/png");
568
+ // If PNG is still too large, scale down further
569
+ let scale = 0.9;
570
+ while (dataUrl.length * 0.75 > MAX_IMAGE_BYTES_SEND && scale > 0.3) {
571
+ const sw = Math.round(width * scale);
572
+ const sh = Math.round(height * scale);
573
+ canvas.width = sw;
574
+ canvas.height = sh;
575
+ ctx.drawImage(img, 0, 0, sw, sh);
576
+ dataUrl = canvas.toDataURL("image/png");
577
+ scale -= 0.1;
578
+ }
579
+ resolve(dataUrl);
580
+ } else {
581
+ let quality = 0.85;
582
+ let dataUrl = canvas.toDataURL("image/jpeg", quality);
583
+ while (dataUrl.length * 0.75 > MAX_IMAGE_BYTES_SEND && quality > 0.2) {
584
+ quality -= 0.1;
585
+ dataUrl = canvas.toDataURL("image/jpeg", quality);
586
+ }
587
+ resolve(dataUrl);
563
588
  }
564
- resolve(dataUrl);
565
589
  };
566
590
  img.src = e.target.result;
567
591
  };
@@ -584,7 +608,7 @@ const Sessions = (() => {
584
608
 
585
609
  _compressImage(file)
586
610
  .then(dataUrl => {
587
- _pendingImages.push({ dataUrl, name: displayName, mimeType: "image/jpeg", seq });
611
+ _pendingImages.push({ dataUrl, name: displayName, mimeType: file.type === "image/png" ? "image/png" : "image/jpeg", seq });
588
612
  _renderAttachmentPreviews();
589
613
  })
590
614
  .catch(err => alert(`Image processing failed: ${err.message}`));
@@ -743,6 +767,9 @@ const Sessions = (() => {
743
767
  }).join(" ");
744
768
  bubbleHtml = badges + (bubbleHtml ? "<br>" + bubbleHtml : "");
745
769
  }
770
+ if (typeof window._closeAllPhases === "function") {
771
+ window._closeAllPhases("incomplete");
772
+ }
746
773
  Sessions.appendMsg("user", bubbleHtml, { time: new Date() });
747
774
 
748
775
  // Merge images and files into unified files array for WS payload.
@@ -777,6 +804,7 @@ const Sessions = (() => {
777
804
 
778
805
  input.value = "";
779
806
  input.style.height = "auto";
807
+ _drafts.delete(Sessions.activeId);
780
808
  setTimeout(() => { _sending = false; }, 300);
781
809
  }
782
810
 
@@ -847,13 +875,13 @@ const Sessions = (() => {
847
875
  }
848
876
  });
849
877
 
850
- // Enter key → commit search (fires whichever input has focus)
851
- document.addEventListener("keydown", (e) => {
852
- if (e.key === "Enter" && e.target && e.target.id === "session-search-q") {
853
- e.preventDefault();
854
- Sessions.commitSearch();
855
- }
856
- });
878
+ // Enter key → commit search.
879
+ // Bound on the input directly so IME.bindEnter can attach compositionend
880
+ // to the input itself (Safari needs the timestamp to suppress fake Enters).
881
+ const searchInput = document.getElementById("session-search-q");
882
+ if (searchInput) {
883
+ IME.bindEnter(searchInput, () => Sessions.commitSearch());
884
+ }
857
885
 
858
886
  // Inline ✕ button — clear the q input and re-fetch
859
887
  document.addEventListener("click", (e) => {
@@ -908,8 +936,8 @@ const Sessions = (() => {
908
936
  // but kept here in case some brand / template still renders it).
909
937
  function _initMessageHistory() {
910
938
  // Infinite-scroll older history when the user reaches the top.
911
- document.getElementById("messages").addEventListener("scroll", () => {
912
- const messages = document.getElementById("messages");
939
+ RenderTarget.outer().addEventListener("scroll", (e) => {
940
+ const messages = e.currentTarget;
913
941
  if (messages.scrollTop < 80 && Sessions.activeId && Sessions.hasMoreHistory(Sessions.activeId)) {
914
942
  Sessions.loadMoreHistory(Sessions.activeId);
915
943
  }
@@ -963,11 +991,87 @@ const Sessions = (() => {
963
991
  `<span class="tool-item-status running">…</span>` +
964
992
  `</div>` +
965
993
  `<div class="tool-item-details" style="display:none"></div>` +
994
+ `<div class="tool-item-diff" style="display:none"></div>` +
966
995
  `<pre class="tool-item-stdout" style="display:none"></pre>`;
967
996
  _ensureCopyDelegation();
968
997
  return item;
969
998
  }
970
999
 
1000
+ function _lineDiff(oldText, newText) {
1001
+ const a = String(oldText || "").split("\n");
1002
+ const b = String(newText || "").split("\n");
1003
+ const m = a.length, n = b.length;
1004
+ const lcs = Array.from({ length: m + 1 }, () => new Uint32Array(n + 1));
1005
+ for (let i = m - 1; i >= 0; i--) {
1006
+ for (let j = n - 1; j >= 0; j--) {
1007
+ lcs[i][j] = a[i] === b[j] ? lcs[i+1][j+1] + 1 : Math.max(lcs[i+1][j], lcs[i][j+1]);
1008
+ }
1009
+ }
1010
+ const ops = [];
1011
+ let i = 0, j = 0;
1012
+ while (i < m && j < n) {
1013
+ if (a[i] === b[j]) { ops.push({ kind: "ctx", text: a[i] }); i++; j++; }
1014
+ else if (lcs[i+1][j] >= lcs[i][j+1]) { ops.push({ kind: "del", text: a[i] }); i++; }
1015
+ else { ops.push({ kind: "add", text: b[j] }); j++; }
1016
+ }
1017
+ while (i < m) { ops.push({ kind: "del", text: a[i++] }); }
1018
+ while (j < n) { ops.push({ kind: "add", text: b[j++] }); }
1019
+ return ops;
1020
+ }
1021
+
1022
+ function _trimDiffContext(ops, ctxLines = 3) {
1023
+ const keep = new Array(ops.length).fill(false);
1024
+ for (let i = 0; i < ops.length; i++) {
1025
+ if (ops[i].kind !== "ctx") {
1026
+ for (let k = Math.max(0, i - ctxLines); k <= Math.min(ops.length - 1, i + ctxLines); k++) keep[k] = true;
1027
+ }
1028
+ }
1029
+ const out = [];
1030
+ let skipped = 0;
1031
+ for (let i = 0; i < ops.length; i++) {
1032
+ if (keep[i]) {
1033
+ if (skipped > 0) { out.push({ kind: "hunk", text: `@@ ${skipped} unchanged lines @@` }); skipped = 0; }
1034
+ out.push(ops[i]);
1035
+ } else {
1036
+ skipped++;
1037
+ }
1038
+ }
1039
+ return out;
1040
+ }
1041
+
1042
+ function _renderEditWriteDiff(item, name, args) {
1043
+ if (!item || !args || typeof args !== "object") return;
1044
+ let oldText = "", newText = "";
1045
+ if (name === "edit") {
1046
+ oldText = args.old_string || args["old_string"] || "";
1047
+ newText = args.new_string || args["new_string"] || "";
1048
+ } else if (name === "write") {
1049
+ oldText = "";
1050
+ newText = args.content || args["content"] || "";
1051
+ } else {
1052
+ return;
1053
+ }
1054
+ if (!oldText && !newText) return;
1055
+
1056
+ const diffEl = item.querySelector(".tool-item-diff");
1057
+ if (!diffEl || diffEl.dataset.filled === "1") return;
1058
+
1059
+ const ops = _trimDiffContext(_lineDiff(oldText, newText), 3);
1060
+ if (!ops.length) return;
1061
+
1062
+ const MAX = 50;
1063
+ const truncated = ops.length > MAX;
1064
+ const shown = truncated ? ops.slice(0, MAX) : ops;
1065
+ const prefix = (k) => k === "add" ? "+" : k === "del" ? "-" : k === "hunk" ? "" : " ";
1066
+ let html = shown.map(o => `<div class="diff-line diff-${o.kind}">${escapeHtml(prefix(o.kind) + o.text)}</div>`).join("");
1067
+ if (truncated) {
1068
+ html += `<div class="diff-line diff-more">… ${ops.length - MAX} more lines hidden</div>`;
1069
+ }
1070
+ diffEl.innerHTML = html;
1071
+ diffEl.style.display = "";
1072
+ diffEl.dataset.filled = "1";
1073
+ }
1074
+
971
1075
  function _toggleToolItemDetails(item) {
972
1076
  if (!item) return;
973
1077
  const details = item.querySelector(".tool-item-details");
@@ -1093,6 +1197,12 @@ const Sessions = (() => {
1093
1197
  status.className = "tool-item-status ok";
1094
1198
  status.textContent = "✓";
1095
1199
  }
1200
+ const toolName = last.dataset.toolName || "";
1201
+ if (toolName === "edit" || toolName === "write") {
1202
+ let parsedArgs = null;
1203
+ try { parsedArgs = JSON.parse(last.dataset.argsJson || "null"); } catch (_) {}
1204
+ if (parsedArgs) _renderEditWriteDiff(last, toolName, parsedArgs);
1205
+ }
1096
1206
  // Render the result string (e.g. "waiting (#4) — 128B\nstep1\nstep2…")
1097
1207
  // into the stdout area so the user can see what actually happened.
1098
1208
  // If the area already has streamed content (future feature), leave it.
@@ -1132,7 +1242,7 @@ const Sessions = (() => {
1132
1242
  switch (ev.type) {
1133
1243
  case "history_user_message": {
1134
1244
  // Collapse any open tool group from the previous round
1135
- if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
1245
+ if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; historyCtx.lastItem = null; }
1136
1246
  const el = document.createElement("div");
1137
1247
  el.className = "msg msg-user";
1138
1248
  // Render image thumbnails and PDF badges (if any) followed by the text content
@@ -1186,7 +1296,7 @@ const Sessions = (() => {
1186
1296
 
1187
1297
  case "assistant_message": {
1188
1298
  // Collapse tool group before assistant reply
1189
- if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
1299
+ if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; historyCtx.lastItem = null; }
1190
1300
  const el = document.createElement("div");
1191
1301
  el.className = "msg msg-assistant";
1192
1302
  el.dataset.raw = ev.content || "";
@@ -1210,6 +1320,12 @@ const Sessions = (() => {
1210
1320
  if (historyCtx.group && historyCtx.lastItem) {
1211
1321
  const status = historyCtx.lastItem.querySelector(".tool-item-status");
1212
1322
  if (status) { status.className = "tool-item-status ok"; status.textContent = "✓"; }
1323
+ const toolName = historyCtx.lastItem.dataset.toolName || "";
1324
+ if (toolName === "edit" || toolName === "write") {
1325
+ let parsedArgs = null;
1326
+ try { parsedArgs = JSON.parse(historyCtx.lastItem.dataset.argsJson || "null"); } catch (_) {}
1327
+ if (parsedArgs) _renderEditWriteDiff(historyCtx.lastItem, toolName, parsedArgs);
1328
+ }
1213
1329
  const stdout = historyCtx.lastItem.querySelector(".tool-item-stdout");
1214
1330
  if (stdout) {
1215
1331
  const resultStr = (ev.result == null) ? "" : String(ev.result).trim();
@@ -1220,21 +1336,18 @@ const Sessions = (() => {
1220
1336
  stdout.style.display = "none";
1221
1337
  }
1222
1338
  }
1223
- historyCtx.lastItem = null;
1224
1339
  }
1225
1340
  break;
1226
1341
  }
1227
1342
 
1228
1343
  case "token_usage": {
1229
- // Collapse any open tool group before rendering the token line
1230
- if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
1231
- Sessions.appendTokenUsage(ev, container);
1344
+ Sessions.appendTokenUsage(ev, container, historyCtx.lastItem);
1232
1345
  break;
1233
1346
  }
1234
1347
 
1235
1348
  case "request_feedback": {
1236
1349
  // Collapse any open tool group
1237
- if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
1350
+ if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; historyCtx.lastItem = null; }
1238
1351
 
1239
1352
  const rfQuestion = ev.question || "";
1240
1353
  const rfContext = ev.context || "";
@@ -1302,7 +1415,7 @@ const Sessions = (() => {
1302
1415
  stdout.innerHTML += lines.map(_ansiToHtml).join("");
1303
1416
  if (stdout.style.display === "none") stdout.style.display = "";
1304
1417
  stdout.scrollTop = stdout.scrollHeight;
1305
- const messages = $("messages");
1418
+ const messages = RenderTarget.outer();
1306
1419
  _scrollToBottomIfNeeded(messages);
1307
1420
  }
1308
1421
 
@@ -1313,7 +1426,7 @@ const Sessions = (() => {
1313
1426
  const lines = _pendingStdoutLines;
1314
1427
  _pendingStdoutLines = null;
1315
1428
 
1316
- const messages = $("messages");
1429
+ const messages = RenderTarget.outer();
1317
1430
  if (!messages) return;
1318
1431
  const items = messages.querySelectorAll(".tool-item");
1319
1432
  if (items.length === 0) return;
@@ -1393,9 +1506,9 @@ const Sessions = (() => {
1393
1506
  // Collapse any tool group still open at end of page
1394
1507
  if (historyCtx.group) _collapseToolGroup(historyCtx.group);
1395
1508
 
1396
- // Insert into #messages (only renders if this session is currently active)
1509
+ // Insert into the outer message stream (history never lands inside an active phase card).
1397
1510
  if (id === _activeId) {
1398
- const messages = $("messages");
1511
+ const messages = RenderTarget.outer();
1399
1512
  if (prepend && messages.firstChild) {
1400
1513
  const scrollBefore = messages.scrollHeight - messages.scrollTop;
1401
1514
  messages.insertBefore(frag, messages.firstChild);
@@ -1542,7 +1655,7 @@ const Sessions = (() => {
1542
1655
  let _copyDelegationInstalled = false;
1543
1656
  function _ensureCopyDelegation() {
1544
1657
  if (_copyDelegationInstalled) return;
1545
- const messages = $("messages");
1658
+ const messages = RenderTarget.outer();
1546
1659
  if (!messages) return;
1547
1660
  messages.addEventListener("click", (e) => {
1548
1661
  // ── Tool item: click header to expand/collapse args details ──
@@ -1644,7 +1757,44 @@ const Sessions = (() => {
1644
1757
  return btn;
1645
1758
  }
1646
1759
 
1760
+ function _makeSearchHeader(text) {
1761
+ const div = document.createElement("div");
1762
+ div.className = "session-search-group";
1763
+ div.textContent = text;
1764
+ return div;
1765
+ }
1766
+
1647
1767
  // ── Private render helper ─────────────────────────────────────────────
1768
+ // Escape regex metacharacters so a user query can be safely substituted
1769
+ // into a RegExp constructor (used to build a case-insensitive highlighter).
1770
+ function _escapeRegex(s) {
1771
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1772
+ }
1773
+
1774
+ // Wrap occurrences of `query` inside `text` with <mark>. Both inputs are
1775
+ // first HTML-escaped so the resulting fragment is safe to inject.
1776
+ function _highlightSnippet(text, query) {
1777
+ const safe = escapeHtml(text || "");
1778
+ const q = (query || "").trim();
1779
+ if (!q) return safe;
1780
+ const re = new RegExp(_escapeRegex(escapeHtml(q)), "gi");
1781
+ return safe.replace(re, (m) => `<mark>${m}</mark>`);
1782
+ }
1783
+
1784
+ // Re-center a snippet around its first match so the keyword is always
1785
+ // visible in a single-line ellipsis layout. Backend already gives us a
1786
+ // ±N byte window, but for ASCII-heavy lines that's still wider than the
1787
+ // sidebar can render on one line. We trim the head when the match sits
1788
+ // too far to the right, keeping ~`headRoom` chars before it.
1789
+ function _centerSnippet(text, query, headRoom = 16) {
1790
+ const t = text || "";
1791
+ const q = (query || "").trim();
1792
+ if (!q) return t;
1793
+ const idx = t.toLowerCase().indexOf(q.toLowerCase());
1794
+ if (idx <= headRoom) return t;
1795
+ return "…" + t.slice(idx - headRoom);
1796
+ }
1797
+
1648
1798
  //
1649
1799
  // Build and append a single session-item <div> into `container`.
1650
1800
  // Used by both the general list and the coding section.
@@ -1655,6 +1805,10 @@ const Sessions = (() => {
1655
1805
  if (s.pinned) el.classList.add("pinned");
1656
1806
 
1657
1807
  const displayName = s.name || _relativeTime(s.created_at);
1808
+ const q = (_filter.q || "").trim();
1809
+ const nameHtml = (q && s._matchVia === "name" && s.name)
1810
+ ? _highlightSnippet(displayName, q)
1811
+ : escapeHtml(displayName);
1658
1812
 
1659
1813
  // Meta line — prefer relative time of last activity. Tasks count is
1660
1814
  // only shown when > 0 to avoid visual noise on fresh sessions.
@@ -1695,10 +1849,15 @@ const Sessions = (() => {
1695
1849
  ? `<span class="session-dot dot-${s.status}"></span>`
1696
1850
  : "";
1697
1851
 
1852
+ const snippetHtml = (s._matchVia === "content" && s.search_snippet)
1853
+ ? `<div class="session-snippet">${_highlightSnippet(_centerSnippet(s.search_snippet, _filter.q), _filter.q)}</div>`
1854
+ : "";
1855
+
1698
1856
  el.innerHTML = `
1699
1857
  <div class="session-body">
1700
- <div class="session-name">${dotHtml}<span class="session-name__text">${escapeHtml(displayName)}</span>${badgeHtml}${codingBadgeHtml}${pinIcon}</div>
1858
+ <div class="session-name">${dotHtml}<span class="session-name__text">${nameHtml}</span>${badgeHtml}${codingBadgeHtml}${pinIcon}</div>
1701
1859
  <div class="session-meta">${metaText}</div>
1860
+ ${snippetHtml}
1702
1861
  </div>
1703
1862
  <button class="session-actions-btn" title="Actions"><svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="2.5" cy="7" r="1.2" fill="currentColor"/><circle cx="7" cy="7" r="1.2" fill="currentColor"/><circle cx="11.5" cy="7" r="1.2" fill="currentColor"/></svg></button>`;
1704
1863
 
@@ -1719,6 +1878,13 @@ const Sessions = (() => {
1719
1878
  }, 200);
1720
1879
  };
1721
1880
 
1881
+ // Right-click context menu
1882
+ el.oncontextmenu = (e) => {
1883
+ e.preventDefault();
1884
+ Sessions._closeActionsMenu();
1885
+ _showContextMenu(e, s);
1886
+ };
1887
+
1722
1888
  // Actions button - show menu
1723
1889
  const actionsBtn = el.querySelector(".session-actions-btn");
1724
1890
  actionsBtn.onclick = (e) => {
@@ -1889,6 +2055,7 @@ const Sessions = (() => {
1889
2055
  }
1890
2056
  // Clean up per-session progress state (timer + DOM + logical state)
1891
2057
  Sessions._deleteProgressState(id);
2058
+ _drafts.delete(id);
1892
2059
  },
1893
2060
 
1894
2061
  /** Load the next page of older sessions (unified time cursor). */
@@ -1989,7 +2156,6 @@ const Sessions = (() => {
1989
2156
 
1990
2157
  /** Commit current filter values and re-fetch from server. Called by Enter / Go button. */
1991
2158
  async commitSearch() {
1992
- // Read live input values into _filter
1993
2159
  const qEl = document.getElementById("session-search-q");
1994
2160
  const typeEl = document.getElementById("session-search-type");
1995
2161
  const dateEl = document.getElementById("session-search-date");
@@ -1997,29 +2163,73 @@ const Sessions = (() => {
1997
2163
  if (typeEl) _filter.type = typeEl.value;
1998
2164
  if (dateEl) _filter.date = dateEl.dataset.value || "";
1999
2165
 
2000
- // Clear list and reload from server with new filters
2001
- _sessions.length = 0;
2002
- _hasMore = false;
2166
+ const token = ++_searchToken;
2003
2167
  _loadingMore = true;
2004
- Sessions.renderList();
2168
+ Sessions.renderList({ skipScrollToActive: true });
2169
+
2170
+ let nextSessions = [];
2171
+ let nextHasMore = false;
2172
+ let nextCronCnt = 0;
2173
+ let nextSplit = null;
2005
2174
 
2006
2175
  try {
2007
- const params = new URLSearchParams({ limit: "20" });
2008
- if (_filter.q) params.set("q", _filter.q);
2009
- if (_filter.date) params.set("date", _filter.date);
2010
- if (_filter.type) params.set("type", _filter.type);
2176
+ const baseParams = new URLSearchParams({ limit: "20" });
2177
+ if (_filter.date) baseParams.set("date", _filter.date);
2178
+ if (_filter.type) baseParams.set("type", _filter.type);
2179
+
2180
+ if (_filter.q) {
2181
+ const pName = new URLSearchParams(baseParams);
2182
+ const pContent = new URLSearchParams(baseParams);
2183
+ pName.set("q", _filter.q); pName.set("q_scope", "name");
2184
+ pContent.set("q", _filter.q); pContent.set("q_scope", "content");
2185
+ pContent.set("limit", "50");
2186
+
2187
+ const [nameRes, contentRes] = await Promise.all([
2188
+ fetch(`/api/sessions?${pName}`),
2189
+ fetch(`/api/sessions?${pContent}`),
2190
+ ]);
2191
+ if (token !== _searchToken) return;
2192
+ const nameData = nameRes.ok ? await nameRes.json() : { sessions: [] };
2193
+ const contentData = contentRes.ok ? await contentRes.json() : { sessions: [] };
2194
+ if (token !== _searchToken) return;
2195
+
2196
+ const nameIds = new Set();
2197
+ const contentIds = new Set();
2198
+ (nameData.sessions || []).forEach(s => { nameIds.add(s.id); s._matchVia = "name"; });
2199
+ (contentData.sessions || []).forEach(s => {
2200
+ if (nameIds.has(s.id)) return;
2201
+ contentIds.add(s.id);
2202
+ s._matchVia = "content";
2203
+ });
2011
2204
 
2012
- const res = await fetch(`/api/sessions?${params}`);
2013
- if (!res.ok) return;
2014
- const data = await res.json();
2015
- _sessions.push(...(data.sessions || []));
2016
- _hasMore = !!data.has_more;
2017
- _cronCount = data.cron_count || 0;
2205
+ nextSessions = [...(nameData.sessions || [])];
2206
+ (contentData.sessions || []).forEach(s => { if (!nameIds.has(s.id)) nextSessions.push(s); });
2207
+ nextHasMore = false;
2208
+ nextCronCnt = nameData.cron_count || 0;
2209
+ nextSplit = { nameIds, contentIds, contentLoaded: contentRes.ok };
2210
+ } else {
2211
+ const res = await fetch(`/api/sessions?${baseParams}`);
2212
+ if (token !== _searchToken) return;
2213
+ if (!res.ok) return;
2214
+ const data = await res.json();
2215
+ if (token !== _searchToken) return;
2216
+ nextSessions = data.sessions || [];
2217
+ nextHasMore = !!data.has_more;
2218
+ nextCronCnt = data.cron_count || 0;
2219
+ }
2220
+
2221
+ _sessions.length = 0;
2222
+ _sessions.push(...nextSessions);
2223
+ _hasMore = nextHasMore;
2224
+ _cronCount = nextCronCnt;
2225
+ _searchSplit = nextSplit;
2018
2226
  } catch (e) {
2019
- console.error("commitSearch error:", e);
2227
+ if (token === _searchToken) console.error("commitSearch error:", e);
2020
2228
  } finally {
2021
- _loadingMore = false;
2022
- Sessions.renderList();
2229
+ if (token === _searchToken) {
2230
+ _loadingMore = false;
2231
+ Sessions.renderList();
2232
+ }
2023
2233
  }
2024
2234
  },
2025
2235
 
@@ -2111,6 +2321,26 @@ const Sessions = (() => {
2111
2321
  }
2112
2322
  },
2113
2323
 
2324
+ /** Fork a session — creates a copy with the same history and working dir. */
2325
+ async fork(sessionId) {
2326
+ try {
2327
+ const res = await fetch(`/api/sessions/${sessionId}/fork`, { method: "POST" });
2328
+ if (!res.ok) {
2329
+ const data = await res.json().catch(() => ({}));
2330
+ console.error("Fork session failed:", data.error || res.status);
2331
+ return;
2332
+ }
2333
+ const data = await res.json();
2334
+ if (data.session) {
2335
+ Sessions.add(data.session);
2336
+ Sessions.renderList();
2337
+ Sessions.select(data.session.id);
2338
+ }
2339
+ } catch (err) {
2340
+ console.error("Fork session error:", err);
2341
+ }
2342
+ },
2343
+
2114
2344
  // ── Selection ─────────────────────────────────────────────────────────
2115
2345
  //
2116
2346
  // Panel switching is handled by Router — Sessions only manages state.
@@ -2138,6 +2368,12 @@ const Sessions = (() => {
2138
2368
  /** Set _activeId directly (called by Router when activating a session). */
2139
2369
  _setActiveId(id) {
2140
2370
  _activeId = id;
2371
+ const input = $("user-input");
2372
+ if (input) {
2373
+ input.value = _drafts.get(id) || "";
2374
+ input.style.height = "auto";
2375
+ if (input.value) input.style.height = Math.min(input.scrollHeight, 200) + "px";
2376
+ }
2141
2377
  },
2142
2378
 
2143
2379
  /** Restore cached messages for a session into the #messages container. */
@@ -2149,9 +2385,11 @@ const Sessions = (() => {
2149
2385
  * Called by Router before switching away from a session view. */
2150
2386
  _cacheActiveAndDeselect() {
2151
2387
  _cacheActiveMessages();
2152
- // Detach progress UI (DOM + timer) but preserve the logical state
2153
- // so it can be restored when the user switches back to this session.
2154
- if (_activeId) Sessions._detachProgressUI(_activeId);
2388
+ if (_activeId) {
2389
+ const input = $("user-input");
2390
+ if (input) _drafts.set(_activeId, input.value);
2391
+ Sessions._detachProgressUI(_activeId);
2392
+ }
2155
2393
  _activeId = null;
2156
2394
  WS.setSubscribedSession(null);
2157
2395
  Sessions.renderList();
@@ -2211,8 +2449,29 @@ const Sessions = (() => {
2211
2449
  list.innerHTML = "";
2212
2450
 
2213
2451
  if (hasActiveFilter) {
2214
- // Filter active: show all matching results flat, no group entry
2215
- visible.forEach(s => _renderSessionItem(list, s));
2452
+ if (_filter.q && _searchSplit) {
2453
+ const { nameIds, contentIds, contentLoaded } = _searchSplit;
2454
+ const nameRows = visible.filter(s => nameIds.has(s.id));
2455
+ const contentRows = visible.filter(s => contentIds.has(s.id));
2456
+
2457
+ if (nameRows.length > 0) {
2458
+ list.appendChild(_makeSearchHeader(I18n.t("sessions.search.byName", { n: nameRows.length })));
2459
+ nameRows.forEach(s => _renderSessionItem(list, s));
2460
+ }
2461
+ if (contentLoaded) {
2462
+ list.appendChild(_makeSearchHeader(I18n.t("sessions.search.byContent", { n: contentRows.length })));
2463
+ if (contentRows.length === 0) {
2464
+ const empty = document.createElement("div");
2465
+ empty.className = "session-empty";
2466
+ empty.textContent = I18n.t("sessions.search.contentEmpty");
2467
+ list.appendChild(empty);
2468
+ } else {
2469
+ contentRows.forEach(s => _renderSessionItem(list, s));
2470
+ }
2471
+ }
2472
+ } else {
2473
+ visible.forEach(s => _renderSessionItem(list, s));
2474
+ }
2216
2475
  } else if (isCronView) {
2217
2476
  // Cron sub-view: show only cron sessions, paginated independently via
2218
2477
  // loadMoreCron() (the first page is fetched on entering the view).
@@ -2308,6 +2567,54 @@ const Sessions = (() => {
2308
2567
  }
2309
2568
  },
2310
2569
 
2570
+ /** Show right-click context menu for a session item. */
2571
+ _showContextMenu(e, session) {
2572
+ Sessions._closeContextMenu();
2573
+ Sessions._closeActionsMenu();
2574
+
2575
+ const iconFork = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>`;
2576
+
2577
+ const menu = document.createElement("div");
2578
+ menu.className = "session-context-menu";
2579
+ menu.innerHTML = `
2580
+ <div class="session-actions-menu-item" data-action="fork">
2581
+ <span class="session-actions-menu-icon">${iconFork}</span>
2582
+ <span class="session-actions-menu-label">${escapeHtml(I18n.t("sessions.actions.fork"))}</span>
2583
+ </div>
2584
+ `;
2585
+
2586
+ document.body.appendChild(menu);
2587
+ menu.style.position = "fixed";
2588
+ menu.style.top = e.clientY + "px";
2589
+ menu.style.left = e.clientX + "px";
2590
+ // Keep menu within viewport
2591
+ requestAnimationFrame(() => {
2592
+ const r = menu.getBoundingClientRect();
2593
+ if (r.right > window.innerWidth) menu.style.left = (window.innerWidth - r.width - 8) + "px";
2594
+ if (r.bottom > window.innerHeight) menu.style.top = (window.innerHeight - r.height - 8) + "px";
2595
+ });
2596
+
2597
+ menu.addEventListener("click", async (ev) => {
2598
+ const item = ev.target.closest(".session-actions-menu-item");
2599
+ if (!item) return;
2600
+ const action = item.dataset.action;
2601
+ Sessions._closeContextMenu();
2602
+ if (action === "fork") {
2603
+ await Sessions.fork(session.id);
2604
+ }
2605
+ });
2606
+
2607
+ setTimeout(() => {
2608
+ document.addEventListener("click", Sessions._closeContextMenu, { once: true });
2609
+ document.addEventListener("contextmenu", Sessions._closeContextMenu, { once: true });
2610
+ }, 0);
2611
+ },
2612
+
2613
+ _closeContextMenu() {
2614
+ const existing = document.querySelector(".session-context-menu");
2615
+ if (existing) existing.remove();
2616
+ },
2617
+
2311
2618
  /** Show actions menu (pin/rename/delete) next to the actions button. */
2312
2619
  _showActionsMenu(button, session) {
2313
2620
  // Close any existing menu first
@@ -2315,6 +2622,7 @@ const Sessions = (() => {
2315
2622
 
2316
2623
  // Lucide-style stroked icons to match the rest of the UI
2317
2624
  const iconPin = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="transform:rotate(45deg);display:block"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>`;
2625
+ const iconFork = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>`;
2318
2626
  const iconRename = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4z"/></svg>`;
2319
2627
  const iconTrash = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>`;
2320
2628
 
@@ -2323,6 +2631,10 @@ const Sessions = (() => {
2323
2631
  const menu = document.createElement("div");
2324
2632
  menu.className = "session-actions-menu";
2325
2633
  menu.innerHTML = `
2634
+ <div class="session-actions-menu-item" data-action="fork">
2635
+ <span class="session-actions-menu-icon">${iconFork}</span>
2636
+ <span class="session-actions-menu-label">${escapeHtml(I18n.t("sessions.actions.fork"))}</span>
2637
+ </div>
2326
2638
  <div class="session-actions-menu-item" data-action="pin">
2327
2639
  <span class="session-actions-menu-icon">${iconPin}</span>
2328
2640
  <span class="session-actions-menu-label">${escapeHtml(pinLabel)}</span>
@@ -2352,7 +2664,9 @@ const Sessions = (() => {
2352
2664
  const action = item.dataset.action;
2353
2665
  Sessions._closeActionsMenu();
2354
2666
 
2355
- if (action === "pin") {
2667
+ if (action === "fork") {
2668
+ await Sessions.fork(session.id);
2669
+ } else if (action === "pin") {
2356
2670
  await Sessions.togglePin(session.id);
2357
2671
  } else if (action === "rename") {
2358
2672
  // Close sidebar on mobile so the rename dialog isn't obscured
@@ -2708,10 +3022,15 @@ const Sessions = (() => {
2708
3022
  _liveToolGroup: null, // current open .tool-group DOM element
2709
3023
  _liveLastToolItem: null, // last .tool-item added (for tool_result pairing)
2710
3024
 
3025
+ // Append a diff block to the message stream (for edit/write previews).
3026
+ appendDiff(rows, truncated, hiddenLines) {
3027
+ // Deprecated no-op; diff is now rendered inline within the tool-item.
3028
+ },
3029
+
2711
3030
  // Append a tool_call as a compact item inside the live tool group.
2712
3031
  // Creates the group if it doesn't exist yet.
2713
3032
  appendToolCall(name, args, summary) {
2714
- const messages = $("messages");
3033
+ const messages = RenderTarget.current();
2715
3034
  if (!Sessions._liveToolGroup) {
2716
3035
  Sessions._liveToolGroup = _makeToolGroup();
2717
3036
  messages.appendChild(Sessions._liveToolGroup);
@@ -2724,7 +3043,6 @@ const Sessions = (() => {
2724
3043
  appendToolResult(result) {
2725
3044
  if (Sessions._liveToolGroup && Sessions._liveLastToolItem) {
2726
3045
  _completeLastToolItem(Sessions._liveToolGroup, result);
2727
- Sessions._liveLastToolItem = null;
2728
3046
  }
2729
3047
  },
2730
3048
 
@@ -2737,7 +3055,7 @@ const Sessions = (() => {
2737
3055
  // .tool-item visible in the DOM — that is the in-flight tool the stdout belongs to.
2738
3056
  let toolItem = Sessions._liveLastToolItem;
2739
3057
  if (!toolItem) {
2740
- const messages = $("messages");
3058
+ const messages = RenderTarget.current();
2741
3059
  if (messages) {
2742
3060
  const items = messages.querySelectorAll(".tool-item");
2743
3061
  if (items.length > 0) toolItem = items[items.length - 1];
@@ -2755,12 +3073,13 @@ const Sessions = (() => {
2755
3073
  _applyStdoutToItem(toolItem, lines);
2756
3074
  },
2757
3075
 
2758
- // Append a token usage line directly to the message list.
2759
- // Server guarantees this event arrives AFTER assistant_message, so no buffering needed.
2760
- // Format mirrors CLI:
2761
- // [Tokens] | +409 | [*] | Input: 69,977 (cache: 69,566 read, 410 write) | Output: 101 | Total: 70,078 | Cost: $0.02392
2762
- appendTokenUsage(ev, container) {
2763
- const messages = container || $("messages");
3076
+ // Append a token usage line. By default it attaches to the most recent
3077
+ // .tool-item (so it visually belongs to that tool); falls back to the
3078
+ // outer message list when no tool-item is available (e.g. plain
3079
+ // assistant turn with no tool calls).
3080
+ appendTokenUsage(ev, container, hostItem) {
3081
+ const messages = container || RenderTarget.current();
3082
+ const host = hostItem || Sessions._liveLastToolItem || null;
2764
3083
  const el = document.createElement("div");
2765
3084
  el.className = "token-usage-line";
2766
3085
 
@@ -2819,8 +3138,13 @@ const Sessions = (() => {
2819
3138
  `<span class="tu-field">Total: <b>${(ev.total_tokens || 0).toLocaleString()}</b></span>` +
2820
3139
  `</span>`;
2821
3140
 
2822
- messages.appendChild(el);
2823
- if (!container) _scrollToBottomIfNeeded(messages); // only auto-scroll for live events
3141
+ el.classList.add(host ? "tu-attached" : "tu-standalone");
3142
+ if (host) {
3143
+ host.appendChild(el);
3144
+ } else {
3145
+ messages.appendChild(el);
3146
+ if (!container) _scrollToBottomIfNeeded(messages);
3147
+ }
2824
3148
  },
2825
3149
 
2826
3150
  // Collapse the live tool group (call when AI starts responding or task ends).
@@ -2836,7 +3160,7 @@ const Sessions = (() => {
2836
3160
  // Starting a new assistant/user/info message: close any open tool group
2837
3161
  if (type !== "tool") Sessions.collapseToolGroup();
2838
3162
 
2839
- const messages = $("messages");
3163
+ const messages = RenderTarget.current();
2840
3164
 
2841
3165
  // For error messages: remove any existing error messages first to avoid duplicates
2842
3166
  if (type === "error") {
@@ -2890,7 +3214,7 @@ const Sessions = (() => {
2890
3214
 
2891
3215
  appendInfo(text, subline) {
2892
3216
  Sessions.collapseToolGroup();
2893
- const messages = $("messages");
3217
+ const messages = RenderTarget.current();
2894
3218
  const el = document.createElement("div");
2895
3219
  el.className = subline ? "msg msg-info msg-info-main" : "msg msg-info";
2896
3220
  el.textContent = text;
@@ -2908,7 +3232,7 @@ const Sessions = (() => {
2908
3232
  // Called when the agent needs user input to continue.
2909
3233
  showFeedbackRequest(question, context, options) {
2910
3234
  Sessions.collapseToolGroup();
2911
- const messages = $("messages");
3235
+ const messages = RenderTarget.current();
2912
3236
  const hasOptions = options && Array.isArray(options) && options.length > 0;
2913
3237
 
2914
3238
  // Normalize bullet symbols to markdown list format so marked renders them as <ul>
@@ -3053,7 +3377,7 @@ const Sessions = (() => {
3053
3377
  // Only attach if this session is currently visible
3054
3378
  if (id !== _activeId) return;
3055
3379
 
3056
- const messages = $("messages");
3380
+ const messages = RenderTarget.outer();
3057
3381
  if (!messages) return;
3058
3382
 
3059
3383
  // Clean up any previous DOM/timer for this session (idempotent)
@@ -3128,7 +3452,7 @@ const Sessions = (() => {
3128
3452
  existing.el.textContent = Sessions._composeProgressLine(existing.displayText, existing.startTime, existing.metadata, existing.lastChunkAt);
3129
3453
  }
3130
3454
  }, 250);
3131
- _scrollToBottomIfNeeded($("messages"));
3455
+ _scrollToBottomIfNeeded(RenderTarget.outer());
3132
3456
  return;
3133
3457
  }
3134
3458
 
@@ -4168,11 +4492,12 @@ const Sessions = (() => {
4168
4492
  // Load tree for a given path
4169
4493
  async function loadTreeForPath(dirPath, absolute = false) {
4170
4494
  treeContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
4495
+ if (dirPath) { pathInput.value = dirPath; selectedPath = dirPath; }
4171
4496
  // Auto-detect absolute mode: path is absolute and outside working directory
4172
- const useAbsolute = absolute || (dirPath.startsWith("/") && (!rootDir || !dirPath.startsWith(rootDir)));
4497
+ const useAbsolute = absolute || (dirPath.startsWith("/") && (!rootDir || !(dirPath === rootDir || dirPath.startsWith(rootDir + "/"))));
4173
4498
  // Convert absolute path to relative path for API
4174
4499
  let relPath = dirPath;
4175
- if (!useAbsolute && rootDir && dirPath.startsWith(rootDir)) {
4500
+ if (!useAbsolute && rootDir && (dirPath === rootDir || dirPath.startsWith(rootDir + "/"))) {
4176
4501
  relPath = dirPath.substring(rootDir.length).replace(/^\/+/, "");
4177
4502
  }
4178
4503
  try {
@@ -4181,7 +4506,7 @@ const Sessions = (() => {
4181
4506
  if (rootDir && presets.children.length === 0) {
4182
4507
  setupPresets();
4183
4508
  // Update pathInput to show absolute path
4184
- if (rootDir !== currentDir && !currentDir.startsWith(rootDir)) {
4509
+ if (rootDir !== currentDir && !(currentDir === rootDir || currentDir.startsWith(rootDir + "/"))) {
4185
4510
  pathInput.value = rootDir;
4186
4511
  selectedPath = rootDir;
4187
4512
  }
@@ -4274,9 +4599,9 @@ const Sessions = (() => {
4274
4599
  }
4275
4600
 
4276
4601
  // Determine if we need absolute mode (path outside working directory)
4277
- const isAbsolute = parentPath.startsWith("/") && (!rootDir || !parentPath.startsWith(rootDir));
4602
+ const isAbsolute = parentPath.startsWith("/") && (!rootDir || !(parentPath === rootDir || parentPath.startsWith(rootDir + "/")));
4278
4603
  let relPath = parentPath;
4279
- if (!isAbsolute && rootDir && parentPath.startsWith(rootDir)) {
4604
+ if (!isAbsolute && rootDir && (parentPath === rootDir || parentPath.startsWith(rootDir + "/"))) {
4280
4605
  relPath = parentPath.substring(rootDir.length).replace(/^\/+/, "");
4281
4606
  }
4282
4607
 
@@ -4308,10 +4633,12 @@ const Sessions = (() => {
4308
4633
  });
4309
4634
 
4310
4635
  // Keyboard navigation in autocomplete
4636
+ const pathIme = IME.track(pathInput);
4311
4637
  pathInput.addEventListener("keydown", (e) => {
4312
4638
  const items = autocomplete.querySelectorAll(".dp-ac-item");
4313
4639
  if (!items.length || autocomplete.style.display === "none") {
4314
4640
  if (e.key === "Enter") {
4641
+ if (pathIme.isComposing(e)) return;
4315
4642
  e.preventDefault();
4316
4643
  // Navigate to typed path without closing modal
4317
4644
  const dir = pathInput.value.trim();
@@ -4335,6 +4662,7 @@ const Sessions = (() => {
4335
4662
  items.forEach((el, i) => el.classList.toggle("active", i === activeIndex));
4336
4663
  items[activeIndex]?.scrollIntoView({ block: "nearest" });
4337
4664
  } else if (e.key === "Enter") {
4665
+ if (pathIme.isComposing(e)) return;
4338
4666
  e.preventDefault();
4339
4667
  if (activeIndex >= 0 && items[activeIndex]) {
4340
4668
  // Select the highlighted suggestion