@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/dist/server.js ADDED
@@ -0,0 +1,331 @@
1
+ import * as http from 'node:http';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import * as crypto from 'node:crypto';
6
+ export class NanoCodeWebServer {
7
+ server;
8
+ clients = [];
9
+ fileDir;
10
+ port;
11
+ host;
12
+ htmlContent = null;
13
+ inputCb = null;
14
+ cancelCb = null;
15
+ confirmCb = null;
16
+ questionAnswerCb = null;
17
+ modeToggleCb = null;
18
+ connectCb = null;
19
+ /** 实际监听端口(start 后设置) */
20
+ portUsed = 0;
21
+ constructor(opts) {
22
+ this.port = opts?.port ?? 3030;
23
+ this.host = opts?.host ?? '0.0.0.0';
24
+ this.fileDir = opts?.fileDir ?? path.join(os.tmpdir(), 'nano-code-web-files');
25
+ this.server = this.createServer();
26
+ }
27
+ onInput(cb) { this.inputCb = cb; }
28
+ onCancel(cb) { this.cancelCb = cb; }
29
+ onConfirm(cb) { this.confirmCb = cb; }
30
+ onQuestionAnswer(cb) { this.questionAnswerCb = cb; }
31
+ onModeToggle(cb) { this.modeToggleCb = cb; }
32
+ /** 新 SSE 客户端连入时回调,可发送初始状态 */
33
+ onConnect(cb) { this.connectCb = cb; }
34
+ async start() {
35
+ await fs.promises.mkdir(this.fileDir, { recursive: true });
36
+ return new Promise((resolve, reject) => {
37
+ this.server.listen(this.port, this.host, () => {
38
+ const addr = this.server.address();
39
+ this.portUsed = addr && typeof addr === 'object' ? addr.port : this.port;
40
+ resolve(this.portUsed);
41
+ });
42
+ this.server.on('error', reject);
43
+ });
44
+ }
45
+ async stop() {
46
+ for (const c of this.clients)
47
+ c.res.end();
48
+ this.clients = [];
49
+ this.server.closeAllConnections?.();
50
+ this.server.close();
51
+ await fs.promises.rm(this.fileDir, { recursive: true, force: true }).catch(() => { });
52
+ }
53
+ hasClients() {
54
+ return this.clients.length > 0;
55
+ }
56
+ /** 向所有 SSE 客户端广播事件 */
57
+ broadcast(type, data) {
58
+ const msg = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
59
+ // 倒序遍历,方便在写失败时移除失效客户端
60
+ for (let i = this.clients.length - 1; i >= 0; i--) {
61
+ try {
62
+ this.clients[i].res.write(msg);
63
+ }
64
+ catch {
65
+ this.clients.splice(i, 1);
66
+ }
67
+ }
68
+ }
69
+ /** 写入大内容到临时文件,返回 URL */
70
+ async writeContent(content, ext = '.txt') {
71
+ const uuid = crypto.randomUUID();
72
+ const filePath = path.join(this.fileDir, `${uuid}${ext}`);
73
+ await fs.promises.writeFile(filePath, content, 'utf-8');
74
+ setTimeout(() => fs.promises.unlink(filePath).catch(() => { }), 10 * 60 * 1000);
75
+ return { uuid, url: `/web-files/${uuid}${ext}` };
76
+ }
77
+ // ── HTTP 路由 ──
78
+ createServer() {
79
+ return http.createServer((req, res) => {
80
+ try {
81
+ // 目录遍历防护:必须在 URL 解析之前检查原始路径
82
+ if (req.url?.includes('..')) {
83
+ res.writeHead(403);
84
+ res.end('Forbidden');
85
+ return;
86
+ }
87
+ const sUrl = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
88
+ if (req.method === 'GET' && sUrl.pathname === '/events')
89
+ return this.handleSSE(req, res);
90
+ if (req.method === 'POST' && sUrl.pathname === '/input')
91
+ return this.handleInput(req, res);
92
+ if (req.method === 'POST' && sUrl.pathname === '/cancel')
93
+ return this.handleCancel(req, res);
94
+ if (req.method === 'POST' && sUrl.pathname === '/confirm')
95
+ return this.handleConfirm(req, res);
96
+ if (req.method === 'POST' && sUrl.pathname === '/question-answer')
97
+ return this.handleQuestionAnswer(req, res);
98
+ if (req.method === 'POST' && sUrl.pathname === '/mode-toggle')
99
+ return this.handleModeToggle(req, res);
100
+ if (req.method === 'GET' && sUrl.pathname === '/health')
101
+ return this.handleHealth(res);
102
+ if (req.method === 'GET' && sUrl.pathname.startsWith('/web-files/'))
103
+ return this.handleFile(req, res, sUrl.pathname);
104
+ if (req.method === 'GET' && sUrl.pathname.startsWith('/vendor/'))
105
+ return this.handleVendor(res, sUrl.pathname);
106
+ // 通用静态文件: 在 public/ 或 dist/ 下查找,存在即返回,否则走 index.html
107
+ if (req.method === 'GET') {
108
+ const found = this.resolvePublicFile(sUrl.pathname);
109
+ if (found)
110
+ return this.serveStaticFile(res, sUrl.pathname, found);
111
+ }
112
+ return this.handleIndex(res);
113
+ }
114
+ catch {
115
+ res.writeHead(500);
116
+ res.end('Internal Server Error');
117
+ }
118
+ });
119
+ }
120
+ handleSSE(_req, res) {
121
+ res.writeHead(200, {
122
+ 'Content-Type': 'text/event-stream',
123
+ 'Cache-Control': 'no-cache',
124
+ 'Connection': 'keep-alive',
125
+ 'Access-Control-Allow-Origin': '*',
126
+ });
127
+ res.flushHeaders();
128
+ const client = { id: crypto.randomUUID(), res };
129
+ // 新客户端连入时,发送当前状态(用于浏览器晚于 server 启动的场景)
130
+ this.connectCb?.(client);
131
+ // 初始化完成后才加入广播列表,避免初始化期间收到 broadcast 写入
132
+ this.clients.push(client);
133
+ const keepAlive = setInterval(() => { res.write(':keepalive\n\n'); }, 15000);
134
+ const onClose = () => {
135
+ clearInterval(keepAlive);
136
+ this.clients = this.clients.filter(c => c.id !== client.id);
137
+ };
138
+ res.on('close', onClose);
139
+ }
140
+ handleInput(req, res) {
141
+ let body = '';
142
+ req.on('data', (chunk) => { body += chunk; });
143
+ req.on('end', () => {
144
+ try {
145
+ const parsed = JSON.parse(body);
146
+ const text = typeof parsed?.text === 'string' ? parsed.text : '';
147
+ if (text && this.inputCb)
148
+ this.inputCb(text);
149
+ }
150
+ catch {
151
+ // 非 JSON 格式,直接作为文本
152
+ if (body.trim() && this.inputCb)
153
+ this.inputCb(body.trim());
154
+ }
155
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
156
+ res.end(JSON.stringify({ ok: true }));
157
+ });
158
+ }
159
+ handleCancel(_req, res) {
160
+ if (this.cancelCb)
161
+ this.cancelCb();
162
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
163
+ res.end(JSON.stringify({ ok: true }));
164
+ }
165
+ handleConfirm(req, res) {
166
+ let body = '';
167
+ req.on('data', (chunk) => { body += chunk; });
168
+ req.on('end', () => {
169
+ try {
170
+ const parsed = JSON.parse(body);
171
+ if (typeof parsed?.id === 'string' && typeof parsed?.approved === 'boolean' && this.confirmCb) {
172
+ this.confirmCb(parsed.id, parsed.approved);
173
+ }
174
+ }
175
+ catch { /* ignore parse errors */ }
176
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
177
+ res.end(JSON.stringify({ ok: true }));
178
+ });
179
+ }
180
+ handleQuestionAnswer(req, res) {
181
+ let body = '';
182
+ req.on('data', (chunk) => { body += chunk; });
183
+ req.on('end', () => {
184
+ try {
185
+ const parsed = JSON.parse(body);
186
+ if (typeof parsed?.id === 'string' && typeof parsed?.answers === 'object' && this.questionAnswerCb) {
187
+ this.questionAnswerCb(parsed.id, parsed.answers);
188
+ }
189
+ }
190
+ catch { /* ignore parse errors */ }
191
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
192
+ res.end(JSON.stringify({ ok: true }));
193
+ });
194
+ }
195
+ handleModeToggle(_req, res) {
196
+ if (this.modeToggleCb)
197
+ this.modeToggleCb();
198
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
199
+ res.end(JSON.stringify({ ok: true }));
200
+ }
201
+ handleHealth(res) {
202
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
203
+ res.end(JSON.stringify({ status: 'ok', clients: this.clients.length }));
204
+ }
205
+ handleFile(_req, res, pathname) {
206
+ const fileName = pathname.slice('/web-files/'.length);
207
+ // 目录遍历防护
208
+ if (fileName.includes('..') || fileName.includes('/')) {
209
+ res.writeHead(403);
210
+ res.end('Forbidden');
211
+ return;
212
+ }
213
+ const filePath = path.join(this.fileDir, fileName);
214
+ if (!filePath.startsWith(this.fileDir)) {
215
+ res.writeHead(403);
216
+ res.end('Forbidden');
217
+ return;
218
+ }
219
+ let stream;
220
+ try {
221
+ fs.statSync(filePath);
222
+ stream = fs.createReadStream(filePath);
223
+ }
224
+ catch {
225
+ res.writeHead(404);
226
+ res.end('Not Found');
227
+ return;
228
+ }
229
+ res.writeHead(200, {
230
+ 'Content-Type': 'text/plain; charset=utf-8',
231
+ 'Access-Control-Allow-Origin': '*',
232
+ 'Cache-Control': 'public, max-age=300',
233
+ });
234
+ stream.pipe(res);
235
+ }
236
+ handleVendor(res, pathname) {
237
+ const fileName = pathname.slice('/vendor/'.length);
238
+ if (fileName.includes('..') || fileName.includes('/')) {
239
+ res.writeHead(403);
240
+ res.end('Forbidden');
241
+ return;
242
+ }
243
+ const scriptDir = new URL('.', import.meta.url).pathname;
244
+ const candidates = [
245
+ path.join(scriptDir, 'vendor', fileName),
246
+ path.join(scriptDir, '..', 'dist', 'vendor', fileName),
247
+ ];
248
+ let content = null;
249
+ for (const p of candidates) {
250
+ try {
251
+ content = fs.readFileSync(p);
252
+ break;
253
+ }
254
+ catch { /* try next */ }
255
+ }
256
+ if (!content) {
257
+ res.writeHead(404);
258
+ res.end('Not Found');
259
+ return;
260
+ }
261
+ const ext = path.extname(fileName);
262
+ const mime = { '.js': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' };
263
+ res.writeHead(200, {
264
+ 'Content-Type': mime[ext] || 'application/octet-stream',
265
+ 'Access-Control-Allow-Origin': '*',
266
+ 'Cache-Control': 'public, max-age=3600',
267
+ });
268
+ res.end(content);
269
+ }
270
+ resolvePublicFile(pathname) {
271
+ const fileName = pathname.replace(/^\//, '');
272
+ if (!fileName)
273
+ return null;
274
+ const scriptDir = new URL('.', import.meta.url).pathname;
275
+ const candidates = [
276
+ path.join(scriptDir, fileName),
277
+ path.join(scriptDir, '..', 'public', fileName),
278
+ path.join(scriptDir, '..', '..', 'public', fileName),
279
+ ];
280
+ for (const p of candidates) {
281
+ try {
282
+ if (fs.statSync(p).isFile())
283
+ return p;
284
+ }
285
+ catch { /* try next */ }
286
+ }
287
+ return null;
288
+ }
289
+ serveStaticFile(res, pathname, filePath) {
290
+ const ext = path.extname(pathname);
291
+ const mime = {
292
+ '.js': 'application/javascript; charset=utf-8',
293
+ '.css': 'text/css; charset=utf-8',
294
+ '.html': 'text/html; charset=utf-8',
295
+ '.png': 'image/png',
296
+ '.svg': 'image/svg+xml',
297
+ '.ico': 'image/x-icon',
298
+ '.woff2': 'font/woff2',
299
+ '.json': 'application/json',
300
+ };
301
+ res.writeHead(200, {
302
+ 'Content-Type': mime[ext] || 'application/octet-stream',
303
+ 'Access-Control-Allow-Origin': '*',
304
+ 'Cache-Control': 'public, max-age=3600',
305
+ });
306
+ fs.createReadStream(filePath).pipe(res);
307
+ }
308
+ handleIndex(res) {
309
+ if (!this.htmlContent) {
310
+ const scriptDir = new URL('.', import.meta.url).pathname;
311
+ // 尝试多个路径: 同目录 (dist/) → ../public/ (tsx 开发模式)
312
+ const candidates = [
313
+ path.join(scriptDir, 'index.html'),
314
+ path.join(scriptDir, '..', 'public', 'index.html'),
315
+ path.join(scriptDir, '..', '..', 'public', 'index.html'),
316
+ ];
317
+ for (const p of candidates) {
318
+ try {
319
+ this.htmlContent = fs.readFileSync(p, 'utf-8');
320
+ break;
321
+ }
322
+ catch { /* try next */ }
323
+ }
324
+ if (!this.htmlContent) {
325
+ this.htmlContent = '<!DOCTYPE html><html lang="zh"><meta charset="utf-8"><title>nano-code-web</title><body><h1>nano-code-web</h1><p>index.html 未找到</p></body></html>';
326
+ }
327
+ }
328
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
329
+ res.end(this.htmlContent);
330
+ }
331
+ }
package/dist/style.css ADDED
@@ -0,0 +1,182 @@
1
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2
+ :root {
3
+ --bg: #1a1b1e; --surface: #25262b; --border: #373a40;
4
+ --text: #c1c2c5; --text-dim: #909296; --text-muted: #5c5f66;
5
+ --accent: #4c9aff; --accent-bg: rgba(76,154,255,0.08);
6
+ --success: #51cf66; --error: #ff6b6b; --warning: #fcc419;
7
+ }
8
+ body { font: 14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; background: var(--bg); color: var(--text); height: 100vh; display: flex; flex-direction: column; }
9
+
10
+ /* ── Header ── */
11
+ header { display: flex; align-items: center; gap: 8px; padding: 10px 20px; background: var(--surface); border-bottom: 1px solid var(--border); flex-shrink: 0; }
12
+ header h1 { font-size: 15px; font-weight: 600; color: var(--text); letter-spacing: -0.3px; }
13
+ .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
14
+ .dot.connected { background: var(--success); }
15
+ .dot.connecting { background: var(--warning); animation: pulse 1s infinite; }
16
+ .dot.disconnected { background: var(--error); }
17
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
18
+ #status-text { color: var(--text-dim); font-size: 12px; flex: 1; }
19
+ #cancel-btn { padding: 4px 14px; font-size: 12px; border: 1px solid var(--border); border-radius: 6px; background: transparent; color: var(--text-dim); cursor: pointer; display: none; }
20
+ #cancel-btn:hover { background: rgba(255,107,107,0.1); border-color: var(--error); color: var(--error); }
21
+
22
+ /* ── Mode indicator ── */
23
+ #mode-indicator { display: none; font-size: 12px; font-weight: 600; margin-left: auto; margin-right: 8px; cursor: pointer; user-select: none; padding: 2px 8px; border-radius: 4px; }
24
+ #mode-indicator.show { display: inline; }
25
+ #mode-indicator.plan { color: #fbbf24; }
26
+ #mode-indicator.plan:hover { background: rgba(251,191,36,0.12); }
27
+
28
+ /* ── Welcome placeholder ── */
29
+ #welcome { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; gap: 12px; color: var(--text-muted); user-select: none; }
30
+ #welcome.hidden { display: none; }
31
+ #welcome .logo { font-size: 32px; font-weight: 700; color: var(--text); letter-spacing: -1px; opacity: 0.15; }
32
+ #welcome .hint { font-size: 13px; text-align: center; line-height: 1.8; }
33
+
34
+ /* ── Messages ── */
35
+ #messages { flex: 1; overflow-y: auto; padding: 20px; display: none; flex-direction: column; gap: 12px; scroll-behavior: smooth; }
36
+ #messages.show { display: flex; }
37
+ .msg { max-width: 88%; padding: 10px 14px; border-radius: 8px; line-height: 1.65; word-break: break-word; }
38
+ .msg.user { align-self: flex-end; background: var(--accent-bg); border: 1px solid rgba(76,154,255,0.15); white-space: pre-wrap; }
39
+ .msg.assistant { align-self: flex-start; background: var(--surface); border: 1px solid var(--border); white-space: pre-wrap; }
40
+ .msg.system { align-self: center; color: var(--text-dim); font-size: 13px; background: transparent; border: none; padding: 6px; text-align: center; }
41
+ .msg.error { align-self: flex-start; background: rgba(255,107,107,0.08); border: 1px solid rgba(255,107,107,0.2); color: var(--error); }
42
+
43
+ /* ── Tool call cards ── */
44
+ .tool-card { align-self: flex-start; width: 100%; max-width: 640px; border-radius: 8px; border: 1px solid var(--border); border-left: 3px solid var(--accent); background: var(--surface); overflow: hidden; flex-shrink: 0; }
45
+ .tool-card.success { border-left-color: var(--success); }
46
+ .tool-card.error { border-left-color: var(--error); }
47
+ .tool-card.rejected { border-left-color: var(--warning); }
48
+ .tool-card-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 13px; background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--border); }
49
+ .tool-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; flex-shrink: 0; }
50
+ .tool-card.success .tool-spinner { border-color: var(--success); animation: none; }
51
+ .tool-card.success .tool-spinner::after { content: "✓"; display: block; font-size: 11px; line-height: 13px; text-align: center; color: var(--success); }
52
+ .tool-card.error .tool-spinner { border-color: var(--error); animation: none; }
53
+ .tool-card.error .tool-spinner::after { content: "✕"; display: block; font-size: 11px; line-height: 13px; text-align: center; color: var(--error); }
54
+ .tool-card.rejected .tool-spinner { border-color: var(--warning); animation: none; }
55
+ .tool-card.rejected .tool-spinner::after { content: "!"; display: block; font-size: 12px; line-height: 14px; text-align: center; color: var(--warning); font-weight: 700; }
56
+ @keyframes spin { to { transform: rotate(360deg); } }
57
+ .tool-card-header { cursor: pointer; user-select: none; }
58
+ .tool-name { font-weight: 600; flex: 1; }
59
+ .tool-args-preview { font-size: 12px; color: var(--text-muted); font-weight: 400; margin-left: 4px; }
60
+ .tool-status { font-size: 12px; color: var(--text-dim); }
61
+ .tool-arrow { font-size: 10px; color: var(--text-muted); margin-left: auto; }
62
+ .tool-detail { display: none; }
63
+ .tool-detail.open { display: block; }
64
+ .tool-section { border-top: 1px solid var(--border); }
65
+ .tool-section-label { padding: 4px 12px 2px; font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; background: rgba(255,255,255,0.02); }
66
+ .tool-section-content { padding: 6px 12px 8px; margin: 0; font-size: 12px; font-family: ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; white-space: pre-wrap; word-break: break-word; color: var(--text-dim); background: transparent; border: none; }
67
+
68
+ /* ── 授权确认卡片 ── */
69
+ .confirm-card { align-self: flex-start; width: 100%; max-width: 640px; border-radius: 8px; border: 1px solid var(--warning); border-left: 3px solid var(--warning); background: var(--surface); overflow: hidden; flex-shrink: 0; }
70
+ .confirm-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 13px; background: rgba(252,196,25,0.06); border-bottom: 1px solid var(--border); }
71
+ .confirm-icon { font-size: 15px; }
72
+ .confirm-title { font-weight: 600; flex: 1; color: var(--warning); }
73
+ .confirm-tool { padding: 4px 12px; font-size: 13px; color: var(--text-dim); }
74
+ .confirm-message { padding: 4px 12px 8px; font-size: 14px; color: var(--text); white-space: pre-wrap; }
75
+ .confirm-details { padding: 4px 12px 8px; font-size: 12px; color: var(--text-muted); font-family: ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; white-space: pre-wrap; word-break: break-word; }
76
+ .confirm-buttons { display: flex; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--border); justify-content: flex-end; }
77
+ .confirm-btn { padding: 6px 20px; border-radius: 6px; font-size: 13px; cursor: pointer; font-weight: 500; border: none; }
78
+ .confirm-btn.allow { background: var(--success); color: #fff; }
79
+ .confirm-btn.allow:hover { filter: brightness(1.1); }
80
+ .confirm-btn.deny { background: transparent; border: 1px solid var(--border); color: var(--text-dim); }
81
+ .confirm-btn.deny:hover { border-color: var(--error); color: var(--error); }
82
+
83
+ /* ── Markdown 内容 ── */
84
+ .msg.assistant p { margin: 6px 0; }
85
+ .msg.assistant p:first-child { margin-top: 0; }
86
+ .msg.assistant p:last-child { margin-bottom: 0; }
87
+ .msg.assistant ul, .msg.assistant ol { margin: 6px 0; padding-left: 20px; }
88
+ .msg.assistant li { margin: 2px 0; }
89
+ .msg.assistant h1, .msg.assistant h2, .msg.assistant h3, .msg.assistant h4 { margin: 12px 0 6px; color: var(--text); font-weight: 600; }
90
+ .msg.assistant h1 { font-size: 16px; }
91
+ .msg.assistant h2 { font-size: 15px; }
92
+ .msg.assistant h3, .msg.assistant h4 { font-size: 14px; }
93
+ .msg.assistant table { border-collapse: collapse; width: 100%; margin: 6px 0; font-size: 13px; }
94
+ .msg.assistant th, .msg.assistant td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; }
95
+ .msg.assistant th { background: rgba(255,255,255,0.04); font-weight: 600; }
96
+ .msg.assistant tr:nth-child(even) { background: rgba(255,255,255,0.02); }
97
+ .msg.assistant pre { background: #151618; border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; overflow-x: auto; margin: 6px 0; }
98
+ .msg.assistant pre code { background: none; padding: 0; border-radius: 0; font-size: 13px; color: var(--text); }
99
+ .msg.assistant code { background: rgba(255,255,255,0.06); padding: 2px 5px; border-radius: 4px; font-family: ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; font-size: 13px; }
100
+ .msg.assistant blockquote { margin: 6px 0; padding-left: 12px; border-left: 3px solid var(--border); color: var(--text-dim); }
101
+ .msg.assistant hr { border: none; border-top: 1px solid var(--border); margin: 8px 0; }
102
+ .msg.assistant a { color: var(--accent); text-decoration: none; }
103
+ .msg.assistant a:hover { text-decoration: underline; }
104
+ .msg.assistant .table-pending { display: block; font-family: ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; font-size: 13px; color: var(--text-dim); white-space: pre; margin: 6px 0; }
105
+
106
+ /* ── Thinking indicator ── */
107
+ #thinking { display: none; align-items: center; gap: 8px; padding: 8px 12px; color: var(--text-dim); font-size: 13px; align-self: flex-start; margin-left: 20px; }
108
+ #thinking.show { display: flex; }
109
+ #thinking .label { color: var(--text-muted); }
110
+ .think-dots { display: flex; gap: 4px; }
111
+ .think-dots span { width: 5px; height: 5px; background: var(--text-muted); border-radius: 50%; animation: bounce 1.4s infinite both; }
112
+ .think-dots span:nth-child(2) { animation-delay: 0.16s; }
113
+ .think-dots span:nth-child(3) { animation-delay: 0.32s; }
114
+ @keyframes bounce { 0%,80%,100%{transform:scale(0)} 40%{transform:scale(1)} }
115
+
116
+ /* ── Agent turn badge ── */
117
+ .agent-badge { align-self: center; padding: 2px 10px; border-radius: 10px; font-size: 11px; background: var(--accent-bg); border: 1px solid rgba(76,154,255,0.2); color: var(--accent); }
118
+
119
+ /* ── Question dialog overlay ── */
120
+ #qd-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; align-items: center; justify-content: center; }
121
+ #qd-overlay.show { display: flex; }
122
+ #qd-dialog { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; max-width: 640px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,0.4); }
123
+ #qd-header { display: flex; align-items: center; gap: 8px; padding: 14px 18px 0; font-size: 14px; }
124
+ #qd-header .icon { font-size: 16px; }
125
+ #qd-header .title { font-weight: 600; color: var(--warning); }
126
+ #qd-header .progress { color: var(--text-muted); font-size: 12px; margin-left: auto; }
127
+ #qd-question { padding: 10px 18px 6px; font-size: 14px; color: var(--text); line-height: 1.6; }
128
+ #qd-options { padding: 6px 18px 8px; }
129
+ .qd-opt { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 6px; cursor: pointer; margin-top: 4px; border: 1px solid transparent; }
130
+ .qd-opt:hover { background: rgba(255,255,255,0.04); }
131
+ .qd-opt.focused { background: rgba(76,154,255,0.1); border-color: var(--accent); }
132
+ .qd-opt .indicator { flex-shrink: 0; font-size: 15px; width: 20px; text-align: center; color: var(--text-dim); }
133
+ .qd-opt.focused .indicator { color: var(--accent); }
134
+ .qd-opt.selected .indicator { color: var(--accent); }
135
+ .qd-opt .label { flex: 1; font-size: 14px; color: var(--text); }
136
+ .qd-opt.focused .label { color: #fff; }
137
+ .qd-opt.other .indicator { color: var(--text-muted); }
138
+ #qd-desc { padding: 0 18px 6px; font-size: 12px; color: var(--text-muted); min-height: 20px; }
139
+ #qd-hint { padding: 0 18px 14px; font-size: 11px; color: var(--text-muted); border-bottom: 1px solid var(--border); }
140
+ #qd-custom-area { display: none; padding: 8px 18px 14px; }
141
+ #qd-custom-area.show { display: block; }
142
+ #qd-custom-input { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); font-size: 14px; font-family: inherit; outline: none; resize: none; min-height: 40px; }
143
+ #qd-custom-input:focus { border-color: var(--accent); }
144
+ #qd-custom-hint { display: none; padding: 0 18px 14px; font-size: 11px; color: var(--text-muted); }
145
+ #qd-custom-hint.show { display: block; }
146
+ #qd-confirm-area { display: none; padding: 8px 18px 14px; }
147
+ #qd-confirm-area.show { display: block; }
148
+ .qd-confirm-item { padding: 6px 0; }
149
+ .qd-confirm-item .q { font-size: 13px; color: var(--text-dim); }
150
+ .qd-confirm-item .a { font-size: 14px; color: var(--accent); padding-left: 12px; margin-top: 2px; }
151
+ #qd-footer { display: flex; gap: 8px; padding: 10px 18px 14px; border-top: 1px solid var(--border); justify-content: flex-end; }
152
+ .qd-btn { padding: 6px 18px; border-radius: 6px; font-size: 13px; cursor: pointer; font-weight: 500; border: none; }
153
+ .qd-btn.primary { background: var(--accent); color: #fff; }
154
+ .qd-btn.primary:hover { filter: brightness(1.08); }
155
+ .qd-btn.secondary { background: transparent; border: 1px solid var(--border); color: var(--text-dim); }
156
+ .qd-btn.secondary:hover { border-color: var(--text-dim); color: var(--text); }
157
+ .qd-btn.danger { background: transparent; border: 1px solid var(--border); color: var(--text-dim); }
158
+ .qd-btn.danger:hover { border-color: var(--error); color: var(--error); }
159
+
160
+ /* ── Background task display ── */
161
+ #bg-tasks { display: none; gap: 4px 12px; padding: 4px 20px; background: var(--surface); border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-dim); flex-shrink: 0; flex-wrap: wrap; }
162
+ #bg-tasks.show { display: flex; }
163
+ .bg-task { display: inline-flex; align-items: center; gap: 4px; }
164
+ .bg-task .dot { width: 6px; height: 6px; border-radius: 50%; }
165
+ .bg-task .dot.running { background: var(--accent); animation: pulse 1s infinite; }
166
+ .bg-task .dot.completed { background: var(--success); }
167
+ .bg-task .dot.error { background: var(--error); }
168
+
169
+ /* ── Status bar ── */
170
+ #status-bar { display: none; flex-wrap: wrap; gap: 4px 12px; padding: 4px 20px; background: var(--surface); border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-dim); flex-shrink: 0; }
171
+ #status-bar.show { display: flex; }
172
+ .status-seg { display: inline-flex; align-items: center; gap: 4px; }
173
+
174
+ /* ── Input area ── */
175
+ #input-area { display: flex; gap: 8px; padding: 12px 20px; background: var(--surface); border-top: 1px solid var(--border); flex-shrink: 0; }
176
+ #input { flex: 1; padding: 10px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--text); font-size: 14px; font-family: inherit; resize: none; outline: none; max-height: 120px; }
177
+ #input::placeholder { color: var(--text-muted); }
178
+ #input:focus { border-color: var(--accent); }
179
+ #input:disabled { opacity: 0.35; }
180
+ #send-btn { padding: 10px 20px; border: none; border-radius: 8px; background: var(--accent); color: #fff; font-size: 14px; cursor: pointer; font-weight: 500; }
181
+ #send-btn:disabled { opacity: 0.35; cursor: default; }
182
+ #send-btn:hover:not(:disabled) { filter: brightness(1.08); }
@@ -0,0 +1,112 @@
1
+ const BIG_FIELDS = new Set([
2
+ 'content', 'old_string', 'new_string',
3
+ 'fileContent', 'fileContents', 'originalFile', 'replace',
4
+ ]);
5
+ const BUILTIN_DISPLAY_NAMES = {
6
+ 'run_bash_command': 'Bash',
7
+ 'view_file_content': 'Read',
8
+ 'write_file_content': 'Write',
9
+ 'patch_file': 'Patch',
10
+ 'list_project_files': 'List',
11
+ 'glob_files': 'Glob',
12
+ 'grep_file_content': 'Grep',
13
+ };
14
+ function capitalize(word) {
15
+ return word.charAt(0).toUpperCase() + word.slice(1);
16
+ }
17
+ /**
18
+ * Resolve user-friendly tool display name.
19
+ * Priority: tool definition displayName > built-in mapping > agent-xxx > snake_case.
20
+ */
21
+ export function getToolDisplayName(toolName, definitions) {
22
+ if (definitions) {
23
+ for (const def of definitions) {
24
+ if (def.function.name === toolName && def.function.displayName) {
25
+ return def.function.displayName;
26
+ }
27
+ }
28
+ }
29
+ if (BUILTIN_DISPLAY_NAMES[toolName])
30
+ return BUILTIN_DISPLAY_NAMES[toolName];
31
+ if (toolName.startsWith('agent-')) {
32
+ return toolName.slice(6).split('-').map(capitalize).join(' ');
33
+ }
34
+ return toolName.split('_').map(capitalize).join(' ');
35
+ }
36
+ /**
37
+ * Build concise args preview for display.
38
+ * Single non-big field → value only; multiple → key=value pairs; big fields skipped.
39
+ */
40
+ export function getToolArgsPreview(args) {
41
+ if (!args || typeof args !== 'object')
42
+ return null;
43
+ const truncVal = (v, max = 80) => v.length > max ? v.slice(0, max) + '…' : v;
44
+ const pickKeys = (keys) => {
45
+ const parts = [];
46
+ for (const k of keys) {
47
+ const v = args[k];
48
+ if (v !== undefined && v !== null) {
49
+ parts.push(typeof v === 'string' ? truncVal(v) : String(v));
50
+ }
51
+ }
52
+ return parts.length > 0 ? parts.join(', ') : null;
53
+ };
54
+ if (args.pattern)
55
+ return pickKeys(['pattern', 'path']);
56
+ if (args.glob)
57
+ return pickKeys(['glob', 'path']);
58
+ if (args.path !== undefined || args.file_path !== undefined) {
59
+ const pathArg = args.path ?? args.file_path;
60
+ const rest = [];
61
+ for (const k of Object.keys(args)) {
62
+ if (k === 'path' || k === 'file_path' || BIG_FIELDS.has(k))
63
+ continue;
64
+ const v = args[k];
65
+ if (typeof v === 'string')
66
+ rest.push(`${k}=${v}`);
67
+ else
68
+ rest.push(`${k}=${JSON.stringify(v)}`);
69
+ }
70
+ const pathPart = truncVal(pathArg);
71
+ return rest.length > 0 ? `${pathPart}, ${rest.join(', ')}` : pathPart;
72
+ }
73
+ if (args.content !== undefined) {
74
+ const parts = [];
75
+ for (const k of Object.keys(args)) {
76
+ if (k === 'content')
77
+ continue;
78
+ parts.push(`${k}=${typeof args[k] === 'string' ? args[k] : JSON.stringify(args[k])}`);
79
+ }
80
+ return parts.length > 0 ? parts.join(', ') : pickKeys(['content']);
81
+ }
82
+ if (args.command)
83
+ return truncVal(args.command);
84
+ if (args.url)
85
+ return pickKeys(['url']);
86
+ const entries = [];
87
+ for (const [k, v] of Object.entries(args)) {
88
+ if (BIG_FIELDS.has(k) || v === undefined || v === null)
89
+ continue;
90
+ entries.push({ key: k, value: typeof v === 'string' ? truncVal(v) : JSON.stringify(v) });
91
+ }
92
+ if (entries.length === 0)
93
+ return null;
94
+ if (entries.length === 1)
95
+ return entries[0].value;
96
+ return entries.map(e => `${e.key}=${e.value}`).join(', ');
97
+ }
98
+ /**
99
+ * Full formatted tool call string.
100
+ */
101
+ export function formatToolCall(toolName, args, definitions) {
102
+ const displayName = getToolDisplayName(toolName, definitions);
103
+ if (!args || typeof args !== 'object')
104
+ return `${displayName}(${JSON.stringify(args)})`;
105
+ if (Object.keys(args).length === 0)
106
+ return `${displayName}()`;
107
+ const preview = getToolArgsPreview(args);
108
+ if (preview)
109
+ return `${displayName}(${preview})`;
110
+ const str = JSON.stringify(args);
111
+ return str.length > 200 ? `${displayName}(${str.slice(0, 200)}…)` : `${displayName}(${str})`;
112
+ }