pi2web 0.2.0 → 0.2.1

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.
@@ -0,0 +1,420 @@
1
+ (() => {
2
+ 'use strict';
3
+
4
+ // Wire contract: Nozzala v1-codex-full 1.0.0, build 51AE6B3BF94B49FF.
5
+ // Reference: CodexFull WebHID Console v0.1.0 (2026-08-31), DEVELOPMENT.md.
6
+ // Host requests use {id,m,p} without a newline; replies/notifications end in LF.
7
+ // RPCs are serialized, use IDs 0..998, and wait 50ms after each reply.
8
+ const REPORT_ID = 6;
9
+ const CHANNEL_JSON = 2;
10
+ const REPORT_BYTES = 63;
11
+ const PAYLOAD_BYTES = 61;
12
+ const VENDOR_ID = 0x303A;
13
+ const PRODUCT_ID = 0x8360;
14
+ const USAGE_PAGE = 0xFF00;
15
+ const USAGE = 1;
16
+ const STORAGE_KEY = 'rpSessionKeyboardBindings';
17
+ // Adjacent slots deliberately alternate across the color wheel so all six
18
+ // remain easy to distinguish when they are lit at the same time.
19
+ const PALETTE = ['#FF3040', '#00D9FF', '#FFE600', '#3D5AFE', '#30E070', '#D840FF'];
20
+ const BUSY_STATUSES = new Set(['starting', 'streaming', 'stopping']);
21
+ // codex-full firmware effects that remain meaningful on one independently
22
+ // bound key. Spatial effects such as snake/rainbow are intentionally unused.
23
+ const EFFECT_OFF = 0;
24
+ const EFFECT_SOLID = 1;
25
+ const EFFECT_BREATH = 4;
26
+ const EFFECT_SHALLOW_BREATH = 6;
27
+ // Idle stays continuously lit. Active work uses a slow, full-depth breath
28
+ // so each cycle clearly falls toward dark before returning to full brightness.
29
+ // Waiting remains a shallow breath to read as a separate third state.
30
+ const ACTIVITY_LIGHTS = {
31
+ llm: {effect:EFFECT_BREATH, speed:0.12},
32
+ tool: {effect:EFFECT_BREATH, speed:0.45},
33
+ retry: {effect:EFFECT_BREATH, speed:0.8},
34
+ waiting: {effect:EFFECT_SHALLOW_BREATH, speed:0.1},
35
+ };
36
+ const BUSY_LIGHT = {effect:EFFECT_BREATH, speed:0.15};
37
+
38
+ // Keep activity in one place; the light phase is derived, never stored twice.
39
+ const newActivity = () => ({turn:'idle', tools:new Set(), bash:new Set(), dialogs:new Set()});
40
+ const resetTurn = (activity, turn = 'idle') => { activity.turn = turn; activity.tools.clear(); };
41
+ const activityPhase = activity => {
42
+ if (!activity) return 'idle';
43
+ if (activity.dialogs.size) return 'waiting';
44
+ if (activity.tools.size || activity.bash.size) return 'tool';
45
+ return activity.turn;
46
+ };
47
+
48
+ const normalizeColor = value => {
49
+ const color = String(value || '').trim().toUpperCase();
50
+ return /^#[0-9A-F]{6}$/.test(color) ? color : null;
51
+ };
52
+
53
+ class JsonLineAssembler {
54
+ constructor() { this.reset(); }
55
+ reset() { this.decoder = new TextDecoder(); this.buffer = ''; }
56
+ feed(data) {
57
+ this.buffer += this.decoder.decode(data, {stream:true});
58
+ const messages = [];
59
+ let newline;
60
+ while ((newline = this.buffer.indexOf('\n')) >= 0) {
61
+ const line = this.buffer.slice(0, newline).trim();
62
+ this.buffer = this.buffer.slice(newline + 1);
63
+ if (line) messages.push(JSON.parse(line));
64
+ }
65
+ return messages;
66
+ }
67
+ }
68
+
69
+ class SessionKeyboardController {
70
+ constructor({onKey, onStateChange, onBindingsChange, onError} = {}) {
71
+ this.onKey = onKey;
72
+ this.onStateChange = onStateChange;
73
+ this.onBindingsChange = onBindingsChange;
74
+ this.onError = onError;
75
+ this.device = null;
76
+ this.closePromise = Promise.resolve();
77
+ this.requestId = 0;
78
+ this.queue = Promise.resolve();
79
+ this.pending = new Map();
80
+ this.assembler = new JsonLineAssembler();
81
+ this.agents = new Map();
82
+ this.activities = new Map();
83
+ this.sentLights = new Map();
84
+ this.lastPress = new Map();
85
+ this.syncRequested = false;
86
+ this.forceSync = false;
87
+ this.syncPromise = null;
88
+ this.bindings = this.loadBindings();
89
+ this.handleInputReport = this.handleInputReport.bind(this);
90
+ this.handleDisconnect = this.handleDisconnect.bind(this);
91
+ navigator.hid?.addEventListener('disconnect', this.handleDisconnect);
92
+ }
93
+
94
+ loadBindings() {
95
+ try {
96
+ const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
97
+ if (!Array.isArray(parsed)) return new Map();
98
+ return new Map(parsed.flatMap(item => {
99
+ const slot = Number(item?.slot), agentId = String(item?.agentId || ''), color = normalizeColor(item?.color);
100
+ return Number.isInteger(slot) && slot >= 0 && slot < 6 && agentId && color ? [[slot, {agentId, color}]] : [];
101
+ }));
102
+ } catch { return new Map(); }
103
+ }
104
+
105
+ saveBindings() {
106
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(this.getBindings()));
107
+ this.onBindingsChange?.(this.getBindings());
108
+ this.syncLights(true).catch(error => this.fail(error));
109
+ }
110
+
111
+ get connected() { return Boolean(this.device?.opened); }
112
+ getBindings() { return [...this.bindings].sort(([a], [b]) => a - b).map(([slot, value]) => ({slot, ...value})); }
113
+ getBinding(agentId) { return this.getBindings().find(binding => binding.agentId === agentId) || null; }
114
+ getSlot(slot) { const value = this.bindings.get(Number(slot)); return value ? {slot:Number(slot), ...value} : null; }
115
+ getFreeSlots() { return Array.from({length:6}, (_, slot) => slot).filter(slot => !this.bindings.has(slot)); }
116
+
117
+ nextColor() {
118
+ const used = new Set(this.getBindings().map(binding => binding.color));
119
+ return PALETTE.find(color => !used.has(color)) || PALETTE[this.bindings.size % PALETTE.length];
120
+ }
121
+
122
+ bind(agentId, slot, color = this.nextColor()) {
123
+ slot = Number(slot); color = normalizeColor(color);
124
+ if (!agentId || !Number.isInteger(slot) || slot < 0 || slot > 5 || !color) throw Error('无效的键盘绑定');
125
+ const occupied = this.bindings.get(slot);
126
+ if (occupied && occupied.agentId !== agentId) throw Error(`按键 ${slot + 1} 已被其他 Session 使用`);
127
+ for (const [boundSlot, binding] of this.bindings) if (binding.agentId === agentId) this.bindings.delete(boundSlot);
128
+ this.bindings.set(slot, {agentId, color});
129
+ this.saveBindings();
130
+ return this.getSlot(slot);
131
+ }
132
+
133
+ autoBind(agentId) {
134
+ const existing = this.getBinding(agentId);
135
+ if (existing) return existing;
136
+ const slot = this.getFreeSlots()[0];
137
+ return slot === undefined ? null : this.bind(agentId, slot, this.nextColor());
138
+ }
139
+
140
+ unbind(agentId) {
141
+ const entry = [...this.bindings].find(([, binding]) => binding.agentId === agentId);
142
+ if (!entry) return false;
143
+ this.bindings.delete(entry[0]);
144
+ this.saveBindings();
145
+ return true;
146
+ }
147
+
148
+ setColor(agentId, color) {
149
+ color = normalizeColor(color);
150
+ const entry = [...this.bindings].find(([, binding]) => binding.agentId === agentId);
151
+ if (!entry || !color) throw Error('无效的 Session 颜色');
152
+ entry[1].color = color;
153
+ this.saveBindings();
154
+ return {slot:entry[0], ...entry[1]};
155
+ }
156
+
157
+ setAgents(agents) {
158
+ this.agents = new Map((agents || []).map(agent => [agent.agentId, agent]));
159
+ for (const agentId of this.activities.keys()) this.reconcileActivity(agentId);
160
+ this.syncLights().catch(error => this.fail(error));
161
+ }
162
+
163
+ updateAgent(agent) {
164
+ if (!agent?.agentId) return;
165
+ this.agents.set(agent.agentId, agent);
166
+ if (agent.activity) {
167
+ const activity = this.activities.get(agent.agentId) || newActivity();
168
+ activity.bash = new Set(agent.activity.bashIds || []);
169
+ activity.dialogs = new Set(agent.activity.dialogIds || []);
170
+ this.activities.set(agent.agentId, activity);
171
+ }
172
+ this.reconcileActivity(agent.agentId);
173
+ this.syncLights().catch(error => this.fail(error));
174
+ }
175
+
176
+ setAgentSnapshot(agent) {
177
+ // A gap invalidates old details; restore independent work from the server.
178
+ // Older servers omit activity and fall back to ordinary Agent status.
179
+ this.activities.delete(agent.agentId);
180
+ this.updateAgent(agent);
181
+ }
182
+
183
+ reconcileActivity(agentId) {
184
+ const activity = this.activities.get(agentId), status = this.agents.get(agentId)?.status;
185
+ if (!activity) return;
186
+ if (!status || ['unloaded', 'stopped', 'error'].includes(status)) {
187
+ this.activities.delete(agentId);
188
+ return;
189
+ }
190
+ // Status describes the LLM run, not independent !bash commands or dialogs.
191
+ if (!BUSY_STATUSES.has(status)) resetTurn(activity);
192
+ if (activityPhase(activity) === 'idle') this.activities.delete(agentId);
193
+ }
194
+
195
+ handleAgentEvent(agentId, event) {
196
+ const activity = this.activities.get(agentId) || newActivity();
197
+ const previous = activityPhase(activity);
198
+ switch (event.type) {
199
+ case 'agent_start':
200
+ resetTurn(activity, 'llm');
201
+ break;
202
+ case 'message_start':
203
+ if ((event.message?.role || event.role) !== 'assistant') return;
204
+ activity.turn = 'llm';
205
+ break;
206
+ case 'message_update':
207
+ activity.turn = 'llm';
208
+ break;
209
+ case 'tool_execution_start':
210
+ case 'tool_execution_update':
211
+ activity.turn = 'llm';
212
+ activity.tools.add(event.toolCallId || event.toolName || 'default');
213
+ break;
214
+ case 'tool_execution_end':
215
+ activity.tools.delete(event.toolCallId || event.toolName || 'default');
216
+ break;
217
+ case 'bash_execution_start':
218
+ case 'bash_execution_update':
219
+ activity.bash.add(event.id || 'default');
220
+ break;
221
+ case 'bash_execution_end':
222
+ activity.bash.delete(event.id || 'default');
223
+ break;
224
+ case 'auto_retry_start':
225
+ resetTurn(activity, 'retry');
226
+ break;
227
+ case 'auto_retry_end':
228
+ activity.turn = event.success === false ? 'idle' : 'llm';
229
+ break;
230
+ case 'extension_ui_request':
231
+ activity.dialogs.add(event.requestId);
232
+ break;
233
+ case 'extension_ui_response':
234
+ activity.dialogs.delete(event.requestId);
235
+ break;
236
+ case 'agent_end':
237
+ resetTurn(activity, event.willRetry ? 'retry' : 'idle');
238
+ break;
239
+ case 'agent_settled':
240
+ resetTurn(activity);
241
+ break;
242
+ default:
243
+ return;
244
+ }
245
+ const phase = activityPhase(activity);
246
+ if (phase === 'idle') this.activities.delete(agentId); else this.activities.set(agentId, activity);
247
+ if (phase !== previous) this.syncLights().catch(error => this.fail(error));
248
+ }
249
+
250
+ isBusy(agentId) { return BUSY_STATUSES.has(this.agents.get(agentId)?.status); }
251
+
252
+ collection(device) {
253
+ return device.collections.find(item => item.usagePage === USAGE_PAGE && item.usage === USAGE &&
254
+ item.outputReports.some(report => report.reportId === REPORT_ID) && item.inputReports.some(report => report.reportId === REPORT_ID));
255
+ }
256
+
257
+ async connect({request = true} = {}) {
258
+ if (!window.isSecureContext || !navigator.hid) throw Error('WebHID 需要 Chrome/Edge,并通过 HTTPS 或 localhost 打开');
259
+ let devices = (await navigator.hid.getDevices()).filter(device => device.vendorId === VENDOR_ID && device.productId === PRODUCT_ID && this.collection(device));
260
+ if (!devices.length && request) devices = await navigator.hid.requestDevice({filters:[{vendorId:VENDOR_ID, productId:PRODUCT_ID, usagePage:USAGE_PAGE, usage:USAGE}]});
261
+ if (!devices.length) return false;
262
+ const device = devices[0];
263
+ // Finish the previous close and sync before reusing the same HIDDevice.
264
+ await this.closePromise;
265
+ await this.syncPromise?.catch(() => undefined);
266
+ try {
267
+ if (!device.opened) await device.open();
268
+ if (!this.collection(device)) throw Error('键盘缺少兼容的 Vendor HID 接口');
269
+ if (this.device && this.device !== device) await this.disconnect();
270
+ if (this.device === device) device.removeEventListener('inputreport', this.handleInputReport);
271
+ this.device = device;
272
+ device.addEventListener('inputreport', this.handleInputReport);
273
+ this.assembler.reset(); this.sentLights.clear();
274
+ await this.syncLights(true);
275
+ if (this.device !== device || !device.opened) throw Error('键盘已断开');
276
+ } catch (error) {
277
+ // disconnect() clears local state even if close() fails. Keep the
278
+ // initialization error as the reason reported to the caller.
279
+ if (this.device === device) await this.disconnect().catch(() => undefined);
280
+ else if (device.opened) await device.close().catch(() => undefined);
281
+ throw error;
282
+ }
283
+ this.onStateChange?.({connected:true, name:device.productName || 'Codex Micro'});
284
+ return true;
285
+ }
286
+
287
+ async restoreAuthorized() {
288
+ if (!window.isSecureContext || !navigator.hid) return false;
289
+ try { return await this.connect({request:false}); }
290
+ catch (error) { this.fail(error); return false; }
291
+ }
292
+
293
+ async disconnect() {
294
+ if (!this.device) return this.closePromise;
295
+ const device = this.device; this.device = null;
296
+ device.removeEventListener('inputreport', this.handleInputReport);
297
+ // Detach and notify before awaiting the OS: close() can fail or stall.
298
+ for (const waiter of this.pending.values()) { clearTimeout(waiter.timer); waiter.reject(Error('键盘已断开')); }
299
+ this.pending.clear(); this.assembler.reset(); this.sentLights.clear();
300
+ this.syncRequested = false; this.forceSync = false;
301
+ const closing = device.opened ? device.close() : Promise.resolve();
302
+ this.closePromise = closing.catch(() => undefined);
303
+ this.onStateChange?.({connected:false, name:''});
304
+ return closing;
305
+ }
306
+
307
+ handleDisconnect(event) { if (event.device === this.device) this.disconnect().catch(error => this.fail(error)); }
308
+
309
+ handleInputReport(event) {
310
+ if (event.device !== this.device || Number(event.reportId) !== REPORT_ID) return;
311
+ try {
312
+ const bytes = new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength);
313
+ if (bytes.length < 2 || bytes[0] !== CHANNEL_JSON || bytes[1] > PAYLOAD_BYTES || bytes[1] > bytes.length - 2) throw Error('收到无效的键盘 HID 数据');
314
+ for (const message of this.assembler.feed(bytes.slice(2, 2 + bytes[1]))) this.handleMessage(message);
315
+ } catch (error) { this.fail(error); }
316
+ }
317
+
318
+ handleMessage(message) {
319
+ if (Object.prototype.hasOwnProperty.call(message, 'id')) {
320
+ const waiter = this.pending.get(String(message.id));
321
+ if (waiter) { this.pending.delete(String(message.id)); clearTimeout(waiter.timer); waiter.resolve(message); }
322
+ return;
323
+ }
324
+ if (message.m !== 'v.oai.hid' || !/^AG0[0-5]$/.test(message.p?.k) || Number(message.p?.act) !== 1) return;
325
+ const slot = Number(message.p.k.slice(-1)), now = Date.now();
326
+ if (now - (this.lastPress.get(slot) || 0) < 250) return;
327
+ this.lastPress.set(slot, now);
328
+ const binding = this.getSlot(slot);
329
+ if (binding) this.onKey?.(binding, message);
330
+ }
331
+
332
+ nextId() { this.requestId = this.requestId >= 998 ? 0 : this.requestId + 1; return this.requestId; }
333
+
334
+ reports(message) {
335
+ const bytes = new TextEncoder().encode(message), reports = [];
336
+ for (let offset = 0; offset < bytes.length; offset += PAYLOAD_BYTES) {
337
+ const fragment = bytes.slice(offset, offset + PAYLOAD_BYTES), report = new Uint8Array(REPORT_BYTES);
338
+ report[0] = CHANNEL_JSON; report[1] = fragment.length; report.set(fragment, 2); reports.push(report);
339
+ }
340
+ return reports;
341
+ }
342
+
343
+ transmit(request) {
344
+ const device = this.device;
345
+ const job = this.queue.then(async () => {
346
+ if (this.device !== device || !device?.opened) throw Error('键盘未连接');
347
+ const response = new Promise((resolve, reject) => {
348
+ const timer = setTimeout(() => { this.pending.delete(String(request.id)); reject(Error('键盘响应超时')); }, 5000);
349
+ this.pending.set(String(request.id), {resolve, reject, timer});
350
+ });
351
+ // A timeout/disconnect can reject the reply while sendReport is still
352
+ // pending. Observe it now; the await below still propagates the error.
353
+ response.catch(() => undefined);
354
+ try {
355
+ for (const report of this.reports(JSON.stringify(request))) {
356
+ if (this.device !== device || !device.opened) throw Error('键盘已断开');
357
+ await device.sendReport(REPORT_ID, report);
358
+ }
359
+ const result = await response;
360
+ if (result?.error) throw Error(result.error.message || '键盘拒绝了灯光设置');
361
+ await new Promise(resolve => setTimeout(resolve, 50));
362
+ return result;
363
+ } catch (error) {
364
+ const waiter = this.pending.get(String(request.id));
365
+ if (waiter) clearTimeout(waiter.timer);
366
+ this.pending.delete(String(request.id));
367
+ throw error;
368
+ }
369
+ });
370
+ this.queue = job.catch(() => undefined);
371
+ return job;
372
+ }
373
+
374
+ lightRequest(slot, color, effect, speed = 0) {
375
+ const enabled = effect !== EFFECT_OFF;
376
+ return {id:this.nextId(), m:'v.oai.thstatus', p:[{id:slot, c:parseInt(color.slice(1), 16), b:enabled ? 1 : 0, e:effect, s:speed, sk:0, sa:0}]};
377
+ }
378
+
379
+ lightState(slot) {
380
+ const binding = this.bindings.get(slot), active = Boolean(binding && this.agents.has(binding.agentId));
381
+ if (!active) return {color:binding?.color || '#000000', effect:EFFECT_OFF, speed:0};
382
+ const phase = activityPhase(this.activities.get(binding.agentId));
383
+ let animation = ACTIVITY_LIGHTS[phase];
384
+ if (!animation && this.agents.get(binding.agentId)?.status === 'waiting_for_user') animation = ACTIVITY_LIGHTS.waiting;
385
+ if (!animation && this.isBusy(binding.agentId)) animation = BUSY_LIGHT;
386
+ return {color:binding.color, effect:animation?.effect || EFFECT_SOLID, speed:animation?.speed || 0};
387
+ }
388
+
389
+ async syncLights(force = false) {
390
+ if (!this.connected) return;
391
+ this.syncRequested = true;
392
+ this.forceSync ||= force;
393
+ if (this.syncPromise) return this.syncPromise;
394
+ const device = this.device;
395
+ // Defer the first pass so synchronous updates use the latest state. Clear
396
+ // the worker inside its own finally, even when every light is a cache hit.
397
+ this.syncPromise = Promise.resolve().then(async () => {
398
+ try {
399
+ if (this.device !== device || !device.opened) throw Error('键盘已断开');
400
+ do {
401
+ this.syncRequested = false;
402
+ const forcePass = this.forceSync; this.forceSync = false;
403
+ for (let slot = 0; slot < 6; slot++) {
404
+ const {color, effect, speed} = this.lightState(slot), signature = `${color}:${effect}:${speed}`;
405
+ if (!forcePass && this.sentLights.get(slot) === signature) continue;
406
+ await this.transmit(this.lightRequest(slot, color, effect, speed));
407
+ if (this.device !== device || !device.opened) throw Error('键盘已断开');
408
+ this.sentLights.set(slot, signature);
409
+ }
410
+ } while (this.syncRequested && this.connected);
411
+ } finally { this.syncPromise = null; }
412
+ });
413
+ return this.syncPromise;
414
+ }
415
+
416
+ fail(error) { this.onError?.(error instanceof Error ? error : Error(String(error))); }
417
+ }
418
+
419
+ window.SessionKeyboardController = SessionKeyboardController;
420
+ })();
package/web/index.html CHANGED
@@ -8,12 +8,11 @@
8
8
  <title>Remote Pi</title>
