synabun 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +665 -0
- package/LICENSE +191 -0
- package/README.md +853 -0
- package/hooks/claude-code/post-plan.mjs +391 -0
- package/hooks/claude-code/post-remember.mjs +239 -0
- package/hooks/claude-code/pre-compact.mjs +241 -0
- package/hooks/claude-code/pre-websearch.mjs +90 -0
- package/hooks/claude-code/prompt-submit.mjs +861 -0
- package/hooks/claude-code/session-start.mjs +494 -0
- package/hooks/claude-code/shared.mjs +368 -0
- package/hooks/claude-code/stop.mjs +646 -0
- package/hooks/claude-code/test-user-learning.mjs +531 -0
- package/mcp-server/package.json +38 -0
- package/mcp-server/run.mjs +10 -0
- package/mcp-server/src/config.ts +70 -0
- package/mcp-server/src/http.ts +93 -0
- package/mcp-server/src/index.ts +203 -0
- package/mcp-server/src/preload-http.ts +25 -0
- package/mcp-server/src/preload.ts +29 -0
- package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
- package/mcp-server/src/services/categories.ts +429 -0
- package/mcp-server/src/services/discord.ts +702 -0
- package/mcp-server/src/services/file-checksums.ts +33 -0
- package/mcp-server/src/services/local-embeddings.ts +77 -0
- package/mcp-server/src/services/neural-interface.ts +344 -0
- package/mcp-server/src/services/sqlite.ts +823 -0
- package/mcp-server/src/tools/browser-advanced.ts +123 -0
- package/mcp-server/src/tools/browser-interact.ts +255 -0
- package/mcp-server/src/tools/browser-navigate.ts +77 -0
- package/mcp-server/src/tools/browser-observe.ts +1330 -0
- package/mcp-server/src/tools/browser.ts +107 -0
- package/mcp-server/src/tools/card-tools.ts +193 -0
- package/mcp-server/src/tools/card.ts +22 -0
- package/mcp-server/src/tools/category.ts +379 -0
- package/mcp-server/src/tools/discord-channel.ts +274 -0
- package/mcp-server/src/tools/discord-guild.ts +188 -0
- package/mcp-server/src/tools/discord-member.ts +158 -0
- package/mcp-server/src/tools/discord-message.ts +231 -0
- package/mcp-server/src/tools/discord-onboarding.ts +228 -0
- package/mcp-server/src/tools/discord-role.ts +209 -0
- package/mcp-server/src/tools/discord-thread.ts +110 -0
- package/mcp-server/src/tools/discord-webhook.ts +176 -0
- package/mcp-server/src/tools/discord.ts +40 -0
- package/mcp-server/src/tools/forget.ts +38 -0
- package/mcp-server/src/tools/git-tools.ts +141 -0
- package/mcp-server/src/tools/git.ts +13 -0
- package/mcp-server/src/tools/image-tools.ts +98 -0
- package/mcp-server/src/tools/image.ts +17 -0
- package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
- package/mcp-server/src/tools/leonardo.ts +28 -0
- package/mcp-server/src/tools/loop.ts +348 -0
- package/mcp-server/src/tools/memories.ts +118 -0
- package/mcp-server/src/tools/recall.ts +323 -0
- package/mcp-server/src/tools/reflect.ts +145 -0
- package/mcp-server/src/tools/remember.ts +115 -0
- package/mcp-server/src/tools/response.ts +26 -0
- package/mcp-server/src/tools/restore.ts +34 -0
- package/mcp-server/src/tools/sync.ts +114 -0
- package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
- package/mcp-server/src/tools/tictactoe.ts +13 -0
- package/mcp-server/src/tools/utils.ts +25 -0
- package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
- package/mcp-server/src/tools/whiteboard.ts +22 -0
- package/mcp-server/src/tui.ts +940 -0
- package/mcp-server/src/types.ts +85 -0
- package/mcp-server/tsconfig.json +19 -0
- package/neural-interface/package.json +27 -0
- package/neural-interface/public/apple-touch-icon.png +0 -0
- package/neural-interface/public/category-logos/claude.svg +1 -0
- package/neural-interface/public/category-logos/gemini-color.svg +1 -0
- package/neural-interface/public/category-logos/openai.svg +1 -0
- package/neural-interface/public/category-logos/synabun.png +0 -0
- package/neural-interface/public/claude-chat.css +878 -0
- package/neural-interface/public/claude-chat.html +92 -0
- package/neural-interface/public/claude-chat.js +1168 -0
- package/neural-interface/public/favicon-16x16.png +0 -0
- package/neural-interface/public/favicon-32x32.png +0 -0
- package/neural-interface/public/gallery.svg +6 -0
- package/neural-interface/public/icon-192.png +0 -0
- package/neural-interface/public/icon-512.png +0 -0
- package/neural-interface/public/index-legacy.html +14677 -0
- package/neural-interface/public/index.html +212 -0
- package/neural-interface/public/index2d-legacy.html +8193 -0
- package/neural-interface/public/index2d.html +127 -0
- package/neural-interface/public/invite.html +351 -0
- package/neural-interface/public/leonardoai.svg +1 -0
- package/neural-interface/public/logo-synabun.png +0 -0
- package/neural-interface/public/logoHD.png +0 -0
- package/neural-interface/public/manifest.json +14 -0
- package/neural-interface/public/offline.html +299 -0
- package/neural-interface/public/onboarding.html +2335 -0
- package/neural-interface/public/openclaw-logo-text.png +0 -0
- package/neural-interface/public/pin-svgrepo-com.svg +25 -0
- package/neural-interface/public/sessionicon.svg +8 -0
- package/neural-interface/public/shared/ansi-parser.js +305 -0
- package/neural-interface/public/shared/api.js +1043 -0
- package/neural-interface/public/shared/colors.js +153 -0
- package/neural-interface/public/shared/constants.js +141 -0
- package/neural-interface/public/shared/html-shell.js +777 -0
- package/neural-interface/public/shared/html-term-renderer.js +1022 -0
- package/neural-interface/public/shared/i18n.js +147 -0
- package/neural-interface/public/shared/registry.js +82 -0
- package/neural-interface/public/shared/state.js +86 -0
- package/neural-interface/public/shared/storage.js +150 -0
- package/neural-interface/public/shared/styles.css +21212 -0
- package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
- package/neural-interface/public/shared/ui-bookmarks.js +231 -0
- package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
- package/neural-interface/public/shared/ui-command-runner.js +534 -0
- package/neural-interface/public/shared/ui-cost-widget.js +602 -0
- package/neural-interface/public/shared/ui-detail.js +1454 -0
- package/neural-interface/public/shared/ui-explorer.js +1356 -0
- package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
- package/neural-interface/public/shared/ui-games.js +411 -0
- package/neural-interface/public/shared/ui-help.js +107 -0
- package/neural-interface/public/shared/ui-image-gallery.js +461 -0
- package/neural-interface/public/shared/ui-invite.js +519 -0
- package/neural-interface/public/shared/ui-keybinds.js +452 -0
- package/neural-interface/public/shared/ui-layouts.js +297 -0
- package/neural-interface/public/shared/ui-link.js +731 -0
- package/neural-interface/public/shared/ui-loading.js +255 -0
- package/neural-interface/public/shared/ui-menubar.js +499 -0
- package/neural-interface/public/shared/ui-multiselect.js +93 -0
- package/neural-interface/public/shared/ui-navbar.js +222 -0
- package/neural-interface/public/shared/ui-notifications.js +287 -0
- package/neural-interface/public/shared/ui-panels.js +309 -0
- package/neural-interface/public/shared/ui-resume.js +653 -0
- package/neural-interface/public/shared/ui-search.js +145 -0
- package/neural-interface/public/shared/ui-sessions.js +531 -0
- package/neural-interface/public/shared/ui-settings.js +5987 -0
- package/neural-interface/public/shared/ui-sidebar.js +1905 -0
- package/neural-interface/public/shared/ui-skills.js +2805 -0
- package/neural-interface/public/shared/ui-stats.js +38 -0
- package/neural-interface/public/shared/ui-sync.js +201 -0
- package/neural-interface/public/shared/ui-terminal.js +6385 -0
- package/neural-interface/public/shared/ui-tooltip.js +123 -0
- package/neural-interface/public/shared/ui-trash.js +439 -0
- package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
- package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
- package/neural-interface/public/shared/ui-tutorial.js +1117 -0
- package/neural-interface/public/shared/ui-update.js +244 -0
- package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
- package/neural-interface/public/shared/ui-workspaces.js +722 -0
- package/neural-interface/public/shared/utils.js +360 -0
- package/neural-interface/public/shared/vterm-buffer.js +1102 -0
- package/neural-interface/public/sw.js +52 -0
- package/neural-interface/public/synabun.png +0 -0
- package/neural-interface/public/toolbar-album.svg +8 -0
- package/neural-interface/public/toolbar-question.svg +6 -0
- package/neural-interface/public/toolbar-sessions.svg +6 -0
- package/neural-interface/public/variant/2d/background.js +113 -0
- package/neural-interface/public/variant/2d/context-menu.js +133 -0
- package/neural-interface/public/variant/2d/gfx.js +45 -0
- package/neural-interface/public/variant/2d/graph.js +1635 -0
- package/neural-interface/public/variant/2d/hulls.js +207 -0
- package/neural-interface/public/variant/2d/lasso.js +135 -0
- package/neural-interface/public/variant/2d/layout.js +594 -0
- package/neural-interface/public/variant/2d/main.js +521 -0
- package/neural-interface/public/variant/2d/minimap.js +312 -0
- package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
- package/neural-interface/public/variant/3d/background.js +884 -0
- package/neural-interface/public/variant/3d/camera.js +661 -0
- package/neural-interface/public/variant/3d/gfx.js +116 -0
- package/neural-interface/public/variant/3d/graph.js +2399 -0
- package/neural-interface/public/variant/3d/main.js +500 -0
- package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
- package/neural-interface/scripts/rebuild-pty.js +100 -0
- package/neural-interface/server.js +15000 -0
- package/neural-interface/templates/CLAUDE-template.md +211 -0
- package/package.json +53 -0
- package/postinstall.js +135 -0
- package/setup.js +260 -0
- package/skills/leonardo/SKILL.md +159 -0
- package/skills/leonardo/icon.svg +1 -0
- package/skills/leonardo/modules/browser-guide.md +378 -0
- package/skills/leonardo/modules/image-prompter.md +277 -0
- package/skills/leonardo/modules/model-advisor.md +100 -0
- package/skills/leonardo/modules/prompt-library.md +110 -0
- package/skills/leonardo/modules/style-guide.md +186 -0
- package/skills/leonardo/modules/video-prompter.md +311 -0
- package/skills/synabun/SKILL.md +185 -0
- package/skills/synabun/icon.png +0 -0
- package/skills/synabun/modules/audit.md +374 -0
- package/skills/synabun/modules/changelog.md +194 -0
- package/skills/synabun/modules/idea.md +144 -0
- package/skills/synabun/modules/memorize.md +188 -0
- package/skills/synabun/synabunicon.png +0 -0
|
@@ -0,0 +1,1168 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════
|
|
2
|
+
// SynaBun Chat — Claude Code skin
|
|
3
|
+
// Stream-JSON renderer with model/project/branch selection
|
|
4
|
+
// ═══════════════════════════════════════════
|
|
5
|
+
|
|
6
|
+
import { storage } from '/shared/storage.js';
|
|
7
|
+
import { restoreInterfaceConfig, restoreSkin } from '/shared/ui-settings.js';
|
|
8
|
+
import { fetchClaudeSessions } from '/shared/api.js';
|
|
9
|
+
import { mountSessionWidget } from '/shared/ui-sessions.js';
|
|
10
|
+
|
|
11
|
+
restoreInterfaceConfig();
|
|
12
|
+
restoreSkin();
|
|
13
|
+
|
|
14
|
+
// ── Markdown (lazy CDN load) ──
|
|
15
|
+
let marked = null;
|
|
16
|
+
(async () => {
|
|
17
|
+
try {
|
|
18
|
+
const m = await import('https://cdn.jsdelivr.net/npm/marked@14/lib/marked.esm.js').catch(() => null);
|
|
19
|
+
if (m) {
|
|
20
|
+
marked = m.marked || m.default;
|
|
21
|
+
if (marked?.setOptions) marked.setOptions({ breaks: true, gfm: true });
|
|
22
|
+
}
|
|
23
|
+
} catch {}
|
|
24
|
+
})();
|
|
25
|
+
|
|
26
|
+
function md(text) {
|
|
27
|
+
if (!marked) return esc(text).replace(/\n/g, '<br>');
|
|
28
|
+
try { return marked.parse(text); }
|
|
29
|
+
catch { return esc(text).replace(/\n/g, '<br>'); }
|
|
30
|
+
}
|
|
31
|
+
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
32
|
+
|
|
33
|
+
const CLAUDE_ICON = '<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"/></svg>';
|
|
34
|
+
|
|
35
|
+
// ── State ──
|
|
36
|
+
let ws = null;
|
|
37
|
+
let reconnectTimer = null;
|
|
38
|
+
let sessionId = null;
|
|
39
|
+
let running = false;
|
|
40
|
+
let totalCost = 0;
|
|
41
|
+
let thinkingEl = null;
|
|
42
|
+
let sendStartTime = null;
|
|
43
|
+
let thinkTimerInterval = null;
|
|
44
|
+
let attachedFiles = [];
|
|
45
|
+
|
|
46
|
+
const STOR = {
|
|
47
|
+
session: 'synabun-chat-session',
|
|
48
|
+
project: 'synabun-chat-project',
|
|
49
|
+
model: 'synabun-chat-model',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// ── Tab-scoped storage for session isolation ──
|
|
53
|
+
// Uses browser sessionStorage so each browser tab has its own project/model/session.
|
|
54
|
+
// Falls back to server-synced storage for initial defaults on first load.
|
|
55
|
+
const tabStore = {
|
|
56
|
+
getItem(key) { return sessionStorage.getItem(key); },
|
|
57
|
+
setItem(key, val) { sessionStorage.setItem(key, val); },
|
|
58
|
+
removeItem(key) { sessionStorage.removeItem(key); },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ── Clipboard fallback for non-secure contexts ──
|
|
62
|
+
function _clipCopy(text) {
|
|
63
|
+
if (navigator.clipboard?.writeText) {
|
|
64
|
+
navigator.clipboard.writeText(text).catch(() => _clipFallback(text));
|
|
65
|
+
} else {
|
|
66
|
+
_clipFallback(text);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function _clipFallback(text) {
|
|
70
|
+
const ta = document.createElement('textarea');
|
|
71
|
+
ta.value = text;
|
|
72
|
+
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0';
|
|
73
|
+
document.body.appendChild(ta);
|
|
74
|
+
ta.select();
|
|
75
|
+
try { document.execCommand('copy'); } catch {}
|
|
76
|
+
ta.remove();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── DOM ──
|
|
80
|
+
const $ = (id) => document.getElementById(id);
|
|
81
|
+
const $msgs = $('messages');
|
|
82
|
+
const $input = $('chat-input');
|
|
83
|
+
const $send = $('chat-send-btn');
|
|
84
|
+
const $cost = $('chat-cost');
|
|
85
|
+
const $project = $('project-select');
|
|
86
|
+
const $branch = $('branch-select');
|
|
87
|
+
const $model = $('model-select');
|
|
88
|
+
const $newBtn = $('chat-new-btn');
|
|
89
|
+
|
|
90
|
+
// ── Config ──
|
|
91
|
+
let projects = [];
|
|
92
|
+
let models = [];
|
|
93
|
+
|
|
94
|
+
async function loadConfig() {
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetch('/api/claude/config').then(r => r.json());
|
|
97
|
+
if (!res.ok) return;
|
|
98
|
+
|
|
99
|
+
projects = res.projects || [];
|
|
100
|
+
models = res.models || [];
|
|
101
|
+
|
|
102
|
+
// Populate project selector
|
|
103
|
+
$project.innerHTML = '<option value="">Select project...</option>';
|
|
104
|
+
for (const p of projects) {
|
|
105
|
+
const label = p.label || p.path.split(/[/\\]/).pop();
|
|
106
|
+
const opt = document.createElement('option');
|
|
107
|
+
opt.value = p.path;
|
|
108
|
+
opt.textContent = label;
|
|
109
|
+
$project.appendChild(opt);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Restore saved project (tab-scoped, falls back to server default on first load)
|
|
113
|
+
const saved = tabStore.getItem(STOR.project) || storage.getItem(STOR.project);
|
|
114
|
+
if (saved && projects.some(p => p.path === saved)) {
|
|
115
|
+
$project.value = saved;
|
|
116
|
+
tabStore.setItem(STOR.project, saved);
|
|
117
|
+
loadBranches(saved);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Populate model selector
|
|
121
|
+
$model.innerHTML = '';
|
|
122
|
+
for (const m of models) {
|
|
123
|
+
const opt = document.createElement('option');
|
|
124
|
+
opt.value = m.id;
|
|
125
|
+
opt.textContent = m.label;
|
|
126
|
+
$model.appendChild(opt);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Restore saved model (tab-scoped only — no cross-session bleed)
|
|
130
|
+
const savedModel = tabStore.getItem(STOR.model);
|
|
131
|
+
if (savedModel) { $model.value = savedModel; }
|
|
132
|
+
|
|
133
|
+
} catch {}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function loadBranches(projectPath) {
|
|
137
|
+
$branch.innerHTML = '<option value="">branch</option>';
|
|
138
|
+
if (!projectPath) return;
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetch(`/api/terminal/branches?path=${encodeURIComponent(projectPath)}`).then(r => r.json());
|
|
141
|
+
if (res.branches?.length) {
|
|
142
|
+
$branch.innerHTML = '';
|
|
143
|
+
for (const b of res.branches) {
|
|
144
|
+
const opt = document.createElement('option');
|
|
145
|
+
opt.value = b;
|
|
146
|
+
opt.textContent = b;
|
|
147
|
+
if (b === res.current) opt.selected = true;
|
|
148
|
+
$branch.appendChild(opt);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Events ──
|
|
155
|
+
$project.addEventListener('change', () => {
|
|
156
|
+
tabStore.setItem(STOR.project, $project.value);
|
|
157
|
+
loadBranches($project.value);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
$model.addEventListener('change', () => {
|
|
161
|
+
tabStore.setItem(STOR.model, $model.value);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
$branch.addEventListener('change', async () => {
|
|
165
|
+
const branch = $branch.value;
|
|
166
|
+
const path = $project.value;
|
|
167
|
+
if (!branch || !path) return;
|
|
168
|
+
try {
|
|
169
|
+
await fetch('/api/terminal/checkout', {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { 'Content-Type': 'application/json' },
|
|
172
|
+
body: JSON.stringify({ path, branch }),
|
|
173
|
+
});
|
|
174
|
+
} catch {}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ── WebSocket ──
|
|
178
|
+
function connect() {
|
|
179
|
+
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
|
|
180
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
181
|
+
ws = new WebSocket(`${proto}//${location.host}/ws/claude-skin`);
|
|
182
|
+
|
|
183
|
+
ws.addEventListener('open', () => clearTimeout(reconnectTimer));
|
|
184
|
+
|
|
185
|
+
ws.addEventListener('message', (e) => {
|
|
186
|
+
try { handleMsg(JSON.parse(e.data)); } catch {}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
ws.addEventListener('close', () => {
|
|
190
|
+
if (running) appendStatus('Connection lost. Reconnecting...');
|
|
191
|
+
reconnectTimer = setTimeout(connect, 2000);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
ws.addEventListener('error', () => ws.close());
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Message dispatch ──
|
|
198
|
+
function handleMsg(msg) {
|
|
199
|
+
switch (msg.type) {
|
|
200
|
+
case 'event': handleEvent(msg.event); break;
|
|
201
|
+
case 'control_request': handleControlRequest(msg); break;
|
|
202
|
+
case 'stderr':
|
|
203
|
+
if (msg.text?.trim()) appendStatus(msg.text.trim());
|
|
204
|
+
break;
|
|
205
|
+
case 'done': finish(); break;
|
|
206
|
+
case 'aborted': finish(); appendStatus('Aborted.'); break;
|
|
207
|
+
case 'error': finish(); appendError(msg.message); break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function handleEvent(ev) {
|
|
212
|
+
if (!ev?.type) return;
|
|
213
|
+
|
|
214
|
+
// init
|
|
215
|
+
if (ev.type === 'system' && ev.subtype === 'init') {
|
|
216
|
+
if (ev.session_id) {
|
|
217
|
+
sessionId = ev.session_id;
|
|
218
|
+
tabStore.setItem(STOR.session, ev.session_id);
|
|
219
|
+
}
|
|
220
|
+
// Don't hide thinking on init — Claude is still working
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// assistant message
|
|
225
|
+
if (ev.type === 'assistant' && ev.message) {
|
|
226
|
+
renderAssistant(ev.message);
|
|
227
|
+
repositionThinking(); // keep indicator at bottom, below new content
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// tool result
|
|
232
|
+
if (ev.type === 'tool_result') {
|
|
233
|
+
updateToolResult(ev);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// final result
|
|
238
|
+
if (ev.type === 'result') {
|
|
239
|
+
finish();
|
|
240
|
+
if (ev.total_cost_usd != null) {
|
|
241
|
+
totalCost += ev.total_cost_usd;
|
|
242
|
+
const monthLabel = new Date().toLocaleString('en', { month: 'short' });
|
|
243
|
+
$cost.textContent = `${monthLabel}: $${totalCost.toFixed(2)}`;
|
|
244
|
+
$cost.classList.add('flash');
|
|
245
|
+
setTimeout(() => $cost.classList.remove('flash'), 800);
|
|
246
|
+
}
|
|
247
|
+
if (ev.session_id) {
|
|
248
|
+
sessionId = ev.session_id;
|
|
249
|
+
tabStore.setItem(STOR.session, ev.session_id);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Render ──
|
|
255
|
+
function renderAssistant(msg) {
|
|
256
|
+
const content = msg.content || [];
|
|
257
|
+
const thinks = content.filter(b => b.type === 'thinking');
|
|
258
|
+
const texts = content.filter(b => b.type === 'text');
|
|
259
|
+
const tools = content.filter(b => b.type === 'tool_use');
|
|
260
|
+
if (!texts.length && !tools.length && !thinks.length) return;
|
|
261
|
+
|
|
262
|
+
const el = document.createElement('div');
|
|
263
|
+
el.className = 'msg msg-assistant';
|
|
264
|
+
|
|
265
|
+
// Avatar
|
|
266
|
+
const avatar = document.createElement('div');
|
|
267
|
+
avatar.className = 'msg-avatar';
|
|
268
|
+
avatar.textContent = 'S';
|
|
269
|
+
el.appendChild(avatar);
|
|
270
|
+
|
|
271
|
+
// Content wrapper
|
|
272
|
+
const wrap = document.createElement('div');
|
|
273
|
+
wrap.className = 'msg-content';
|
|
274
|
+
|
|
275
|
+
if (thinks.length) {
|
|
276
|
+
const thinkText = thinks.map(b => b.thinking || '').join('\n').trim();
|
|
277
|
+
if (thinkText) {
|
|
278
|
+
const thinkEl = document.createElement('details');
|
|
279
|
+
thinkEl.className = 'msg-thinking';
|
|
280
|
+
thinkEl.innerHTML = `<summary><span class="msg-thinking-icon">⚡</span><span class="msg-thinking-label">Thinking</span><span class="msg-thinking-chevron">›</span></summary><div class="msg-thinking-content"></div>`;
|
|
281
|
+
thinkEl.querySelector('.msg-thinking-content').textContent = thinkText;
|
|
282
|
+
wrap.appendChild(thinkEl);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (texts.length) {
|
|
287
|
+
const body = document.createElement('div');
|
|
288
|
+
body.className = 'msg-body';
|
|
289
|
+
body.innerHTML = md(texts.map(b => b.text).join('\n'));
|
|
290
|
+
addCodeLabels(body);
|
|
291
|
+
linkifyFilePaths(body);
|
|
292
|
+
wrap.appendChild(body);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const t of tools) wrap.appendChild(buildTool(t));
|
|
296
|
+
|
|
297
|
+
el.appendChild(wrap);
|
|
298
|
+
$msgs.appendChild(el);
|
|
299
|
+
scrollEnd();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function buildTool(block) {
|
|
303
|
+
const card = document.createElement('div');
|
|
304
|
+
card.className = 'tool-card';
|
|
305
|
+
card.dataset.toolId = block.id || '';
|
|
306
|
+
|
|
307
|
+
const hdr = document.createElement('div');
|
|
308
|
+
hdr.className = 'tool-hdr';
|
|
309
|
+
|
|
310
|
+
const icon = toolIcon(block.name);
|
|
311
|
+
const name = document.createElement('span');
|
|
312
|
+
name.className = 'tool-name';
|
|
313
|
+
name.textContent = block.name;
|
|
314
|
+
|
|
315
|
+
const detail = document.createElement('span');
|
|
316
|
+
detail.className = 'tool-detail';
|
|
317
|
+
detail.textContent = toolDetail(block);
|
|
318
|
+
|
|
319
|
+
const chevron = document.createElement('span');
|
|
320
|
+
chevron.className = 'tool-chevron';
|
|
321
|
+
chevron.innerHTML = '›';
|
|
322
|
+
|
|
323
|
+
hdr.appendChild(icon);
|
|
324
|
+
hdr.appendChild(name);
|
|
325
|
+
hdr.appendChild(detail);
|
|
326
|
+
hdr.appendChild(chevron);
|
|
327
|
+
hdr.addEventListener('click', () => card.classList.toggle('open'));
|
|
328
|
+
|
|
329
|
+
const body = document.createElement('div');
|
|
330
|
+
body.className = 'tool-body';
|
|
331
|
+
|
|
332
|
+
if (block.input && Object.keys(block.input).length) {
|
|
333
|
+
const lbl = document.createElement('div');
|
|
334
|
+
lbl.className = 'tool-section-label';
|
|
335
|
+
lbl.textContent = 'INPUT';
|
|
336
|
+
const sec = document.createElement('pre');
|
|
337
|
+
sec.className = 'tool-section';
|
|
338
|
+
sec.textContent = JSON.stringify(block.input, null, 2);
|
|
339
|
+
body.appendChild(lbl);
|
|
340
|
+
body.appendChild(sec);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Result placeholder
|
|
344
|
+
const rLbl = document.createElement('div');
|
|
345
|
+
rLbl.className = 'tool-section-label tool-result-label';
|
|
346
|
+
rLbl.textContent = 'RESULT';
|
|
347
|
+
rLbl.hidden = true;
|
|
348
|
+
const rSec = document.createElement('pre');
|
|
349
|
+
rSec.className = 'tool-section tool-result-content';
|
|
350
|
+
rSec.hidden = true;
|
|
351
|
+
body.appendChild(rLbl);
|
|
352
|
+
body.appendChild(rSec);
|
|
353
|
+
|
|
354
|
+
card.appendChild(hdr);
|
|
355
|
+
card.appendChild(body);
|
|
356
|
+
return card;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function updateToolResult(ev) {
|
|
360
|
+
const card = $msgs.querySelector(`.tool-card[data-tool-id="${CSS.escape(ev.tool_use_id)}"]`);
|
|
361
|
+
if (!card) return;
|
|
362
|
+
|
|
363
|
+
const rLbl = card.querySelector('.tool-result-label');
|
|
364
|
+
const rSec = card.querySelector('.tool-result-content');
|
|
365
|
+
if (!rSec) return;
|
|
366
|
+
|
|
367
|
+
let text = '';
|
|
368
|
+
if (Array.isArray(ev.content)) text = ev.content.map(b => b.text || b.data || '').join('\n');
|
|
369
|
+
else if (typeof ev.content === 'string') text = ev.content;
|
|
370
|
+
|
|
371
|
+
rSec.textContent = text.slice(0, 3000) + (text.length > 3000 ? '\n...' : '');
|
|
372
|
+
rLbl.hidden = false;
|
|
373
|
+
rSec.hidden = false;
|
|
374
|
+
|
|
375
|
+
card.classList.add(ev.is_error ? 'tool-error' : 'tool-ok');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function toolDetail(block) {
|
|
379
|
+
const i = block.input || {};
|
|
380
|
+
if (['Read', 'Edit', 'Write'].includes(block.name)) return (i.file_path || i.path || '').split(/[/\\]/).pop() || '';
|
|
381
|
+
if (block.name === 'Bash') return (i.command || '').slice(0, 50);
|
|
382
|
+
if (block.name === 'Glob' || block.name === 'Grep') return i.pattern || '';
|
|
383
|
+
return '';
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function toolIcon(name) {
|
|
387
|
+
const el = document.createElement('span');
|
|
388
|
+
el.className = 'tool-icon';
|
|
389
|
+
const map = {
|
|
390
|
+
Read: 'F', Edit: 'E', Write: 'W', Bash: '$',
|
|
391
|
+
Glob: '*', Grep: '?', Agent: 'A', WebSearch: 'S', WebFetch: 'U',
|
|
392
|
+
};
|
|
393
|
+
el.textContent = map[name] || '#';
|
|
394
|
+
return el;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function addCodeLabels(el) {
|
|
398
|
+
el.querySelectorAll('pre code').forEach(code => {
|
|
399
|
+
const lang = (code.className || '').replace('language-', '').trim();
|
|
400
|
+
if (lang) {
|
|
401
|
+
const lbl = document.createElement('span');
|
|
402
|
+
lbl.className = 'code-label';
|
|
403
|
+
lbl.textContent = lang;
|
|
404
|
+
code.parentElement.style.position = 'relative';
|
|
405
|
+
code.parentElement.appendChild(lbl);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── File path linking ──
|
|
411
|
+
function linkifyFilePaths(el) {
|
|
412
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
413
|
+
const nodes = [];
|
|
414
|
+
while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
415
|
+
const pathRe = /(?:(?:[A-Za-z]:)?(?:[/\\][\w.@\-]+){2,}(?::(\d+))?)(?=\s|$|[)"',;])/g;
|
|
416
|
+
for (const node of nodes) {
|
|
417
|
+
if (node.parentElement?.closest('pre, code, a')) continue;
|
|
418
|
+
const text = node.textContent;
|
|
419
|
+
pathRe.lastIndex = 0;
|
|
420
|
+
if (!pathRe.test(text)) continue;
|
|
421
|
+
pathRe.lastIndex = 0;
|
|
422
|
+
const frag = document.createDocumentFragment();
|
|
423
|
+
let last = 0, match;
|
|
424
|
+
while ((match = pathRe.exec(text))) {
|
|
425
|
+
if (match.index > last) frag.appendChild(document.createTextNode(text.slice(last, match.index)));
|
|
426
|
+
const a = document.createElement('a');
|
|
427
|
+
a.className = 'file-link';
|
|
428
|
+
a.href = '#';
|
|
429
|
+
a.textContent = match[0];
|
|
430
|
+
a.title = 'Click to copy path';
|
|
431
|
+
const path = match[0].replace(/:\d+$/, '');
|
|
432
|
+
a.addEventListener('click', (e) => {
|
|
433
|
+
e.preventDefault();
|
|
434
|
+
_clipCopy(path);
|
|
435
|
+
a.style.opacity = '0.5';
|
|
436
|
+
setTimeout(() => a.style.opacity = '', 300);
|
|
437
|
+
});
|
|
438
|
+
frag.appendChild(a);
|
|
439
|
+
last = match.index + match[0].length;
|
|
440
|
+
}
|
|
441
|
+
if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
|
|
442
|
+
if (last > 0) node.parentNode.replaceChild(frag, node);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── Cost loading ──
|
|
447
|
+
async function loadMonthlyCost() {
|
|
448
|
+
try {
|
|
449
|
+
const res = await fetch('/api/claude-skin/cost').then(r => r.json());
|
|
450
|
+
if (res.month) {
|
|
451
|
+
totalCost = res.month.totalUsd || 0;
|
|
452
|
+
const monthLabel = new Date().toLocaleString('en', { month: 'short' });
|
|
453
|
+
$cost.textContent = `${monthLabel}: $${totalCost.toFixed(2)}`;
|
|
454
|
+
$cost.title = `${res.month.queries || 0} queries this month`;
|
|
455
|
+
}
|
|
456
|
+
} catch {}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── File attachment ──
|
|
460
|
+
const TEXT_EXTENSIONS = new Set([
|
|
461
|
+
'txt','md','js','ts','jsx','tsx','json','html','css','scss','less','xml','svg',
|
|
462
|
+
'py','rb','go','rs','java','c','cpp','h','hpp','cs','php','sh','bash','zsh',
|
|
463
|
+
'yml','yaml','toml','ini','cfg','conf','env','gitignore','dockerignore',
|
|
464
|
+
'sql','graphql','proto','csv','tsv','log','diff','patch','vue','svelte',
|
|
465
|
+
'mjs','cjs','mts','cts','astro','mdx','rst','tex','lua','r','swift','kt',
|
|
466
|
+
'dockerfile','makefile','cmake','gradle','bat','ps1','fish',
|
|
467
|
+
]);
|
|
468
|
+
function isTextFile(name) {
|
|
469
|
+
const ext = name.split('.').pop()?.toLowerCase() || '';
|
|
470
|
+
return TEXT_EXTENSIONS.has(ext) || !name.includes('.');
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function buildPromptWithAttachments(userText) {
|
|
474
|
+
if (!attachedFiles.length) return userText;
|
|
475
|
+
let prompt = '';
|
|
476
|
+
for (const f of attachedFiles) prompt += `<file path="${f.name}">\n${f.content}\n</file>\n\n`;
|
|
477
|
+
prompt += userText;
|
|
478
|
+
attachedFiles = [];
|
|
479
|
+
updateAttachBadge();
|
|
480
|
+
return prompt;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function updateAttachBadge() {
|
|
484
|
+
const badge = $('chat-attach-badge');
|
|
485
|
+
if (badge) {
|
|
486
|
+
badge.textContent = attachedFiles.length || '';
|
|
487
|
+
badge.hidden = !attachedFiles.length;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ── Session history ──
|
|
492
|
+
const LABEL_PREFIX = 'synabun-chat-label:';
|
|
493
|
+
function getLabel(id) { try { return storage.getItem(LABEL_PREFIX + id) || ''; } catch { return ''; } }
|
|
494
|
+
function cleanPrompt(raw) { return (raw || '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); }
|
|
495
|
+
function relDate(d) {
|
|
496
|
+
const ms = Date.now() - new Date(d).getTime();
|
|
497
|
+
const m = Math.floor(ms / 60000), h = Math.floor(ms / 3600000), dy = Math.floor(ms / 86400000);
|
|
498
|
+
if (m < 1) return 'now'; if (m < 60) return m + 'm ago'; if (h < 24) return h + 'h ago';
|
|
499
|
+
return dy + 'd ago';
|
|
500
|
+
}
|
|
501
|
+
function timeGroup(d) {
|
|
502
|
+
const now = new Date(), dt = new Date(d);
|
|
503
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
504
|
+
const diff = today - new Date(dt.getFullYear(), dt.getMonth(), dt.getDate());
|
|
505
|
+
if (diff <= 0) return 'Today'; if (diff <= 86400000) return 'Yesterday';
|
|
506
|
+
if (diff <= 604800000) return 'This Week'; return 'Older';
|
|
507
|
+
}
|
|
508
|
+
function trunc(s, n) { return s.length > n ? s.slice(0, n) + '...' : s; }
|
|
509
|
+
|
|
510
|
+
async function renderSessionMenu() {
|
|
511
|
+
const menu = $('chat-session-menu');
|
|
512
|
+
if (!menu) return;
|
|
513
|
+
menu.innerHTML = '<div class="sess-loading">loading...</div>';
|
|
514
|
+
try {
|
|
515
|
+
const currentProject = $project.value || undefined;
|
|
516
|
+
const data = await fetchClaudeSessions({ limit: 30, project: currentProject });
|
|
517
|
+
if (!data?.projects?.length) { menu.innerHTML = '<div class="sess-loading">no sessions</div>'; return; }
|
|
518
|
+
let html = '<div class="sess-new">+ New chat</div>';
|
|
519
|
+
for (const proj of data.projects) {
|
|
520
|
+
const groups = {};
|
|
521
|
+
for (const s of (proj.sessions || [])) {
|
|
522
|
+
if (s.deleted) continue;
|
|
523
|
+
const g = timeGroup(s.modified || s.created);
|
|
524
|
+
(groups[g] = groups[g] || []).push(s);
|
|
525
|
+
}
|
|
526
|
+
for (const g of ['Today', 'Yesterday', 'This Week', 'Older']) {
|
|
527
|
+
if (!groups[g]?.length) continue;
|
|
528
|
+
html += `<div class="sess-group">${esc(g)}</div>`;
|
|
529
|
+
for (const s of groups[g]) {
|
|
530
|
+
const label = getLabel(s.sessionId) || cleanPrompt(s.firstPrompt) || 'Empty session';
|
|
531
|
+
const active = s.sessionId === sessionId ? ' active' : '';
|
|
532
|
+
html += `<div class="sess-item${active}" data-sid="${esc(s.sessionId)}">`;
|
|
533
|
+
html += `<div class="sess-prompt">${esc(trunc(label, 60))}</div>`;
|
|
534
|
+
html += `<div class="sess-meta"><span>${relDate(s.modified || s.created)}</span>`;
|
|
535
|
+
if (s.messageCount) html += `<span>${s.messageCount} msgs</span>`;
|
|
536
|
+
html += `</div></div>`;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
menu.innerHTML = html;
|
|
541
|
+
menu.querySelector('.sess-new')?.addEventListener('click', () => {
|
|
542
|
+
newSession();
|
|
543
|
+
menu.classList.remove('open');
|
|
544
|
+
});
|
|
545
|
+
menu.querySelectorAll('.sess-item').forEach(el => {
|
|
546
|
+
el.addEventListener('click', () => {
|
|
547
|
+
selectSession(el.dataset.sid);
|
|
548
|
+
menu.classList.remove('open');
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
} catch {
|
|
552
|
+
menu.innerHTML = '<div class="sess-loading">failed to load</div>';
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function selectSession(sid) {
|
|
557
|
+
sessionId = sid;
|
|
558
|
+
if (sid) tabStore.setItem(STOR.session, sid);
|
|
559
|
+
else tabStore.removeItem(STOR.session);
|
|
560
|
+
$msgs.innerHTML = '';
|
|
561
|
+
if (sid) loadSessionHistory(sid);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function loadSessionHistory(sid) {
|
|
565
|
+
const project = $project.value || undefined;
|
|
566
|
+
const params = new URLSearchParams({ limit: '500' });
|
|
567
|
+
if (project) params.set('project', project);
|
|
568
|
+
try {
|
|
569
|
+
const res = await fetch(`/api/claude-code/sessions/${encodeURIComponent(sid)}/messages?${params}`);
|
|
570
|
+
const data = await res.json();
|
|
571
|
+
if (!data.messages?.length) {
|
|
572
|
+
$msgs.innerHTML = '<div class="msg-status">No messages in this session</div>';
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
for (const m of data.messages) {
|
|
576
|
+
if (m.role === 'user') {
|
|
577
|
+
appendUser(m.text);
|
|
578
|
+
} else if (m.role === 'assistant') {
|
|
579
|
+
const msg = { content: [] };
|
|
580
|
+
if (m.text) msg.content.push({ type: 'text', text: m.text });
|
|
581
|
+
if (m.tools) for (const t of m.tools) {
|
|
582
|
+
if (typeof t === 'string') msg.content.push({ type: 'tool_use', name: t, id: '', input: {} });
|
|
583
|
+
else msg.content.push({ type: 'tool_use', ...t });
|
|
584
|
+
}
|
|
585
|
+
renderAssistant(msg);
|
|
586
|
+
} else if (m.role === 'tool_result' && m.toolUseId) {
|
|
587
|
+
updateToolResult({ tool_use_id: m.toolUseId, content: m.text || '', is_error: m.isError });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (data.total > data.messages.length) {
|
|
591
|
+
const note = document.createElement('div'); note.className = 'msg-status';
|
|
592
|
+
note.textContent = `Showing last ${data.messages.length} of ${data.total} messages`;
|
|
593
|
+
$msgs.prepend(note);
|
|
594
|
+
}
|
|
595
|
+
scrollEnd();
|
|
596
|
+
} catch {
|
|
597
|
+
$msgs.innerHTML = '<div class="msg-status">Failed to load history</div>';
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ── UI helpers ──
|
|
602
|
+
function appendUser(text) {
|
|
603
|
+
const el = document.createElement('div');
|
|
604
|
+
el.className = 'msg msg-user';
|
|
605
|
+
const bubble = document.createElement('div');
|
|
606
|
+
bubble.className = 'msg-bubble';
|
|
607
|
+
bubble.textContent = text;
|
|
608
|
+
el.appendChild(bubble);
|
|
609
|
+
$msgs.appendChild(el);
|
|
610
|
+
scrollEnd();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function showThinking() {
|
|
614
|
+
hideThinking();
|
|
615
|
+
if (!sendStartTime) sendStartTime = Date.now();
|
|
616
|
+
const el = document.createElement('div');
|
|
617
|
+
el.className = 'thinking';
|
|
618
|
+
el.innerHTML = '<div class="think-avatar">' + CLAUDE_ICON + '</div><div class="think-dots"><span></span><span></span><span></span></div><span class="think-timer"></span>';
|
|
619
|
+
$msgs.appendChild(el);
|
|
620
|
+
thinkingEl = el;
|
|
621
|
+
thinkTimerInterval = setInterval(() => {
|
|
622
|
+
const sec = Math.round((Date.now() - sendStartTime) / 1000);
|
|
623
|
+
const timer = el.querySelector('.think-timer');
|
|
624
|
+
if (timer) timer.textContent = sec > 0 ? `${sec}s` : '';
|
|
625
|
+
}, 1000);
|
|
626
|
+
scrollEnd();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function hideThinking() {
|
|
630
|
+
if (thinkTimerInterval) { clearInterval(thinkTimerInterval); thinkTimerInterval = null; }
|
|
631
|
+
if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function repositionThinking() {
|
|
635
|
+
if (thinkingEl && running && thinkingEl !== $msgs.lastElementChild) {
|
|
636
|
+
$msgs.appendChild(thinkingEl); // appendChild moves existing child — no remove() needed
|
|
637
|
+
scrollEnd();
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function appendStatus(text) {
|
|
642
|
+
const el = document.createElement('div');
|
|
643
|
+
el.className = 'msg-status';
|
|
644
|
+
el.textContent = text;
|
|
645
|
+
$msgs.appendChild(el);
|
|
646
|
+
scrollEnd();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function appendError(text) {
|
|
650
|
+
const el = document.createElement('div');
|
|
651
|
+
el.className = 'msg-error';
|
|
652
|
+
el.textContent = text;
|
|
653
|
+
$msgs.appendChild(el);
|
|
654
|
+
scrollEnd();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function scrollEnd() {
|
|
658
|
+
requestAnimationFrame(() => { $msgs.scrollTop = $msgs.scrollHeight; });
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function setRunning(r) {
|
|
662
|
+
running = r;
|
|
663
|
+
$send.disabled = false;
|
|
664
|
+
if (r) {
|
|
665
|
+
$send.classList.add('abort');
|
|
666
|
+
$send.title = 'Stop';
|
|
667
|
+
$send.innerHTML = '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="1" fill="currentColor"/></svg>';
|
|
668
|
+
$input.disabled = true;
|
|
669
|
+
} else {
|
|
670
|
+
$send.classList.remove('abort');
|
|
671
|
+
$send.title = 'Send (Enter)';
|
|
672
|
+
$send.innerHTML = '<svg viewBox="0 0 24 24"><path d="M22 2L11 13" stroke="currentColor" stroke-width="2" fill="none"/><path d="M22 2L15 22L11 13L2 9L22 2Z" stroke="currentColor" stroke-width="2" fill="none"/></svg>';
|
|
673
|
+
$input.disabled = false;
|
|
674
|
+
$input.focus();
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function finish() {
|
|
679
|
+
hideThinking();
|
|
680
|
+
sendStartTime = null;
|
|
681
|
+
setRunning(false);
|
|
682
|
+
pendingAskRequestId = null;
|
|
683
|
+
pendingAskBufferedAnswer = null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ── AskUserQuestion / control_request / permissions ──
|
|
687
|
+
let pendingAsk = null;
|
|
688
|
+
let pendingAskRequestId = null;
|
|
689
|
+
let pendingAskBufferedAnswer = null;
|
|
690
|
+
let askRenderedViaControl = false;
|
|
691
|
+
let attachedImages = [];
|
|
692
|
+
const _autoAllowTools = new Set();
|
|
693
|
+
|
|
694
|
+
function handleControlRequest(msg) {
|
|
695
|
+
const req = msg.request || msg;
|
|
696
|
+
const requestId = msg.request_id || req.request_id;
|
|
697
|
+
const toolName = req.tool_name;
|
|
698
|
+
console.log('[claude-skin] handleControlRequest:', toolName, 'request_id:', requestId, 'keys:', Object.keys(msg).join(','));
|
|
699
|
+
if (!requestId) return;
|
|
700
|
+
|
|
701
|
+
if (toolName === 'AskUserQuestion') {
|
|
702
|
+
pendingAskRequestId = requestId;
|
|
703
|
+
// Render if not already rendered (prevents duplicate cards from proactive + denial control_requests)
|
|
704
|
+
if (!pendingAsk && !askRenderedViaControl) renderAskUserQuestion(requestId, req.input);
|
|
705
|
+
// Flush buffered answer if user already clicked before control_request arrived
|
|
706
|
+
if (pendingAskBufferedAnswer) {
|
|
707
|
+
const buf = pendingAskBufferedAnswer;
|
|
708
|
+
pendingAskBufferedAnswer = null;
|
|
709
|
+
sendControlResponse(requestId, buf.questions, buf.answers);
|
|
710
|
+
}
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (toolName) {
|
|
715
|
+
if (_autoAllowTools.has(toolName)) {
|
|
716
|
+
sendPermissionResponse(requestId, 'allow');
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
renderPermissionPrompt(requestId, req);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function renderAskUserQuestion(requestId, input) {
|
|
725
|
+
hideThinking();
|
|
726
|
+
askRenderedViaControl = true;
|
|
727
|
+
const questions = input?.questions || [input];
|
|
728
|
+
const allQuestions = input.questions || questions;
|
|
729
|
+
const el = document.createElement('div');
|
|
730
|
+
el.className = 'msg msg-assistant';
|
|
731
|
+
|
|
732
|
+
const avatar = document.createElement('div');
|
|
733
|
+
avatar.className = 'msg-avatar';
|
|
734
|
+
avatar.innerHTML = CLAUDE_ICON;
|
|
735
|
+
el.appendChild(avatar);
|
|
736
|
+
|
|
737
|
+
const wrap = document.createElement('div');
|
|
738
|
+
wrap.className = 'msg-content';
|
|
739
|
+
|
|
740
|
+
// Batched answer collection
|
|
741
|
+
const batchAnswers = {};
|
|
742
|
+
const totalQuestions = questions.length;
|
|
743
|
+
const submitBar = document.createElement('div');
|
|
744
|
+
submitBar.className = 'ask-submit-bar';
|
|
745
|
+
const submitBtn = document.createElement('button');
|
|
746
|
+
submitBtn.className = 'ask-submit';
|
|
747
|
+
submitBtn.disabled = true;
|
|
748
|
+
submitBtn.textContent = `Submit (0/${totalQuestions})`;
|
|
749
|
+
submitBar.appendChild(submitBtn);
|
|
750
|
+
|
|
751
|
+
function updateSubmitState() {
|
|
752
|
+
const answered = Object.keys(batchAnswers).length;
|
|
753
|
+
submitBtn.textContent = `Submit (${answered}/${totalQuestions})`;
|
|
754
|
+
submitBtn.disabled = answered < totalQuestions;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
for (const q of questions) {
|
|
758
|
+
const questionText = q.question || q.text || q.header || '';
|
|
759
|
+
const isMultiSelect = q.multiSelect === true;
|
|
760
|
+
const card = document.createElement('div');
|
|
761
|
+
card.className = 'ask-card';
|
|
762
|
+
if (q.header) {
|
|
763
|
+
const hdr = document.createElement('div');
|
|
764
|
+
hdr.className = 'ask-header';
|
|
765
|
+
hdr.textContent = q.header;
|
|
766
|
+
card.appendChild(hdr);
|
|
767
|
+
}
|
|
768
|
+
if (questionText && questionText !== q.header) {
|
|
769
|
+
const qEl = document.createElement('div');
|
|
770
|
+
qEl.className = 'ask-question';
|
|
771
|
+
qEl.textContent = questionText;
|
|
772
|
+
card.appendChild(qEl);
|
|
773
|
+
}
|
|
774
|
+
if (isMultiSelect) {
|
|
775
|
+
const hint = document.createElement('div');
|
|
776
|
+
hint.className = 'ask-multi-hint';
|
|
777
|
+
hint.textContent = 'Select all that apply';
|
|
778
|
+
card.appendChild(hint);
|
|
779
|
+
}
|
|
780
|
+
if (q.options?.length) {
|
|
781
|
+
const opts = document.createElement('div');
|
|
782
|
+
opts.className = 'ask-options';
|
|
783
|
+
if (isMultiSelect) opts.classList.add('multi');
|
|
784
|
+
for (const opt of q.options) {
|
|
785
|
+
const optLabel = typeof opt === 'string' ? opt : (opt.label || opt.value || String(opt));
|
|
786
|
+
const optDesc = typeof opt === 'string' ? '' : (opt.description || '');
|
|
787
|
+
const btn = document.createElement('button');
|
|
788
|
+
btn.className = 'ask-option';
|
|
789
|
+
btn.innerHTML = `<span class="ask-option-wrap"><span class="ask-option-label">${esc(optLabel)}</span>` +
|
|
790
|
+
(optDesc ? `<span class="ask-option-desc">${esc(optDesc)}</span>` : '') + `</span>`;
|
|
791
|
+
btn.addEventListener('click', () => {
|
|
792
|
+
if (isMultiSelect) {
|
|
793
|
+
btn.classList.toggle('selected');
|
|
794
|
+
const selected = [];
|
|
795
|
+
opts.querySelectorAll('.ask-option.selected').forEach(b => {
|
|
796
|
+
selected.push(b.querySelector('.ask-option-label').textContent);
|
|
797
|
+
});
|
|
798
|
+
if (selected.length > 0) batchAnswers[questionText] = selected.join(', ');
|
|
799
|
+
else delete batchAnswers[questionText];
|
|
800
|
+
} else {
|
|
801
|
+
opts.querySelectorAll('.ask-option').forEach(b => b.classList.remove('selected'));
|
|
802
|
+
btn.classList.add('selected');
|
|
803
|
+
batchAnswers[questionText] = optLabel;
|
|
804
|
+
}
|
|
805
|
+
updateSubmitState();
|
|
806
|
+
});
|
|
807
|
+
opts.appendChild(btn);
|
|
808
|
+
}
|
|
809
|
+
card.appendChild(opts);
|
|
810
|
+
} else {
|
|
811
|
+
const textInput = document.createElement('input');
|
|
812
|
+
textInput.type = 'text';
|
|
813
|
+
textInput.className = 'ask-text-input';
|
|
814
|
+
textInput.placeholder = 'Type your answer...';
|
|
815
|
+
textInput.addEventListener('input', () => {
|
|
816
|
+
if (textInput.value.trim()) batchAnswers[questionText] = textInput.value.trim();
|
|
817
|
+
else delete batchAnswers[questionText];
|
|
818
|
+
updateSubmitState();
|
|
819
|
+
});
|
|
820
|
+
card.appendChild(textInput);
|
|
821
|
+
}
|
|
822
|
+
wrap.appendChild(card);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Submit button — sends all answers as a batch
|
|
826
|
+
submitBtn.addEventListener('click', () => {
|
|
827
|
+
wrap.querySelectorAll('.ask-option').forEach(b => { b.disabled = true; });
|
|
828
|
+
wrap.querySelectorAll('.ask-text-input').forEach(i => { i.disabled = true; });
|
|
829
|
+
submitBtn.disabled = true;
|
|
830
|
+
submitBtn.textContent = 'Submitted';
|
|
831
|
+
sendControlResponse(requestId, allQuestions, batchAnswers);
|
|
832
|
+
});
|
|
833
|
+
wrap.appendChild(submitBar);
|
|
834
|
+
|
|
835
|
+
el.appendChild(wrap);
|
|
836
|
+
$msgs.appendChild(el);
|
|
837
|
+
scrollEnd();
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function sendControlResponse(requestId, questions, answers) {
|
|
841
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
842
|
+
const rid = requestId || pendingAskRequestId;
|
|
843
|
+
if (!rid) {
|
|
844
|
+
// control_request hasn't arrived yet — buffer and send when it does
|
|
845
|
+
pendingAskBufferedAnswer = { questions, answers };
|
|
846
|
+
showThinking();
|
|
847
|
+
setRunning(true);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
ws.send(JSON.stringify({
|
|
851
|
+
type: 'control_response',
|
|
852
|
+
request_id: rid,
|
|
853
|
+
response: { behavior: 'allow', updatedInput: { questions, answers } },
|
|
854
|
+
}));
|
|
855
|
+
pendingAskRequestId = null;
|
|
856
|
+
pendingAskBufferedAnswer = null;
|
|
857
|
+
askRenderedViaControl = false;
|
|
858
|
+
showThinking();
|
|
859
|
+
setRunning(true);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// ── Permission prompts ──
|
|
863
|
+
function renderPermissionPrompt(requestId, req) {
|
|
864
|
+
hideThinking();
|
|
865
|
+
setRunning(false);
|
|
866
|
+
|
|
867
|
+
const toolName = req.tool_name || 'Unknown';
|
|
868
|
+
const input = req.input || {};
|
|
869
|
+
const iconMap = { Read:'F', Edit:'E', Write:'W', Bash:'$', Glob:'*', Grep:'?', Agent:'A', WebSearch:'S', WebFetch:'U' };
|
|
870
|
+
|
|
871
|
+
let detail = '';
|
|
872
|
+
if (['Read','Edit','Write'].includes(toolName)) detail = input.file_path || '';
|
|
873
|
+
else if (toolName === 'Bash') detail = input.command || '';
|
|
874
|
+
else if (toolName === 'Glob' || toolName === 'Grep') detail = input.pattern || '';
|
|
875
|
+
else if (toolName === 'Agent') detail = input.description || (input.prompt || '').slice(0, 80);
|
|
876
|
+
else detail = Object.keys(input).length ? JSON.stringify(input).slice(0, 120) : '';
|
|
877
|
+
|
|
878
|
+
const el = document.createElement('div');
|
|
879
|
+
el.className = 'msg msg-assistant';
|
|
880
|
+
const avatar = document.createElement('div');
|
|
881
|
+
avatar.className = 'msg-avatar';
|
|
882
|
+
avatar.innerHTML = CLAUDE_ICON;
|
|
883
|
+
el.appendChild(avatar);
|
|
884
|
+
|
|
885
|
+
const wrap = document.createElement('div');
|
|
886
|
+
wrap.className = 'msg-content';
|
|
887
|
+
|
|
888
|
+
const card = document.createElement('div');
|
|
889
|
+
card.className = 'perm-card';
|
|
890
|
+
|
|
891
|
+
const toolLine = document.createElement('div');
|
|
892
|
+
toolLine.className = 'perm-tool-line';
|
|
893
|
+
const icon = document.createElement('span');
|
|
894
|
+
icon.className = 'perm-tool-icon';
|
|
895
|
+
icon.textContent = iconMap[toolName] || '#';
|
|
896
|
+
const name = document.createElement('span');
|
|
897
|
+
name.className = 'perm-tool-name';
|
|
898
|
+
name.textContent = toolName;
|
|
899
|
+
toolLine.append(icon, name);
|
|
900
|
+
card.appendChild(toolLine);
|
|
901
|
+
|
|
902
|
+
if (detail) {
|
|
903
|
+
const detailEl = document.createElement('div');
|
|
904
|
+
detailEl.className = 'perm-detail';
|
|
905
|
+
detailEl.textContent = detail;
|
|
906
|
+
card.appendChild(detailEl);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const actions = document.createElement('div');
|
|
910
|
+
actions.className = 'perm-actions';
|
|
911
|
+
|
|
912
|
+
const allowBtn = document.createElement('button');
|
|
913
|
+
allowBtn.className = 'perm-btn perm-btn-allow';
|
|
914
|
+
allowBtn.textContent = 'Allow';
|
|
915
|
+
|
|
916
|
+
const denyBtn = document.createElement('button');
|
|
917
|
+
denyBtn.className = 'perm-btn perm-btn-deny';
|
|
918
|
+
denyBtn.textContent = 'Deny';
|
|
919
|
+
|
|
920
|
+
const alwaysLbl = document.createElement('label');
|
|
921
|
+
alwaysLbl.className = 'perm-always';
|
|
922
|
+
const alwaysCb = document.createElement('input');
|
|
923
|
+
alwaysCb.type = 'checkbox';
|
|
924
|
+
alwaysLbl.append(alwaysCb, document.createTextNode('Always'));
|
|
925
|
+
|
|
926
|
+
const resolve = (behavior) => {
|
|
927
|
+
if (alwaysCb.checked && behavior === 'allow') _autoAllowTools.add(toolName);
|
|
928
|
+
card.classList.add('resolved');
|
|
929
|
+
allowBtn.disabled = true;
|
|
930
|
+
denyBtn.disabled = true;
|
|
931
|
+
sendPermissionResponse(requestId, behavior);
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
allowBtn.addEventListener('click', () => resolve('allow'));
|
|
935
|
+
denyBtn.addEventListener('click', () => resolve('deny'));
|
|
936
|
+
|
|
937
|
+
actions.append(allowBtn, denyBtn, alwaysLbl);
|
|
938
|
+
card.appendChild(actions);
|
|
939
|
+
wrap.appendChild(card);
|
|
940
|
+
el.appendChild(wrap);
|
|
941
|
+
$msgs.appendChild(el);
|
|
942
|
+
scrollEnd();
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function sendPermissionResponse(requestId, behavior) {
|
|
946
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
947
|
+
ws.send(JSON.stringify({
|
|
948
|
+
type: 'control_response',
|
|
949
|
+
request_id: requestId,
|
|
950
|
+
response: { behavior },
|
|
951
|
+
}));
|
|
952
|
+
showThinking();
|
|
953
|
+
setRunning(true);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// ── Send ──
|
|
957
|
+
function send() {
|
|
958
|
+
const text = $input.value.trim();
|
|
959
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
960
|
+
|
|
961
|
+
if (running) {
|
|
962
|
+
ws.send(JSON.stringify({ type: 'abort' }));
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
if (!text && !attachedImages.length) return;
|
|
966
|
+
|
|
967
|
+
// Slash commands
|
|
968
|
+
if (text.startsWith('/')) {
|
|
969
|
+
const cmd = text.split(/\s/)[0].toLowerCase();
|
|
970
|
+
if (cmd === '/clear') { $input.value = ''; $msgs.innerHTML = ''; return; }
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Pending AskUserQuestion free-text response
|
|
974
|
+
if (pendingAsk && text) {
|
|
975
|
+
$input.value = ''; autoResize();
|
|
976
|
+
appendUser(text); sendStartTime = Date.now(); showThinking(); setRunning(true);
|
|
977
|
+
const answers = {};
|
|
978
|
+
answers[pendingAsk.questionText] = text;
|
|
979
|
+
sendControlResponse(pendingAsk.requestId, pendingAsk.questions, answers);
|
|
980
|
+
pendingAsk = null;
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
$input.value = '';
|
|
985
|
+
autoResize();
|
|
986
|
+
appendUser(text);
|
|
987
|
+
sendStartTime = Date.now();
|
|
988
|
+
showThinking();
|
|
989
|
+
setRunning(true);
|
|
990
|
+
|
|
991
|
+
const prompt = buildPromptWithAttachments(text);
|
|
992
|
+
const msg = {
|
|
993
|
+
type: 'query',
|
|
994
|
+
prompt,
|
|
995
|
+
cwd: $project.value || undefined,
|
|
996
|
+
sessionId: sessionId || undefined,
|
|
997
|
+
model: $model.value || undefined,
|
|
998
|
+
};
|
|
999
|
+
if (attachedImages.length) {
|
|
1000
|
+
msg.images = attachedImages.map(i => ({ base64: i.base64, mediaType: i.mediaType }));
|
|
1001
|
+
attachedImages = [];
|
|
1002
|
+
const preview = document.getElementById('chat-image-preview');
|
|
1003
|
+
if (preview) preview.innerHTML = '';
|
|
1004
|
+
const badge = document.getElementById('chat-attach-badge');
|
|
1005
|
+
if (badge) { badge.textContent = ''; badge.hidden = true; }
|
|
1006
|
+
}
|
|
1007
|
+
ws.send(JSON.stringify(msg));
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function newSession() {
|
|
1011
|
+
if (running) return;
|
|
1012
|
+
sessionId = null;
|
|
1013
|
+
tabStore.removeItem(STOR.session);
|
|
1014
|
+
const div = document.createElement('div');
|
|
1015
|
+
div.className = 'session-divider';
|
|
1016
|
+
div.innerHTML = '<span>new session</span>';
|
|
1017
|
+
$msgs.appendChild(div);
|
|
1018
|
+
scrollEnd();
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function autoResize() {
|
|
1022
|
+
$input.style.height = 'auto';
|
|
1023
|
+
$input.style.height = Math.min($input.scrollHeight, 160) + 'px';
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// ── Init ──
|
|
1027
|
+
async function init() {
|
|
1028
|
+
sessionId = tabStore.getItem(STOR.session) || null;
|
|
1029
|
+
await loadConfig();
|
|
1030
|
+
loadMonthlyCost();
|
|
1031
|
+
connect();
|
|
1032
|
+
|
|
1033
|
+
$input.addEventListener('input', () => {
|
|
1034
|
+
autoResize();
|
|
1035
|
+
$send.disabled = !$input.value.trim() && !running;
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
$input.addEventListener('keydown', (e) => {
|
|
1039
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1040
|
+
e.preventDefault();
|
|
1041
|
+
if (!$send.disabled) send();
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
$send.addEventListener('click', send);
|
|
1046
|
+
|
|
1047
|
+
// Image paste with thumbnail preview
|
|
1048
|
+
const $imgPreview = document.getElementById('chat-image-preview');
|
|
1049
|
+
|
|
1050
|
+
function addImageThumb(dataUrl, idx) {
|
|
1051
|
+
if (!$imgPreview) return;
|
|
1052
|
+
const thumb = document.createElement('div');
|
|
1053
|
+
thumb.className = 'chat-thumb';
|
|
1054
|
+
thumb.dataset.idx = idx;
|
|
1055
|
+
const img = document.createElement('img');
|
|
1056
|
+
img.src = dataUrl;
|
|
1057
|
+
const x = document.createElement('button');
|
|
1058
|
+
x.className = 'chat-thumb-x';
|
|
1059
|
+
x.textContent = '\u00d7';
|
|
1060
|
+
x.addEventListener('click', (e) => {
|
|
1061
|
+
e.stopPropagation();
|
|
1062
|
+
const i = parseInt(thumb.dataset.idx, 10);
|
|
1063
|
+
attachedImages.splice(i, 1);
|
|
1064
|
+
thumb.remove();
|
|
1065
|
+
$imgPreview.querySelectorAll('.chat-thumb').forEach((t, j) => { t.dataset.idx = j; });
|
|
1066
|
+
updateAttachBadge();
|
|
1067
|
+
if (!attachedImages.length && !$input.value.trim()) $send.disabled = true;
|
|
1068
|
+
});
|
|
1069
|
+
thumb.append(img, x);
|
|
1070
|
+
$imgPreview.appendChild(thumb);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function updateAttachBadge() {
|
|
1074
|
+
const badge = document.getElementById('chat-attach-badge');
|
|
1075
|
+
if (badge) {
|
|
1076
|
+
const count = attachedImages.length + (typeof attachedFiles !== 'undefined' ? attachedFiles.length : 0);
|
|
1077
|
+
badge.textContent = count || '';
|
|
1078
|
+
badge.hidden = !count;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
$input.addEventListener('paste', (e) => {
|
|
1083
|
+
const items = e.clipboardData?.items;
|
|
1084
|
+
if (!items) return;
|
|
1085
|
+
for (const item of items) {
|
|
1086
|
+
if (!item.type.startsWith('image/')) continue;
|
|
1087
|
+
e.preventDefault();
|
|
1088
|
+
const blob = item.getAsFile();
|
|
1089
|
+
if (!blob) continue;
|
|
1090
|
+
const reader = new FileReader();
|
|
1091
|
+
reader.onload = () => {
|
|
1092
|
+
const dataUrl = reader.result;
|
|
1093
|
+
const base64 = dataUrl.split(',')[1];
|
|
1094
|
+
attachedImages.push({ base64, mediaType: item.type });
|
|
1095
|
+
addImageThumb(dataUrl, attachedImages.length - 1);
|
|
1096
|
+
updateAttachBadge();
|
|
1097
|
+
$send.disabled = false;
|
|
1098
|
+
};
|
|
1099
|
+
reader.readAsDataURL(blob);
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
$newBtn.addEventListener('click', newSession);
|
|
1104
|
+
|
|
1105
|
+
// Session history menu
|
|
1106
|
+
const $sessBtn = $('chat-session-btn');
|
|
1107
|
+
const $sessMenu = $('chat-session-menu');
|
|
1108
|
+
if ($sessBtn && $sessMenu) {
|
|
1109
|
+
$sessBtn.addEventListener('click', () => {
|
|
1110
|
+
const isOpen = $sessMenu.classList.toggle('open');
|
|
1111
|
+
if (isOpen) renderSessionMenu();
|
|
1112
|
+
});
|
|
1113
|
+
document.addEventListener('click', (e) => {
|
|
1114
|
+
if (!e.target.closest('.toolbar-session-wrap')) $sessMenu?.classList.remove('open');
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// File attachment
|
|
1119
|
+
const $attach = $('chat-attach-btn');
|
|
1120
|
+
if ($attach) {
|
|
1121
|
+
const fileInput = document.createElement('input');
|
|
1122
|
+
fileInput.type = 'file'; fileInput.multiple = true; fileInput.style.display = 'none';
|
|
1123
|
+
document.body.appendChild(fileInput);
|
|
1124
|
+
$attach.addEventListener('click', () => fileInput.click());
|
|
1125
|
+
fileInput.addEventListener('change', async () => {
|
|
1126
|
+
for (const file of fileInput.files) {
|
|
1127
|
+
if (!isTextFile(file.name)) {
|
|
1128
|
+
appendStatus(`Skipped "${file.name}" — only text/code files supported`);
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
if (file.size > 100000) {
|
|
1132
|
+
appendStatus(`Skipped "${file.name}" — file too large (${Math.round(file.size/1024)}KB, max 100KB)`);
|
|
1133
|
+
continue;
|
|
1134
|
+
}
|
|
1135
|
+
const text = await file.text();
|
|
1136
|
+
attachedFiles.push({ name: file.name, content: text.slice(0, 50000) });
|
|
1137
|
+
}
|
|
1138
|
+
updateAttachBadge();
|
|
1139
|
+
fileInput.value = '';
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// Session monitor widget
|
|
1144
|
+
const $monitorBtn = $('chat-monitor-btn');
|
|
1145
|
+
const $monitorContainer = $('session-monitor-container');
|
|
1146
|
+
let monitorWidget = null;
|
|
1147
|
+
if ($monitorBtn && $monitorContainer) {
|
|
1148
|
+
$monitorBtn.addEventListener('click', () => {
|
|
1149
|
+
if ($monitorContainer.hidden) {
|
|
1150
|
+
$monitorContainer.hidden = false;
|
|
1151
|
+
$monitorBtn.classList.add('active');
|
|
1152
|
+
if (!monitorWidget) monitorWidget = mountSessionWidget($monitorContainer);
|
|
1153
|
+
} else {
|
|
1154
|
+
$monitorContainer.hidden = true;
|
|
1155
|
+
$monitorBtn.classList.remove('active');
|
|
1156
|
+
}
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// Load history for saved session
|
|
1161
|
+
if (sessionId) {
|
|
1162
|
+
loadSessionHistory(sessionId);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
$input.focus();
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
init();
|