openclacky 1.5.11 → 1.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/lib/clacky/access_key.rb +59 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -0
- data/lib/clacky/agent/message_compressor_helper.rb +53 -2
- data/lib/clacky/agent/session_serializer.rb +72 -19
- data/lib/clacky/agent/time_machine.rb +10 -1
- data/lib/clacky/agent.rb +107 -26
- data/lib/clacky/agent_config.rb +40 -0
- data/lib/clacky/agent_profile.rb +5 -5
- data/lib/clacky/billing/billing_record.rb +3 -0
- data/lib/clacky/billing/billing_store.rb +31 -5
- data/lib/clacky/billing/platform_billing.rb +231 -0
- data/lib/clacky/brand_config.rb +4 -3
- data/lib/clacky/cancel_flag.rb +23 -0
- data/lib/clacky/cli.rb +4 -2
- data/lib/clacky/client.rb +107 -0
- data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
- data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
- data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
- data/lib/clacky/default_extensions/git/ext.yml +1 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
- data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
- data/lib/clacky/extension/packager.rb +4 -2
- data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
- data/lib/clacky/extension/verifier.rb +14 -1
- data/lib/clacky/fanout.rb +49 -2
- data/lib/clacky/json_ui_controller.rb +3 -2
- data/lib/clacky/message_format/open_ai.rb +5 -1
- data/lib/clacky/message_format/open_ai_responses.rb +409 -0
- data/lib/clacky/message_history.rb +43 -1
- data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
- data/lib/clacky/plain_ui_controller.rb +15 -5
- data/lib/clacky/providers.rb +24 -7
- data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
- data/lib/clacky/server/channel/channel_manager.rb +93 -18
- data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
- data/lib/clacky/server/dir_picker.rb +154 -0
- data/lib/clacky/server/git_panel.rb +61 -9
- data/lib/clacky/server/http_server.rb +366 -42
- data/lib/clacky/server/web_ui_controller.rb +7 -5
- data/lib/clacky/session_manager.rb +74 -17
- data/lib/clacky/tools/ask_user.rb +3 -2
- data/lib/clacky/tools/terminal.rb +30 -3
- data/lib/clacky/ui2/markdown_renderer.rb +15 -12
- data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +2 -2
- data/lib/clacky/utils/model_pricing.rb +92 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +915 -142
- data/lib/clacky/web/components/code-editor.js +33 -1
- data/lib/clacky/web/components/composer.js +315 -0
- data/lib/clacky/web/components/custom-select.js +136 -0
- data/lib/clacky/web/components/mentions.js +655 -0
- data/lib/clacky/web/components/model-picker.js +527 -0
- data/lib/clacky/web/components/onboard.js +29 -55
- data/lib/clacky/web/core/aside.js +3 -3
- data/lib/clacky/web/core/ext.js +38 -0
- data/lib/clacky/web/features/billing/store.js +2 -6
- data/lib/clacky/web/features/billing/view.js +32 -16
- data/lib/clacky/web/features/extensions/view.js +22 -10
- data/lib/clacky/web/features/new-session/store.js +15 -1
- data/lib/clacky/web/features/new-session/view.js +80 -34
- data/lib/clacky/web/features/trash/view.js +31 -19
- data/lib/clacky/web/features/workspace/store.js +1 -0
- data/lib/clacky/web/features/workspace/view.js +766 -80
- data/lib/clacky/web/i18n.js +107 -17
- data/lib/clacky/web/index.html +125 -45
- data/lib/clacky/web/sessions.js +704 -635
- data/lib/clacky/web/settings.js +164 -190
- data/lib/clacky/web/skills.js +11 -17
- data/lib/clacky/web/ws-dispatcher.js +11 -33
- data/lib/clacky.rb +4 -0
- metadata +12 -1
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
// ── MentionAC — @ mention autocomplete for file/directory/session references ─
|
|
2
|
+
// Cursor-style inline menu above the composer. Two top-level kinds:
|
|
3
|
+
// - Files & Folders → directory drill-down (pick a file or a whole directory)
|
|
4
|
+
// - Past Chats → session list (pick a conversation to reference)
|
|
5
|
+
// Selection removes the typed "@" and inserts a chip via Composer.insertChip;
|
|
6
|
+
// send-time extraction is handled by the caller (sessions.js / new-session view).
|
|
7
|
+
//
|
|
8
|
+
// Multi-composer: attach() wires a second composer (new-session page) exactly
|
|
9
|
+
// like SkillAC.attach(). The caller supplies element ids and data resolvers.
|
|
10
|
+
//
|
|
11
|
+
// IMPORTANT ordering: attach() must run BEFORE Composer.init() and SkillAC so
|
|
12
|
+
// its keydown consumes @ / menu navigation first (it uses stopImmediatePropagation
|
|
13
|
+
// to keep Enter from being treated as "send").
|
|
14
|
+
//
|
|
15
|
+
// Depends on: Composer, global $ / I18n (optional). Loaded after ext.js.
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
17
|
+
"use strict";
|
|
18
|
+
|
|
19
|
+
const MentionAC = (() => {
|
|
20
|
+
let _cfg = null;
|
|
21
|
+
let _visible = false;
|
|
22
|
+
let _mode = "root"; // "root" | "files" | "chats"
|
|
23
|
+
let _items = [];
|
|
24
|
+
let _activeIndex = -1;
|
|
25
|
+
let _relPath = ""; // relative path under working dir (files mode)
|
|
26
|
+
let _atNode = null; // DOM node holding the "@" while the menu is open
|
|
27
|
+
let _keyboardNav = false; // suppress mouse hover while navigating with arrows
|
|
28
|
+
let _mouseBound = false;
|
|
29
|
+
let _chatsCursor = null; // `before` cursor for Past Chats pagination
|
|
30
|
+
let _chatsHasMore = false;
|
|
31
|
+
let _chatsLoading = false;
|
|
32
|
+
let _chatsReq = 0; // request token to ignore stale fetches
|
|
33
|
+
|
|
34
|
+
const _menuEl = () => (_cfg ? document.getElementById(_cfg.menu) : null);
|
|
35
|
+
const _inputEl = () => (_cfg ? document.getElementById(_cfg.input) : null);
|
|
36
|
+
|
|
37
|
+
function _label(zh, en) {
|
|
38
|
+
return (typeof I18n !== "undefined" && I18n.lang && I18n.lang() === "zh") ? zh : en;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── data ───────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
// List one directory level. Chat sessions use the session-scoped endpoint
|
|
44
|
+
// (relative paths); the new-session page uses /api/dirs (absolute paths).
|
|
45
|
+
async function _fetchFiles() {
|
|
46
|
+
const sid = _cfg.getSessionId ? _cfg.getSessionId() : null;
|
|
47
|
+
const wd = ((_cfg.getWorkingDir ? _cfg.getWorkingDir() : "") || "").replace(/\/+$/, "");
|
|
48
|
+
if (sid) {
|
|
49
|
+
const url = `/api/sessions/${encodeURIComponent(sid)}/files?path=${encodeURIComponent(_relPath || "")}`;
|
|
50
|
+
const res = await fetch(url);
|
|
51
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
return (data.entries || []).map((e) => ({
|
|
54
|
+
name: e.name, type: e.type, size: e.size,
|
|
55
|
+
absPath: wd + "/" + (e.path || "").replace(/^\/+|\/+$/g, ""),
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
const url = `/api/dirs?path=${encodeURIComponent(wd + "/" + (_relPath || ""))}&files=true`;
|
|
59
|
+
const res = await fetch(url);
|
|
60
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
61
|
+
const data = await res.json();
|
|
62
|
+
return (data.entries || []).map((e) => ({
|
|
63
|
+
name: e.name, type: e.type, size: e.size, absPath: e.path,
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function _fetchSessions(before) {
|
|
68
|
+
// Cron/scheduled-task sessions are noise here — exclude them from references.
|
|
69
|
+
const params = new URLSearchParams({ limit: "20", exclude_type: "cron" });
|
|
70
|
+
if (before) params.set("before", before);
|
|
71
|
+
const url = `/api/sessions?${params}`;
|
|
72
|
+
const res = await fetch(url);
|
|
73
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
74
|
+
return res.json(); // { sessions, has_more }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── render helpers ─────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
function _el(tag, className, text) {
|
|
80
|
+
const node = document.createElement(tag);
|
|
81
|
+
if (className) node.className = className;
|
|
82
|
+
if (text != null) node.textContent = text;
|
|
83
|
+
return node;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Lucide-style icons (24×24 stroke), matching the inline SVGs used across
|
|
87
|
+
// the rest of the UI (feather/lucide stroke="currentColor" 2px round caps).
|
|
88
|
+
const ICONS = {
|
|
89
|
+
folder: "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",
|
|
90
|
+
file: ["M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z", "M14 2v4a2 2 0 0 0 2 2h4"],
|
|
91
|
+
message: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z",
|
|
92
|
+
chevronRight: "m9 18 6-6-6-6",
|
|
93
|
+
chevronLeft: "m15 18-6-6 6-6",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function _svg(paths, size) {
|
|
97
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
98
|
+
svg.setAttribute("width", size || 16);
|
|
99
|
+
svg.setAttribute("height", size || 16);
|
|
100
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
101
|
+
svg.setAttribute("fill", "none");
|
|
102
|
+
svg.setAttribute("stroke", "currentColor");
|
|
103
|
+
svg.setAttribute("stroke-width", "2");
|
|
104
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
105
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
106
|
+
svg.setAttribute("aria-hidden", "true");
|
|
107
|
+
(Array.isArray(paths) ? paths : [paths]).forEach((d) => {
|
|
108
|
+
const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
109
|
+
p.setAttribute("d", d);
|
|
110
|
+
svg.appendChild(p);
|
|
111
|
+
});
|
|
112
|
+
return svg;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// HTML-string variant of _svg for innerHTML-rendered badges (message bubbles).
|
|
116
|
+
function svgHtml(paths, size) {
|
|
117
|
+
const sz = size || 16;
|
|
118
|
+
const ds = (Array.isArray(paths) ? paths : [paths]).map((d) => `<path d="${d}"/>`).join("");
|
|
119
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${sz}" height="${sz}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ds}</svg>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function _ico(className, iconPaths) {
|
|
123
|
+
const span = _el("span", className, "");
|
|
124
|
+
span.appendChild(_svg(iconPaths, 16));
|
|
125
|
+
return span;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function _header(menu, backLabel, title) {
|
|
129
|
+
const bar = _el("div", "mention-header");
|
|
130
|
+
if (backLabel) {
|
|
131
|
+
const back = _el("button", "mention-back", "");
|
|
132
|
+
back.type = "button";
|
|
133
|
+
back.appendChild(_svg(ICONS.chevronLeft, 16));
|
|
134
|
+
back.appendChild(_el("span", "mention-back-label", backLabel));
|
|
135
|
+
back.addEventListener("mousedown", (e) => e.preventDefault());
|
|
136
|
+
back.addEventListener("click", () => _goBack());
|
|
137
|
+
bar.appendChild(back);
|
|
138
|
+
}
|
|
139
|
+
bar.appendChild(_el("span", "mention-title", title));
|
|
140
|
+
menu.appendChild(bar);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Scroll an item into the menu's own list container (never the page).
|
|
144
|
+
// Uses offsetTop (relative to the .mention-list, which is position:relative)
|
|
145
|
+
// so it is immune to page scroll and viewport coordinates.
|
|
146
|
+
function _scrollTo(node) {
|
|
147
|
+
const list = node.closest(".mention-list");
|
|
148
|
+
if (!list) return;
|
|
149
|
+
const top = node.offsetTop;
|
|
150
|
+
const bottom = top + node.offsetHeight;
|
|
151
|
+
const viewTop = list.scrollTop;
|
|
152
|
+
const viewBottom = list.scrollTop + list.clientHeight;
|
|
153
|
+
if (top < viewTop) list.scrollTop = top;
|
|
154
|
+
else if (bottom > viewBottom) list.scrollTop = bottom - list.clientHeight;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function _setActive(idx) {
|
|
158
|
+
_activeIndex = idx;
|
|
159
|
+
const menu = _menuEl();
|
|
160
|
+
if (!menu) return;
|
|
161
|
+
menu.querySelectorAll(".mention-item").forEach((n, i) => {
|
|
162
|
+
n.classList.toggle("active", i === idx);
|
|
163
|
+
if (i === idx) _scrollTo(n);
|
|
164
|
+
});
|
|
165
|
+
// Past Chats: the trailing "load more" button is also keyboard-reachable.
|
|
166
|
+
const more = menu.querySelector(".mention-load-more");
|
|
167
|
+
if (more) {
|
|
168
|
+
const isActive = _mode === "chats" && idx === _items.length;
|
|
169
|
+
more.classList.toggle("active", isActive);
|
|
170
|
+
if (isActive) _scrollTo(more);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Mouse hover sets the active item only when the user isn't navigating with
|
|
175
|
+
// the arrow keys (list scrolling can fire spurious mouseenter events).
|
|
176
|
+
function _hover(idx) {
|
|
177
|
+
if (!_keyboardNav) _setActive(idx);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _renderRoot() {
|
|
181
|
+
const menu = _menuEl();
|
|
182
|
+
menu.innerHTML = "";
|
|
183
|
+
_items = [{ kind: "files" }, { kind: "chats" }];
|
|
184
|
+
|
|
185
|
+
const files = _el("div", "mention-item", "");
|
|
186
|
+
files.appendChild(_ico("mention-ico", ICONS.folder));
|
|
187
|
+
files.appendChild(_el("span", "mention-name", _label("文件和文件夹", "Files & Folders")));
|
|
188
|
+
files.addEventListener("mousedown", (e) => e.preventDefault());
|
|
189
|
+
files.addEventListener("mouseenter", () => _hover(0));
|
|
190
|
+
files.addEventListener("click", () => _enter("files"));
|
|
191
|
+
menu.appendChild(files);
|
|
192
|
+
|
|
193
|
+
const chats = _el("div", "mention-item", "");
|
|
194
|
+
chats.appendChild(_ico("mention-ico", ICONS.message));
|
|
195
|
+
chats.appendChild(_el("span", "mention-name", _label("历史会话", "Past Chats")));
|
|
196
|
+
chats.addEventListener("mousedown", (e) => e.preventDefault());
|
|
197
|
+
chats.addEventListener("mouseenter", () => _hover(1));
|
|
198
|
+
chats.addEventListener("click", () => _enter("chats"));
|
|
199
|
+
menu.appendChild(chats);
|
|
200
|
+
|
|
201
|
+
_setActive(0);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function _renderFiles() {
|
|
205
|
+
const menu = _menuEl();
|
|
206
|
+
menu.innerHTML = "";
|
|
207
|
+
_header(menu, _label("返回", "Back"), _relPath || _label("工作目录", "Workspace"));
|
|
208
|
+
const listWrap = _el("div", "mention-list", "");
|
|
209
|
+
menu.appendChild(listWrap);
|
|
210
|
+
|
|
211
|
+
_fetchFiles().then((entries) => {
|
|
212
|
+
if (_mode !== "files") return; // user navigated away mid-fetch
|
|
213
|
+
_items = entries;
|
|
214
|
+
if (entries.length === 0) {
|
|
215
|
+
listWrap.appendChild(_el("div", "mention-empty", _label("空目录", "Empty directory")));
|
|
216
|
+
_activeIndex = -1;
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
entries.forEach((entry, idx) => {
|
|
220
|
+
const isDir = entry.type === "dir";
|
|
221
|
+
const item = _el("div", "mention-item", "");
|
|
222
|
+
|
|
223
|
+
const main = _el("span", "mention-item-main", "");
|
|
224
|
+
main.appendChild(_ico("mention-ico", isDir ? ICONS.folder : ICONS.file));
|
|
225
|
+
main.appendChild(_el("span", "mention-name", entry.name));
|
|
226
|
+
if (!isDir && entry.size != null) {
|
|
227
|
+
main.appendChild(_el("span", "mention-size", _fmtSize(entry.size)));
|
|
228
|
+
}
|
|
229
|
+
main.addEventListener("mousedown", (e) => e.preventDefault());
|
|
230
|
+
main.addEventListener("click", () => _pickEntry(entry, isDir));
|
|
231
|
+
item.appendChild(main);
|
|
232
|
+
|
|
233
|
+
if (isDir) {
|
|
234
|
+
const chevron = _ico("mention-chevron", ICONS.chevronRight);
|
|
235
|
+
chevron.addEventListener("mousedown", (e) => e.preventDefault());
|
|
236
|
+
chevron.addEventListener("click", () => _drillInto(entry));
|
|
237
|
+
item.appendChild(chevron);
|
|
238
|
+
}
|
|
239
|
+
item.addEventListener("mouseenter", () => _hover(idx));
|
|
240
|
+
listWrap.appendChild(item);
|
|
241
|
+
});
|
|
242
|
+
_setActive(0);
|
|
243
|
+
}).catch((err) => {
|
|
244
|
+
if (_mode !== "files") return;
|
|
245
|
+
listWrap.appendChild(_el("div", "mention-empty", String(err.message || err)));
|
|
246
|
+
_activeIndex = -1;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function _renderChats() {
|
|
251
|
+
const menu = _menuEl();
|
|
252
|
+
menu.innerHTML = "";
|
|
253
|
+
_header(menu, _label("返回", "Back"), _label("历史会话", "Past Chats"));
|
|
254
|
+
const listWrap = _el("div", "mention-list", "");
|
|
255
|
+
menu.appendChild(listWrap);
|
|
256
|
+
_chatsCursor = null;
|
|
257
|
+
_chatsHasMore = false;
|
|
258
|
+
_items = [];
|
|
259
|
+
_chatsLoading = false;
|
|
260
|
+
_loadChats(listWrap, true);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function _renderChatItems(listWrap, resetActive) {
|
|
264
|
+
listWrap.innerHTML = "";
|
|
265
|
+
if (_items.length === 0) {
|
|
266
|
+
listWrap.appendChild(_el("div", "mention-empty", _label("无会话", "No chats")));
|
|
267
|
+
_activeIndex = -1;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
_items.forEach((s, idx) => {
|
|
271
|
+
const item = _el("div", "mention-item", "");
|
|
272
|
+
item.appendChild(_ico("mention-ico", ICONS.message));
|
|
273
|
+
item.appendChild(_el("span", "mention-name", s.name));
|
|
274
|
+
item.addEventListener("mousedown", (e) => e.preventDefault());
|
|
275
|
+
item.addEventListener("mouseenter", () => _hover(idx));
|
|
276
|
+
item.addEventListener("click", () => _pickSession(s));
|
|
277
|
+
listWrap.appendChild(item);
|
|
278
|
+
});
|
|
279
|
+
if (_chatsHasMore) {
|
|
280
|
+
const more = _el("button", "mention-load-more", _label("加载更多", "Load more"));
|
|
281
|
+
more.type = "button";
|
|
282
|
+
more.addEventListener("mousedown", (e) => e.preventDefault());
|
|
283
|
+
more.addEventListener("click", () => _loadChats(listWrap, false));
|
|
284
|
+
listWrap.appendChild(more);
|
|
285
|
+
}
|
|
286
|
+
_setActive(resetActive ? 0 : Math.max(_activeIndex, 0));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function _loadChats(listWrap, reset) {
|
|
290
|
+
if (_chatsLoading) return;
|
|
291
|
+
_chatsLoading = true;
|
|
292
|
+
const req = ++_chatsReq;
|
|
293
|
+
if (reset) {
|
|
294
|
+
listWrap.innerHTML = "";
|
|
295
|
+
listWrap.appendChild(_el("div", "mention-empty", _label("加载中…", "Loading…")));
|
|
296
|
+
} else {
|
|
297
|
+
const more = listWrap.querySelector(".mention-load-more");
|
|
298
|
+
if (more) { more.textContent = _label("加载中…", "Loading…"); more.disabled = true; }
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const data = await _fetchSessions(_chatsCursor);
|
|
302
|
+
if (_mode !== "chats" || req !== _chatsReq) return;
|
|
303
|
+
const sessions = data.sessions || [];
|
|
304
|
+
_chatsHasMore = !!data.has_more;
|
|
305
|
+
_items = _items.concat(sessions.map((s) => ({ kind: "session", id: s.id, name: s.name || s.id })));
|
|
306
|
+
if (sessions.length) {
|
|
307
|
+
// Cursor must come from a non-pinned session: pinned sessions sit at
|
|
308
|
+
// the top regardless of age, so an old pinned row would otherwise push
|
|
309
|
+
// the cursor too far back and leave the next page empty.
|
|
310
|
+
const oldest = sessions.reduce((min, s) => {
|
|
311
|
+
if (s.pinned) return min;
|
|
312
|
+
const t = s.updated_at || s.created_at;
|
|
313
|
+
if (!t) return min;
|
|
314
|
+
return (!min || t < min) ? t : min;
|
|
315
|
+
}, null);
|
|
316
|
+
if (oldest) _chatsCursor = oldest;
|
|
317
|
+
}
|
|
318
|
+
_renderChatItems(listWrap, reset);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
if (_mode !== "chats" || req !== _chatsReq) return;
|
|
321
|
+
listWrap.innerHTML = "";
|
|
322
|
+
listWrap.appendChild(_el("div", "mention-empty", String(err.message || err)));
|
|
323
|
+
_activeIndex = -1;
|
|
324
|
+
} finally {
|
|
325
|
+
if (req === _chatsReq) _chatsLoading = false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function _fmtSize(bytes) {
|
|
330
|
+
if (bytes == null || isNaN(bytes)) return "";
|
|
331
|
+
if (bytes < 1024) return bytes + " B";
|
|
332
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
|
333
|
+
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── actions ────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
function _enter(mode) {
|
|
339
|
+
_mode = mode;
|
|
340
|
+
_relPath = "";
|
|
341
|
+
_activeIndex = -1;
|
|
342
|
+
if (mode === "files") _renderFiles();
|
|
343
|
+
else if (mode === "chats") _renderChats();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function _goBack() {
|
|
347
|
+
if (_mode === "files") {
|
|
348
|
+
if (_relPath) {
|
|
349
|
+
_relPath = _relPath.includes("/") ? _relPath.slice(0, _relPath.lastIndexOf("/")) : "";
|
|
350
|
+
_renderFiles();
|
|
351
|
+
} else {
|
|
352
|
+
_mode = "root";
|
|
353
|
+
_renderRoot();
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
_mode = "root";
|
|
357
|
+
_renderRoot();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function _drillInto(entry) {
|
|
362
|
+
_relPath = _relPath ? _relPath + "/" + entry.name : entry.name;
|
|
363
|
+
_activeIndex = -1;
|
|
364
|
+
_renderFiles();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function _pickEntry(entry, isDir) {
|
|
368
|
+
_insert({ type: isDir ? "directory" : "file", name: entry.name, path: entry.absPath });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function _pickSession(s) {
|
|
372
|
+
_insert({ type: "session", name: s.name, sessionId: s.id });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function _insert(chip) {
|
|
376
|
+
const el = _inputEl();
|
|
377
|
+
if (!el) return;
|
|
378
|
+
_clearAtMarker();
|
|
379
|
+
hide();
|
|
380
|
+
if (_cfg.onChip) _cfg.onChip(chip);
|
|
381
|
+
else Composer.insertChip(el, chip);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Insert a literal "@" (as a tracked marker) at the caret and open the menu
|
|
385
|
+
// right after it. While the menu is open the "@" is visible in the composer;
|
|
386
|
+
// picking an item removes it (see _clearAtMarker).
|
|
387
|
+
function _insertAtMarker() {
|
|
388
|
+
const el = _inputEl();
|
|
389
|
+
if (!el) return;
|
|
390
|
+
const sel = window.getSelection();
|
|
391
|
+
if (!sel || !sel.rangeCount) return;
|
|
392
|
+
const range = sel.getRangeAt(0);
|
|
393
|
+
range.deleteContents();
|
|
394
|
+
const marker = document.createElement("span");
|
|
395
|
+
marker.className = "mention-at-marker";
|
|
396
|
+
marker.textContent = "@";
|
|
397
|
+
range.insertNode(marker);
|
|
398
|
+
const after = document.createRange();
|
|
399
|
+
after.setStartAfter(marker);
|
|
400
|
+
after.collapse(true);
|
|
401
|
+
sel.removeAllRanges();
|
|
402
|
+
sel.addRange(after);
|
|
403
|
+
_atNode = marker;
|
|
404
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Promote an "@" already committed by the browser into the tracked marker.
|
|
408
|
+
// Windows IMEs may suppress the printable keydown (or expose it as Process),
|
|
409
|
+
// so the input/composition events are the only reliable character boundary.
|
|
410
|
+
function _adoptCommittedAtMarker() {
|
|
411
|
+
const el = _inputEl();
|
|
412
|
+
const sel = window.getSelection();
|
|
413
|
+
if (!el || !sel || !sel.rangeCount || !sel.isCollapsed) return false;
|
|
414
|
+
|
|
415
|
+
const caret = sel.getRangeAt(0);
|
|
416
|
+
if (!el.contains(caret.startContainer)) return false;
|
|
417
|
+
|
|
418
|
+
let node = caret.startContainer;
|
|
419
|
+
let offset = caret.startOffset;
|
|
420
|
+
if (node.nodeType !== Node.TEXT_NODE) {
|
|
421
|
+
if (node.nodeType !== Node.ELEMENT_NODE || offset === 0) return false;
|
|
422
|
+
const previousChildIndex = offset - 1;
|
|
423
|
+
node = node.childNodes[previousChildIndex];
|
|
424
|
+
while (node && node.lastChild) node = node.lastChild;
|
|
425
|
+
if (!node || node.nodeType !== Node.TEXT_NODE) return false;
|
|
426
|
+
offset = node.nodeValue.length;
|
|
427
|
+
}
|
|
428
|
+
const atOffset = offset - 1;
|
|
429
|
+
if (atOffset < 0 || node.nodeValue[atOffset] !== "@") return false;
|
|
430
|
+
|
|
431
|
+
const at = document.createRange();
|
|
432
|
+
at.setStart(node, atOffset);
|
|
433
|
+
at.setEnd(node, offset);
|
|
434
|
+
at.deleteContents();
|
|
435
|
+
|
|
436
|
+
const marker = document.createElement("span");
|
|
437
|
+
marker.className = "mention-at-marker";
|
|
438
|
+
marker.textContent = "@";
|
|
439
|
+
at.insertNode(marker);
|
|
440
|
+
|
|
441
|
+
const after = document.createRange();
|
|
442
|
+
after.setStartAfter(marker);
|
|
443
|
+
after.collapse(true);
|
|
444
|
+
sel.removeAllRanges();
|
|
445
|
+
sel.addRange(after);
|
|
446
|
+
_atNode = marker;
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Cancel path: turn the tracked "@" marker back into plain text so the user
|
|
451
|
+
// keeps the character they typed.
|
|
452
|
+
function _unwrapAtMarker() {
|
|
453
|
+
if (!_atNode) return;
|
|
454
|
+
const el = _inputEl();
|
|
455
|
+
if (el && el.contains(_atNode)) {
|
|
456
|
+
const text = document.createTextNode("@");
|
|
457
|
+
_atNode.parentNode.replaceChild(text, _atNode);
|
|
458
|
+
const sel = window.getSelection();
|
|
459
|
+
const r = document.createRange();
|
|
460
|
+
r.setStartAfter(text);
|
|
461
|
+
r.collapse(true);
|
|
462
|
+
sel.removeAllRanges();
|
|
463
|
+
sel.addRange(r);
|
|
464
|
+
}
|
|
465
|
+
_atNode = null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Selection path: remove the "@" (and any filter text typed after it) before
|
|
469
|
+
// the chip is inserted, leaving the caret where the "@" was.
|
|
470
|
+
function _clearAtMarker() {
|
|
471
|
+
const el = _inputEl();
|
|
472
|
+
if (!_atNode || !el || !el.contains(_atNode)) { _atNode = null; return; }
|
|
473
|
+
const sel = window.getSelection();
|
|
474
|
+
const tail = document.createRange();
|
|
475
|
+
tail.setStartAfter(_atNode);
|
|
476
|
+
if (sel && sel.rangeCount) {
|
|
477
|
+
const caret = sel.getRangeAt(0);
|
|
478
|
+
tail.setEnd(caret.endContainer, caret.endOffset);
|
|
479
|
+
} else {
|
|
480
|
+
tail.collapse(true);
|
|
481
|
+
}
|
|
482
|
+
tail.deleteContents();
|
|
483
|
+
const before = document.createRange();
|
|
484
|
+
before.setStartBefore(_atNode);
|
|
485
|
+
before.collapse(true);
|
|
486
|
+
_atNode.remove();
|
|
487
|
+
_atNode = null;
|
|
488
|
+
sel.removeAllRanges();
|
|
489
|
+
sel.addRange(before);
|
|
490
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ── keyboard ───────────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
function _handleKey(e) {
|
|
496
|
+
if (!_visible) return false;
|
|
497
|
+
// Past Chats appends a trailing "load more" button after the last item.
|
|
498
|
+
const lastIdx = _items.length + ((_mode === "chats" && _chatsHasMore) ? 1 : 0) - 1;
|
|
499
|
+
if (e.key === "ArrowDown") { e.preventDefault(); _keyboardNav = true; _setActive(Math.min(_activeIndex + 1, lastIdx)); return true; }
|
|
500
|
+
if (e.key === "ArrowUp") { e.preventDefault(); _keyboardNav = true; _setActive(Math.max(_activeIndex - 1, 0)); return true; }
|
|
501
|
+
if (e.key === "Escape") { e.preventDefault(); hide(); return true; }
|
|
502
|
+
if (e.key === "Backspace" && _mode !== "root") { e.preventDefault(); _goBack(); return true; }
|
|
503
|
+
if (e.key === "Enter" || e.key === "Tab") {
|
|
504
|
+
e.preventDefault();
|
|
505
|
+
if (_mode === "root") {
|
|
506
|
+
if (_activeIndex === 0) _enter("files");
|
|
507
|
+
else if (_activeIndex === 1) _enter("chats");
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
if (_mode === "chats" && _chatsHasMore && _activeIndex === _items.length) {
|
|
511
|
+
const menu = _menuEl();
|
|
512
|
+
const listWrap = menu ? menu.querySelector(".mention-list") : null;
|
|
513
|
+
if (listWrap) _loadChats(listWrap, false);
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
const item = _items[_activeIndex];
|
|
517
|
+
if (!item) return true;
|
|
518
|
+
if (_mode === "files") {
|
|
519
|
+
if (item.type === "dir") _drillInto(item);
|
|
520
|
+
else _pickEntry(item, false);
|
|
521
|
+
} else if (_mode === "chats") {
|
|
522
|
+
_pickSession(item);
|
|
523
|
+
}
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── show / hide ────────────────────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
function _createOverlay() {
|
|
532
|
+
_removeOverlay();
|
|
533
|
+
const overlay = document.createElement("div");
|
|
534
|
+
overlay.id = "mention-overlay";
|
|
535
|
+
overlay.style.cssText = "position: fixed; inset: 0; z-index: 998; background: transparent;";
|
|
536
|
+
overlay.addEventListener("click", hide);
|
|
537
|
+
document.body.appendChild(overlay);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function _removeOverlay() {
|
|
541
|
+
const overlay = document.getElementById("mention-overlay");
|
|
542
|
+
if (overlay) overlay.remove();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function open(mode) {
|
|
546
|
+
_mode = mode || "root";
|
|
547
|
+
_relPath = "";
|
|
548
|
+
_activeIndex = -1;
|
|
549
|
+
const menu = _menuEl();
|
|
550
|
+
if (menu) menu.style.display = "";
|
|
551
|
+
if (_mode === "root") _renderRoot();
|
|
552
|
+
else if (_mode === "files") _renderFiles();
|
|
553
|
+
else if (_mode === "chats") _renderChats();
|
|
554
|
+
_visible = true;
|
|
555
|
+
_createOverlay();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function hide() {
|
|
559
|
+
_visible = false;
|
|
560
|
+
_mode = "root";
|
|
561
|
+
_items = [];
|
|
562
|
+
_activeIndex = -1;
|
|
563
|
+
_relPath = "";
|
|
564
|
+
const menu = _menuEl();
|
|
565
|
+
if (menu) menu.style.display = "none";
|
|
566
|
+
_unwrapAtMarker();
|
|
567
|
+
_removeOverlay();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function _toggle() {
|
|
571
|
+
if (_visible) hide();
|
|
572
|
+
else open("root");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ── attach ─────────────────────────────────────────────────────────────
|
|
576
|
+
|
|
577
|
+
function attach(cfg) {
|
|
578
|
+
const el = document.getElementById(cfg.input);
|
|
579
|
+
if (!el) return;
|
|
580
|
+
_cfg = cfg;
|
|
581
|
+
|
|
582
|
+
// Reset keyboard-nav mode on the next real mouse movement so hover takes
|
|
583
|
+
// over again (bound once globally; attach runs for two composers).
|
|
584
|
+
if (!_mouseBound) {
|
|
585
|
+
_mouseBound = true;
|
|
586
|
+
document.addEventListener("mousemove", () => { _keyboardNav = false; });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// @ inserts the literal character into the composer (as a tracked marker)
|
|
590
|
+
// and opens the menu; picking an item removes it. Menu navigation keys are
|
|
591
|
+
// consumed here and stopImmediatePropagation keeps Composer/SkillAC from
|
|
592
|
+
// double-handling. `_cfg = cfg` re-points the shared context to this
|
|
593
|
+
// composer on every event so the chat + new-session composers can coexist
|
|
594
|
+
// (same pattern as SkillAC).
|
|
595
|
+
el.addEventListener("keydown", (e) => {
|
|
596
|
+
_cfg = cfg;
|
|
597
|
+
if (e.key === "@" && !e.isComposing && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
598
|
+
e.preventDefault();
|
|
599
|
+
e.stopImmediatePropagation();
|
|
600
|
+
if (_visible) hide();
|
|
601
|
+
else { _insertAtMarker(); open("root"); }
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (_visible && _handleKey(e)) {
|
|
605
|
+
e.stopImmediatePropagation();
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
// Any other printable character typed while the menu is open closes it:
|
|
609
|
+
// the "@" stays as plain text and the character is inserted normally.
|
|
610
|
+
if (_visible && e.key.length === 1 && !e.isComposing && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
611
|
+
hide();
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// Keydown is not reliable while a Windows IME is active. Once the browser
|
|
616
|
+
// commits a literal "@", adopt it into the existing marker state machine.
|
|
617
|
+
// Composing input waits for compositionend so we never rewrite active IME
|
|
618
|
+
// text; non-composing input can be handled immediately.
|
|
619
|
+
el.addEventListener("input", (e) => {
|
|
620
|
+
_cfg = cfg;
|
|
621
|
+
if (!_visible && !e.isComposing && e.data === "@" && _adoptCommittedAtMarker()) {
|
|
622
|
+
open("root");
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (_visible && _atNode && !el.contains(_atNode)) {
|
|
626
|
+
_atNode = null;
|
|
627
|
+
hide();
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
el.addEventListener("compositionend", (e) => {
|
|
632
|
+
_cfg = cfg;
|
|
633
|
+
if (!_visible && e.data === "@" && _adoptCommittedAtMarker()) open("root");
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// Optional @ button removed — @ is opened via the keyboard only.
|
|
637
|
+
|
|
638
|
+
return {
|
|
639
|
+
open, hide, toggle: _toggle,
|
|
640
|
+
get visible() { return _visible; },
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
attach,
|
|
646
|
+
open,
|
|
647
|
+
hide,
|
|
648
|
+
toggle: _toggle,
|
|
649
|
+
svgHtml,
|
|
650
|
+
icons: ICONS,
|
|
651
|
+
get visible() { return _visible; },
|
|
652
|
+
};
|
|
653
|
+
})();
|
|
654
|
+
|
|
655
|
+
Clacky.MentionAC = MentionAC;
|