ai-code-engineer 0.1.0__py3-none-any.whl
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.
- ai_code_engineer/__init__.py +2 -0
- ai_code_engineer/catalog.py +143 -0
- ai_code_engineer/chat.py +181 -0
- ai_code_engineer/cli.py +384 -0
- ai_code_engineer/config.py +405 -0
- ai_code_engineer/engine.py +1282 -0
- ai_code_engineer/errors.py +27 -0
- ai_code_engineer/git_integration.py +443 -0
- ai_code_engineer/gui.py +2646 -0
- ai_code_engineer/host.py +81 -0
- ai_code_engineer/ignore.py +269 -0
- ai_code_engineer/intent.py +222 -0
- ai_code_engineer/labels.py +871 -0
- ai_code_engineer/memory.py +91 -0
- ai_code_engineer/modes.py +156 -0
- ai_code_engineer/overrides.py +540 -0
- ai_code_engineer/planbook.py +192 -0
- ai_code_engineer/providers.py +404 -0
- ai_code_engineer/redaction.py +54 -0
- ai_code_engineer/repair.py +564 -0
- ai_code_engineer/report.py +352 -0
- ai_code_engineer/runner.py +854 -0
- ai_code_engineer/setup.py +386 -0
- ai_code_engineer/symbols.py +1286 -0
- ai_code_engineer/verification.py +218 -0
- ai_code_engineer/webapp/__init__.py +1 -0
- ai_code_engineer/webapp/__main__.py +45 -0
- ai_code_engineer/webapp/contract.py +36 -0
- ai_code_engineer/webapp/controller.py +3556 -0
- ai_code_engineer/webapp/fake.py +1141 -0
- ai_code_engineer/webapp/launch.py +108 -0
- ai_code_engineer/webapp/server.py +349 -0
- ai_code_engineer/webapp/static/app.css +780 -0
- ai_code_engineer/webapp/static/app.js +2118 -0
- ai_code_engineer/webapp/static/boot.js +19 -0
- ai_code_engineer/webapp/static/index.html +89 -0
- ai_code_engineer/webapp/static/tokens.css +173 -0
- ai_code_engineer/workspace.py +385 -0
- ai_code_engineer-0.1.0.dist-info/METADATA +7 -0
- ai_code_engineer-0.1.0.dist-info/RECORD +44 -0
- ai_code_engineer-0.1.0.dist-info/WHEEL +5 -0
- ai_code_engineer-0.1.0.dist-info/entry_points.txt +2 -0
- ai_code_engineer-0.1.0.dist-info/licenses/LICENSE +21 -0
- ai_code_engineer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2118 @@
|
|
|
1
|
+
/* Client for the local UI server. Everything is rendered from one state object that
|
|
2
|
+
the server pushes deltas for over SSE, so the DOM never holds its own truth. */
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
let TOKEN = new URLSearchParams(location.search).get('t') || '';
|
|
6
|
+
const $ = (id) => document.getElementById(id);
|
|
7
|
+
const el = (tag, cls, html) => {
|
|
8
|
+
const n = document.createElement(tag);
|
|
9
|
+
if (cls) n.className = cls;
|
|
10
|
+
if (html !== undefined) n.innerHTML = html;
|
|
11
|
+
return n;
|
|
12
|
+
};
|
|
13
|
+
const esc = (s) => String(s == null ? '' : s)
|
|
14
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
15
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
16
|
+
/* Tones and levels arrive from the server and land inside a class="" attribute, so they are
|
|
17
|
+
looked up rather than interpolated: an unexpected value renders bare instead of styling. */
|
|
18
|
+
const TONES = new Set(['idle', 'ok', 'warn', 'bad']);
|
|
19
|
+
const tone = (name) => (TONES.has(name) ? ` ${name}` : '');
|
|
20
|
+
|
|
21
|
+
const ICON = {
|
|
22
|
+
file: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 3v5h5"/><path d="M6 3h8l5 5v13H6z"/></svg>',
|
|
23
|
+
plus: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M12 5v14M5 12h14"/></svg>',
|
|
24
|
+
gear: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3.2"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1"/></svg>',
|
|
25
|
+
chev: '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M6 9l6 6 6-6"/></svg>',
|
|
26
|
+
up: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6"><path d="M12 19V5M5 12l7-7 7 7"/></svg>',
|
|
27
|
+
spark: '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M13 2 4 14h6l-1 8 9-12h-6z"/></svg>',
|
|
28
|
+
branch: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="5.5" r="2.2"/><circle cx="6" cy="18.5" r="2.2"/><circle cx="17.5" cy="8" r="2.2"/><path d="M6 7.7v10.8M17.5 10.2c0 3.2-2.4 4.6-5 5.1-1.9.4-3.3.9-4.3 1.6"/></svg>',
|
|
29
|
+
copy: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M15 5.5H6a1.5 1.5 0 0 0-1.5 1.5v9"/></svg>',
|
|
30
|
+
lock: '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4.5" y="10.5" width="15" height="10" rx="2"/><path d="M8 10.5V7.5a4 4 0 0 1 8 0v3"/></svg>',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const state = {
|
|
34
|
+
view: 'task', busy: false, cancellable: false, expanded: {}, style: 'claude', theme: 'light',
|
|
35
|
+
status: '',
|
|
36
|
+
// Which change the rail is previewing (an index into review.files, because the server's own
|
|
37
|
+
// selection is what carries the diff), and which of its tabs is showing.
|
|
38
|
+
railFile: -1, railTab: 'diff',
|
|
39
|
+
// An open "come and look" intent waiting for the snapshot that carries the change set.
|
|
40
|
+
previewWant: '',
|
|
41
|
+
// The one step row the thread has open. Held here rather than in the DOM because `renderThread`
|
|
42
|
+
// rebuilds every row on each state push — and because the server holds what is inside it.
|
|
43
|
+
openStep: '',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/* True when the user's hands are on something: a field with text in it, or an open dialog.
|
|
47
|
+
Two things key off this — a global shortcut must not fire over typing, and a dialog must not
|
|
48
|
+
pop up in the middle of a sentence. */
|
|
49
|
+
function handsBusy() {
|
|
50
|
+
const tag = (document.activeElement || {}).tagName;
|
|
51
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
|
|
52
|
+
return !!document.querySelector('.scrim');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ------------------------------- transport ------------------------------- */
|
|
56
|
+
async function api(path, body) {
|
|
57
|
+
const res = await fetch(path + (path.includes('?') ? '&' : '?') + 't=' + encodeURIComponent(TOKEN), {
|
|
58
|
+
method: body ? 'POST' : 'GET',
|
|
59
|
+
headers: body ? { 'content-type': 'application/json' } : undefined,
|
|
60
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
// Read the body once: a Response cannot be consumed twice, and a 403 that threw on the second
|
|
64
|
+
// read would report a TypeError instead of the reason the server gave.
|
|
65
|
+
const text = await res.text();
|
|
66
|
+
if (res.status === 403) windowGoneDeaf(text);
|
|
67
|
+
throw new Error(text);
|
|
68
|
+
}
|
|
69
|
+
return res.json();
|
|
70
|
+
}
|
|
71
|
+
/* Every action answers with the full state, so one apply covers both the optimistic
|
|
72
|
+
click and the server's verdict on it. */
|
|
73
|
+
const send = (type, payload) => api('/api/action', { type, ...payload })
|
|
74
|
+
.then((reply) => { if (reply && reply.state) render(reply.state); })
|
|
75
|
+
.catch((e) => toast(String(e.message || e), 'bad'));
|
|
76
|
+
/* For actions whose only effect the client already knows about: re-rendering on every
|
|
77
|
+
keystroke would rebuild the composer for nothing. */
|
|
78
|
+
const sendQuiet = (type, payload) => api('/api/action', { type, ...payload }).catch(() => {});
|
|
79
|
+
|
|
80
|
+
/* The token is minted per launch, so a restart leaves an already-open tab showing real history that
|
|
81
|
+
answers no click — every request refused, one 4.2 s toast per click, the page looking perfectly
|
|
82
|
+
alive. That is the failure a user cannot diagnose: a reported "View button does nothing" turned out
|
|
83
|
+
to be this. The notice stays on screen and names the fix, and it appears once, not per click. */
|
|
84
|
+
let DEAF = false;
|
|
85
|
+
function windowGoneDeaf(reason) {
|
|
86
|
+
if (DEAF) return;
|
|
87
|
+
DEAF = true;
|
|
88
|
+
const t = el('div', 'toast bad', `<i></i><span>${esc(reason || 'Not authorised for this UI session.')}</span>`
|
|
89
|
+
+ `<button data-reload>Reload</button><button>Close</button>`);
|
|
90
|
+
t.querySelector('[data-reload]').onclick = () => location.reload();
|
|
91
|
+
t.querySelector('button:last-child').onclick = () => t.remove();
|
|
92
|
+
$('toasts').appendChild(t);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* An EventSource cannot read the body of a 403, so a link whose session ended looks exactly like a
|
|
96
|
+
network blip — and re-dialling it every 1.5 s is silent forever. Counted on a stale token: 33
|
|
97
|
+
rejected requests before anyone looked. After a few, the window has to admit it is deaf. */
|
|
98
|
+
const STREAM_RETRY_LIMIT = 4;
|
|
99
|
+
let streamRetries = 0;
|
|
100
|
+
|
|
101
|
+
function connectEvents() {
|
|
102
|
+
const src = new EventSource('/api/events?t=' + encodeURIComponent(TOKEN));
|
|
103
|
+
/* A dropped stream reconnects on its own, and every event it missed was a whole snapshot. The
|
|
104
|
+
window then keeps showing the last one it saw — measured live: the review card said "Apply
|
|
105
|
+
changes" was unavailable while the server was sitting on a pending proposal waiting for that
|
|
106
|
+
very click. Re-reading the bootstrap when a stream opens costs one request and ends the drift. */
|
|
107
|
+
src.onopen = () => { streamRetries = 0; api('/api/bootstrap').then(render).catch(() => {}); };
|
|
108
|
+
src.onmessage = (row) => {
|
|
109
|
+
let msg; try { msg = JSON.parse(row.data); } catch { return; }
|
|
110
|
+
applyEvent(msg);
|
|
111
|
+
};
|
|
112
|
+
src.onerror = () => {
|
|
113
|
+
if (++streamRetries >= STREAM_RETRY_LIMIT) {
|
|
114
|
+
streamGoneQuiet();
|
|
115
|
+
src.close();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
setTimeout(() => src.close() || connectEvents(), 1500);
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function streamGoneQuiet() {
|
|
123
|
+
windowGoneDeaf('Lost the connection to the agent. Reload this page to open it again.');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function applyEvent(msg) {
|
|
127
|
+
switch (msg.kind) {
|
|
128
|
+
case 'state': render(msg.data); break;
|
|
129
|
+
case 'status': state.status = msg.text; paintStatus(); break;
|
|
130
|
+
case 'log': appendLog(msg.entry || msg); break;
|
|
131
|
+
case 'log_chunk': pushChunk(msg); break;
|
|
132
|
+
case 'message': if ((msg.message || {}).role === 'assistant') STREAM = '';
|
|
133
|
+
state.data.messages.push(msg.message); renderThread(); break;
|
|
134
|
+
case 'token': appendToken(msg.text); break;
|
|
135
|
+
case 'busy': setBusy(msg.value, msg.cancellable); break;
|
|
136
|
+
case 'confirm': drawAsk(msg); break;
|
|
137
|
+
case 'retract': retractAsk(msg.id); break;
|
|
138
|
+
case 'toast': toast(msg.text, msg.level); break;
|
|
139
|
+
case 'folder': drawAsk(msg); break;
|
|
140
|
+
case 'view': showView(msg.value); break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* A question is a server-side object with an id the answer has to carry, so the same sheet can reach
|
|
145
|
+
the client twice: on the event that raised it, and inside every snapshot afterwards. The ids ever
|
|
146
|
+
drawn are remembered, which is what keeps the two paths from stacking two sheets on one waiter —
|
|
147
|
+
D29's duplicate row, one layer up. Ids are random per question, so a re-raised ask is a new id. */
|
|
148
|
+
const ASKS_DRAWN = new Set();
|
|
149
|
+
function drawAsk(msg) {
|
|
150
|
+
if (!msg || !msg.id || ASKS_DRAWN.has(msg.id)) return;
|
|
151
|
+
ASKS_DRAWN.add(msg.id);
|
|
152
|
+
if (msg.kind === 'folder') askFolder(msg); else askConfirm(msg);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* --------------------------------- render -------------------------------- */
|
|
156
|
+
let DATA = null;
|
|
157
|
+
function render(data) {
|
|
158
|
+
DATA = data;
|
|
159
|
+
state.data = data;
|
|
160
|
+
/* A new task, a rollback or a switch of project empties the file list, and a preview holding an
|
|
161
|
+
index into it would be pointing at nothing. */
|
|
162
|
+
if (state.railFile >= (((data.review || {}).files) || []).length) state.railFile = -1;
|
|
163
|
+
/* A row opened because it was *running* has nothing to show once the job ends and nobody has fetched
|
|
164
|
+
its stored block: it closes rather than sitting open and empty. */
|
|
165
|
+
if (state.openStep && !data.busy && !(data.step_detail && data.step_detail.id === state.openStep)) {
|
|
166
|
+
state.openStep = '';
|
|
167
|
+
}
|
|
168
|
+
/* A question opened before this page connected — or before the UI was restarted — arrives here
|
|
169
|
+
rather than as an event, and it is still holding a worker. */
|
|
170
|
+
for (const ask of (data.asks || [])) drawAsk(ask);
|
|
171
|
+
applyServerDraft(data.draft);
|
|
172
|
+
// The server keeps the collapsed state so a relaunch opens the way the window was left; the
|
|
173
|
+
// boot script already applied the local copy, and after this the two agree by construction.
|
|
174
|
+
if (!state.prefsSeen && data.prefs) { state.prefsSeen = true; setSidebar(data.prefs.collapsed); }
|
|
175
|
+
offerIcon(data);
|
|
176
|
+
// Style and theme are the window's own choice, applied before first paint by the boot
|
|
177
|
+
// script; a state push from the server must not snap them back.
|
|
178
|
+
renderThemePick(); renderNav(); renderHeader(); renderThread(); renderComposer();
|
|
179
|
+
renderQueue(); renderSetup();
|
|
180
|
+
renderRail(); renderLog();
|
|
181
|
+
syncSettings();
|
|
182
|
+
// A proposal that arrived on this snapshot was already asked to show itself.
|
|
183
|
+
takePreviewOffer();
|
|
184
|
+
setBusy(data.busy, data.cancellable);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* The server owns the draft only when it puts one there itself — "Try sample project"
|
|
188
|
+
and an auto-advanced plan step. Echoing the client's own keystrokes back would
|
|
189
|
+
fight the caret, so anything we typed ourselves is ignored here. */
|
|
190
|
+
function applyServerDraft(draft) {
|
|
191
|
+
const box = $('prompt');
|
|
192
|
+
const wanted = draft || '';
|
|
193
|
+
if (wanted === box.value || wanted === state.lastDraftSent) return;
|
|
194
|
+
box.value = wanted;
|
|
195
|
+
autosize();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function renderThemePick() {
|
|
199
|
+
const pick = $('themepick'); pick.innerHTML = '';
|
|
200
|
+
const modes = [['light', '☀'], ['dark', '☾'], ['auto', '◐']];
|
|
201
|
+
for (const [mode, glyph] of modes) {
|
|
202
|
+
const b = el('button', state.themeMode === mode ? 'on' : '', glyph);
|
|
203
|
+
b.title = mode + ' theme';
|
|
204
|
+
b.onclick = () => { state.themeMode = mode; applyThemeMode(); renderThemePick(); };
|
|
205
|
+
pick.appendChild(b);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function applyThemeMode() {
|
|
209
|
+
const mode = state.themeMode || 'light';
|
|
210
|
+
const dark = mode === 'auto' ? matchMedia('(prefers-color-scheme: dark)').matches : mode === 'dark';
|
|
211
|
+
state.theme = dark ? 'dark' : 'light';
|
|
212
|
+
document.documentElement.dataset.theme = state.theme;
|
|
213
|
+
savePrefs({ theme: mode });
|
|
214
|
+
send('set_theme', { theme: state.theme, mode });
|
|
215
|
+
}
|
|
216
|
+
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (state.themeMode === 'auto') applyThemeMode(); });
|
|
217
|
+
|
|
218
|
+
function relTime(iso) {
|
|
219
|
+
if (!iso) return '';
|
|
220
|
+
const mins = Math.max(0, (Date.now() - new Date(iso).getTime()) / 60000);
|
|
221
|
+
if (mins < 1) return 'now'; if (mins < 60) return Math.round(mins) + 'm';
|
|
222
|
+
if (mins < 1440) return Math.round(mins / 60) + 'h';
|
|
223
|
+
return Math.round(mins / 1440) + 'd';
|
|
224
|
+
}
|
|
225
|
+
const DOT = { CHECKS_PASSED: 'ok', WAITING_APPROVAL: 'warn', APPLIED_UNVERIFIED: 'warn',
|
|
226
|
+
VERIFICATION_FAILED: 'bad', VERIFICATION_BLOCKED: 'warn', PARTIAL_APPLY: 'bad', APPLYING: 'bad',
|
|
227
|
+
ROLLED_BACK: '', CANCELLED: '', BLOCKED: 'bad' };
|
|
228
|
+
|
|
229
|
+
/* A project granted in this session has no mark yet, so ask once — and only for a project the
|
|
230
|
+
user just picked. A restored branch is marked as asked without prompting, because a modal on
|
|
231
|
+
every launch would be worse than the plain initials avatar. */
|
|
232
|
+
function offerIcon(data) {
|
|
233
|
+
state.iconAsked = state.iconAsked || {};
|
|
234
|
+
const branch = data.branch || {};
|
|
235
|
+
if (!state.booted) {
|
|
236
|
+
state.booted = true;
|
|
237
|
+
if (branch.key) state.iconAsked[branch.key] = true;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (branch.kind !== 'project' || !branch.key || state.iconAsked[branch.key]) return;
|
|
241
|
+
const group = (data.projects || []).find((g) => g.key === branch.key);
|
|
242
|
+
if (!group || group.icon) return;
|
|
243
|
+
state.iconAsked[branch.key] = true;
|
|
244
|
+
/* The click that picked this project is still settling when render() reaches here, so a dialog
|
|
245
|
+
mounted on this tick is dismissed by that same event — the picker appears and vanishes at
|
|
246
|
+
once. Deferring it past the pointer events is what makes the offer visible; the branch check
|
|
247
|
+
is so a deferred picker never opens over a project the user has already left. */
|
|
248
|
+
clearTimeout(state.iconTimer);
|
|
249
|
+
let tries = 0;
|
|
250
|
+
const ask = () => {
|
|
251
|
+
/* A picker that lands while a sentence is being typed is the same glitch with a new face: the
|
|
252
|
+
dialog steals the caret, and the keystrokes that follow go to its buttons. So it waits for a
|
|
253
|
+
quiet moment, and gives up rather than queueing behind someone who is working. */
|
|
254
|
+
if (handsBusy() || document.querySelector('.scrim')) {
|
|
255
|
+
if (++tries < 5) state.iconTimer = setTimeout(ask, 1200);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if ((DATA.branch || {}).key === branch.key) iconPicker(group);
|
|
259
|
+
};
|
|
260
|
+
state.iconTimer = setTimeout(ask, 400);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function renderNav() {
|
|
264
|
+
const nav = $('nav'); nav.innerHTML = '';
|
|
265
|
+
const q = (state.query || '').toLowerCase();
|
|
266
|
+
const proj = el('div', 'sec');
|
|
267
|
+
proj.appendChild(el('h4', '', '<span>Projects</span><span class="add" id="add-project">' + '+ open</span>'
|
|
268
|
+
+ '<span class="add" id="new-project">' + '+ new</span>'));
|
|
269
|
+
proj.querySelector('#add-project').onclick = () => send('pick_project');
|
|
270
|
+
proj.querySelector('#new-project').onclick = () => send('new_project');
|
|
271
|
+
for (const group of DATA.projects) {
|
|
272
|
+
if (q && !(group.name.toLowerCase().includes(q) || group.chats.some((c) => c.title.toLowerCase().includes(q)))) continue;
|
|
273
|
+
// Nothing is open until the user opens it, so the tree starts collapsed on every load. A
|
|
274
|
+
// search expands the projects it matched because a hidden result is not a result. Once a
|
|
275
|
+
// node carries a stored choice that choice wins outright — an extra `|| branch in front`
|
|
276
|
+
// clause here used to make the active project uncloseable: the click stored false while the
|
|
277
|
+
// clause kept re-opening it.
|
|
278
|
+
const chosen = state.expanded[group.key];
|
|
279
|
+
const matched = !!q && group.chats.some((c) => c.title.toLowerCase().includes(q));
|
|
280
|
+
const open = chosen === undefined ? matched : chosen;
|
|
281
|
+
const mark = avatar(group.name);
|
|
282
|
+
const node = el('div', 'node' + (open ? '' : ' closed'),
|
|
283
|
+
`<span class="av" style="background:${mark.bg};color:${mark.ink}">`
|
|
284
|
+
+ (group.icon ? esc(group.icon) : esc(group.initials)) + '</span>'
|
|
285
|
+
+ `<span class="nm">${esc(group.name)}</span>`
|
|
286
|
+
+ '<button class="dots node-add">+</button><button class="dots node-menu">⋯</button>'
|
|
287
|
+
+ '<span class="chev">▾</span>');
|
|
288
|
+
node.onclick = () => { state.expanded[group.key] = !open; renderNav(); };
|
|
289
|
+
node.title = group.path + '\nDrop a chat here to let it read this project';
|
|
290
|
+
node.querySelector('.node-add').title = 'New chat in ' + group.name + ' — reads this project, writes nothing';
|
|
291
|
+
node.querySelector('.node-add').onclick = (e) => {
|
|
292
|
+
e.stopPropagation();
|
|
293
|
+
send('new_chat_in', { project: group.key });
|
|
294
|
+
};
|
|
295
|
+
node.querySelector('.node-menu').title = 'Project options';
|
|
296
|
+
node.querySelector('.node-menu').onclick = (e) => { e.stopPropagation(); projectMenu(group); };
|
|
297
|
+
// The drop target is the node itself: a chat moved here reads this folder from then on.
|
|
298
|
+
node.ondragover = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; node.classList.add('drop'); };
|
|
299
|
+
node.ondragleave = () => node.classList.remove('drop');
|
|
300
|
+
node.ondrop = (e) => {
|
|
301
|
+
e.preventDefault(); node.classList.remove('drop');
|
|
302
|
+
const id = e.dataTransfer.getData('text/chat');
|
|
303
|
+
if (!id) return;
|
|
304
|
+
send('bind_chat', { chat: id, project: group.key });
|
|
305
|
+
toast('Moved into ' + group.name);
|
|
306
|
+
};
|
|
307
|
+
proj.appendChild(node);
|
|
308
|
+
if (!open) continue;
|
|
309
|
+
for (const chat of group.chats) {
|
|
310
|
+
if (q && !chat.title.toLowerCase().includes(q)) continue;
|
|
311
|
+
proj.appendChild(leaf(chat, chat.kind || 'session', group.key));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (!DATA.projects.length) proj.appendChild(el('div', 'empty', 'No project yet. Open or create a folder to work on real files.'));
|
|
315
|
+
nav.appendChild(proj);
|
|
316
|
+
|
|
317
|
+
const chats = el('div', 'sec');
|
|
318
|
+
chats.appendChild(el('h4', '', '<span>Chats · no project</span>'));
|
|
319
|
+
const loose = DATA.chats.filter((c) => !q || c.title.toLowerCase().includes(q));
|
|
320
|
+
for (const chat of loose) chats.appendChild(leaf(chat, 'chat', ''));
|
|
321
|
+
if (!loose.length) chats.appendChild(el('div', 'empty', 'New chat starts on its own: no folder, no context, nothing to write.'));
|
|
322
|
+
nav.appendChild(chats);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* One sidebar row. A chat leaf drags onto a project node to bind itself to that folder, and
|
|
326
|
+
the same command is on its menu, because a drag is not reachable from a keyboard. */
|
|
327
|
+
function leaf(chat, kind, projectKey) {
|
|
328
|
+
const leaf = el('div', 'leaf' + (chat.id === DATA.current ? ' on' : ''),
|
|
329
|
+
`<i class="dot ${chat.busy ? 'run' : (DOT[chat.state] || '')}"></i><span class="t">${esc(chat.title)}</span>`
|
|
330
|
+
+ `<time>${relTime(chat.updated)}</time>`);
|
|
331
|
+
leaf.onclick = () => send('open', { id: chat.id, kind });
|
|
332
|
+
if (kind === 'chat') {
|
|
333
|
+
leaf.draggable = true;
|
|
334
|
+
leaf.ondragstart = (e) => {
|
|
335
|
+
e.dataTransfer.setData('text/chat', chat.id);
|
|
336
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
337
|
+
leaf.classList.add('dragging');
|
|
338
|
+
};
|
|
339
|
+
leaf.ondragend = () => leaf.classList.remove('dragging');
|
|
340
|
+
const menu = el('button', 'dots', '⋯');
|
|
341
|
+
menu.title = 'Move this chat';
|
|
342
|
+
menu.onclick = (e) => { e.stopPropagation(); chatMenu(chat, projectKey); };
|
|
343
|
+
leaf.appendChild(menu);
|
|
344
|
+
}
|
|
345
|
+
return leaf;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function projectMenu(group) {
|
|
349
|
+
const s = sheet(group.name, group.path);
|
|
350
|
+
const list = el('div', 'content');
|
|
351
|
+
const rows = [
|
|
352
|
+
['Project settings & status…', () => projectDrawer(group)],
|
|
353
|
+
['New chat in this project', () => send('new_chat_in', { project: group.key })],
|
|
354
|
+
['Choose project mark…', () => iconPicker(group)],
|
|
355
|
+
];
|
|
356
|
+
if ((DATA.branch || {}).key === group.key) rows.push(['Leave the project (standalone chat)', () => send('new_chat')]);
|
|
357
|
+
for (const [label, run] of rows) {
|
|
358
|
+
const b = el('button', 'cmd', `<span>${esc(label)}</span><span class="g"></span>`);
|
|
359
|
+
b.onclick = () => { close(); run(); };
|
|
360
|
+
list.appendChild(b);
|
|
361
|
+
}
|
|
362
|
+
s.appendChild(list);
|
|
363
|
+
const close = modal(s);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/* -------------------------------- project drawer ------------------------------- */
|
|
367
|
+
/* One drawer per project: what folder it really is, what it remembers, what the next request
|
|
368
|
+
will cost, and which command it will run. The numbers come from the server because the map is
|
|
369
|
+
measured, not guessed — and measuring it is the one slow part, so it is fetched on open. */
|
|
370
|
+
function drawRow(title, value, used, total) {
|
|
371
|
+
const pct = total > 0 ? Math.min(100, Math.round((used / total) * 100)) : 0;
|
|
372
|
+
return el('div', 'meter', `<span class="t">${esc(title)}</span>`
|
|
373
|
+
+ `<span class="bar"><i style="width:${pct}%"></i></span>`
|
|
374
|
+
+ `<span class="v mono">${esc(value)}</span>`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function projectDrawer(group) {
|
|
378
|
+
const s = sheet('Project · ' + group.name, String(group.path || ''));
|
|
379
|
+
const body = el('div', 'content');
|
|
380
|
+
body.appendChild(el('div', 'quiet', 'Measuring this folder…'));
|
|
381
|
+
s.appendChild(body);
|
|
382
|
+
const close = modal(s);
|
|
383
|
+
let info;
|
|
384
|
+
try {
|
|
385
|
+
info = await api('/api/project?project=' + encodeURIComponent(group.key));
|
|
386
|
+
} catch (err) {
|
|
387
|
+
body.innerHTML = '';
|
|
388
|
+
body.appendChild(el('div', 'warnbox', esc(String(err.message || err))));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const n = (value) => Number(value || 0).toLocaleString('en-US');
|
|
392
|
+
body.innerHTML = '';
|
|
393
|
+
|
|
394
|
+
const root = el('div', 'dsec');
|
|
395
|
+
root.appendChild(el('h6', '', 'Root'));
|
|
396
|
+
root.appendChild(el('div', 'mono pathline', esc(info.path)
|
|
397
|
+
+ (info.exists ? '' : ' (this folder is not on disk right now)')));
|
|
398
|
+
const open = el('button', 'line-btn', 'Open in Explorer');
|
|
399
|
+
open.disabled = !info.exists;
|
|
400
|
+
open.onclick = () => { send('reveal', { project: info.key }); toast('Opened in the file manager'); };
|
|
401
|
+
root.appendChild(open);
|
|
402
|
+
body.appendChild(root);
|
|
403
|
+
|
|
404
|
+
const notes = el('div', 'dsec');
|
|
405
|
+
notes.appendChild(el('h6', '', 'Notes'));
|
|
406
|
+
const area = el('textarea', 'notes');
|
|
407
|
+
area.value = info.notes || '';
|
|
408
|
+
area.rows = 5;
|
|
409
|
+
area.maxLength = info.notes_limit;
|
|
410
|
+
const count = el('div', 'quiet small');
|
|
411
|
+
const show = () => { count.textContent = `${n(area.value.length)} / ${n(info.notes_limit)} chars`; };
|
|
412
|
+
area.oninput = show; show();
|
|
413
|
+
notes.appendChild(area);
|
|
414
|
+
notes.appendChild(count);
|
|
415
|
+
notes.appendChild(el('div', 'quiet small',
|
|
416
|
+
'Saved as ' + esc(info.notes_file) + ' in ' + esc(info.notes_dir)
|
|
417
|
+
+ ' — outside the project, so a proposal can never rewrite it.'));
|
|
418
|
+
const save = el('button', 'solid', 'Save notes');
|
|
419
|
+
save.onclick = async () => {
|
|
420
|
+
await send('save_memory', { text: area.value });
|
|
421
|
+
toast('Project notes saved');
|
|
422
|
+
show();
|
|
423
|
+
};
|
|
424
|
+
notes.appendChild(save);
|
|
425
|
+
body.appendChild(notes);
|
|
426
|
+
|
|
427
|
+
const ctx = el('div', 'dsec');
|
|
428
|
+
const c = info.context;
|
|
429
|
+
ctx.appendChild(el('h6', '', 'Context budget'));
|
|
430
|
+
ctx.appendChild(drawRow('Repository map', n(c.map) + ' chars', c.map, c.budget));
|
|
431
|
+
ctx.appendChild(drawRow('Project notes', n(c.notes) + ' chars', c.notes, c.budget));
|
|
432
|
+
ctx.appendChild(drawRow('This conversation' + (c.bound ? '' : ' (none here)'),
|
|
433
|
+
n(c.turns) + ' chars', c.turns, c.budget));
|
|
434
|
+
ctx.appendChild(drawRow('System prompt', n(c.system) + ' chars', c.system, c.budget));
|
|
435
|
+
ctx.appendChild(drawRow('Remaining', n(c.remaining) + ' chars', c.remaining, c.budget));
|
|
436
|
+
ctx.appendChild(el('div', 'quiet small',
|
|
437
|
+
`${n(c.used)} of ${n(c.budget)} chars ≈ ${n(c.est_tokens)} tokens (chars ÷ 4, estimate)`
|
|
438
|
+
+ ` · ${n(c.files)} files indexed`));
|
|
439
|
+
body.appendChild(ctx);
|
|
440
|
+
|
|
441
|
+
const tool = el('div', 'dsec');
|
|
442
|
+
tool.appendChild(el('h6', '', 'Toolchain'));
|
|
443
|
+
if (!info.toolchain.detected.length) {
|
|
444
|
+
tool.appendChild(el('div', 'quiet', 'No build tool this app knows how to run was detected here.'));
|
|
445
|
+
}
|
|
446
|
+
for (const recipe of info.toolchain.detected) {
|
|
447
|
+
const on = recipe.name === info.toolchain.selected;
|
|
448
|
+
const row = el('button', 'cmd recipe' + (on ? ' on' : ''),
|
|
449
|
+
`<span>${esc(recipe.label)}${on ? ' · selected' : ''}</span><span class="g mono">${esc(recipe.command)}</span>`);
|
|
450
|
+
row.onclick = () => { send('set_recipe', { value: recipe.label }); close(); };
|
|
451
|
+
tool.appendChild(row);
|
|
452
|
+
}
|
|
453
|
+
if (info.toolchain.selected) {
|
|
454
|
+
tool.appendChild(el('div', 'quiet small',
|
|
455
|
+
'Runs with a ' + n(info.toolchain.timeout) + 's limit; success is judged by '
|
|
456
|
+
+ esc(info.toolchain.proof) + '. Provider requests time out at '
|
|
457
|
+
+ n(info.toolchain.request_timeout) + 's.'));
|
|
458
|
+
}
|
|
459
|
+
body.appendChild(tool);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/* The module graph. The server walks the folder and answers with nodes, edges and a column for each
|
|
463
|
+
module, and this only draws what it said: no layout maths, no simulation, no library. The columns are
|
|
464
|
+
build order, so the leftmost box is the first thing that compiles and an arrow points left, at what a
|
|
465
|
+
module needs — which is why the subtitle says it before the reader has to guess it from the shape. */
|
|
466
|
+
function graphSvg(data) {
|
|
467
|
+
const COL = 186, ROW = 58, TOP = 26, LEFT = 14, BOX = 150, BOXH = 38;
|
|
468
|
+
const columns = {};
|
|
469
|
+
let deepest = 1;
|
|
470
|
+
(data.nodes || []).forEach((node) => {
|
|
471
|
+
const key = Number(node.column || 0);
|
|
472
|
+
(columns[key] = columns[key] || []).push(node);
|
|
473
|
+
if (key + 1 > deepest) deepest = key + 1;
|
|
474
|
+
});
|
|
475
|
+
const rows = Math.max(1, ...Object.values(columns).map((list) => list.length));
|
|
476
|
+
const place = {};
|
|
477
|
+
Object.keys(columns).forEach((key) => {
|
|
478
|
+
columns[key].forEach((node, index) => {
|
|
479
|
+
place[node.name] = { x: LEFT + Number(key) * COL, y: TOP + index * ROW };
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
const width = LEFT + (deepest - 1) * COL + BOX + LEFT;
|
|
483
|
+
const height = TOP + rows * ROW + 10;
|
|
484
|
+
const clip = (name) => (name.length > 19 ? name.slice(0, 18) + '…' : name);
|
|
485
|
+
const lines = (data.edges || []).map((edge) => {
|
|
486
|
+
const from = place[edge.from], to = place[edge.to];
|
|
487
|
+
if (!from || !to) return '';
|
|
488
|
+
const x1 = from.x, y1 = from.y + BOXH / 2, x2 = to.x + BOX, y2 = to.y + BOXH / 2;
|
|
489
|
+
const lean = Math.max(28, (x1 - x2) / 2);
|
|
490
|
+
const heavy = Math.min(5, 1 + Number(edge.count || 1) * 0.7);
|
|
491
|
+
return `<path class="gedge" marker-end="url(#ghead)" stroke-width="${heavy.toFixed(1)}" `
|
|
492
|
+
+ `d="M${x1} ${y1} C${x1 - lean} ${y1}, ${x2 + lean} ${y2}, ${x2} ${y2}"><title>`
|
|
493
|
+
+ `${esc(edge.from)} → ${esc(edge.to)} (${esc(String(edge.count))} files)</title></path>`;
|
|
494
|
+
}).join('');
|
|
495
|
+
const boxes = (data.nodes || []).map((node) => {
|
|
496
|
+
const at = place[node.name];
|
|
497
|
+
if (!at) return '';
|
|
498
|
+
return `<g class="gnode"><rect x="${at.x}" y="${at.y}" width="${BOX}" height="${BOXH}" rx="7">`
|
|
499
|
+
+ `<title>${esc(node.name)} — ${esc(String(node.files))} indexed file(s)</title></rect>`
|
|
500
|
+
+ `<text x="${at.x + 9}" y="${at.y + 23}">${esc(clip(String(node.name)))}</text></g>`;
|
|
501
|
+
}).join('');
|
|
502
|
+
return `<svg class="graph" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" `
|
|
503
|
+
+ `role="img" aria-label="${esc(data.caption || 'Module dependency graph')}">`
|
|
504
|
+
+ `<defs><marker id="ghead" viewBox="0 0 9 9" refX="8" refY="4.5" markerWidth="7" `
|
|
505
|
+
+ `markerHeight="7" orient="auto-start-reverse"><path class="ghead" d="M0 0 L9 4.5 L0 9 z"/>`
|
|
506
|
+
+ `</marker></defs>${lines}${boxes}</svg>`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function graphSheet() {
|
|
510
|
+
const s = sheet('Dependency graph', 'Left builds first · an arrow points at what a module needs');
|
|
511
|
+
s.classList.add('wide');
|
|
512
|
+
const body = el('div', 'content');
|
|
513
|
+
body.appendChild(el('div', 'quiet', 'Mapping this folder…'));
|
|
514
|
+
s.appendChild(body);
|
|
515
|
+
modal(s);
|
|
516
|
+
let data;
|
|
517
|
+
try {
|
|
518
|
+
const reply = await api('/api/action', { type: 'show_graph' });
|
|
519
|
+
data = reply && reply.result;
|
|
520
|
+
if (reply && reply.state) render(reply.state);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
body.innerHTML = '';
|
|
523
|
+
body.appendChild(el('div', 'warnbox', esc(String(err.message || err))));
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
body.innerHTML = '';
|
|
527
|
+
if (!data || !(data.nodes || []).length) {
|
|
528
|
+
/* The server always answers in words when there is nothing to draw; this is the click that must not
|
|
529
|
+
open an empty box and leave the reader wondering whether the project has no structure. */
|
|
530
|
+
body.appendChild(el('div', 'quiet', esc((data || {}).note || 'The window answered with no graph.')));
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
body.appendChild(el('div', 'quiet', esc(data.caption || '')));
|
|
534
|
+
body.appendChild(el('div', 'gscroll', graphSvg(data)));
|
|
535
|
+
const list = el('div', 'gmods');
|
|
536
|
+
(data.nodes || []).forEach((node) => list.appendChild(el('div', 'gmod',
|
|
537
|
+
`<b>${esc(String(node.name))}</b> <span class="quiet mono">`
|
|
538
|
+
+ `${esc(String(node.files))} file(s) · column ${esc(String(Number(node.column || 0) + 1))}</span>`)));
|
|
539
|
+
body.appendChild(list);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/* The palette is served by the controller, so a mark the app does not know about cannot be
|
|
543
|
+
painted into the sidebar no matter what the registry file contains. */
|
|
544
|
+
function iconPicker(group) {
|
|
545
|
+
const s = sheet('Project mark', group.name);
|
|
546
|
+
const grid = el('div', 'iconpick');
|
|
547
|
+
for (const icon of (DATA.icons || [])) {
|
|
548
|
+
const b = el('button', 'icon' + (icon === group.icon ? ' on' : ''), esc(icon));
|
|
549
|
+
b.onclick = () => { close(); send('set_icon', { project: group.key, value: icon }); };
|
|
550
|
+
grid.appendChild(b);
|
|
551
|
+
}
|
|
552
|
+
const body = el('div', 'content');
|
|
553
|
+
body.appendChild(grid);
|
|
554
|
+
s.appendChild(body);
|
|
555
|
+
const close = modal(s);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function chatMenu(chat, projectKey) {
|
|
559
|
+
const s = sheet('Move this chat', chat.title);
|
|
560
|
+
const list = el('div', 'content');
|
|
561
|
+
const rows = [];
|
|
562
|
+
for (const group of DATA.projects) {
|
|
563
|
+
if (group.key === projectKey) continue;
|
|
564
|
+
rows.push([group.name, () => send('bind_chat', { chat: chat.id, project: group.key })]);
|
|
565
|
+
}
|
|
566
|
+
if (projectKey) rows.push(['Leave the project (standalone chat)', () => send('bind_chat', { chat: chat.id, project: '' })]);
|
|
567
|
+
if (!rows.length) list.appendChild(el('div', 'empty', 'No other project to move it into.'));
|
|
568
|
+
for (const [label, run] of rows) {
|
|
569
|
+
const b = el('button', 'cmd', `<span>${esc(label)}</span><span class="g">reads its context</span>`);
|
|
570
|
+
b.onclick = () => { close(); run(); };
|
|
571
|
+
list.appendChild(b);
|
|
572
|
+
}
|
|
573
|
+
s.appendChild(list);
|
|
574
|
+
const close = modal(s);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/* A project mark keeps the same colour for the same folder name. The lightness is pinned to one
|
|
578
|
+
end of the scale on purpose: at 46% a name that happens to hash to a yellow hue reaches only
|
|
579
|
+
2.5:1 under white and 4.3:1 under near-black, so no ink is readable at all. At these two
|
|
580
|
+
lightness values every one of the 360 hues clears AA against the ink returned with it. */
|
|
581
|
+
function avatar(name) {
|
|
582
|
+
let h = 0; for (const ch of String(name)) h = (h * 31 + ch.charCodeAt(0)) % 360;
|
|
583
|
+
return document.documentElement.dataset.theme === 'dark'
|
|
584
|
+
? { bg: `hsl(${h} 42% 32%)`, ink: '#ffffff' }
|
|
585
|
+
: { bg: `hsl(${h} 42% 72%)`, ink: '#171612' };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function renderHeader() {
|
|
589
|
+
$('title').textContent = DATA.header.title || 'New chat';
|
|
590
|
+
$('subtitle').textContent = DATA.header.subtitle || '';
|
|
591
|
+
$('attach-top').classList.toggle('hidden', !DATA.project);
|
|
592
|
+
// The snapshot carries the activity line because the server keeps it current: a status written
|
|
593
|
+
// mid-run has to survive the next push, which is exactly what used to wipe it.
|
|
594
|
+
state.status = DATA.status || '';
|
|
595
|
+
paintStatus();
|
|
596
|
+
markTabs(state.view);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/* The activity strip under the header. It used to be a 2px shimmer with no words in it while the
|
|
600
|
+
live progress line was written into the header subtitle instead, so the next state push erased a
|
|
601
|
+
status that was still true and the header read "Connecting to the model…" where it should name
|
|
602
|
+
the folder and the step. */
|
|
603
|
+
function paintStatus() {
|
|
604
|
+
const bar = $('busybar');
|
|
605
|
+
const line = state.status || '';
|
|
606
|
+
bar.textContent = line;
|
|
607
|
+
bar.dir = ARABIC_RUN.test(line) ? 'rtl' : 'auto';
|
|
608
|
+
bar.classList.toggle('busy', !!state.busy);
|
|
609
|
+
bar.classList.toggle('hidden', !line && !state.busy);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/* A block that opens by naming a file — "# path: src/main.py", "// calculator.js",
|
|
613
|
+
"<!-- notes.md -->", or the bare name on its own first line — is the model handing over a
|
|
614
|
+
whole file rather than a snippet. Only a name with an extension counts, and only alone on
|
|
615
|
+
that line, so a comment that happens to mention src/old.py stays a comment. */
|
|
616
|
+
const BLOCK_LABEL = /^(?:path|file|filename)\s*[:=]\s*(.+)$/i;
|
|
617
|
+
function blockTarget(code) {
|
|
618
|
+
let head = String(code).replace(/^\s+/, '').split('\n')[0] || '';
|
|
619
|
+
head = head.replace(/^(?:#+|\/\/+|<!--|--+|%%?|;+|!+|\*\*+)\s*/, '')
|
|
620
|
+
.replace(/\s*(?:-->|\*\*+)$/, '').trim();
|
|
621
|
+
const named = BLOCK_LABEL.exec(head);
|
|
622
|
+
const raw = (named ? named[1] : head).trim().replace(/^["'`]+|["'`]+$/g, '');
|
|
623
|
+
if (!raw || raw.length > 200 || /\s/.test(raw)) return '';
|
|
624
|
+
if (raw.startsWith('/') || raw.includes('\\') || /^[a-zA-Z]:/.test(raw) || raw.includes('..')) return '';
|
|
625
|
+
return /\.[A-Za-z][A-Za-z0-9]{0,7}$/.test(raw) ? raw : '';
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/* The header line is a label, not content: it comes off before the block becomes a file,
|
|
629
|
+
which is what a person pasting the block by hand would do too. */
|
|
630
|
+
function blockBody(text, target) {
|
|
631
|
+
const body = String(text).replace(/ /g, ' ');
|
|
632
|
+
if (blockTarget(body) !== target) return body;
|
|
633
|
+
const cut = body.indexOf('\n');
|
|
634
|
+
return cut < 0 ? '' : body.slice(cut + 1);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function mdToHtml(text) {
|
|
638
|
+
const parts = String(text).split('```');
|
|
639
|
+
let out = '';
|
|
640
|
+
parts.forEach((chunk, i) => {
|
|
641
|
+
if (i % 2 === 1) {
|
|
642
|
+
const nl = chunk.indexOf('\n');
|
|
643
|
+
const lang = nl > 0 ? chunk.slice(0, nl).trim() : '';
|
|
644
|
+
const code = nl > 0 ? chunk.slice(nl + 1) : chunk;
|
|
645
|
+
const target = blockTarget(code);
|
|
646
|
+
/* The button writes nothing. It opens the same WAITING_APPROVAL proposal a model's
|
|
647
|
+
proposal opens, so Apply, the SHA-256 guard and rollback stay the only road to the
|
|
648
|
+
disk — and it appears only when this branch actually has a folder to write in. */
|
|
649
|
+
const apply = target && DATA.project
|
|
650
|
+
? `<span class="fname" title="${esc(target)}">${esc(target)}</span>` +
|
|
651
|
+
`<button data-block="${esc(target)}" title="Propose writing this block to ${esc(target)}. You review the diff before anything is written.">` +
|
|
652
|
+
ICON.spark + ' Apply to File</button>'
|
|
653
|
+
: '';
|
|
654
|
+
out += `<div class="code"><div class="bar"><b>${esc(lang || 'code')}</b>${apply}<button data-copy>Copy</button></div><pre>${hl(code.replace(/\n$/, ''))}</pre></div>`;
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
out += inline(chunk);
|
|
658
|
+
});
|
|
659
|
+
return out;
|
|
660
|
+
}
|
|
661
|
+
function inline(chunk) {
|
|
662
|
+
return esc(chunk).trim().split(/\n{2,}/).map((block) => {
|
|
663
|
+
const lines = block.split('\n');
|
|
664
|
+
if (/^\s*[-*]\s+/.test(lines[0]) || /^\s*\d+\.\s+/.test(lines[0])) {
|
|
665
|
+
const tag = /^\s*\d+\./.test(lines[0]) ? 'ol' : 'ul';
|
|
666
|
+
return `<${tag}>` + lines.filter(Boolean).map((l) => `<li>${rich(l.replace(/^\s*(?:[-*]|\d+\.)\s+/, ''))}</li>`).join('') + `</${tag}>`;
|
|
667
|
+
}
|
|
668
|
+
if (/^#{1,6}\s/.test(lines[0])) return `<h3>${rich(lines[0].replace(/^#{1,6}\s+/, ''))}</h3>`;
|
|
669
|
+
return `<p>${rich(block)}</p>`;
|
|
670
|
+
}).join('');
|
|
671
|
+
}
|
|
672
|
+
const rich = (s) => s
|
|
673
|
+
.replace(/`([^`]+)`/g, '<code class="inl">$1</code>')
|
|
674
|
+
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
|
|
675
|
+
.replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1<i>$2</i>');
|
|
676
|
+
const KEYWORDS = /(?:public|private|protected|class|interface|return|if|else|throw|new|import|package|void|static|final|def|self|None|True|False|for|while|in|not|and|or|const|let|async|await|function|try|except|catch)/;
|
|
677
|
+
/* One pass: highlighting the result of a previous replace() would re-scan the markup
|
|
678
|
+
we just inserted, and `class` is itself a keyword. */
|
|
679
|
+
const TOKENS = new RegExp('([^\\S\\n]*\\/\\/[^\\n]*|#[^\\n]*)|("[^&\\n]*?"|'[^&\\n]*?')|\\b(' + KEYWORDS.source + ')\\b', 'g');
|
|
680
|
+
function hl(code) {
|
|
681
|
+
return esc(code).replace(TOKENS, (all, comment, str, kw) =>
|
|
682
|
+
comment ? `<span class="tok-c">${comment}</span>`
|
|
683
|
+
: str ? `<span class="tok-s">${str}</span>`
|
|
684
|
+
: `<span class="tok-k">${kw}</span>`);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/* A tool row leads with its author label — "Tool", "Changes" — which is a strong LTR run, so
|
|
688
|
+
dir="auto" on the row resolves to LTR even when the notice after it is Arabic. The row's own
|
|
689
|
+
text has to decide, from the same code-point range labels.py tests server-side. */
|
|
690
|
+
const ARABIC_RUN = /[-ۿ]/;
|
|
691
|
+
|
|
692
|
+
function renderThread() {
|
|
693
|
+
const thread = $('thread'); thread.innerHTML = '';
|
|
694
|
+
const stick = $('scroller').scrollTop + $('scroller').clientHeight >= $('scroller').scrollHeight - 80;
|
|
695
|
+
/* The chips belong under the last tool note, because that is the message that says "this
|
|
696
|
+
happened" — and the file list itself comes from the live review block rather than from the
|
|
697
|
+
message, since only the session carries paths the user can still click on. */
|
|
698
|
+
const files = (DATA.review || {}).files || [];
|
|
699
|
+
const chipAt = files.length ? DATA.messages.reduce((last, m, i) => (m.role === 'tool' ? i : last), -1) : -1;
|
|
700
|
+
// Only the newest step row is "the running one", and only while a job is live.
|
|
701
|
+
const lastStep = DATA.messages.reduce((last, m, i) => (m.step ? i : last), -1);
|
|
702
|
+
DATA.messages.forEach((m, i) => {
|
|
703
|
+
const msg = el('div', 'msg ' + (m.role === 'user' ? 'me' : m.role === 'tool' ? 'sys' : ''));
|
|
704
|
+
const pic = el('div', 'pic', m.role === 'user' ? 'Y' : m.role === 'tool' ? '⚙' : 'A');
|
|
705
|
+
const body = el('div', 'body');
|
|
706
|
+
if (m.step) {
|
|
707
|
+
body.appendChild(stepRow(m, i === lastStep));
|
|
708
|
+
} else if (m.role === 'tool') {
|
|
709
|
+
const note = el('div', '', `<span class="tool"><b>${esc(m.author)}</b> ${esc(m.text)}</span>`);
|
|
710
|
+
note.dir = ARABIC_RUN.test(m.text) ? 'rtl' : 'auto';
|
|
711
|
+
body.appendChild(note);
|
|
712
|
+
} else {
|
|
713
|
+
if (m.role === 'assistant') body.appendChild(el('div', 'who', `${esc(m.author)} · ${esc(m.time || '')}`));
|
|
714
|
+
// dir="auto" lets the browser choose from the first strong character, so an Arabic answer
|
|
715
|
+
// reads right-to-left while an English one is untouched — no per-message detection in JS.
|
|
716
|
+
const bub = el('div', 'bub', mdToHtml(m.text));
|
|
717
|
+
bub.dir = 'auto';
|
|
718
|
+
body.appendChild(bub);
|
|
719
|
+
}
|
|
720
|
+
if (i === chipAt) body.appendChild(chipCard(DATA.review));
|
|
721
|
+
/* Copy belongs to what the server produced — the answer and the notices — and not to the
|
|
722
|
+
user's own row, which is text they already have. It sits beside the bubble rather than in
|
|
723
|
+
it because appendToken rewrites the bubble's innerHTML on every streamed chunk. */
|
|
724
|
+
if (m.role !== 'user' && (m.text || '').trim()) body.appendChild(msgActions(i));
|
|
725
|
+
msg.append(pic, body); thread.appendChild(msg);
|
|
726
|
+
});
|
|
727
|
+
if (DATA.pending || STREAM) {
|
|
728
|
+
const msg = el('div', 'msg');
|
|
729
|
+
/* While an answer is arriving it is plain text in here, and markdown appears when the finished
|
|
730
|
+
message lands: reparsing the whole reply per line would repaint on every one of them. The
|
|
731
|
+
bubble is drawn from the text that has arrived when there is any, and from the waiting line
|
|
732
|
+
before there is — which is what lets a stream show an answer without claiming a job is running. */
|
|
733
|
+
msg.innerHTML = `<div class="pic">A</div><div class="body"><div class="bub" dir="auto">`
|
|
734
|
+
+ `<span class="typing"><i></i><i></i><i></i> `
|
|
735
|
+
+ `<span class="typing-line">${esc(STREAM || DATA.pending)}</span></span></div></div>`;
|
|
736
|
+
thread.appendChild(msg);
|
|
737
|
+
}
|
|
738
|
+
thread.querySelectorAll('[data-copy]').forEach((b) => {
|
|
739
|
+
b.onclick = () => { navigator.clipboard.writeText(b.closest('.code').querySelector('pre').innerText); toast('Copied to clipboard'); };
|
|
740
|
+
});
|
|
741
|
+
/* Wired here rather than inside mdToHtml: appendToken repaints the streaming bubble's
|
|
742
|
+
innerHTML on every chunk, and a handler attached there would go with it. */
|
|
743
|
+
thread.querySelectorAll('[data-block]').forEach((b) => {
|
|
744
|
+
b.onclick = () => {
|
|
745
|
+
if (DATA.busy) { toast('Wait for the running task, then apply that block'); return; }
|
|
746
|
+
const code = blockBody(b.closest('.code').querySelector('pre').innerText, b.dataset.block);
|
|
747
|
+
if (!code.trim()) { toast('That block has nothing left to write'); return; }
|
|
748
|
+
send('apply_block', { path: b.dataset.block, content: code });
|
|
749
|
+
toast('Proposing ' + b.dataset.block + ' — review the diff, then Apply');
|
|
750
|
+
};
|
|
751
|
+
});
|
|
752
|
+
if (stick) $('scroller').scrollTop = $('scroller').scrollHeight;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/* The answer that is arriving right now, whole lines from the server, kept out of DATA.messages for
|
|
756
|
+
the same reason LIVE is: a snapshot goes out on every other event, and a half-finished reply must
|
|
757
|
+
not be stored, hashed or exported as if it were one. Cleared when the next job starts. */
|
|
758
|
+
let STREAM = '';
|
|
759
|
+
const STREAM_MAX = 20000;
|
|
760
|
+
function appendToken(text) {
|
|
761
|
+
STREAM = (STREAM ? STREAM + '\n' + text : text).slice(-STREAM_MAX);
|
|
762
|
+
if (!document.querySelector('.typing-line')) {
|
|
763
|
+
renderThread(); // the first chunk has to create its own bubble
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
const line = document.querySelector('.typing-line');
|
|
767
|
+
line.textContent = STREAM;
|
|
768
|
+
$('scroller').scrollTop = $('scroller').scrollHeight;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function msgActions(index) {
|
|
772
|
+
const row = el('div', 'macts');
|
|
773
|
+
const copy = el('button', 'mact', ICON.copy);
|
|
774
|
+
copy.dataset.copyRow = index;
|
|
775
|
+
copy.title = 'Copy this message';
|
|
776
|
+
copy.setAttribute('aria-label', 'Copy this message');
|
|
777
|
+
row.appendChild(copy);
|
|
778
|
+
return row;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/* One thing the agent did, as a row that can be opened. The sentence and its glyph are the server's
|
|
782
|
+
(`labels.step_line`), and so is the answer to "is there anything behind this one" — `step.detail`,
|
|
783
|
+
because a chevron that opens onto nothing teaches the reader to stop opening rows. The running row
|
|
784
|
+
is the one exception the client decides for itself: while a command is going, its detail is the
|
|
785
|
+
output streaming in, which no stored record holds yet. */
|
|
786
|
+
function stepRow(m, isLast) {
|
|
787
|
+
const step = m.step;
|
|
788
|
+
const live = !!(DATA.busy && isLast && step.action === 'executing');
|
|
789
|
+
const open = state.openStep === step.id;
|
|
790
|
+
const row = el('div', 'steprow' + (open ? ' open' : '') + (live ? ' live' : ''));
|
|
791
|
+
const head = el('button', 'st-head',
|
|
792
|
+
`<span class="st-txt">${esc(m.text)}</span>`
|
|
793
|
+
+ ((open || live || step.detail) ? `<span class="st-chev">${ICON.chev}</span>` : ''));
|
|
794
|
+
head.dir = ARABIC_RUN.test(m.text) ? 'rtl' : 'auto';
|
|
795
|
+
head.title = (open || live || step.detail)
|
|
796
|
+
? 'What this step has behind it' : '';
|
|
797
|
+
head.onclick = () => toggleStep(step.id, live);
|
|
798
|
+
row.appendChild(head);
|
|
799
|
+
if (open) row.appendChild(stepBody(step, live));
|
|
800
|
+
return row;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function toggleStep(id, live) {
|
|
804
|
+
if (state.openStep === id) {
|
|
805
|
+
state.openStep = '';
|
|
806
|
+
if (!live) sendQuiet('step_detail', { id: '' }); // the server stops carrying the block
|
|
807
|
+
renderThread();
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
state.openStep = id;
|
|
811
|
+
// A running row needs no round trip, and asking would answer "nothing stored yet" about a command
|
|
812
|
+
// that is visibly still printing.
|
|
813
|
+
if (live) renderThread(); else send('step_detail', { id });
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function stepBody(step, live) {
|
|
817
|
+
const box = el('div', 'st-body');
|
|
818
|
+
if (live) {
|
|
819
|
+
const out = el('div', 'st-out');
|
|
820
|
+
out.innerHTML = LIVE.map((entry) => `<span>${esc(entry.text)}</span>`).join('');
|
|
821
|
+
box.appendChild(out);
|
|
822
|
+
return box;
|
|
823
|
+
}
|
|
824
|
+
const detail = (DATA.step_detail && DATA.step_detail.id === step.id) ? DATA.step_detail : null;
|
|
825
|
+
if (!detail) return box; // the reply has not landed yet
|
|
826
|
+
if (detail.note) { box.appendChild(el('div', 'st-note', esc(detail.note))); return box; }
|
|
827
|
+
for (const [title, lines] of (detail.sections || [])) {
|
|
828
|
+
const section = el('div', 'st-sec');
|
|
829
|
+
section.appendChild(el('h6', '', esc(title)));
|
|
830
|
+
const out = el('div', 'st-out');
|
|
831
|
+
(lines || []).forEach((line) => out.appendChild(el('span', '', esc(line))));
|
|
832
|
+
section.appendChild(out);
|
|
833
|
+
box.appendChild(section);
|
|
834
|
+
}
|
|
835
|
+
const paths = detail.files || [];
|
|
836
|
+
if (paths.length) {
|
|
837
|
+
const list = el('div', 'st-files');
|
|
838
|
+
paths.forEach((path) => list.appendChild(stepFileRow(path)));
|
|
839
|
+
box.appendChild(list);
|
|
840
|
+
}
|
|
841
|
+
return box;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/* A file named by a step is the same control as a chip: it opens the side viewer, because reading a
|
|
845
|
+
change has one door in this window now. */
|
|
846
|
+
function stepFileRow(path) {
|
|
847
|
+
const known = ((DATA.review || {}).files || []).find((f) => f.path === path);
|
|
848
|
+
const row = el('button', 'rfile', (known ? kindTag(known.kind) : '') + `<code>${esc(path)}</code>`);
|
|
849
|
+
row.onclick = () => {
|
|
850
|
+
const at = ((DATA.review || {}).files || []).findIndex((f) => f.path === path);
|
|
851
|
+
if (at < 0) { toast('That file is no longer in the change set.', 'bad'); return; }
|
|
852
|
+
openFile(at);
|
|
853
|
+
};
|
|
854
|
+
return row;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/* One listener for the whole thread, attached once: renderThread rebuilds every row on each state
|
|
858
|
+
push, and a handler bound to the button itself would be rebuilt with it. The index is read from
|
|
859
|
+
the dataset and resolved against DATA.messages, so what lands on the clipboard is the message
|
|
860
|
+
text the server wrote — never the rendered markup, and never a stale row. */
|
|
861
|
+
$('thread').addEventListener('click', (event) => {
|
|
862
|
+
const hit = event.target.closest('[data-copy-row]');
|
|
863
|
+
if (!hit) return;
|
|
864
|
+
const message = DATA.messages[Number(hit.dataset.copyRow)];
|
|
865
|
+
if (!message) return;
|
|
866
|
+
navigator.clipboard.writeText(message.text || '');
|
|
867
|
+
toast('Copied to clipboard');
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
/* The last thing you sent, back in the box. A model that stalls or answers nonsense is answered by
|
|
871
|
+
asking again, and at temperature 0 "again" means the same bytes — so this refills rather than
|
|
872
|
+
fires: nothing leaves without your Enter, and a half-typed message is never overwritten. */
|
|
873
|
+
function lastAsked() {
|
|
874
|
+
for (let i = DATA.messages.length - 1; i >= 0; i--) {
|
|
875
|
+
const m = DATA.messages[i];
|
|
876
|
+
if (m.role === 'user' && (m.text || '').trim()) return m.text;
|
|
877
|
+
}
|
|
878
|
+
return '';
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function askAgain() {
|
|
882
|
+
const ta = $('prompt');
|
|
883
|
+
const text = lastAsked();
|
|
884
|
+
// No refusal while a task runs: the composer stayed typable through the whole run once the queue
|
|
885
|
+
// landed, so pressing Enter after this refill queues the repeat instead of being swallowed.
|
|
886
|
+
if (!text) { toast('Nothing has been sent in this chat yet', 'warn'); return; }
|
|
887
|
+
if (ta.value.trim() && ta.value.trim() !== text.trim()) {
|
|
888
|
+
toast('Your message is still unsent — send it, or clear the box first', 'warn');
|
|
889
|
+
ta.focus();
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
ta.value = text;
|
|
893
|
+
autosize();
|
|
894
|
+
// The server keeps the unsent text to warn before a project switch discards it, so the refill
|
|
895
|
+
// has to be the draft too — and marked as ours, or the echo guard would drop it.
|
|
896
|
+
state.lastDraftSent = text;
|
|
897
|
+
sendQuiet('set_draft', { text });
|
|
898
|
+
ta.focus();
|
|
899
|
+
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
900
|
+
toast('The last message is back in the box — press Send to ask again');
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function renderComposer() {
|
|
904
|
+
const bar = $('cbar'); bar.innerHTML = '';
|
|
905
|
+
const add = (node) => bar.appendChild(node);
|
|
906
|
+
add(modeBadge());
|
|
907
|
+
if (DATA.project) {
|
|
908
|
+
const bound = !!(DATA.branch || {}).bound;
|
|
909
|
+
/* Which folder this branch is on, always visible where the message is typed: the sidebar can
|
|
910
|
+
be collapsed, and a name alone does not tell two same-named projects apart. */
|
|
911
|
+
const where = el('button', 'pill path mono', ICON.file + esc(DATA.project.path));
|
|
912
|
+
where.title = DATA.project.path + '\nClick to copy the path';
|
|
913
|
+
where.onclick = () => { navigator.clipboard.writeText(DATA.project.path); toast('Project folder copied'); };
|
|
914
|
+
add(where);
|
|
915
|
+
const git = gitChip();
|
|
916
|
+
if (git) add(git);
|
|
917
|
+
const restore = restorePill();
|
|
918
|
+
if (restore) add(restore);
|
|
919
|
+
/* A chat that was dropped onto a folder reads it and nothing more: the server refuses both the
|
|
920
|
+
mode and the switch there, so drawing them as live controls would be a broken affordance. */
|
|
921
|
+
if (!bound) add(autoPill());
|
|
922
|
+
}
|
|
923
|
+
add(el('button', 'pill', ICON.plus + ' Plan')).onclick = () => send('pick_plan');
|
|
924
|
+
if (DATA.plan) {
|
|
925
|
+
const p = el('span', 'pill on', '📄 ' + esc(DATA.plan.name) + (DATA.plan.step ? ` · step ${DATA.plan.step}/${DATA.plan.total}` : '') + '<span class="x">×</span>');
|
|
926
|
+
p.onclick = () => send('clear_plan'); add(p);
|
|
927
|
+
}
|
|
928
|
+
add(el('button', 'pill soft', esc(DATA.provider.mode))).onclick = () => choose('mode', DATA.provider.mode, DATA.provider.modes);
|
|
929
|
+
add(el('button', 'pill mono', esc(shortModel(DATA.provider.model)) + ICON.chev)).onclick = () => choose('model', DATA.provider.model, DATA.provider.models);
|
|
930
|
+
add(el('button', 'pill', ICON.gear + ' Settings')).onclick = openSettings;
|
|
931
|
+
if (lastAsked()) {
|
|
932
|
+
const again = el('button', 'pill soft', '↻ Again');
|
|
933
|
+
again.title = 'Put your last message back in the box. It does not send — press Send when you want it asked again.';
|
|
934
|
+
again.onclick = askAgain;
|
|
935
|
+
add(again);
|
|
936
|
+
}
|
|
937
|
+
if (DATA.busy && DATA.cancellable) {
|
|
938
|
+
/* Stop shares the `.send` look but not the `.send` handle: while a task runs it is the first
|
|
939
|
+
button of that class in the bar, so anything that finds the composer's button by class —
|
|
940
|
+
a test, a macro, an assistive tool — presses Stop and cancels a model turn. */
|
|
941
|
+
const s = el('button', 'send stop', 'Stop');
|
|
942
|
+
s.id = 'stop';
|
|
943
|
+
s.onclick = () => send('stop'); add(s);
|
|
944
|
+
}
|
|
945
|
+
const sendBtn = el('button', 'send', (DATA.busy ? 'Queue ' : 'Send ') + ICON.up);
|
|
946
|
+
sendBtn.id = 'send';
|
|
947
|
+
sendBtn.title = DATA.busy
|
|
948
|
+
? 'A task is running — this adds your message to the queue and it starts when that task ends'
|
|
949
|
+
: 'Send';
|
|
950
|
+
sendBtn.onclick = submit; add(sendBtn);
|
|
951
|
+
const ta = $('prompt');
|
|
952
|
+
/* Typable during a run, because typing is how the next message gets written; Send is the button
|
|
953
|
+
that changes meaning, not the box. */
|
|
954
|
+
ta.disabled = false;
|
|
955
|
+
// modeBadge() has already chosen the normal placeholder for this mode; a run overrides it.
|
|
956
|
+
if (DATA.busy) ta.placeholder = 'Ask the next thing — it queues until this task ends.';
|
|
957
|
+
$('composer').classList.toggle('busy', !!DATA.busy);
|
|
958
|
+
}
|
|
959
|
+
const shortModel = (m) => (m || 'no model').replace(/^.*?\//, '').slice(0, 30);
|
|
960
|
+
|
|
961
|
+
/* git is decoration, so it stays quiet rather than guessing: no chip for a folder that is
|
|
962
|
+
not a repository, and no claim of "clean" when the count never came back.
|
|
963
|
+
It is still a button, because it is the only way to start a task on its own branch: the click
|
|
964
|
+
asks git to create `agent/task-…` and move HEAD onto it, and the server confirms the name
|
|
965
|
+
before anything moves. Once this window has moved HEAD, the chip is the way back. */
|
|
966
|
+
function gitChip() {
|
|
967
|
+
const g = DATA.git || {};
|
|
968
|
+
if (!g.repo) return null;
|
|
969
|
+
const ref = g.branch || (g.detached ? String(g.head || '').slice(0, 7) : '');
|
|
970
|
+
if (!ref) return null;
|
|
971
|
+
const counted = typeof g.dirty === 'number';
|
|
972
|
+
const back = g.base && g.base !== ref ? g.base : '';
|
|
973
|
+
/* A task branch carries the task's own words, so its tail is what tells two of them apart. The
|
|
974
|
+
whole name stays in the title: an untrimmed 44-character pill measured wider than every other
|
|
975
|
+
control on its line of the composer. */
|
|
976
|
+
const shown = ref.length > 22 ? '\u2026' + ref.slice(-21) : ref;
|
|
977
|
+
const chip = el('button', 'pill git mono ' + (!counted ? 'soft' : (g.dirty ? 'dirty' : 'clean')));
|
|
978
|
+
chip.innerHTML = ICON.branch + '<span>' + esc(shown + (back ? ' \u21a9' : '')) + '</span>' +
|
|
979
|
+
(counted && g.dirty ? '<b>' + g.dirty + '</b>' : '');
|
|
980
|
+
chip.title = (back ? 'Back to ' + back + ' — this task started there\nOn ' + ref
|
|
981
|
+
: (g.detached ? 'Detached HEAD at ' : 'Branch ') + ref) + (counted
|
|
982
|
+
? (g.dirty ? ' \u00b7 ' + g.dirty + ' file(s) changed since the last commit'
|
|
983
|
+
: ' \u00b7 nothing changed since the last commit')
|
|
984
|
+
: ' \u00b7 git did not answer in time') +
|
|
985
|
+
(back || g.detached ? '' : '\nClick to put this task on its own git branch.');
|
|
986
|
+
chip.onclick = () => send('git_branch', { back: back });
|
|
987
|
+
return chip;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/* The escalation, not a second undo: the server offers this only after the session's own rollback
|
|
991
|
+
refused because something edited the files afterwards, and only for the files that proposal wrote.
|
|
992
|
+
It is drawn apart from the chip because it destroys current work rather than moving a pointer. */
|
|
993
|
+
function restorePill() {
|
|
994
|
+
const offer = (DATA.git || {}).restore;
|
|
995
|
+
if (!offer || !offer.paths) return null;
|
|
996
|
+
const commit = String(offer.commit || '');
|
|
997
|
+
const b = el('button', 'pill restore', '\u26a0 ' + offer.paths + ' \u00b7 git ' + esc(commit));
|
|
998
|
+
b.title = 'Restore those file(s) from commit ' + commit + '. This replaces what is in them now,'
|
|
999
|
+
+ ' including any edit made after the task wrote them.';
|
|
1000
|
+
b.onclick = () => send('git_restore');
|
|
1001
|
+
return b;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/* The badge is the only thing that says what Send will do. A project branch opens in chat
|
|
1005
|
+
mode on purpose, so a greeting is answered instead of turned into a rejected diff. */
|
|
1006
|
+
/* Auto-Apply is the one switch that writes without asking, so it is drawn where the message is
|
|
1007
|
+
typed, named for what it does, and reads as the amber "this needs your attention" pair rather
|
|
1008
|
+
than as a decoration. It belongs to the folder in front, not to the window. */
|
|
1009
|
+
function autoPill() {
|
|
1010
|
+
const on = !!(DATA.settings && DATA.settings.auto_apply);
|
|
1011
|
+
const b = el('button', 'pill auto ' + (on ? 'on' : 'off'));
|
|
1012
|
+
b.innerHTML = ICON.spark + ' Auto-Apply: ' + (on ? 'ON' : 'OFF');
|
|
1013
|
+
b.title = on
|
|
1014
|
+
? 'ON for this folder: what the model proposes writes itself, then the project command runs. ' +
|
|
1015
|
+
'It still asks before emptying a file you wrote, and a block you clicked still waits for ' +
|
|
1016
|
+
'Apply. Click to go back to reviewing every proposal.'
|
|
1017
|
+
: 'OFF: nothing is written until you click Apply. Click to let this folder write itself.';
|
|
1018
|
+
b.onclick = () => send('set_auto_apply', { value: !on });
|
|
1019
|
+
return b;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/* The three positions on the one axis that decides what Send may become. The server owns the rule and
|
|
1023
|
+
the words of every refusal; this table is only what choosing each one means, so the badge cannot
|
|
1024
|
+
advertise a position the controller would then turn down. */
|
|
1025
|
+
const MODES = [
|
|
1026
|
+
['chat', 'Chat', 'Answer in prose. Reads the project as context, writes nothing.'],
|
|
1027
|
+
['read', 'Read-only', 'Read it, search it and map it, and explain what is wrong. Builds no proposal and '
|
|
1028
|
+
+ 'writes nothing, and your project\u2019s own command runs only when you approve that one.'],
|
|
1029
|
+
['change', 'Change', 'Propose a diff you review before any file is written.'],
|
|
1030
|
+
];
|
|
1031
|
+
const modeRow = (value) => MODES.find((row) => row[0] === value) || MODES[0];
|
|
1032
|
+
|
|
1033
|
+
function modeMenu() {
|
|
1034
|
+
const branch = DATA.branch || {};
|
|
1035
|
+
const s = sheet('What should Send do?', branch.projectName || 'This conversation');
|
|
1036
|
+
const list = el('div', 'content');
|
|
1037
|
+
for (const [value, name, desc] of MODES) {
|
|
1038
|
+
const b = el('button', 'cmd' + (DATA.composer === value ? ' on' : ''),
|
|
1039
|
+
`<span>${esc(name)}</span><span class="g">${DATA.composer === value ? 'current' : ''}</span>`);
|
|
1040
|
+
b.onclick = () => { close(); if (DATA.composer !== value) send('set_composer', { value }); };
|
|
1041
|
+
list.append(b, el('p', 'cmd-help', esc(desc)));
|
|
1042
|
+
}
|
|
1043
|
+
s.appendChild(list);
|
|
1044
|
+
const close = modal(s);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function modeBadge() {
|
|
1048
|
+
const branch = DATA.branch || {};
|
|
1049
|
+
const change = DATA.composer === 'change', reading = DATA.composer === 'read';
|
|
1050
|
+
const name = branch.projectName ? ' · ' + esc(branch.projectName) : '';
|
|
1051
|
+
const b = el('button', 'pill mode ' + (reading ? 'read' : change ? 'change' : 'chat'));
|
|
1052
|
+
b.innerHTML = modeRow(DATA.composer)[1] + name + ICON.chev;
|
|
1053
|
+
if (branch.bound) {
|
|
1054
|
+
// A bound chat is not a mode choice: the folder's remembered mode and switch stay with the
|
|
1055
|
+
// project branch, and asking for Change here is refused. So no chevron and no click.
|
|
1056
|
+
b.innerHTML = modeRow(DATA.composer)[1] + name;
|
|
1057
|
+
b.title = 'A chat moved into ' + (branch.projectName || 'a project') + ' answers in prose and reads '
|
|
1058
|
+
+ 'that folder as context. A message that asks for files is still planned as a proposal you '
|
|
1059
|
+
+ 'approve, but the mode and Auto-Apply belong to the project — open it in the sidebar to change them.';
|
|
1060
|
+
b.onclick = () => toast('That belongs to the project. Open ' + (branch.projectName || 'it') + ' in the sidebar.');
|
|
1061
|
+
const ta0 = $('prompt');
|
|
1062
|
+
if (ta0) ta0.placeholder = 'Ask about ' + branch.projectName + ' — or ask it for a change you will approve.';
|
|
1063
|
+
return b;
|
|
1064
|
+
}
|
|
1065
|
+
b.title = modeRow(DATA.composer)[2] + ' Click to choose what the next Send will do.';
|
|
1066
|
+
const declared = DATA.declared || {};
|
|
1067
|
+
if (declared.sealed) {
|
|
1068
|
+
// A folder can carry a position written by another surface — the other window, or a terminal that
|
|
1069
|
+
// never opened one. Without the lock here that seal looks like a button that forgot to work, so
|
|
1070
|
+
// the badge says who set it and when before it says why the click did nothing.
|
|
1071
|
+
b.classList.add('sealed');
|
|
1072
|
+
b.insertAdjacentHTML('afterbegin', ICON.lock + ' ');
|
|
1073
|
+
if (declared.note) b.title = declared.note;
|
|
1074
|
+
else if (declared.by) b.title = modeRow(DATA.composer)[2] + ' Set by ' + declared.by + '.';
|
|
1075
|
+
}
|
|
1076
|
+
b.onclick = () => {
|
|
1077
|
+
if (!DATA.project) { toast('Choose a project first — then Chat, Read-only and Change each mean something'); return; }
|
|
1078
|
+
modeMenu();
|
|
1079
|
+
};
|
|
1080
|
+
const ta = $('prompt');
|
|
1081
|
+
/* The one field where the user decides what Send will do, so it has to describe the folder's
|
|
1082
|
+
actual behaviour: with the switch on, "review before applying" is a promise this window will
|
|
1083
|
+
not keep. */
|
|
1084
|
+
const autoOn = !!(DATA.settings && DATA.settings.auto_apply);
|
|
1085
|
+
if (ta) ta.placeholder = change
|
|
1086
|
+
? (autoOn
|
|
1087
|
+
? 'Describe the change you want. This folder writes itself, then runs your command.'
|
|
1088
|
+
: 'Describe the change you want. Nothing is written until you click Apply.')
|
|
1089
|
+
: reading
|
|
1090
|
+
? 'Ask what is wrong here, where it is, and what a fix would touch. Nothing is written.'
|
|
1091
|
+
: branch.key ? 'Ask about ' + branch.projectName + ' — it answers in prose and writes nothing.'
|
|
1092
|
+
: 'Ask anything. Choose a project to work on its files.';
|
|
1093
|
+
return b;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function submit() {
|
|
1097
|
+
const text = $('prompt').value.trim();
|
|
1098
|
+
// An empty Send means "the next step", and that is only true in step-by-step mode, which the
|
|
1099
|
+
// settings block carries. A top-level flag of that name never arrives from the server, and
|
|
1100
|
+
// reading it here silently disabled the gesture.
|
|
1101
|
+
if (!text && !((DATA.settings || {}).chained)) return;
|
|
1102
|
+
if (DATA.busy) {
|
|
1103
|
+
/* A running task used to swallow this: start_plan returned with no message and the typed text
|
|
1104
|
+
was gone. Now it queues, and the strip above the composer shows it as one line. The
|
|
1105
|
+
confirmation is the server's toast, because that is the side that knows the language. */
|
|
1106
|
+
send('queue_add', { text });
|
|
1107
|
+
$('prompt').value = '';
|
|
1108
|
+
autosize();
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
send('send', { text });
|
|
1112
|
+
$('prompt').value = '';
|
|
1113
|
+
autosize();
|
|
1114
|
+
}
|
|
1115
|
+
function autosize() {
|
|
1116
|
+
const ta = $('prompt'); ta.style.height = 'auto';
|
|
1117
|
+
ta.style.height = Math.min(240, Math.max(50, ta.scrollHeight)) + 'px';
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/* The first-run card, over the composer. Each row says its status in a word as well as a colour:
|
|
1121
|
+
"green" is not an answer for someone who cannot see it, and this is the first screen a new operator
|
|
1122
|
+
reads. Nothing here probes the machine — the rows come from the server, and only a click re-runs
|
|
1123
|
+
them. */
|
|
1124
|
+
const SETUP_MARK = { ok: 'OK', warn: 'WATCH', bad: 'BLOCKED', info: 'NOTE' };
|
|
1125
|
+
|
|
1126
|
+
function renderSetup() {
|
|
1127
|
+
const box = $('setup');
|
|
1128
|
+
if (!box) return;
|
|
1129
|
+
box.innerHTML = '';
|
|
1130
|
+
const card = DATA.setup || {};
|
|
1131
|
+
const rows = card.rows || [];
|
|
1132
|
+
if (!card.show || !rows.length) return;
|
|
1133
|
+
const btn = (label, title, run) => {
|
|
1134
|
+
const b = el('button', 'line-btn');
|
|
1135
|
+
b.textContent = label; b.title = title; b.onclick = run;
|
|
1136
|
+
return b;
|
|
1137
|
+
};
|
|
1138
|
+
const head = el('div', 'setup-head');
|
|
1139
|
+
const title = el('b'); title.textContent = 'Set up this machine';
|
|
1140
|
+
const tally = el('span', 'setup-tally');
|
|
1141
|
+
// The server writes the tally: the card's rows already arrive as its sentences in its language, and
|
|
1142
|
+
// a count line assembled here would be the one part of the card in a second voice.
|
|
1143
|
+
tally.textContent = card.tally || '';
|
|
1144
|
+
head.append(title, tally,
|
|
1145
|
+
btn('Run the checks', 'Ask this machine what it can reach: the provider, its model list, and the '
|
|
1146
|
+
+ 'command the folder answers to.', () => send('setup_check')),
|
|
1147
|
+
btn('Run the offline proof', 'A proposal applied, checked and rolled back in a temporary folder. '
|
|
1148
|
+
+ 'No model is asked, and none of your files are touched.', () => send('setup_demo')),
|
|
1149
|
+
btn("Don't show this again", 'The card stops appearing at launch. Everything it says stays in '
|
|
1150
|
+
+ 'Settings.', () => send('setup_hide')));
|
|
1151
|
+
box.appendChild(head);
|
|
1152
|
+
for (const row of rows) {
|
|
1153
|
+
const line = el('div', 'setup-row');
|
|
1154
|
+
const badge = el('span', 'setup-badge ' + (SETUP_MARK[row.status] ? row.status : 'info'));
|
|
1155
|
+
badge.textContent = SETUP_MARK[row.status] || 'NOTE';
|
|
1156
|
+
const body = el('div', 'setup-body');
|
|
1157
|
+
const text = el('div', 'setup-text'); text.textContent = row.text || '';
|
|
1158
|
+
body.appendChild(text);
|
|
1159
|
+
if (row.advice) {
|
|
1160
|
+
const advice = el('div', 'setup-advice');
|
|
1161
|
+
advice.textContent = row.advice;
|
|
1162
|
+
body.appendChild(advice);
|
|
1163
|
+
}
|
|
1164
|
+
line.append(badge, body);
|
|
1165
|
+
box.appendChild(line);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/* Messages sent during a running task, one line each. The strip is where the promise lives: a
|
|
1170
|
+
queued message starts by itself the moment the current task ends, so what is waiting has to be
|
|
1171
|
+
readable without opening anything, and reversible without asking twice. */
|
|
1172
|
+
function renderQueue() {
|
|
1173
|
+
const box = $('queue'); box.innerHTML = '';
|
|
1174
|
+
const q = DATA.queue || {};
|
|
1175
|
+
const items = q.items || [];
|
|
1176
|
+
if (!items.length && !q.elsewhere) return;
|
|
1177
|
+
if (q.held) {
|
|
1178
|
+
const held = el('div', 'qrow held', '<span class="qk">⏸</span>'
|
|
1179
|
+
+ `<span class="qt">${esc(q.held_note || '')}</span>`);
|
|
1180
|
+
const act = el('div', 'qacts');
|
|
1181
|
+
const resume = el('button', 'qbtn', '▶');
|
|
1182
|
+
resume.title = 'Let the queue run again';
|
|
1183
|
+
resume.onclick = () => send('queue_resume', {});
|
|
1184
|
+
act.appendChild(resume); held.appendChild(act);
|
|
1185
|
+
box.appendChild(held);
|
|
1186
|
+
}
|
|
1187
|
+
for (const item of items) {
|
|
1188
|
+
const row = el('div', 'qrow');
|
|
1189
|
+
row.dir = 'auto';
|
|
1190
|
+
// Every sentence here arrives from the server, because only it knows what language the task
|
|
1191
|
+
// was asked in; the strip supplies the glyphs and the buttons.
|
|
1192
|
+
const when = item.restored ? q.when_restored
|
|
1193
|
+
: item.detached ? q.when_detached : q.when;
|
|
1194
|
+
row.innerHTML = `<span class="qk">${item.detached ? '↗' : '⏳'}</span>`
|
|
1195
|
+
+ `<span class="qt">${esc(item.text)}</span><span class="qwhen">${esc(when || '')}</span>`;
|
|
1196
|
+
const act = el('div', 'qacts');
|
|
1197
|
+
const btn = (label, title, fn) => {
|
|
1198
|
+
const b = el('button', 'qbtn', label); b.title = title; b.onclick = fn; act.appendChild(b);
|
|
1199
|
+
};
|
|
1200
|
+
btn('▶', 'Run this one next', () => send('queue_now', { id: item.id }));
|
|
1201
|
+
btn('✎', 'Edit the message before it runs', () => editQueued(row, item));
|
|
1202
|
+
btn('↗', 'Ask it in a chat of its own, on this project', () => send('queue_chat', { id: item.id }));
|
|
1203
|
+
btn('✕', 'Remove it from the queue', () => send('queue_drop', { id: item.id }));
|
|
1204
|
+
row.appendChild(act);
|
|
1205
|
+
box.appendChild(row);
|
|
1206
|
+
}
|
|
1207
|
+
if (q.elsewhere) box.appendChild(el('div', 'qnote', esc(q.elsewhere_note || '')));
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function editQueued(row, item) {
|
|
1211
|
+
row.innerHTML = '';
|
|
1212
|
+
const input = el('input', 'qedit');
|
|
1213
|
+
input.value = item.text; input.dir = 'auto'; input.spellcheck = false;
|
|
1214
|
+
const act = el('div', 'qacts');
|
|
1215
|
+
const save = el('button', 'qbtn', 'Save');
|
|
1216
|
+
save.onclick = () => send('queue_edit', { id: item.id, text: input.value });
|
|
1217
|
+
const cancel = el('button', 'qbtn', 'Cancel');
|
|
1218
|
+
cancel.onclick = () => renderQueue();
|
|
1219
|
+
act.append(save, cancel);
|
|
1220
|
+
row.append(input, act);
|
|
1221
|
+
input.focus(); input.select();
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function renderRail() {
|
|
1225
|
+
const rail = $('rail'); rail.innerHTML = '';
|
|
1226
|
+
const a = DATA.artifact;
|
|
1227
|
+
const r = DATA.review || {};
|
|
1228
|
+
/* A preview takes the artifact card's place: both answer "what happened to my files", and
|
|
1229
|
+
stacking them pushed the Sources card off a 322px column. */
|
|
1230
|
+
if (state.railFile >= 0 && r.files && r.files[state.railFile]) {
|
|
1231
|
+
rail.appendChild(railPreviewCard(r));
|
|
1232
|
+
} else {
|
|
1233
|
+
const art = el('div', 'card');
|
|
1234
|
+
/* The status line moves on as soon as the checks run; a write nobody clicked for should
|
|
1235
|
+
not disappear from the window with it. Its undo is the bar under the file list, not a second
|
|
1236
|
+
button inside the sentence — one control per decision, and that bar carries the enablement. */
|
|
1237
|
+
art.innerHTML = (DATA.banner && DATA.banner.text
|
|
1238
|
+
? `<div class="auto-note">${esc(DATA.banner.text)}</div>` : '') +
|
|
1239
|
+
`<span class="state${tone(a.tone)}">● ${esc(a.state)}</span>
|
|
1240
|
+
<div class="t">${esc(a.title)}</div><div class="d">${esc(a.detail)}</div>`;
|
|
1241
|
+
/* One row per touched file, so the card answers "which files" without a trip anywhere else.
|
|
1242
|
+
The row is the same control as a chip in the thread. */
|
|
1243
|
+
if ((r.files || []).length) {
|
|
1244
|
+
const list = el('div', 'rail-files');
|
|
1245
|
+
r.files.forEach((f, i) => {
|
|
1246
|
+
const row = el('button', 'rfile',
|
|
1247
|
+
kindTag(f.kind) + `<code>${esc(f.path)}</code>`);
|
|
1248
|
+
row.onclick = () => openFile(i);
|
|
1249
|
+
list.appendChild(row);
|
|
1250
|
+
});
|
|
1251
|
+
art.appendChild(list);
|
|
1252
|
+
/* The pane's three decisions, in the card that names the change set. Apply has to stay a
|
|
1253
|
+
visible click — D31 means a delete never auto-applies, and "you review first" is only
|
|
1254
|
+
true if there is somewhere on screen to do the clicking. */
|
|
1255
|
+
art.appendChild(el('div', 'hr'));
|
|
1256
|
+
changeActions(r, art);
|
|
1257
|
+
}
|
|
1258
|
+
rail.appendChild(art);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
if (DATA.plan) {
|
|
1262
|
+
const p = el('div', 'card');
|
|
1263
|
+
const pct = Math.round((DATA.plan.verified / Math.max(1, DATA.plan.total)) * 100);
|
|
1264
|
+
p.innerHTML = `<h5>Plan · step-by-step</h5><div class="t" style="font-size:12.5px">${esc(DATA.plan.name)} — step ${DATA.plan.step}/${DATA.plan.total}</div>
|
|
1265
|
+
<div class="bar"><i style="width:${pct}%"></i></div>
|
|
1266
|
+
<div class="meta"><span>${DATA.plan.verified} verified</span><span>${esc(DATA.plan.note)}</span></div><div class="hr"></div>`;
|
|
1267
|
+
for (const s of DATA.plan.steps) {
|
|
1268
|
+
p.appendChild(el('div', 'step ' + (s.status === 'verified' ? 'done' : s.current ? 'now' : ''),
|
|
1269
|
+
`<i>${s.status === 'verified' ? '✓' : s.id}</i><span>${esc(s.title)}</span>`));
|
|
1270
|
+
}
|
|
1271
|
+
rail.appendChild(p);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
if (DATA.recipes.length) {
|
|
1275
|
+
/* A folder with more than one project in it gets the module named here, because "Maven test" at
|
|
1276
|
+
the reactor root and "Maven test" in one module are different questions with different answers.
|
|
1277
|
+
With one project there is nothing to choose, and a picker of one is noise. */
|
|
1278
|
+
const many = (DATA.targets || []).length > 1;
|
|
1279
|
+
const sb = DATA.sandbox || {};
|
|
1280
|
+
const c = el('div', 'card');
|
|
1281
|
+
c.innerHTML = `<h5>Checks</h5>
|
|
1282
|
+
${many ? `<button class="pill" id="target" style="width:100%;justify-content:space-between">${esc(DATA.targetLabel || 'choose a module')}${ICON.chev}</button>` : ''}
|
|
1283
|
+
<button class="pill" id="recipe" style="width:100%;justify-content:space-between;${many ? 'margin-top:7px' : ''}">${esc(DATA.recipe || 'choose a command')}${ICON.chev}</button>
|
|
1284
|
+
<label class="switch" style="margin-top:8px"><input type="checkbox" id="sandboxOn"
|
|
1285
|
+
${sb.on ? 'checked' : ''} ${sb.available ? '' : 'disabled'}> Run in Docker</label>
|
|
1286
|
+
<input id="sandboxImage" placeholder="image@sha256:…" value="${esc(sb.image || '')}" dir="ltr"
|
|
1287
|
+
${sb.on && sb.available ? '' : 'disabled'} style="width:100%;font-family:Consolas,monospace">
|
|
1288
|
+
<div class="meta" dir="auto">${esc(sb.note || '')}</div>
|
|
1289
|
+
<div class="row" style="margin-top:9px"><button class="line-btn" style="flex:1" id="run">▶ Run</button><button class="line-btn" style="flex:1" id="fix">Run & fix</button></div>
|
|
1290
|
+
${DATA.fixRounds && DATA.fixRounds.spent ? `<div class="meta" style="margin-top:7px"><span>Fix round ${Number(DATA.fixRounds.spent) || 0} of ${Number(DATA.fixRounds.of) || 0}</span></div>` : ''}
|
|
1291
|
+
<div class="warn" dir="auto">${esc(DATA.runWarning || '')}</div>
|
|
1292
|
+
<div class="d" style="margin-top:9px">${esc(DATA.runInfo)}</div>`;
|
|
1293
|
+
c.querySelector('#recipe').onclick = () => choose('recipe', DATA.recipe, DATA.recipes);
|
|
1294
|
+
if (many) c.querySelector('#target').onclick = () => choose('target', DATA.targetLabel,
|
|
1295
|
+
DATA.targets.map(row => row.label));
|
|
1296
|
+
/* The digest is sent when the field is left, not on every keystroke: it is a name that either
|
|
1297
|
+
matches or does not, and the server writes the preference on each answer. */
|
|
1298
|
+
c.querySelector('#sandboxOn').onchange = (e) => send('sandbox', { on: e.target.checked });
|
|
1299
|
+
c.querySelector('#sandboxImage').onchange = (e) => send('sandbox', { image: e.target.value });
|
|
1300
|
+
c.querySelector('#run').onclick = () => send('run', { fix: false });
|
|
1301
|
+
c.querySelector('#fix').onclick = () => send('run', { fix: true });
|
|
1302
|
+
c.querySelector('#run').disabled = c.querySelector('#fix').disabled = !DATA.canRun;
|
|
1303
|
+
rail.appendChild(c);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const s = el('div', 'card');
|
|
1307
|
+
/* The branch label is display only: choosing, creating and leaving a project all happen in
|
|
1308
|
+
the sidebar, so the right panel cannot silently repoint the window at another folder. */
|
|
1309
|
+
s.innerHTML = `<h5>Sources</h5>
|
|
1310
|
+
<div class="link quiet">${ICON.file} ${esc(DATA.project ? 'Project: ' + DATA.project.name : 'This chat has no project')}</div>
|
|
1311
|
+
<button class="link" id="notes">🧾 Project notes <span class="r">${esc(DATA.memory.info || '')}</span></button>
|
|
1312
|
+
<div class="hr"></div>
|
|
1313
|
+
<button class="link" id="settings">${ICON.gear} Settings <span class="r">›</span></button>`;
|
|
1314
|
+
s.querySelector('#notes').onclick = () => openSettings('notes');
|
|
1315
|
+
s.querySelector('#settings').onclick = () => openSettings();
|
|
1316
|
+
// The drawer is per project, so it only appears once one is in front of the window.
|
|
1317
|
+
if ((DATA.branch || {}).key) {
|
|
1318
|
+
const group = (DATA.projects || []).find((g) => g.key === DATA.branch.key);
|
|
1319
|
+
const info = el('button', 'link', '📁 Project settings & status <span class="r">›</span>');
|
|
1320
|
+
info.onclick = () => projectDrawer(group || { key: DATA.branch.key, name: DATA.project.name, path: DATA.project.path });
|
|
1321
|
+
s.insertBefore(info, s.querySelector('.hr'));
|
|
1322
|
+
const graph = el('button', 'link', '🕸️ Dependency graph <span class="r">›</span>');
|
|
1323
|
+
graph.onclick = () => graphSheet();
|
|
1324
|
+
s.insertBefore(graph, s.querySelector('.hr'));
|
|
1325
|
+
}
|
|
1326
|
+
rail.appendChild(s);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/* Unified diff lines carry no line numbers of their own; the @@ -old,+new @@ header does.
|
|
1330
|
+
Counting array positions instead numbered the header lines and drifted from the file. */
|
|
1331
|
+
function diffRows(lines) {
|
|
1332
|
+
let oldLine = 0, newLine = 0;
|
|
1333
|
+
return lines.map((line) => {
|
|
1334
|
+
const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
1335
|
+
if (hunk) { oldLine = +hunk[1]; newLine = +hunk[2]; return { cls: 'hunk', ln: '', tx: line }; }
|
|
1336
|
+
if (line.startsWith('---') || line.startsWith('+++')) return { cls: 'hunk', ln: '', tx: line };
|
|
1337
|
+
if (line.startsWith('+')) return { cls: 'add', ln: newLine++, tx: line };
|
|
1338
|
+
if (line.startsWith('-')) return { cls: 'del', ln: oldLine++, tx: line };
|
|
1339
|
+
oldLine += 1; newLine += 1;
|
|
1340
|
+
return { cls: '', ln: newLine - 1, tx: line };
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/* One diff painter, two surfaces: the rail preview and the sheet it shares its markup with. They
|
|
1345
|
+
used to be the same loop inlined into a pane, which left the rail with nothing to draw without
|
|
1346
|
+
leaving Chat. */
|
|
1347
|
+
function paintDiff(host, lines) {
|
|
1348
|
+
host.innerHTML = '';
|
|
1349
|
+
diffRows(lines).forEach((row) => {
|
|
1350
|
+
const node = el('div', 'dline ' + row.cls,
|
|
1351
|
+
`<span class="ln">${row.ln === '' ? '' : row.ln}</span><span class="tx">${esc(row.tx) || ' '}</span>`);
|
|
1352
|
+
node.dir = 'auto';
|
|
1353
|
+
host.appendChild(node);
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/* Whole-file contents, numbered. The stored `after` is the file as this task wrote it and `before`
|
|
1358
|
+
is what the task replaced, so the preview never needs to read the disk to show a file. */
|
|
1359
|
+
function paintCode(host, lines) {
|
|
1360
|
+
host.innerHTML = '';
|
|
1361
|
+
lines.forEach((tx, i) => {
|
|
1362
|
+
const node = el('div', 'dline', `<span class="ln">${i + 1}</span><span class="tx">${esc(tx) || ' '}</span>`);
|
|
1363
|
+
node.dir = 'auto';
|
|
1364
|
+
host.appendChild(node);
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/* A chip and a rail row both mean "show me this file", and both are answered by the side viewer.
|
|
1369
|
+
Below 1180px the rail is display:none, so the same card opens in a sheet laid over the chat: the
|
|
1370
|
+
pane that used to catch these clicks is gone, and reading a change never leaves Chat. */
|
|
1371
|
+
function openFile(index) {
|
|
1372
|
+
const r = DATA.review || {};
|
|
1373
|
+
/* A chip that is no longer in the change set means the page is behind the server, so the click
|
|
1374
|
+
has to say that rather than return silently — a silent no-return is what made this look like a
|
|
1375
|
+
broken button. */
|
|
1376
|
+
if (!r.files || !r.files[index]) { toast('That file is no longer in the change set.', 'bad'); return; }
|
|
1377
|
+
state.railFile = index;
|
|
1378
|
+
state.railTab = 'diff';
|
|
1379
|
+
// The reply carries this file's diff, so the viewer is drawn only once the server has answered.
|
|
1380
|
+
api('/api/action', { type: 'select_file', index })
|
|
1381
|
+
.then((reply) => {
|
|
1382
|
+
if (reply && reply.state) render(reply.state); // the rail branch repaints itself here
|
|
1383
|
+
if (railHidden()) viewerSheet();
|
|
1384
|
+
})
|
|
1385
|
+
.catch((e) => toast(String(e.message || e), 'bad'));
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function railHidden() { return getComputedStyle($('rail')).display === 'none'; }
|
|
1389
|
+
|
|
1390
|
+
/* The one change viewer, on two surfaces. SHEET is non-null only while a sheet is open, and it is
|
|
1391
|
+
what the preview's tab buttons repaint into — a sheet that kept showing the last file after a tab
|
|
1392
|
+
click would be the pane's old bug wearing a scrim. */
|
|
1393
|
+
let SHEET = null;
|
|
1394
|
+
let SETTINGS = null;
|
|
1395
|
+
|
|
1396
|
+
/* A profile or a provider change moves the connection row underneath an open tab: the endpoint, the
|
|
1397
|
+
key variable and whether approval is needed all belong to the server. Only the Connection tab is
|
|
1398
|
+
repainted, because the others hold text the user is typing and a snapshot must not eat it. */
|
|
1399
|
+
function connectionSignature() {
|
|
1400
|
+
const c = DATA.connection || {};
|
|
1401
|
+
return [c.kind, c.endpoint, c.profile, DATA.provider.mode, DATA.provider.model].join('|');
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/* The rows are the server's, so a save that changed them has to repaint the tab that lists them.
|
|
1405
|
+
The value field survives the repaint for the same reason the key field does: the next row may be
|
|
1406
|
+
half-typed while this one lands. */
|
|
1407
|
+
function overrideSignature() {
|
|
1408
|
+
const o = DATA.overrides || {};
|
|
1409
|
+
return (o.rows || []).map((r) => [r.target, r.key, r.value, r.state].join(':')).join('|');
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
function syncSettings() {
|
|
1413
|
+
if (!SETTINGS || (SETTINGS.tab !== 'connection' && SETTINGS.tab !== 'overrides')) return;
|
|
1414
|
+
if (SETTINGS.signature === connectionSignature() + overrideSignature()) return;
|
|
1415
|
+
// Only the key field is text the server never echoes back. The value box is not preserved on
|
|
1416
|
+
// purpose: this repaint runs when a row actually landed, and a box still holding the value that
|
|
1417
|
+
// just saved is a box waiting to save it twice. A refused row changes nothing, so no repaint runs.
|
|
1418
|
+
const typed = document.querySelector('#key');
|
|
1419
|
+
const keep = typed ? typed.value : '';
|
|
1420
|
+
SETTINGS.show(SETTINGS.tab);
|
|
1421
|
+
const again = document.querySelector('#key');
|
|
1422
|
+
if (again) again.value = keep;
|
|
1423
|
+
SETTINGS.signature = connectionSignature() + overrideSignature();
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function refreshPreview() {
|
|
1427
|
+
if (!SHEET) { renderRail(); return; }
|
|
1428
|
+
SHEET.host.innerHTML = '';
|
|
1429
|
+
SHEET.host.appendChild(railPreviewCard(DATA.review || {}));
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function viewerSheet() {
|
|
1433
|
+
if (SHEET) { refreshPreview(); return; }
|
|
1434
|
+
const r = DATA.review || {};
|
|
1435
|
+
// The header is the server's own task line and file count, so the sheet is a labelled dialog.
|
|
1436
|
+
const box = sheet(r.title, r.detail);
|
|
1437
|
+
box.classList.add('wide');
|
|
1438
|
+
const host = el('div', 'content pv-sheet');
|
|
1439
|
+
box.appendChild(host);
|
|
1440
|
+
const close = modal(box, () => { SHEET = null; state.railFile = -1; renderRail(); });
|
|
1441
|
+
SHEET = { host: host, close: close };
|
|
1442
|
+
refreshPreview();
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/* The server's "come and look" nudge — a proposal became ready, a chat block became a proposal, a
|
|
1446
|
+
fix round produced one. It used to switch the whole window to the Changes pane; with the pane gone
|
|
1447
|
+
the same intent opens the side viewer on the newest file and leaves the user on the screen they
|
|
1448
|
+
were reading. `asClick` is the deep link, where opening the sheet is the point of the URL; an
|
|
1449
|
+
arriving proposal must not pop a dialog over somebody's typing. */
|
|
1450
|
+
function showView(value, asClick) {
|
|
1451
|
+
if (value !== 'review' && value !== 'preview') { switchView(value); return; }
|
|
1452
|
+
state.previewWant = asClick ? 'sheet' : 'rail';
|
|
1453
|
+
takePreviewOffer();
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/* The event is emitted by the worker before the snapshot that carries the proposal, so the intent is
|
|
1457
|
+
recorded and taken up by the next render — otherwise it would open the viewer on the last task's
|
|
1458
|
+
file list. */
|
|
1459
|
+
function takePreviewOffer() {
|
|
1460
|
+
if (!state.previewWant) return;
|
|
1461
|
+
const want = state.previewWant;
|
|
1462
|
+
state.previewWant = '';
|
|
1463
|
+
const r = DATA && DATA.review;
|
|
1464
|
+
if (!r || !r.files || !r.files.length) return;
|
|
1465
|
+
state.railFile = 0;
|
|
1466
|
+
state.railTab = 'diff';
|
|
1467
|
+
if (want === 'sheet' && railHidden()) viewerSheet(); else renderRail();
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/* Three fates for a file, one badge each. `D` has to be visibly its own thing: a chip that reads
|
|
1471
|
+
"~ Modified" over a removal is the card lying about the one change you cannot read back. */
|
|
1472
|
+
function kindTag(kind) {
|
|
1473
|
+
if (kind === 'A') return '<span class="chip-tag created">+ Created</span>';
|
|
1474
|
+
if (kind === 'D') return '<span class="chip-tag deleted">− Removed</span>';
|
|
1475
|
+
return '<span class="chip-tag modified">~ Modified</span>';
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function chipCard(r) {
|
|
1479
|
+
const card = el('div', 'chat-task-card');
|
|
1480
|
+
card.dir = 'auto';
|
|
1481
|
+
/* The state line is the server's, so an Arabic task reads Arabic here and English there is
|
|
1482
|
+
only ever the chrome: `+ Created` names a badge, not a sentence. */
|
|
1483
|
+
const a = DATA.artifact || {};
|
|
1484
|
+
card.appendChild(el('div', 'chat-task-head',
|
|
1485
|
+
`<span class="state${tone(a.tone)}">● ${esc(a.state)}</span>`
|
|
1486
|
+
+ `<span class="quiet">${esc(a.title)}</span>`));
|
|
1487
|
+
const row = el('div', 'chat-file-chips');
|
|
1488
|
+
r.files.forEach((f, i) => {
|
|
1489
|
+
const chip = el('button', 'file-chip',
|
|
1490
|
+
`<span class="chip-icon">${ICON.file}</span><span class="chip-name">${esc(f.path)}</span>`
|
|
1491
|
+
+ kindTag(f.kind)
|
|
1492
|
+
+ `<span class="chip-action">↗ View</span>`);
|
|
1493
|
+
chip.title = (a.written ? 'Inspect what was written to this file' : 'Inspect the proposed changes to this file');
|
|
1494
|
+
chip.onclick = () => openFile(i);
|
|
1495
|
+
row.appendChild(chip);
|
|
1496
|
+
});
|
|
1497
|
+
card.appendChild(row);
|
|
1498
|
+
return card;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/* The three decisions about a change set, built once. The pane that used to hold them is gone, so the
|
|
1502
|
+
rail's artifact card and the preview ask for the same controls. Roll back follows `canRollback` rather
|
|
1503
|
+
than the pane's `canMutate`, because an interrupted apply is exactly when the escape must be on screen. */
|
|
1504
|
+
function changeActions(r, host) {
|
|
1505
|
+
const bar = el('div', 'pv-acts');
|
|
1506
|
+
const apply = el('button', 'solid', 'Apply changes');
|
|
1507
|
+
apply.disabled = !r.canApply;
|
|
1508
|
+
apply.onclick = () => send('apply');
|
|
1509
|
+
const verify = el('button', 'line-btn', 'Check syntax');
|
|
1510
|
+
const undo = el('button', 'line-btn', '↩ Roll back');
|
|
1511
|
+
verify.disabled = !r.canMutate;
|
|
1512
|
+
undo.disabled = !r.canRollback;
|
|
1513
|
+
verify.onclick = () => send('verify');
|
|
1514
|
+
undo.onclick = () => send('rollback', {});
|
|
1515
|
+
bar.append(apply, verify, undo);
|
|
1516
|
+
host.appendChild(bar);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
function railPreviewCard(r) {
|
|
1520
|
+
const file = r.files[state.railFile];
|
|
1521
|
+
const view = r.view || {};
|
|
1522
|
+
const card = el('div', 'card rail-preview');
|
|
1523
|
+
const tabs = [['diff', 'Diff'], ['now', 'Now'], ['was', 'Was'], ['checks', 'Checks']];
|
|
1524
|
+
card.innerHTML = `<div class="pv-head"><span class="badge ${file.kind}">${file.kind}</span>
|
|
1525
|
+
<code title="${esc(file.path)}">${esc(file.path)}</code>
|
|
1526
|
+
<span class="num"><span class="p">+${file.add}</span><span class="n">−${file.del}</span></span></div>
|
|
1527
|
+
<div class="pv-note">${file.kind === 'A' ? '+ Created by this task'
|
|
1528
|
+
: file.kind === 'D' ? '− Removed by this task. Was shows what it held.'
|
|
1529
|
+
: '~ Modified by this task'}
|
|
1530
|
+
· ${DATA.artifact.written ? 'on disk' : 'proposed, not written'}</div>
|
|
1531
|
+
<div class="dtabs pv-tabs"></div><div class="dcode pv-body"></div><div class="hr"></div>`;
|
|
1532
|
+
const bar = card.querySelector('.pv-tabs');
|
|
1533
|
+
for (const [id, label] of tabs) {
|
|
1534
|
+
const b = el('button', id === state.railTab ? 'on' : '', label);
|
|
1535
|
+
b.onclick = () => { state.railTab = id; refreshPreview(); };
|
|
1536
|
+
bar.appendChild(b);
|
|
1537
|
+
}
|
|
1538
|
+
const body = card.querySelector('.pv-body');
|
|
1539
|
+
const lines = state.railTab === 'diff' ? (view.diff || [])
|
|
1540
|
+
: state.railTab === 'now' ? (view.after || [])
|
|
1541
|
+
: state.railTab === 'checks' ? (view.checks || []) : (view.before || []);
|
|
1542
|
+
if (!lines.length) {
|
|
1543
|
+
/* An empty "Was" tab is not a missing file — it is a file this task created. Saying so keeps
|
|
1544
|
+
the tab from reading as a load failure. */
|
|
1545
|
+
body.innerHTML = '';
|
|
1546
|
+
body.appendChild(el('div', 'pv-empty', state.railTab === 'was'
|
|
1547
|
+
? 'This file did not exist before this task.'
|
|
1548
|
+
: 'No recorded contents for this tab.'));
|
|
1549
|
+
} else if (state.railTab === 'diff') {
|
|
1550
|
+
paintDiff(body, lines);
|
|
1551
|
+
} else if (state.railTab === 'checks') {
|
|
1552
|
+
// Server-built sentences, plain text: the checks the model promised, not results.
|
|
1553
|
+
body.innerHTML = '';
|
|
1554
|
+
body.appendChild(el('div', 'pv-checks', esc(lines.join('\n'))));
|
|
1555
|
+
} else {
|
|
1556
|
+
paintCode(body, lines);
|
|
1557
|
+
}
|
|
1558
|
+
const close = el('button', 'line-btn', '✕ Close preview');
|
|
1559
|
+
close.onclick = () => { if (SHEET) SHEET.close(); else { state.railFile = -1; renderRail(); } };
|
|
1560
|
+
card.appendChild(close);
|
|
1561
|
+
changeActions(r, card);
|
|
1562
|
+
return card;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/* Live build output: streamed straight to the Activity view, never stored on the server,
|
|
1566
|
+
so a 5 000-line build cannot ride along in every later snapshot. Replayed after DATA.log
|
|
1567
|
+
when a state render rebuilds the list, and cleared when the next job starts. */
|
|
1568
|
+
const LIVE = [];
|
|
1569
|
+
const LIVE_MAX = 300;
|
|
1570
|
+
function pushChunk(msg) {
|
|
1571
|
+
const entry = { ts: msg.ts, kind: 'out', text: msg.text, stream: true };
|
|
1572
|
+
LIVE.push(entry);
|
|
1573
|
+
if (LIVE.length > LIVE_MAX) LIVE.shift();
|
|
1574
|
+
appendLog(entry);
|
|
1575
|
+
/* The same line inside the row that is running it: "what is it doing" and "what has it printed so
|
|
1576
|
+
far" belong to one place. Appended rather than re-rendered — rebuilding the thread per line would
|
|
1577
|
+
fight the reader's scroll for the whole build. */
|
|
1578
|
+
const host = document.querySelector('.steprow.live.open .st-out');
|
|
1579
|
+
if (host) {
|
|
1580
|
+
host.appendChild(el('span', '', esc(msg.text)));
|
|
1581
|
+
while (host.children.length > LIVE_MAX) host.removeChild(host.firstChild);
|
|
1582
|
+
host.scrollTop = host.scrollHeight;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
function appendLog(entry) {
|
|
1586
|
+
const log = $('log');
|
|
1587
|
+
if (!log.children.length) log.innerHTML = '';
|
|
1588
|
+
const row = el('div', 'r' + (entry.stream ? ' chunk' : ''), `<span class="ts">${esc(entry.ts || '')}</span><span class="k">${esc(entry.kind || '')}</span><span class="m">${esc(entry.text || '')}</span>`);
|
|
1589
|
+
log.appendChild(row); log.parentElement.scrollTop = log.parentElement.scrollHeight;
|
|
1590
|
+
}
|
|
1591
|
+
function renderLog() {
|
|
1592
|
+
const log = $('log'); log.innerHTML = '';
|
|
1593
|
+
/* The cap is the server's and so is the admission: a log that got shorter on its own reads as a task
|
|
1594
|
+
that did less than it did. */
|
|
1595
|
+
if (DATA.log_note) log.appendChild(el('div', 'r', `<span class="m">${esc(DATA.log_note)}</span>`));
|
|
1596
|
+
for (const entry of DATA.log) appendLog(entry);
|
|
1597
|
+
for (const entry of LIVE) appendLog(entry);
|
|
1598
|
+
if (!DATA.log.length && !LIVE.length) log.appendChild(el('div', 'm', 'Nothing has happened yet in this task.'));
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
function markTabs(view) {
|
|
1602
|
+
for (const b of $('seg').children) {
|
|
1603
|
+
const active = b.dataset.view === view;
|
|
1604
|
+
b.classList.toggle('on', active);
|
|
1605
|
+
b.setAttribute('aria-selected', String(active));
|
|
1606
|
+
}
|
|
1607
|
+
for (const id of ['task', 'details']) $('view-' + id).classList.toggle('on', id === view);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function switchView(view) {
|
|
1611
|
+
state.view = view;
|
|
1612
|
+
markTabs(view);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
function setBusy(busy, cancellable) {
|
|
1616
|
+
state.busy = busy;
|
|
1617
|
+
paintStatus();
|
|
1618
|
+
if (busy && (LIVE.length || STREAM)) { LIVE.length = 0; STREAM = ''; renderLog(); }
|
|
1619
|
+
if (DATA) {
|
|
1620
|
+
DATA.busy = busy; DATA.cancellable = !!cancellable;
|
|
1621
|
+
// The typing line means "a request is in flight". Once the window is not busy it has to go,
|
|
1622
|
+
// or a reply that arrives over SSE leaves a phantom wait behind it until the next full render.
|
|
1623
|
+
if (!busy && DATA.pending) { DATA.pending = null; renderThread(); }
|
|
1624
|
+
renderComposer();
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
/* --------------------------------- toasts -------------------------------- */
|
|
1629
|
+
function toast(text, level) {
|
|
1630
|
+
const t = el('div', 'toast' + tone(level), `<i></i><span>${esc(text)}</span><button>×</button>`);
|
|
1631
|
+
t.querySelector('button').onclick = () => close();
|
|
1632
|
+
$('toasts').appendChild(t);
|
|
1633
|
+
const timer = setTimeout(close, 4200);
|
|
1634
|
+
function close() { clearTimeout(timer); t.classList.add('out'); setTimeout(() => t.remove(), 200); }
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/* --------------------------------- modals -------------------------------- */
|
|
1638
|
+
const FOCUSABLE = 'button, input, textarea, select, [tabindex]:not([tabindex="-1"])';
|
|
1639
|
+
|
|
1640
|
+
function modal(node, onClose) {
|
|
1641
|
+
const scrim = el('div', 'scrim');
|
|
1642
|
+
node.setAttribute('role', 'dialog');
|
|
1643
|
+
node.setAttribute('aria-modal', 'true');
|
|
1644
|
+
scrim.appendChild(node);
|
|
1645
|
+
/* Dismissal is a click on the scrim that did not begin as a press elsewhere. Listening for
|
|
1646
|
+
mousedown here dismissed a dialog that mounted under the pointer mid-click — the flash where
|
|
1647
|
+
a sheet appeared and vanished in the same frame. The 200 ms guard covers the other half: the
|
|
1648
|
+
click that opened this dialog is still on its way out of the event queue. */
|
|
1649
|
+
const mountedAt = Date.now();
|
|
1650
|
+
scrim.addEventListener('click', (event) => {
|
|
1651
|
+
if (event.target === scrim && Date.now() - mountedAt > 200) done();
|
|
1652
|
+
});
|
|
1653
|
+
$('modal-root').appendChild(scrim);
|
|
1654
|
+
const opener = document.activeElement;
|
|
1655
|
+
const focusables = () => [...node.querySelectorAll(FOCUSABLE)].filter((el) => !el.disabled && el.offsetParent);
|
|
1656
|
+
(focusables()[0] || node).focus({ preventScroll: true });
|
|
1657
|
+
|
|
1658
|
+
function onKey(event) {
|
|
1659
|
+
if (event.key === 'Escape') { event.preventDefault(); done(); return; }
|
|
1660
|
+
if (event.key !== 'Tab') return;
|
|
1661
|
+
// A dialog the user can Tab out of is a dialog that hides the rest of the app.
|
|
1662
|
+
const items = focusables();
|
|
1663
|
+
if (!items.length) return;
|
|
1664
|
+
const first = items[0], last = items[items.length - 1];
|
|
1665
|
+
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
|
1666
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
|
1667
|
+
}
|
|
1668
|
+
scrim.addEventListener('keydown', onKey);
|
|
1669
|
+
let closed = false;
|
|
1670
|
+
function done() {
|
|
1671
|
+
if (closed) return;
|
|
1672
|
+
closed = true;
|
|
1673
|
+
scrim.classList.add('out');
|
|
1674
|
+
setTimeout(() => scrim.remove(), 140);
|
|
1675
|
+
if (opener && document.contains(opener)) opener.focus({ preventScroll: true });
|
|
1676
|
+
if (onClose) onClose();
|
|
1677
|
+
}
|
|
1678
|
+
return done;
|
|
1679
|
+
}
|
|
1680
|
+
function sheet(title, subtitle) {
|
|
1681
|
+
const s = el('div', 'sheet');
|
|
1682
|
+
if (title || subtitle) s.appendChild(el('header', '', (title ? `<h3>${esc(title)}</h3>` : '') + (subtitle ? `<p>${esc(subtitle)}</p>` : '')));
|
|
1683
|
+
return s;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/* The server withdraws a question it stopped waiting for, and the window has to forget it too.
|
|
1687
|
+
The id is matched by walking the mounted sheets rather than by a selector, because the id is a
|
|
1688
|
+
string from the network and a CSS escape of one stray quote would throw on every later event. */
|
|
1689
|
+
function retractAsk(id) {
|
|
1690
|
+
const wanted = String(id || '');
|
|
1691
|
+
if (!wanted) return;
|
|
1692
|
+
for (const scrim of [...$('modal-root').children]) {
|
|
1693
|
+
const box = scrim.firstElementChild;
|
|
1694
|
+
if (box && box.dataset.ask === wanted) scrim.remove();
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
function askConfirm(msg) {
|
|
1699
|
+
const s = sheet(msg.title, msg.message);
|
|
1700
|
+
s.dataset.ask = msg.id;
|
|
1701
|
+
if (msg.warning) s.appendChild(el('div', 'content')).appendChild(el('div', 'warnbox', esc(msg.warning)));
|
|
1702
|
+
const foot = el('footer');
|
|
1703
|
+
const no = el('button', 'line-btn', esc(msg.cancel || 'Cancel'));
|
|
1704
|
+
const yes = el('button', 'solid', esc(msg.confirm || 'Continue'));
|
|
1705
|
+
const close = modal(s);
|
|
1706
|
+
no.onclick = () => { close(); api('/api/confirm', { id: msg.id, ok: false }); };
|
|
1707
|
+
yes.onclick = () => { close(); api('/api/confirm', { id: msg.id, ok: true }); };
|
|
1708
|
+
foot.append(no, yes);
|
|
1709
|
+
/* A third answer, and it says so on the wire with ok:false beside it: every other reader of a
|
|
1710
|
+
confirm treats a reply as yes/no, and this one is "not this round, and do not ask again". Only
|
|
1711
|
+
the fix offer sends `alt`, and only the web window has a batch for it to mean. */
|
|
1712
|
+
if (msg.alt) {
|
|
1713
|
+
const alt = el('button', 'line-btn', esc(msg.alt));
|
|
1714
|
+
alt.onclick = () => { close(); api('/api/confirm', { id: msg.id, ok: false, alt: true }); };
|
|
1715
|
+
foot.insertBefore(alt, yes);
|
|
1716
|
+
}
|
|
1717
|
+
s.appendChild(foot);
|
|
1718
|
+
setTimeout(() => yes.focus(), 40);
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
function askFolder(msg) {
|
|
1722
|
+
const s = sheet(msg.title || 'Choose a folder', msg.hint || '');
|
|
1723
|
+
s.dataset.ask = msg.id;
|
|
1724
|
+
const content = el('div', 'content');
|
|
1725
|
+
const tree = el('div', 'tree');
|
|
1726
|
+
content.appendChild(tree);
|
|
1727
|
+
const foot = el('footer');
|
|
1728
|
+
const cancel = el('button', 'line-btn', 'Cancel');
|
|
1729
|
+
const pick = el('button', 'solid', 'Choose here');
|
|
1730
|
+
pick.disabled = true;
|
|
1731
|
+
let chosen = null;
|
|
1732
|
+
const close = modal(s);
|
|
1733
|
+
|
|
1734
|
+
let create = null, nameField = null;
|
|
1735
|
+
if (msg.mustexist === false) {
|
|
1736
|
+
// "New project folder" needs a name, not an existing directory: the whole point is a
|
|
1737
|
+
// folder that does not exist yet.
|
|
1738
|
+
nameField = el('input');
|
|
1739
|
+
nameField.placeholder = 'my-project';
|
|
1740
|
+
nameField.spellcheck = false;
|
|
1741
|
+
const field = el('div', 'field', '<label>New folder name</label>');
|
|
1742
|
+
field.appendChild(nameField);
|
|
1743
|
+
content.insertBefore(field, tree);
|
|
1744
|
+
create = el('button', 'line-btn', 'Create here');
|
|
1745
|
+
create.disabled = true;
|
|
1746
|
+
nameField.addEventListener('input', () => {
|
|
1747
|
+
const name = nameField.value.trim();
|
|
1748
|
+
create.disabled = !name || !chosen;
|
|
1749
|
+
create.textContent = name ? 'Create ' + name : 'Create here';
|
|
1750
|
+
});
|
|
1751
|
+
create.onclick = () => {
|
|
1752
|
+
const name = nameField.value.trim();
|
|
1753
|
+
const base = jump.value.trim() || chosen;
|
|
1754
|
+
if (!name || !base) return;
|
|
1755
|
+
close(); api('/api/confirm', { id: msg.id, ok: true, path: base + '/' + name });
|
|
1756
|
+
};
|
|
1757
|
+
nameField.focus();
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
function refreshCreate() {
|
|
1761
|
+
if (!create) return;
|
|
1762
|
+
const name = (nameField.value || '').trim();
|
|
1763
|
+
create.disabled = !name || !chosen;
|
|
1764
|
+
create.textContent = name ? 'Create ' + name : 'Create here';
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
/* The picker opens wherever the home folder is, so it needs two extra ways out: the mounted
|
|
1768
|
+
roots once a drive root has no parent to go up to, and a path you can type — a project that
|
|
1769
|
+
does not exist yet cannot be reached by clicking through folders that do. */
|
|
1770
|
+
const where = el('div', 'where');
|
|
1771
|
+
const jump = el('input', 'jump');
|
|
1772
|
+
jump.placeholder = 'Or type a folder path, e.g. D:\\AI\\spring-project';
|
|
1773
|
+
jump.spellcheck = false;
|
|
1774
|
+
const drives = el('div', 'drives');
|
|
1775
|
+
const head = el('div', 'fshead');
|
|
1776
|
+
head.append(where, jump, drives);
|
|
1777
|
+
content.insertBefore(head, tree);
|
|
1778
|
+
const usePath = el('button', 'line-btn', 'Use this path');
|
|
1779
|
+
usePath.disabled = true;
|
|
1780
|
+
usePath.onclick = () => { close(); api('/api/confirm', { id: msg.id, ok: true, path: jump.value.trim() }); };
|
|
1781
|
+
|
|
1782
|
+
async function load(path) {
|
|
1783
|
+
tree.innerHTML = '<div class="empty">Loading…</div>';
|
|
1784
|
+
let data;
|
|
1785
|
+
try {
|
|
1786
|
+
data = await api('/api/fs?path=' + encodeURIComponent(path || ''));
|
|
1787
|
+
} catch (e) {
|
|
1788
|
+
tree.innerHTML = '';
|
|
1789
|
+
toast('That folder could not be read', 'bad');
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
tree.innerHTML = '';
|
|
1793
|
+
where.textContent = data.path;
|
|
1794
|
+
where.title = data.path;
|
|
1795
|
+
if (data.parent) {
|
|
1796
|
+
const up = el('div', 'trow', '<span class="chev">↰</span><span class="nm">..</span>');
|
|
1797
|
+
up.onclick = () => load(data.parent); tree.appendChild(up);
|
|
1798
|
+
}
|
|
1799
|
+
drives.innerHTML = '';
|
|
1800
|
+
for (const root of (data.roots || [])) {
|
|
1801
|
+
if (data.parent || root === data.path) continue;
|
|
1802
|
+
const chip = el('button', 'drive', esc(root));
|
|
1803
|
+
chip.onclick = () => load(root);
|
|
1804
|
+
drives.appendChild(chip);
|
|
1805
|
+
}
|
|
1806
|
+
chosen = data.path;
|
|
1807
|
+
pick.disabled = false;
|
|
1808
|
+
refreshCreate();
|
|
1809
|
+
for (const dir of data.dirs) {
|
|
1810
|
+
const row = el('div', 'trow', `<span class="chev">▸</span><span class="nm">📁 ${esc(dir.name)}</span>`);
|
|
1811
|
+
row.onclick = () => load(dir.path); tree.appendChild(row);
|
|
1812
|
+
}
|
|
1813
|
+
for (const file of (data.files || [])) {
|
|
1814
|
+
const row = el('div', 'trow leaf-file', `<span class="chev"></span><span class="nm">${esc(file.name)}</span>`);
|
|
1815
|
+
tree.appendChild(row);
|
|
1816
|
+
if (msg.files) row.onclick = () => { close(); api('/api/confirm', { id: msg.id, ok: true, path: file.path }); };
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
jump.addEventListener('input', () => {
|
|
1820
|
+
const typed = jump.value.trim();
|
|
1821
|
+
usePath.disabled = !typed;
|
|
1822
|
+
if (create) {
|
|
1823
|
+
const name = nameField.value.trim();
|
|
1824
|
+
create.disabled = !name || (!chosen && !typed);
|
|
1825
|
+
create.textContent = name ? 'Create ' + name : 'Create here';
|
|
1826
|
+
}
|
|
1827
|
+
});
|
|
1828
|
+
jump.addEventListener('keydown', (e) => { if (e.key === 'Enter' && jump.value.trim()) load(jump.value.trim()); });
|
|
1829
|
+
load(msg.path || '');
|
|
1830
|
+
cancel.onclick = () => { close(); api('/api/confirm', { id: msg.id, ok: false }); };
|
|
1831
|
+
pick.onclick = () => { close(); api('/api/confirm', { id: msg.id, ok: true, path: chosen }); };
|
|
1832
|
+
foot.append(cancel);
|
|
1833
|
+
if (create) foot.append(create);
|
|
1834
|
+
foot.append(usePath, pick);
|
|
1835
|
+
s.append(content, foot);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
function choose(kind, current, options) {
|
|
1839
|
+
const s = sheet(kind === 'model' ? 'Choose a model' : kind === 'mode' ? 'Choose a provider' : 'Choose a command');
|
|
1840
|
+
const list = el('div', 'content');
|
|
1841
|
+
const entries = (options || []).map((opt) => ({
|
|
1842
|
+
value: typeof opt === 'string' ? opt : opt.id,
|
|
1843
|
+
desc: typeof opt === 'string' ? '' : (opt.description || ''),
|
|
1844
|
+
}));
|
|
1845
|
+
let query = '';
|
|
1846
|
+
// Filtering happens here, against what this sheet already holds. Round-tripping a keystroke to
|
|
1847
|
+
// the server narrowed a list the server keeps in memory, and cost a state push per character.
|
|
1848
|
+
const matches = () => {
|
|
1849
|
+
const q = query.trim().toLowerCase();
|
|
1850
|
+
return !q ? entries
|
|
1851
|
+
: entries.filter((item) => (item.value + ' ' + item.desc).toLowerCase().includes(q));
|
|
1852
|
+
};
|
|
1853
|
+
function paint() {
|
|
1854
|
+
list.innerHTML = '';
|
|
1855
|
+
const kept = matches();
|
|
1856
|
+
for (const item of kept) {
|
|
1857
|
+
const b = el('button', 'cmd' + (item.value === current ? ' on' : ''),
|
|
1858
|
+
`<span>${esc(item.value)}</span><span class="g">${esc(item.desc.slice(0, 46))}</span>`);
|
|
1859
|
+
b.onclick = () => { close(); send('set_' + kind, { value: item.value }); };
|
|
1860
|
+
list.appendChild(b);
|
|
1861
|
+
}
|
|
1862
|
+
if (!kept.length) {
|
|
1863
|
+
list.appendChild(el('div', 'empty', entries.length ? 'No model matches that search.'
|
|
1864
|
+
: 'Nothing loaded yet. Try Refresh in Settings.'));
|
|
1865
|
+
}
|
|
1866
|
+
if (count) {
|
|
1867
|
+
count.textContent = kept.length === entries.length ? entries.length + ' models'
|
|
1868
|
+
: kept.length + ' of ' + entries.length + ' models';
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
let count = null;
|
|
1872
|
+
if (kind === 'model' && entries.length > 6) {
|
|
1873
|
+
const head = el('div', 'field pickfilter');
|
|
1874
|
+
const input = el('input');
|
|
1875
|
+
input.placeholder = 'Search models — name, size, cloud';
|
|
1876
|
+
input.spellcheck = false;
|
|
1877
|
+
count = el('span', 'g');
|
|
1878
|
+
input.oninput = () => { query = input.value; paint(); };
|
|
1879
|
+
input.onkeydown = (event) => {
|
|
1880
|
+
if (event.key !== 'Enter') return;
|
|
1881
|
+
event.preventDefault();
|
|
1882
|
+
const hit = matches()[0];
|
|
1883
|
+
if (hit) { close(); send('set_model', { value: hit.value }); }
|
|
1884
|
+
};
|
|
1885
|
+
head.append(input, count);
|
|
1886
|
+
s.appendChild(head);
|
|
1887
|
+
}
|
|
1888
|
+
s.appendChild(list);
|
|
1889
|
+
paint();
|
|
1890
|
+
const close = modal(s);
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
function openSettings(tab) {
|
|
1894
|
+
const s = sheet('Settings', ''); s.classList.add('wide');
|
|
1895
|
+
const st = DATA.settings;
|
|
1896
|
+
const tabs = el('div', 'tabs');
|
|
1897
|
+
const body = el('div', 'content');
|
|
1898
|
+
body.style.maxHeight = '60vh';
|
|
1899
|
+
s.append(tabs, body);
|
|
1900
|
+
const close = modal(s, () => { SETTINGS = null; });
|
|
1901
|
+
const sections = {
|
|
1902
|
+
project: () => `
|
|
1903
|
+
<div class="field"><label>Project folder</label><input readonly value="${esc(st.project || '')}"></div>
|
|
1904
|
+
<div class="field"><label>Attached plan</label><input readonly value="${esc(st.plan || '')}"></div>
|
|
1905
|
+
<label class="switch"><input type="checkbox" id="chained" ${st.chained ? 'checked' : ''}> Step-by-step plan mode: work one step at a time</label>
|
|
1906
|
+
<label class="switch"><input type="checkbox" id="autoapply" ${st.auto_apply ? 'checked' : ''} ${st.project && !st.bound ? '' : 'disabled'}> ${st.bound ? 'Auto-Apply belongs to the project branch — open ' + esc(String(st.project || '').split(/[\\/]/).filter(Boolean).pop() || 'it') + ' in the sidebar to change it' : 'Auto-Apply in this folder: what the model proposes writes itself, then the project command runs'}</label>
|
|
1907
|
+
<div class="field" style="margin-top:14px"><label>Request timeout (seconds)</label>
|
|
1908
|
+
<input type="number" id="timeout" min="30" max="900" step="30" value="${esc(st.timeout)}"></div>`,
|
|
1909
|
+
models: () => `
|
|
1910
|
+
<div class="field"><label>Filter models by name</label><input id="filter" placeholder="qwen"></div>
|
|
1911
|
+
<div class="field"><label>${esc(st.model_info || '')}</label></div>`,
|
|
1912
|
+
notes: () => `
|
|
1913
|
+
<div class="field"><label>Project notes — sent with every task in this folder</label>
|
|
1914
|
+
<textarea id="memory" ${st.project ? '' : 'disabled placeholder="Choose a project folder first."'}>${esc(st.memory || '')}</textarea>
|
|
1915
|
+
<div class="hint">${esc(st.memory_info || '')}</div></div>`,
|
|
1916
|
+
overrides: () => {
|
|
1917
|
+
const o = DATA.overrides || {};
|
|
1918
|
+
const rows = (o.rows || []).map((r) => `
|
|
1919
|
+
<div class="ovrow"><span class="ovk">${esc(r.display)}</span>
|
|
1920
|
+
<span class="ovm">${esc(r.state === 'in force' ? r.target : r.why)}`
|
|
1921
|
+
+ `${r.by ? ' \u00b7 ' + esc(r.by) + ' ' + esc(r.at || '') : ''}</span>
|
|
1922
|
+
${r.state === 'in force' ? `<button class="link" data-drop="${esc(r.target)}|${esc(r.key)}">Remove</button>` : ''}</div>`).join('');
|
|
1923
|
+
const keys = (o.keys || []).map((k) => `<option value="${esc(k.key)}">${esc(k.key)}`
|
|
1924
|
+
+ `${k.number ? ' (' + k.low + ' to ' + k.high + ')' : ''}</option>`).join('');
|
|
1925
|
+
const targets = (o.targets || []).map((v) => `<option value="${esc(v)}"`
|
|
1926
|
+
+ `${v === o.kind ? ' selected' : ''}>${esc(v === '*' ? 'every provider' : v)}</option>`).join('');
|
|
1927
|
+
return `
|
|
1928
|
+
<div class="field"><label>Rows in force. Only this program writes them, and it signs each one.</label>
|
|
1929
|
+
${rows || '<div class="ovrow"><span class="ovm">Nothing is overridden.</span></div>'}
|
|
1930
|
+
<div class="hint">${esc(o.note || '')}</div>
|
|
1931
|
+
<div class="hint">${esc(o.path || '')}</div></div>
|
|
1932
|
+
<div class="field"><label>Provider this row speaks for, or every provider</label>
|
|
1933
|
+
<select id="ov-target">${targets}</select></div>
|
|
1934
|
+
<div class="field"><label>Field to change</label><select id="ov-key">${keys}</select></div>
|
|
1935
|
+
<div class="field"><label>New value</label><input id="ov-value" placeholder="600"></div>
|
|
1936
|
+
<button class="solid" id="ov-save">Save row</button>`;
|
|
1937
|
+
},
|
|
1938
|
+
connection: () => {
|
|
1939
|
+
const c = DATA.connection || {};
|
|
1940
|
+
const rows = (list, selected) => list.map((v) =>
|
|
1941
|
+
`<option value="${esc(v)}"${v === selected ? ' selected' : ''}>${esc(v)}</option>`).join('');
|
|
1942
|
+
return `
|
|
1943
|
+
<div class="field"><label>Profile — a file in profiles/, read at pick time</label>
|
|
1944
|
+
<select id="profile"><option value="">${esc('None (choose the row below)')}</option>${
|
|
1945
|
+
rows(c.profiles || [], c.profile || '')}</select>
|
|
1946
|
+
<div class="hint">A profile sets provider, model and endpoint. It carries the name of the
|
|
1947
|
+
key variable, never a key.</div></div>
|
|
1948
|
+
<div class="field"><label>Provider</label>
|
|
1949
|
+
<select id="mode">${rows(DATA.provider.modes || [], DATA.provider.mode)}</select></div>
|
|
1950
|
+
<div class="field"><label>Endpoint — where this provider's requests go</label>
|
|
1951
|
+
<input id="endpoint" value="${esc(c.endpoint || '')}" placeholder="${esc(c.default_endpoint || 'https://example/v1')}">
|
|
1952
|
+
<div class="hint">${esc(connectionHint(c))}</div></div>
|
|
1953
|
+
<div class="field"><label>${esc(c.key_env || 'API key')} — never written to disk</label>
|
|
1954
|
+
<input type="password" id="key" placeholder="${esc(c.key_present ? 'found in the environment; paste to override for this session' : 'paste for this session only')}">
|
|
1955
|
+
<div class="hint">${esc(c.needs_key ? 'This provider requires a key.' : 'This provider needs no key.')}</div></div>
|
|
1956
|
+
${c.consent ? `<label class="switch"><input type="checkbox" id="consent" ${st.consent ? 'checked' : ''}>
|
|
1957
|
+
Allow this public / synthetic code to be sent to that address</label>` : ''}`;
|
|
1958
|
+
},
|
|
1959
|
+
};
|
|
1960
|
+
function connectionHint(c) {
|
|
1961
|
+
if (c.shape === 'ollama') return 'Ollama on this device. A model tagged "cloud" is still refused without approval.';
|
|
1962
|
+
if (c.consent) return 'Outside this device, so it needs your approval on every task and the address must be https.';
|
|
1963
|
+
return 'On this device. Nothing leaves it.';
|
|
1964
|
+
}
|
|
1965
|
+
function show(name) {
|
|
1966
|
+
for (const b of tabs.children) b.classList.toggle('on', b.dataset.tab === name);
|
|
1967
|
+
body.innerHTML = sections[name]();
|
|
1968
|
+
if (SETTINGS) SETTINGS.tab = name;
|
|
1969
|
+
body.querySelector('#chained')?.addEventListener('change', (e) => send('set_chained', { value: e.target.checked }));
|
|
1970
|
+
body.querySelector('#autoapply')?.addEventListener('change', (e) => send('set_auto_apply', { value: e.target.checked }));
|
|
1971
|
+
body.querySelector('#timeout')?.addEventListener('change', (e) => send('set_timeout', { value: +e.target.value }));
|
|
1972
|
+
body.querySelector('#filter')?.addEventListener('input', (e) => send('set_filter', { value: e.target.value }));
|
|
1973
|
+
body.querySelector('#memory')?.addEventListener('change', (e) => send('save_memory', { text: e.target.value }));
|
|
1974
|
+
body.querySelector('#profile')?.addEventListener('change', (e) => send('set_profile', { value: e.target.value }));
|
|
1975
|
+
body.querySelector('#mode')?.addEventListener('change', (e) => send('set_mode', { value: e.target.value }));
|
|
1976
|
+
// change, not input: the endpoint is checked on the server and a rejected value must not be
|
|
1977
|
+
// sent on every keystroke, because a valid one re-fetches that provider's model list.
|
|
1978
|
+
body.querySelector('#endpoint')?.addEventListener('change', (e) => send('set_endpoint', { value: e.target.value }));
|
|
1979
|
+
body.querySelector('#key')?.addEventListener('input', (e) => send('set_key', { value: e.target.value }));
|
|
1980
|
+
body.querySelector('#consent')?.addEventListener('change', (e) => send('set_consent', { value: e.target.checked }));
|
|
1981
|
+
body.querySelector('#ov-save')?.addEventListener('click', () => send('set_override', {
|
|
1982
|
+
target: body.querySelector('#ov-target').value,
|
|
1983
|
+
key: body.querySelector('#ov-key').value,
|
|
1984
|
+
value: body.querySelector('#ov-value').value}));
|
|
1985
|
+
for (const button of body.querySelectorAll('[data-drop]')) {
|
|
1986
|
+
button.addEventListener('click', () => {
|
|
1987
|
+
const [target, key] = button.dataset.drop.split('|');
|
|
1988
|
+
send('unset_override', { target: target, key: key });
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
for (const [name, label] of [['project', 'Project & plan'], ['models', 'Models'], ['notes', 'Notes'], ['connection', 'Connection'], ['overrides', 'Overrides']]) {
|
|
1993
|
+
const b = el('button', '', label); b.dataset.tab = name; b.onclick = () => show(name); tabs.appendChild(b);
|
|
1994
|
+
}
|
|
1995
|
+
const foot = el('footer');
|
|
1996
|
+
const refresh = el('button', 'line-btn', 'Refresh models'); refresh.onclick = () => send('refresh_models');
|
|
1997
|
+
/* "Don't show this again" hides the card, and the note on that button promises the answers stay
|
|
1998
|
+
here — so the way back has to be a button, not a memory of where the card was. */
|
|
1999
|
+
const checks = el('button', 'line-btn', 'Setup checks');
|
|
2000
|
+
checks.title = 'Ask this machine what it can reach, and show the first-run card again.';
|
|
2001
|
+
checks.onclick = () => send('setup_check');
|
|
2002
|
+
const done = el('button', 'solid', 'Done'); done.onclick = close;
|
|
2003
|
+
foot.append(refresh, checks, done); s.appendChild(foot);
|
|
2004
|
+
SETTINGS = { show: show, tab: tab || 'project',
|
|
2005
|
+
signature: connectionSignature() + overrideSignature() };
|
|
2006
|
+
show(tab || 'project');
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/* ------------------------------- command bar ------------------------------ */
|
|
2010
|
+
function palette() {
|
|
2011
|
+
const s = el('div', 'sheet palette');
|
|
2012
|
+
const input = el('input', 'cmdinput'); input.placeholder = 'Type a command…';
|
|
2013
|
+
const list = el('div', 'cmdlist');
|
|
2014
|
+
s.append(input, list);
|
|
2015
|
+
const close = modal(s);
|
|
2016
|
+
const commands = [
|
|
2017
|
+
['New chat (standalone, no project)', () => send('new_chat')],
|
|
2018
|
+
['Chat mode — answer in prose', () => send('set_composer', { value: 'chat' })],
|
|
2019
|
+
['Read-only mode — explain, write nothing', () => send('set_composer', { value: 'read' })],
|
|
2020
|
+
['Change mode — propose a reviewed diff', () => send('set_composer', { value: 'change' })],
|
|
2021
|
+
['Setup checks — what this machine can reach', () => send('setup_check')],
|
|
2022
|
+
['Move this chat into a project…', () => chatMenu({ id: DATA.current, title: DATA.header.title },
|
|
2023
|
+
(DATA.branch || {}).key || '')],
|
|
2024
|
+
['Open a project folder', () => send('pick_project')],
|
|
2025
|
+
['New project folder', () => send('new_project')], ['Attach a plan', () => send('pick_plan')],
|
|
2026
|
+
['Try the sample project', () => send('example')],
|
|
2027
|
+
['Ask your last message again', askAgain],
|
|
2028
|
+
['Run the project command', () => send('run', { fix: false })],
|
|
2029
|
+
['Run and fix', () => send('run', { fix: true })], ['Check syntax', () => send('verify')],
|
|
2030
|
+
['Roll back changes', () => send('rollback')], ['Apply changes', () => send('apply')],
|
|
2031
|
+
['Stop the current task', () => send('stop')], ['Settings', () => openSettings()],
|
|
2032
|
+
['Style: Claude warm', () => setStyle('claude')], ['Style: Codex charcoal', () => setStyle('codex')],
|
|
2033
|
+
['Style: Linear indigo', () => setStyle('linear')], ['Theme: light', () => { state.themeMode = 'light'; applyThemeMode(); renderThemePick(); }],
|
|
2034
|
+
['Theme: dark', () => { state.themeMode = 'dark'; applyThemeMode(); renderThemePick(); }],
|
|
2035
|
+
];
|
|
2036
|
+
let shown = commands.slice(), at = 0;
|
|
2037
|
+
function draw() {
|
|
2038
|
+
list.innerHTML = '';
|
|
2039
|
+
shown.forEach(([label, run], i) => {
|
|
2040
|
+
const b = el('button', 'cmd' + (i === at ? ' on' : ''), esc(label));
|
|
2041
|
+
b.onclick = () => { close(); run(); }; b.onmouseenter = () => { at = i; draw(); };
|
|
2042
|
+
list.appendChild(b);
|
|
2043
|
+
});
|
|
2044
|
+
}
|
|
2045
|
+
input.oninput = () => { const q = input.value.toLowerCase(); shown = commands.filter((c) => c[0].toLowerCase().includes(q)); at = 0; draw(); };
|
|
2046
|
+
input.onkeydown = (e) => {
|
|
2047
|
+
if (e.key === 'ArrowDown') { at = Math.min(shown.length - 1, at + 1); draw(); e.preventDefault(); }
|
|
2048
|
+
else if (e.key === 'ArrowUp') { at = Math.max(0, at - 1); draw(); e.preventDefault(); }
|
|
2049
|
+
else if (e.key === 'Enter') { close(); const hit = shown[at]; if (hit) hit[1](); e.preventDefault(); }
|
|
2050
|
+
else if (e.key === 'Escape') close();
|
|
2051
|
+
};
|
|
2052
|
+
draw(); setTimeout(() => input.focus(), 30);
|
|
2053
|
+
}
|
|
2054
|
+
function setStyle(style) {
|
|
2055
|
+
state.style = style; document.documentElement.dataset.style = style;
|
|
2056
|
+
savePrefs({ style });
|
|
2057
|
+
send('set_style', { style });
|
|
2058
|
+
}
|
|
2059
|
+
function savePrefs(patch) {
|
|
2060
|
+
const saved = JSON.parse(localStorage.getItem('ui.prefs') || '{}');
|
|
2061
|
+
localStorage.setItem('ui.prefs', JSON.stringify({ ...saved, ...patch }));
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/* The button that folds the sidebar away lives inside it, so the header carries the way back.
|
|
2065
|
+
Both that button and Ctrl+B come through here, so the two cannot drift apart. */
|
|
2066
|
+
const sidebarCollapsed = () => document.documentElement.classList.contains('side-collapsed');
|
|
2067
|
+
function setSidebar(collapsed) {
|
|
2068
|
+
document.documentElement.classList.toggle('side-collapsed', !!collapsed);
|
|
2069
|
+
savePrefs({ collapsed: !!collapsed });
|
|
2070
|
+
sendQuiet('set_collapsed', { value: !!collapsed });
|
|
2071
|
+
}
|
|
2072
|
+
const toggleSidebar = () => setSidebar(!sidebarCollapsed());
|
|
2073
|
+
|
|
2074
|
+
/* --------------------------------- wiring -------------------------------- */
|
|
2075
|
+
$('seg').addEventListener('click', (e) => { const b = e.target.closest('button'); if (b) switchView(b.dataset.view); });
|
|
2076
|
+
$('new-chat').onclick = () => send('new_chat');
|
|
2077
|
+
$('collapse').onclick = toggleSidebar;
|
|
2078
|
+
$('sidebar-expand').onclick = toggleSidebar;
|
|
2079
|
+
$('attach-top').onclick = () => send('pick_plan');
|
|
2080
|
+
$('search').addEventListener('input', (e) => { state.query = e.target.value; renderNav(); });
|
|
2081
|
+
$('prompt').addEventListener('input', () => {
|
|
2082
|
+
autosize();
|
|
2083
|
+
// The server needs the unsent text to warn before a project switch discards it.
|
|
2084
|
+
clearTimeout(state.draftTimer);
|
|
2085
|
+
state.lastDraftSent = $('prompt').value;
|
|
2086
|
+
state.draftTimer = setTimeout(() => sendQuiet('set_draft', { text: $('prompt').value }), 400);
|
|
2087
|
+
});
|
|
2088
|
+
$('prompt').addEventListener('keydown', (e) => {
|
|
2089
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); }
|
|
2090
|
+
});
|
|
2091
|
+
addEventListener('keydown', (e) => {
|
|
2092
|
+
/* AltGr on an Arabic layout is reported as Ctrl+Alt, so an unguarded `ctrlKey` test turned the
|
|
2093
|
+
key that types ÷ into the command palette and Ctrl+Alt+7 into a hidden sidebar. Held-down
|
|
2094
|
+
repeats are excluded for the same reason: one keypress, one panel. */
|
|
2095
|
+
if (e.altKey || e.repeat || handsBusy()) return;
|
|
2096
|
+
const key = (e.key || '').toLowerCase();
|
|
2097
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
2098
|
+
/* Shift is part of the binding, not a modifier to ignore: without the `!e.shiftKey` guards
|
|
2099
|
+
Ctrl+Shift+K matched the plain Ctrl+K branch first and opened the palette on the way to
|
|
2100
|
+
starting a chat. */
|
|
2101
|
+
if (mod && key === 'k' && !e.shiftKey) { e.preventDefault(); palette(); }
|
|
2102
|
+
if (mod && key === 'k' && e.shiftKey) { e.preventDefault(); send('new_chat'); }
|
|
2103
|
+
if (mod && key === 'b' && !e.shiftKey) { e.preventDefault(); toggleSidebar(); }
|
|
2104
|
+
});
|
|
2105
|
+
|
|
2106
|
+
const PARAMS = new URLSearchParams(location.search);
|
|
2107
|
+
TOKEN = PARAMS.get('t') || '';
|
|
2108
|
+
|
|
2109
|
+
connectEvents();
|
|
2110
|
+
api('/api/bootstrap').then((data) => {
|
|
2111
|
+
render(data);
|
|
2112
|
+
// Lets a design review open straight onto one screen of one style: ?style=codex&theme=dark&view=review
|
|
2113
|
+
const saved = JSON.parse(localStorage.getItem('ui.prefs') || '{}');
|
|
2114
|
+
state.themeMode = PARAMS.get('theme') || saved.theme || 'light';
|
|
2115
|
+
renderThemePick();
|
|
2116
|
+
if (PARAMS.get('view')) showView(PARAMS.get('view'), true);
|
|
2117
|
+
requestAnimationFrame(() => document.documentElement.classList.remove('booting'));
|
|
2118
|
+
}).catch((e) => toast('Could not reach the local server: ' + e.message, 'bad'));
|