返回 CodeWhale
runtime_mobile.html
根目录 / crates / tui / src / runtime_mobile.html
1 <!doctype html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6 <title>Codewhale Mobile</title>
7 <style>
8 :root {
9 color-scheme: dark;
10 --bg: #101214;
11 --panel: #181b1f;
12 --panel-2: #20242a;
13 --line: #323840;
14 --text: #f4f1e8;
15 --muted: #a8a49a;
16 --accent: #48c2a8;
17 --accent-2: #c89543;
18 --danger: #e86458;
19 --ok: #76c46b;
20 }
21 * { box-sizing: border-box; }
22 html, body { min-height: 100%; }
23 body {
24 margin: 0;
25 background: var(--bg);
26 color: var(--text);
27 font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
28 letter-spacing: 0;
29 }
30 header {
31 position: sticky;
32 top: 0;
33 z-index: 4;
34 display: flex;
35 align-items: center;
36 justify-content: space-between;
37 gap: 12px;
38 padding: calc(12px + env(safe-area-inset-top)) 14px 12px;
39 background: rgba(16, 18, 20, 0.96);
40 border-bottom: 1px solid var(--line);
41 backdrop-filter: blur(12px);
42 }
43 h1 {
44 margin: 0;
45 font-size: 18px;
46 font-weight: 740;
47 }
48 main {
49 display: grid;
50 gap: 12px;
51 padding: 12px 12px calc(14px + env(safe-area-inset-bottom));
52 }
53 section {
54 border: 1px solid var(--line);
55 background: var(--panel);
56 border-radius: 8px;
57 overflow: hidden;
58 }
59 .head {
60 display: flex;
61 align-items: center;
62 justify-content: space-between;
63 gap: 8px;
64 padding: 10px 12px;
65 border-bottom: 1px solid var(--line);
66 min-height: 46px;
67 }
68 .body { padding: 12px; }
69 .grid { display: grid; gap: 10px; }
70 .row { display: flex; align-items: center; gap: 8px; }
71 .row > * { flex: 1; min-width: 0; }
72 button, input, textarea, select {
73 color: var(--text);
74 background: var(--panel-2);
75 border: 1px solid var(--line);
76 border-radius: 8px;
77 font: inherit;
78 letter-spacing: 0;
79 }
80 button {
81 min-height: 40px;
82 padding: 8px 11px;
83 font-weight: 680;
84 cursor: pointer;
85 }
86 button.primary {
87 background: #173d36;
88 border-color: #2a8b77;
89 }
90 button.warn {
91 background: #3c2b14;
92 border-color: #8b682b;
93 }
94 button.danger {
95 background: #421f1d;
96 border-color: #9b4038;
97 }
98 button:disabled {
99 cursor: not-allowed;
100 opacity: 0.55;
101 }
102 input, textarea, select {
103 width: 100%;
104 padding: 10px;
105 }
106 textarea {
107 min-height: 96px;
108 resize: vertical;
109 }
110 label.toggle {
111 display: flex;
112 align-items: center;
113 gap: 8px;
114 min-height: 40px;
115 padding: 8px 10px;
116 color: var(--muted);
117 background: var(--panel-2);
118 border: 1px solid var(--line);
119 border-radius: 8px;
120 font-size: 13px;
121 }
122 label.toggle input { width: auto; }
123 .status {
124 min-height: 38px;
125 padding: 9px 10px;
126 border-radius: 8px;
127 background: #121518;
128 border: 1px solid var(--line);
129 color: var(--muted);
130 overflow-wrap: anywhere;
131 }
132 .status.ok { color: var(--ok); }
133 .status.bad { color: var(--danger); }
134 .status.warn { color: var(--accent-2); }
135 #threads {
136 display: grid;
137 gap: 8px;
138 max-height: 260px;
139 overflow: auto;
140 }
141 .thread {
142 width: 100%;
143 padding: 10px;
144 text-align: left;
145 border-radius: 8px;
146 background: var(--panel-2);
147 }
148 .thread.active { border-color: var(--accent); }
149 .thread-title {
150 font-weight: 720;
151 white-space: nowrap;
152 overflow: hidden;
153 text-overflow: ellipsis;
154 }
155 .meta {
156 margin-top: 3px;
157 color: var(--muted);
158 font-size: 12px;
159 overflow-wrap: anywhere;
160 }
161 #events {
162 display: grid;
163 gap: 8px;
164 max-height: 54vh;
165 overflow: auto;
166 padding-right: 2px;
167 }
168 .event {
169 padding: 9px 10px;
170 border: 1px solid var(--line);
171 border-radius: 8px;
172 background: #121518;
173 white-space: pre-wrap;
174 overflow-wrap: anywhere;
175 }
176 .event.agent { border-color: #357969; }
177 .event.tool { border-color: #8b682b; }
178 .event.error { border-color: #9b4038; }
179 .event.ok { border-color: #4d8048; }
180 .approval-actions {
181 display: flex;
182 flex-wrap: wrap;
183 gap: 8px;
184 margin-top: 9px;
185 }
186 .approval-actions button {
187 flex: 0 0 auto;
188 min-width: 84px;
189 }
190 .empty {
191 color: var(--muted);
192 font-size: 13px;
193 }
194 @media (min-width: 900px) {
195 main {
196 grid-template-columns: 330px minmax(0, 1fr);
197 align-items: start;
198 }
199 section.chat {
200 grid-column: 2;
201 grid-row: 1 / span 3;
202 }
203 #threads { max-height: 420px; }
204 #events { max-height: calc(100vh - 150px); }
205 }
206 </style>
207 </head>
208 <body>
209 <header>
210 <h1>Codewhale Mobile</h1>
211 <button id="refresh" title="Refresh threads">Refresh</button>
212 </header>
213
214 <main>
215 <section>
216 <div class="head">
217 <strong>Connection</strong>
218 <button id="save-token">Save</button>
219 </div>
220 <div class="body grid">
221 <input id="token" autocomplete="off" spellcheck="false" placeholder="Runtime token">
222 <div id="conn" class="status">Not connected</div>
223 </div>
224 </section>
225
226 <section>
227 <div class="head">
228 <strong>Threads</strong>
229 <button id="new-thread" class="primary">New</button>
230 </div>
231 <div class="body"><div id="threads"></div></div>
232 </section>
233
234 <section class="chat">
235 <div class="head">
236 <strong id="active-title">No thread selected</strong>
237 <span id="event-count" class="meta">0 events</span>
238 </div>
239 <div class="body"><div id="events"></div></div>
240 </section>
241
242 <section>
243 <div class="head">
244 <strong>Composer</strong>
245 <button id="interrupt" class="danger">Interrupt</button>
246 </div>
247 <div class="body grid">
248 <textarea id="prompt" spellcheck="true" placeholder="Message"></textarea>
249 <div class="row">
250 <label class="toggle"><input id="allow-shell" type="checkbox"> shell</label>
251 <label class="toggle"><input id="auto-approve" type="checkbox"> auto</label>
252 <label class="toggle"><input id="remember-approval" type="checkbox"> remember</label>
253 </div>
254 <div class="row">
255 <button id="send" class="primary">Send</button>
256 <button id="steer" class="warn">Steer</button>
257 </div>
258 </div>
259 </section>
260 </main>
261
262 <script>
263 const $ = (id) => document.getElementById(id);
264 const MAX_VISIBLE_EVENTS = 100;
265 const TOKEN_STORAGE_KEY = "codewhale_runtime_token";
266 const TOKEN_COOKIE = "codewhale_runtime_token";
267 const state = {
268 threadId: "",
269 activeTurnId: "",
270 source: null,
271 eventCount: 0
272 };
273
274 function setStatus(message, tone = "") {
275 const el = $("conn");
276 el.textContent = message;
277 el.className = "status" + (tone ? " " + tone : "");
278 }
279
280 function token() {
281 return $("token").value.trim();
282 }
283
284 function setRuntimeTokenCookie(value) {
285 const attrs = "; Path=/; SameSite=Strict";
286 if (!value) {
287 document.cookie = TOKEN_COOKIE + "=; Max-Age=0" + attrs;
288 return;
289 }
290 document.cookie = TOKEN_COOKIE + "=" + encodeURIComponent(value) + attrs;
291 }
292
293 function takeTokenFromUrl() {
294 const hash = location.hash.startsWith("#") ? location.hash.slice(1) : "";
295 const params = new URLSearchParams(hash);
296 const urlToken = params.get("token");
297 if (!urlToken) return;
298 localStorage.setItem(TOKEN_STORAGE_KEY, urlToken);
299 setRuntimeTokenCookie(urlToken);
300 params.delete("token");
301 const nextHash = params.toString();
302 history.replaceState(
303 null,
304 "",
305 location.pathname + location.search + (nextHash ? "#" + nextHash : "")
306 );
307 }
308
309 function headers(extra = {}) {
310 const out = Object.assign({ "Content-Type": "application/json" }, extra);
311 if (token()) out.Authorization = "Bearer " + token();
312 return out;
313 }
314
315 async function api(path, options = {}) {
316 const res = await fetch(path, Object.assign({}, options, {
317 headers: headers(options.headers || {})
318 }));
319 if (!res.ok) {
320 let detail = await res.text();
321 try {
322 const parsed = JSON.parse(detail);
323 detail = parsed.error?.message || detail;
324 } catch (_) {}
325 throw new Error(detail || ("HTTP " + res.status));
326 }
327 if (res.status === 204) return null;
328 return res.json();
329 }
330
331 function escapeHtml(raw) {
332 return String(raw).replace(/[&<>"']/g, (char) => ({
333 "&": "&amp;",
334 "<": "&lt;",
335 ">": "&gt;",
336 "\"": "&quot;",
337 "'": "&#039;"
338 }[char]));
339 }
340
341 function eventPayload(data) {
342 return data && typeof data === "object" && "payload" in data ? data.payload : data;
343 }
344
345 function eventText(name, data) {
346 const payload = eventPayload(data) || {};
347 if (name === "item.delta") return payload.delta || "";
348 if (name === "item.started") {
349 const tool = payload.tool;
350 if (tool) return "Tool started: " + tool.name + "\n" + JSON.stringify(tool.input || {}, null, 2);
351 }
352 if (name === "item.completed" || name === "item.failed") {
353 const item = payload.item || {};
354 return (name === "item.completed" ? "Completed: " : "Failed: ") + (item.summary || item.kind || "");
355 }
356 if (name === "approval.required") {
357 return "Approval required: " + (payload.tool_name || "") + "\n" + (payload.description || "");
358 }
359 if (name === "approval.decided") {
360 return "Approval " + (payload.decision || "decided") + ": " + (payload.approval_id || "");
361 }
362 if (name === "approval.timeout") {
363 return "Approval timed out: " + (payload.approval_id || "");
364 }
365 if (name === "sandbox.denied") {
366 return "Sandbox denied: " + (payload.tool_name || "") + "\n" + (payload.reason || "");
367 }
368 if (name === "turn.lifecycle") return "Turn status: " + (payload.status || "");
369 if (name === "turn.completed") return "Turn completed";
370 return JSON.stringify(payload, null, 2);
371 }
372
373 function approvalInfo(name, data) {
374 if (name !== "approval.required") return null;
375 const payload = eventPayload(data) || {};
376 const approvalId = payload.approval_id || payload.id;
377 if (!approvalId) return null;
378 return { id: approvalId };
379 }
380
381 function pruneVisibleEvents() {
382 const events = $("events");
383 while (events.childElementCount > MAX_VISIBLE_EVENTS) {
384 events.firstElementChild.remove();
385 }
386 }
387
388 async function decideApproval(approvalId, decision, container) {
389 for (const button of container.querySelectorAll("button")) button.disabled = true;
390 const remember = $("remember-approval").checked;
391 try {
392 const result = await api("/v1/approvals/" + encodeURIComponent(approvalId), {
393 method: "POST",
394 body: JSON.stringify({ decision, remember })
395 });
396 const decided = result?.decision ?? decision;
397 container.innerHTML = "<span class='meta'>Decision sent: " + escapeHtml(decided) + "</span>";
398 } catch (err) {
399 for (const button of container.querySelectorAll("button")) button.disabled = false;
400 throw err;
401 }
402 }
403
404 function appendEvent(name, data) {
405 const text = eventText(name, data);
406 if (!text) return;
407 const item = document.createElement("div");
408 item.className = "event";
409 if (name === "item.delta") item.classList.add("agent");
410 if (name === "item.started" || name.includes("tool")) item.classList.add("tool");
411 if (name.includes("failed") || name.includes("denied") || name.includes("timeout")) item.classList.add("error");
412 if (name === "turn.completed" || name === "approval.decided") item.classList.add("ok");
413 item.innerHTML = "<div class='meta'>" + escapeHtml(name) + "</div>" + escapeHtml(text);
414
415 const approval = approvalInfo(name, data);
416 if (approval) {
417 const actions = document.createElement("div");
418 actions.className = "approval-actions";
419 const allow = document.createElement("button");
420 allow.className = "primary";
421 allow.textContent = "Allow";
422 allow.onclick = () => decideApproval(approval.id, "allow", actions)
423 .catch((err) => setStatus(err.message, "bad"));
424 const deny = document.createElement("button");
425 deny.className = "danger";
426 deny.textContent = "Deny";
427 deny.onclick = () => decideApproval(approval.id, "deny", actions)
428 .catch((err) => setStatus(err.message, "bad"));
429 actions.appendChild(allow);
430 actions.appendChild(deny);
431 item.appendChild(actions);
432 }
433
434 $("events").appendChild(item);
435 pruneVisibleEvents();
436 state.eventCount += 1;
437 const visible = Math.min(state.eventCount, MAX_VISIBLE_EVENTS);
438 $("event-count").textContent = visible + " of " + state.eventCount + " events";
439 $("events").scrollTop = $("events").scrollHeight;
440 }
441
442 async function loadThreads() {
443 const threads = await api("/v1/threads/summary?limit=60&include_archived=false");
444 const list = $("threads");
445 list.innerHTML = "";
446 if (!threads || !threads.length) {
447 list.innerHTML = "<div class='empty'>No active threads.</div>";
448 return;
449 }
450 for (const thread of threads) {
451 const el = document.createElement("button");
452 el.className = "thread" + (thread.id === state.threadId ? " active" : "");
453 el.innerHTML =
454 "<div class='thread-title'>" + escapeHtml(thread.title || thread.id) + "</div>" +
455 "<div class='meta'>" + escapeHtml(thread.mode || "") + " / " +
456 escapeHtml(thread.model || "") + "</div>";
457 el.onclick = () => selectThread(thread.id, thread.title || thread.id);
458 list.appendChild(el);
459 }
460 }
461
462 async function newThread() {
463 const thread = await api("/v1/threads", {
464 method: "POST",
465 body: JSON.stringify({
466 mode: "agent",
467 allow_shell: $("allow-shell").checked,
468 trust_mode: false,
469 auto_approve: $("auto-approve").checked
470 })
471 });
472 await loadThreads();
473 selectThread(thread.id, thread.title || thread.id);
474 }
475
476 async function selectThread(id, title) {
477 state.threadId = id;
478 state.activeTurnId = "";
479 state.eventCount = 0;
480 $("event-count").textContent = "0 events";
481 $("active-title").textContent = title || id;
482 $("events").innerHTML = "";
483 if (state.source) state.source.close();
484 if (token()) setRuntimeTokenCookie(token());
485 const qs = "?since_seq=0&replay_limit=" + MAX_VISIBLE_EVENTS;
486 const source = new EventSource("/v1/threads/" + encodeURIComponent(id) + "/events" + qs);
487 state.source = source;
488 const names = [
489 "thread.started",
490 "turn.started",
491 "turn.lifecycle",
492 "turn.steered",
493 "turn.interrupt_requested",
494 "turn.completed",
495 "item.started",
496 "item.delta",
497 "item.completed",
498 "item.failed",
499 "approval.required",
500 "approval.decided",
501 "approval.timeout",
502 "sandbox.denied"
503 ];
504 for (const name of names) {
505 source.addEventListener(name, (ev) => {
506 let data = {};
507 try { data = JSON.parse(ev.data || "{}"); } catch (_) {}
508 if (data.turn_id) state.activeTurnId = data.turn_id;
509 appendEvent(name, data);
510 });
511 }
512 source.onopen = () => setStatus("Connected", "ok");
513 source.onerror = () => setStatus("Event stream disconnected", "warn");
514 await loadThreads();
515 }
516
517 async function sendPrompt() {
518 if (!state.threadId) await newThread();
519 const prompt = $("prompt").value.trim();
520 if (!prompt) return;
521 const res = await api("/v1/threads/" + encodeURIComponent(state.threadId) + "/turns", {
522 method: "POST",
523 body: JSON.stringify({
524 prompt,
525 allow_shell: $("allow-shell").checked,
526 trust_mode: false,
527 auto_approve: $("auto-approve").checked
528 })
529 });
530 state.activeTurnId = res.turn?.id || state.activeTurnId;
531 $("prompt").value = "";
532 await loadThreads();
533 }
534
535 async function steerTurn() {
536 if (!state.threadId || !state.activeTurnId) throw new Error("No active turn");
537 const prompt = $("prompt").value.trim();
538 if (!prompt) return;
539 await api(
540 "/v1/threads/" + encodeURIComponent(state.threadId) +
541 "/turns/" + encodeURIComponent(state.activeTurnId) + "/steer",
542 { method: "POST", body: JSON.stringify({ prompt }) }
543 );
544 $("prompt").value = "";
545 }
546
547 async function interruptTurn() {
548 if (!state.threadId || !state.activeTurnId) throw new Error("No active turn");
549 await api(
550 "/v1/threads/" + encodeURIComponent(state.threadId) +
551 "/turns/" + encodeURIComponent(state.activeTurnId) + "/interrupt",
552 { method: "POST", body: "{}" }
553 );
554 }
555
556 async function boot() {
557 takeTokenFromUrl();
558 $("token").value = localStorage.getItem(TOKEN_STORAGE_KEY) || "";
559 if (token()) setRuntimeTokenCookie(token());
560 $("save-token").onclick = async () => {
561 localStorage.setItem(TOKEN_STORAGE_KEY, token());
562 setRuntimeTokenCookie(token());
563 await loadThreads().catch((err) => setStatus(err.message, "bad"));
564 };
565 $("refresh").onclick = () => loadThreads().catch((err) => setStatus(err.message, "bad"));
566 $("new-thread").onclick = () => newThread().catch((err) => setStatus(err.message, "bad"));
567 $("send").onclick = () => sendPrompt().catch((err) => setStatus(err.message, "bad"));
568 $("steer").onclick = () => steerTurn().catch((err) => setStatus(err.message, "bad"));
569 $("interrupt").onclick = () => interruptTurn().catch((err) => setStatus(err.message, "bad"));
570 await loadThreads();
571 setStatus("Connected", "ok");
572 }
573
574 boot().catch((err) => setStatus(err.message, "bad"));
575 </script>
576 </body>
577 </html>
578
578 lines HTML