openclacky 1.2.8 → 1.2.10

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. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/lib/clacky/agent/llm_caller.rb +3 -0
  4. data/lib/clacky/agent/message_compressor_helper.rb +6 -5
  5. data/lib/clacky/agent/session_serializer.rb +4 -0
  6. data/lib/clacky/agent.rb +9 -0
  7. data/lib/clacky/agent_config.rb +111 -8
  8. data/lib/clacky/brand_config.rb +1 -0
  9. data/lib/clacky/cli.rb +49 -22
  10. data/lib/clacky/client.rb +6 -2
  11. data/lib/clacky/default_skills/channel-manager/SKILL.md +33 -110
  12. data/lib/clacky/default_skills/media-gen/SKILL.md +128 -0
  13. data/lib/clacky/idle_compression_timer.rb +38 -15
  14. data/lib/clacky/media/base.rb +68 -0
  15. data/lib/clacky/media/gemini.rb +36 -0
  16. data/lib/clacky/media/generator.rb +78 -0
  17. data/lib/clacky/media/openai_compat.rb +168 -0
  18. data/lib/clacky/providers.rb +89 -2
  19. data/lib/clacky/rich_ui_controller.rb +1549 -0
  20. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +24 -2
  21. data/lib/clacky/server/channel/channel_manager.rb +89 -2
  22. data/lib/clacky/server/http_server.rb +334 -29
  23. data/lib/clacky/session_manager.rb +9 -8
  24. data/lib/clacky/telemetry.rb +26 -6
  25. data/lib/clacky/ui2/layout_manager.rb +11 -7
  26. data/lib/clacky/ui2/ui_controller.rb +2 -2
  27. data/lib/clacky/ui_interface.rb +1 -1
  28. data/lib/clacky/utils/model_pricing.rb +75 -53
  29. data/lib/clacky/version.rb +1 -1
  30. data/lib/clacky/web/app.css +393 -14
  31. data/lib/clacky/web/billing.js +1 -1
  32. data/lib/clacky/web/i18n.js +86 -4
  33. data/lib/clacky/web/index.html +23 -3
  34. data/lib/clacky/web/model-tester.js +58 -0
  35. data/lib/clacky/web/onboard.js +17 -30
  36. data/lib/clacky/web/sessions.js +443 -2
  37. data/lib/clacky/web/settings.js +372 -97
  38. data/lib/clacky/web/workspace.js +9 -1
  39. data/lib/clacky.rb +3 -0
  40. data/scripts/build/lib/network.sh +61 -30
  41. data/scripts/install.ps1 +16 -4
  42. data/scripts/install.sh +61 -30
  43. data/scripts/install_browser.sh +61 -30
  44. data/scripts/install_full.sh +61 -30
  45. data/scripts/install_rails_deps.sh +61 -30
  46. data/scripts/install_system_deps.sh +61 -30
  47. metadata +12 -3
  48. data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +0 -574