9
9
  <link rel="icon" href="/icon.svg" type="image/svg+xml">
10
10
  <link rel="stylesheet" href="/styles.css">
11
- <link rel="stylesheet" href="/vendor/xterm.css">
12
11
  </head>
13
12
  <body>
14
13
  <header>
15
14
  <div class="brand"><img src="/icon.svg" alt="" width="32" height="32"><h1>Remote Pi</h1></div>
16
- <label class="api-field">服务地址 <input id="api" placeholder="http://127.0.0.1:11318"></label>
15
+ <a href="/collab.html" id="collabLink" title="多 Agent 协作看板" style="color:var(--blue)">协作</a>
17
16
  <button id="openConfig" title="连接、通知与语音设置">配置</button>
18
17
  <span id="status" class="muted">未配对</span>
19
18
  <span id="serverInfo" class="muted"></span>
@@ -21,7 +20,7 @@
21
20
  <main>
22
21
  <aside>
23
22
  <section id="workspaceSection">
24
- <div class="section-title"><div class="section-heading"><button id="workspaceBack" class="mobile-only" title="返回主页" aria-label="返回主页">←</button><h2 id="workspaceHeading">Workspace</h2></div><span class="workspace-home-actions"><button id="refreshWs" title="刷新">↻</button><button id="addWs">添加</button></span></div>
23
+ <div class="section-title"><div class="section-heading"><button id="workspaceBack" class="mobile-only" title="返回主页" aria-label="返回主页">←</button><h2 id="workspaceHeading">Workspace</h2></div><span class="workspace-home-actions"><select id="backendFilter" class="backend-filter" title="按 Backend 过滤 Workspace 与 Session 列表" aria-label="按 Backend 过滤 Workspace 与 Session 列表" hidden></select><button id="refreshWs" title="刷新">↻</button><button id="addWs">添加</button></span></div>
25
24
  <select id="workspaces" size="3" aria-label="Workspace"></select>
