koneck 2.31.0 → 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.
@@ -62,9 +62,14 @@ const api = async (path, opts) => {
62
62
  });
63
63
  if (res.status === 401) { $('tokdlg').showModal(); throw new Error('token required'); }
64
64
  if (!res.ok) {
65
- let msg = res.statusText;
66
- try { msg = (await res.json()).error || msg; } catch (e) {}
67
- throw new Error(msg);
65
+ let msg = res.statusText, body = null;
66
+ try { body = await res.json(); msg = body.error || msg; } catch (e) {}
67
+ const err = new Error(msg);
68
+ // The status and the body travel with the error: a refusal that can be confirmed and retried
69
+ // is not the same as one that cannot, and the caller is what knows the difference.
70
+ err.status = res.status;
71
+ err.body = body;
72
+ throw err;
68
73
  }
69
74
  return res.status === 204 ? null : res.json();
70
75
  };
@@ -274,6 +279,39 @@ function renderAsk(e) {
274
279
  */
275
280
  let tick = null;
276
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
+
277
315
  function setBusy(on, what) {
278
316
  state.busy = on;
279
317
  $('send').disabled = on || !$('ta').value.trim();
@@ -283,8 +321,20 @@ function setBusy(on, what) {
283
321
  if (on) {
284
322
  if (!tick) {
285
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);
286
328
  tick = setInterval(() => {
287
- $('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
+ }
288
338
  }, 250);
289
339
  }
290
340
  } else if (tick) {
@@ -349,8 +399,11 @@ function onEvent(e) {
349
399
  // shape:" as one paragraph.
350
400
  state.replyEl = null;
351
401
  state.thinkEl = null;
402
+ turn.tools += 1;
352
403
  $('livetool').textContent = e.name + (e.detail ? ' ' + e.detail : '');
353
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);
354
407
  break;
355
408
  case 'tool-output':
356
409
  if (state.lastTool) {
@@ -358,11 +411,15 @@ function onEvent(e) {
358
411
  (state.lastTool.body.textContent + e.line + '\n').slice(-5000);
359
412
  }
360
413
  break;
361
- case 'tool-done':
414
+ case 'tool-done': {
362
415
  finishTool(e);
363
416
  setBusy(true, 'thinking');
364
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);
365
421
  break;
422
+ }
366
423
  case 'plan': renderPlan(e.steps); break;
367
424
  case 'agents': renderAgents(e.agents); break;
368
425
  case 'tokens':
@@ -378,7 +435,16 @@ function onEvent(e) {
378
435
  // The server says a turn touched files, so the badge needs no polling to be right.
379
436
  $('tab-changes').innerHTML = 'Changes <span class="n">' + e.files + '</span>';
380
437
  break;
381
- 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);
382
448
  setBusy(false);
383
449
  state.replyEl = null; state.thinkEl = null;
384
450
  if (state.view === 'changes') loadChanges();
@@ -388,6 +454,7 @@ function onEvent(e) {
388
454
  if (mine) setPosture(mine);
389
455
  }).catch(() => {});
390
456
  break;
457
+ }
391
458
  }
392
459
  }
393
460
 
@@ -1745,10 +1812,136 @@ $('pmode').onclick = async () => {
1745
1812
  }
1746
1813
  $('modedlg').showModal();
1747
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
+
1748
1934
  /* ── choosing a model ────────────────────────────────────────────────────────────────────── */
1749
1935
 
1750
- /** The provider's own listing, kept so filtering does not re-ask on every keystroke. */
1751
- 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: '' };
1752
1945
 
1753
1946
  /** Fills the provider dropdown from whatever the registry holds, declared entries included. */
