@preprocess-cn/nano-code-web 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,199 @@
1
+ # nano-code-web
2
+
3
+ nano-code 的 Web 显示插件(v0.1.2)。通过 SSE(Server-Sent Events)将 nano-code 的 DisplayPlugin 事件推送到浏览器前端,提供图形化交互界面。
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ nano-code 核心 → DisplayPlugin 事件 → display.ts → SSE → public/index.html
9
+ ```
10
+
11
+ 三层结构:
12
+
13
+ 1. **`src/server.ts`** — `NanoCodeWebServer`: HTTP + SSE 服务器
14
+ - `GET /events` — SSE 端点,推送实时事件
15
+ - `POST /input` — 接收用户输入
16
+ - `POST /cancel` — 取消当前请求
17
+ - `POST /confirm` — 处理前端授权确认(Allow/Deny)
18
+ - `POST /question-answer` — 处理前端问询对话框答案提交
19
+ - `POST /mode-toggle` — 切换 plan/normal 模式(Shift+Tab)
20
+ - `GET /` — 前端页面(先查找 `public/` 或 `dist/` 下同路径文件,存在即返回,否则返回 `index.html`)
21
+ - `GET /vendor/*` — 第三方 JS/CSS 库
22
+ - `GET /health` — 健康检查
23
+ - `GET /web-files/*` — 临时文件服务
24
+
25
+ 2. **`src/display.ts`** — DisplayPlugin 实现
26
+ - 将 nano-code 的 `onStart/onStreamChunk/onToolCall/onStatus` 等事件广播为 SSE
27
+ - 通过 NanoPlugin `onBeforeToolCall/onAfterToolCall` 追踪工具调用 ID
28
+
29
+ 3. **`public/index.html`** + **`public/app.js`** + **`public/dialog.js`** + **`public/style.css`** — 单页前端(v0.1.2 将 CSS/JS 从内联提取为独立文件)
30
+ - Markdown 渲染(`markdown-it` + `highlight.js` 语法高亮 + `DOMPurify` 安全消毒)
31
+ - 流式输出优化:debounce(150ms)+ 代码块闭合检测(``` 成对时立即渲染)
32
+ - 工具卡片点击展开/收拢(参数 + 返回结果)
33
+ - 问询对话框(`dialog.js` 模块,三屏:选择 → 自定义输入 → 确认,Esc 取消)
34
+ - 后台任务状态条(`background:task` 事件驱动,自动消隐)
35
+ - Plan mode 指示器(`● PLAN`/`○ normal`,Shift+Tab 切换)
36
+ - 状态栏(`status:bar` 事件驱动,显示 mode/tasks 等段落)
37
+ - 纯 vanilla JS,无框架依赖;第三方库(markdown-it、DOMPurify、highlight.js)通过构建脚本复制到 `dist/vendor/` 本地加载,无需 CDN
38
+
39
+ ## Quick Start
40
+
41
+ ### 构建
42
+
43
+ ```bash
44
+ npm install
45
+ npm run build
46
+ ```
47
+
48
+ 构建输出 `dist/display.js` + `dist/index.html` + `dist/app.js` + `dist/dialog.js` + `dist/style.css` + `dist/vendor/`(markdown-it、DOMPurify、highlight.js 运行时库)。
49
+
50
+ ### 配置 nano-code
51
+
52
+ 在 `.nano-code.yaml` 中配置:
53
+
54
+ ```yaml
55
+ display:
56
+ plugin: ./dist/display.js
57
+ ```
58
+
59
+ ### 启动
60
+
61
+ ```bash
62
+ nano-code
63
+ ```
64
+
65
+ 终端会显示 Web UI 地址(默认 `http://localhost:3030`)。启动时可配合以下参数:
66
+
67
+ ```bash
68
+ nano-code --think # 显示思考过程
69
+ nano-code --debug # 显示调试信息
70
+ ```
71
+
72
+ ## 配置选项
73
+
74
+ nano-code 通过 `session:start` 事件的 `config` 对象传递配置:
75
+
76
+ | 字段 | 类型 | 说明 |
77
+ |------|------|------|
78
+ | `showThink` | `boolean` | 是否显示 `<think>...</think>` 内容 |
79
+ | `debug` | `boolean` | 是否显示调试事件 |
80
+ | `greeting` | `string` | 会话欢迎语 |
81
+ | `hasTools` | `boolean` | 是否有可用工具 |
82
+
83
+ ## 多标签页同步
84
+
85
+ 多个浏览器标签页连接同一后端时,所有页面的消息和状态保持同步:
86
+
87
+ - **消息同步**:`user:input`、`stream:chunk` 等事件通过 `broadcast()` 发送给所有连接的 SSE 客户端
88
+ - **Plan mode 同步**:切换 mode 时广播 `status:bar` 事件给所有在线客户端;新客户端连入时查询后端当前 mode 并直接推送
89
+ - **广播健壮性**:`broadcast()` 使用倒序遍历 + try-catch 保护,单个客户端写失败不影响其他客户端,失效客户端自动移除
90
+
91
+ ## 事件流
92
+
93
+ | SSE 事件 | 方向 | 说明 |
94
+ |----------|------|------|
95
+ | `session:start` | 后端 → 前端 | 新会话开始,包含配置 |
96
+ | `session:ready` | 后端 → 前端 | 等待用户输入 |
97
+ | `session:stop` | 后端 → 前端 | 会话结束 |
98
+ | `stream:chunk` | 后端 → 前端 | LLM 文本输出流 |
99
+ | `tool:call` | 后端 → 前端 | 工具调用(显示工具卡片) |
100
+ | `tool:result` | 后端 → 前端 | 工具调用结果(更新卡片状态) |
101
+ | `status` | 后端 → 前端 | 状态更新(thinking/end/信息) |
102
+ | `error` | 后端 → 前端 | 错误信息 |
103
+ | `debug` | 后端 → 前端 | 调试信息 |
104
+ | `agent:turn_start` | 后端 → 前端 | Agent 轮次开始 |
105
+ | `agent:turn_end` | 后端 → 前端 | Agent 轮次结束 |
106
+ | `confirmation:request` | 后端 → 前端 | 授权确认请求(Allow/Deny) |
107
+ | `question:dialog` | 后端 → 前端 | 问询对话框(LLM 向用户提问) |
108
+ | `background:task` | 后端 → 前端 | 后台任务状态(started/completed/error) |
109
+ | `status:bar` | 后端 → 前端 | 状态栏段落(mode/tasks 等) |
110
+ | `user:input` | 后端 → 前端 | 用户输入(全局广播,用于历史重放) |
111
+ | `cancel` | 前端 → 后端 | 取消请求(HTTP POST) |
112
+ | `confirm` | 前端 → 后端 | 授权确认结果(HTTP POST) |
113
+ | `question-answer` | 前端 → 后端 | 问询对话框答案提交(HTTP POST) |
114
+ | `mode-toggle` | 前端 → 后端 | Plan mode 切换(HTTP POST) |
115
+
116
+ ## 关键实现细节
117
+
118
+ ### 前端重连与历史消息
119
+
120
+ 前端断开后重新打开时,后端会将已广播的历史事件(`stream:chunk`、`user:input`、`tool:call/result`、`status`、`agent:turn_start/end`、`error`)通过 `onConnect` 回调重新发送给新客户端。缓冲区上限 500 条,`/clear` 或新 session 时自动清空。
121
+
122
+ 配合 nano-code 的 `--continue` / `-c` 标志使用时,`restoreSession()` 会通过 DisplayPlugin 回放已保存的对话消息,nano-code-web 将其广播为 SSE 事件并同时写入历史缓冲区,前端刷新后仍可看到历史。
123
+
124
+ ### 广播容错
125
+
126
+ `broadcast()` 使用倒序遍历 + try-catch:
127
+ - 单个客户端写失败(连接已关闭但尚未清理)不会阻塞其他客户端
128
+ - 失效客户端自动从广播列表移除
129
+
130
+ ### 工具调用双向广播
131
+
132
+ 工具调用事件有两条路径到达 SSE:
133
+
134
+ - **NanoPlugin 路径**(`onBeforeToolCall`/`onAfterToolCall`):使用 OpenAI 格式(`toolCall.function.name`、`toolCall.id`),是工具卡片的主要来源
135
+ - **DisplayPlugin 路径**(`onToolCall`/`onToolResult`):DisplayPlugin 标准事件路径,作为 NanoPlugin 的补充
136
+
137
+ `ToolCallBroadcaster` 通过 ID 去重,确保同一工具调用只广播一次。
138
+
139
+ ### 前端渲染
140
+
141
+ - 消息气泡使用 `white-space: pre-wrap` 保留换行和空白
142
+ - 工具卡片使用 `flex-shrink: 0` 避免在长对话滚动后被 flex 布局压缩
143
+ - `stream:chunk` 遇到纯空白块时不创建新气泡(避免工具卡片旁的空白气泡)
144
+
145
+ ## 开发
146
+
147
+ ```bash
148
+ npm run dev # tsc --watch 增量编译
149
+ npm test # 运行所有测试(70 用例)
150
+ ```
151
+
152
+ ### 测试
153
+
154
+ ```bash
155
+ # 全部测试(单元测试 + 前端集成测试)
156
+ npm test
157
+
158
+ # Playwright 前端集成测试
159
+ npx tsx --test tests/frontend.test.ts
160
+
161
+ # 工具卡片滚动高度测试
162
+ npx tsx --test tests/tool-card-scroll.test.ts
163
+ ```
164
+
165
+ 测试覆盖:
166
+ - `tests/display.test.ts` — ThinkFilter(12 用例)+ ToolCallBroadcaster(12 用例,含历史回调)+ 环形缓冲区(9 用例)+ SSE 重放(4 用例)+ background:task(2 用例)+ question:dialog(1 用例)
167
+ - `tests/server.test.ts` — NanoCodeWebServer(20 用例,含 question-answer / mode-toggle)
168
+ - `tests/frontend.test.ts` — Playwright 前端渲染(9 用例)
169
+ - `tests/tool-card-scroll.test.ts` — Playwright 滚动高度(1 用例)
170
+
171
+ ## 项目文件结构
172
+
173
+ ```
174
+ ├── src/
175
+ │ ├── display.ts # DisplayPlugin 实现(ThinkFilter、ToolCallBroadcaster、ask_user_question handler)
176
+ │ ├── server.ts # HTTP/SSE 服务器
177
+ │ └── tool-display.ts # 工具名/参数格式化
178
+ ├── public/
179
+ │ ├── index.html # 前端页面骨架
180
+ │ ├── app.js # 前端主逻辑(消息渲染、事件处理)
181
+ │ ├── dialog.js # 问询对话框模块(三屏:选择→输入→确认)
182
+ │ └── style.css # 前端样式表
183
+ ├── scripts/
184
+ │ └── copy-vendor.mjs # 构建时将 markdown-it/DOMPurify/highlight.js 复制到 dist/vendor/
185
+ ├── tests/
186
+ │ ├── display.test.ts
187
+ │ ├── server.test.ts
188
+ │ ├── frontend.test.ts
189
+ │ └── tool-card-scroll.test.ts
190
+ ├── dist/ # 构建输出(含 vendor/ 第三方库)
191
+ ├── vendor/ # highlight.js CDN 缓存(可选,构建时自动下载)
192
+ ├── .nano-code.yaml # nano-code 插件配置
193
+ ├── README.md
194
+ └── ROADMAP.md
195
+ ```
196
+
197
+ ## License
198
+
199
+ MIT
package/dist/app.js ADDED
@@ -0,0 +1,432 @@
1
+ // ── State ──
2
+ const state = { currentMsg: null, currentRawText: '', renderTimer: null, toolCards: new Map(), connected: false, es: null, showThink: false, debug: false, toolDefinitions: [] };
3
+
4
+ const $ = id => document.getElementById(id);
5
+ const msgArea = $('messages');
6
+ const welcomeEl = $('welcome');
7
+ const inputEl = $('input');
8
+ const sendBtn = $('send-btn');
9
+ const cancelBtn = $('cancel-btn');
10
+ const statusDot = $('status-dot');
11
+ const statusText = $('status-text');
12
+ const thinkingEl = $('thinking');
13
+
14
+ function escapeHtml(s) {
15
+ const d = document.createElement('div');
16
+ d.textContent = s;
17
+ return d.innerHTML;
18
+ }
19
+
20
+ function scrollBottom() { msgArea.scrollTop = msgArea.scrollHeight; }
21
+
22
+ // ── Markdown 渲染(惰性初始化,CDN defer 加载完成后可用) ──
23
+ let _md = null;
24
+ function getMd() {
25
+ if (!_md && window.markdownit) {
26
+ _md = window.markdownit({
27
+ html: false, breaks: true, linkify: false,
28
+ highlight(str, lang) {
29
+ if (lang && hljs.getLanguage(lang)) {
30
+ try {
31
+ return '<pre><code class="hljs">' + hljs.highlight(str, { language: lang, ignoreIllegals: true }).value + '</code></pre>';
32
+ } catch {}
33
+ }
34
+ return '<pre><code class="hljs">' + _md.utils.escapeHtml(str) + '</code></pre>';
35
+ },
36
+ });
37
+ }
38
+ return _md;
39
+ }
40
+
41
+ function renderMarkdown(text) {
42
+ if (!text) return '';
43
+ const m = getMd();
44
+ if (!m || !window.DOMPurify) return escapeHtml(text.replace(/\n+$/, ''));
45
+ return DOMPurify.sanitize(m.render(text)).replace(/\n+$/, '');
46
+ }
47
+
48
+ function scheduleRender() {
49
+ if (state.renderTimer) clearTimeout(state.renderTimer);
50
+ state.renderTimer = setTimeout(doRender, 150);
51
+ }
52
+
53
+ function doRender() {
54
+ state.renderTimer = null;
55
+ if (!state.currentMsg || !state.currentRawText) return;
56
+ state.currentMsg.innerHTML = renderStreaming(state.currentRawText);
57
+ scrollBottom();
58
+ }
59
+
60
+ function renderImmediate() {
61
+ if (state.renderTimer) { clearTimeout(state.renderTimer); state.renderTimer = null; }
62
+ doRender();
63
+ }
64
+
65
+ function renderStreaming(text) {
66
+ if (!text) return '';
67
+ const info = findIncompleteTable(text);
68
+ if (info) {
69
+ const m = getMd();
70
+ const beforeHtml = m && window.DOMPurify
71
+ ? DOMPurify.sanitize(m.render(info.before))
72
+ : escapeHtml(info.before);
73
+ return beforeHtml + '<div class="table-pending">' + escapeHtml(info.tablePart) + '</div>';
74
+ }
75
+ return renderMarkdown(text);
76
+ }
77
+
78
+ function findIncompleteTable(text) {
79
+ if (!/^\|[-:| ]+\|$/m.test(text)) return null;
80
+ const lines = text.split('\n');
81
+ let i = lines.length;
82
+ while (i--) { if (lines[i].trim()) break; }
83
+ if (i < 0) return null;
84
+ const trimmed = lines[i].trim();
85
+ if (!trimmed.startsWith('|') || trimmed.endsWith('|')) return null;
86
+ while (i >= 0 && lines[i].trim().startsWith('|')) i--;
87
+ const before = lines.slice(0, i + 1).join('\n');
88
+ const tablePart = lines.slice(i + 1).join('\n');
89
+ if (!/^\|[-:| ]+\|$/m.test(tablePart)) return null;
90
+ return { before, tablePart };
91
+ }
92
+
93
+ function toggleToolDetail(card) {
94
+ const detail = card.querySelector('.tool-detail');
95
+ const arrow = card.querySelector('.tool-arrow');
96
+ if (!detail) return;
97
+ const open = detail.classList.toggle('open');
98
+ arrow.textContent = open ? '▾' : '▸';
99
+ }
100
+
101
+ function addMsg(className, html) {
102
+ welcomeEl.classList.add('hidden');
103
+ msgArea.classList.add('show');
104
+ const el = document.createElement('div');
105
+ el.className = 'msg ' + className;
106
+ el.innerHTML = html;
107
+ msgArea.appendChild(el);
108
+ scrollBottom();
109
+ return el;
110
+ }
111
+
112
+ function setStatus(stateClass, text) {
113
+ statusDot.className = 'dot ' + stateClass;
114
+ statusText.textContent = text;
115
+ }
116
+
117
+ // ── Mode indicator ──
118
+ const modeEl = $('mode-indicator');
119
+ function updateMode(segments) {
120
+ const modeText = (segments?.mode || '').toLowerCase();
121
+ const isPlan = modeText.includes('plan');
122
+ if (isPlan) {
123
+ modeEl.className = 'show plan';
124
+ modeEl.textContent = '● PLAN (shift+tab)';
125
+ modeEl.title = 'Click or Shift+Tab to switch to normal mode';
126
+ } else {
127
+ modeEl.className = '';
128
+ }
129
+ }
130
+ async function toggleMode() {
131
+ try { await fetch('/mode-toggle', { method: 'POST', body: '{}' }); } catch {}
132
+ }
133
+
134
+ // ── 跨标签页输入状态同步 ──
135
+ const syncChannel = new BroadcastChannel('nano-code-sync');
136
+ syncChannel.onmessage = (e) => {
137
+ if (e.data.type === 'inputEnabled') {
138
+ inputEl.disabled = !e.data.enabled;
139
+ sendBtn.disabled = !e.data.enabled;
140
+ }
141
+ };
142
+
143
+ function setInputEnabled(en, broadcast = true) {
144
+ inputEl.disabled = !en;
145
+ sendBtn.disabled = !en;
146
+ if (en && broadcast) setTimeout(() => inputEl.focus(), 100);
147
+ if (broadcast) syncChannel.postMessage({ type: 'inputEnabled', enabled: en });
148
+ }
149
+
150
+ function showThinking(show) { thinkingEl.classList.toggle('show', show); }
151
+
152
+ let isProcessing = false;
153
+ function setProcessing(p) {
154
+ isProcessing = p;
155
+ cancelBtn.style.display = p ? 'block' : 'none';
156
+ }
157
+
158
+ // ── SSE connection ──
159
+ function connect() {
160
+ if (state.es) state.es.close();
161
+ const es = new EventSource('/events');
162
+ state.es = es;
163
+
164
+ es.addEventListener('session:start', (e) => {
165
+ const d = JSON.parse(e.data);
166
+ msgArea.innerHTML = '';
167
+ msgArea.classList.remove('show');
168
+ welcomeEl.classList.remove('hidden');
169
+ state.currentMsg = null;
170
+ state.currentRawText = '';
171
+ if (state.renderTimer) { clearTimeout(state.renderTimer); state.renderTimer = null; }
172
+ state.toolCards.clear();
173
+ state.showThink = d.showThink === true;
174
+ state.debug = d.debug === true;
175
+ setStatus('connected', 'Ready');
176
+ setInputEnabled(true);
177
+ modeEl.className = '';
178
+ $('bg-tasks').innerHTML = '';
179
+ $('bg-tasks').classList.remove('show');
180
+ $('status-bar').innerHTML = '';
181
+ $('status-bar').classList.remove('show');
182
+ });
183
+
184
+ es.addEventListener('session:ready', () => {
185
+ renderImmediate();
186
+ setInputEnabled(true);
187
+ setProcessing(false);
188
+ showThinking(false);
189
+ state.currentMsg = null;
190
+ });
191
+
192
+ es.addEventListener('session:stop', (e) => {
193
+ const d = JSON.parse(e.data);
194
+ addMsg('system', d.message || 'Session ended');
195
+ setInputEnabled(false);
196
+ setProcessing(false);
197
+ showThinking(false);
198
+ setStatus('disconnected', 'Ended');
199
+ es.close();
200
+ });
201
+
202
+ es.addEventListener('stream:chunk', (e) => {
203
+ const d = JSON.parse(e.data);
204
+ if (!state.currentMsg) {
205
+ if (!d.text.trim()) return;
206
+ state.currentMsg = addMsg('assistant', '');
207
+ state.currentRawText = '';
208
+ }
209
+ state.currentRawText += d.text;
210
+ state.currentMsg.innerHTML = escapeHtml(state.currentRawText.replace(/\n+$/, ''));
211
+ scrollBottom();
212
+ const markers = state.currentRawText.match(/```/g);
213
+ if (markers && markers.length % 2 === 0 && markers.length > 0) {
214
+ renderImmediate();
215
+ } else {
216
+ scheduleRender();
217
+ }
218
+ });
219
+
220
+ es.addEventListener('tool:call', (e) => {
221
+ const d = JSON.parse(e.data);
222
+ renderImmediate();
223
+ state.currentMsg = null;
224
+ welcomeEl.classList.add('hidden');
225
+ msgArea.classList.add('show');
226
+ const card = document.createElement('div');
227
+ card.className = 'tool-card running';
228
+ card.dataset.toolId = d.id;
229
+ const toolName = d.displayName || d.toolName;
230
+ const argsStr = d.args && Object.keys(d.args).length > 0 ? JSON.stringify(d.args, null, 2) : '';
231
+ const argsPreview = d.argsPreview || '';
232
+ card.innerHTML = `
233
+ <div class="tool-card-header" onclick="toggleToolDetail(this.parentElement)">
234
+ <div class="tool-spinner"></div>
235
+ <span class="tool-name">${escapeHtml(toolName)}<span class="tool-args-preview">${escapeHtml(argsPreview)}</span></span>
236
+ <span class="tool-status">running</span>
237
+ <span class="tool-arrow">▸</span>
238
+ </div>
239
+ <div class="tool-detail">
240
+ ${argsStr ? `<div class="tool-section"><div class="tool-section-label">Arguments</div><pre class="tool-section-content">${escapeHtml(argsStr)}</pre></div>` : ''}
241
+ <div class="tool-section tool-result-section" style="display:none"><div class="tool-section-label">Result</div><pre class="tool-section-content tool-result-content"></pre></div>
242
+ </div>`;
243
+ msgArea.appendChild(card);
244
+ state.toolCards.set(d.id, card);
245
+ scrollBottom();
246
+ });
247
+
248
+ es.addEventListener('tool:result', (e) => {
249
+ const d = JSON.parse(e.data);
250
+ const card = state.toolCards.get(d.id);
251
+ if (!card) return;
252
+ card.classList.remove('running');
253
+ card.classList.add(d.status === 'success' ? 'success' : d.status === 'error' ? 'error' : 'rejected');
254
+ card.querySelector('.tool-status').textContent = d.status === 'success' ? 'ok' : d.status === 'error' ? (d.message || 'failed') : 'rejected';
255
+ const sec = card.querySelector('.tool-result-section');
256
+ const content = card.querySelector('.tool-result-content');
257
+ if (sec && content) { sec.style.display = ''; content.textContent = d.message || d.status || ''; }
258
+ });
259
+
260
+ es.addEventListener('tool:definitions', (e) => {
261
+ const d = JSON.parse(e.data);
262
+ state.toolDefinitions = d.definitions || [];
263
+ });
264
+
265
+ es.addEventListener('status', (e) => {
266
+ const d = JSON.parse(e.data);
267
+ if (d.level === 'status' && d.message === 'thinking') { if (state.showThink) showThinking(true); setProcessing(true); }
268
+ else if (d.level === 'status' && d.message === 'end') { renderImmediate(); showThinking(false); setProcessing(false); state.currentMsg = null; }
269
+ else if (d.level === 'info' || d.level === 'warn') addMsg('system', d.message);
270
+ });
271
+
272
+ es.addEventListener('error', (e) => {
273
+ const d = JSON.parse(e.data);
274
+ addMsg('error', 'Error: ' + escapeHtml(d.message));
275
+ });
276
+
277
+ es.addEventListener('debug', (e) => {
278
+ if (!state.debug) return;
279
+ const d = JSON.parse(e.data);
280
+ const data = typeof d.data === 'string' ? d.data : JSON.stringify(d.data, null, 2);
281
+ const tag = d.agentName && d.agentName !== 'main' ? `[${escapeHtml(d.agentName)}] ` : '';
282
+ addMsg('system', '[debug] ' + tag + escapeHtml(data));
283
+ });
284
+
285
+ es.addEventListener('agent:turn_start', (e) => {
286
+ const d = JSON.parse(e.data);
287
+ if (d.agentName !== 'main') addMsg('agent-badge', '&#9881; Agent: ' + escapeHtml(d.agentName));
288
+ });
289
+
290
+ es.addEventListener('agent:turn_end', () => { renderImmediate(); state.currentMsg = null; });
291
+
292
+ es.addEventListener('confirmation:request', (e) => {
293
+ const d = JSON.parse(e.data);
294
+ setInputEnabled(false, false);
295
+ msgArea.querySelectorAll('.confirm-card').forEach(el => el.remove());
296
+ const card = document.createElement('div');
297
+ card.className = 'confirm-card';
298
+ card.dataset.confirmId = d.id;
299
+ const toolLabel = escapeHtml(d.displayName || d.toolName);
300
+ let diffHtml = '';
301
+ if (d.diff && d.diff.length > 0) {
302
+ diffHtml = '<div class="tool-section"><div class="tool-section-label">Diff</div><div class="tool-section-content">';
303
+ for (const h of d.diff) {
304
+ for (const l of h.lines || []) {
305
+ const cl = l.type === 'add' ? 'color:#51cf66' : l.type === 'remove' ? 'color:#ff6b6b' : 'color:var(--text-muted)';
306
+ const prefix = l.type === 'add' ? '+' : l.type === 'remove' ? '-' : ' ';
307
+ diffHtml += `<span style="${cl}">${escapeHtml(prefix + l.content)}</span>\n`;
308
+ }
309
+ }
310
+ diffHtml += '</div></div>';
311
+ }
312
+ const fileHtml = d.filePath ? `<div class="confirm-details">File: ${escapeHtml(d.filePath)}</div>` : '';
313
+ card.innerHTML = `
314
+ <div class="confirm-header"><span class="confirm-icon">⚠</span><span class="confirm-title">Authorization Required</span></div>
315
+ <div class="confirm-tool">${toolLabel}</div>
316
+ <div class="confirm-message">${escapeHtml(d.message)}</div>
317
+ ${fileHtml}
318
+ ${d.details ? `<div class="confirm-details">${escapeHtml(d.details)}</div>` : ''}
319
+ ${diffHtml}
320
+ <div class="confirm-buttons">
321
+ <button class="confirm-btn deny">Deny</button>
322
+ <button class="confirm-btn allow">Allow</button>
323
+ </div>`;
324
+ const allowBtn = card.querySelector('.allow');
325
+ const denyBtn = card.querySelector('.deny');
326
+ if (allowBtn) allowBtn.addEventListener('click', () => sendConfirm(d.id, true));
327
+ if (denyBtn) denyBtn.addEventListener('click', () => sendConfirm(d.id, false));
328
+ msgArea.appendChild(card);
329
+ scrollBottom();
330
+ });
331
+
332
+ es.addEventListener('confirmation:resolved', (e) => {
333
+ const d = JSON.parse(e.data);
334
+ const card = document.querySelector(`.confirm-card[data-confirm-id="${d.id}"]`);
335
+ if (card) card.remove();
336
+ });
337
+
338
+ es.addEventListener('question:dialog', (e) => {
339
+ const d = JSON.parse(e.data);
340
+ qdOpen(d);
341
+ });
342
+
343
+ es.addEventListener('question:resolved', () => {
344
+ qdClose();
345
+ });
346
+
347
+ es.addEventListener('background:task', (e) => {
348
+ const d = JSON.parse(e.data);
349
+ const bar = $('bg-tasks');
350
+ const existing = bar.querySelector(`[data-task-id="${d.taskId}"]`);
351
+ if (existing) existing.remove();
352
+ if (d.taskStatus === 'started') {
353
+ const el = document.createElement('span');
354
+ el.className = 'bg-task';
355
+ el.dataset.taskId = d.taskId;
356
+ el.innerHTML = `<span class="dot running"></span> ${escapeHtml(d.message || d.taskId)}`;
357
+ bar.appendChild(el);
358
+ } else if (d.taskStatus === 'completed' || d.taskStatus === 'error') {
359
+ const el = document.createElement('span');
360
+ el.className = 'bg-task';
361
+ el.dataset.taskId = d.taskId;
362
+ el.innerHTML = `<span class="dot ${d.taskStatus}"></span> ${escapeHtml(d.message || d.taskId)}`;
363
+ bar.appendChild(el);
364
+ setTimeout(() => { const e2 = bar.querySelector(`[data-task-id="${d.taskId}"]`); if (e2) e2.remove(); bar.classList.toggle('show', bar.children.length > 0); }, 5000);
365
+ }
366
+ bar.classList.toggle('show', bar.children.length > 0);
367
+ });
368
+
369
+ es.addEventListener('status:bar', (e) => {
370
+ const d = JSON.parse(e.data);
371
+ const bar = $('status-bar');
372
+ bar.innerHTML = '';
373
+ if (d.segments && typeof d.segments === 'object') {
374
+ let count = 0;
375
+ for (const [key, value] of Object.entries(d.segments)) {
376
+ if (key === 'mode') continue;
377
+ if (value) { bar.innerHTML += `<span class="status-seg">${escapeHtml(value)}</span>`; count++; }
378
+ }
379
+ bar.classList.toggle('show', count > 0);
380
+ }
381
+ updateMode(d.segments);
382
+ });
383
+
384
+ es.addEventListener('user:input', (e) => {
385
+ const d = JSON.parse(e.data);
386
+ renderImmediate();
387
+ state.currentMsg = null;
388
+ addMsg('user', escapeHtml(d.text));
389
+ });
390
+
391
+ es.onerror = () => { setStatus('disconnected', 'Reconnecting...'); state.connected = false; setInputEnabled(false, false); };
392
+ es.onopen = () => { setStatus('connected', 'Connected'); state.connected = true; };
393
+ }
394
+
395
+ async function sendInput() {
396
+ const text = inputEl.value;
397
+ if (!text.trim()) return;
398
+ inputEl.value = '';
399
+ setInputEnabled(false);
400
+ state.currentMsg = null;
401
+ try {
402
+ const r = await fetch('/input', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text.trim() }) });
403
+ if (!r.ok) throw new Error('HTTP ' + r.status);
404
+ } catch (err) {
405
+ addMsg('user', escapeHtml(text.trim()));
406
+ addMsg('error', 'Failed to send: ' + err.message);
407
+ setInputEnabled(true);
408
+ }
409
+ }
410
+
411
+ async function sendCancel() { try { await fetch('/cancel', { method: 'POST', body: '{}' }); } catch {} }
412
+
413
+ async function sendConfirm(id, approved) {
414
+ try { await fetch('/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, approved }) }); } catch {}
415
+ const card = document.querySelector(`.confirm-card[data-confirm-id="${id}"]`);
416
+ if (card) card.remove();
417
+ }
418
+
419
+ inputEl.addEventListener('keydown', (e) => {
420
+ if (e.key === 'Tab' && e.shiftKey) { e.preventDefault(); toggleMode(); return; }
421
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendInput(); }
422
+ });
423
+ connect();
424
+
425
+ (function pollMdLibs() {
426
+ if (window.markdownit && window.DOMPurify) {
427
+ if (state.currentMsg && state.currentRawText) doRender();
428
+ if (window.hljs && state.currentMsg && state.currentRawText) doRender();
429
+ return;
430
+ }
431
+ setTimeout(pollMdLibs, 300);
432
+ })();