koneck 2.31.1 → 2.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"ui-client.d.ts","sourceRoot":"","sources":["../../src/web/ui-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAy4DrC"}
1
+ {"version":3,"file":"ui-client.d.ts","sourceRoot":"","sources":["../../src/web/ui-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,IAAI,MAAM,CA8lErC"}
@@ -279,6 +279,39 @@ function renderAsk(e) {
279
279
  */
280
280
  let tick = null;
281
281
  let startedAt = 0;
282
+ /** What this turn has amounted to so far, which is what decides the wording. */
283
+ const turn = { tools: 0, recovered: false, seed: 0, word: '' };
284
+
285
+ /**
286
+ * Which family of words fits, by the same rule the terminal uses.
287
+ *
288
+ * Time alone cannot tell effort from latency, so a turn that ran no tools stays in the reply
289
+ * vocabulary however long it took: a cooking verb would be describing work that never happened.
290
+ */
291
+ function tierFor(elapsedMs, tools, recovered) {
292
+ if (recovered) return 'recovery';
293
+ if (tools === 0) return 'reply';
294
+ if (elapsedMs < 3000) return 'quick';
295
+ if (elapsedMs > 10000) return 'heavy';
296
+ return 'normal';
297
+ }
298
+
299
+ /** The gerund to show while working, and the past tense to report when the turn lands. */
300
+ function workWord(elapsedMs, tools, recovered, seed) {
301
+ const bucket = WORK_WORDS[tierFor(elapsedMs, tools, recovered)];
302
+ return bucket[Math.abs(seed) % bucket.length];
303
+ }
304
+
305
+ /**
306
+ * Whether the turn earned a verb at all.
307
+ *
308
+ * "Cooked lekker in 5.0s" for answering "hi" is a boast about nothing: no tool ran, and the only
309
+ * reason it took five seconds is that the provider is slow.
310
+ */
311
+ function earnedAVerb(tools, recovered, chars) {
312
+ return recovered || tools > 0 || chars >= 240;
313
+ }
314
+
282
315
  function setBusy(on, what) {
283
316
  state.busy = on;
284
317
  $('send').disabled = on || !$('ta').value.trim();
@@ -288,8 +321,20 @@ function setBusy(on, what) {
288
321
  if (on) {
289
322
  if (!tick) {
290
323
  startedAt = Date.now();
324
+ turn.tools = 0; turn.recovered = false; turn.word = '';
325
+ // One seed per turn, so the phrase is stable while the turn runs and different next time.
326
+ // Math.random is fine here: nothing depends on it being reproducible.
327
+ turn.seed = Math.floor(Math.random() * 100000);
291
328
  tick = setInterval(() => {
292
- $('liveelapsed').textContent = ms(Date.now() - startedAt);
329
+ const elapsed = Date.now() - startedAt;
330
+ $('liveelapsed').textContent = ms(elapsed);
331
+ // The word escalates as the wait grows: what began as "Sharp-sharp" should not still be
332
+ // claiming that two minutes later.
333
+ const pair = workWord(elapsed, turn.tools, turn.recovered, turn.seed);
334
+ if (pair && pair[0] !== turn.word) {
335
+ turn.word = pair[0];
336
+ $('livewhat').textContent = pair[0];
337
+ }
293
338
  }, 250);
294
339
  }
295
340
  } else if (tick) {
@@ -354,8 +399,11 @@ function onEvent(e) {
354
399
  // shape:" as one paragraph.
355
400
  state.replyEl = null;
356
401
  state.thinkEl = null;
402
+ turn.tools += 1;
357
403
  $('livetool').textContent = e.name + (e.detail ? ' ' + e.detail : '');
358
404
  renderTool(e);
405
+ // Follow it: the path a tool names is the file to be looking at.
406
+ watchPath(pathFromTool(e), e.name, false);
359
407
  break;
360
408
  case 'tool-output':
361
409
  if (state.lastTool) {
@@ -363,11 +411,15 @@ function onEvent(e) {
363
411
  (state.lastTool.body.textContent + e.line + '\n').slice(-5000);
364
412
  }
365
413
  break;
366
- case 'tool-done':
414
+ case 'tool-done': {
367
415
  finishTool(e);
368
416
  setBusy(true, 'thinking');
369
417
  $('livetool').textContent = '';
418
+ // Once it has finished writing, the diff is what there is to see.
419
+ const wrotePath = pathFromTool(e);
420
+ if (e.ok && toolWrites(e.name)) watchPath(wrotePath || follow.path, e.name, true);
370
421
  break;
422
+ }
371
423
  case 'plan': renderPlan(e.steps); break;
372
424
  case 'agents': renderAgents(e.agents); break;
373
425
  case 'tokens':
@@ -383,7 +435,16 @@ function onEvent(e) {
383
435
  // The server says a turn touched files, so the badge needs no polling to be right.
384
436
  $('tab-changes').innerHTML = 'Changes <span class="n">' + e.files + '</span>';
385
437
  break;
386
- case 'turn-end':
438
+ case 'turn-end': {
439
+ // What it came to, in the tense that matches. The terminal has always reported this; the
440
+ // browser used to just stop showing a spinner, which reads as though nothing happened.
441
+ const took = Date.now() - startedAt;
442
+ const said = state.replyEl ? (state.replyEl.textContent || '').length : 0;
443
+ const pair = workWord(took, turn.tools, turn.recovered, turn.seed);
444
+ const verb = earnedAVerb(turn.tools, turn.recovered, said) && pair ? pair[1] : '';
445
+ const line = el('div', 'landed', (verb ? verb + ' · ' : '') + ms(took)
446
+ + (turn.tools ? ' · ' + turn.tools + (turn.tools === 1 ? ' tool' : ' tools') : ''));
447
+ if (startedAt) stream().appendChild(line);
387
448
  setBusy(false);
388
449
  state.replyEl = null; state.thinkEl = null;
389
450
  if (state.view === 'changes') loadChanges();
@@ -393,6 +454,7 @@ function onEvent(e) {
393
454
  if (mine) setPosture(mine);
394
455
  }).catch(() => {});
395
456
  break;
457
+ }
396
458
  }
397
459
  }
398
460
 
@@ -1750,10 +1812,136 @@ $('pmode').onclick = async () => {
1750
1812
  }
1751
1813
  $('modedlg').showModal();
1752
1814
  };
1815
+ /* ── following the file being worked on ───────────────────────────────────────────────────── */
1816
+
1817
+ /**
1818
+ * The panel beside the conversation, showing whatever the agent is touching.
1819
+ *
1820
+ * On by default, because the interesting part of watching an agent work is watching files change,
1821
+ * and a view you have to go and click is one you find out about after the fact. Remembered per
1822
+ * browser: a preference about a layout belongs to whoever is looking at it.
1823
+ */
1824
+ const follow = {
1825
+ on: (() => { try { return localStorage.getItem('koneck.follow') !== 'off'; } catch (e) { return true; } })(),
1826
+ path: null,
1827
+ /** Set while a fetch is in flight, so a burst of tool calls does not queue a burst of requests. */
1828
+ busy: false,
1829
+ /** The path a newer tool named while a fetch was running. */
1830
+ pending: null,
1831
+ };
1832
+
1833
+ /** The file a tool is acting on, from its arguments. Null when it is not about a file. */
1834
+ function pathFromTool(e) {
1835
+ let args = e.args;
1836
+ if (!args && e.detail) args = null;
1837
+ if (args) {
1838
+ try {
1839
+ const parsed = JSON.parse(args);
1840
+ for (const key of ['path', 'file', 'filename', 'file_path']) {
1841
+ if (typeof parsed[key] === 'string' && parsed[key].trim()) return parsed[key].trim();
1842
+ }
1843
+ if (Array.isArray(parsed.paths) && typeof parsed.paths[0] === 'string') return parsed.paths[0];
1844
+ } catch (err) { /* not JSON: fall through to the one-line detail */ }
1845
+ }
1846
+ // The detail line is often just the path, which is enough to follow by.
1847
+ if (e.detail && /[./]/.test(e.detail) && !/\s/.test(e.detail.trim())) return e.detail.trim();
1848
+ return null;
1849
+ }
1850
+
1851
+ /** Whether a tool changes a file, which decides whether the panel shows content or a diff. */
1852
+ function toolWrites(name) {
1853
+ return /write|edit|patch|replace|apply|create|delete|move|rename|format/i.test(name || '');
1854
+ }
1855
+
1856
+ function setFollow(on) {
1857
+ follow.on = on;
1858
+ try { localStorage.setItem('koneck.follow', on ? 'on' : 'off'); } catch (e) {}
1859
+ $('pfollow').innerHTML = 'follow <b>' + (on ? 'on' : 'off') + '</b>';
1860
+ $('watch').hidden = !on || !follow.path;
1861
+ if (on && follow.path) void showWatched(follow.path, null);
1862
+ }
1863
+
1864
+ /** Points the panel at a path. Cheap to call repeatedly; only the newest target is fetched. */
1865
+ function watchPath(path, toolName, wrote) {
1866
+ if (!follow.on || !path) return;
1867
+ follow.path = path;
1868
+ $('watch').hidden = false;
1869
+ $('wpath').textContent = path;
1870
+ $('wtool').textContent = toolName || 'watching';
1871
+ if (follow.busy) { follow.pending = [path, wrote]; return; }
1872
+ void showWatched(path, wrote);
1873
+ }
1874
+
1875
+ /**
1876
+ * Draws the panel.
1877
+ *
1878
+ * A file that was only read is shown as itself. A file that was just written is shown as its diff,
1879
+ * because what changed is the thing worth seeing and reading the whole file to find three new
1880
+ * lines is work the panel should be doing.
1881
+ */
1882
+ async function showWatched(path, wrote) {
1883
+ follow.busy = true;
1884
+ const host = $('wbody');
1885
+ try {
1886
+ if (wrote && state.session) {
1887
+ let ch = null;
1888
+ try {
1889
+ const out = await api('/api/changes?session=' + encodeURIComponent(state.session));
1890
+ ch = (out.changes || []).find((c) => c.path === path || path.endsWith(c.path));
1891
+ } catch (e) { /* fall through to showing the file */ }
1892
+ if (ch && ch.diff) {
1893
+ host.innerHTML = '';
1894
+ const head = el('div', 'wempty');
1895
+ head.style.padding = '8px 12px';
1896
+ head.textContent = '+' + ch.added + ' \u2212' + ch.removed + ' ' + ch.kind;
1897
+ host.appendChild(head);
1898
+ host.appendChild(renderDiff(ch.diff));
1899
+ return;
1900
+ }
1901
+ }
1902
+ let data;
1903
+ try { data = await api('/api/file?path=' + encodeURIComponent(path)); }
1904
+ catch (e) {
1905
+ host.innerHTML = '';
1906
+ host.appendChild(el('div', 'wempty', e.message));
1907
+ return;
1908
+ }
1909
+ host.innerHTML = '';
1910
+ if (data.binary || data.text === null) {
1911
+ host.appendChild(el('div', 'wempty', 'Binary file — nothing to show as text.'));
1912
+ return;
1913
+ }
1914
+ const code = el('div', 'code');
1915
+ const lines = data.text.split('\n');
1916
+ code.appendChild(el('div', 'gut', lines.map((_, i) => i + 1).join('\n')));
1917
+ code.appendChild(el('div', 'src', data.text));
1918
+ host.appendChild(code);
1919
+ } finally {
1920
+ follow.busy = false;
1921
+ // A newer target arrived while this was in flight: honour the last one and drop the rest,
1922
+ // which is what keeps a run of twenty edits to twenty files at one request rather than twenty.
1923
+ if (follow.pending) {
1924
+ const next = follow.pending;
1925
+ follow.pending = null;
1926
+ if (next[0] !== path || next[1]) void showWatched(next[0], next[1]);
1927
+ }
1928
+ }
1929
+ }
1930
+
1931
+ $('pfollow').onclick = () => setFollow(!follow.on);
1932
+ $('wclose').onclick = () => setFollow(false);
1933
+
1753
1934
  /* ── choosing a model ────────────────────────────────────────────────────────────────────── */
1754
1935
 
1755
- /** The provider's own listing, kept so filtering does not re-ask on every keystroke. */
1756
- const catalog = { provider: null, entries: [], listed: false, note: '' };
1936
+ /**
1937
+ * The provider's own listing, kept so filtering does not re-ask on every keystroke.
1938
+ *
1939
+ * The chosen entry is the model that will be switched to, and it is deliberately NOT the search
1940
+ * box. They were the same field at first, so opening the dialog pre-filled the box with the model
1941
+ * already in use — and since the box also filtered, 476 models were immediately narrowed to the
1942
+ * one you were on. It looked exactly like a text field with no choice in it, because it was.
1943
+ */
1944
+ const catalog = { provider: null, entries: [], listed: false, note: '', chosen: '' };
1757
1945
 
1758
1946
  /** Fills the provider dropdown from whatever the registry holds, declared entries included. */
1759
1947
  async function fillProviders(selected) {
@@ -1775,46 +1963,61 @@ async function fillProviders(selected) {
1775
1963
  return info;
1776
1964
  }
1777
1965
 
1778
- /** Draws the listing, narrowed to what has been typed. */
1966
+ /** What Switch will use: whatever row is picked, or a name typed in full when nothing is. */
1967
+ function chosenModel() {
1968
+ const typed = $('mmodel').value.trim();
1969
+ // A provider that publishes no list leaves the box as the only way in, so there the box IS the
1970
+ // answer. Otherwise a picked row wins, and typing only narrows the list.
1971
+ if (!catalog.listed) return typed;
1972
+ if (catalog.chosen) return catalog.chosen;
1973
+ return typed;
1974
+ }
1975
+
1976
+ /** Draws the listing, narrowed by the search box. */
1779
1977
  function drawModels() {
1780
1978
  const host = $('mlist');
1781
1979
  const typed = $('mmodel').value.trim().toLowerCase();
1782
1980
  host.innerHTML = '';
1981
+ if (!catalog.listed) { $('mnote').textContent = catalog.note || ''; return; }
1982
+
1783
1983
  const shown = catalog.entries.filter((m) => !typed || m.id.toLowerCase().includes(typed));
1984
+ const total = catalog.entries.length;
1784
1985
 
1785
- if (!catalog.listed) { $('mnote').textContent = catalog.note || ''; return; }
1786
1986
  if (shown.length === 0) {
1787
- $('mnote').textContent = 'Nothing in the list matches. It will be used as typed.';
1987
+ // Not a dead end: a gateway may serve something it does not advertise, so an unmatched name
1988
+ // stays usable — but it has to be said out loud rather than silently accepted.
1989
+ $('mnote').textContent = 'Nothing in ' + total + ' matches. Switch will use it as typed.';
1990
+ catalog.chosen = '';
1788
1991
  return;
1789
1992
  }
1790
- $('mnote').textContent = shown.length === catalog.entries.length
1791
- ? catalog.entries.length + ' models'
1792
- : shown.length + ' of ' + catalog.entries.length + ' models';
1993
+ $('mnote').textContent = (shown.length === total ? total + ' models' : shown.length + ' of ' + total)
1994
+ + (catalog.chosen ? ' · using ' + catalog.chosen : ' · click one to choose');
1793
1995
 
1794
- // Grouped by vendor, which is what turns a flat several-hundred-entry list into something
1795
- // you can actually read.
1796
- let group = null;
1996
+ // Grouped by vendor, which is what turns a flat several-hundred-entry list into something you
1997
+ // can actually read.
1998
+ let group = null, first = null;
1797
1999
  for (const m of shown.slice(0, 400)) {
1798
2000
  if (m.vendor !== group) {
1799
2001
  group = m.vendor;
1800
2002
  host.appendChild(el('div', 'mgroup', group));
1801
2003
  }
1802
- const row = el('div', 'mrow' + (m.id === $('mmodel').value.trim() ? ' on' : ''));
2004
+ const on = m.id === catalog.chosen;
2005
+ const row = el('div', 'mrow' + (on ? ' on' : ''));
1803
2006
  row.appendChild(el('div', 'mid', m.id));
1804
2007
  row.appendChild(el('div', 'mdet', m.detail));
1805
- row.onclick = () => {
1806
- $('mmodel').value = m.id;
1807
- drawModels();
1808
- };
2008
+ row.onclick = () => { catalog.chosen = m.id; drawModels(); };
1809
2009
  host.appendChild(row);
2010
+ if (on && !first) first = row;
1810
2011
  }
1811
2012
  if (shown.length > 400) {
1812
2013
  host.appendChild(el('div', 'mgroup', 'and ' + (shown.length - 400) + ' more — keep typing'));
1813
2014
  }
2015
+ // The model in use should be in view when the dialog opens, not hundreds of rows down.
2016
+ if (first) first.scrollIntoView({ block: 'center' });
1814
2017
  }
1815
2018
 
1816
2019
  /** Asks the selected provider what it serves. */
1817
- async function loadModels(provider) {
2020
+ async function loadModels(provider, preselect) {
1818
2021
  if (catalog.provider === provider) { drawModels(); return; }
1819
2022
  $('mlist').innerHTML = '';
1820
2023
  $('mnote').textContent = 'Asking ' + provider + ' what it serves…';
@@ -1823,6 +2026,9 @@ async function loadModels(provider) {
1823
2026
  catch (e) {
1824
2027
  catalog.provider = provider; catalog.entries = []; catalog.listed = false;
1825
2028
  catalog.note = e.message;
2029
+ catalog.chosen = '';
2030
+ // With no list, the box is the only way to name a model, so it gets the current one to edit.
2031
+ if (preselect) $('mmodel').value = preselect;
1826
2032
  drawModels();
1827
2033
  return;
1828
2034
  }
@@ -1830,24 +2036,28 @@ async function loadModels(provider) {
1830
2036
  catalog.entries = out.entries || [];
1831
2037
  catalog.listed = !!out.listed;
1832
2038
  catalog.note = out.note || '';
2039
+ // Preselect only if the provider actually serves it: carrying a name across providers is how a
2040
+ // session ends up asking Anthropic for qwen3-coder.
2041
+ catalog.chosen = preselect && catalog.entries.some((m) => m.id === preselect) ? preselect : '';
2042
+ if (!catalog.listed && preselect) $('mmodel').value = preselect;
1833
2043
  drawModels();
1834
2044
  }
1835
2045
 
1836
2046
  $('pmodel').onclick = async () => {
1837
2047
  const info = await fillProviders(state.provider);
1838
2048
  if (!info) return;
1839
- $('mmodel').value = state.model === '-' ? '' : state.model;
2049
+ // Empty, because this box searches. Pre-filling it with the model already in use filtered the
2050
+ // list down to that one model and made the whole listing look like it was not there.
2051
+ $('mmodel').value = '';
1840
2052
  catalog.provider = null; // re-ask: a key may have been set since last time
1841
2053
  $('mdlg').showModal();
1842
- void loadModels($('mprov').value);
2054
+ void loadModels($('mprov').value, state.model === '-' ? '' : state.model);
1843
2055
  };
1844
2056
  $('mprov').onchange = () => {
1845
- // A different provider serves different models, and its default is a better starting point than
1846
- // the name of a model the previous one happened to serve.
1847
- const chosen = $('mprov').selectedOptions[0];
2057
+ // A different provider serves different models, so nothing carries over.
1848
2058
  $('mmodel').value = '';
1849
- void loadModels($('mprov').value);
1850
- if (chosen) $('mmodel').placeholder = 'filter, or type a model name';
2059
+ catalog.chosen = '';
2060
+ void loadModels($('mprov').value, '');
1851
2061
  };
1852
2062
  $('mmodel').oninput = () => drawModels();
1853
2063
 
@@ -1909,7 +2119,7 @@ async function afterProviderSaved(out) {
1909
2119
  }
1910
2120
 
1911
2121
  $('msave').onclick = async () => {
1912
- const provider = $('mprov').value, model = $('mmodel').value.trim();
2122
+ const provider = $('mprov').value, model = chosenModel();
1913
2123
  if (!state.session) await newSession();
1914
2124
  let info;
1915
2125
  try {
@@ -1923,6 +2133,9 @@ for (const b of document.querySelectorAll('.hero .ex button')) {
1923
2133
  b.onclick = () => send(b.textContent);
1924
2134
  }
1925
2135
 
2136
+ // The pill has to say what the remembered setting actually is before anything is asked.
2137
+ setFollow(follow.on);
2138
+
1926
2139
  (async function start() {
1927
2140
  let hello;
1928
2141
  try { hello = await fetch('/api/hello').then((r) => r.json()); } catch (e) {}
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAu4DlB,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4lElB,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,CAqgB3C"}
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,CAkiB3C"}
@@ -518,6 +518,35 @@ dialog:has(.dlg.wide){max-width:560px}
518
518
  .mrow .mid{font-family:var(--mono);font-size:12.5px;flex:1;min-width:0;overflow-wrap:anywhere}
519
519
  .mrow .mdet{font-size:11px;color:var(--dim);white-space:nowrap;flex:0 0 auto}
520
520
  .mrow.on .mid{color:var(--cyan)}
521
+ /*
522
+ * The file being worked on, beside the conversation.
523
+ *
524
+ * Watching an agent work means watching files change, and a tab you have to click is a tab you
525
+ * find out about afterwards. This follows the tool calls: the path a tool names becomes the panel,
526
+ * and once the file has actually been written it becomes that file's diff.
527
+ */
528
+ .chatsplit{flex:1;display:flex;min-height:0;min-width:0}
529
+ .chatsplit .wrap{flex:1 1 auto;min-width:0}
530
+ .watch{flex:0 0 44%;max-width:720px;min-width:0;border-left:1px solid var(--line);
531
+ display:flex;flex-direction:column;background:var(--panel-2)}
532
+ .whead{display:flex;gap:9px;align-items:center;padding:9px 12px;background:var(--panel);
533
+ border-bottom:1px solid var(--line);font-size:11.5px;flex:0 0 auto}
534
+ .whead .wt{color:var(--cyan);font-size:10px;letter-spacing:.09em;text-transform:uppercase;
535
+ flex:0 0 auto}
536
+ .whead .wp{font-family:var(--mono);font-size:12px;flex:1;min-width:0;overflow:hidden;
537
+ text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}
538
+ .whead .wx{flex:0 0 auto;background:none;border:0;color:var(--dim);font-size:15px;cursor:pointer;
539
+ line-height:1;padding:0 2px}
540
+ .whead .wx:hover{color:var(--red)}
541
+ .wbody{flex:1;overflow:auto;min-height:0}
542
+ .wbody .code{min-height:100%}
543
+ .wbody .diff{border-top:0}
544
+ .wempty{padding:22px 14px;color:var(--dim);font-size:12.5px;line-height:1.6}
545
+ @media (max-width:1100px){ .watch{display:none} }
546
+
547
+ /* What the turn came to, in the tense that matches — the line the terminal has always printed. */
548
+ .landed{font-size:11.5px;color:var(--dim);font-family:var(--mono);margin:2px 0 14px;
549
+ letter-spacing:.02em}
521
550
  .dlg .row{display:flex;gap:8px;justify-content:flex-end}
522
551
  .dlg .row button{padding:7px 15px;border-radius:8px;border:1px solid var(--line)}
523
552
  .dlg .go{border-color:var(--cyan);color:var(--cyan)}
@@ -1 +1 @@
1
- {"version":3,"file":"ui-css.js","sourceRoot":"","sources":["../../src/web/ui-css.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,GAAG,CAAC,OAAe;IACjC,OAAO;;;;;;;;;;gBAUO,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyftB,CAAC;AACF,CAAC"}
1
+ {"version":3,"file":"ui-css.js","sourceRoot":"","sources":["../../src/web/ui-css.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,GAAG,CAAC,OAAe;IACjC,OAAO;;;;;;;;;;gBAUO,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAshBtB,CAAC;AACF,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"ui.d.ts","sourceRoot":"","sources":["../../src/web/ui.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,wBAAgB,IAAI,IAAI,MAAM,CA0N7B"}
1
+ {"version":3,"file":"ui.d.ts","sourceRoot":"","sources":["../../src/web/ui.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAOH,wBAAgB,IAAI,IAAI,MAAM,CA2O7B"}
package/dist/web/ui.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * model, which is one that is offline.
9
9
  */
10
10
  import { MARK_MASK } from './mark.js';
11
+ import { WORK_WORDS } from '../work-words.js';
11
12
  import { css } from './ui-css.js';
12
13
  import { clientScript } from './ui-client.js';
13
14
  export function page() {
@@ -51,6 +52,7 @@ export function page() {
51
52
  <button class="tab" id="tab-settings">Settings</button>
52
53
  </div>
53
54
  <div class="spacer"></div>
55
+ <button class="pill" id="pfollow" title="Show the file being worked on beside the conversation">follow <b>on</b></button>
54
56
  <button class="pill" id="pgoal" title="A standing objective, put in front of every message">goal <b>none</b></button>
55
57
  <button class="pill" id="peffort" title="How hard to think">effort <b>medium</b></button>
56
58
  <button class="pill" id="pmode" title="What KONECK may do without asking">mode <b>auto</b></button>
@@ -62,6 +64,7 @@ export function page() {
62
64
  </header>
63
65
 
64
66
  <div class="view" id="chatview">
67
+ <div class="chatsplit">
65
68
  <div class="wrap" id="stream">
66
69
  <div class="hero">
67
70
  <div class="mark"></div>
@@ -74,6 +77,17 @@ export function page() {
74
77
  </div>
75
78
  </div>
76
79
  </div>
80
+ <!-- The file being worked on, beside the conversation rather than behind a tab. A tool call
81
+ names a path; this shows it, and shows the diff once the file has actually been written. -->
82
+ <aside class="watch" id="watch" hidden>
83
+ <div class="whead">
84
+ <span class="wt" id="wtool">watching</span>
85
+ <span class="wp" id="wpath">&mdash;</span>
86
+ <button class="wx" id="wclose" title="Stop following (also the follow pill)">&times;</button>
87
+ </div>
88
+ <div class="wbody" id="wbody"></div>
89
+ </aside>
90
+ </div>
77
91
  </div>
78
92
 
79
93
  <div class="view" id="changesview" hidden></div>
@@ -199,7 +213,7 @@ export function page() {
199
213
  <!-- Typing stays possible on purpose: a gateway may not publish a list at all, and a virtual
200
214
  model such as OmniRoute's auto/best-coding is a routing instruction that appears in no
201
215
  catalogue. The list below narrows as you type. -->
202
- <input id="mmodel" placeholder="filter, or type a model name" autocomplete="off" spellcheck="false">
216
+ <input id="mmodel" placeholder="search the list, or type a name to use" autocomplete="off" spellcheck="false">
203
217
  <div class="mnote" id="mnote">Asking the provider what it serves&hellip;</div>
204
218
  <div class="mlist" id="mlist"></div>
205
219
  <div class="row"><button>Cancel</button><button class="go" id="msave">Switch</button></div>
@@ -225,6 +239,10 @@ export function page() {
225
239
  </form></dialog>
226
240
 
227
241
  <script>
242
+ /* The same vocabulary the terminal narrates with, embedded rather than fetched so the page still
243
+ has it on a machine with no network — which is the machine most likely to be running a local
244
+ model. Generated from src/work-words.ts, so there is one list, not two. */
245
+ const WORK_WORDS = ${JSON.stringify(WORK_WORDS)};
228
246
  ${clientScript()}
229
247
  </script>
230
248
  </body></html>`;
@@ -1 +1 @@
1
- {"version":3,"file":"ui.js","sourceRoot":"","sources":["../../src/web/ui.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,GAAG,EAAE,MAAM,aAAa,CAAC;AAClC,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAE9C,MAAM,UAAU,IAAI;IAClB,OAAO;;;;;;;SAOA,GAAG,CAAC,SAAS,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA+MrB,YAAY,EAAE;;eAED,CAAC;AAChB,CAAC"}
1
+ {"version":3,"file":"ui.js","sourceRoot":"","sources":["../../src/web/ui.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAE,GAAG,EAAE,MAAM,aAAa,CAAC;AAClC,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAE9C,MAAM,UAAU,IAAI;IAClB,OAAO;;;;;;;SAOA,GAAG,CAAC,SAAS,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBA+NF,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC;EAC7C,YAAY,EAAE;;eAED,CAAC;AAChB,CAAC"}
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The words, in one place.
3
+ *
4
+ * These lived in the Ink component, so the browser had no way to reach them and showed a bare
5
+ * "working" instead — the same turn narrated with personality in one window and flatly in the
6
+ * other. Nothing here is terminal-specific: it is vocabulary and a rule for choosing from it.
7
+ */
8
+ /**
9
+ * What KONECK says it is doing, and what it says it did.
10
+ *
11
+ * Grouped by how much work the turn turned out to be, so the wording matches the wait: a
12
+ * sub-three-second answer gets something snappy, a two-minute one gets something that sounds
13
+ * like effort, and a turn that had to recover from a failed tool gets a clutch verb. The word
14
+ * shown while working escalates through the tiers as time passes, and the finished row reports
15
+ * the tense that matches the real elapsed time.
16
+ *
17
+ * Apostrophes are ASCII on purpose: U+2019 is East Asian Ambiguous, the same class of character
18
+ * that used to tear the panel borders.
19
+ */
20
+ export type WorkTier = 'reply' | 'quick' | 'normal' | 'heavy' | 'recovery';
21
+ export declare const WORK_WORDS: Record<WorkTier, Array<readonly [string, string]>>;
22
+ /** Which vocabulary the wait deserves. */
23
+ /** What a turn actually amounted to, which is what the wording should follow. */
24
+ export interface TurnEffort {
25
+ elapsedMs: number;
26
+ /** Tools the turn ran. Zero means it only thought and answered. */
27
+ tools: number;
28
+ /** Output tokens produced. */
29
+ tokens: number;
30
+ /** A tool failed and the turn recovered from it. */
31
+ recovered: boolean;
32
+ }
33
+ /**
34
+ * Whether the turn earned a verb at all.
35
+ *
36
+ * "Cooked lekker in 5.0s" for answering "hi" is a boast about nothing: no tool ran, ten tokens
37
+ * came back, and the only reason it took five seconds is that the provider is slow. Elapsed time
38
+ * alone cannot tell effort from latency, so a turn that ran no tools and said almost nothing gets
39
+ * its timing reported plainly and no verb.
40
+ */
41
+ export declare function shouldName(effort: TurnEffort): boolean;
42
+ /**
43
+ * Which family of words fits.
44
+ *
45
+ * Time still decides between quick, normal and heavy, but only once the turn has done something.
46
+ * A turn with no tools belongs to `reply` however long it took, because nothing was built and a
47
+ * cooking verb would be describing work that never happened.
48
+ */
49
+ export declare function tierFor(effort: TurnEffort): WorkTier;
50
+ /**
51
+ * The gerund to show while working, and the past tense to report when the turn lands.
52
+ *
53
+ * The past tense is empty when the turn did not earn one, which the caller renders as timing
54
+ * alone.
55
+ */
56
+ export declare function workWord(effort: TurnEffort, seed: number): readonly [string, string];
57
+ //# sourceMappingURL=work-words.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"work-words.d.ts","sourceRoot":"","sources":["../src/work-words.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,GAAG,OAAO,GAAG,UAAU,CAAC;AAE3E,eAAO,MAAM,UAAU,EAAE,MAAM,CAAC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAqLzE,CAAC;AAEF,0CAA0C;AAC1C,iFAAiF;AACjF,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,KAAK,EAAE,MAAM,CAAC;IACd,8BAA8B;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,oDAAoD;IACpD,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAItD;AAED;;;;;;GAMG;AACH,wBAAgB,OAAO,CAAC,MAAM,EAAE,UAAU,GAAG,QAAQ,CAMpD;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAIpF"}