1754
1947
  async function fillProviders(selected) {
@@ -1770,46 +1963,61 @@ async function fillProviders(selected) {
1770
1963
  return info;
1771
1964
  }
1772
1965
 
1773
- /** 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. */
1774
1977
  function drawModels() {
1775
1978
  const host = $('mlist');
1776
1979
  const typed = $('mmodel').value.trim().toLowerCase();
1777
1980
  host.innerHTML = '';
1981
+ if (!catalog.listed) { $('mnote').textContent = catalog.note || ''; return; }
1982
+
1778
1983
  const shown = catalog.entries.filter((m) => !typed || m.id.toLowerCase().includes(typed));
1984
+ const total = catalog.entries.length;
1779
1985
 
1780
- if (!catalog.listed) { $('mnote').textContent = catalog.note || ''; return; }
1781
1986
  if (shown.length === 0) {
1782
- $('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 = '';
1783
1991
  return;
1784
1992
  }
1785
- $('mnote').textContent = shown.length === catalog.entries.length
1786
- ? catalog.entries.length + ' models'
1787
- : 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');
1788
1995
 
1789
- // Grouped by vendor, which is what turns a flat several-hundred-entry list into something
1790
- // you can actually read.
1791
- 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;
1792
1999
  for (const m of shown.slice(0, 400)) {
1793
2000
  if (m.vendor !== group) {
1794
2001
  group = m.vendor;
1795
2002
  host.appendChild(el('div', 'mgroup', group));
1796
2003
  }
1797
- 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' : ''));
1798
2006
  row.appendChild(el('div', 'mid', m.id));
1799
2007
  row.appendChild(el('div', 'mdet', m.detail));
1800
- row.onclick = () => {
1801
- $('mmodel').value = m.id;
1802
- drawModels();
1803
- };
2008
+ row.onclick = () => { catalog.chosen = m.id; drawModels(); };
1804
2009
  host.appendChild(row);
2010
+ if (on && !first) first = row;
1805
2011
  }
1806
2012
  if (shown.length > 400) {
1807
2013
  host.appendChild(el('div', 'mgroup', 'and ' + (shown.length - 400) + ' more — keep typing'));
1808
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' });
1809
2017
  }
1810
2018
 
1811
2019
  /** Asks the selected provider what it serves. */
1812
- async function loadModels(provider) {
2020
+ async function loadModels(provider, preselect) {
1813
2021
  if (catalog.provider === provider) { drawModels(); return; }
1814
2022
  $('mlist').innerHTML = '';
1815
2023
  $('mnote').textContent = 'Asking ' + provider + ' what it serves…';
@@ -1818,6 +2026,9 @@ async function loadModels(provider) {
1818
2026
  catch (e) {
1819
2027
  catalog.provider = provider; catalog.entries = []; catalog.listed = false;
1820
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;
1821
2032
  drawModels();
1822
2033
  return;
1823
2034
  }
@@ -1825,24 +2036,28 @@ async function loadModels(provider) {
1825
2036
  catalog.entries = out.entries || [];
1826
2037
  catalog.listed = !!out.listed;
1827
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;
1828
2043
  drawModels();
1829
2044
  }
1830
2045
 
1831
2046
  $('pmodel').onclick = async () => {
1832
2047
  const info = await fillProviders(state.provider);
1833
2048
  if (!info) return;
1834
- $('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 = '';
1835
2052
  catalog.provider = null; // re-ask: a key may have been set since last time
1836
2053
  $('mdlg').showModal();
1837
- void loadModels($('mprov').value);
2054
+ void loadModels($('mprov').value, state.model === '-' ? '' : state.model);
1838
2055
  };
1839
2056
  $('mprov').onchange = () => {
1840
- // A different provider serves different models, and its default is a better starting point than
1841
- // the name of a model the previous one happened to serve.
1842
- const chosen = $('mprov').selectedOptions[0];
2057
+ // A different provider serves different models, so nothing carries over.
1843
2058
  $('mmodel').value = '';
1844
- void loadModels($('mprov').value);
1845
- if (chosen) $('mmodel').placeholder = 'filter, or type a model name';
2059
+ catalog.chosen = '';
2060
+ void loadModels($('mprov').value, '');
1846
2061
  };
1847
2062
  $('mmodel').oninput = () => drawModels();
1848
2063
 
@@ -1850,7 +2065,8 @@ $('addprov').onclick = () => {
1850
2065
  $('pverr').hidden = true;
1851
2066
  $('provdlg').showModal();
1852
2067
  };
1853
- $('pvsave').onclick = async (ev) => {
2068
+ /** Submits the form, once plainly and once with consent if a built-in is in the way. */
2069
+ async function saveProvider(replace) {
1854
2070
  const body = {
1855
2071
  name: $('pvname').value.trim(),
1856
2072
  label: $('pvname').value.trim(),
@@ -1858,25 +2074,52 @@ $('pvsave').onclick = async (ev) => {
1858
2074
  defaultModel: $('pvmodel').value.trim(),
1859
2075
  apiKeyEnv: $('pvkey').value.trim(),
1860
2076
  };
2077
+ if (replace) body.replace = true;
2078
+ return api('/api/providers', { method: 'POST', body: JSON.stringify(body) });
2079
+ }
2080
+
2081
+ $('pvsave').onclick = async (ev) => {
1861
2082
  let out;
1862
- try { out = await api('/api/providers', { method: 'POST', body: JSON.stringify(body) }); }
2083
+ try { out = await saveProvider(false); }
1863
2084
  catch (e) {
1864
2085
  // Kept open on failure: the message says what to change, and closing would lose the typing.
1865
2086
  ev.preventDefault();
1866
2087
  $('pverr').textContent = e.message;
1867
2088
  $('pverr').hidden = false;
2089
+ // A name KONECK ships with is not a mistake to correct, it is a decision to confirm — putting a
2090
+ // company proxy in front of a vendor is exactly this. So the refusal comes with the way through
2091
+ // rather than being a dead end.
2092
+ if (e.status === 409 && e.body && e.body.confirmWith === 'replace') {
2093
+ const go = el('button', 'go', 'Repoint ' + e.body.shadowsBuiltIn + ' anyway');
2094
+ go.type = 'button';
2095
+ go.style.marginTop = '8px';
2096
+ go.onclick = async () => {
2097
+ let done;
2098
+ try { done = await saveProvider(true); }
2099
+ catch (e2) { $('pverr').textContent = e2.message; return; }
2100
+ $('provdlg').close();
2101
+ await afterProviderSaved(done);
2102
+ };
2103
+ $('pverr').appendChild(document.createElement('br'));
2104
+ $('pverr').appendChild(go);
2105
+ }
1868
2106
  return;
1869
2107
  }
2108
+ await afterProviderSaved(out);
2109
+ };
2110
+
2111
+ /** Clears the form and moves the model dialog onto whatever was just declared. */
2112
+ async function afterProviderSaved(out) {
1870
2113
  $('provdlg').close();
1871
2114
  for (const id of ['pvname', 'pvurl', 'pvmodel', 'pvkey']) $(id).value = '';
1872
2115
  await fillProviders(out.name);
1873
2116
  $('mmodel').value = out.defaultModel === 'default' ? '' : out.defaultModel;
1874
2117
  catalog.provider = null;
1875
2118
  void loadModels(out.name);
1876
- };
2119
+ }
1877
2120
 
1878
2121
  $('msave').onclick = async () => {
1879
- const provider = $('mprov').value, model = $('mmodel').value.trim();
2122
+ const provider = $('mprov').value, model = chosenModel();
1880
2123
  if (!state.session) await newSession();
1881
2124
  let info;
1882
2125
  try {
@@ -1890,6 +2133,9 @@ for (const b of document.querySelectorAll('.hero .ex button')) {
1890
2133
  b.onclick = () => send(b.textContent);
1891
2134
  }
1892
2135
 
2136
+ // The pill has to say what the remembered setting actually is before anything is asked.
2137
+ setFollow(follow.on);
2138
+
1893
2139
  (async function start() {
1894
2140
  let hello;
1895
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAs2DlB,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