26
25
  <div id="mobileWorkspaces" class="mobile-only" aria-label="Workspaces"></div>
27
26
  <div id="workspaceBrowser">
@@ -46,6 +45,7 @@
46
45
  <button id="undo" class="agent-action" title="在 Session Tree 中回退输入或 Agent 输出">Undo</button>
47
46
  <button id="sessionTree" class="agent-action">Session</button>
48
47
  <button id="controls" class="agent-action">模型</button>
48
+ <button id="mcp" class="agent-action">MCP</button>
49
49
  <button id="compact" class="agent-action">Compact</button>
50
50
  <button id="stop" class="agent-action">停止</button>
51
51
  <button id="abort" class="agent-action danger-action">Abort</button>
@@ -59,10 +59,12 @@
59
59
  <div id="widgets"></div>
60
60
  <div id="messages" aria-live="polite"><div class="empty">选择或创建 Agent 开始对话</div></div>
61
61
  <form id="prompt">
62
+ <div id="queueBar" hidden><span id="queueText"></span><button type="button" id="queueClear" title="清空队列并把消息放回输入框">清空队列</button></div>
62
63
  <div id="mentionPicker" hidden><div class="picker-head"><button type="button" id="mentionUp">↑</button><b id="mentionPath">.</b><button type="button" id="mentionClose">×</button></div><div id="mentionItems"></div></div>
