@hncode/hncode 0.3.6 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/agent-task.js +154 -84
- package/src/agent.js +533 -145
- package/src/clipboard.js +5 -2
- package/src/colors.js +242 -238
- package/src/config.js +217 -17
- package/src/file-history.js +177 -0
- package/src/hooks.js +11 -1
- package/src/i18n.js +389 -0
- package/src/index.js +46 -3
- package/src/llm.js +139 -16
- package/src/permissions.js +120 -0
- package/src/session-hub.js +407 -0
- package/src/session.js +31 -0
- package/src/term.js +19 -3
- package/src/tool-result.js +236 -0
- package/src/tools/agent.js +11 -0
- package/src/tools/bash.js +84 -17
- package/src/tools/edit.js +192 -32
- package/src/tools/file-lines.js +5 -0
- package/src/tools/index.js +4 -0
- package/src/tools/memory.js +88 -0
- package/src/tools/read-media-file.js +85 -68
- package/src/tools/read.js +15 -1
- package/src/tools/utils.js +14 -0
- package/src/tools/write.js +35 -5
- package/src/tui.js +11665 -9179
- package/src/web-auth.js +98 -0
- package/src/web-daemon-cli.js +63 -0
- package/src/web-daemon-client.js +203 -0
- package/src/web-daemon.js +871 -0
- package/src/web-public/app.css +1509 -0
- package/src/web-public/app.js +2913 -0
- package/src/web-public/complete.js +154 -0
- package/src/web-public/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/src/web-public/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-100-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-101-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-102-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-103-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-104-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-105-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-106-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-107-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-108-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-109-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-110-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-111-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-112-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-113-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-114-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-115-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-116-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-117-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-118-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-119-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-21-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-22-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-23-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-24-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-25-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-26-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-27-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-28-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-29-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-30-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-31-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-32-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-33-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-34-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-35-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-36-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-37-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-38-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-39-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-4-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-40-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-41-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-42-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-43-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-44-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-45-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-46-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-47-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-48-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-49-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-5-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-50-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-51-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-52-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-53-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-54-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-55-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-56-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-57-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-58-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-59-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-6-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-60-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-61-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-62-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-63-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-64-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-65-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-66-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-67-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-68-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-69-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-70-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-71-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-72-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-73-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-74-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-75-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-76-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-77-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-78-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-79-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-80-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-81-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-82-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-83-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-84-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-85-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-86-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-87-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-88-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-89-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-90-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-91-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-97-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-98-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-99-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc.css +775 -0
- package/src/web-public/fonts.css +31 -0
- package/src/web-public/home.css +222 -0
- package/src/web-public/home.html +33 -0
- package/src/web-public/home.js +160 -0
- package/src/web-public/i18n.js +983 -0
- package/src/web-public/index.html +196 -0
- package/src/web-public/login.css +123 -0
- package/src/web-public/login.html +25 -0
- package/src/web-public/login.js +46 -0
- package/src/web-public/settings.js +290 -0
- package/src/web-ws.js +226 -0
- package/src/web.js +380 -0
|
@@ -0,0 +1,2913 @@
|
|
|
1
|
+
import { t, catalog, LANGUAGES, DEFAULT_LANG } from './i18n.js';
|
|
2
|
+
import { Completer } from './complete.js';
|
|
3
|
+
import { SettingsPanel, attachSettings } from './settings.js';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
const $ = (id) => document.getElementById(id);
|
|
7
|
+
const stream = $('stream');
|
|
8
|
+
const input = $('input');
|
|
9
|
+
|
|
10
|
+
let LANG = DEFAULT_LANG;
|
|
11
|
+
try {
|
|
12
|
+
const saved = localStorage.getItem('hncode.lang');
|
|
13
|
+
if (saved && LANGUAGES.some((l) => l.id === saved)) LANG = saved;
|
|
14
|
+
} catch {}
|
|
15
|
+
|
|
16
|
+
let T = catalog(LANG);
|
|
17
|
+
|
|
18
|
+
const tr = (key, vars) => {
|
|
19
|
+
let s = T[key] || key;
|
|
20
|
+
if (vars) {
|
|
21
|
+
for (const k of Object.keys(vars)) s = s.split('{' + k + '}').join(String(vars[k]));
|
|
22
|
+
}
|
|
23
|
+
return s;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Strip ANSI escape codes from a string. Rows the TUI sends for WIN-PLATFORM
|
|
27
|
+
// (role 'rich', some system lines) carry terminal styling (\x1b[97m etc.); the
|
|
28
|
+
// browser has no terminal, so those must become plain text or the literal
|
|
29
|
+
// "[97m" leak shows up in the transcript. Applied once at paintRow so every
|
|
30
|
+
// role — simple text, markdown, tool rows — is clean.
|
|
31
|
+
const ANSI_RE = /\u001b\[[0-9;:<=>?]*[ -/]*[@-~]/g;
|
|
32
|
+
function stripAnsi(s) { return String(s == null ? '' : s).replace(ANSI_RE, ''); }
|
|
33
|
+
|
|
34
|
+
let status = {};
|
|
35
|
+
let completer = null;
|
|
36
|
+
let settingsPanel = null;
|
|
37
|
+
|
|
38
|
+
function applyStatic() {
|
|
39
|
+
document.documentElement.lang = LANG === 'zh' ? 'zh-CN' : 'en';
|
|
40
|
+
for (const node of document.querySelectorAll('[data-i18n]')) {
|
|
41
|
+
node.textContent = tr(node.getAttribute('data-i18n'));
|
|
42
|
+
}
|
|
43
|
+
for (const node of document.querySelectorAll('[data-i18n-title]')) {
|
|
44
|
+
node.title = tr(node.getAttribute('data-i18n-title'));
|
|
45
|
+
}
|
|
46
|
+
for (const node of document.querySelectorAll('[data-i18n-placeholder]')) {
|
|
47
|
+
node.placeholder = tr(node.getAttribute('data-i18n-placeholder'));
|
|
48
|
+
}
|
|
49
|
+
for (const node of document.querySelectorAll('[data-i18n-aria-label]')) {
|
|
50
|
+
node.setAttribute('aria-label', tr(node.getAttribute('data-i18n-aria-label')));
|
|
51
|
+
}
|
|
52
|
+
rerenderRows();
|
|
53
|
+
renderStatus();
|
|
54
|
+
setPmode();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildLangSwitch() {
|
|
58
|
+
const box = $('lang-switch');
|
|
59
|
+
box.replaceChildren();
|
|
60
|
+
box.title = tr('nav.language');
|
|
61
|
+
for (const l of LANGUAGES) {
|
|
62
|
+
const b = document.createElement('button');
|
|
63
|
+
b.type = 'button';
|
|
64
|
+
b.textContent = l.label;
|
|
65
|
+
b.className = l.id === LANG ? 'is-on' : '';
|
|
66
|
+
b.addEventListener('click', () => {
|
|
67
|
+
if (l.id === LANG) return;
|
|
68
|
+
LANG = l.id;
|
|
69
|
+
T = catalog(LANG);
|
|
70
|
+
try { localStorage.setItem('hncode.lang', LANG); } catch {}
|
|
71
|
+
buildLangSwitch();
|
|
72
|
+
applyStatic();
|
|
73
|
+
});
|
|
74
|
+
box.appendChild(b);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function rerenderRows() {
|
|
79
|
+
for (const [i, node] of win.els) {
|
|
80
|
+
const row = rowItems[i];
|
|
81
|
+
if (row && node._rec) paintRow(node._rec);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let es = null;
|
|
86
|
+
let retry = 0;
|
|
87
|
+
let retryTimer = null;
|
|
88
|
+
let readOnly = false;
|
|
89
|
+
let readOnlyChecked = false;
|
|
90
|
+
|
|
91
|
+
function setConn(kind, text) {
|
|
92
|
+
$('conn').className = 'dot dot-' + kind;
|
|
93
|
+
$('conn-text').textContent = text;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Live updates arrive over WebSocket, not EventSource. The server (web.js) runs a
|
|
97
|
+
// zero-dependency RFC 6455 endpoint at /api/ws: on connect it sends a `snapshot`
|
|
98
|
+
// of the current view, then every hub event as a JSON text frame. A dropped
|
|
99
|
+
// connection is retried with backoff, like EventSource auto-reconnect did for
|
|
100
|
+
// SSE.
|
|
101
|
+
function connect() {
|
|
102
|
+
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
|
103
|
+
if (es) { try { es.close(); } catch { /* already gone */ } }
|
|
104
|
+
|
|
105
|
+
setConn('wait', tr('nav.connecting'));
|
|
106
|
+
// Relative URL, exactly like the old `new EventSource('api/events')`: the
|
|
107
|
+
// browser resolves it against the current document path, so behind the daemon
|
|
108
|
+
// (page at /s/<id>/) the handshake reaches /s/<id>/api/ws, and a standalone
|
|
109
|
+
// /web reaches /api/ws. Building `location.host + '/api/ws'` instead dropped
|
|
110
|
+
// the /s/<id> prefix and made the daemon drop every handshake.
|
|
111
|
+
const src = new WebSocket('api/ws');
|
|
112
|
+
es = src;
|
|
113
|
+
|
|
114
|
+
src.onopen = () => {
|
|
115
|
+
if (src !== es) return;
|
|
116
|
+
retry = 0;
|
|
117
|
+
setConn('on', tr('nav.connected'));
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
src.onmessage = (ev) => {
|
|
121
|
+
let msg;
|
|
122
|
+
try { msg = JSON.parse(ev.data); } catch { return; }
|
|
123
|
+
handle(msg);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
src.onclose = (ev) => {
|
|
127
|
+
// TEMP diag: record every close so a "reloads every few seconds" bug can be
|
|
128
|
+
// tied to who dropped the socket. Remove after diagnosing.
|
|
129
|
+
if (!window.__wsCloseLog) window.__wsCloseLog = [];
|
|
130
|
+
window.__wsCloseLog.push({ t: Date.now(), code: ev && ev.code, wasClean: ev && ev.wasClean, reason: ev && ev.reason });
|
|
131
|
+
console.log('WS-close', ev && ev.code, ev && ev.wasClean);
|
|
132
|
+
if (src !== es) return;
|
|
133
|
+
if (es === src) es = null;
|
|
134
|
+
// A WebSocket that never opened usually means the session has EXITED: the
|
|
135
|
+
// daemon destroys the handshake for read-only sessions (there is no live
|
|
136
|
+
// server to proxy). Fall back to a one-shot `api/state` fetch so the
|
|
137
|
+
// finished transcript still renders, instead of spinning on reconnect.
|
|
138
|
+
if (!readOnly && !readOnlyChecked && retry === 0) {
|
|
139
|
+
readOnlyChecked = true;
|
|
140
|
+
loadReadOnlySnapshot();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
setConn('off', tr('nav.disconnected'));
|
|
144
|
+
retry = Math.min(retry + 1, 6);
|
|
145
|
+
retryTimer = setTimeout(connect, 400 * retry);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
src.onerror = () => { /* onclose follows and does the retry */ };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The session has EXITED (read-only): load its snapshot over HTTP and freeze
|
|
152
|
+
// the UI. api/state on the daemon answer for a saved session; standalone web.js
|
|
153
|
+
// does too. Disable the composer so a read-only page cannot pretend it can act.
|
|
154
|
+
async function loadReadOnlySnapshot() {
|
|
155
|
+
if (readOnly) return;
|
|
156
|
+
let res;
|
|
157
|
+
try { res = await fetch('api/state', { headers: { accept: 'application/json' } }); }
|
|
158
|
+
catch { /* network error — fall through to normal retry */ }
|
|
159
|
+
if (!res || !res.ok) {
|
|
160
|
+
// Not a read-only case; resume normal reconnect.
|
|
161
|
+
if (res) setConn('off', tr('nav.disconnected'));
|
|
162
|
+
retry = Math.min(retry + 1, 6);
|
|
163
|
+
retryTimer = setTimeout(connect, 400 * retry);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
let data;
|
|
167
|
+
try { data = await res.json(); } catch { /* ignore */ return; }
|
|
168
|
+
readOnly = true;
|
|
169
|
+
applySnapshot({ rows: (data && data.rows) || [], status: (data && data.status) || {} });
|
|
170
|
+
setConn('off', tr('nav.disconnected'));
|
|
171
|
+
// Freeze the composer: no WS to send on, and the session is read-only anyway.
|
|
172
|
+
const inputEl = $('input');
|
|
173
|
+
if (inputEl) inputEl.disabled = true;
|
|
174
|
+
setPanelVisible('send', false);
|
|
175
|
+
setPanelVisible('btn-interrupt', false);
|
|
176
|
+
setPanelVisible('working', false);
|
|
177
|
+
const readonlyBadge = $('readonly-badge');
|
|
178
|
+
if (readonlyBadge) readonlyBadge.hidden = false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function act(name, args) {
|
|
182
|
+
// The ONLY transport is the WebSocket. Sending is fire-and-forget (the UI
|
|
183
|
+
// updates optimistically; the real result rides the status/row stream back),
|
|
184
|
+
// and there is deliberately NO HTTP fallback: if the socket is down the server
|
|
185
|
+
// is unreachable, so a POST would not reach it either — it would just hang the
|
|
186
|
+
// caller. Tell the user the link is down and let the reconnect loop restore it.
|
|
187
|
+
var ws = es;
|
|
188
|
+
if (ws && ws.readyState === 1) {
|
|
189
|
+
try {
|
|
190
|
+
ws.send(JSON.stringify({ action: name, args: args || [] }));
|
|
191
|
+
return { ok: true };
|
|
192
|
+
} catch (e) { /* fall through to down-report */ }
|
|
193
|
+
}
|
|
194
|
+
// Connection lost. If the socket is simply reconnecting (readyState 0), wait
|
|
195
|
+
// for it; if it is closed, tell the user and ensure connect() is running.
|
|
196
|
+
if (ws && ws.readyState === 0) {
|
|
197
|
+
return { ok: false, error: tr('err.unreachable') };
|
|
198
|
+
}
|
|
199
|
+
toast(tr('nav.disconnected'));
|
|
200
|
+
connect();
|
|
201
|
+
return { ok: false, error: tr('err.unreachable') };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function actReturning(name, args) {
|
|
205
|
+
const body = await act(name, args);
|
|
206
|
+
if (!body || body.ok !== true) throw new Error((body && body.error) || tr('err.actionFailed'));
|
|
207
|
+
return body;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let toastTimer = null;
|
|
211
|
+
|
|
212
|
+
function toast(text) {
|
|
213
|
+
const node = $('notice');
|
|
214
|
+
node.textContent = text;
|
|
215
|
+
node.hidden = false;
|
|
216
|
+
clearTimeout(toastTimer);
|
|
217
|
+
toastTimer = setTimeout(() => { node.hidden = true; }, 4000);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function handle(msg) {
|
|
221
|
+
switch (msg.type) {
|
|
222
|
+
case 'snapshot': applySnapshot(msg); break;
|
|
223
|
+
case 'row': addRow(msg.row); break;
|
|
224
|
+
case 'row_patch': patchRow(msg.id, msg.patch); break;
|
|
225
|
+
case 'rows_reset': applySnapshot({ rows: msg.rows, status }); break;
|
|
226
|
+
case 'status': applyStatus(msg.status); break;
|
|
227
|
+
// Granular events (claude-code style): update just one panel, not the whole
|
|
228
|
+
// status DOM. The server pushes these when a single field changes.
|
|
229
|
+
case 'queued': {
|
|
230
|
+
const q = Array.isArray(msg.queued) ? msg.queued : [];
|
|
231
|
+
if (!status) status = {};
|
|
232
|
+
status.queued = q;
|
|
233
|
+
setPanelVisible('queue-panel', q.length > 0);
|
|
234
|
+
renderQueuePanel(q, !!(status && status.busy));
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
case 'todos': {
|
|
238
|
+
const t = Array.isArray(msg.todos) ? msg.todos : [];
|
|
239
|
+
if (!status) status = {};
|
|
240
|
+
status.todos = t;
|
|
241
|
+
setPanelVisible('todo-panel', t.length > 0);
|
|
242
|
+
renderTodoPanel(t);
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case 'tasks': {
|
|
246
|
+
const t = Array.isArray(msg.tasks) ? msg.tasks : [];
|
|
247
|
+
if (status) status.tasks = t;
|
|
248
|
+
if (!isViewHidden('tasks')) renderTasks(t);
|
|
249
|
+
renderTabCounts(status || {});
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
default: break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Animation-only fields, matching the server's exclusion list (session-hub.js).
|
|
258
|
+
* They drive the Working pulse, which the browser draws for itself; a change in
|
|
259
|
+
* just these must NOT trigger a full status re-render, or a long turn would
|
|
260
|
+
* rebuild every card pane every frame — exactly the churn that froze the tab.
|
|
261
|
+
*/
|
|
262
|
+
const ANIMATION_FIELDS = new Set(['spin', 'workMsg', 'pulseStart']);
|
|
263
|
+
|
|
264
|
+
function samePlain(a, b) {
|
|
265
|
+
if (a === b) return true; // identical ref or primitive
|
|
266
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
267
|
+
if (a.length !== b.length) return false; // cheap length gate first
|
|
268
|
+
}
|
|
269
|
+
return JSON.stringify(a) === JSON.stringify(b); // deep: last resort only
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function stableStatusChanged(next) {
|
|
273
|
+
for (const k of Object.keys(next)) {
|
|
274
|
+
if (ANIMATION_FIELDS.has(k)) continue;
|
|
275
|
+
if (!samePlain(next[k], status[k])) return true;
|
|
276
|
+
}
|
|
277
|
+
for (const k of Object.keys(status)) {
|
|
278
|
+
if (ANIMATION_FIELDS.has(k)) continue;
|
|
279
|
+
if (!(k in next)) return true;
|
|
280
|
+
}
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function applyStatus(s) {
|
|
285
|
+
const next = s || {};
|
|
286
|
+
// The WORKING row's word / pulse phase is drawn by drawPulse() off its own
|
|
287
|
+
// clock, so a broadcast that only moved `spin` should not touch the panels.
|
|
288
|
+
const stableChanged = !status || stableStatusChanged(next);
|
|
289
|
+
const wasBusy = !!(status && status.busy);
|
|
290
|
+
status = next;
|
|
291
|
+
// Always animate the Working row off the latest state; only re-render the
|
|
292
|
+
// whole status DOM when a stable field actually changed.
|
|
293
|
+
if (status.busy) drawPulse(); else clearPulse();
|
|
294
|
+
if (stableChanged) renderStatus();
|
|
295
|
+
else if (status.busy !== wasBusy) renderBusyButton();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let rowItems = [];
|
|
299
|
+
|
|
300
|
+
const rowIndex = new Map();
|
|
301
|
+
let expandedRows = new Set();
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Set scrollTop as a PROGRAMMATIC move and remember the target, so the async
|
|
305
|
+
* `scroll` event it fires is recognized as ours and not mistaken for a user
|
|
306
|
+
* scroll. The old `pinning` flag alone was unreliable: the scroll event is
|
|
307
|
+
* dispatched a frame later, by which time the microtask that cleared `pinning`
|
|
308
|
+
* had already run — so a programmatic "snap to bottom" was read back as the user
|
|
309
|
+
* scrolling to the bottom, which re-pinned the view and fought a user who had
|
|
310
|
+
* just scrolled up.
|
|
311
|
+
*/
|
|
312
|
+
function setScrollTop(v) {
|
|
313
|
+
stream.scrollTop = v;
|
|
314
|
+
// Remember the CLAMPED position the browser actually landed on (scrollTop is
|
|
315
|
+
// capped to the scrollable range), so the async scroll event compares equal and
|
|
316
|
+
// is correctly ignored rather than misread as a user scroll.
|
|
317
|
+
win._progTop = stream.scrollTop;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Flow virtualisation: rows live in NORMAL document flow; the browser computes
|
|
321
|
+
// every real height, so two rows can never overlap. Only rows inside the
|
|
322
|
+
// window have DOM; the ranges above/below it are reserved by two padding divs,
|
|
323
|
+
// sized from measured heights (PLACEHOLDER_H until a row is first mounted).
|
|
324
|
+
const WIN_OVERSCAN = 8;
|
|
325
|
+
const NEAR_END_PX = 24;
|
|
326
|
+
// TanStack Virtual's rule for estimateSize: "estimate the LARGEST possible
|
|
327
|
+
// size (within comfort)". An underestimate makes the scroll range too short,
|
|
328
|
+
// so unmeasured history is skipped past and rows pop/jump as they mount. A
|
|
329
|
+
// generous overestimate only makes the scrollbar slightly long, which the
|
|
330
|
+
// measurement cache shrinks as rows mount. Tool rows dominate this transcript,
|
|
331
|
+
// and a folded result is ~8 lines + chrome, so ~180px is a safe floor.
|
|
332
|
+
const PLACEHOLDER_H = 180;
|
|
333
|
+
|
|
334
|
+
const win = {
|
|
335
|
+
spacer: null,
|
|
336
|
+
topPad: null,
|
|
337
|
+
bottomPad: null,
|
|
338
|
+
heights: [], // REAL heights, measured per mounted row (0 = never seen)
|
|
339
|
+
start: 0,
|
|
340
|
+
end: 0,
|
|
341
|
+
els: new Map(),
|
|
342
|
+
raf: null,
|
|
343
|
+
pinned: true,
|
|
344
|
+
_lastStart: -1,
|
|
345
|
+
_lastEnd: -1,
|
|
346
|
+
_progTop: null, // last programmatic scrollTop, to ignore our own scroll event
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
function knownHeight(i) {
|
|
350
|
+
const h = win.heights[i];
|
|
351
|
+
return h > 0 ? h : PLACEHOLDER_H;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function heightAbove(i) {
|
|
355
|
+
let sum = 0;
|
|
356
|
+
for (let k = 0; k < i && k < rowItems.length; k++) sum += knownHeight(k);
|
|
357
|
+
return sum;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function heightBelow(i) {
|
|
361
|
+
let sum = 0;
|
|
362
|
+
for (let k = i; k < rowItems.length; k++) sum += knownHeight(k);
|
|
363
|
+
return sum;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function scheduleWindow() {
|
|
367
|
+
if (win.raf !== null) return;
|
|
368
|
+
win.raf = requestAnimationFrame(renderWindow);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function ensureSpacer() {
|
|
372
|
+
if (win.spacer && win.spacer.isConnected) return win.spacer;
|
|
373
|
+
stream.replaceChildren();
|
|
374
|
+
win.spacer = document.createElement("div");
|
|
375
|
+
win.spacer.className = "win-spacer";
|
|
376
|
+
win.topPad = document.createElement("div");
|
|
377
|
+
win.bottomPad = document.createElement("div");
|
|
378
|
+
win.topPad.className = "win-pad";
|
|
379
|
+
win.bottomPad.className = "win-pad";
|
|
380
|
+
win.spacer.append(win.topPad, win.bottomPad);
|
|
381
|
+
stream.appendChild(win.spacer);
|
|
382
|
+
win.els.clear();
|
|
383
|
+
return win.spacer;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function mountRow(i, el) {
|
|
387
|
+
// Insert at the row's DOCUMENT position, not blindly at the end. Scrolling
|
|
388
|
+
// UP mounts top rows while later rows are already in the spacer; appending
|
|
389
|
+
// them at the end put a row that belongs ABOVE below rows that belong BELOW
|
|
390
|
+
// — document order broke and the transcript rendered scrambled/overlapping.
|
|
391
|
+
let before = win.bottomPad;
|
|
392
|
+
for (let k = i + 1; k < win.end; k++) {
|
|
393
|
+
const next = win.els.get(k);
|
|
394
|
+
if (next) { before = next; break; }
|
|
395
|
+
}
|
|
396
|
+
win.spacer.insertBefore(el, before);
|
|
397
|
+
win.els.set(i, el);
|
|
398
|
+
const rec = el._rec;
|
|
399
|
+
if (rec) rec.index = i;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function unmountRow(i) {
|
|
403
|
+
const el = win.els.get(i);
|
|
404
|
+
if (!el) return;
|
|
405
|
+
const rec = el._rec;
|
|
406
|
+
if (rec) {
|
|
407
|
+
const h = el.getBoundingClientRect().height;
|
|
408
|
+
if (h > 0 && win.heights[rec.index] !== h) { win.heights[rec.index] = h; win._lastStart = -1; }
|
|
409
|
+
if (rec.ro) rec.ro.unobserve(el);
|
|
410
|
+
}
|
|
411
|
+
el.remove();
|
|
412
|
+
win.els.delete(i);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function clearAllRows() {
|
|
416
|
+
for (const i of [...win.els.keys()]) unmountRow(i);
|
|
417
|
+
win._lastStart = -1;
|
|
418
|
+
win._lastEnd = -1;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function renderWindow() {
|
|
422
|
+
win.raf = null;
|
|
423
|
+
if (!win.spacer || !win.spacer.isConnected) return;
|
|
424
|
+
if (!rowItems.length) {
|
|
425
|
+
win.topPad.style.height = "0px";
|
|
426
|
+
win.bottomPad.style.height = "0px";
|
|
427
|
+
clearAllRows();
|
|
428
|
+
win.start = win.end = 0;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const viewH = stream.clientHeight || 608;
|
|
432
|
+
if (win.pinned) {
|
|
433
|
+
// Fixed-size tail window (by count, not by cumulative pixels): the tail
|
|
434
|
+
// rows are the newest, and a row-count window is exact regardless of how
|
|
435
|
+
// tall the measured rows are. PLACEHOLDER_H only prices the mounts into
|
|
436
|
+
// the two pads above/below the window; the pad math handles real heights.
|
|
437
|
+
const want = Math.ceil(viewH / PLACEHOLDER_H) + WIN_OVERSCAN;
|
|
438
|
+
win.end = rowItems.length;
|
|
439
|
+
win.start = Math.max(0, win.end - want);
|
|
440
|
+
} else {
|
|
441
|
+
let acc = 0;
|
|
442
|
+
let first = rowItems.length - 1;
|
|
443
|
+
for (let i = 0; i < rowItems.length; i++) {
|
|
444
|
+
if (acc >= stream.scrollTop) { first = i; break; }
|
|
445
|
+
acc += knownHeight(i);
|
|
446
|
+
}
|
|
447
|
+
const start = Math.max(0, first - WIN_OVERSCAN);
|
|
448
|
+
let end = start;
|
|
449
|
+
let used = 0;
|
|
450
|
+
while (end < rowItems.length && used < viewH + WIN_OVERSCAN * PLACEHOLDER_H) {
|
|
451
|
+
used += knownHeight(end);
|
|
452
|
+
end++;
|
|
453
|
+
}
|
|
454
|
+
win.start = start;
|
|
455
|
+
win.end = Math.min(rowItems.length, end + WIN_OVERSCAN);
|
|
456
|
+
}
|
|
457
|
+
const same = win.start === win._lastStart && win.end === win._lastEnd;
|
|
458
|
+
win._lastStart = win.start;
|
|
459
|
+
win._lastEnd = win.end;
|
|
460
|
+
if (same) return;
|
|
461
|
+
for (const k of [...win.els.keys()]) {
|
|
462
|
+
if (k < win.start || k >= win.end) unmountRow(k);
|
|
463
|
+
}
|
|
464
|
+
for (let k = win.start; k < win.end; k++) {
|
|
465
|
+
if (!win.els.has(k)) mountRow(k, buildRow(rowItems[k]));
|
|
466
|
+
}
|
|
467
|
+
win.topPad.style.height = heightAbove(win.start) + "px";
|
|
468
|
+
win.bottomPad.style.height = heightBelow(win.end) + "px";
|
|
469
|
+
if (win.pinned) setScrollTop(stream.scrollHeight);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
var sharedRO = typeof ResizeObserver === "function" ? new ResizeObserver(function (entries) {
|
|
473
|
+
let delta = 0;
|
|
474
|
+
for (const en of entries) {
|
|
475
|
+
const rec = en.target._rec;
|
|
476
|
+
if (!rec || rec.index < 0) continue;
|
|
477
|
+
const h = en.target.getBoundingClientRect().height;
|
|
478
|
+
const prev = win.heights[rec.index] > 0 ? win.heights[rec.index] : PLACEHOLDER_H;
|
|
479
|
+
if (h <= 0 || prev === h) continue;
|
|
480
|
+
win.heights[rec.index] = h;
|
|
481
|
+
delta += h - prev;
|
|
482
|
+
}
|
|
483
|
+
if (delta !== 0) {
|
|
484
|
+
win._lastStart = -1; // heights changed: paddings must be recomputed
|
|
485
|
+
scheduleWindow();
|
|
486
|
+
// TanStack semantics (shouldAdjustScrollPositionOnItemSizeChange): correct
|
|
487
|
+
// the scroll offset only when the change is ABOVE the viewport AND the user
|
|
488
|
+
// is not scrolling backward — that combination is the classic "rows jump
|
|
489
|
+
// while scrolling up" jank. End-pinned views still re-anchor to the bottom.
|
|
490
|
+
if (win.pinned) {
|
|
491
|
+
setScrollTop(stream.scrollHeight);
|
|
492
|
+
} else if (delta > 0) {
|
|
493
|
+
// A row above the viewport got TALLER: keep the text under the reader
|
|
494
|
+
// stationary by shifting the offset by the same amount.
|
|
495
|
+
setScrollTop(stream.scrollTop + delta);
|
|
496
|
+
}
|
|
497
|
+
// delta < 0 while unpinned: content above shrank; no correction needed,
|
|
498
|
+
// the browser keeps the viewport anchored to the same content.
|
|
499
|
+
}
|
|
500
|
+
}) : null;
|
|
501
|
+
|
|
502
|
+
function observeRow(el) {
|
|
503
|
+
if (sharedRO) sharedRO.observe(el);
|
|
504
|
+
return sharedRO;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function onScroll() {
|
|
508
|
+
// Ignore the scroll event our OWN setScrollTop just fired (same position),
|
|
509
|
+
// so a programmatic snap-to-bottom is never mistaken for a user scroll.
|
|
510
|
+
if (win._progTop != null && Math.abs(stream.scrollTop - win._progTop) <= 1) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
win._progTop = null;
|
|
514
|
+
const dist = stream.scrollHeight - stream.scrollTop - stream.clientHeight;
|
|
515
|
+
// Any scroll AWAY from the bottom is a deliberate move by the user, so the
|
|
516
|
+
// tail-follow ends IMMEDIATELY — no matter how small. The old rule kept
|
|
517
|
+
// `pinned` true until the user had scrolled more than a full viewport, so a
|
|
518
|
+
// small upward scroll left the view pinned and the next render yanked it back
|
|
519
|
+
// to the bottom: the "fighting the scroll" jank. Re-pin only when the user
|
|
520
|
+
// returns to (or near) the bottom.
|
|
521
|
+
win.pinned = dist <= NEAR_END_PX * 3;
|
|
522
|
+
scheduleWindow();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Patch any height-affecting repaint into the measurement cycle.
|
|
526
|
+
const patchQueue = new Set();
|
|
527
|
+
let patchFlushQueued = false;
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* A streaming patch repaints the row's content, which almost always changes its
|
|
531
|
+
* height — and a changed height invalidates every offset below it. So the reflow
|
|
532
|
+
* is forced here instead of waiting for the row's resize observer: the observer
|
|
533
|
+
* fires a frame LATER, and until it does the rows underneath sit at their old
|
|
534
|
+
* positions with the new, taller row drawn over them. That gap is the overlap
|
|
535
|
+
* that showed up while text streamed in.
|
|
536
|
+
*/
|
|
537
|
+
function queueRowPatch(rec, patch) {
|
|
538
|
+
for (const k of Object.keys(patch)) rec.row[k] = patch[k];
|
|
539
|
+
if (patch.append !== undefined) {
|
|
540
|
+
const cut = typeof patch.textTo === 'number' ? patch.textTo : 0;
|
|
541
|
+
rec.row.text = String(rec.row.text || '').slice(0, cut) + patch.append;
|
|
542
|
+
}
|
|
543
|
+
patchQueue.add(rec);
|
|
544
|
+
if (patchFlushQueued) return;
|
|
545
|
+
patchFlushQueued = true;
|
|
546
|
+
requestAnimationFrame(() => {
|
|
547
|
+
patchFlushQueued = false;
|
|
548
|
+
let heightsChanged = false;
|
|
549
|
+
for (const r of patchQueue) {
|
|
550
|
+
paintRow(r);
|
|
551
|
+
const h = r.el.getBoundingClientRect().height;
|
|
552
|
+
if (h > 0 && r.index >= 0 && win.heights[r.index] !== h) { win.heights[r.index] = h; heightsChanged = true; }
|
|
553
|
+
}
|
|
554
|
+
if (heightsChanged) win._lastStart = -1;
|
|
555
|
+
patchQueue.clear();
|
|
556
|
+
scheduleWindow();
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function resetView(rows, nextStatus) {
|
|
561
|
+
rowIndex.clear();
|
|
562
|
+
rowItems = (rows || []).map((r) => ({ ...r }));
|
|
563
|
+
for (let i = 0; i < rowItems.length; i++) {
|
|
564
|
+
const r = rowItems[i];
|
|
565
|
+
if (r && r.id != null) rowIndex.set(r.id, i);
|
|
566
|
+
}
|
|
567
|
+
status = nextStatus || {};
|
|
568
|
+
renderStatus();
|
|
569
|
+
|
|
570
|
+
expandedRows = new Set();
|
|
571
|
+
win.heights = new Array(rowItems.length).fill(0); // 0 = unmeasured placeholder
|
|
572
|
+
win.start = win.end = 0;
|
|
573
|
+
win.pinned = true;
|
|
574
|
+
clearAllRows();
|
|
575
|
+
win.spacer = null;
|
|
576
|
+
ensureSpacer();
|
|
577
|
+
renderWindow();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function applySnapshot(s) {
|
|
581
|
+
resetView(s.rows, s.status);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* The browser's transcript is capped at the same number the server keeps
|
|
586
|
+
* (`MAX_ROWS` in session-hub.js). Without a cap the two grow apart in the way
|
|
587
|
+
* that only shows up on the long sessions this UI exists for: the server trims
|
|
588
|
+
* its ring while the tab keeps every row it was ever sent, so the page holds a
|
|
589
|
+
* transcript the server no longer has — and every structure derived from it
|
|
590
|
+
* (the id index, the heights array, the expanded set) grows with it.
|
|
591
|
+
*/
|
|
592
|
+
const MAX_ROWS = 4000;
|
|
593
|
+
|
|
594
|
+
/** Drop the oldest rows until the list is back under the cap, moving every
|
|
595
|
+
derived structure with them. */
|
|
596
|
+
function trimRows() {
|
|
597
|
+
const over = rowItems.length - MAX_ROWS;
|
|
598
|
+
if (over <= 0) return;
|
|
599
|
+
|
|
600
|
+
rowItems.splice(0, over);
|
|
601
|
+
win.heights.splice(0, over);
|
|
602
|
+
|
|
603
|
+
// Every remaining row moved up by `over`, so the id index is rebuilt rather
|
|
604
|
+
// than patched — an incremental fix-up here is exactly where an off-by-`over`
|
|
605
|
+
// bug would hide.
|
|
606
|
+
rowIndex.clear();
|
|
607
|
+
for (let i = 0; i < rowItems.length; i++) {
|
|
608
|
+
const r = rowItems[i];
|
|
609
|
+
if (r && r.id != null) rowIndex.set(r.id, i);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Mounted elements are keyed by index, so they are now wrong too; dropping
|
|
613
|
+
// them costs one re-render and removes any chance of a stale element painting
|
|
614
|
+
// a row that has moved.
|
|
615
|
+
clearAllRows();
|
|
616
|
+
win.start = win.end = 0;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function addRow(row) {
|
|
620
|
+
if (!row) return;
|
|
621
|
+
if (row.id != null && rowIndex.has(row.id)) { patchRow(row.id, row); return; }
|
|
622
|
+
const item = { ...row };
|
|
623
|
+
rowItems.push(item);
|
|
624
|
+
// Pre-estimate the row's height so a long message is sized correctly even
|
|
625
|
+
// before it scrolls into view; the resize observer replaces this once mounted.
|
|
626
|
+
win.heights.push(0); // unmeasured until first mount
|
|
627
|
+
if (item.id != null) rowIndex.set(item.id, rowItems.length - 1);
|
|
628
|
+
// Results start FOLDED — the "more" button adds the id to expandedRows.
|
|
629
|
+
// (Every row used to start expanded, which defeated the fold caps entirely
|
|
630
|
+
// and made a giant tool result overlap everything around it.)
|
|
631
|
+
trimRows();
|
|
632
|
+
scheduleWindow();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function patchRow(id, patch) {
|
|
636
|
+
const i = rowIndex.get(id);
|
|
637
|
+
if (i === undefined) return;
|
|
638
|
+
const prev = rowItems[i];
|
|
639
|
+
const next = { ...prev, ...patch };
|
|
640
|
+
if (patch.append !== undefined) {
|
|
641
|
+
const cut = typeof patch.textTo === 'number' ? patch.textTo : 0;
|
|
642
|
+
next.text = String(prev.text || '').slice(0, cut) + patch.append;
|
|
643
|
+
delete next.append;
|
|
644
|
+
delete next.textTo;
|
|
645
|
+
}
|
|
646
|
+
rowItems[i] = next;
|
|
647
|
+
const el = win.els.get(i);
|
|
648
|
+
if (el && el._rec) {
|
|
649
|
+
queueRowPatch(el._rec, patch);
|
|
650
|
+
} else if (win.heights[i] !== undefined) {
|
|
651
|
+
// Not on screen: keep the pre-estimate in step with the growing text, or the
|
|
652
|
+
// scrollbar lags a long streaming message by a whole viewport.
|
|
653
|
+
win.heights[i] = 0; // re-measure on next mount
|
|
654
|
+
scheduleWindow();
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
function repaintRow(id) {
|
|
660
|
+
const i = rowIndex.get(id);
|
|
661
|
+
if (i === undefined) return;
|
|
662
|
+
const el = win.els.get(i);
|
|
663
|
+
if (!el || !el._rec) return;
|
|
664
|
+
paintRow(el._rec);
|
|
665
|
+
const h = el.getBoundingClientRect().height;
|
|
666
|
+
if (h > 0) win.heights[i] = h;
|
|
667
|
+
scheduleWindow();
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* MARKERS — the `❯` / `●` / `↳` the terminal prints at the start of a line.
|
|
674
|
+
*
|
|
675
|
+
* The terminal has NO gutter column: `messageLines()` builds each row as a text
|
|
676
|
+
* prefix (`'❯ '`, `'● '`, `'↳ '`, or a two-space continuation) concatenated to
|
|
677
|
+
* the body, and `rowToLine()` emits `ind + body` verbatim. The marker's two
|
|
678
|
+
* columns ARE the layout, so a wrapped paragraph hangs under the marker for
|
|
679
|
+
* free. The browser reproduces that with a CSS `::before` on the body box — a
|
|
680
|
+
* real grid column would have to centre the glyph and would not hang right.
|
|
681
|
+
*/
|
|
682
|
+
const MARK_KEY = {
|
|
683
|
+
user: 'mark.user',
|
|
684
|
+
queued: 'mark.queued',
|
|
685
|
+
steer: 'mark.steer',
|
|
686
|
+
bash: 'mark.bash',
|
|
687
|
+
warn: 'mark.warn',
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
const BOX_ROLES = new Set(['user', 'queued', 'bash']);
|
|
691
|
+
const SIMPLE_ROLES = new Set(['user', 'queued', 'steer', 'bash', 'system', 'warn', 'error', 'aborted']);
|
|
692
|
+
|
|
693
|
+
function markFor(role, row) {
|
|
694
|
+
if (role === 'tool') return tr(row.failed ? 'mark.toolFailed' : 'mark.tool');
|
|
695
|
+
if (role === 'tool_result') return tr('mark.result');
|
|
696
|
+
if (role === 'thinking') return tr('mark.thinking');
|
|
697
|
+
if (role === 'compaction') return tr('mark.tool');
|
|
698
|
+
if (role === 'bg_task') {
|
|
699
|
+
return tr(row.card && row.card.phase === 'failed' ? 'mark.bgTaskFailed' : 'mark.bgTask');
|
|
700
|
+
}
|
|
701
|
+
const key = MARK_KEY[role];
|
|
702
|
+
return key ? tr(key) : '';
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function rowClassFor(role) {
|
|
706
|
+
if (role === 'tool') return 'msg-tool';
|
|
707
|
+
if (role === 'tool_result') return 'msg-result';
|
|
708
|
+
if (role === 'bg_task') return 'msg-bgtask';
|
|
709
|
+
return 'msg-' + role;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// A single assistant row past this many characters renders as PLAIN TEXT even
|
|
713
|
+
// if it contains markdown: block-parsing a 200KB reply into thousands of nodes
|
|
714
|
+
// in one row stalled the tab. Plain text is one node and reads fine; the row
|
|
715
|
+
// content itself is what the model sent.
|
|
716
|
+
const MD_CHAR_BUDGET = 60000;
|
|
717
|
+
|
|
718
|
+
function buildRow(row) {
|
|
719
|
+
const el = document.createElement('div');
|
|
720
|
+
el.className = 'win-row';
|
|
721
|
+
const rec = { el, row: row || {}, role: '', body: null, ro: null, simple: false, index: -1, mark: null };
|
|
722
|
+
el._rec = rec;
|
|
723
|
+
rec.ro = observeRow(el);
|
|
724
|
+
paintRow(rec);
|
|
725
|
+
return el;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function paintRow(rec) {
|
|
729
|
+
const r = rec.row || {};
|
|
730
|
+
const role = String(r.role || 'system');
|
|
731
|
+
const text = stripAnsi(String(r.text == null ? '' : r.text));
|
|
732
|
+
|
|
733
|
+
// STREAMING: a pending assistant row ALWAYS renders as one text node. The
|
|
734
|
+
// markdown parser is O(text) per repaint, so running it on every streamed
|
|
735
|
+
// patch of a growing reply is quadratic and froze the tab. When the turn
|
|
736
|
+
// finishes (pending -> false) the row is re-classified once and the full
|
|
737
|
+
// markdown render happens exactly once, like the terminal finishing a block.
|
|
738
|
+
const streamPending = role === 'assistant' && r.pending === true;
|
|
739
|
+
const tooBigForMd = role === 'assistant' && text.length > MD_CHAR_BUDGET;
|
|
740
|
+
const simple = SIMPLE_ROLES.has(role) || (role === 'assistant' && (streamPending || tooBigForMd || !hasMarkup(text)));
|
|
741
|
+
if (rec.role !== role || rec.simple !== simple) {
|
|
742
|
+
rec.el.className = 'win-row ' + rowClassFor(role);
|
|
743
|
+
rec.body = document.createElement('div');
|
|
744
|
+
rec.body.className = BOX_ROLES.has(role) ? 'msg-box' : 'msg-body';
|
|
745
|
+
rec.el.replaceChildren(rec.body);
|
|
746
|
+
rec.role = role;
|
|
747
|
+
rec.simple = simple;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
rec.el.classList.toggle('is-failed', r.failed === true);
|
|
751
|
+
rec.el.classList.toggle('is-running', role === 'tool' && !!r.pending && !r.failed);
|
|
752
|
+
rec.el.classList.toggle('is-done', role === 'tool' && !r.pending && !r.failed);
|
|
753
|
+
if (role === 'compaction') {
|
|
754
|
+
rec.el.classList.toggle('is-done', r.phase === 'done');
|
|
755
|
+
rec.el.classList.toggle('is-cancelled', r.phase === 'cancelled');
|
|
756
|
+
rec.el.classList.toggle('is-running', r.phase !== 'done' && r.phase !== 'cancelled');
|
|
757
|
+
}
|
|
758
|
+
if (role === 'bg_task' && r.card) {
|
|
759
|
+
rec.el.classList.toggle('is-started', r.card.phase === 'started');
|
|
760
|
+
rec.el.classList.toggle('is-completed', r.card.phase === 'completed');
|
|
761
|
+
rec.el.classList.toggle('is-failed', r.card.phase === 'failed');
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const mark = markFor(role, r);
|
|
765
|
+
if (rec.mark !== mark) {
|
|
766
|
+
rec.mark = mark;
|
|
767
|
+
if (mark) rec.el.setAttribute('data-mark', mark);
|
|
768
|
+
else rec.el.removeAttribute('data-mark');
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (rec.simple) {
|
|
772
|
+
if (rec.body.textContent !== text) rec.body.textContent = text;
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
paintBody(rec, r, role, text);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
/** True when the text needs block rendering. A plain reply keeps ONE text node
|
|
780
|
+
for its whole life, so a streamed patch is a single assignment instead of a
|
|
781
|
+
rebuilt subtree on every frame. */
|
|
782
|
+
function hasMarkup(text) {
|
|
783
|
+
return text.indexOf('```') >= 0 || text.indexOf('#') >= 0
|
|
784
|
+
|| text.indexOf('`') >= 0 || text.indexOf('**') >= 0 || text.indexOf('](') >= 0;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const RESULT_MAX_LINES = 12;
|
|
788
|
+
const DIFF_MAX_ROWS = 16;
|
|
789
|
+
const THINK_PREVIEW = 1; // collapsed thinking shows a single live/one-line preview
|
|
790
|
+
|
|
791
|
+
function paintBody(rec, r, role, text) {
|
|
792
|
+
const body = rec.body;
|
|
793
|
+
body.replaceChildren();
|
|
794
|
+
|
|
795
|
+
if (role === 'tool') { paintToolRow(body, r, rec); return; }
|
|
796
|
+
if (role === 'tool_result') { paintResultRow(body, r); return; }
|
|
797
|
+
if (role === 'compaction') { paintCompactionRow(body, r); return; }
|
|
798
|
+
if (role === 'bg_task') { paintBgTaskRow(body, r); return; }
|
|
799
|
+
if (role === 'thinking') { paintThinkingRow(body, r, text); return; }
|
|
800
|
+
appendMarkdown(body, text);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function el(cls, text) {
|
|
804
|
+
const n = document.createElement('span');
|
|
805
|
+
if (cls) n.className = cls;
|
|
806
|
+
if (text != null) n.textContent = text;
|
|
807
|
+
return n;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Which argument identifies a call, mirroring the terminal's `KEY_ARG`: a tool
|
|
812
|
+
* line must read `Using Read (src/tui.js)`, so the field is chosen per tool
|
|
813
|
+
* rather than being "the first string that happens to be non-empty".
|
|
814
|
+
*/
|
|
815
|
+
const KEY_ARG = {
|
|
816
|
+
Bash: ['command'],
|
|
817
|
+
Read: ['path', 'file_path'],
|
|
818
|
+
Write: ['path', 'file_path'],
|
|
819
|
+
Edit: ['path', 'file_path'],
|
|
820
|
+
Grep: ['pattern'],
|
|
821
|
+
Glob: ['pattern'],
|
|
822
|
+
FileLines: ['path', 'file_path'],
|
|
823
|
+
FetchURL: ['url'],
|
|
824
|
+
WebSearch: ['query'],
|
|
825
|
+
Agent: ['description', 'prompt'],
|
|
826
|
+
TaskOutput: ['task_id'],
|
|
827
|
+
TaskStop: ['task_id'],
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
const MAX_ARG = 60;
|
|
831
|
+
|
|
832
|
+
/** Terminal `keyArgument`: first line only, workspace-relative for paths,
|
|
833
|
+
truncated to MAX_ARG with the ellipsis on the side that keeps a path's
|
|
834
|
+
basename readable. */
|
|
835
|
+
function keyArgument(name, args, workspace) {
|
|
836
|
+
if (!args || typeof args !== 'object') return '';
|
|
837
|
+
const keys = KEY_ARG[name] || Object.keys(args);
|
|
838
|
+
for (const k of keys) {
|
|
839
|
+
const v = args[k];
|
|
840
|
+
if (typeof v !== 'string' || !v.length) continue;
|
|
841
|
+
let text = v.split('\n')[0];
|
|
842
|
+
const isPath = k === 'path' || k === 'file_path';
|
|
843
|
+
if (isPath && workspace && text.startsWith(workspace)) {
|
|
844
|
+
const rel = text.slice(workspace.length).replace(/^[/\\]+/, '');
|
|
845
|
+
if (rel) text = rel;
|
|
846
|
+
}
|
|
847
|
+
if (text.length > MAX_ARG) {
|
|
848
|
+
text = isPath ? '\u2026' + text.slice(text.length - (MAX_ARG - 1))
|
|
849
|
+
: text.slice(0, MAX_ARG - 1) + '\u2026';
|
|
850
|
+
}
|
|
851
|
+
if (isPath) text = text.replace(/\\/g, '/');
|
|
852
|
+
return text;
|
|
853
|
+
}
|
|
854
|
+
return '';
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* The tool NAME's sweep while a call is running — the terminal's own animation,
|
|
859
|
+
* ported rather than approximated.
|
|
860
|
+
*
|
|
861
|
+
* A 2s loop in two 1s colour pairs: base cyan → pale cyan, back, then base cyan
|
|
862
|
+
* → cyan-blue, back. Each character fades across a band ~2-4 characters wide, so
|
|
863
|
+
* the crest moves as a gradient instead of a hard edge. The phase is measured
|
|
864
|
+
* from the tick the pulse STARTED (`pulseStart`), not the raw spinner tick: the
|
|
865
|
+
* spinner has been running since the turn began, so using it directly drops the
|
|
866
|
+
* pulse into a mid-cycle colour the instant the animation hands over.
|
|
867
|
+
*/
|
|
868
|
+
const SWEEP = 6;
|
|
869
|
+
const SWEEP_HALF = SWEEP + SWEEP;
|
|
870
|
+
const SWEEP_CYCLE = SWEEP_HALF * 2;
|
|
871
|
+
const CYAN_RGB = [0, 215, 255];
|
|
872
|
+
const PALE_CYAN_RGB = [150, 255, 255];
|
|
873
|
+
const CYAN_BLUE_RGB = [0, 150, 255];
|
|
874
|
+
|
|
875
|
+
function mixRgb(from, to, k) {
|
|
876
|
+
const t = Math.max(0, Math.min(1, k));
|
|
877
|
+
return 'rgb('
|
|
878
|
+
+ Math.round(from[0] + (to[0] - from[0]) * t) + ','
|
|
879
|
+
+ Math.round(from[1] + (to[1] - from[1]) * t) + ','
|
|
880
|
+
+ Math.round(from[2] + (to[2] - from[2]) * t) + ')';
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function paintToolName(rec, name, pending, tick) {
|
|
884
|
+
const box = rec.nameEl;
|
|
885
|
+
if (!box || !box.isConnected) return;
|
|
886
|
+
|
|
887
|
+
if (!pending) {
|
|
888
|
+
unregisterSweep(rec);
|
|
889
|
+
if (rec.namePending !== false) {
|
|
890
|
+
box.replaceChildren(document.createTextNode(name));
|
|
891
|
+
rec.namePending = false;
|
|
892
|
+
rec.nameText = name;
|
|
893
|
+
} else if (box.textContent !== name) {
|
|
894
|
+
box.textContent = name;
|
|
895
|
+
rec.nameText = name;
|
|
896
|
+
}
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// The tick comes from a LOCAL clock (see sweepFrame), not from `status.spin`.
|
|
901
|
+
// The server stopped broadcasting every spin frame (ANIMATION_FIELDS in
|
|
902
|
+
// session-hub.js), so a sweep driven by it only moved when some OTHER field
|
|
903
|
+
// happened to change — it froze for long stretches and then jumped. Worse,
|
|
904
|
+
// pushing per-character gradient values over the wire for every frame is exactly
|
|
905
|
+
// the cost the browser should be paying instead.
|
|
906
|
+
const start = rec.sweepStart == null ? (rec.sweepStart = tick) : rec.sweepStart;
|
|
907
|
+
if (rec.namePending === true && rec.nameTick === tick && rec.nameText === name) return;
|
|
908
|
+
rec.namePending = true;
|
|
909
|
+
rec.nameTick = tick;
|
|
910
|
+
rec.nameText = name;
|
|
911
|
+
|
|
912
|
+
const inCycle = (((tick - start) % SWEEP_CYCLE) + SWEEP_CYCLE) % SWEEP_CYCLE;
|
|
913
|
+
const inHalf = inCycle % SWEEP_HALF;
|
|
914
|
+
const target = inCycle < SWEEP_HALF ? PALE_CYAN_RGB : CYAN_BLUE_RGB;
|
|
915
|
+
const n = name.length || 1;
|
|
916
|
+
const band = Math.max(2, Math.min(4, n / 3)) / n;
|
|
917
|
+
|
|
918
|
+
const frag = document.createDocumentFragment();
|
|
919
|
+
for (let i = 0; i < name.length; i++) {
|
|
920
|
+
const at = i / n;
|
|
921
|
+
const t = inHalf < SWEEP
|
|
922
|
+
? ((inHalf + 1) / SWEEP - at) / band
|
|
923
|
+
: 1 - ((inHalf - SWEEP + 1) / SWEEP - at) / band;
|
|
924
|
+
const s = document.createElement('span');
|
|
925
|
+
s.style.color = mixRgb(CYAN_RGB, target, t);
|
|
926
|
+
s.textContent = name[i];
|
|
927
|
+
frag.appendChild(s);
|
|
928
|
+
}
|
|
929
|
+
box.replaceChildren(frag);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/* ---- pending tool-name sweeps, on their own clock -------------------------
|
|
933
|
+
Every pending tool row registers here; ONE rAF loop repaints them all at the
|
|
934
|
+
terminal's 80ms tick (not 60fps: rebuilding per-character spans faster buys
|
|
935
|
+
nothing visible and burns CPU). A row unregisters the moment it stops pending,
|
|
936
|
+
so an idle page runs no loop at all. */
|
|
937
|
+
const sweeps = new Set();
|
|
938
|
+
let sweepRaf = null;
|
|
939
|
+
let sweepLastTick = -1;
|
|
940
|
+
|
|
941
|
+
function registerSweep(rec) {
|
|
942
|
+
sweeps.add(rec);
|
|
943
|
+
if (sweepRaf === null) sweepRaf = requestAnimationFrame(sweepFrame);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function unregisterSweep(rec) {
|
|
947
|
+
rec.sweepStart = null;
|
|
948
|
+
if (!sweeps.delete(rec)) return;
|
|
949
|
+
if (!sweeps.size) stopSweeps();
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function stopSweeps() {
|
|
953
|
+
if (sweepRaf !== null) { cancelAnimationFrame(sweepRaf); sweepRaf = null; }
|
|
954
|
+
sweepLastTick = -1;
|
|
955
|
+
sweeps.clear();
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const SWEEP_TICK_MS = 80;
|
|
959
|
+
|
|
960
|
+
function sweepFrame() {
|
|
961
|
+
sweepRaf = null;
|
|
962
|
+
// Drop anything detached (its row was removed or re-rendered).
|
|
963
|
+
for (const rec of [...sweeps]) if (!rec.nameEl || !rec.nameEl.isConnected) sweeps.delete(rec);
|
|
964
|
+
if (!sweeps.size) { sweepLastTick = -1; return; }
|
|
965
|
+
const now = performance.now();
|
|
966
|
+
const tick = Math.floor(now / SWEEP_TICK_MS);
|
|
967
|
+
if (tick !== sweepLastTick) {
|
|
968
|
+
sweepLastTick = tick;
|
|
969
|
+
for (const rec of sweeps) paintToolName(rec, rec.nameText, true, tick);
|
|
970
|
+
}
|
|
971
|
+
sweepRaf = requestAnimationFrame(sweepFrame);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function paintToolRow(body, r, rec) {
|
|
975
|
+
const name = r.toolName || r.name || 'tool';
|
|
976
|
+
// The verb IS the state: `Using` while running, `Used` once finished. It is
|
|
977
|
+
// the one thing that tells a reader at a glance whether a call is in flight.
|
|
978
|
+
body.appendChild(el('tool-verb', tr(r.pending ? 'tool.using' : 'tool.used')));
|
|
979
|
+
body.append(' ');
|
|
980
|
+
|
|
981
|
+
// The name gets its own node so the sweep can repaint just its glyphs. The
|
|
982
|
+
// record lives on the NODE, never on the row: the row object is serialized to
|
|
983
|
+
// the server and copied on every patch, so putting DOM references on it would
|
|
984
|
+
// keep the old tree alive after every repaint.
|
|
985
|
+
const nameEl = document.createElement('span');
|
|
986
|
+
nameEl.className = 'tool-name';
|
|
987
|
+
body.appendChild(nameEl);
|
|
988
|
+
// The sweep record lives on the ROW record (`rec`), which survives a repaint; only
|
|
989
|
+
// its `nameEl` is refreshed here. `paintBody` calls `replaceChildren()` before
|
|
990
|
+
// rebuilding, so this node is a NEW element on every patch — keying the state on
|
|
991
|
+
// the node reset the cycle on each frame and left detached records in the set.
|
|
992
|
+
if (!rec._sweep) rec._sweep = { nameEl, namePending: null, nameTick: -1, nameText: '', sweepStart: null };
|
|
993
|
+
rec._sweep.nameEl = nameEl;
|
|
994
|
+
// A pending row joins the local sweep loop; a finished one paints once and leaves
|
|
995
|
+
// it. `sweepStart` is stamped on the first frame, so the cycle begins when the
|
|
996
|
+
// call started rather than at an arbitrary phase.
|
|
997
|
+
if (r.pending) {
|
|
998
|
+
registerSweep(rec._sweep);
|
|
999
|
+
paintToolName(rec._sweep, name, true, Math.floor(performance.now() / SWEEP_TICK_MS));
|
|
1000
|
+
} else {
|
|
1001
|
+
paintToolName(rec._sweep, name, false, 0);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
const arg = keyArgument(name, r.toolArgs, status.cwd);
|
|
1006
|
+
if (arg) body.appendChild(el('tool-arg', ' (' + arg + ')'));
|
|
1007
|
+
|
|
1008
|
+
if (name === 'Write' && typeof r.streamContent === 'string' && r.streamContent.length) {
|
|
1009
|
+
const bytes = r.streamContent.length;
|
|
1010
|
+
const size = bytes >= 1048576 ? (bytes / 1048576).toFixed(1) + 'MB'
|
|
1011
|
+
: bytes >= 1024 ? (bytes / 1024).toFixed(1) + 'KB'
|
|
1012
|
+
: bytes + 'B';
|
|
1013
|
+
body.appendChild(el('tool-size', ' ' + size));
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// The counts ride the tool line; the diff BODY belongs to the result row. The
|
|
1017
|
+
// terminal shows them in exactly that split, never the diff twice.
|
|
1018
|
+
if (name === 'Edit' && !r.pending) {
|
|
1019
|
+
const d = Array.isArray(r.diff) && r.diff.length ? r.diff : r._diffCounts;
|
|
1020
|
+
if (Array.isArray(d) && d.length) {
|
|
1021
|
+
const adds = d.filter((x) => x.type === 'add').length;
|
|
1022
|
+
const dels = d.filter((x) => x.type === 'del').length;
|
|
1023
|
+
if (adds) body.appendChild(el('tool-add', ' +' + adds));
|
|
1024
|
+
if (dels) body.appendChild(el('tool-del', ' \u2212' + dels));
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Live output of a still-running command, where the terminal puts it: under the
|
|
1029
|
+
// `Using …` row, tail-capped, so a chatty command cannot flood the row.
|
|
1030
|
+
if (r.pending && typeof r.liveOutput === 'string' && r.liveOutput.length) {
|
|
1031
|
+
const raw = r.liveOutput.replace(/\r\n/g, '\n').split('\n');
|
|
1032
|
+
const cap = expandedRows.has(r.id) ? Infinity : 8;
|
|
1033
|
+
body.appendChild(el('tool-live', '\n' + raw.slice(-cap).map((l) => ' ' + l).join('\n')));
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Tool output. The terminal's shape is the whole point of the row:
|
|
1041
|
+
*
|
|
1042
|
+
* ● Used Edit (a.js) +1 -1 <- tool line, counts only
|
|
1043
|
+
* ↳ 179 - old line <- ONE `↳` on the FIRST body row
|
|
1044
|
+
* 179 + new line
|
|
1045
|
+
* Edited a.js: replaced 1 occurrence(s). <- receipt LAST
|
|
1046
|
+
*
|
|
1047
|
+
* An Edit's DIFF is the body and carries the marker, with the receipt moved to
|
|
1048
|
+
* the bottom; every other tool's output IS the body and carries the marker on
|
|
1049
|
+
* its first line.
|
|
1050
|
+
*/
|
|
1051
|
+
function paintResultRow(body, r) {
|
|
1052
|
+
const failed = r.failed === true;
|
|
1053
|
+
const text = String(r.text == null ? '' : r.text);
|
|
1054
|
+
const diff = Array.isArray(r.diff) ? r.diff.filter((d) => d.type !== 'ctx') : [];
|
|
1055
|
+
|
|
1056
|
+
if (diff.length) {
|
|
1057
|
+
if (!expandedRows.has(r.id)) {
|
|
1058
|
+
body.appendChild(el('res-fold', tr('mark.result')));
|
|
1059
|
+
body.appendChild(expandButton(r.id, diff.length));
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
const width = Math.max(1, ...diff.map((d) => String(d.no || 0).length));
|
|
1063
|
+
diff.forEach((d, i) => {
|
|
1064
|
+
const no = String(d.no || 0).padStart(width);
|
|
1065
|
+
const mark = d.type === 'add' ? '+' : '-';
|
|
1066
|
+
const prefix = i === 0 ? tr('mark.result') + ' ' : ' ';
|
|
1067
|
+
body.appendChild(el(d.type === 'add' ? 'diff-add' : 'diff-del',
|
|
1068
|
+
prefix + no + ' ' + mark + ' ' + String(d.text) + '\n'));
|
|
1069
|
+
});
|
|
1070
|
+
const receipt = text.trim()
|
|
1071
|
+
.split('\n').filter((ln) => !/^\[exit code:/.test(ln.trim())).join('\n')
|
|
1072
|
+
.trim();
|
|
1073
|
+
if (receipt) body.appendChild(el(failed ? 'diff-del' : 'res-more', receipt));
|
|
1074
|
+
body.appendChild(collapseButton(r.id));
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// A finished result is FOLDED by default (12 lines, like the terminal's
|
|
1079
|
+
// ctrl+o): a whole-file Read is thousands of DOM nodes in ONE row, which
|
|
1080
|
+
// stutters scrolling. The "more" button expands it in place.
|
|
1081
|
+
// `[exit code: N]` is bookkeeping for the model, never shown to the user.
|
|
1082
|
+
const lines = text.replace(/\r\n/g, '\n').split('\n')
|
|
1083
|
+
.filter((ln) => !/^\[exit code:/.test(ln.trim()));
|
|
1084
|
+
while (lines.length && lines[0].trim() === '') lines.shift();
|
|
1085
|
+
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
|
|
1086
|
+
if (!lines.length) return;
|
|
1087
|
+
|
|
1088
|
+
if (!expandedRows.has(r.id)) {
|
|
1089
|
+
body.appendChild(el('res-fold', tr('mark.result')));
|
|
1090
|
+
body.appendChild(expandButton(r.id, lines.length));
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
const shown = lines;
|
|
1094
|
+
shown.forEach((ln, i) => {
|
|
1095
|
+
const isErr = /^\[error:/.test(ln.trim());
|
|
1096
|
+
const prefix = i === 0 ? tr('mark.result') + ' ' : ' ';
|
|
1097
|
+
body.appendChild(el(isErr || failed ? 'diff-del' : '', prefix + ln + '\n'));
|
|
1098
|
+
});
|
|
1099
|
+
const hidden = lines.length - shown.length;
|
|
1100
|
+
if (hidden > 0) body.appendChild(moreButton(r.id, hidden));
|
|
1101
|
+
body.appendChild(collapseButton(r.id));
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
function expandButton(id, n) {
|
|
1106
|
+
const b = document.createElement('button');
|
|
1107
|
+
b.type = 'button';
|
|
1108
|
+
b.className = 'msg-more';
|
|
1109
|
+
b.textContent = tr('tool.moreLines', { n: Number(n) || 0 });
|
|
1110
|
+
b.addEventListener('click', () => { expandedRows.add(id); repaintRow(id); });
|
|
1111
|
+
return b;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function moreButton(id, n) {
|
|
1115
|
+
const b = document.createElement('button');
|
|
1116
|
+
b.type = 'button';
|
|
1117
|
+
b.className = 'msg-more';
|
|
1118
|
+
b.textContent = tr('tool.moreLines', { n });
|
|
1119
|
+
b.addEventListener('click', () => { expandedRows.add(id); repaintRow(id); });
|
|
1120
|
+
return b;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** A "collapse" control shown once a foldable row is expanded, so a long
|
|
1124
|
+
thinking / result block can be folded back without a reload. */
|
|
1125
|
+
function collapseButton(id) {
|
|
1126
|
+
const b = document.createElement('button');
|
|
1127
|
+
b.type = 'button';
|
|
1128
|
+
b.className = 'msg-more';
|
|
1129
|
+
b.textContent = tr('tool.collapse');
|
|
1130
|
+
b.addEventListener('click', () => { expandedRows.delete(id); repaintRow(id); });
|
|
1131
|
+
return b;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/** Compaction, matching the terminal: `● Compacting context…` / `● Compaction
|
|
1135
|
+
complete (X → Y tokens)` / `● Compaction cancelled`. The marker carries the
|
|
1136
|
+
phase colour, so it is part of the line rather than a separate column. */
|
|
1137
|
+
function paintCompactionRow(body, r) {
|
|
1138
|
+
const done = r.phase === 'done';
|
|
1139
|
+
const cancelled = r.phase === 'cancelled';
|
|
1140
|
+
const label = done ? tr('tool.compacted')
|
|
1141
|
+
: cancelled ? tr('tool.compactCancelled')
|
|
1142
|
+
: tr('tool.compacting');
|
|
1143
|
+
let line = tr('mark.tool') + ' ' + label;
|
|
1144
|
+
if (done && r.tokensBefore != null && r.tokensAfter != null) {
|
|
1145
|
+
line += ' (' + fmtNum(r.tokensBefore) + ' \u2192 ' + fmtNum(r.tokensAfter) + ' tokens)';
|
|
1146
|
+
}
|
|
1147
|
+
body.appendChild(document.createTextNode(line));
|
|
1148
|
+
if (r.instruction) body.appendChild(document.createTextNode('\n ' + String(r.instruction)));
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** Background-task lifecycle card: `● agent task completed in background`, the
|
|
1152
|
+
conclusion indented underneath and capped like a tool result so one chatty
|
|
1153
|
+
subagent cannot flood the transcript. */
|
|
1154
|
+
function paintBgTaskRow(body, r) {
|
|
1155
|
+
const card = r.card || { phase: 'completed', headline: '' };
|
|
1156
|
+
body.appendChild(document.createTextNode(
|
|
1157
|
+
tr(card.phase === 'failed' ? 'mark.bgTaskFailed' : 'mark.bgTask') + ' ' + (card.headline || '')));
|
|
1158
|
+
if (card.detail) body.appendChild(el('res-more', ' (' + card.detail + ')'));
|
|
1159
|
+
const t = String(r.text || '').trim();
|
|
1160
|
+
if (!t) return;
|
|
1161
|
+
const lines = t.split('\n');
|
|
1162
|
+
const cap = expandedRows.has(r.id) ? 200 : 6;
|
|
1163
|
+
body.appendChild(document.createTextNode('\n ' + lines.slice(0, cap).join('\n ')));
|
|
1164
|
+
if (lines.length > cap) body.appendChild(el('res-more', '\n \u2026 ' + (lines.length - cap)));
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Reasoning block, matching the terminal:
|
|
1170
|
+
* live -> "<spinner> thinking…" then the LAST 2 content lines, indented
|
|
1171
|
+
* finalized -> `●` inline on the first line, up to 2 preview lines, then a
|
|
1172
|
+
* "… (N more lines)" hint
|
|
1173
|
+
* All of it dim, body italic. The marker is part of the text (prefix `● `,
|
|
1174
|
+
* continuations indented two), exactly as `messageLines` builds it.
|
|
1175
|
+
*/
|
|
1176
|
+
function paintThinkingRow(body, r, text) {
|
|
1177
|
+
const lines = text ? text.replace(/\r\n/g, '\n').split('\n') : [];
|
|
1178
|
+
const cap = expandedRows.has(r.id) ? Infinity : THINK_PREVIEW;
|
|
1179
|
+
|
|
1180
|
+
if (r.pending) {
|
|
1181
|
+
// The live form shows a spinner label instead of the `●`, and previews the
|
|
1182
|
+
// TAIL of the reasoning rather than its head.
|
|
1183
|
+
const label = document.createElement('div');
|
|
1184
|
+
label.className = 'think-live';
|
|
1185
|
+
label.textContent = tr('chat.thinking');
|
|
1186
|
+
body.appendChild(label);
|
|
1187
|
+
// Live: show the newest lines (up to THINK_PREVIEW), replaced in place as the
|
|
1188
|
+
// model's reasoning grows — matching the terminal's `contentLines.slice(-PREVIEW)`.
|
|
1189
|
+
const tail = lines.slice(-THINK_PREVIEW);
|
|
1190
|
+
for (const ln of tail) body.appendChild(document.createTextNode(' ' + ln + '\n'));
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const shown = lines.slice(0, cap);
|
|
1195
|
+
shown.forEach((ln, i) => {
|
|
1196
|
+
body.appendChild(document.createTextNode(
|
|
1197
|
+
(i === 0 ? tr('mark.thinking') + ' ' : ' ') + ln + '\n'));
|
|
1198
|
+
});
|
|
1199
|
+
const hidden = lines.length - shown.length;
|
|
1200
|
+
if (hidden > 0) body.appendChild(moreButton(r.id, hidden));
|
|
1201
|
+
else if (expandedRows.has(r.id) && lines.length > 1) body.appendChild(collapseButton(r.id));
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
const FENCE_RE = /^[ \t]*```+\s*(\S*)/;
|
|
1206
|
+
const INLINE_RE = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*?\*|~~[^~]+~~|\[[^\]]+\]\([^)\s]+\)|(^|\s)https?:\/\/[^\s]+)/g;
|
|
1207
|
+
|
|
1208
|
+
/** Markdown as the terminal renders it: a fenced block becomes its own box with
|
|
1209
|
+
the language on a rule, `#` lines are emphasised, and inline `code` / **bold**
|
|
1210
|
+
/ [links](url) keep their form. Everything else stays verbatim — a tool
|
|
1211
|
+
result or a shell line must never be reinterpreted as prose. */
|
|
1212
|
+
function appendMarkdown(wrap, text) {
|
|
1213
|
+
const lines = String(text == null ? '' : text).split('\n');
|
|
1214
|
+
let plain = [];
|
|
1215
|
+
let code = null;
|
|
1216
|
+
let lang = '';
|
|
1217
|
+
|
|
1218
|
+
const flushPlain = () => {
|
|
1219
|
+
if (!plain.length) return;
|
|
1220
|
+
const chunk = plain;
|
|
1221
|
+
plain = [];
|
|
1222
|
+
// Split the buffered lines into block groups: lists, tables, quotes, hr,
|
|
1223
|
+
// headings, and paragraphs. Each is appended as its own container.
|
|
1224
|
+
let i = 0;
|
|
1225
|
+
const isTableSep = (l) => /^\s*\|?\s*:?-{2,}.*\|/.test(l) || /^\s*\|[-: ]+\|/.test(l);
|
|
1226
|
+
while (i < chunk.length) {
|
|
1227
|
+
const raw = chunk[i];
|
|
1228
|
+
const t = raw.replace(/\s+$/, '');
|
|
1229
|
+
// table: header line, then a separator line
|
|
1230
|
+
if (t.includes('|') && i + 1 < chunk.length && isTableSep(chunk[i + 1])) {
|
|
1231
|
+
const header = t.split('|').map((c) => c.trim()).filter((c, j, a) => !(c === '' && (j === 0 || j === a.length - 1)));
|
|
1232
|
+
const body = [];
|
|
1233
|
+
i += 2;
|
|
1234
|
+
while (i < chunk.length && chunk[i].trim().includes('|') && !isTableSep(chunk[i])) {
|
|
1235
|
+
const cells = chunk[i].split('|').map((c) => c.trim()).filter((c, j, a) => !(c === '' && (j === 0 || j === a.length - 1)));
|
|
1236
|
+
body.push(cells);
|
|
1237
|
+
i++;
|
|
1238
|
+
}
|
|
1239
|
+
wrap.appendChild(buildTable(header, body));
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
// hr
|
|
1243
|
+
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(t)) { wrap.appendChild(el('md-hr', '')); i++; continue; }
|
|
1244
|
+
// quote: one or more consecutive `>` lines
|
|
1245
|
+
if (/^\s*>\s?/.test(t)) {
|
|
1246
|
+
const q = [];
|
|
1247
|
+
while (i < chunk.length && /^\s*>\s?/.test(chunk[i])) {
|
|
1248
|
+
q.push(chunk[i].replace(/^\s*>\s?/, ''));
|
|
1249
|
+
i++;
|
|
1250
|
+
}
|
|
1251
|
+
const box = document.createElement('div');
|
|
1252
|
+
box.className = 'md-quote';
|
|
1253
|
+
q.forEach((ql, j) => { if (j) box.appendChild(document.createTextNode('\n')); box.appendChild(inlineParts(ql)); });
|
|
1254
|
+
wrap.appendChild(box);
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
// task checkbox
|
|
1258
|
+
const task = /^(\s*)([-*+]\s+)?\[( |x|X)\]\s+(.*)$/.exec(t);
|
|
1259
|
+
if (task) {
|
|
1260
|
+
const row = document.createElement('div');
|
|
1261
|
+
row.className = 'md-task';
|
|
1262
|
+
const box = el('md-check', task[3] !== ' ' ? '\u2713' : '\u25cb');
|
|
1263
|
+
row.appendChild(box);
|
|
1264
|
+
row.appendChild(inlineParts(task[4]));
|
|
1265
|
+
wrap.appendChild(row);
|
|
1266
|
+
i++;
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
// list item, ordered or unordered
|
|
1270
|
+
const li = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(t);
|
|
1271
|
+
if (li) {
|
|
1272
|
+
const list = document.createElement('div');
|
|
1273
|
+
list.className = /^\d/.test(li[2]) ? 'md-list-ol' : 'md-list-ul';
|
|
1274
|
+
while (i < chunk.length) {
|
|
1275
|
+
const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(chunk[i].replace(/\s+$/, ''));
|
|
1276
|
+
if (!m || (/^\d/.test(m[2]))) {
|
|
1277
|
+
if (!m) break;
|
|
1278
|
+
}
|
|
1279
|
+
const isO = /^\d/.test(m[2]);
|
|
1280
|
+
if (isO !== /^\d/.test(li[2])) break;
|
|
1281
|
+
const item = document.createElement('div');
|
|
1282
|
+
item.className = 'md-li';
|
|
1283
|
+
const bullet = isO ? m[2] : '\u2022';
|
|
1284
|
+
item.appendChild(el('md-bullet', bullet));
|
|
1285
|
+
item.appendChild(inlineParts(m[3]));
|
|
1286
|
+
list.appendChild(item);
|
|
1287
|
+
i++;
|
|
1288
|
+
}
|
|
1289
|
+
wrap.appendChild(list);
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
// heading
|
|
1293
|
+
const head = /^\s*#{1,6}\s+(.*)$/.exec(t);
|
|
1294
|
+
if (head) { wrap.appendChild(el('md-h', head[1])); i++; continue; }
|
|
1295
|
+
// paragraph / plain
|
|
1296
|
+
const paraNode = document.createElement('div');
|
|
1297
|
+
paraNode.className = 'md-p';
|
|
1298
|
+
paraNode.appendChild(inlineParts(t));
|
|
1299
|
+
wrap.appendChild(paraNode);
|
|
1300
|
+
i++;
|
|
1301
|
+
}
|
|
1302
|
+
};
|
|
1303
|
+
|
|
1304
|
+
const flushCode = () => {
|
|
1305
|
+
const box = document.createElement('div');
|
|
1306
|
+
box.className = 'code-block';
|
|
1307
|
+
if (lang) {
|
|
1308
|
+
const bar = document.createElement('div');
|
|
1309
|
+
bar.className = 'code-bar';
|
|
1310
|
+
bar.textContent = lang;
|
|
1311
|
+
box.appendChild(bar);
|
|
1312
|
+
}
|
|
1313
|
+
const pre = document.createElement('pre');
|
|
1314
|
+
pre.className = 'code-body';
|
|
1315
|
+
pre.textContent = code.join('\n');
|
|
1316
|
+
box.appendChild(pre);
|
|
1317
|
+
wrap.appendChild(box);
|
|
1318
|
+
code = null;
|
|
1319
|
+
lang = '';
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1322
|
+
for (const line of lines) {
|
|
1323
|
+
const fence = FENCE_RE.exec(line);
|
|
1324
|
+
if (fence) {
|
|
1325
|
+
if (code === null) { flushPlain(); code = []; lang = fence[1] || ''; }
|
|
1326
|
+
else flushCode();
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
if (code === null) plain.push(line);
|
|
1330
|
+
else code.push(line);
|
|
1331
|
+
}
|
|
1332
|
+
flushPlain();
|
|
1333
|
+
if (code !== null && code.length) flushCode();
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/** Render a Markdown table as a grid. Header is the first row (bold). */
|
|
1337
|
+
function buildTable(header, body) {
|
|
1338
|
+
const table = document.createElement('div');
|
|
1339
|
+
table.className = 'md-table';
|
|
1340
|
+
const h = document.createElement('div');
|
|
1341
|
+
h.className = 'md-tr';
|
|
1342
|
+
for (const c of header) { const cell = el('md-th', c); h.appendChild(cell); }
|
|
1343
|
+
table.appendChild(h);
|
|
1344
|
+
for (const row of body) {
|
|
1345
|
+
const r = document.createElement('div');
|
|
1346
|
+
r.className = 'md-tr';
|
|
1347
|
+
for (const c of row) r.appendChild(el('md-td', c));
|
|
1348
|
+
table.appendChild(r);
|
|
1349
|
+
}
|
|
1350
|
+
return table;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function inlineParts(line) {
|
|
1354
|
+
if (!line) return document.createTextNode('');
|
|
1355
|
+
const frag = document.createDocumentFragment();
|
|
1356
|
+
let at = 0;
|
|
1357
|
+
for (const m of line.matchAll(INLINE_RE)) {
|
|
1358
|
+
if (m.index > at) frag.appendChild(document.createTextNode(line.slice(at, m.index)));
|
|
1359
|
+
const tok = m[0];
|
|
1360
|
+
if (tok[0] === '`') frag.appendChild(el('md-code', tok.slice(1, -1)));
|
|
1361
|
+
else if (tok.startsWith('**')) frag.appendChild(el('md-b', tok.slice(2, -2)));
|
|
1362
|
+
else if (tok.startsWith('~~')) frag.appendChild(el('md-s', tok.slice(2, -2)));
|
|
1363
|
+
else if (tok.startsWith('*')) frag.appendChild(el('md-i', tok.slice(1, -1)));
|
|
1364
|
+
else if (tok.startsWith('[')) {
|
|
1365
|
+
const label = tok.slice(1, tok.indexOf(']'));
|
|
1366
|
+
const href = tok.slice(tok.indexOf('(') + 1, -1);
|
|
1367
|
+
const a = document.createElement('a');
|
|
1368
|
+
a.className = 'md-link';
|
|
1369
|
+
a.href = href;
|
|
1370
|
+
a.target = '_blank';
|
|
1371
|
+
a.rel = 'noreferrer noopener';
|
|
1372
|
+
a.textContent = label;
|
|
1373
|
+
frag.appendChild(a);
|
|
1374
|
+
} else {
|
|
1375
|
+
// Bare URL, possibly left-trimmed to the leading whitespace captured.
|
|
1376
|
+
const url = tok.trim();
|
|
1377
|
+
const a = document.createElement('a');
|
|
1378
|
+
a.className = 'md-link';
|
|
1379
|
+
a.href = url;
|
|
1380
|
+
a.target = '_blank';
|
|
1381
|
+
a.rel = 'noreferrer noopener';
|
|
1382
|
+
a.textContent = url;
|
|
1383
|
+
if (tok[0].match(/\s/)) frag.appendChild(document.createTextNode(' '));
|
|
1384
|
+
frag.appendChild(a);
|
|
1385
|
+
}
|
|
1386
|
+
at = m.index + tok.length;
|
|
1387
|
+
}
|
|
1388
|
+
if (at < line.length) frag.appendChild(document.createTextNode(line.slice(at)));
|
|
1389
|
+
return frag;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function summarizeArgs(args) {
|
|
1393
|
+
if (!args || typeof args !== 'object') return '';
|
|
1394
|
+
const keys = ['command', 'cmd', 'path', 'file_path', 'pattern', 'query', 'url', 'description', 'task_id'];
|
|
1395
|
+
for (const k of keys) {
|
|
1396
|
+
const v = args[k];
|
|
1397
|
+
if (typeof v === 'string' && v) return v.split('\n')[0].slice(0, 160);
|
|
1398
|
+
}
|
|
1399
|
+
return '';
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
function fmtNum(n) {
|
|
1403
|
+
n = Number(n) || 0;
|
|
1404
|
+
const trim = (v) => {
|
|
1405
|
+
const s = v.toFixed(1);
|
|
1406
|
+
return s.endsWith('.0') ? s.slice(0, -2) : s;
|
|
1407
|
+
};
|
|
1408
|
+
if (n >= 1048576) return trim(n / 1048576) + 'M';
|
|
1409
|
+
if (n >= 1024) return (n >= 102400 ? Math.round(n / 1024) : trim(n / 1024)) + 'k';
|
|
1410
|
+
return String(n);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// ------------------------------------------------------- status and panels
|
|
1414
|
+
|
|
1415
|
+
// A hidden tab pane is skipped by renderStatus; VIEW_IDS tracks which one is
|
|
1416
|
+
// visible so switching a tab back on rebuilds it once from the latest status.
|
|
1417
|
+
const dirtyViews = new Set();
|
|
1418
|
+
function isViewHidden(name) {
|
|
1419
|
+
const sec = document.getElementById("view-" + name);
|
|
1420
|
+
if (!sec) return false;
|
|
1421
|
+
if (!sec.hidden) return false;
|
|
1422
|
+
dirtyViews.add(name);
|
|
1423
|
+
return true;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function renderStatus() {
|
|
1427
|
+
const s = status || {};
|
|
1428
|
+
|
|
1429
|
+
$('sess-title').textContent = s.title || tr('chat.untitled');
|
|
1430
|
+
const sessId = String(s.session || '');
|
|
1431
|
+
const idEl = $('sess-id');
|
|
1432
|
+
idEl.textContent = sessId;
|
|
1433
|
+
const oldCopy = $('sess-id-copy');
|
|
1434
|
+
if (oldCopy) oldCopy.remove();
|
|
1435
|
+
if (sessId) {
|
|
1436
|
+
const cb = copyButton(sessId, tr('copy.copy'));
|
|
1437
|
+
cb.id = 'sess-id-copy';
|
|
1438
|
+
idEl.after(cb);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// Send and Interrupt share one edge of the composer: while a turn runs, the
|
|
1442
|
+
// only useful action there is to stop it, so Send yields to Stop instead of
|
|
1443
|
+
// sitting beside a button that would just queue another message.
|
|
1444
|
+
setPanelVisible('btn-interrupt', !!s.busy);
|
|
1445
|
+
setPanelVisible('send', !s.busy);
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
setPanelVisible('todo-panel', (s.todos || []).length > 0);
|
|
1449
|
+
setPanelVisible('queue-panel', (s.queued || []).length > 0);
|
|
1450
|
+
setPanelVisible('working', !!s.busy);
|
|
1451
|
+
|
|
1452
|
+
renderTodoPanel(s.todos || []);
|
|
1453
|
+
renderQueuePanel(s.queued || [], !!s.busy);
|
|
1454
|
+
// Hidden tabs keep stale DOM — their pane is display:none, nobody sees it,
|
|
1455
|
+
// and rebuilding hundreds of cards per status broadcast was pure waste.
|
|
1456
|
+
// setupViews() marks a pane "dirty" when its view becomes visible.
|
|
1457
|
+
if (!isViewHidden("tasks")) renderTasks(s.tasks || []);
|
|
1458
|
+
if (!isViewHidden("files")) renderFiles(s.files || []);
|
|
1459
|
+
if (!isViewHidden("tools")) renderTools(s.tools || []);
|
|
1460
|
+
if (!isViewHidden("commands")) renderCommands(s.commands || []);
|
|
1461
|
+
renderTabCounts(s);
|
|
1462
|
+
applyModal(s);
|
|
1463
|
+
renderStatusLine(s);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/** Flip only the composer's Send/Stop state when `busy` changed — called from
|
|
1467
|
+
applyStatus on an animation-only broadcast, so a run that never changes a
|
|
1468
|
+
stable field still swaps the button without touching the panes. */
|
|
1469
|
+
function renderBusyButton() {
|
|
1470
|
+
const busy = !!(status && status.busy);
|
|
1471
|
+
setPanelVisible('btn-interrupt', busy);
|
|
1472
|
+
setPanelVisible('send', !busy);
|
|
1473
|
+
setPanelVisible('working', busy);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function setPanelVisible(id, on) {
|
|
1477
|
+
const node = $(id);
|
|
1478
|
+
if (node) node.hidden = !on;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* One row, grouped the way the terminal's footer groups it: which mode I am in,
|
|
1483
|
+
* which model, where I am, what git says, and the usage readout pinned right.
|
|
1484
|
+
* Every part is a fact about the running session — nothing here is decoration.
|
|
1485
|
+
*/
|
|
1486
|
+
function renderStatusLine(s) {
|
|
1487
|
+
const line = $('statusline');
|
|
1488
|
+
line.replaceChildren();
|
|
1489
|
+
const o = s.options || {};
|
|
1490
|
+
|
|
1491
|
+
line.appendChild(popoverButton('sl-mode', modeLabel(s.mode), 'side.mode', () => modeItems(o)));
|
|
1492
|
+
const badge = s.plan ? 'Plan' : s.focus ? 'Focus' : s.swarm ? 'Swarm' : '';
|
|
1493
|
+
if (badge) line.appendChild(el('sl-badge', badge));
|
|
1494
|
+
|
|
1495
|
+
if (s.model) {
|
|
1496
|
+
const label = s.model + (s.reasoning
|
|
1497
|
+
? (s.effort && s.effort !== 'on' ? ' thinking: ' + s.effort : ' thinking')
|
|
1498
|
+
: '');
|
|
1499
|
+
line.appendChild(popoverButton('sl-model', label, 'side.model', () => modelItems(o)));
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
const running = (s.tasks || []).filter((t) => t.status === 'running');
|
|
1503
|
+
const bash = running.filter((t) => t.kind !== 'agent').length;
|
|
1504
|
+
const agents = running.filter((t) => t.kind === 'agent').length;
|
|
1505
|
+
if (bash) line.appendChild(el('sl-tasks-bash', tr('status.tasksBash', { n: bash })));
|
|
1506
|
+
if (agents) line.appendChild(el('sl-tasks-agent', tr('status.tasksAgent', { n: agents })));
|
|
1507
|
+
|
|
1508
|
+
if (s.cwd) line.appendChild(el('sl-cwd', s.cwd));
|
|
1509
|
+
|
|
1510
|
+
const gi = s.git;
|
|
1511
|
+
if (gi && gi.branch) {
|
|
1512
|
+
line.appendChild(el('sl-branch', gi.branch));
|
|
1513
|
+
if (gi.insertions) line.appendChild(el('sl-add', '+' + gi.insertions));
|
|
1514
|
+
if (gi.deletions) line.appendChild(el('sl-del', '\u2212' + gi.deletions));
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
line.appendChild(el('sl-spacer', ''));
|
|
1518
|
+
|
|
1519
|
+
// The usage readout is the terminal's own string (`3 turns | 7 steps | 42
|
|
1520
|
+
// tok/s`), and the terminal does not translate it. Neither does this: one
|
|
1521
|
+
// person reading both screens must not see two different sentences.
|
|
1522
|
+
line.appendChild(el('sl-usage', tr('status.usage', {
|
|
1523
|
+
turns: Number(s.rounds || 0),
|
|
1524
|
+
steps: Number(s.steps || 0),
|
|
1525
|
+
rate: Math.round(Number(s.tokRate || 0)),
|
|
1526
|
+
})));
|
|
1527
|
+
line.appendChild(el('sl-ctx', tr('status.context', {
|
|
1528
|
+
pct: Number(s.ctxPercent || 0),
|
|
1529
|
+
used: fmtNum(s.ctxTokens || 0),
|
|
1530
|
+
max: fmtNum(s.ctxMax || 1),
|
|
1531
|
+
})));
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
function popoverButton(cls, label, titleKey, buildItems) {
|
|
1536
|
+
const b = document.createElement('button');
|
|
1537
|
+
b.type = 'button';
|
|
1538
|
+
b.className = cls;
|
|
1539
|
+
b.textContent = label;
|
|
1540
|
+
b.title = tr('popover.click');
|
|
1541
|
+
b.addEventListener('click', (ev) => {
|
|
1542
|
+
ev.preventDefault();
|
|
1543
|
+
openPopover(b, tr(titleKey), buildItems(), cls);
|
|
1544
|
+
});
|
|
1545
|
+
return b;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function modelItems(o) {
|
|
1549
|
+
const items = [];
|
|
1550
|
+
for (const m of (o.model && o.model.items) || []) {
|
|
1551
|
+
items.push({
|
|
1552
|
+
label: m.label || m.value,
|
|
1553
|
+
sub: m.sub || '',
|
|
1554
|
+
on: m.value === (o.model && o.model.value),
|
|
1555
|
+
cmd: '/model',
|
|
1556
|
+
arg: m.value,
|
|
1557
|
+
// Each model's own thinking levels, sent by the server. Null when the
|
|
1558
|
+
// model has no Thinking control. The effort popover renders these on the
|
|
1559
|
+
// right of the "Thinking Effort" row when the pointer sits on it.
|
|
1560
|
+
efforts: (m.efforts && m.efforts.map((e) => ({
|
|
1561
|
+
label: e.label || e.value,
|
|
1562
|
+
on: e.value === (o.effort && o.effort.value),
|
|
1563
|
+
cmd: '/effort',
|
|
1564
|
+
arg: e.value,
|
|
1565
|
+
}))) || null,
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
return items;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
function modeItems(o) {
|
|
1572
|
+
return ((o.mode && o.mode.items) || []).map((m) => ({
|
|
1573
|
+
label: m.label || m.value,
|
|
1574
|
+
sub: m.sub || '',
|
|
1575
|
+
on: m.value === (o.mode && o.mode.value),
|
|
1576
|
+
cmd: '/permission',
|
|
1577
|
+
arg: m.value,
|
|
1578
|
+
}));
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function modeLabel(mode) {
|
|
1582
|
+
const m = String(mode || '').toLowerCase();
|
|
1583
|
+
if (m === 'auto') return 'Auto';
|
|
1584
|
+
if (m === 'yolo') return 'Yolo';
|
|
1585
|
+
if (m === 'ask') return 'Ask';
|
|
1586
|
+
return mode ? String(mode) : '\u2014';
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
let popoverOpen = null;
|
|
1590
|
+
|
|
1591
|
+
function openPopover(anchor, title, items, kind) {
|
|
1592
|
+
const box = $('popover');
|
|
1593
|
+
const list = $('popover-list');
|
|
1594
|
+
if (!box || !list) return;
|
|
1595
|
+
$('popover-title').textContent = title;
|
|
1596
|
+
list.replaceChildren();
|
|
1597
|
+
|
|
1598
|
+
if (!items.length) {
|
|
1599
|
+
list.appendChild(el('popover-item', tr('side.none')));
|
|
1600
|
+
box.hidden = false; positionPopover(anchor, box);
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// A model popover is two stacked sections, exactly as the user asked:
|
|
1605
|
+
// Model A
|
|
1606
|
+
// Model B <- pick a model (its own levels follow)
|
|
1607
|
+
// Model C
|
|
1608
|
+
// ──────────────
|
|
1609
|
+
// Thinking Effort > on <- hover the row to reveal its level list
|
|
1610
|
+
const isModelPopover = kind === 'sl-model';
|
|
1611
|
+
box.dataset.modelPopover = isModelPopover ? 'yes' : 'no';
|
|
1612
|
+
if (isModelPopover) {
|
|
1613
|
+
renderModelPopover(list, items);
|
|
1614
|
+
} else {
|
|
1615
|
+
for (const it of items) list.appendChild(buildPopoverItem(it));
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
box.hidden = false;
|
|
1619
|
+
positionPopover(anchor, box);
|
|
1620
|
+
popoverOpen = kind;
|
|
1621
|
+
|
|
1622
|
+
// The listener is registered on the NEXT frame's task, not in a microtask: the
|
|
1623
|
+
// click that opened the popover is still propagating up to `document`, so a
|
|
1624
|
+
// listener added synchronously here would receive that same click, see a target
|
|
1625
|
+
// outside the popover, and close it again — the menu appeared and vanished in
|
|
1626
|
+
// one gesture, which is why the buttons looked dead.
|
|
1627
|
+
setTimeout(() => {
|
|
1628
|
+
document.addEventListener('mousedown', onDocDown);
|
|
1629
|
+
document.addEventListener('keydown', onDocKey);
|
|
1630
|
+
}, 0);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* Position the popover ABOVE the anchor — the status line sits at the bottom of
|
|
1635
|
+
* the screen, so a menu that appears below the button runs into the floor.
|
|
1636
|
+
* `gap` is kept small (a few px) so the menu reads as attached to what it opens.
|
|
1637
|
+
*/
|
|
1638
|
+
function positionPopover(anchor, box) {
|
|
1639
|
+
const r = anchor.getBoundingClientRect();
|
|
1640
|
+
box.style.left = Math.max(8, Math.min(r.left, window.innerWidth - box.offsetWidth - 8)) + 'px';
|
|
1641
|
+
box.style.top = Math.max(8, r.top - box.offsetHeight - 8) + 'px';
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/** Position the floating level list just to the RIGHT of the effort row, flush
|
|
1645
|
+
with its top. */
|
|
1646
|
+
function positionFlyout(flyout, anchor) {
|
|
1647
|
+
const r = anchor.getBoundingClientRect();
|
|
1648
|
+
flyout.style.left = Math.max(4, r.right + 4) + 'px';
|
|
1649
|
+
flyout.style.top = Math.max(4, r.top - 2) + 'px';
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
function buildPopoverItem(it) {
|
|
1653
|
+
const b = document.createElement('button');
|
|
1654
|
+
b.type = 'button';
|
|
1655
|
+
b.className = 'popover-item' + (it.on ? ' is-on' : '');
|
|
1656
|
+
b.appendChild(el('', it.label));
|
|
1657
|
+
if (it.sub) b.appendChild(el('sub', it.sub));
|
|
1658
|
+
b.addEventListener('click', () => {
|
|
1659
|
+
closePopover();
|
|
1660
|
+
void act('dispatch', [it.cmd, it.arg]);
|
|
1661
|
+
});
|
|
1662
|
+
return b;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/** Model + effort popover, three stacked blocks:
|
|
1666
|
+
1. the model list (its own scroll)
|
|
1667
|
+
2. a hairline divider
|
|
1668
|
+
3. a "Thinking Effort" row showing the current level
|
|
1669
|
+
Hovering the effort row floats the level list out to its RIGHT, as a separate
|
|
1670
|
+
panel — it does not push the main layout. Hovering a model on top shows that
|
|
1671
|
+
model's levels instead. */
|
|
1672
|
+
function renderModelPopover(list, items) {
|
|
1673
|
+
const currentEff = items.find((m) => m.on) || items[0];
|
|
1674
|
+
const currentEfforts = (currentEff && currentEff.efforts) || null;
|
|
1675
|
+
|
|
1676
|
+
// 1) model list.
|
|
1677
|
+
const modelBox = document.createElement('div');
|
|
1678
|
+
modelBox.className = 'popover-section';
|
|
1679
|
+
for (const m of items) {
|
|
1680
|
+
const it = document.createElement('button');
|
|
1681
|
+
it.type = 'button';
|
|
1682
|
+
it.className = 'popover-item' + (m.on ? ' is-on' : '');
|
|
1683
|
+
it.appendChild(el('', m.label));
|
|
1684
|
+
if (m.sub) it.appendChild(el('sub', m.sub));
|
|
1685
|
+
it.addEventListener('click', () => {
|
|
1686
|
+
closePopover();
|
|
1687
|
+
void act('dispatch', [m.cmd, m.arg]);
|
|
1688
|
+
});
|
|
1689
|
+
it._efforts = m.efforts;
|
|
1690
|
+
it._isCurrent = !!m.on;
|
|
1691
|
+
modelBox.appendChild(it);
|
|
1692
|
+
}
|
|
1693
|
+
list.appendChild(modelBox);
|
|
1694
|
+
|
|
1695
|
+
// 2) divider.
|
|
1696
|
+
list.appendChild(el('popover-rule', ''));
|
|
1697
|
+
|
|
1698
|
+
// 3) effort row.
|
|
1699
|
+
const effRow = document.createElement('div');
|
|
1700
|
+
effRow.className = 'popover-effort';
|
|
1701
|
+
effRow.appendChild(el('', tr('popover.effortLabel')));
|
|
1702
|
+
const effValue = document.createElement('span');
|
|
1703
|
+
effValue.className = 'popover-effort-val' + (currentEfforts ? ' has-levels' : '');
|
|
1704
|
+
const curLevel = currentEfforts && currentEfforts.filter((e) => e.on).map((e) => e.label)[0];
|
|
1705
|
+
effValue.textContent = curLevel || (currentEfforts ? tr('popover.off') : tr('popover.unknown'));
|
|
1706
|
+
effRow.appendChild(effValue);
|
|
1707
|
+
list.appendChild(effRow);
|
|
1708
|
+
|
|
1709
|
+
if (!currentEfforts) return;
|
|
1710
|
+
|
|
1711
|
+
// The floating level list, positioned to the RIGHT of the effort row. It is a
|
|
1712
|
+
// sibling of the main popover so it can break out beside it rather than
|
|
1713
|
+
// reflowing the stack.
|
|
1714
|
+
const effHost = $('popover');
|
|
1715
|
+
const flyout = document.createElement('div');
|
|
1716
|
+
flyout.className = 'popover-flyout';
|
|
1717
|
+
flyout.hidden = true;
|
|
1718
|
+
document.body.appendChild(flyout);
|
|
1719
|
+
|
|
1720
|
+
const setLevels = (efforts, isCurrent) => {
|
|
1721
|
+
flyout.replaceChildren();
|
|
1722
|
+
if (!efforts || !efforts.length) {
|
|
1723
|
+
flyout.appendChild(el('popover-item', tr('popover.unknown')));
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
for (const e of efforts) flyout.appendChild(buildPopoverItem({ ...e, on: e.on && isCurrent }));
|
|
1727
|
+
positionFlyout(flyout, effRow);
|
|
1728
|
+
flyout.hidden = false;
|
|
1729
|
+
};
|
|
1730
|
+
|
|
1731
|
+
const hideFlyout = () => { flyout.hidden = true; };
|
|
1732
|
+
|
|
1733
|
+
const clearHover = () => {
|
|
1734
|
+
for (const o of list.querySelectorAll('.popover-item.is-hover')) o.classList.remove('is-hover');
|
|
1735
|
+
};
|
|
1736
|
+
|
|
1737
|
+
// hovering a model row hides any open effort flyout — the flyout belongs to the
|
|
1738
|
+
// Thinking Effort row, so moving to the model list dismisses it.
|
|
1739
|
+
for (const it of modelBox.children) {
|
|
1740
|
+
it.addEventListener('mouseenter', () => {
|
|
1741
|
+
clearHover();
|
|
1742
|
+
it.classList.add('is-hover');
|
|
1743
|
+
hideFlyout();
|
|
1744
|
+
});
|
|
1745
|
+
it.addEventListener('mouseleave', () => { it.classList.remove('is-hover'); });
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// hovering the effort row floats the CURRENT model's levels.
|
|
1749
|
+
effRow.addEventListener('mouseenter', () => {
|
|
1750
|
+
clearHover();
|
|
1751
|
+
setLevels(currentEfforts, true);
|
|
1752
|
+
});
|
|
1753
|
+
effRow.addEventListener('mouseleave', () => { /* keep flyout until mouse leaves it */ });
|
|
1754
|
+
|
|
1755
|
+
// Keep the flyout visible only while the pointer is over it or the popover.
|
|
1756
|
+
// (flyoutWatcher, registered once below, does that.)
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/** Last pointer position, used by flyoutWatcher. */
|
|
1760
|
+
let flyoutX = -1, flyoutY = -1;
|
|
1761
|
+
|
|
1762
|
+
/** Hide the effort flyout whenever the pointer leaves both the popover and the
|
|
1763
|
+
flyout itself. Runs for every mousemove; the check is cheap. */
|
|
1764
|
+
function flyoutWatcher(ev) {
|
|
1765
|
+
flyoutX = ev.clientX;
|
|
1766
|
+
flyoutY = ev.clientY;
|
|
1767
|
+
const f = document.querySelector('.popover-flyout');
|
|
1768
|
+
if (!f || f.hidden) return;
|
|
1769
|
+
const box = document.getElementById('popover');
|
|
1770
|
+
const inBox = box && !box.hidden && insideRect(box, flyoutX, flyoutY);
|
|
1771
|
+
const inFly = insideRect(f, flyoutX, flyoutY);
|
|
1772
|
+
if (!inBox && !inFly) f.hidden = true;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
/** True when (x, y) lies within the element's box. */
|
|
1776
|
+
function insideRect(el, x, y) {
|
|
1777
|
+
const r = el.getBoundingClientRect();
|
|
1778
|
+
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
// Registered ONCE (module scope), not per popover open — the flyout is looked up
|
|
1782
|
+
// each move, so one listener serves every open/close cycle.
|
|
1783
|
+
if (typeof document !== 'undefined') document.addEventListener('mousemove', flyoutWatcher);
|
|
1784
|
+
|
|
1785
|
+
function onDocDown(ev) {
|
|
1786
|
+
const box = $('popover');
|
|
1787
|
+
if (box && box.contains(ev.target)) return;
|
|
1788
|
+
closePopover();
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
function onDocKey(ev) {
|
|
1792
|
+
if (ev.key === 'Escape') closePopover();
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
function closePopover() {
|
|
1796
|
+
const box = $('popover');
|
|
1797
|
+
if (box) box.hidden = true;
|
|
1798
|
+
popoverOpen = null;
|
|
1799
|
+
// The floating level list is appended to <body>, so it must be removed here or
|
|
1800
|
+
// it would linger after the popover closes.
|
|
1801
|
+
for (const f of document.querySelectorAll('.popover-flyout')) f.remove();
|
|
1802
|
+
document.removeEventListener('mousedown', onDocDown);
|
|
1803
|
+
document.removeEventListener('keydown', onDocKey);
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
const SPINNER = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'];
|
|
1807
|
+
const ORANGE_RGB = [255, 140, 0];
|
|
1808
|
+
const WORK_YELLOW_RGB = [255, 240, 120];
|
|
1809
|
+
const WORK_RED_RGB = [255, 0, 0];
|
|
1810
|
+
/** Animation clock and phrase for the Working pulse. Kept at module scope so the
|
|
1811
|
+
rAF loop can stop itself and so `applyStatus` can restart it without a fresh
|
|
1812
|
+
capture each time. */
|
|
1813
|
+
let pulseRaf = null;
|
|
1814
|
+
let pulseMsg = '';
|
|
1815
|
+
|
|
1816
|
+
/**
|
|
1817
|
+
* The Working row's pulse, driven by its OWN rAF loop, not by the server's `spin`
|
|
1818
|
+
* counter. The server no longer broadcasts every spin frame (see
|
|
1819
|
+
* ANIMATION_FIELDS in session-hub.js); even when it does, redrawing the row from
|
|
1820
|
+
* a remote tick lets the two drift. The sweep keeps the terminal's orange→yellow→
|
|
1821
|
+
* red cycle, advanced by elapsed TIME so it stays smooth.
|
|
1822
|
+
*/
|
|
1823
|
+
function drawPulse() {
|
|
1824
|
+
pulseMsg = String((status && status.workMsg) || '');
|
|
1825
|
+
if (pulseRaf === null) pulseRaf = requestAnimationFrame(pulseFrame);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
/* The pulse advances at the TERMINAL's tick — 80ms per spinner frame — not at
|
|
1829
|
+
display refresh rate: rebuilding the row's per-character spans at 60fps buys
|
|
1830
|
+
nothing the eye can see and burns real CPU. */
|
|
1831
|
+
const PULSE_TICK_MS = 80;
|
|
1832
|
+
let pulseLastTick = -1;
|
|
1833
|
+
|
|
1834
|
+
function pulseFrame() {
|
|
1835
|
+
pulseRaf = null;
|
|
1836
|
+
if (!status || !status.busy) { stopPulse(); return; }
|
|
1837
|
+
const box = $('working-text');
|
|
1838
|
+
const now = performance.now();
|
|
1839
|
+
if (box && box.isConnected) {
|
|
1840
|
+
const tick = Math.floor(now / PULSE_TICK_MS);
|
|
1841
|
+
if (tick !== pulseLastTick) { paintPulseFrame(box, now); pulseLastTick = tick; }
|
|
1842
|
+
}
|
|
1843
|
+
pulseRaf = requestAnimationFrame(pulseFrame);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
function clearPulse() {
|
|
1847
|
+
stopPulse();
|
|
1848
|
+
const box = $('working-text');
|
|
1849
|
+
if (box) box.replaceChildren();
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
function stopPulse() {
|
|
1853
|
+
if (pulseRaf !== null) { cancelAnimationFrame(pulseRaf); pulseRaf = null; }
|
|
1854
|
+
pulseMsg = '';
|
|
1855
|
+
pulseLastTick = -1;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
function paintPulseFrame(box, now) {
|
|
1859
|
+
const msg = pulseMsg;
|
|
1860
|
+
if (!msg) { box.replaceChildren(); return; }
|
|
1861
|
+
// The sweep advances on time: 6 ticks ≈ 0.5s, so a 24-tick cycle is ~2s.
|
|
1862
|
+
const tick = Math.floor(now / 80);
|
|
1863
|
+
const inCycle = tick % SWEEP_CYCLE;
|
|
1864
|
+
const inHalf = inCycle % SWEEP_HALF;
|
|
1865
|
+
const target = inCycle < SWEEP_HALF ? WORK_YELLOW_RGB : WORK_RED_RGB;
|
|
1866
|
+
const n = msg.length;
|
|
1867
|
+
const band = Math.max(2, Math.min(4, n / 3)) / n;
|
|
1868
|
+
|
|
1869
|
+
const frag = document.createDocumentFragment();
|
|
1870
|
+
const spin = document.createElement('span');
|
|
1871
|
+
spin.className = 'spin';
|
|
1872
|
+
spin.textContent = SPINNER[tick % SPINNER.length];
|
|
1873
|
+
frag.appendChild(spin);
|
|
1874
|
+
|
|
1875
|
+
for (let i = 0; i < n; i++) {
|
|
1876
|
+
const at = i / n;
|
|
1877
|
+
const t = inHalf < SWEEP
|
|
1878
|
+
? ((inHalf + 1) / SWEEP - at) / band
|
|
1879
|
+
: 1 - ((inHalf - SWEEP + 1) / SWEEP - at) / band;
|
|
1880
|
+
const s = document.createElement('span');
|
|
1881
|
+
s.style.color = mixRgb(ORANGE_RGB, target, t);
|
|
1882
|
+
s.textContent = msg[i];
|
|
1883
|
+
frag.appendChild(s);
|
|
1884
|
+
}
|
|
1885
|
+
box.replaceChildren(frag);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
|
|
1889
|
+
/**
|
|
1890
|
+
* Drag-to-resize for the todo and queue panels, ported from the terminal.
|
|
1891
|
+
*
|
|
1892
|
+
* The terminal marks each panel's top rule as a resize hitbox, and dragging
|
|
1893
|
+
* works in ROWS with `up = more rows`: the delta is taken against the row the
|
|
1894
|
+
* drag started on, so the panel grows as the pointer rises and shrinks as it
|
|
1895
|
+
* falls. The result is clamped to `[1, total]` — at least one row, never more
|
|
1896
|
+
* than exist. `null` means "no manual size", i.e. the automatic heuristic.
|
|
1897
|
+
*/
|
|
1898
|
+
const QUEUE_MAX_VISIBLE = 5;
|
|
1899
|
+
const panelRows = { todo: null, queue: null };
|
|
1900
|
+
const resizeDrag = { kind: null, startY: 0, startRows: 0 };
|
|
1901
|
+
|
|
1902
|
+
|
|
1903
|
+
/** Row count a panel shows: the manual drag value when set, else the automatic
|
|
1904
|
+
one. Always clamped to [1, total], and 0 when there is nothing to show. */
|
|
1905
|
+
function panelRowCount(kind, total) {
|
|
1906
|
+
if (!total) return 0;
|
|
1907
|
+
const rows = panelRows[kind];
|
|
1908
|
+
if (typeof rows === 'number' && Number.isFinite(rows)) {
|
|
1909
|
+
return Math.max(1, Math.min(total, Math.round(rows)));
|
|
1910
|
+
}
|
|
1911
|
+
return kind === 'todo'
|
|
1912
|
+
? (todosExpanded ? total : selectVisibleTodos(status.todos || []).rows.length)
|
|
1913
|
+
: Math.min(total, QUEUE_MAX_VISIBLE);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
function setResizeHover(el, on) {
|
|
1917
|
+
el.classList.toggle('is-hover', on);
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
function setupPanelResize() {
|
|
1921
|
+
for (const rule of document.querySelectorAll('.stack-rule')) {
|
|
1922
|
+
const kind = rule.getAttribute('data-resize');
|
|
1923
|
+
|
|
1924
|
+
rule.addEventListener('mouseenter', () => setResizeHover(rule, true));
|
|
1925
|
+
rule.addEventListener('mouseleave', () => {
|
|
1926
|
+
if (resizeDrag.kind !== kind) setResizeHover(rule, false);
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
rule.addEventListener('pointerdown', (ev) => {
|
|
1930
|
+
// Capturing keeps the drag alive once the pointer leaves the 6px rule,
|
|
1931
|
+
// which it immediately does — a resize handle you have to stay on top of
|
|
1932
|
+
// is unusable.
|
|
1933
|
+
ev.preventDefault();
|
|
1934
|
+
rule.setPointerCapture(ev.pointerId);
|
|
1935
|
+
rule.classList.add('is-dragging');
|
|
1936
|
+
const total = kind === 'todo'
|
|
1937
|
+
? (status.todos || []).length
|
|
1938
|
+
: (status.queued || []).length;
|
|
1939
|
+
resizeDrag.kind = kind;
|
|
1940
|
+
resizeDrag.startY = ev.clientY;
|
|
1941
|
+
resizeDrag.startRows = panelRowCount(kind, total);
|
|
1942
|
+
setResizeHover(rule, true);
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1945
|
+
rule.addEventListener('pointermove', (ev) => {
|
|
1946
|
+
if (resizeDrag.kind !== kind) return;
|
|
1947
|
+
const total = kind === 'todo'
|
|
1948
|
+
? (status.todos || []).length
|
|
1949
|
+
: (status.queued || []).length;
|
|
1950
|
+
if (!total) return;
|
|
1951
|
+
// Rows are a line-height tall; converting the pixel delta into rows is what
|
|
1952
|
+
// makes the drag track the pointer instead of jumping a row per pixel.
|
|
1953
|
+
const rowPx = kind === 'todo' ? TODO_ROW_PX : QUEUE_ROW_PX;
|
|
1954
|
+
const delta = Math.round((resizeDrag.startY - ev.clientY) / rowPx);
|
|
1955
|
+
const want = Math.max(1, Math.min(total, resizeDrag.startRows + delta));
|
|
1956
|
+
if (panelRows[kind] === want) return;
|
|
1957
|
+
panelRows[kind] = want;
|
|
1958
|
+
if (kind === 'todo') renderTodoPanel(status.todos || []);
|
|
1959
|
+
else renderQueuePanel(status.queued || [], !!status.busy);
|
|
1960
|
+
});
|
|
1961
|
+
|
|
1962
|
+
const end = (ev) => {
|
|
1963
|
+
if (resizeDrag.kind !== kind) return;
|
|
1964
|
+
rule.classList.remove('is-dragging');
|
|
1965
|
+
try { rule.releasePointerCapture(ev.pointerId); } catch { /* already gone */ }
|
|
1966
|
+
resizeDrag.kind = null;
|
|
1967
|
+
setResizeHover(rule, false);
|
|
1968
|
+
};
|
|
1969
|
+
rule.addEventListener('pointerup', end);
|
|
1970
|
+
rule.addEventListener('pointercancel', end);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// -------------------------------------------------------------- todo panel
|
|
1975
|
+
|
|
1976
|
+
const TODO_MAX_VISIBLE = 5;
|
|
1977
|
+
const TODO_MARK = { in_progress: '\u25cf', done: '\u2713', pending: '\u25cb' };
|
|
1978
|
+
/** One rendered todo row, in CSS pixels — the pointer-delta to row conversion. */
|
|
1979
|
+
const TODO_ROW_PX = 21;
|
|
1980
|
+
const QUEUE_ROW_PX = 21;
|
|
1981
|
+
|
|
1982
|
+
let todosExpanded = false;
|
|
1983
|
+
|
|
1984
|
+
|
|
1985
|
+
function selectVisibleTodos(todos) {
|
|
1986
|
+
if (todos.length <= TODO_MAX_VISIBLE) return { rows: todos, hidden: 0, counts: null };
|
|
1987
|
+
const inProgress = [], pending = [], done = [];
|
|
1988
|
+
todos.forEach((t, i) => {
|
|
1989
|
+
if (t.status === 'in_progress') inProgress.push(i);
|
|
1990
|
+
else if (t.status === 'pending') pending.push(i);
|
|
1991
|
+
else done.push(i);
|
|
1992
|
+
});
|
|
1993
|
+
const picked = new Set(inProgress.slice(0, TODO_MAX_VISIBLE));
|
|
1994
|
+
if (picked.size < TODO_MAX_VISIBLE) {
|
|
1995
|
+
const doneC = done.slice().reverse();
|
|
1996
|
+
const remaining = TODO_MAX_VISIBLE - picked.size;
|
|
1997
|
+
let doneCount, pendCount;
|
|
1998
|
+
if (!doneC.length) { doneCount = 0; pendCount = Math.min(remaining, pending.length); }
|
|
1999
|
+
else if (!pending.length) { pendCount = 0; doneCount = Math.min(remaining, doneC.length); }
|
|
2000
|
+
else {
|
|
2001
|
+
doneCount = 1;
|
|
2002
|
+
pendCount = Math.min(remaining - 1, pending.length);
|
|
2003
|
+
if (pendCount < remaining - 1) doneCount = Math.min(doneC.length, remaining - pendCount);
|
|
2004
|
+
}
|
|
2005
|
+
for (let i = 0; i < doneCount; i++) picked.add(doneC[i]);
|
|
2006
|
+
for (let i = 0; i < pendCount; i++) picked.add(pending[i]);
|
|
2007
|
+
}
|
|
2008
|
+
const idx = [...picked].sort((a, b) => a - b);
|
|
2009
|
+
const counts = { done: 0, in_progress: 0, pending: 0 };
|
|
2010
|
+
todos.forEach((t, i) => { if (!picked.has(i)) counts[t.status] = (counts[t.status] || 0) + 1; });
|
|
2011
|
+
return { rows: idx.map((i) => todos[i]), hidden: todos.length - idx.length, counts };
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
function todoMoreButton(label, title, onClick) {
|
|
2015
|
+
const b = document.createElement('button');
|
|
2016
|
+
b.type = 'button';
|
|
2017
|
+
b.className = 'msg-more';
|
|
2018
|
+
b.style.margin = '0';
|
|
2019
|
+
b.textContent = label;
|
|
2020
|
+
if (title) b.title = title;
|
|
2021
|
+
b.addEventListener('click', onClick);
|
|
2022
|
+
return b;
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
function renderTodoPanel(todos) {
|
|
2026
|
+
const ul = $('st-todos');
|
|
2027
|
+
if (!ul) return;
|
|
2028
|
+
ul.replaceChildren();
|
|
2029
|
+
if (!todos.length) return;
|
|
2030
|
+
|
|
2031
|
+
// The visible set is the automatic pick topped up to the panel's row count, so
|
|
2032
|
+
// dragging the rule taller ADDS rows rather than reordering them — the
|
|
2033
|
+
// in-progress item keeps its priority while more of the list appears under it.
|
|
2034
|
+
const want = panelRowCount('todo', todos.length);
|
|
2035
|
+
const auto = selectVisibleTodos(todos);
|
|
2036
|
+
const rows = auto.rows.slice();
|
|
2037
|
+
if (rows.length < want) {
|
|
2038
|
+
const seen = new Set(rows);
|
|
2039
|
+
for (const t of todos) {
|
|
2040
|
+
if (rows.length >= want) break;
|
|
2041
|
+
if (!seen.has(t)) { rows.push(t); seen.add(t); }
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
const shown = rows.slice(0, want);
|
|
2045
|
+
|
|
2046
|
+
for (const t of shown) {
|
|
2047
|
+
const li = document.createElement('li');
|
|
2048
|
+
li.className = t.status === 'done' ? 'done' : t.status === 'in_progress' ? 'now' : '';
|
|
2049
|
+
li.appendChild(el('mark', TODO_MARK[t.status] || TODO_MARK.pending));
|
|
2050
|
+
li.appendChild(el('t', String(t.title == null ? '' : t.title)));
|
|
2051
|
+
ul.appendChild(li);
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
const hidden = todos.length - shown.length;
|
|
2055
|
+
if (hidden > 0) {
|
|
2056
|
+
const counts = { done: 0, in_progress: 0, pending: 0 };
|
|
2057
|
+
for (const t of todos) if (!shown.includes(t)) counts[t.status] = (counts[t.status] || 0) + 1;
|
|
2058
|
+
const dist = [['done', 'done'], ['in_progress', 'in progress'], ['pending', 'pending']]
|
|
2059
|
+
.filter(([k]) => counts[k] > 0)
|
|
2060
|
+
.map(([k, label]) => counts[k] + ' ' + label)
|
|
2061
|
+
.join(', ');
|
|
2062
|
+
const li = document.createElement('li');
|
|
2063
|
+
li.className = 'more';
|
|
2064
|
+
li.appendChild(todoMoreButton(
|
|
2065
|
+
'\u2026 +' + hidden + (dist ? ' (' + dist + ')' : ''),
|
|
2066
|
+
tr('status.expandTodos'),
|
|
2067
|
+
() => { todosExpanded = true; panelRows.todo = todos.length; renderTodoPanel(todos); },
|
|
2068
|
+
));
|
|
2069
|
+
ul.appendChild(li);
|
|
2070
|
+
} else if (todosExpanded && todos.length > TODO_MAX_VISIBLE) {
|
|
2071
|
+
const li = document.createElement('li');
|
|
2072
|
+
li.className = 'more';
|
|
2073
|
+
li.appendChild(todoMoreButton(tr('tool.collapse'), '', () => {
|
|
2074
|
+
|
|
2075
|
+
todosExpanded = false;
|
|
2076
|
+
panelRows.todo = null;
|
|
2077
|
+
renderTodoPanel(todos);
|
|
2078
|
+
}));
|
|
2079
|
+
ul.appendChild(li);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
|
|
2084
|
+
// ------------------------------------------------------------- queue panel
|
|
2085
|
+
|
|
2086
|
+
function renderQueuePanel(queued, busy) {
|
|
2087
|
+
const ul = $('st-queue');
|
|
2088
|
+
if (!ul) return;
|
|
2089
|
+
ul.replaceChildren();
|
|
2090
|
+
if (!queued.length) return;
|
|
2091
|
+
|
|
2092
|
+
// Same rule as the todo panel: the drag count wins, clamped to what exists.
|
|
2093
|
+
const rows = queued.slice(0, panelRowCount('queue', queued.length));
|
|
2094
|
+
|
|
2095
|
+
rows.forEach((raw) => {
|
|
2096
|
+
const text = String(raw == null ? '' : raw);
|
|
2097
|
+
const single = text.replace(/\s+/g, ' ').trim();
|
|
2098
|
+
const li = document.createElement('li');
|
|
2099
|
+
li.appendChild(el('mark', tr('mark.queued')));
|
|
2100
|
+
const tx = el('t', single);
|
|
2101
|
+
tx.title = single;
|
|
2102
|
+
li.appendChild(tx);
|
|
2103
|
+
|
|
2104
|
+
if (busy) {
|
|
2105
|
+
const b = document.createElement('button');
|
|
2106
|
+
b.type = 'button';
|
|
2107
|
+
b.className = 'steer-btn';
|
|
2108
|
+
b.textContent = tr('queue.steer');
|
|
2109
|
+
b.title = tr('queue.steerHint');
|
|
2110
|
+
b.addEventListener('click', (ev) => {
|
|
2111
|
+
ev.preventDefault();
|
|
2112
|
+
// The payload is the item's OWN text, captured here. Indexing into the
|
|
2113
|
+
// `queued` array read a list that status rebuilds from scratch on every
|
|
2114
|
+
// frame, so a click after any change steered the wrong message — or
|
|
2115
|
+
// nothing. A successful steer removes the item, so the row disappears on
|
|
2116
|
+
// the next status frame.
|
|
2117
|
+
void steerQueued(b, text);
|
|
2118
|
+
});
|
|
2119
|
+
li.appendChild(b);
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
// Edit: pull the message back out of the queue and load it into the composer,
|
|
2123
|
+
// mirroring the terminal's ↑-to-recall. Available whether or not a turn is
|
|
2124
|
+
// running — a queued message is editable at any time.
|
|
2125
|
+
const eb = document.createElement('button');
|
|
2126
|
+
eb.type = 'button';
|
|
2127
|
+
eb.className = 'steer-btn edit-btn';
|
|
2128
|
+
eb.textContent = tr('queue.edit');
|
|
2129
|
+
eb.title = tr('queue.editHint');
|
|
2130
|
+
eb.addEventListener('click', (ev) => {
|
|
2131
|
+
ev.preventDefault();
|
|
2132
|
+
void editQueued(eb, text);
|
|
2133
|
+
});
|
|
2134
|
+
li.appendChild(eb);
|
|
2135
|
+
|
|
2136
|
+
// Drop: remove it from the queue without editing.
|
|
2137
|
+
const db = document.createElement('button');
|
|
2138
|
+
db.type = 'button';
|
|
2139
|
+
db.className = 'steer-btn drop-btn';
|
|
2140
|
+
db.textContent = tr('queue.drop');
|
|
2141
|
+
db.title = tr('queue.dropHint');
|
|
2142
|
+
db.addEventListener('click', (ev) => {
|
|
2143
|
+
ev.preventDefault();
|
|
2144
|
+
void dropQueued(db, text);
|
|
2145
|
+
});
|
|
2146
|
+
li.appendChild(db);
|
|
2147
|
+
ul.appendChild(li);
|
|
2148
|
+
});
|
|
2149
|
+
|
|
2150
|
+
const li = document.createElement('li');
|
|
2151
|
+
li.className = 'more';
|
|
2152
|
+
const hidden = queued.length - rows.length;
|
|
2153
|
+
li.textContent = hidden > 0
|
|
2154
|
+
? tr('queue.moreHint', { n: hidden })
|
|
2155
|
+
: tr(busy ? 'queue.hintRunning' : 'queue.hintIdle');
|
|
2156
|
+
ul.appendChild(li);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
/** Steer one queued message into the running turn. The button reports its own
|
|
2160
|
+
progress, because a send that silently does nothing is indistinguishable
|
|
2161
|
+
from a broken button. */
|
|
2162
|
+
async function steerQueued(btn, text) {
|
|
2163
|
+
btn.disabled = true;
|
|
2164
|
+
btn.textContent = tr('queue.steering');
|
|
2165
|
+
const body = await act('steer', [text]);
|
|
2166
|
+
if (!body || body.ok !== true) {
|
|
2167
|
+
btn.disabled = false;
|
|
2168
|
+
btn.textContent = tr('queue.steer');
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
/** Recall a queued message into the composer for editing, like the terminal's ↑.
|
|
2173
|
+
The server removes it from the queue and returns its text; we put that text in
|
|
2174
|
+
the input so the user can change and re-send it. */
|
|
2175
|
+
async function editQueued(btn, text) {
|
|
2176
|
+
btn.disabled = true;
|
|
2177
|
+
const body = await act('editQueued', [text]);
|
|
2178
|
+
btn.disabled = false;
|
|
2179
|
+
if (!body || body.ok !== true) return;
|
|
2180
|
+
input.value = String(body.text == null ? text : body.text);
|
|
2181
|
+
input.focus();
|
|
2182
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
2183
|
+
autoGrow();
|
|
2184
|
+
setPmode();
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
/** Remove a queued message without editing it. */
|
|
2188
|
+
async function dropQueued(btn, text) {
|
|
2189
|
+
btn.disabled = true;
|
|
2190
|
+
const body = await act('dropQueued', [text]);
|
|
2191
|
+
btn.disabled = false;
|
|
2192
|
+
if (!body || body.ok !== true) btn.disabled = false;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
|
|
2196
|
+
// ------------------------------------------------------------- side tabs
|
|
2197
|
+
|
|
2198
|
+
function card() {
|
|
2199
|
+
const n = document.createElement('div');
|
|
2200
|
+
n.className = 'card';
|
|
2201
|
+
return n;
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
function cardHead(...nodes) {
|
|
2205
|
+
const h = document.createElement('div');
|
|
2206
|
+
h.className = 'card-head';
|
|
2207
|
+
h.append(...nodes);
|
|
2208
|
+
return h;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
function cardFields() {
|
|
2212
|
+
const g = document.createElement('div');
|
|
2213
|
+
g.className = 'card-fields';
|
|
2214
|
+
return g;
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
function rightMeta(text) {
|
|
2218
|
+
const s = document.createElement('span');
|
|
2219
|
+
s.className = 'right';
|
|
2220
|
+
s.textContent = text;
|
|
2221
|
+
return s;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
function field(label, value) {
|
|
2225
|
+
const row = document.createElement('div');
|
|
2226
|
+
row.className = 'field';
|
|
2227
|
+
row.appendChild(el('field-label', label));
|
|
2228
|
+
const v = el('field-value', '');
|
|
2229
|
+
if (value == null || value === '') {
|
|
2230
|
+
v.className = 'field-value dim';
|
|
2231
|
+
v.textContent = tr('unit.none');
|
|
2232
|
+
} else if (value instanceof Node) {
|
|
2233
|
+
v.appendChild(value);
|
|
2234
|
+
} else {
|
|
2235
|
+
v.textContent = String(value);
|
|
2236
|
+
}
|
|
2237
|
+
row.appendChild(v);
|
|
2238
|
+
return row;
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
function pill(text, tone) {
|
|
2242
|
+
return el('pill' + (tone ? ' pill-' + tone : ''), text);
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
function emptyNote(text) {
|
|
2246
|
+
return el('cards-empty', text);
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
function renderTasks(list) {
|
|
2250
|
+
const wrap = $('tasks');
|
|
2251
|
+
wrap.replaceChildren();
|
|
2252
|
+
if (!list.length) { wrap.appendChild(emptyNote(tr('tasks.empty'))); return; }
|
|
2253
|
+
for (const task of list) {
|
|
2254
|
+
const c = card();
|
|
2255
|
+
const tone = task.status === 'running' ? 'info'
|
|
2256
|
+
: task.status === 'failed' ? 'danger'
|
|
2257
|
+
: task.status === 'done' || task.status === 'completed' ? 'ok'
|
|
2258
|
+
: 'soft';
|
|
2259
|
+
const head = cardHead(
|
|
2260
|
+
pill(task.kind || 'bash', 'soft'),
|
|
2261
|
+
pill(task.status || '', tone),
|
|
2262
|
+
pill(task.id || '', 'sub'),
|
|
2263
|
+
);
|
|
2264
|
+
if (task.status === 'running') {
|
|
2265
|
+
const b = document.createElement('button');
|
|
2266
|
+
b.className = 'steer-btn';
|
|
2267
|
+
b.textContent = tr('tasks.stop');
|
|
2268
|
+
b.addEventListener('click', () => act('stopTask', [task.id]));
|
|
2269
|
+
head.appendChild(b);
|
|
2270
|
+
}
|
|
2271
|
+
c.appendChild(head);
|
|
2272
|
+
c.appendChild(rightMeta(task.startedAt ? ago(task.startedAt) : ''));
|
|
2273
|
+
|
|
2274
|
+
const fields = cardFields();
|
|
2275
|
+
fields.appendChild(field(tr('tasks.colSummary'), String(task.summary || task.prompt || '')));
|
|
2276
|
+
if (task.agentId) fields.appendChild(field('agent', task.agentId));
|
|
2277
|
+
c.appendChild(fields);
|
|
2278
|
+
wrap.appendChild(c);
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
function renderFiles(files) {
|
|
2283
|
+
const wrap = $('files');
|
|
2284
|
+
wrap.replaceChildren();
|
|
2285
|
+
if (!files.length) { wrap.appendChild(emptyNote(tr('files.empty'))); return; }
|
|
2286
|
+
for (const f of files) {
|
|
2287
|
+
const c = card();
|
|
2288
|
+
c.appendChild(cardHead(pill(f.ops || '', 'soft')));
|
|
2289
|
+
c.appendChild(rightMeta(String(f.count || 0)));
|
|
2290
|
+
const fields = cardFields();
|
|
2291
|
+
fields.appendChild(field(tr('files.colPath'), f.path));
|
|
2292
|
+
c.appendChild(fields);
|
|
2293
|
+
wrap.appendChild(c);
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
function renderTools(list) {
|
|
2298
|
+
const wrap = $('tools');
|
|
2299
|
+
wrap.replaceChildren();
|
|
2300
|
+
if (!list.length) { wrap.appendChild(emptyNote(tr('side.none'))); return; }
|
|
2301
|
+
for (const x of list) {
|
|
2302
|
+
const c = card();
|
|
2303
|
+
c.appendChild(cardHead(pill(x.name || '', 'soft')));
|
|
2304
|
+
const fields = cardFields();
|
|
2305
|
+
fields.appendChild(field('description', String(x.description || '').split('\n')[0]));
|
|
2306
|
+
c.appendChild(fields);
|
|
2307
|
+
wrap.appendChild(c);
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
function renderCommands(list) {
|
|
2312
|
+
const wrap = $('commands');
|
|
2313
|
+
wrap.replaceChildren();
|
|
2314
|
+
if (!list.length) { wrap.appendChild(emptyNote(tr('side.none'))); return; }
|
|
2315
|
+
for (const cmd of list) {
|
|
2316
|
+
const c = card();
|
|
2317
|
+
c.classList.add('cmd-card'); // enables the hover affordance
|
|
2318
|
+
c.appendChild(cardHead(pill('/' + (cmd.name || ''), 'solid'), pill(cmd.argumentHint || '', 'soft')));
|
|
2319
|
+
const fields = cardFields();
|
|
2320
|
+
fields.appendChild(field('description', cmd.desc || ''));
|
|
2321
|
+
c.appendChild(fields);
|
|
2322
|
+
// Clicking a command card acts on it, the way accepting a completion does in
|
|
2323
|
+
// the terminal:
|
|
2324
|
+
// * a command that takes NO argument runs immediately,
|
|
2325
|
+
// * a command that needs one (its argumentHint is shown on the card head) is
|
|
2326
|
+
// PREPARED in the composer, with the leading slash and trailing space in
|
|
2327
|
+
// place, so the user fills in the value and presses Enter — sending it
|
|
2328
|
+
// empty would just bounce the usage/error back.
|
|
2329
|
+
const nm = String(cmd.name || '');
|
|
2330
|
+
const needsArg = !!(cmd && cmd.argumentHint);
|
|
2331
|
+
c.addEventListener('click', () => {
|
|
2332
|
+
if (needsArg) {
|
|
2333
|
+
const box = $('composer');
|
|
2334
|
+
if (box) box.focus();
|
|
2335
|
+
input.value = '/' + nm + ' ';
|
|
2336
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
2337
|
+
if (typeof autoGrow === 'function') autoGrow();
|
|
2338
|
+
if (typeof setPmode === 'function') setPmode();
|
|
2339
|
+
} else {
|
|
2340
|
+
void send('/' + nm);
|
|
2341
|
+
}
|
|
2342
|
+
});
|
|
2343
|
+
wrap.appendChild(c);
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
|
|
2348
|
+
function renderTabCounts(s) {
|
|
2349
|
+
const counts = {
|
|
2350
|
+
tasks: (s.tasks || []).length,
|
|
2351
|
+
files: (s.files || []).length,
|
|
2352
|
+
logs: ($('logs') || { childElementCount: 0 }).childElementCount,
|
|
2353
|
+
tools: (s.tools || []).length,
|
|
2354
|
+
commands: (s.commands || []).length,
|
|
2355
|
+
};
|
|
2356
|
+
for (const b of document.querySelectorAll('.tab')) {
|
|
2357
|
+
const n = counts[b.getAttribute('data-view')];
|
|
2358
|
+
const old = b.querySelector('.tab-count');
|
|
2359
|
+
if (!n) { if (old) old.remove(); continue; }
|
|
2360
|
+
if (old) { old.textContent = String(n); continue; }
|
|
2361
|
+
b.appendChild(el('tab-count', String(n)));
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
const MAX_LOG = 600;
|
|
2366
|
+
|
|
2367
|
+
function addLog(e) {
|
|
2368
|
+
const box = $('logs');
|
|
2369
|
+
const line = document.createElement('div');
|
|
2370
|
+
line.className = 'log-line';
|
|
2371
|
+
line.append(
|
|
2372
|
+
el('log-t', new Date(e.at || Date.now()).toTimeString().slice(0, 8)),
|
|
2373
|
+
el('log-k', String(e.kind || e.type || '')),
|
|
2374
|
+
el('log-d', String(e.text || '')),
|
|
2375
|
+
);
|
|
2376
|
+
box.appendChild(line);
|
|
2377
|
+
while (box.children.length > MAX_LOG) box.removeChild(box.firstChild);
|
|
2378
|
+
if (!$('view-logs').hidden) box.scrollTop = box.scrollHeight;
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
// ----------------------------------------------------------------- modal
|
|
2382
|
+
|
|
2383
|
+
let modalKey = '';
|
|
2384
|
+
|
|
2385
|
+
function applyModal(s) {
|
|
2386
|
+
const box = $('modal');
|
|
2387
|
+
const pending = s.pending;
|
|
2388
|
+
if (!pending) { box.hidden = true; modalKey = ''; return; }
|
|
2389
|
+
const key = JSON.stringify(pending);
|
|
2390
|
+
if (key === modalKey && !box.hidden) return;
|
|
2391
|
+
modalKey = key;
|
|
2392
|
+
|
|
2393
|
+
$('modal-box').className = 'modal-box '
|
|
2394
|
+
+ (pending.kind === 'approval' ? 'is-approval' : 'is-question');
|
|
2395
|
+
$('modal-title').textContent = pending.kind === 'approval' ? tr('modal.approve') : tr('modal.question');
|
|
2396
|
+
$('modal-body').textContent = pending.detail || pending.question || '';
|
|
2397
|
+
const actions = $('modal-actions');
|
|
2398
|
+
actions.replaceChildren();
|
|
2399
|
+
|
|
2400
|
+
const mkBtn = (label, fn, primary) => {
|
|
2401
|
+
const b = document.createElement('button');
|
|
2402
|
+
b.className = primary ? 'btn' : 'btn-quiet';
|
|
2403
|
+
b.textContent = label;
|
|
2404
|
+
b.addEventListener('click', fn);
|
|
2405
|
+
actions.appendChild(b);
|
|
2406
|
+
};
|
|
2407
|
+
|
|
2408
|
+
if (pending.kind === 'approval') {
|
|
2409
|
+
mkBtn(tr('modal.allow'), () => act('approve', [pending.id, true]), true);
|
|
2410
|
+
mkBtn(tr('modal.deny'), () => act('approve', [pending.id, false]), false);
|
|
2411
|
+
} else {
|
|
2412
|
+
const body = $('modal-body');
|
|
2413
|
+
const multi = !!pending.multiSelect;
|
|
2414
|
+
const picked = new Set();
|
|
2415
|
+
// Declared before the option handlers below reference it (single-select
|
|
2416
|
+
// submits immediately and must be able to read the note if it is present).
|
|
2417
|
+
let noteEl = null;
|
|
2418
|
+
// Progress line: "Question n/total" + optional header.
|
|
2419
|
+
const total = Number(pending.total || 1);
|
|
2420
|
+
const idx = Number(pending.index || 0);
|
|
2421
|
+
if (total > 1) {
|
|
2422
|
+
const prog = document.createElement('div');
|
|
2423
|
+
prog.className = 'modal-progress';
|
|
2424
|
+
prog.textContent = tr('modal.progress', { n: idx + 1, total })
|
|
2425
|
+
+ (pending.header ? ' ' + pending.header : '');
|
|
2426
|
+
body.appendChild(prog);
|
|
2427
|
+
}
|
|
2428
|
+
// Option rows (the model's real options).
|
|
2429
|
+
const list = document.createElement('div');
|
|
2430
|
+
list.className = 'modal-options';
|
|
2431
|
+
for (const o of pending.options || []) {
|
|
2432
|
+
const label = (o && typeof o === 'object') ? o.label : String(o);
|
|
2433
|
+
const desc = (o && typeof o === 'object') ? (o.description || '') : '';
|
|
2434
|
+
const b = document.createElement('button');
|
|
2435
|
+
b.type = 'button';
|
|
2436
|
+
b.className = 'modal-opt';
|
|
2437
|
+
b.dataset.label = label;
|
|
2438
|
+
const t = document.createElement('span');
|
|
2439
|
+
t.className = 'modal-opt-label';
|
|
2440
|
+
t.textContent = label;
|
|
2441
|
+
b.appendChild(t);
|
|
2442
|
+
if (desc) {
|
|
2443
|
+
const d = document.createElement('span');
|
|
2444
|
+
d.className = 'modal-opt-desc';
|
|
2445
|
+
d.textContent = desc;
|
|
2446
|
+
b.appendChild(d);
|
|
2447
|
+
}
|
|
2448
|
+
if (multi) {
|
|
2449
|
+
const box = document.createElement('span');
|
|
2450
|
+
box.className = 'modal-check';
|
|
2451
|
+
box.textContent = '\u25cb';
|
|
2452
|
+
b.prepend(box);
|
|
2453
|
+
b.addEventListener('click', () => {
|
|
2454
|
+
if (picked.has(label)) picked.delete(label); else picked.add(label);
|
|
2455
|
+
b.classList.toggle('is-on', picked.has(label));
|
|
2456
|
+
box.textContent = picked.has(label) ? '\u2713' : '\u25cb';
|
|
2457
|
+
});
|
|
2458
|
+
} else {
|
|
2459
|
+
// Single-select: choosing an option answers immediately, like the terminal.
|
|
2460
|
+
b.addEventListener('click', () => {
|
|
2461
|
+
void act('answerQuestion', [pending.id, [label], '', noteEl ? noteEl.value : '', true]);
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
2464
|
+
list.appendChild(b);
|
|
2465
|
+
}
|
|
2466
|
+
body.appendChild(list);
|
|
2467
|
+
// Per-question "Other": a free-text answer of the user's own.
|
|
2468
|
+
const otherWrap = document.createElement('div');
|
|
2469
|
+
otherWrap.className = 'modal-other';
|
|
2470
|
+
const otherBtn = document.createElement('button');
|
|
2471
|
+
otherBtn.type = 'button';
|
|
2472
|
+
otherBtn.className = 'modal-opt modal-other-toggle';
|
|
2473
|
+
otherBtn.textContent = tr('modal.other');
|
|
2474
|
+
const otherInput = document.createElement('input');
|
|
2475
|
+
otherInput.type = 'text';
|
|
2476
|
+
otherInput.className = 'modal-input inline';
|
|
2477
|
+
otherInput.placeholder = tr('modal.otherHint');
|
|
2478
|
+
let otherOpen = false;
|
|
2479
|
+
otherBtn.addEventListener('click', () => {
|
|
2480
|
+
otherOpen = !otherOpen;
|
|
2481
|
+
otherInput.hidden = !otherOpen;
|
|
2482
|
+
otherBtn.classList.toggle('is-on', otherOpen);
|
|
2483
|
+
if (otherOpen) otherInput.focus();
|
|
2484
|
+
});
|
|
2485
|
+
otherInput.hidden = true;
|
|
2486
|
+
otherWrap.append(otherBtn, otherInput);
|
|
2487
|
+
body.appendChild(otherWrap);
|
|
2488
|
+
// Whole-request note, only after the last question (may be left blank).
|
|
2489
|
+
if (pending.hasSupplement) {
|
|
2490
|
+
const noteWrap = document.createElement('div');
|
|
2491
|
+
noteWrap.className = 'modal-note';
|
|
2492
|
+
const nl = document.createElement('div');
|
|
2493
|
+
nl.className = 'modal-note-label';
|
|
2494
|
+
nl.textContent = tr('modal.note');
|
|
2495
|
+
noteEl = document.createElement('textarea');
|
|
2496
|
+
noteEl.rows = 2;
|
|
2497
|
+
noteEl.className = 'modal-input';
|
|
2498
|
+
noteEl.placeholder = tr('modal.noteHint');
|
|
2499
|
+
noteWrap.append(nl, noteEl);
|
|
2500
|
+
body.appendChild(noteWrap);
|
|
2501
|
+
}
|
|
2502
|
+
// Actions: submit the current answers. In single-select the option click
|
|
2503
|
+
// already submitted, but Submit also works (honours the Other text box).
|
|
2504
|
+
const submitCurrent = () => {
|
|
2505
|
+
const other = otherOpen ? otherInput.value.trim() : '';
|
|
2506
|
+
const list0 = [...picked];
|
|
2507
|
+
void act('answerQuestion', [pending.id, other ? [] : list0, other, noteEl ? noteEl.value : '', true]);
|
|
2508
|
+
};
|
|
2509
|
+
mkBtn(total > 1 && idx < total - 1 ? tr('modal.next') : tr('modal.submit'), submitCurrent, true);
|
|
2510
|
+
}
|
|
2511
|
+
box.hidden = false;
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
function copyButton(value, label) {
|
|
2515
|
+
const b = document.createElement('button');
|
|
2516
|
+
b.type = 'button';
|
|
2517
|
+
b.className = 'copy-btn';
|
|
2518
|
+
b.textContent = label || tr('copy.copy');
|
|
2519
|
+
b.title = tr('copy.copyTitle');
|
|
2520
|
+
let timer = null;
|
|
2521
|
+
b.addEventListener('click', (ev) => {
|
|
2522
|
+
ev.stopPropagation();
|
|
2523
|
+
const done = (okState) => {
|
|
2524
|
+
b.textContent = okState ? tr('copy.done') : tr('copy.failed');
|
|
2525
|
+
b.classList.toggle('is-ok', okState);
|
|
2526
|
+
b.classList.toggle('is-err', !okState);
|
|
2527
|
+
if (timer) clearTimeout(timer);
|
|
2528
|
+
timer = setTimeout(() => {
|
|
2529
|
+
b.textContent = label || tr('copy.copy');
|
|
2530
|
+
b.classList.remove('is-ok', 'is-err');
|
|
2531
|
+
}, 1200);
|
|
2532
|
+
};
|
|
2533
|
+
const text = String(value == null ? '' : value);
|
|
2534
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
2535
|
+
navigator.clipboard.writeText(text).then(() => done(true)).catch(() => done(false));
|
|
2536
|
+
} else {
|
|
2537
|
+
done(false);
|
|
2538
|
+
}
|
|
2539
|
+
});
|
|
2540
|
+
return b;
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
// ------------------------------------------------------------------ rail
|
|
2544
|
+
|
|
2545
|
+
function ago(ms) {
|
|
2546
|
+
if (!ms) return '';
|
|
2547
|
+
const diff = Date.now() - ms;
|
|
2548
|
+
if (diff < 0) return new Date(ms).toLocaleDateString();
|
|
2549
|
+
const min = Math.floor(diff / 60000);
|
|
2550
|
+
if (min < 1) return tr('home.justNow');
|
|
2551
|
+
if (min < 60) return tr('home.minutesAgo', { n: min });
|
|
2552
|
+
const hr = Math.floor(min / 60);
|
|
2553
|
+
if (hr < 24) return tr('home.hoursAgo', { n: hr });
|
|
2554
|
+
const day = Math.floor(hr / 24);
|
|
2555
|
+
if (day < 7) return tr('home.daysAgo', { n: day });
|
|
2556
|
+
return new Date(ms).toLocaleDateString();
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
function workspaceLabel(p) {
|
|
2560
|
+
const parts = String(p || '').split(/[/\\]+/).filter(Boolean);
|
|
2561
|
+
if (!parts.length) return tr('rail.noWorkspace');
|
|
2562
|
+
return parts.slice(-2).join('/');
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
function shortId(id) {
|
|
2566
|
+
const s = String(id || '');
|
|
2567
|
+
return s.length > 12 ? '\u2026' + s.slice(-10) : s;
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
let railShape = '';
|
|
2571
|
+
let railFilter = '';
|
|
2572
|
+
let railData = { sessions: [], current: '' };
|
|
2573
|
+
|
|
2574
|
+
function renderRail(data) {
|
|
2575
|
+
const list = $('rail-list');
|
|
2576
|
+
if (!list) return;
|
|
2577
|
+
railData = data || railData;
|
|
2578
|
+
const all = railData.sessions || [];
|
|
2579
|
+
const current = String(railData.current || '');
|
|
2580
|
+
|
|
2581
|
+
const q = railFilter.trim().toLowerCase();
|
|
2582
|
+
const sessions = q
|
|
2583
|
+
? all.filter((s) => (s.id + ' ' + (s.title || '') + ' ' + (s.workspace || '')).toLowerCase().includes(q))
|
|
2584
|
+
: all;
|
|
2585
|
+
|
|
2586
|
+
const shape = current + '\u0000' + q + '\u0000'
|
|
2587
|
+
+ sessions.map((s) => s.id + ':' + s.title + ':' + s.updatedAt).join('\u0000');
|
|
2588
|
+
if (shape === railShape) return;
|
|
2589
|
+
railShape = shape;
|
|
2590
|
+
|
|
2591
|
+
$('rail-count').textContent = !all.length ? ''
|
|
2592
|
+
: q ? sessions.length + ' / ' + all.length
|
|
2593
|
+
: String(all.length);
|
|
2594
|
+
|
|
2595
|
+
list.replaceChildren();
|
|
2596
|
+
if (!sessions.length) {
|
|
2597
|
+
list.appendChild(el('rail-empty', all.length ? tr('rail.noMatch') : tr('rail.empty')));
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
const byWs = new Map();
|
|
2602
|
+
for (const s of sessions) {
|
|
2603
|
+
const key = workspaceLabel(s.workspace);
|
|
2604
|
+
if (!byWs.has(key)) byWs.set(key, []);
|
|
2605
|
+
byWs.get(key).push(s);
|
|
2606
|
+
}
|
|
2607
|
+
const groups = [...byWs.entries()].sort((a, b) => {
|
|
2608
|
+
const na = Math.max(0, ...a[1].map((s) => s.updatedAt || 0));
|
|
2609
|
+
const nb = Math.max(0, ...b[1].map((s) => s.updatedAt || 0));
|
|
2610
|
+
return nb - na;
|
|
2611
|
+
});
|
|
2612
|
+
|
|
2613
|
+
for (const [ws, items] of groups) {
|
|
2614
|
+
const head = document.createElement('div');
|
|
2615
|
+
head.className = 'rail-group';
|
|
2616
|
+
const name = document.createElement('span');
|
|
2617
|
+
name.className = 'ws';
|
|
2618
|
+
name.textContent = ws;
|
|
2619
|
+
name.title = items[0].workspace || '';
|
|
2620
|
+
head.append(name, el('mono', String(items.length)));
|
|
2621
|
+
list.appendChild(head);
|
|
2622
|
+
for (const s of items) list.appendChild(railRow(s, s.id === current));
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
function railRow(s, isCurrent) {
|
|
2627
|
+
const a = document.createElement('a');
|
|
2628
|
+
a.className = 'rail-row' + (isCurrent ? ' is-current' : '');
|
|
2629
|
+
a.href = '/s/' + encodeURIComponent(s.id) + '/';
|
|
2630
|
+
a.title = (s.title || tr('home.untitled')) + '\n' + (s.id || '') + '\n' + (s.workspace || '');
|
|
2631
|
+
|
|
2632
|
+
const top = document.createElement('span');
|
|
2633
|
+
top.className = 'rail-row-top';
|
|
2634
|
+
top.append(
|
|
2635
|
+
el('rail-dot' + (isCurrent ? ' is-current' : ''), ''),
|
|
2636
|
+
el('rail-id', shortId(s.id)),
|
|
2637
|
+
el('rail-when', ago(s.updatedAt)),
|
|
2638
|
+
);
|
|
2639
|
+
a.appendChild(top);
|
|
2640
|
+
a.appendChild(el('rail-title', s.title || tr('home.untitled')));
|
|
2641
|
+
return a;
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
async function loadRail() {
|
|
2645
|
+
try {
|
|
2646
|
+
const res = await fetch('api/sessions', { headers: { accept: 'application/json' } });
|
|
2647
|
+
if (!res.ok) return;
|
|
2648
|
+
renderRail(await res.json());
|
|
2649
|
+
} catch { /* the rail is an accessory; a failed poll must not spam errors */ }
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
function startRail() {
|
|
2653
|
+
void loadRail();
|
|
2654
|
+
setInterval(() => { void loadRail(); }, 10000);
|
|
2655
|
+
const box = $('rail-filter');
|
|
2656
|
+
if (box) {
|
|
2657
|
+
box.addEventListener('input', () => {
|
|
2658
|
+
railFilter = box.value;
|
|
2659
|
+
railShape = '';
|
|
2660
|
+
renderRail(railData);
|
|
2661
|
+
});
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
// ------------------------------------------------------------------ input
|
|
2666
|
+
|
|
2667
|
+
let inputHistory = [];
|
|
2668
|
+
let historyIdx = -1;
|
|
2669
|
+
|
|
2670
|
+
function rememberInput(text) {
|
|
2671
|
+
const t = String(text || '').trim();
|
|
2672
|
+
if (!t) return;
|
|
2673
|
+
if (inputHistory[inputHistory.length - 1] !== t) inputHistory.push(t);
|
|
2674
|
+
if (inputHistory.length > 200) inputHistory.shift();
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
function historyNavigate(dir) {
|
|
2678
|
+
if (!inputHistory.length) return false;
|
|
2679
|
+
const caret = input.selectionStart == null ? 0 : input.selectionStart;
|
|
2680
|
+
const end = input.selectionEnd == null ? caret : input.selectionEnd;
|
|
2681
|
+
if (caret !== end) return false;
|
|
2682
|
+
if (dir < 0 && input.value.slice(0, caret).includes('\n')) return false;
|
|
2683
|
+
if (dir > 0 && input.value.slice(end).includes('\n')) return false;
|
|
2684
|
+
|
|
2685
|
+
if (historyIdx === -1) historyIdx = inputHistory.length;
|
|
2686
|
+
historyIdx = dir < 0
|
|
2687
|
+
? Math.max(0, historyIdx - 1)
|
|
2688
|
+
: Math.min(inputHistory.length, historyIdx + 1);
|
|
2689
|
+
input.value = historyIdx >= inputHistory.length ? '' : (inputHistory[historyIdx] || '');
|
|
2690
|
+
const at = input.value.length;
|
|
2691
|
+
input.setSelectionRange(at, at);
|
|
2692
|
+
autoGrow();
|
|
2693
|
+
setPmode();
|
|
2694
|
+
return true;
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
function resetHistoryCursor() {
|
|
2698
|
+
historyIdx = -1;
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
let growTimer = null;
|
|
2702
|
+
let growRows = 0;
|
|
2703
|
+
|
|
2704
|
+
function autoGrow() {
|
|
2705
|
+
const est = 1 + (input.value.match(/\n/g) || []).length
|
|
2706
|
+
+ Math.floor(input.value.length / 90);
|
|
2707
|
+
if (est === growRows) return;
|
|
2708
|
+
if (growTimer !== null) return;
|
|
2709
|
+
growTimer = requestAnimationFrame(() => {
|
|
2710
|
+
growTimer = null;
|
|
2711
|
+
growRows = 1 + (input.value.match(/\n/g) || []).length
|
|
2712
|
+
+ Math.floor(input.value.length / 90);
|
|
2713
|
+
input.style.height = 'auto';
|
|
2714
|
+
input.style.height = Math.min(200, input.scrollHeight) + 'px';
|
|
2715
|
+
});
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
function setPmode() {
|
|
2719
|
+
const shell = input.value.startsWith('!');
|
|
2720
|
+
const node = $('pmode');
|
|
2721
|
+
node.textContent = shell ? '!' : '\u203a';
|
|
2722
|
+
node.title = tr(shell ? 'chat.shellMode' : 'chat.shellHint');
|
|
2723
|
+
$('composer').classList.toggle('is-shell', shell);
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
async function send(forced) {
|
|
2727
|
+
const text = forced === undefined ? input.value : String(forced);
|
|
2728
|
+
if (!text.trim()) return;
|
|
2729
|
+
if (forced === undefined) {
|
|
2730
|
+
input.value = '';
|
|
2731
|
+
autoGrow();
|
|
2732
|
+
setPmode();
|
|
2733
|
+
}
|
|
2734
|
+
rememberInput(text);
|
|
2735
|
+
resetHistoryCursor();
|
|
2736
|
+
|
|
2737
|
+
if (text.startsWith('!')) await act('shell', [text.replace(/^!+/, '').trim()]);
|
|
2738
|
+
else if (text.startsWith('/')) {
|
|
2739
|
+
// Slash commands (including /skill:<name>) go through the dispatcher, not
|
|
2740
|
+
// the plain submit path — otherwise a command line was sent as a literal
|
|
2741
|
+
// user message and the skill body / action never ran.
|
|
2742
|
+
const sp = text.indexOf(' ');
|
|
2743
|
+
const cmd = sp === -1 ? text : text.slice(0, sp);
|
|
2744
|
+
const arg = sp === -1 ? '' : text.slice(sp + 1).trim();
|
|
2745
|
+
await act('dispatch', [cmd, arg]);
|
|
2746
|
+
}
|
|
2747
|
+
else await act('submit', [text]);
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
// ------------------------------------------------------------------ views
|
|
2751
|
+
|
|
2752
|
+
const VIEW_IDS = ['chat', 'tasks', 'files', 'tools', 'commands', 'logs'];
|
|
2753
|
+
|
|
2754
|
+
const VIEW_SUB = {
|
|
2755
|
+
chat: '',
|
|
2756
|
+
tasks: 'tasks.sub',
|
|
2757
|
+
files: 'files.sub',
|
|
2758
|
+
tools: 'tools.sub',
|
|
2759
|
+
commands: 'commands.sub',
|
|
2760
|
+
logs: 'logs.sub',
|
|
2761
|
+
};
|
|
2762
|
+
|
|
2763
|
+
function setupViews() {
|
|
2764
|
+
for (const b of document.querySelectorAll('.tab')) {
|
|
2765
|
+
b.addEventListener('click', () => {
|
|
2766
|
+
for (const o of document.querySelectorAll('.tab')) o.classList.toggle('is-on', o === b);
|
|
2767
|
+
const v = b.getAttribute('data-view');
|
|
2768
|
+
for (const id of VIEW_IDS) $('view-' + id).hidden = id !== v;
|
|
2769
|
+
$('view-sub').textContent = VIEW_SUB[v] ? tr(VIEW_SUB[v]) : '';
|
|
2770
|
+
// A pane rebuilt-while-hidden never rendered: paint it once now from the
|
|
2771
|
+
// latest status, then clear its dirty flag.
|
|
2772
|
+
if (dirtyViews.has(v)) {
|
|
2773
|
+
dirtyViews.delete(v);
|
|
2774
|
+
if (v === 'tasks') renderTasks(status.tasks || []);
|
|
2775
|
+
else if (v === 'files') renderFiles(status.files || []);
|
|
2776
|
+
else if (v === 'tools') renderTools(status.tools || []);
|
|
2777
|
+
else if (v === 'commands') renderCommands(status.commands || []);
|
|
2778
|
+
}
|
|
2779
|
+
if (v === 'chat') { win.pinned = true; scheduleWindow(); }
|
|
2780
|
+
});
|
|
2781
|
+
}
|
|
2782
|
+
$('btn-interrupt').addEventListener('click', () => act('interrupt'));
|
|
2783
|
+
$('logs-clear').addEventListener('click', () => {
|
|
2784
|
+
$('logs').replaceChildren();
|
|
2785
|
+
renderTabCounts(status);
|
|
2786
|
+
});
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
/**
|
|
2790
|
+
* The rail on a narrow screen.
|
|
2791
|
+
*
|
|
2792
|
+
* Below the breakpoint the rail is no longer a column, so the ONLY way to reach
|
|
2793
|
+
* sessions, settings or the language switch is through the header button.
|
|
2794
|
+
* Drawing it as an overlay (rather than hiding it outright) is what keeps those
|
|
2795
|
+
* reachable on a phone at all — `display: none` left them permanently
|
|
2796
|
+
* inaccessible, which is a missing feature rather than a layout choice.
|
|
2797
|
+
*/
|
|
2798
|
+
const railDrawerOpen = { on: false };
|
|
2799
|
+
|
|
2800
|
+
function setRailDrawer(open) {
|
|
2801
|
+
railDrawerOpen.on = open;
|
|
2802
|
+
document.body.classList.toggle('rail-open', open);
|
|
2803
|
+
const btn = $('btn-rail');
|
|
2804
|
+
if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
function setupRailDrawer() {
|
|
2808
|
+
const btn = $('btn-rail');
|
|
2809
|
+
if (!btn) return;
|
|
2810
|
+
btn.addEventListener('click', () => setRailDrawer(!railDrawerOpen.on));
|
|
2811
|
+
|
|
2812
|
+
// A scrim click and Escape both close it, matching the settings overlay.
|
|
2813
|
+
const scrim = $('rail-scrim');
|
|
2814
|
+
if (scrim) scrim.addEventListener('click', () => setRailDrawer(false));
|
|
2815
|
+
document.addEventListener('keydown', (ev) => {
|
|
2816
|
+
if (ev.key === 'Escape' && railDrawerOpen.on) setRailDrawer(false);
|
|
2817
|
+
});
|
|
2818
|
+
|
|
2819
|
+
// Choosing a session is the reason the drawer was opened; leaving it up would
|
|
2820
|
+
// cover the page that was just loaded.
|
|
2821
|
+
$('rail-list').addEventListener('click', (ev) => {
|
|
2822
|
+
if (ev.target.closest && ev.target.closest('a.rail-row')) setRailDrawer(false);
|
|
2823
|
+
});
|
|
2824
|
+
|
|
2825
|
+
// Growing past the breakpoint turns the rail back into a column, where the
|
|
2826
|
+
// drawer state means nothing.
|
|
2827
|
+
const mq = window.matchMedia('(max-width: 720px)');
|
|
2828
|
+
const sync = () => { if (!mq.matches) setRailDrawer(false); };
|
|
2829
|
+
if (mq.addEventListener) mq.addEventListener('change', sync);
|
|
2830
|
+
else if (mq.addListener) mq.addListener(sync);
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
// ------------------------------------------------------------------- boot
|
|
2834
|
+
|
|
2835
|
+
|
|
2836
|
+
function main() {
|
|
2837
|
+
applyStatic();
|
|
2838
|
+
buildLangSwitch();
|
|
2839
|
+
setupViews();
|
|
2840
|
+
setupRailDrawer();
|
|
2841
|
+
setupPanelResize();
|
|
2842
|
+
setPmode();
|
|
2843
|
+
startRail();
|
|
2844
|
+
|
|
2845
|
+
stream.addEventListener('scroll', onScroll, { passive: true });
|
|
2846
|
+
if (typeof ResizeObserver === 'function') {
|
|
2847
|
+
new ResizeObserver(() => scheduleWindow()).observe(stream);
|
|
2848
|
+
} else {
|
|
2849
|
+
window.addEventListener('resize', () => scheduleWindow());
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
completer = new Completer(input, $('complete'), {
|
|
2853
|
+
getCommands: () => (status && status.commands) || [],
|
|
2854
|
+
getFiles: () => (status && status.workspaceFiles) || [],
|
|
2855
|
+
onAccept: (text, caret) => {
|
|
2856
|
+
input.value = text;
|
|
2857
|
+
input.setSelectionRange(caret, caret);
|
|
2858
|
+
autoGrow();
|
|
2859
|
+
setPmode();
|
|
2860
|
+
},
|
|
2861
|
+
onSubmit: (cmd) => {
|
|
2862
|
+
input.value = '';
|
|
2863
|
+
autoGrow();
|
|
2864
|
+
setPmode();
|
|
2865
|
+
void send(cmd);
|
|
2866
|
+
},
|
|
2867
|
+
});
|
|
2868
|
+
|
|
2869
|
+
input.addEventListener('input', () => {
|
|
2870
|
+
if (historyIdx !== -1 && input.value !== inputHistory[historyIdx]) resetHistoryCursor();
|
|
2871
|
+
autoGrow();
|
|
2872
|
+
setPmode();
|
|
2873
|
+
completer.refresh();
|
|
2874
|
+
});
|
|
2875
|
+
input.addEventListener('click', () => { completer.refresh(); });
|
|
2876
|
+
input.addEventListener('blur', () => { completer.close(); });
|
|
2877
|
+
input.addEventListener('keydown', (ev) => {
|
|
2878
|
+
if (completer.open) {
|
|
2879
|
+
if (ev.key === 'ArrowDown') { ev.preventDefault(); completer.move(1); return; }
|
|
2880
|
+
if (ev.key === 'ArrowUp') { ev.preventDefault(); completer.move(-1); return; }
|
|
2881
|
+
if (ev.key === 'Tab') { ev.preventDefault(); completer.accept(); return; }
|
|
2882
|
+
if (ev.key === 'Escape') { ev.preventDefault(); completer.close(); return; }
|
|
2883
|
+
if (ev.key === 'Enter' && !ev.shiftKey) {
|
|
2884
|
+
ev.preventDefault();
|
|
2885
|
+
if (completer.run()) return;
|
|
2886
|
+
void send();
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') {
|
|
2892
|
+
if (!historyNavigate(ev.key === 'ArrowUp' ? -1 : 1)) return;
|
|
2893
|
+
ev.preventDefault();
|
|
2894
|
+
return;
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); void send(); }
|
|
2898
|
+
});
|
|
2899
|
+
$('composer').addEventListener('submit', (ev) => { ev.preventDefault(); void send(); });
|
|
2900
|
+
|
|
2901
|
+
settingsPanel = new SettingsPanel({
|
|
2902
|
+
act: actReturning,
|
|
2903
|
+
getStatus: () => status,
|
|
2904
|
+
tr,
|
|
2905
|
+
toast: (m) => toast(m),
|
|
2906
|
+
});
|
|
2907
|
+
attachSettings(settingsPanel);
|
|
2908
|
+
|
|
2909
|
+
connect();
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
main();
|
|
2913
|
+
|