@@ -0,0 +1,58 @@
1
+ // Shared helpers for the model config UI flows.
2
+ // Used by both the onboarding wizard and the settings model modal.
3
+ window.ModelTester = (function () {
4
+ // Test a model connection.
5
+ // Returns one of:
6
+ // { ok: true, base_url, message } — connected, no rewrite
7
+ // { ok: true, base_url, message, rewrote: true } — connected, base_url auto-corrected (/v1 appended)
8
+ // { ok: false, message } — failed (server-reported or network)
9
+ async function testConnection({ model, base_url, api_key, anthropic_format, index } = {}) {
10
+ const body = { model, base_url, api_key };
11
+ if (typeof index === "number") body.index = index;
12
+ if (anthropic_format) body.anthropic_format = true;
13
+
14
+ let data;
15
+ try {
16
+ const res = await fetch("/api/config/test", {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify(body)
20
+ });
21
+ data = await res.json();
22
+ } catch (e) {
23
+ return { ok: false, message: e.message };
24
+ }
25
+
26
+ if (!data.ok) return { ok: false, message: data.message || "" };
27
+
28
+ if (data.effective_base_url && data.effective_base_url !== base_url) {
29
+ return { ok: true, base_url: data.effective_base_url, message: data.message || "", rewrote: true };
30
+ }
31
+ return { ok: true, base_url, message: data.message || "" };
32
+ }
33
+
34
+ // Persist a model config (create or update).
35
+ // existingId === null/undefined → POST /api/config/models (create).
36
+ // existingId === string → PATCH /api/config/models/:id (update).
37
+ // Returns { ok: bool, error? }.
38
+ async function saveModel(payload, { existingId } = {}) {
39
+ const url = existingId
40
+ ? `/api/config/models/${encodeURIComponent(existingId)}`
41
+ : "/api/config/models";
42
+ const method = existingId ? "PATCH" : "POST";
43
+
44
+ try {
45
+ const res = await fetch(url, {
46
+ method,
47
+ headers: { "Content-Type": "application/json" },
48
+ body: JSON.stringify(payload)
49
+ });
50
+ const data = await res.json();
51
+ return data.ok ? { ok: true } : { ok: false, error: data.error || "" };
52
+ } catch (e) {
53
+ return { ok: false, error: e.message };
54
+ }
55
+ }
56
+
57
+ return { testConnection, saveModel };
58
+ })();
@@ -417,43 +417,30 @@ const Onboard = (() => {
417
417
  _setResult(null, "");
418
418
 
419
419
  // Step 1: test connection
420
- try {
421
- const res = await fetch("/api/config/test", {
422
- method: "POST",
423
- headers: { "Content-Type": "application/json" },
424
- body: JSON.stringify({ model, base_url: baseUrl, api_key: apiKey, index: 0 })
425
- });
426
- const data = await res.json();
427
- if (!data.ok) {
428
- _setResult(false, data.message || (zh ? "连接失败。" : "Connection failed."));
429
- btn.disabled = false;
430
- btn.textContent = I18n.t("onboard.key.btn.test");
431
- return;
432
- }
433
- } catch (e) {
434
- _setResult(false, e.message);
420
+ const testResult = await ModelTester.testConnection({
421
+ model, base_url: baseUrl, api_key: apiKey, index: 0
422
+ });
423
+
424
+ if (!testResult.ok) {
425
+ _setResult(false, testResult.message || (zh ? "连接失败。" : "Connection failed."));
435
426
  btn.disabled = false;
436
427
  btn.textContent = I18n.t("onboard.key.btn.test");
437
428
  return;
438
429
  }
439
430
 
431
+ let effectiveBaseUrl = testResult.base_url;
432
+ if (testResult.rewrote) {
433
+ const baseInput = document.getElementById("setup-base-url");
434
+ if (baseInput) baseInput.value = effectiveBaseUrl;
435
+ }
436
+
440
437
  // Step 2: save config
441
438
  btn.textContent = I18n.t("onboard.key.saving");
442
- try {
443
- const res = await fetch("/api/config/models", {
444
- method: "POST",
445
- headers: { "Content-Type": "application/json" },
446
- body: JSON.stringify({ type: "default", model, base_url: baseUrl, api_key: apiKey, anthropic_format: false })
447
- });
448
- const data = await res.json();
449
- if (!data.ok) {
450
- _setResult(false, data.error || (zh ? "保存失败。" : "Save failed."));
451
- btn.disabled = false;
452
- btn.textContent = I18n.t("onboard.key.btn.test");
453
- return;
454
- }
455
- } catch (e) {
456
- _setResult(false, e.message);
439
+ const saveResult = await ModelTester.saveModel({
440
+ type: "default", model, base_url: effectiveBaseUrl, api_key: apiKey
441
+ });
442
+ if (!saveResult.ok) {
443
+ _setResult(false, saveResult.error || (zh ? "保存失败。" : "Save failed."));
457
444
  btn.disabled = false;
458
445
  btn.textContent = I18n.t("onboard.key.btn.test");
459
446
  return;
@@ -3804,6 +3804,448 @@ const Sessions = (() => {
3804
3804
 
3805
3805
  // ── Session Info Bar Working Directory Switcher ───────────────────────────
3806
3806
  (function() {
3807
+ // Directory picker with predefined list
3808
+ // ── Tree-based directory picker ─────────────────────────────────────────
3809
+ const ICON_FOLDER_SVG = '<svg xmlns="http://www.w3.org/2000/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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
3810
+ const ICON_CARET_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
3811
+ function showDirectoryPicker(currentDir, sessionId) {
3812
+ return new Promise((resolve) => {
3813
+ const t = (key, fallback) => {
3814
+ const s = I18n.t(key);
3815
+ return (s && s !== key) ? s : fallback;
3816
+ };
3817
+
3818
+ let selectedPath = currentDir;
3819
+ let rootDir = ""; // absolute path of the session's working directory
3820
+
3821
+ // Fetch directory entries from API, returns dirs with absolute paths
3822
+ async function fetchDirs(relPath, absolute = false) {
3823
+ let url = `/api/sessions/${encodeURIComponent(sessionId)}/files?path=${encodeURIComponent(relPath || "")}`;
3824
+ if (absolute) url += "&absolute=true";
3825
+ const resp = await fetch(url);
3826
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
3827
+ const data = await resp.json();
3828
+ // Only update rootDir in relative mode; absolute mode would overwrite it with "/"
3829
+ if (!absolute) rootDir = data.root || rootDir;
3830
+ const dirs = (data.entries || []).filter(e => e.type === "dir");
3831
+ // Convert relative paths to absolute
3832
+ dirs.forEach(d => {
3833
+ // Strip leading slashes from path to avoid double slashes
3834
+ const cleanPath = d.path.replace(/^\/+/, "");
3835
+ d.absPath = absolute ? ("/" + cleanPath) : (rootDir.replace(/\/+$/, "") + "/" + cleanPath);
3836
+ d.absolute = absolute; // Store absolute flag for child expansion
3837
+ }); return dirs;
3838
+ }
3839
+
3840
+ // Build a tree node for a directory entry
3841
+ function buildDirNode(entry, depth) {
3842
+ const node = document.createElement("div");
3843
+ node.className = "dp-node";
3844
+ node.dataset.depth = depth; // Store depth for child expansion
3845
+
3846
+ const row = document.createElement("div");
3847
+ row.className = "dp-row";
3848
+ row.style.paddingLeft = `${depth * 16 + 8}px`;
3849
+ const caret = document.createElement("span");
3850
+ caret.className = "dp-caret";
3851
+ caret.innerHTML = ICON_CARET_SVG;
3852
+
3853
+ const icon = document.createElement("span");
3854
+ icon.className = "dp-icon";
3855
+ icon.innerHTML = ICON_FOLDER_SVG;
3856
+
3857
+ const name = document.createElement("span");
3858
+ name.className = "dp-name";
3859
+ name.textContent = entry.name;
3860
+
3861
+ row.appendChild(caret);
3862
+ row.appendChild(icon);
3863
+ row.appendChild(name);
3864
+ node.appendChild(row);
3865
+
3866
+ const children = document.createElement("div");
3867
+ children.className = "dp-children";
3868
+ children.style.display = "none";
3869
+ node.appendChild(children);
3870
+
3871
+ // Single-click: select directory (show path) + expand/collapse
3872
+ let clickTimer = null;
3873
+ row.addEventListener("click", (e) => {
3874
+ e.stopPropagation();
3875
+ if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
3876
+ clickTimer = setTimeout(() => {
3877
+ clickTimer = null;
3878
+ // Select this directory
3879
+ modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
3880
+ row.classList.add("selected");
3881
+ selectedPath = entry.absPath;
3882
+ pathInput.value = entry.absPath;
3883
+ // Also expand/collapse
3884
+ toggleExpand(entry, caret, children);
3885
+ }, 250);
3886
+ });
3887
+
3888
+ // Double-click: enter directory (navigate into it)
3889
+ row.addEventListener("dblclick", (e) => {
3890
+ e.stopPropagation();
3891
+ if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
3892
+ // Enter this directory - reload tree with this as root
3893
+ loadTreeForPath(entry.absPath, entry.absolute);
3894
+ });
3895
+ return node;
3896
+ }
3897
+
3898
+ async function toggleExpand(entry, caret, children) {
3899
+ const isOpen = caret.classList.contains("open");
3900
+ if (isOpen) {
3901
+ caret.classList.remove("open");
3902
+ children.style.display = "none";
3903
+ return;
3904
+ }
3905
+ caret.classList.add("open");
3906
+ children.style.display = "flex";
3907
+ if (children.dataset.loaded === "1") return;
3908
+ children.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
3909
+ try {
3910
+ const dirs = await fetchDirs(entry.path, entry.absolute);
3911
+ children.innerHTML = "";
3912
+ if (dirs.length === 0) {
3913
+ children.innerHTML = `<div class="dp-empty">${t("sib.dir.empty", "空目录")}</div>`;
3914
+ } else {
3915
+ const parentNode = children.parentElement;
3916
+ const parentDepth = parseInt(parentNode?.dataset?.depth) || 0;
3917
+ const childDepth = parentDepth + 1;
3918
+ const frag = document.createDocumentFragment();
3919
+ dirs.forEach(d => frag.appendChild(buildDirNode(d, childDepth)));
3920
+ children.appendChild(frag);
3921
+ } children.dataset.loaded = "1";
3922
+ } catch (err) {
3923
+ console.error("dir picker load failed:", err);
3924
+ children.innerHTML = `<div class="dp-error">${t("sib.dir.loadError", "加载失败")}</div>`;
3925
+ }
3926
+ }
3927
+
3928
+ // Create modal overlay
3929
+ const overlay = document.createElement("div");
3930
+ overlay.className = "modal-overlay";
3931
+
3932
+ // Create modal content
3933
+ const modal = document.createElement("div");
3934
+ modal.className = "modal-content";
3935
+ modal.style.maxWidth = "520px";
3936
+ modal.style.maxHeight = "80vh";
3937
+ modal.style.display = "flex";
3938
+ modal.style.flexDirection = "column";
3939
+
3940
+ // Title
3941
+ const title = document.createElement("div");
3942
+ title.className = "modal-title";
3943
+ title.textContent = t("sib.dir.changePrompt", "切换工作目录");
3944
+ modal.appendChild(title);
3945
+
3946
+ // Modal body
3947
+ const body = document.createElement("div");
3948
+ body.className = "modal-body";
3949
+ body.style.flex = "1";
3950
+ body.style.overflow = "hidden";
3951
+ body.style.display = "flex";
3952
+ body.style.flexDirection = "column";
3953
+ body.style.gap = "8px";
3954
+
3955
+ // Quick presets (will be populated with absolute paths after first API call)
3956
+ const presets = document.createElement("div");
3957
+ presets.className = "dp-presets";
3958
+ body.appendChild(presets);
3959
+
3960
+ function setupPresets() {
3961
+ presets.innerHTML = "";
3962
+ const presetDirs = [
3963
+ { value: rootDir, text: t("sib.dir.current", "当前工作目录"), absolute: false },
3964
+ { value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
3965
+ ]; presetDirs.forEach(p => {
3966
+ const btn = document.createElement("button");
3967
+ btn.className = "btn btn-secondary btn-sm";
3968
+ btn.textContent = p.text;
3969
+ btn.addEventListener("click", () => {
3970
+ selectedPath = p.value;
3971
+ pathInput.value = p.value;
3972
+ modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
3973
+ loadTreeForPath(p.value, p.absolute);
3974
+ });
3975
+ presets.appendChild(btn);
3976
+ });
3977
+ }
3978
+
3979
+ // Path input
3980
+ const pathContainer = document.createElement("div");
3981
+ pathContainer.className = "dp-path-container";
3982
+ const pathInput = document.createElement("input");
3983
+ pathInput.type = "text";
3984
+ pathInput.className = "dir-picker-input";
3985
+ pathInput.value = currentDir;
3986
+ pathInput.placeholder = t("sib.dir.inputPlaceholder", "输入或选择目录路径");
3987
+ pathContainer.appendChild(pathInput);
3988
+
3989
+ // Autocomplete dropdown
3990
+ const autocomplete = document.createElement("div");
3991
+ autocomplete.className = "dp-autocomplete";
3992
+ autocomplete.style.display = "none";
3993
+ pathContainer.appendChild(autocomplete);
3994
+
3995
+ body.appendChild(pathContainer);
3996
+ // Tree container
3997
+ const treeContainer = document.createElement("div");
3998
+ treeContainer.className = "dp-tree";
3999
+ treeContainer.style.flex = "1";
4000
+ treeContainer.style.overflow = "auto";
4001
+ treeContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
4002
+ body.appendChild(treeContainer);
4003
+
4004
+ modal.appendChild(body);
4005
+
4006
+ // Buttons
4007
+ const buttonContainer = document.createElement("div");
4008
+ buttonContainer.className = "modal-buttons";
4009
+
4010
+ const cancelButton = document.createElement("button");
4011
+ cancelButton.className = "btn btn-secondary";
4012
+ cancelButton.textContent = t("sib.dir.cancel", "取消");
4013
+ cancelButton.onclick = () => {
4014
+ overlay.remove();
4015
+ resolve(null);
4016
+ };
4017
+
4018
+ const confirmButton = document.createElement("button");
4019
+ confirmButton.className = "btn btn-primary";
4020
+ confirmButton.textContent = t("sib.dir.confirm", "确认");
4021
+ confirmButton.onclick = () => {
4022
+ const dir = pathInput.value.trim();
4023
+ overlay.remove();
4024
+ resolve(dir || null);
4025
+ };
4026
+
4027
+ buttonContainer.appendChild(cancelButton);
4028
+ buttonContainer.appendChild(confirmButton);
4029
+ modal.appendChild(buttonContainer);
4030
+
4031
+ overlay.appendChild(modal);
4032
+ document.body.appendChild(overlay);
4033
+
4034
+ // Sync pathInput changes to selectedPath
4035
+ pathInput.addEventListener("input", () => {
4036
+ selectedPath = pathInput.value;
4037
+ modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
4038
+ });
4039
+
4040
+ // Load tree for a given path
4041
+ async function loadTreeForPath(dirPath, absolute = false) {
4042
+ treeContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
4043
+ // Auto-detect absolute mode: path is absolute and outside working directory
4044
+ const useAbsolute = absolute || (dirPath.startsWith("/") && (!rootDir || !dirPath.startsWith(rootDir)));
4045
+ // Convert absolute path to relative path for API
4046
+ let relPath = dirPath;
4047
+ if (!useAbsolute && rootDir && dirPath.startsWith(rootDir)) {
4048
+ relPath = dirPath.substring(rootDir.length).replace(/^\/+/, "");
4049
+ }
4050
+ try {
4051
+ const dirs = await fetchDirs(relPath, useAbsolute);
4052
+ // Update presets and pathInput with absolute paths after first API call
4053
+ if (rootDir && presets.children.length === 0) {
4054
+ setupPresets();
4055
+ // Update pathInput to show absolute path
4056
+ if (rootDir !== currentDir && !currentDir.startsWith(rootDir)) {
4057
+ pathInput.value = rootDir;
4058
+ selectedPath = rootDir;
4059
+ }
4060
+ }
4061
+ treeContainer.innerHTML = "";
4062
+ if (dirs.length === 0) {
4063
+ treeContainer.innerHTML = `<div class="dp-empty">${t("sib.dir.empty", "空目录")}</div>`;
4064
+ } else {
4065
+ const frag = document.createDocumentFragment();
4066
+ dirs.forEach(d => frag.appendChild(buildDirNode(d, 0)));
4067
+ treeContainer.appendChild(frag);
4068
+ }
4069
+ } catch (err) {
4070
+ console.error("dir picker load failed:", err);
4071
+ treeContainer.innerHTML = `<div class="dp-error">${t("sib.dir.loadError", "加载失败")}</div>`;
4072
+ }
4073
+ }
4074
+ loadTreeForPath("");
4075
+
4076
+ // ── Autocomplete logic ──────────────────────────────────────────────
4077
+ let autocompleteTimer = null;
4078
+ let activeIndex = -1;
4079
+
4080
+ function hideAutocomplete() {
4081
+ autocomplete.style.display = "none";
4082
+ autocomplete.innerHTML = "";
4083
+ activeIndex = -1;
4084
+ }
4085
+
4086
+ function showAutocomplete(items) {
4087
+ autocomplete.innerHTML = "";
4088
+ if (!items.length) { hideAutocomplete(); return; }
4089
+
4090
+ items.forEach((item, i) => {
4091
+ const row = document.createElement("div");
4092
+ row.className = "dp-ac-item";
4093
+ if (i === activeIndex) row.classList.add("active");
4094
+
4095
+ const icon = document.createElement("span");
4096
+ icon.className = "dp-ac-icon";
4097
+ icon.innerHTML = ICON_FOLDER_SVG;
4098
+
4099
+ const name = document.createElement("span");
4100
+ name.className = "dp-ac-name";
4101
+ name.textContent = item.name;
4102
+
4103
+ row.appendChild(icon);
4104
+ row.appendChild(name);
4105
+
4106
+ row.addEventListener("mousedown", (e) => {
4107
+ e.preventDefault(); // prevent blur
4108
+ // Construct full path: keep parent path from input, append selected item name
4109
+ const inputVal = pathInput.value;
4110
+ const lastSlash = inputVal.lastIndexOf("/");
4111
+ const fullPath = lastSlash >= 0
4112
+ ? inputVal.substring(0, lastSlash + 1) + item.name
4113
+ : item.name;
4114
+ pathInput.value = fullPath;
4115
+ selectedPath = fullPath;
4116
+ hideAutocomplete();
4117
+ loadTreeForPath(fullPath);
4118
+ });
4119
+ row.addEventListener("mouseenter", () => {
4120
+ activeIndex = i;
4121
+ autocomplete.querySelectorAll(".dp-ac-item").forEach((el, j) => {
4122
+ el.classList.toggle("active", j === i);
4123
+ });
4124
+ });
4125
+
4126
+ autocomplete.appendChild(row);
4127
+ });
4128
+ autocomplete.style.display = "";
4129
+ }
4130
+
4131
+ async function fetchSuggestions(inputVal) {
4132
+ if (!inputVal) { hideAutocomplete(); return; }
4133
+
4134
+ // Determine parent dir and prefix
4135
+ const lastSlash = inputVal.lastIndexOf("/");
4136
+ let parentPath, prefix;
4137
+ if (lastSlash > 0) {
4138
+ parentPath = inputVal.substring(0, lastSlash);
4139
+ prefix = inputVal.substring(lastSlash + 1).toLowerCase();
4140
+ } else if (lastSlash === 0) {
4141
+ parentPath = "/";
4142
+ prefix = inputVal.substring(1).toLowerCase();
4143
+ } else {
4144
+ parentPath = "";
4145
+ prefix = inputVal.toLowerCase();
4146
+ }
4147
+
4148
+ // Determine if we need absolute mode (path outside working directory)
4149
+ const isAbsolute = parentPath.startsWith("/") && (!rootDir || !parentPath.startsWith(rootDir));
4150
+ let relPath = parentPath;
4151
+ if (!isAbsolute && rootDir && parentPath.startsWith(rootDir)) {
4152
+ relPath = parentPath.substring(rootDir.length).replace(/^\/+/, "");
4153
+ }
4154
+
4155
+ try {
4156
+ const dirs = await fetchDirs(relPath, isAbsolute);
4157
+ const filtered = prefix
4158
+ ? dirs.filter(d => d.name.toLowerCase().startsWith(prefix))
4159
+ : dirs;
4160
+ showAutocomplete(filtered.slice(0, 15)); // limit to 15 items
4161
+ } catch (_) {
4162
+ hideAutocomplete();
4163
+ }
4164
+ }
4165
+
4166
+ pathInput.addEventListener("input", () => {
4167
+ selectedPath = pathInput.value;
4168
+ modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
4169
+ clearTimeout(autocompleteTimer);
4170
+ autocompleteTimer = setTimeout(() => fetchSuggestions(pathInput.value.trim()), 200);
4171
+ });
4172
+
4173
+ pathInput.addEventListener("blur", () => {
4174
+ // Delay to allow mousedown on suggestion
4175
+ setTimeout(hideAutocomplete, 150);
4176
+ });
4177
+
4178
+ pathInput.addEventListener("focus", () => {
4179
+ if (pathInput.value.trim()) fetchSuggestions(pathInput.value.trim());
4180
+ });
4181
+
4182
+ // Keyboard navigation in autocomplete
4183
+ pathInput.addEventListener("keydown", (e) => {
4184
+ const items = autocomplete.querySelectorAll(".dp-ac-item");
4185
+ if (!items.length || autocomplete.style.display === "none") {
4186
+ if (e.key === "Enter") {
4187
+ e.preventDefault();
4188
+ // Navigate to typed path without closing modal
4189
+ const dir = pathInput.value.trim();
4190
+ if (dir) {
4191
+ selectedPath = dir;
4192
+ hideAutocomplete();
4193
+ loadTreeForPath(dir);
4194
+ }
4195
+ }
4196
+ return;
4197
+ }
4198
+
4199
+ if (e.key === "ArrowDown") {
4200
+ e.preventDefault();
4201
+ activeIndex = Math.min(activeIndex + 1, items.length - 1);
4202
+ items.forEach((el, i) => el.classList.toggle("active", i === activeIndex));
4203
+ items[activeIndex]?.scrollIntoView({ block: "nearest" });
4204
+ } else if (e.key === "ArrowUp") {
4205
+ e.preventDefault();
4206
+ activeIndex = Math.max(activeIndex - 1, 0);
4207
+ items.forEach((el, i) => el.classList.toggle("active", i === activeIndex));
4208
+ items[activeIndex]?.scrollIntoView({ block: "nearest" });
4209
+ } else if (e.key === "Enter") {
4210
+ e.preventDefault();
4211
+ if (activeIndex >= 0 && items[activeIndex]) {
4212
+ // Select the highlighted suggestion
4213
+ const evt = new MouseEvent("mousedown", { bubbles: true });
4214
+ items[activeIndex].dispatchEvent(evt);
4215
+ } else {
4216
+ // Navigate to typed path without closing modal
4217
+ const dir = pathInput.value.trim();
4218
+ if (dir) {
4219
+ selectedPath = dir;
4220
+ hideAutocomplete();
4221
+ loadTreeForPath(dir);
4222
+ }
4223
+ }
4224
+ } else if (e.key === "Escape") {
4225
+ hideAutocomplete();
4226
+ }
4227
+ });
4228
+
4229
+ overlay.addEventListener("keydown", (e) => {
4230
+ if (e.key === "Escape") {
4231
+ if (autocomplete.style.display !== "none") {
4232
+ hideAutocomplete();
4233
+ } else {
4234
+ cancelButton.click();
4235
+ }
4236
+ }
4237
+ });
4238
+
4239
+ overlay.addEventListener("click", (e) => {
4240
+ if (e.target === overlay) cancelButton.click();
4241
+ });
4242
+
4243
+ // Focus path input
4244
+ pathInput.focus();
4245
+ pathInput.select();
4246
+ });
4247
+ }
4248
+
3807
4249
  // Handle click on working directory
3808
4250
  document.addEventListener("click", async (e) => {
3809
4251
  const dirEl = e.target.closest("#sib-dir");
@@ -3812,12 +4254,11 @@ const Sessions = (() => {
3812
4254
  const sessionId = dirEl.dataset.sessionId;
3813
4255
  const currentDir = dirEl.dataset.workingDir || dirEl.textContent;
3814
4256
 
3815
- const newDir = await Modal.prompt(I18n.t("sib.dir.changePrompt"), currentDir);
4257
+ const newDir = await showDirectoryPicker(currentDir, sessionId);
3816
4258
  if (newDir && newDir !== currentDir) {
3817
4259
  _changeWorkingDirectory(sessionId, newDir);
3818
4260
  }
3819
4261
  }
3820
-
3821
4262
  // Handle click on session ID — toggles a small actions dropdown with
3822
4263
  // items like "Download session files (for debugging)". Designed to be
3823
4264
  // extensible (more session-level actions can be added here later).