64
+ <div id="commandPicker" hidden><div class="picker-head"><b>命令 · prompt template / skill</b><button type="button" id="commandClose">×</button></div><div id="commandItems"></div></div>
63
65
  <div class="composer-row">
64
66
  <select id="sendMode" title="发送方式"><option value="prompt">Prompt</option><option value="steer">Steer</option><option value="follow-up">Follow-up</option></select>
65
- <textarea id="input" placeholder="输入 @ 引用文件;Ctrl/⌘+Enter 发送"></textarea>
67
+ <textarea id="input" placeholder="输入 @ 引用文件,/ 使用命令,! 执行 Shell;Ctrl/⌘+Enter 发送"></textarea>
66
68
  <button id="voiceInput" type="button" title="开始语音输入" hidden>🎙</button>
67
69
  <button type="submit">发送</button>
68
70
  </div>
@@ -70,15 +72,15 @@
70
72
  </article>
71
73
  </main>
72
74
  <div id="contextMenu" class="context-menu" hidden><button id="menuStartAgent">启动 Agent</button><button id="menuOpenTerminal">在 Terminal 中打开</button><button id="menuSetCwd">设为 Agent 工作路径</button><button id="menuMention">在输入框中引用</button></div>
73
- <dialog id="configDialog"><form method="dialog" id="configForm"><h2>Remote Pi 配置</h2><section class="config-section"><h3>连接</h3><button id="connect" type="button">输入配对码</button><small id="configConnectionStatus" class="muted"></small></section><section class="config-section"><h3>浏览器通知与语音</h3><div class="config-actions"><button id="notifications" type="button" title="启用所有 Agent 的完成通知">启用通知</button><button id="voicePlayback" type="button" title="启用 Agent 语音摘要播放" hidden>启用语音</button></div><small id="browserNotificationStatus" class="muted"></small></section><section class="config-section"><h3>邮件通知</h3><p id="mailNotificationAvailability" class="muted"></p><label class="toggle-row"><input id="mailNotificationEnabled" type="checkbox">启动邮件通知</label><label>邮件通知聚合时间(秒)<input id="mailAggregationDelay" type="number" min="0" max="86400" step="1" value="0"><small class="muted">首个任务完成后等待这段时间,期间完成的其他任务合并到同一封邮件;0 表示立即发送。</small></label><label class="toggle-row"><input id="mailIncludeResponse" type="checkbox" checked>包含 Agent 回复内容</label><label class="toggle-row"><input id="mailIncludeSessionDetails" type="checkbox" checked>包含 Session 名称和路径</label><small class="muted">如果回复内容和 Session 信息都不包含,邮件只会说明有任务完成。</small></section><div class="dialog-actions"><button id="configClose" type="button">关闭</button><button id="configSave" type="button">保存邮件设置</button></div></form></dialog>
74
- <dialog id="pairDialog"><form method="dialog" id="pairForm"><h2>连接 Remote Pi</h2><p class="muted">连接成功后可选择是否将配对码保存到浏览器本地存储。请仅在可信设备上保存。</p><label>服务地址<input id="pairBase" required></label><label>配对码<input id="pairToken" type="password" autocomplete="off" required autofocus></label><div class="dialog-actions"><button id="pairCancel" type="button">取消</button><button id="pairSubmit" type="submit" value="default">连接</button></div></form></dialog>
75
- <div id="agentContextMenu" class="context-menu" hidden><button id="menuArchiveAgent">Archive</button><button id="menuCloseTerminal" hidden>关闭 Terminal</button></div>
75
+ <dialog id="configDialog"><form method="dialog" id="configForm"><h2>Remote Pi 配置</h2><section class="config-section"><h3>Backend 连接</h3><div id="backendList"></div><div class="config-actions"><button id="connect" type="button">添加 Backend</button><button id="disconnectAll" type="button" hidden>断开全部</button></div><small id="configConnectionStatus" class="muted"></small></section><section class="config-section"><h3>六键 Session 键盘</h3><div class="config-actions"><button id="keyboardConnect" type="button">连接键盘</button><button id="keyboardDisconnect" type="button" hidden>断开键盘</button></div><small id="keyboardStatus" class="muted">未连接 · 需要 Chrome/Edge 和 HTTPS 或 localhost</small><div id="keyboardSlots" class="keyboard-slots" aria-label="键盘按键绑定"></div></section><section class="config-section"><h3>浏览器通知与语音</h3><div class="config-actions"><button id="notifications" type="button" title="启用所有 Agent 的完成通知">启用通知</button><button id="voicePlayback" type="button" title="启用 Agent 语音摘要播放" hidden>启用语音</button></div><small id="browserNotificationStatus" class="muted"></small></section><section class="config-section"><h3>邮件通知</h3><p id="mailNotificationAvailability" class="muted"></p><label class="toggle-row"><input id="mailNotificationEnabled" type="checkbox">启动邮件通知</label><label>邮件通知聚合时间(秒)<input id="mailAggregationDelay" type="number" min="0" max="86400" step="1" value="0"><small class="muted">首个任务完成后等待这段时间,期间完成的其他任务合并到同一封邮件;0 表示立即发送。</small></label><label class="toggle-row"><input id="mailIncludeResponse" type="checkbox" checked>包含 Agent 回复内容</label><label class="toggle-row"><input id="mailIncludeSessionDetails" type="checkbox" checked>包含 Session 名称和路径</label><label class="toggle-row"><input id="mailCollabEscalations" type="checkbox" checked>协作会话需要人工裁定或停滞时立即发信</label><small class="muted">如果回复内容和 Session 信息都不包含,邮件只会说明有任务完成。</small></section><div class="dialog-actions"><button id="configClose" type="button">关闭</button><button id="configSave" type="button">保存邮件设置</button></div></form></dialog>
76
+ <dialog id="pairDialog"><form method="dialog" id="pairForm"><h2>连接 Backend</h2><p class="muted">可以添加多个 backend;连接成功后可选择是否将配对码保存到浏览器本地存储。请仅在可信设备上保存。</p><label>短名称<input id="pairName" placeholder="例如 home / lab,显示在 Session 标签上"></label><label>服务地址<input id="pairBase" required></label><label>配对码<input id="pairToken" type="password" autocomplete="off" placeholder="服务端使用 --no-auth 时留空" autofocus></label><div class="dialog-actions"><button id="pairCancel" type="button">取消</button><button id="pairSubmit" type="submit" value="default">连接</button></div></form></dialog>
77
+ <div id="agentContextMenu" class="context-menu" hidden><button id="menuBindKeyboard">绑定键盘按键…</button><button id="menuKeyboardColor">更换按键颜色…</button><button id="menuUnbindKeyboard">取消按键绑定</button><button id="menuArchiveAgent">Archive</button><button id="menuCloseTerminal" hidden>关闭 Terminal</button></div>
76
78
  <dialog id="modal"><form method="dialog"><h2 id="modalTitle"></h2><div id="modalBody"></div><div class="dialog-actions"><button id="modalCancel" type="button">取消</button><button id="modalOk" type="submit" value="default">确定</button></div></form></dialog>
