koneck 2.37.0 → 2.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -156,7 +156,7 @@ function md(src) {
156
156
  /* ── views ─────────────────────────────────────────────────── */
157
157
  function setView(name) {
158
158
  state.view = name;
159
- for (const v of ['chat', 'changes', 'timing', 'files', 'search',
159
+ for (const v of ['chat', 'changes', 'timing', 'files', 'terminal', 'preview', 'search',
160
160
  'review', 'refactor', 'restore', 'settings']) {
161
161
  $(v + 'view').hidden = v !== name;
162
162
  $('tab-' + v).className = 'tab' + (v === name ? ' on' : '');
@@ -166,6 +166,8 @@ function setView(name) {
166
166
  if (name === 'changes') loadChanges();
167
167
  if (name === 'timing') loadTiming();
168
168
  if (name === 'files') loadTree('');
169
+ if (name === 'terminal') { loadJobs(); $('tcmd').focus(); }
170
+ if (name === 'preview') loadPreview();
169
171
  if (name === 'search') $('q').focus();
170
172
  if (name === 'review') loadReview();
171
173
  if (name === 'refactor') loadRefactor();
@@ -722,6 +724,138 @@ function renderTreeInto(host, entries, depth) {
722
724
  if (!entries.length) host.appendChild(el('div', 'shead', 'empty'));
723
725
  }
724
726
 
727
+ /* ── colouring code ──────────────────────────────────────────────────────────────────────── */
728
+
729
+ /**
730
+ * Syntax colouring, as one pass over the text.
731
+ *
732
+ * Not a parser. A parser for a dozen languages is a project, and what makes code readable is
733
+ * separating the four things the eye needs first: what is prose (comments), what is data (strings
734
+ * and numbers), what is structure (keywords), and what is a name being used. A tokeniser that gets
735
+ * those right at a glance beats a parser that gets everything right next week.
736
+ *
737
+ * One regular expression, its alternatives ordered so the greedy ones win: a keyword inside a
738
+ * string is part of the string, and a string inside a comment is part of the comment. Getting that
739
+ * order wrong is how one apostrophe in a comment colours the next forty lines as text.
740
+ */
741
+ const KEYWORDS = {
742
+ js: 'abstract as async await break case catch class const continue debugger declare default delete do else enum export extends finally for from function get if implements import in infer instanceof interface is keyof let new of package private protected public readonly return satisfies set static super switch symbol this throw try type typeof var void while with yield',
743
+ py: 'and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield match case',
744
+ go: 'break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var',
745
+ rs: 'as async await break const continue crate dyn else enum extern fn for if impl in let loop match mod move mut pub ref return self static struct super trait type unsafe use where while',
746
+ sh: 'if then else elif fi for while do done case esac function return local export source alias unset trap exit',
747
+ css: 'important media supports keyframes import charset font-face root',
748
+ sql: 'select from where insert into values update set delete create table alter drop join left right inner outer on group by order having limit offset union as and or not null primary key foreign references index view',
749
+ };
750
+ const LITERALS = 'true false null undefined None True False nil NaN Infinity self this super void';
751
+
752
+ /** Which vocabulary a file is written in. */
753
+ function langOf(name) {
754
+ const ext = (String(name).match(/\.([a-z0-9]+)$/i) || [, ''])[1].toLowerCase();
755
+ if (/^(ts|tsx|js|jsx|mjs|cjs|mts|cts|json|jsonc)$/.test(ext)) return 'js';
756
+ if (/^(py|pyi)$/.test(ext)) return 'py';
757
+ if (ext === 'go') return 'go';
758
+ if (ext === 'rs') return 'rs';
759
+ if (/^(sh|bash|zsh|env)$/.test(ext)) return 'sh';
760
+ if (/^(dockerfile|makefile)/i.test(String(name).split('/').pop() || '')) return 'sh';
761
+ if (/^(css|scss|less)$/.test(ext)) return 'css';
762
+ if (ext === 'sql') return 'sql';
763
+ if (/^(html|htm|xml|svg|vue|svelte)$/.test(ext)) return 'html';
764
+ if (/^(yml|yaml|toml|ini|conf)$/.test(ext)) return 'yaml';
765
+ return 'plain';
766
+ }
767
+
768
+ const escHtml = (t) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
769
+
770
+ /** The pattern for a language, built once and kept. */
771
+ const PATTERNS = {};
772
+ function patternFor(lang) {
773
+ if (PATTERNS[lang]) return PATTERNS[lang];
774
+ const hash = /^(py|sh|yaml)$/.test(lang);
775
+ // A backtick, built from its code point: a literal one would end the template this whole script
776
+ // lives in.
777
+ const T = String.fromCharCode(96);
778
+ const parts = [
779
+ // Comments first, longest form first, so nothing inside one is read again.
780
+ '(?<com>/\\*[\\s\\S]*?\\*/' + (hash ? '|#[^\\n]*' : '') + '|//[^\\n]*|<!--[\\s\\S]*?-->)',
781
+ // Then strings: double, single, Python triple, and template literals.
782
+ '(?<str>"""[\\s\\S]*?"""' +
783
+ "|'''[\\s\\S]*?'''" +
784
+ '|"(?:\\\\.|[^"\\\\\\n])*"' +
785
+ "|'(?:\\\\.|[^'\\\\\\n])*'" +
786
+ '|' + T + '(?:\\\\.|[^' + T + '\\\\])*' + T + ')',
787
+ '(?<num>\\b(?:0[xXbBoO][0-9a-fA-F_]+|\\d[\\d_]*(?:\\.[\\d_]+)?(?:[eE][+-]?\\d+)?)\\b)',
788
+ // A name followed by an opening bracket is being called.
789
+ '(?<fun>\\b[A-Za-z_$][\\w$]*(?=\\s*\\())',
790
+ // A capitalised name is a type or a class, near enough to be worth colouring as one.
791
+ '(?<typ>\\b[A-Z][A-Za-z0-9_$]*\\b)',
792
+ '(?<wrd>\\b[A-Za-z_$][\\w$-]*\\b)',
793
+ '(?<pun>[{}()\\[\\];:,.<>=+\\-*/%!?&|^~@#]+)',
794
+ ];
795
+ const re = new RegExp(parts.join('|'), 'g');
796
+ const words = new Set(((KEYWORDS[lang] || '') + ' ' + LITERALS).split(/\s+/).filter(Boolean));
797
+ PATTERNS[lang] = { re: re, words: words };
798
+ return PATTERNS[lang];
799
+ }
800
+
801
+ /**
802
+ * The text as coloured HTML.
803
+ *
804
+ * Line structure is preserved exactly. The caller lays this behind a textarea, and one swallowed
805
+ * newline would shift every colour below it onto the wrong line.
806
+ */
807
+ function colourise(text, filename) {
808
+ const lang = langOf(filename);
809
+ if (lang === 'plain') return escHtml(text);
810
+ const spec = patternFor(lang);
811
+ const re = spec.re, words = spec.words;
812
+ let out = '';
813
+ let last = 0;
814
+ re.lastIndex = 0;
815
+ let m;
816
+ while ((m = re.exec(text)) !== null) {
817
+ if (m.index > last) out += escHtml(text.slice(last, m.index));
818
+ const g = m.groups || {};
819
+ const raw = m[0];
820
+ let cls = null;
821
+ if (g.com !== undefined) cls = 'tk-com';
822
+ else if (g.str !== undefined) cls = 'tk-str';
823
+ else if (g.num !== undefined) cls = 'tk-num';
824
+ else if (g.fun !== undefined) cls = words.has(raw) ? 'tk-key' : 'tk-fun';
825
+ else if (g.typ !== undefined) cls = words.has(raw) ? 'tk-key' : 'tk-typ';
826
+ else if (g.wrd !== undefined) cls = words.has(raw) ? 'tk-key' : null;
827
+ else if (g.pun !== undefined) cls = 'tk-pun';
828
+ out += cls ? '<span class="' + cls + '">' + escHtml(raw) + '</span>' : escHtml(raw);
829
+ last = m.index + raw.length;
830
+ // A zero-length match would spin for ever. Nothing here should make one, but a hung tab is
831
+ // unrecoverable and the guard costs nothing.
832
+ if (raw.length === 0) re.lastIndex++;
833
+ }
834
+ out += escHtml(text.slice(last));
835
+ return out;
836
+ }
837
+
838
+ /**
839
+ * A file, open and editable.
840
+ *
841
+ * The tab could show a file and not change it, which makes it a viewer. Editing a line and saving
842
+ * is the smallest useful thing a person does to a file, and going through the agent to do it —
843
+ * asking a model to please change one character — is slower, less certain, and costs tokens.
844
+ *
845
+ * One textarea, deliberately. Undo and redo are the browser's own, which means every shortcut a
846
+ * person already knows works, including the ones nobody thinks about: ctrl+Z, ctrl+shift+Z, ctrl+Y,
847
+ * the platform variants, and undo history that survives switching tabs. A hand-rolled stack would
848
+ * be a worse version of something already there.
849
+ */
850
+ const editing = { path: null, text: '', size: 0, mtimeMs: 0, dirty: false };
851
+
852
+ function editState(word, cls) {
853
+ const el0 = $('edstate');
854
+ if (!el0) return;
855
+ el0.textContent = word;
856
+ el0.className = 'est' + (cls ? ' ' + cls : '');
857
+ }
858
+
725
859
  async function openFile(rel) {
726
860
  const host = $('fileview');
727
861
  host.innerHTML = '';
@@ -739,12 +873,141 @@ async function openFile(rel) {
739
873
  host.appendChild(el('div', 'empty', 'Binary file — nothing to show as text.'));
740
874
  return;
741
875
  }
742
- // Line numbers in their own column so selecting the code does not select them.
743
- const code = el('div', 'code');
744
- const lines = data.text.split('\n');
745
- code.appendChild(el('div', 'gut', lines.map((_, i) => i + 1).join('\n')));
746
- code.appendChild(el('div', 'src', data.text));
747
- host.appendChild(code);
876
+
877
+ editing.path = data.path;
878
+ editing.text = data.text;
879
+ editing.size = data.size;
880
+ editing.mtimeMs = data.mtimeMs;
881
+ editing.dirty = false;
882
+
883
+ const bar = el('div', 'edbar');
884
+ const undo = el('button', 'ebtn', 'undo');
885
+ const redo = el('button', 'ebtn', 'redo');
886
+ const save = el('button', 'ebtn save', 'save');
887
+ const revert = el('button', 'ebtn', 'revert');
888
+ const status = el('span', 'est', data.truncated ? 'read only - opened truncated' : 'saved');
889
+ status.id = 'edstate';
890
+ for (const b of [undo, redo, save, revert]) b.type = 'button';
891
+ bar.appendChild(undo); bar.appendChild(redo); bar.appendChild(save); bar.appendChild(revert);
892
+ bar.appendChild(status);
893
+ host.appendChild(bar);
894
+
895
+ /*
896
+ * Three columns that must agree: numbers, colours, and the text you actually type into.
897
+ *
898
+ * The textarea is on top with transparent text and a visible caret; the coloured copy is a pre
899
+ * directly behind it; the numbers are a third column. All three share one font, size, line
900
+ * height and padding, and the textarea's scroll drives the other two — a pixel of difference
901
+ * anywhere and the colours sit on the wrong characters.
902
+ */
903
+ const ed = el('div', 'ed');
904
+ const gut = el('div', 'egut');
905
+ const stack = el('div', 'estack');
906
+ const hl = document.createElement('pre');
907
+ hl.className = 'ehl';
908
+ hl.setAttribute('aria-hidden', 'true');
909
+ const area = document.createElement('textarea');
910
+ area.className = 'edit';
911
+ area.spellcheck = false;
912
+ area.value = data.text;
913
+ area.readOnly = data.truncated === true;
914
+ save.disabled = data.truncated === true;
915
+ stack.appendChild(hl);
916
+ stack.appendChild(area);
917
+ ed.appendChild(gut);
918
+ ed.appendChild(stack);
919
+ host.appendChild(ed);
920
+
921
+ /** Redraws the colours and the numbers for whatever the textarea now holds. */
922
+ const repaint = () => {
923
+ const text = area.value;
924
+ hl.innerHTML = colourise(text, data.path);
925
+ const count = text.split('\n').length;
926
+ // Rebuilt only when the count changes: typing inside a line must not rebuild a 900-row column
927
+ // on every keystroke.
928
+ if (gut.dataset.count !== String(count)) {
929
+ const numbers = [];
930
+ for (let i = 1; i <= count; i++) numbers.push(i);
931
+ gut.textContent = numbers.join('\n');
932
+ gut.dataset.count = String(count);
933
+ }
934
+ };
935
+ repaint();
936
+
937
+ // Colouring a large file on every keystroke is wasted work nobody sees; a frame's delay is
938
+ // invisible and keeps typing smooth in a 20,000-character file.
939
+ let pending = null;
940
+ const repaintSoon = () => {
941
+ if (pending) return;
942
+ pending = requestAnimationFrame(() => { pending = null; repaint(); });
943
+ };
944
+
945
+ const sync = () => {
946
+ hl.scrollTop = area.scrollTop;
947
+ hl.scrollLeft = area.scrollLeft;
948
+ gut.scrollTop = area.scrollTop;
949
+ };
950
+ area.addEventListener('scroll', sync);
951
+ area.addEventListener('input', () => {
952
+ editing.dirty = area.value !== editing.text;
953
+ editState(editing.dirty ? 'unsaved' : 'saved', editing.dirty ? 'dirty' : '');
954
+ repaintSoon();
955
+ sync();
956
+ });
957
+
958
+ // The browser's own undo, so every shortcut a person already knows works — including the ones
959
+ // nobody thinks about, and undo history that survives switching tabs.
960
+ undo.onclick = () => { area.focus(); document.execCommand('undo'); area.dispatchEvent(new Event('input')); };
961
+ redo.onclick = () => { area.focus(); document.execCommand('redo'); area.dispatchEvent(new Event('input')); };
962
+ revert.onclick = () => {
963
+ area.value = editing.text;
964
+ editing.dirty = false;
965
+ editState('saved');
966
+ repaint();
967
+ };
968
+
969
+ // Tab inserts a tab rather than leaving the editor, which is what it means in code.
970
+ area.addEventListener('keydown', (ev) => {
971
+ if (ev.key === 'Tab' && !ev.ctrlKey && !ev.metaKey) {
972
+ ev.preventDefault();
973
+ const at = area.selectionStart, to = area.selectionEnd;
974
+ area.setRangeText(' ', at, to, 'end');
975
+ area.dispatchEvent(new Event('input'));
976
+ }
977
+ });
978
+
979
+ const doSave = async () => {
980
+ if (area.readOnly) return;
981
+ editState('saving...');
982
+ let out;
983
+ try {
984
+ out = await api('/api/file', { method: 'POST', body: JSON.stringify({
985
+ path: editing.path, text: area.value, size: editing.size, mtimeMs: editing.mtimeMs }) });
986
+ } catch (e) {
987
+ // A refusal because the agent wrote the file while it was open is the one worth acting on,
988
+ // so it comes with the way through rather than only a message.
989
+ editState(e.message.slice(0, 90), 'bad');
990
+ if (e.status === 409 && !bar.querySelector('.reload')) {
991
+ const again = el('button', 'ebtn reload', 'reload from disk');
992
+ again.type = 'button';
993
+ again.onclick = () => openFile(rel);
994
+ bar.insertBefore(again, status);
995
+ }
996
+ return;
997
+ }
998
+ editing.text = area.value;
999
+ editing.size = out.size;
1000
+ editing.mtimeMs = out.mtimeMs;
1001
+ editing.dirty = false;
1002
+ editState('saved');
1003
+ };
1004
+ save.onclick = () => void doSave();
1005
+ area.addEventListener('keydown', (ev) => {
1006
+ if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 's') {
1007
+ ev.preventDefault();
1008
+ void doSave();
1009
+ }
1010
+ });
748
1011
  }
749
1012
 
750
1013
  /* ── search ────────────────────────────────────────────────── */
@@ -1129,6 +1392,78 @@ async function loadSettings() {
1129
1392
  const wrap = el('div', 'wrap');
1130
1393
  host.appendChild(wrap);
1131
1394
 
1395
+ /* ── how it looks ──────────────────────────────────────────────────────────────────────
1396
+ * Typography is a working condition, not a decoration: somebody reading code for nine hours on
1397
+ * a 1080p panel and somebody on a 4K laptop do not want the same size, and the font a person
1398
+ * reads fastest is the one they are used to. Held per browser, because it belongs to whoever is
1399
+ * looking rather than to the workspace.
1400
+ */
1401
+ wrap.appendChild(el('div', 'sect', 'Type'));
1402
+ const tbox = el('div');
1403
+ wrap.appendChild(tbox);
1404
+
1405
+ const typeRow = (label, hint, control) => {
1406
+ const row = el('div', 'setrow');
1407
+ const left = el('div');
1408
+ left.appendChild(el('div', 'sl', label));
1409
+ if (hint) left.appendChild(el('div', 'sd', hint));
1410
+ row.appendChild(left);
1411
+ row.appendChild(control);
1412
+ tbox.appendChild(row);
1413
+ };
1414
+
1415
+ const sizePick = document.createElement('select');
1416
+ for (const [label, value] of [['Small', '12.5px'], ['Normal', '14px'], ['Large', '15.5px'],
1417
+ ['Larger', '17px'], ['Largest', '19px']]) {
1418
+ const o = document.createElement('option');
1419
+ o.value = value; o.textContent = label + ' (' + value + ')';
1420
+ if (value === look.size) o.selected = true;
1421
+ sizePick.appendChild(o);
1422
+ }
1423
+ sizePick.onchange = () => setLook({ size: sizePick.value });
1424
+ typeRow('Size', 'Everything scales from this, so code and prose stay in proportion.', sizePick);
1425
+
1426
+ const famPick = document.createElement('select');
1427
+ for (const [label, value] of UI_FONTS) {
1428
+ const o = document.createElement('option');
1429
+ o.value = value; o.textContent = label;
1430
+ if (value === look.family) o.selected = true;
1431
+ famPick.appendChild(o);
1432
+ }
1433
+ famPick.onchange = () => setLook({ family: famPick.value });
1434
+ typeRow('Interface font', 'What the page is set in.', famPick);
1435
+
1436
+ const monoPick = document.createElement('select');
1437
+ for (const [label, value] of MONO_FONTS) {
1438
+ const o = document.createElement('option');
1439
+ o.value = value; o.textContent = label;
1440
+ if (value === look.mono) o.selected = true;
1441
+ monoPick.appendChild(o);
1442
+ }
1443
+ monoPick.onchange = () => setLook({ mono: monoPick.value });
1444
+ typeRow('Code font', 'Diffs, file contents, the terminal.', monoPick);
1445
+
1446
+ const tintPick = document.createElement('select');
1447
+ for (const [label, value] of [['None', 'none'], ['Warmer', 'warm'], ['Cooler', 'cool'],
1448
+ ['More contrast', 'high'], ['Softer', 'soft']]) {
1449
+ const o = document.createElement('option');
1450
+ o.value = value; o.textContent = label;
1451
+ if (value === look.tint) o.selected = true;
1452
+ tintPick.appendChild(o);
1453
+ }
1454
+ tintPick.onchange = () => setLook({ tint: tintPick.value });
1455
+ typeRow('Tone', 'Warmth and contrast, on top of the light or dark theme.', tintPick);
1456
+
1457
+ const sample = el('div', 'setrow');
1458
+ const demo = el('div');
1459
+ demo.appendChild(el('div', 'sl', 'The quick brown fox jumps over the lazy dog'));
1460
+ const code = el('div', 'sd');
1461
+ code.style.fontFamily = 'var(--mono)';
1462
+ code.textContent = 'const rate = 0.15; // ILlO0 1 — how it reads in code';
1463
+ demo.appendChild(code);
1464
+ sample.appendChild(demo);
1465
+ tbox.appendChild(sample);
1466
+
1132
1467
  /* health */
1133
1468
  wrap.appendChild(el('div', 'sect', 'Health'));
1134
1469
  const hbox = el('div');
@@ -1783,6 +2118,10 @@ const abort = () => api('/api/abort', { method: 'POST',
1783
2118
  $('stop').onclick = abort;
1784
2119
  $('livestop').onclick = abort;
1785
2120
  $('tab-chat').onclick = () => setView('chat');
2121
+ // Wired one by one, and these two were added to the tab bar and to setView but never here — so
2122
+ // they were painted, highlighted on hover, and did nothing at all when clicked.
2123
+ $('tab-terminal').onclick = () => setView('terminal');
2124
+ $('tab-preview').onclick = () => setView('preview');
1786
2125
  $('tab-files').onclick = () => setView('files');
1787
2126
  $('tab-search').onclick = () => setView('search');
1788
2127
  $('tab-review').onclick = () => setView('review');
@@ -1914,6 +2253,233 @@ $('pmode').onclick = async () => {
1914
2253
  }
1915
2254
  $('modedlg').showModal();
1916
2255
  };
2256
+ /* ── how it looks ─────────────────────────────────────────────────────────────────────────── */
2257
+
2258
+ /**
2259
+ * Typography, chosen and remembered.
2260
+ *
2261
+ * Per browser rather than per workspace: it belongs to whoever is looking. Applied as variables on
2262
+ * the root element so one setting moves the whole page — a size that changed the prose and left the
2263
+ * code at 12.5px would be worse than none.
2264
+ *
2265
+ * Only fonts that are already on the machine are offered. A font pulled from a CDN does not arrive
2266
+ * on the machine most likely to be running a local model, which is one that is offline.
2267
+ */
2268
+ const UI_FONTS = [
2269
+ ['System', "system-ui,-apple-system,'Segoe UI',Roboto,Ubuntu,sans-serif"],
2270
+ ['Humanist sans', "'Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif"],
2271
+ ['Grotesque sans', "Inter,'Roboto','Helvetica Neue',Arial,sans-serif"],
2272
+ ['Serif', "Georgia,'Times New Roman',Times,serif"],
2273
+ ['Monospace everywhere', "ui-monospace,'SF Mono',Menlo,Consolas,monospace"],
2274
+ ];
2275
+ const MONO_FONTS = [
2276
+ ['System monospace', "ui-monospace,'SF Mono',Menlo,Consolas,'DejaVu Sans Mono',monospace"],
2277
+ ['JetBrains Mono', "'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace"],
2278
+ ['Fira Code', "'Fira Code','Fira Mono',ui-monospace,Menlo,Consolas,monospace"],
2279
+ ['Source Code Pro', "'Source Code Pro',ui-monospace,Menlo,Consolas,monospace"],
2280
+ ['Courier', "'Courier New',Courier,monospace"],
2281
+ ];
2282
+
2283
+ const look = (() => {
2284
+ const fallback = { size: '14px', family: UI_FONTS[0][1], mono: MONO_FONTS[0][1], tint: 'none' };
2285
+ try {
2286
+ const saved = JSON.parse(localStorage.getItem('koneck.look') || '{}');
2287
+ return { ...fallback, ...(saved && typeof saved === 'object' ? saved : {}) };
2288
+ } catch (e) { return fallback; }
2289
+ })();
2290
+
2291
+ function applyLook() {
2292
+ const root = document.documentElement;
2293
+ root.style.setProperty('--ui', look.size);
2294
+ root.style.setProperty('--uifam', look.family);
2295
+ root.style.setProperty('--mono', look.mono);
2296
+ document.body.setAttribute('data-tint', look.tint || 'none');
2297
+ }
2298
+
2299
+ function setLook(change) {
2300
+ Object.assign(look, change);
2301
+ try { localStorage.setItem('koneck.look', JSON.stringify(look)); } catch (e) {}
2302
+ applyLook();
2303
+ }
2304
+
2305
+ /* ── a terminal ───────────────────────────────────────────────────────────────────────────── */
2306
+
2307
+ /**
2308
+ * Commands you run yourself.
2309
+ *
2310
+ * The interface had a card for every command the AGENT ran and no way to run one, which made it a
2311
+ * spectator's seat. Half of working in a repository is running things, and the distinction that
2312
+ * matters is not a setting but a fact about the command: a test run finishes and a dev server
2313
+ * does not, and a runner that waits for the second one looks broken.
2314
+ */
2315
+ const term = { selected: null, polling: null };
2316
+
2317
+ async function loadJobs(select) {
2318
+ let out;
2319
+ try { out = await api('/api/jobs'); } catch (e) { return; }
2320
+ const host = $('tjobs');
2321
+ host.innerHTML = '';
2322
+ if ((out.jobs || []).length === 0) {
2323
+ host.appendChild(el('div', 'wempty', 'Nothing run yet.'));
2324
+ }
2325
+ const pick = select || term.selected;
2326
+ let anyRunning = false;
2327
+ for (const j of out.jobs || []) {
2328
+ if (j.state === 'running') anyRunning = true;
2329
+ const row = el('div', 'jrow' + (j.id === pick ? ' on' : ''));
2330
+ row.appendChild(el('div', 'jc', j.command));
2331
+ const meta = el('div', 'jm');
2332
+ meta.appendChild(el('span', 'js ' + j.state, j.state
2333
+ + (j.exitCode !== null && j.state !== 'running' ? ' ' + j.exitCode : '')));
2334
+ if (j.background) meta.appendChild(el('span', null, 'background'));
2335
+ meta.appendChild(el('span', null, j.lines + (j.lines === 1 ? ' line' : ' lines')));
2336
+ if (j.state === 'running') {
2337
+ const stop = el('button', 'jk', 'kill');
2338
+ stop.onclick = async (ev) => {
2339
+ ev.stopPropagation();
2340
+ try { await api('/api/kill', { method: 'POST', body: JSON.stringify({ id: j.id }) }); }
2341
+ catch (e) {}
2342
+ loadJobs(j.id);
2343
+ };
2344
+ meta.appendChild(stop);
2345
+ }
2346
+ row.appendChild(meta);
2347
+ row.onclick = () => { term.selected = j.id; loadJobs(j.id); showJob(j.id); };
2348
+ host.appendChild(row);
2349
+ }
2350
+ if (pick) showJob(pick);
2351
+ // Polled only while something is running, and stopped when nothing is: a terminal tab left open
2352
+ // should not be a request every second for the rest of the day.
2353
+ if (anyRunning && !term.polling) {
2354
+ term.polling = setInterval(() => {
2355
+ if (state.view === 'terminal') loadJobs(); else { clearInterval(term.polling); term.polling = null; }
2356
+ }, 1000);
2357
+ } else if (!anyRunning && term.polling) {
2358
+ clearInterval(term.polling); term.polling = null;
2359
+ }
2360
+ }
2361
+
2362
+ async function showJob(id) {
2363
+ let job;
2364
+ try { job = await api('/api/jobs?id=' + encodeURIComponent(id)); } catch (e) { return; }
2365
+ const host = $('tout');
2366
+ const wasAtEnd = host.scrollHeight - host.scrollTop - host.clientHeight < 60;
2367
+ host.innerHTML = '';
2368
+ if (job.trimmed) host.appendChild(el('div', 'oe', '… earlier output dropped\n'));
2369
+ host.appendChild(document.createTextNode((job.lines || []).join('\n')));
2370
+ if (job.state !== 'running') {
2371
+ host.appendChild(el('div', job.state === 'exited' ? null : 'oe',
2372
+ '\n[' + job.state + (job.exitCode !== null ? ' ' + job.exitCode : '') + ' after '
2373
+ + ms((job.endedAt || Date.now()) - job.startedAt) + ']'));
2374
+ }
2375
+ if (wasAtEnd) host.scrollTop = host.scrollHeight;
2376
+ }
2377
+
2378
+ async function runCommand() {
2379
+ const command = $('tcmd').value.trim();
2380
+ if (!command) return;
2381
+ let out;
2382
+ try {
2383
+ out = await api('/api/run', { method: 'POST', body: JSON.stringify({
2384
+ command: command, background: $('tbackground').checked }) });
2385
+ } catch (e) {
2386
+ $('tout').textContent = e.message;
2387
+ return;
2388
+ }
2389
+ $('tcmd').value = '';
2390
+ term.selected = out.id;
2391
+ loadJobs(out.id);
2392
+ }
2393
+ $('trun').onclick = () => void runCommand();
2394
+ $('tcmd').addEventListener('keydown', (ev) => {
2395
+ if (ev.key === 'Enter') { ev.preventDefault(); void runCommand(); }
2396
+ });
2397
+
2398
+ /* ── the page, as it really renders ───────────────────────────────────────────────────────── */
2399
+
2400
+ /**
2401
+ * The same browser the agent drives.
2402
+ *
2403
+ * Deliberately the same one rather than a second: watching the agent work and then reaching in to
2404
+ * click something yourself only means anything if it is the same page. A click on the screenshot
2405
+ * is dispatched as a real mouse event at the same point, so what happens is what would happen to a
2406
+ * person clicking there.
2407
+ */
2408
+ const preview = { shot: null, width: 0 };
2409
+
2410
+ function drawPreview(data) {
2411
+ const host = $('pvbody');
2412
+ host.innerHTML = '';
2413
+ if (!data || data.running === false) {
2414
+ host.appendChild(el('div', 'pvnone', (data && data.note)
2415
+ || 'Nothing open yet.'));
2416
+ $('pvinfo').textContent = '';
2417
+ return;
2418
+ }
2419
+ $('pvurl').value = data.url || $('pvurl').value;
2420
+ $('pvinfo').textContent = data.title || '';
2421
+ if (!data.shot) {
2422
+ host.appendChild(el('div', 'pvnone', 'The browser is open but produced no picture.'));
2423
+ return;
2424
+ }
2425
+ const img = document.createElement('img');
2426
+ img.className = 'pvshot';
2427
+ img.src = data.shot;
2428
+ img.alt = data.title || 'the page';
2429
+ // The picture is 1280 wide whatever it is displayed at, so a click has to be scaled back into
2430
+ // page coordinates or it lands somewhere else entirely.
2431
+ img.onclick = async (ev) => {
2432
+ const box = img.getBoundingClientRect();
2433
+ const scale = img.naturalWidth / box.width;
2434
+ const x = Math.round((ev.clientX - box.left) * scale);
2435
+ const y = Math.round((ev.clientY - box.top) * scale);
2436
+ $('pvinfo').textContent = 'clicking ' + x + ',' + y + '…';
2437
+ await browserDo({ action: 'click', x: x, y: y });
2438
+ };
2439
+ host.appendChild(img);
2440
+ const hint = el('div', 'pvhint',
2441
+ 'Click the page to click it for real. ' + (data.controls || []).length
2442
+ + ' interactive elements. Scroll with the buttons below.');
2443
+ host.appendChild(hint);
2444
+ const row = el('div', 'pvhint');
2445
+ for (const [label, by] of [['scroll up', -600], ['scroll down', 600]]) {
2446
+ const b = el('button', 'pvbtn', label);
2447
+ b.style.margin = '0 4px';
2448
+ b.onclick = () => void browserDo({ action: 'scroll', by: by });
2449
+ row.appendChild(b);
2450
+ }
2451
+ host.appendChild(row);
2452
+ }
2453
+
2454
+ async function browserDo(body) {
2455
+ $('pvinfo').textContent = 'working…';
2456
+ let out;
2457
+ try { out = await api('/api/browser', { method: 'POST', body: JSON.stringify(body) }); }
2458
+ catch (e) {
2459
+ $('pvbody').innerHTML = '';
2460
+ $('pvbody').appendChild(el('div', 'pvnone', e.message));
2461
+ $('pvinfo').textContent = '';
2462
+ return;
2463
+ }
2464
+ drawPreview({ running: true, ...out });
2465
+ }
2466
+
2467
+ async function loadPreview() {
2468
+ let out;
2469
+ try { out = await api('/api/browser'); } catch (e) { out = null; }
2470
+ drawPreview(out);
2471
+ }
2472
+ $('pvgo').onclick = () => void browserDo({ action: 'open', url: $('pvurl').value.trim() });
2473
+ $('pvurl').addEventListener('keydown', (ev) => {
2474
+ if (ev.key === 'Enter') { ev.preventDefault(); $('pvgo').onclick(); }
2475
+ });
2476
+ $('pvrefresh').onclick = () => void browserDo({ action: 'read' });
2477
+ $('pvclose').onclick = async () => {
2478
+ try { await api('/api/browser', { method: 'POST', body: JSON.stringify({ action: 'close' }) }); }
2479
+ catch (e) {}
2480
+ loadPreview();
2481
+ };
2482
+
1917
2483
  /* ── watching it work ─────────────────────────────────────────────────────────────────────── */
1918
2484
 
1919
2485
  /**
@@ -2460,6 +3026,8 @@ for (const b of document.querySelectorAll('.hero .ex button')) {
2460
3026
 
2461
3027
  // The pill has to say what the remembered setting actually is before anything is asked.
2462
3028
  setFollow(follow.on);
3029
+ // And the page has to be set in the chosen type before it is looked at, not after.
3030
+ applyLook();
2463
3031
 
2464
3032
  (async function start() {
2465
3033
  let hello;
@@ -1 +1 @@
1
- {"version":3,"file":"ui-client.js","sourceRoot":"","sources":["../../src/web/ui-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,YAAY;IAC1B,OAAO,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAi6ElB,CAAC;AACF,CAAC"}
1
+ {"version":3,"file":"ui-client.js","sourceRoot":"","sources":["../../src/web/ui-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,YAAY;IAC1B,OAAO,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAy9FlB,CAAC;AACF,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"ui-css.d.ts","sourceRoot":"","sources":["../../src/web/ui-css.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,wBAAgB,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CA4oB3C"}
1
+ {"version":3,"file":"ui-css.d.ts","sourceRoot":"","sources":["../../src/web/ui-css.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,wBAAgB,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAqxB3C"}