77
79
  <div id="toast" hidden></div>
78
- <script src="/vendor/xterm.js"></script>
79
- <script src="/vendor/addon-fit.js"></script>
80
80
  <script src="/vendor/marked.js"></script>
81
81
  <script src="/vendor/dompurify.js"></script>
82
+ <script src="/hid-keyboard.js"></script>
83
+ <script src="/backend-state.js"></script>
82
84
  <script src="/app.js"></script>
83
85
  </body>
84
86
  </html>
package/web/styles.css CHANGED
@@ -27,3 +27,62 @@
27
27
  .msg summary .summary-label{display:none;min-width:0;flex:1}
28
28
  .msg.derived-title[open] summary .summary-text{display:none}
29
29
  .msg.derived-title[open] summary .summary-label{display:block}
30
+
31
+ /* Slash command picker reuses the @-mention picker chrome. */
32
+ #commandPicker{position:absolute;left:115px;right:80px;bottom:88px;max-height:330px;overflow:auto;background:#252a33;border:1px solid #667085;border-radius:7px;box-shadow:0 8px 24px #0008;z-index:15}
33
+ #commandPicker .picker-item b{display:block}
34
+ #commandPicker .picker-item small{display:block;margin-top:2px;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
35
+ @media(max-width:700px){#commandPicker{left:8px;right:8px;bottom:116px;max-height:45vh}}
36
+ @media(max-width:620px){#commandPicker{left:10px;right:10px}}
37
+
38
+ /* Activity group: tool calls, bash output, retries and extension chatter collapse into one card
39
+ whose summary tracks the newest item so streaming stays visible without flooding the transcript. */
40
+ .msg.activity-group{border-left-color:var(--yellow);background:#262b35}
41
+ .msg.activity-group>summary{color:var(--muted)}
42
+ .msg.activity-group>summary .summary-text{color:var(--text)}
43
+ .msg.activity-group .activity-count{flex:0 0 auto;margin-left:10px;padding:1px 8px;border-radius:999px;font-size:11px;background:#3b4252;color:var(--muted)}
44
+ .msg.activity-group>summary:before{content:'\25B8 \2699';margin-right:7px}
45
+ .msg.activity-group[open]>summary:before{content:'\25BE \2699'}
46
+ .activity-items{padding:0 10px 10px}
47
+ .activity-items .msg{margin:6px 0;background:#2f3542}
48
+ .activity-items .msg summary{padding:7px 9px;font-size:13px}
49
+ .activity-items .msg-content{padding:0 10px 10px;font-size:13px}
50
+ .msg.tool.tool-error{border-left-color:var(--red)}
51
+ .msg.dialog{border-left-color:var(--blue);background:#2f3644}
52
+ .msg.voice{border-left-color:#b48ead}
53
+
54
+ /* Multi-backend connections: short-name tag on sessions and the sidebar filter. */
55
+ .backend-tag{flex:0 0 auto;max-width:92px;padding:2px 7px;border:1px solid #6b7688;border-radius:999px;background:#39404e;color:#d8dee9;font:700 10px/1.4 system-ui,sans-serif;text-transform:uppercase;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
56
+ .backend-filter{max-width:140px;padding:4px 6px;font-size:12px}
57
+ .backend-row{display:flex;align-items:center;gap:10px;margin:6px 0;padding:8px;background:var(--input);border:1px solid var(--line);border-radius:6px}
58
+ .backend-row-info{flex:1;min-width:0}
59
+ .backend-row-info b,.backend-row-info small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
60
+ .backend-row-info small{margin-top:2px;color:var(--muted)}
61
+ .backend-row-actions{display:flex;gap:6px}.backend-row-actions button{padding:4px 8px;font-size:12px}
62
+
63
+ /* Six-key WebHID session keyboard. */
64
+ .key-binding{flex:0 0 auto;min-width:27px;padding:2px 6px;border:1px solid var(--key-color);border-radius:5px;background:#20232b;color:#fff;font:700 11px ui-monospace,monospace;text-align:center;box-shadow:inset 3px 0 var(--key-color)}
65
+ .keyboard-slots{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin-top:10px}
66
+ .keyboard-slot{min-width:0;padding:7px;border:1px solid var(--line);border-radius:6px;background:var(--input)}
67
+ .keyboard-slot b,.keyboard-slot small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
68
+ .keyboard-slot b{padding-left:9px;border-left:4px solid var(--key-color,#586174)}
69
+ .keyboard-slot small{margin-top:3px;color:var(--muted)}
70
+ .keyboard-slot-actions{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
71
+ .keyboard-slot-actions button{padding:4px 6px;font-size:12px}
72
+ @media(max-width:700px){.keyboard-slots{grid-template-columns:repeat(2,minmax(0,1fr))}}
73
+
74
+ /* Queued steering/follow-up messages waiting for the running turn. */
75
+ #queueBar{display:flex;align-items:center;gap:8px;margin-bottom:8px;padding:6px 10px;font-size:12px;color:#161c24;background:var(--blue);border-radius:6px}
76
+ #queueBar span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
77
+ #queueBar button{padding:3px 8px;font-size:12px;color:#161c24;background:#ffffffcc;border-color:#ffffff66}
78
+ /* Tool calls a tool made itself (codemode scripts, MCP batches) stay inside their caller's card. */
79
+ .nested-tools{margin-top:10px;padding-left:10px;border-left:2px solid var(--line)}
80
+ .nested-tools .msg{margin:5px 0}
81
+ .tool-duration{margin-left:8px;font-size:11px;color:var(--muted)}
82
+ /* MCP server list: what Pi reads from mcp.json, with an enable/disable toggle. */
83
+ .mcp-list{max-height:55vh;overflow:auto}
84
+ .mcp-row{display:flex;align-items:center;gap:10px;margin:6px 0;padding:9px;background:var(--input);border-radius:6px}
85
+ .mcp-row.mcp-off{opacity:.6}
86
+ .mcp-info{flex:1;min-width:0}
87
+ .mcp-info small{display:block;margin-top:3px;white-space:pre-wrap;word-break:break-word}
88
+ .mcp-error{color:var(--red);white-space:pre-wrap;word-break:break-word}