koneck 2.25.1 → 2.25.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ink-chat.js CHANGED
@@ -8,10 +8,12 @@ import { generateSessionId, saveSession, listSessions, loadSession } from './ses
8
8
  import { loadMemory } from './memory.js';
9
9
  import { loadKoneckConfig, saveKoneckConfig, setConfigKey, configKeyDescriptions, CONFIG_FILE } from './config-store.js';
10
10
  import { PROVIDERS, resolveProvider, getApiKey, addUserProvider, userProvidersPath } from './providers.js';
11
- import { listCheckpoints, revertCheckpoint } from './checkpoint.js';
11
+ import { listCheckpoints, revertCheckpoint, snapshotTree, filesTouchedBy } from './checkpoint.js';
12
+ import { fileDiff, diffSummary } from './diff-view.js';
13
+ import { bar, humanTokens, humanDuration, diffStatSince, modelInfoFrom, contextWindowFor, recordStatus, classifyResponse, stateLabel, readModelStatus, } from './usage.js';
12
14
  import { Markdown, Panel } from './markdown-ink.js';
13
15
  import { fetchUpdateStatus, updateCommand } from './update-check.js';
14
- import { readFileSync } from 'fs';
16
+ import { readFileSync, statSync } from 'fs';
15
17
  import { fileURLToPath } from 'url';
16
18
  import path from 'path';
17
19
  /** Read once at load so the running version can always be shown without a lookup per render. */
@@ -32,6 +34,13 @@ const AMBER = '#F6C453';
32
34
  const MUTED = '#9AAAB2';
33
35
  const INK = '#F4F1EA';
34
36
  const DIM = '#5C717A';
37
+ // Diff colours. Deliberately not terminal green/red: those clash with the warm palette and read
38
+ // as errors. These are the existing accents lifted a little, over bands dark enough to sit on a
39
+ // black terminal without glowing.
40
+ const DIFF_ADD = '#7FE3B0';
41
+ const DIFF_ADD_BG = '#10291F';
42
+ const DIFF_DEL = '#F2909B';
43
+ const DIFF_DEL_BG = '#2B1419';
35
44
  // The user's message bar: light-on-dark, high contrast against the terminal ground so an
36
45
  // input is easy to find when scrolling back. Deliberately not the cyan used for KONECK.
37
46
  const USER_BG = '#3A434A';
@@ -235,6 +244,7 @@ const COMMANDS = [
235
244
  { cmd: '/mode', desc: 'Set mode: auto, plan or code' },
236
245
  { cmd: '/effort', desc: 'Set reasoning effort: low, medium, high, max (picker)' },
237
246
  { cmd: '/config', desc: 'View or set persistent config' },
247
+ { cmd: '/usage', desc: 'Usage, context, model status and limits (tabbed pane)' },
238
248
  { cmd: '/tokens', desc: 'Token usage breakdown' },
239
249
  { cmd: '/cost', desc: 'Session cost' },
240
250
  { cmd: '/memory', desc: 'Show project memory' },
@@ -255,6 +265,12 @@ const EFFORT_BLURB = {
255
265
  high: 'Thorough — covers edge cases, costs more tokens',
256
266
  max: 'Most rigorous analysis available; slowest and priciest',
257
267
  };
268
+ /** Past this, a file is not read for diffing — the render would be useless anyway. */
269
+ const MAX_DIFF_BYTES = 512 * 1024;
270
+ /** One tool rarely touches more; the cap stops a bulk move from flooding scrollback. */
271
+ const MAX_DIFF_FILES = 4;
272
+ /** The tabs of the usage pane, in the order they are shown. */
273
+ const USAGE_TABS = ['session', 'context', 'models', 'limits'];
258
274
  const MIN_REPLY_WIDTH = 24; // below this a table cannot render legibly anyway
259
275
  const PICKER_ROWS = 9; // visible rows; the list scrolls within this window
260
276
  /**
@@ -301,6 +317,7 @@ Slash commands:
301
317
  /mode [auto|plan|code] Show or set mode
302
318
  /effort [low|med|high|max] Set reasoning effort
303
319
  /config [key] [value] View or set persistent config
320
+ /usage Usage pane: session, context, models, limits
304
321
  /tokens Show token usage breakdown
305
322
  /cost Show session cost
306
323
  /memory Show .koneck/MEMORY.md
@@ -326,6 +343,8 @@ function App({ config: initialConfig }) {
326
343
  // instead of calling setState per token keeps a Raspberry Pi from re-rendering on every chunk.
327
344
  const sayRef = useRef(''); // narration not yet committed
328
345
  const liveToolRef = useRef(null); // the one tool still running
346
+ /** Pre-edit contents of the files the running tool said it would touch. */
347
+ const pendingDiffRef = useRef([]);
329
348
  const charsRef = useRef(0); // streamed chars, for the live token estimate
330
349
  const realTokRef = useRef(0); // real output tokens this turn, once the provider reports usage
331
350
  const tokBaseRef = useRef(0); // session output-token count at the start of this turn
@@ -344,25 +363,73 @@ function App({ config: initialConfig }) {
344
363
  if (text !== '')
345
364
  setRows(prev => [...prev, { role: 'steps', steps: [{ kind: 'say', text }] }]);
346
365
  }
366
+ /**
367
+ * Reads a file for diffing, or null if it is absent or too big to be worth showing. Reading is
368
+ * synchronous on purpose: the tool runs the instant this callback returns, so an await here
369
+ * would race the very write it is trying to photograph.
370
+ */
371
+ function readForDiff(rel) {
372
+ try {
373
+ const abs = path.resolve(cfg.cwd, rel);
374
+ if (statSync(abs).size > MAX_DIFF_BYTES)
375
+ return null;
376
+ return readFileSync(abs, 'utf-8');
377
+ }
378
+ catch {
379
+ return null; // absent: a creation, which fileDiff renders as such
380
+ }
381
+ }
347
382
  function pushTool(name, args) {
348
383
  commitSay();
384
+ // Photograph every file this tool has announced it will touch, before it touches it.
385
+ pendingDiffRef.current = filesTouchedBy(name, args).slice(0, MAX_DIFF_FILES)
386
+ .map(rel => ({ rel, before: readForDiff(rel) }));
349
387
  liveToolRef.current = { kind: 'tool', name, detail: toolDetail(name, args), startedAt: Date.now() };
350
388
  }
351
389
  function finishTool(name, ok, ms) {
352
390
  if (!ok)
353
391
  turnRecoveredRef.current = true;
354
392
  const live = liveToolRef.current;
393
+ const pending = pendingDiffRef.current;
355
394
  liveToolRef.current = null;
395
+ pendingDiffRef.current = [];
356
396
  if (!live)
357
397
  return;
358
- const done = { ...live, endedAt: live.startedAt + ms, ok };
398
+ // A failed tool leaves the file as it was, so there is nothing to show.
399
+ const diffs = ok
400
+ ? pending
401
+ .map(({ rel, before }) => fileDiff(rel, before, readForDiff(rel)))
402
+ .filter(d => !d.unchanged)
403
+ : [];
404
+ const done = { ...live, endedAt: live.startedAt + ms, ok, ...(diffs.length ? { diffs } : {}) };
359
405
  setRows(prev => [...prev, { role: 'steps', steps: [done] }]);
360
406
  }
407
+ /**
408
+ * Moves the agent panel into scrollback once the turn is over. The live panel is transient by
409
+ * design — it repaints — so without this the record of what the agents did would scroll away
410
+ * with the last repaint and leave nothing behind.
411
+ */
412
+ function flushAgents() {
413
+ const list = agentsRef.current;
414
+ if (list.length === 0)
415
+ return;
416
+ agentsRef.current = [];
417
+ setAgents([]);
418
+ const done = list.filter(a => a.status === 'done').length;
419
+ const failed = list.filter(a => a.status === 'failed').length;
420
+ const tokens = list.reduce((n, a) => n + a.tokens, 0);
421
+ // The elapsed span is the longest agent, not the sum: they ran at the same time.
422
+ const span = Math.max(...list.map(a => (a.endedAt ?? Date.now()) - a.startedAt));
423
+ addRow({ role: 'steps', steps: [{ kind: 'say', text: `${list.length} parallel agents finished — ${done} succeeded` +
424
+ (failed ? `, ${failed} failed` : '') +
425
+ ` · ${humanTokens(tokens)} tokens · ${fmtElapsed(span)}` }] });
426
+ }
361
427
  // Session Promise is created eagerly at mount so it resolves before the user's first message.
362
428
  const sessionOpts = {
363
429
  onChunk: (text) => pushSay(text),
364
430
  onToolCall: (name, args) => pushTool(name, args),
365
431
  onToolResult: (name, ok, ms) => finishTool(name, ok, ms),
432
+ onAgentProgress: (list) => { agentsRef.current = list; setAgents(list); },
366
433
  onTokens: (s) => {
367
434
  realTokRef.current = Math.max(0, s.completionTokens - tokBaseRef.current);
368
435
  },
@@ -388,6 +455,22 @@ function App({ config: initialConfig }) {
388
455
  const busRef = useRef(null);
389
456
  const [stats, setStats] = useState({ turns: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 });
390
457
  const [lastAgentText, setLastAgentText] = useState('');
458
+ // Usage accounting. Wall clock is what a user waited; API time is what the model was actually
459
+ // thinking, and the gap between the two is where local tool work lives.
460
+ const sessionStartRef = useRef(Date.now());
461
+ const apiMsRef = useRef(0);
462
+ /** Tree hash captured at startup, so "code changes" is a diff rather than a running tally. */
463
+ const baseTreeRef = useRef(null);
464
+ const [usagePane, setUsagePane] = useState(null);
465
+ // undefined while the diff is still being computed, null once git has said there is nothing to
466
+ // compare against. Collapsing the two would flash "not a git repo" at every open.
467
+ const [usageDiff, setUsageDiff] = useState(undefined);
468
+ const [modelCatalog, setModelCatalog] = useState([]);
469
+ /** Live state of parallel sub-agents. Empty except while spawn_agents is running. */
470
+ const [agents, setAgents] = useState([]);
471
+ // Mirrored in a ref because the turn's exit paths run inside a closure that would otherwise
472
+ // see the state as it was when the turn began.
473
+ const agentsRef = useRef([]);
391
474
  // Masked credential entry. The value is held in component state for the session and passed
392
475
  // to the client as a runtime override — KONECK never writes it to disk, and it is never put
393
476
  // into a transcript row, so it cannot end up in a saved session file.
@@ -522,6 +605,9 @@ function App({ config: initialConfig }) {
522
605
  }, 150);
523
606
  return () => clearInterval(id);
524
607
  }, [busy]);
608
+ // The tree as it stood before the session touched anything. Recorded once, so "code changes"
609
+ // stays a comparison against the starting point however long the session runs.
610
+ useEffect(() => { void snapshotTree(cfg.cwd).then(t => { baseTreeRef.current = t; }); }, [cfg.cwd]);
525
611
  const addRow = (row) => setRows(prev => [...prev, row]);
526
612
  const addSystem = (text) => addRow({ role: 'system', text });
527
613
  async function getSession() {
@@ -618,6 +704,31 @@ function App({ config: initialConfig }) {
618
704
  addSystem(`Could not resume ${id}: ${e instanceof Error ? e.message : String(e)}`);
619
705
  }
620
706
  }
707
+ /**
708
+ * Opens the usage pane and refreshes the two figures that go stale: the working-tree diff and
709
+ * the model catalogue. Both are cheap enough to redo on open and wrong enough to matter if
710
+ * cached, so neither is held across openings.
711
+ */
712
+ async function openUsagePane(tab) {
713
+ setUsagePane(tab);
714
+ void diffStatSince(cfg.cwd, baseTreeRef.current).then(setUsageDiff);
715
+ if (modelCatalog.length === 0)
716
+ void loadModelCatalog();
717
+ }
718
+ /** The provider's own model listing, kept so the models tab and picker agree on the facts. */
719
+ async function loadModelCatalog() {
720
+ try {
721
+ const page = await buildClient(cfg).models.list();
722
+ const infos = page.data
723
+ .map(m => modelInfoFrom(m, cfg.provider))
724
+ .filter(m => m.id);
725
+ setModelCatalog(infos);
726
+ return infos;
727
+ }
728
+ catch {
729
+ return []; // the pane says so rather than showing zeroes
730
+ }
731
+ }
621
732
  /** Opens the update panel, checking the registry each time it is opened deliberately. */
622
733
  async function openUpdatePane() {
623
734
  setUpdatePane({ current: VERSION, latest: null, behind: false, manager: 'npm' });
@@ -643,18 +754,33 @@ function App({ config: initialConfig }) {
643
754
  }
644
755
  }
645
756
  /** Live model list from the connected provider's OpenAI-compatible /models endpoint. */
757
+ /**
758
+ * The model list, annotated with what is actually known about each entry: whether the provider
759
+ * prices it at zero, how much context it takes, and whether it answered the last time it was
760
+ * asked. Choosing a model blind is how a session ends up on one that is out of credit.
761
+ */
646
762
  async function fetchModels() {
647
- const client = buildClient(cfg);
648
- const page = await client.models.list();
649
- const ids = page.data.map(m => m.id).filter(Boolean).sort((a, b) => a.localeCompare(b));
650
- return ids.map(id => ({
651
- value: id,
652
- label: id,
653
- // Grouping by vendor prefix turns a flat 115-entry list into something scannable.
654
- group: id.includes('/') ? id.split('/')[0] : cfg.provider,
655
- desc: '',
656
- current: id === cfg.model,
657
- }));
763
+ const infos = await loadModelCatalog();
764
+ const statuses = readModelStatus();
765
+ return [...infos]
766
+ .sort((a, b) => a.id.localeCompare(b.id))
767
+ .map(m => {
768
+ const st = statuses[`${cfg.provider}:${m.id}`];
769
+ const parts = [
770
+ m.free ? 'free' : 'paid',
771
+ ...(m.contextLength ? [`${humanTokens(m.contextLength)} ctx`] : []),
772
+ ...(m.toolCalling === false ? ['no tools'] : []),
773
+ ...(st && st.state !== 'ok' ? [stateLabel(st.state)] : st ? ['ready'] : []),
774
+ ];
775
+ return {
776
+ value: m.id,
777
+ label: m.id,
778
+ // Grouping by vendor prefix turns a flat 447-entry list into something scannable.
779
+ group: m.vendor,
780
+ desc: parts.join(' · '),
781
+ current: m.id === cfg.model,
782
+ };
783
+ });
658
784
  }
659
785
  function providerItems() {
660
786
  return Object.values(PROVIDERS).map(p => ({
@@ -834,6 +960,11 @@ function App({ config: initialConfig }) {
834
960
  addSystem(`Effort → ${arg}`);
835
961
  return;
836
962
  }
963
+ case '/usage': {
964
+ const wanted = USAGE_TABS.find(t => t.startsWith(arg.trim().toLowerCase()));
965
+ await openUsagePane(wanted ?? 'session');
966
+ return;
967
+ }
837
968
  case '/tokens': {
838
969
  addSystem(`Prompt : ${stats.promptTokens.toLocaleString()}\n` +
839
970
  `Completion : ${stats.completionTokens.toLocaleString()}\n` +
@@ -1208,7 +1339,7 @@ function App({ config: initialConfig }) {
1208
1339
  useInput((input, key) => {
1209
1340
  // Esc abandons the turn in flight and hands the keyboard straight back. The composer is
1210
1341
  // deliberately left alone: cancelling should not cost whatever was typed while waiting.
1211
- if (key.escape && busy && !picker && !keyPrompt && !updatePane) {
1342
+ if (key.escape && busy && !picker && !keyPrompt && !updatePane && !usagePane) {
1212
1343
  abortRef.current?.abort();
1213
1344
  return;
1214
1345
  }
@@ -1312,6 +1443,20 @@ function App({ config: initialConfig }) {
1312
1443
  }
1313
1444
  return;
1314
1445
  }
1446
+ // Inside the usage pane: the arrows walk the tabs and escape closes it. This sits ahead of
1447
+ // the composer's own arrow handling, which would otherwise move the caret behind the pane.
1448
+ if (usagePane) {
1449
+ if (key.escape) {
1450
+ setUsagePane(null);
1451
+ return;
1452
+ }
1453
+ if (key.leftArrow || key.rightArrow) {
1454
+ const at = USAGE_TABS.indexOf(usagePane);
1455
+ const next = (at + (key.rightArrow ? 1 : USAGE_TABS.length - 1)) % USAGE_TABS.length;
1456
+ void openUsagePane(USAGE_TABS[next]);
1457
+ return;
1458
+ }
1459
+ }
1315
1460
  // Shift+U → the update and guide panel.
1316
1461
  if (key.shift && (input === 'U' || input === 'u')) {
1317
1462
  if (updatePane) {
@@ -1498,6 +1643,7 @@ function App({ config: initialConfig }) {
1498
1643
  if (controller.signal.aborted) {
1499
1644
  sayRef.current = '';
1500
1645
  liveToolRef.current = null;
1646
+ flushAgents();
1501
1647
  addRow({ role: 'system', text: `Cancelled after ${fmtElapsed(Date.now() - started)}. Anything already done is above; ` +
1502
1648
  'your composer is untouched.' });
1503
1649
  setAgentState('ready');
@@ -1510,7 +1656,12 @@ function App({ config: initialConfig }) {
1510
1656
  const content = last && typeof last.content === 'string'
1511
1657
  ? last.content : 'Task finished with no written response.';
1512
1658
  setLastAgentText(content);
1659
+ flushAgents();
1513
1660
  const elapsed = Date.now() - started;
1661
+ apiMsRef.current += elapsed;
1662
+ // A turn that completed is the only proof a model actually works that costs nothing
1663
+ // extra to collect, so the catalogue learns from ordinary use rather than from probes.
1664
+ recordStatus(`${cfg.provider}:${cfg.model}`, 'ok');
1514
1665
  setStats({
1515
1666
  turns: active.stats.turns,
1516
1667
  promptTokens: active.stats.promptTokens,
@@ -1534,10 +1685,18 @@ function App({ config: initialConfig }) {
1534
1685
  const detail = error instanceof Error ? error.message : String(error);
1535
1686
  const cancelled = abortRef.current?.signal.aborted === true
1536
1687
  || /abort/i.test(detail) || /interrupt/i.test(detail);
1688
+ // A real failure says something about the model — out of credit, rate limited, not
1689
+ // actually served. Recording it means the models list can warn before the next attempt.
1690
+ if (!cancelled) {
1691
+ const state = classifyResponse(undefined, detail);
1692
+ if (state !== 'ok')
1693
+ recordStatus(`${cfg.provider}:${cfg.model}`, state, detail.slice(0, 120));
1694
+ }
1537
1695
  // Keep any narration that arrived before the failure — it is often the best clue.
1538
1696
  const trailing = sayRef.current.trim();
1539
1697
  sayRef.current = '';
1540
1698
  liveToolRef.current = null;
1699
+ flushAgents();
1541
1700
  setRows(prev => [
1542
1701
  ...prev,
1543
1702
  ...(trailing ? [{ role: 'steps', steps: [{ kind: 'say', text: trailing }] }] : []),
@@ -1589,6 +1748,29 @@ function App({ config: initialConfig }) {
1589
1748
  // Live output token count: real usage when the provider reports it, else a char estimate
1590
1749
  const liveTokens = Math.max(realTokRef.current, Math.round(charsRef.current / 4));
1591
1750
  /** Renders one activity step. Shared by the live view and committed scrollback so they match. */
1751
+ /**
1752
+ * A file change, drawn the way a diff is read: a gutter of line numbers, a sign, and the line
1753
+ * itself on a tinted band. Every rendered line is padded to the same width so the tint forms a
1754
+ * solid block rather than a ragged edge, which is what makes an added run legible at a glance.
1755
+ */
1756
+ const renderDiff = (d, key) => {
1757
+ const numbers = d.hunks.flatMap(h => h.lines.map(l => l.after ?? l.before ?? 0));
1758
+ const gutter = Math.max(2, String(Math.max(0, ...numbers)).length);
1759
+ const width = Math.max(24, barWidth - gutter - 8);
1760
+ const header = d.created ? 'new file' : d.deleted ? 'deleted' : diffSummary(d);
1761
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: DIM, children: "\u2514" }), _jsx(Text, { color: CYAN, children: d.path }), _jsx(Text, { color: d.created ? DIFF_ADD : d.deleted ? DIFF_DEL : MUTED, children: header })] }), d.binary
1762
+ ? _jsx(Text, { color: DIM, children: ' binary file - not shown' })
1763
+ : d.hunks.map((hunk, hi) => (_jsxs(Box, { flexDirection: "column", children: [hi > 0 && _jsxs(Text, { color: DIM, children: [' '.repeat(gutter), " ..."] }), hunk.lines.map((l, li) => {
1764
+ const shown = l.kind === 'del' ? l.before : l.after;
1765
+ const sign = l.kind === 'add' ? '+' : l.kind === 'del' ? '-' : ' ';
1766
+ const fg = l.kind === 'add' ? DIFF_ADD : l.kind === 'del' ? DIFF_DEL : MUTED;
1767
+ const bg = l.kind === 'add' ? DIFF_ADD_BG : l.kind === 'del' ? DIFF_DEL_BG : undefined;
1768
+ // Tabs would make the tinted block a ragged width, so they are expanded.
1769
+ const body = `${sign} ${l.text}`.replace(/\t/g, ' ');
1770
+ const cell = body.length > width ? body.slice(0, width - 3) + '...' : body.padEnd(width);
1771
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: DIM, children: [String(shown ?? '').padStart(gutter), " "] }), _jsx(Text, { color: fg, ...(bg ? { backgroundColor: bg } : {}), children: cell })] }, li));
1772
+ })] }, hi))), d.hiddenLines > 0 && (_jsxs(Text, { color: DIM, children: [' '.repeat(gutter), " ... ", d.hiddenLines, " more changed lines"] }))] }, key));
1773
+ };
1592
1774
  const renderStep = (step, key) => {
1593
1775
  if (step.kind === 'say') {
1594
1776
  return (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: CYAN, children: "\u25CF" }), _jsx(Text, { color: INK, children: step.text.trim() })] }, key));
@@ -1597,7 +1779,7 @@ function App({ config: initialConfig }) {
1597
1779
  const ms = (step.endedAt ?? Date.now()) - step.startedAt;
1598
1780
  const glyph = running ? SPINNER[spinFrame] : step.ok === false ? '✖' : '✓';
1599
1781
  const color = running ? AMBER : step.ok === false ? CRIMSON : GREEN;
1600
- return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: color, children: glyph }), _jsx(Text, { color: AMBER, bold: true, children: step.name }), _jsx(Text, { color: DIM, children: "\u00B7" }), _jsx(Text, { color: MUTED, children: fmtElapsed(ms) })] }), step.detail !== '' && (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { color: DIM, children: "\u2514" }), _jsx(Text, { color: MUTED, children: step.detail })] }))] }, key));
1782
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: color, children: glyph }), _jsx(Text, { color: AMBER, bold: true, children: step.name }), _jsx(Text, { color: DIM, children: "\u00B7" }), _jsx(Text, { color: MUTED, children: fmtElapsed(ms) })] }), step.detail !== '' && (step.diffs ?? []).length === 0 && (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { color: DIM, children: "\u2514" }), _jsx(Text, { color: MUTED, children: step.detail })] })), (step.diffs ?? []).map((d, i) => renderDiff(d, i))] }, key));
1601
1783
  };
1602
1784
  return (_jsxs(Box, { flexDirection: "column", width: uiWidth, paddingX: 2, children: [showAnalytics && (_jsxs(Box, { borderStyle: "round", borderColor: AMBER, paddingX: 1, marginBottom: 1, flexDirection: "column", width: barWidth, children: [_jsx(Text, { color: AMBER, bold: true, children: "\u25C6 Session Analytics" }), _jsxs(Text, { color: MUTED, children: ["Turns : ", _jsx(Text, { color: INK, children: stats.turns })] }), _jsxs(Text, { color: MUTED, children: ["Prompt tok : ", _jsx(Text, { color: INK, children: stats.promptTokens.toLocaleString() })] }), _jsxs(Text, { color: MUTED, children: ["Output tok : ", _jsx(Text, { color: INK, children: stats.completionTokens.toLocaleString() })] }), _jsxs(Text, { color: MUTED, children: ["Total tok : ", _jsx(Text, { color: INK, children: stats.totalTokens.toLocaleString() })] }), _jsxs(Text, { color: MUTED, children: ["Model : ", _jsx(Text, { color: INK, children: modelShort }), " Provider: ", _jsx(Text, { color: INK, children: cfg.provider })] }), _jsx(Text, { color: DIM, children: "Tab to close" })] })), _jsx(Static, { items: rows, children: (row, index) => row.role === 'header' ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", alignItems: "center", marginTop: 1, marginBottom: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: "KONECK / SOFTWARE DELIVERY SYSTEM" }), _jsx(Text, { color: MUTED, children: "Deliberate engineering, with control at every change." })] }), _jsx(Panel, { width: barWidth, color: CYAN, title: "KONECK", children: [`MODEL: \`${modelShort}\` PROVIDER: \`${cfg.provider}\` WORKSPACE: \`${workspace}\``] })] }, index)) : row.role === 'user' ? (
1603
1785
  // Painted as a filled bar so the user's own words are the most findable thing
@@ -1607,9 +1789,144 @@ function App({ config: initialConfig }) {
1607
1789
  // content, so the right edge stepped in and out; and a box inside a box left
1608
1790
  // markdown tables needlessly narrow. A marker plus the content reads better and
1609
1791
  // cannot misalign, which is how Claude Code presents its own answers.
1610
- _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: "\uA7B0K" }), _jsx(Box, { flexDirection: "column", children: _jsx(Markdown, { text: row.text ?? '', width: barWidth - 3 }) })] }), row.tokens != null && (_jsxs(Text, { color: MUTED, children: [_jsx(Text, { color: GREEN, children: "* " }), row.done ?? 'Done', " in ", fmtElapsed(row.elapsed ?? 0), ' | ', row.tokens, " tokens", ' | ', cfg.provider, " ", modelShort] }))] }, index)) }), _jsx(Box, { flexDirection: "column", children: busy && (agentState === 'processing' || agentState === 'syncing') && (_jsxs(Box, { flexDirection: "column", children: [sayRef.current.trim() !== '' && renderStep({ kind: 'say', text: tailLines(sayRef.current, 2, replyWidth) }, 0), liveToolRef.current && renderStep(liveToolRef.current, 1), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsxs(Text, { color: GREEN, children: [SPINNER[spinFrame], " "] }), _jsx(Shimmer, { text: workWord(elapsedMs, spinWord, turnRecoveredRef.current)[0], frame: shimmerFrame, base: MUTED }), _jsx(Text, { color: MUTED, children: "\u2026 " }), _jsxs(Text, { color: DIM, children: ["(", fmtElapsed(elapsedMs), " | ", liveTokens > 0
1792
+ _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: "\uA7B0K" }), _jsx(Box, { flexDirection: "column", children: _jsx(Markdown, { text: row.text ?? '', width: barWidth - 3 }) })] }), row.tokens != null && (_jsxs(Text, { color: MUTED, children: [_jsx(Text, { color: GREEN, children: "* " }), row.done ?? 'Done', " in ", fmtElapsed(row.elapsed ?? 0), ' | ', row.tokens, " tokens", ' | ', cfg.provider, " ", modelShort] }))] }, index)) }), _jsx(Box, { flexDirection: "column", children: busy && (agentState === 'processing' || agentState === 'syncing') && (_jsxs(Box, { flexDirection: "column", children: [sayRef.current.trim() !== '' && renderStep({ kind: 'say', text: tailLines(sayRef.current, 2, replyWidth) }, 0), liveToolRef.current && renderStep(liveToolRef.current, 1), agents.length > 0 && (() => {
1793
+ const done = agents.filter(a => a.status !== 'running').length;
1794
+ const failed = agents.filter(a => a.status === 'failed').length;
1795
+ const tokens = agents.reduce((n, a) => n + a.tokens, 0);
1796
+ const started = Math.min(...agents.map(a => a.startedAt));
1797
+ // The task text is clipped to whatever is left after the fixed columns, so a long
1798
+ // task never wraps and breaks the alignment of the rows under it.
1799
+ const taskWidth = Math.max(16, barWidth - 34);
1800
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: CYAN, children: "\u25C6" }), _jsxs(Text, { color: INK, bold: true, children: [agents.length, " agents"] }), _jsx(Text, { color: DIM, children: "\u00B7" }), _jsxs(Text, { color: done === agents.length ? GREEN : AMBER, children: [done, "/", agents.length, " complete"] }), _jsx(Text, { color: DIM, children: "\u00B7" }), _jsxs(Text, { color: MUTED, children: [humanTokens(tokens), " tok"] }), _jsx(Text, { color: DIM, children: "\u00B7" }), _jsx(Text, { color: MUTED, children: fmtElapsed(Date.now() - started) }), failed > 0 && _jsxs(Text, { color: CRIMSON, children: ["\u00B7 ", failed, " failed"] })] }), agents.map(a => {
1801
+ const glyph = a.status === 'running' ? SPINNER[spinFrame]
1802
+ : a.status === 'failed' ? '✖' : '✓';
1803
+ const color = a.status === 'running' ? AMBER
1804
+ : a.status === 'failed' ? CRIMSON : GREEN;
1805
+ const took = (a.endedAt ?? Date.now()) - a.startedAt;
1806
+ const task = a.task.length > taskWidth ? a.task.slice(0, taskWidth - 3) + '...' : a.task;
1807
+ return (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { color: color, children: glyph }), _jsx(Text, { color: DIM, children: String(a.index + 1).padStart(2) }), _jsx(Text, { color: a.status === 'running' ? INK : MUTED, children: task.padEnd(taskWidth) }), _jsx(Text, { color: DIM, children: humanTokens(a.tokens).padStart(6) }), _jsx(Text, { color: DIM, children: fmtElapsed(took).padStart(6) })] }, a.index));
1808
+ })] }));
1809
+ })(), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsxs(Text, { color: GREEN, children: [SPINNER[spinFrame], " "] }), _jsx(Shimmer, { text: workWord(elapsedMs, spinWord, turnRecoveredRef.current)[0], frame: shimmerFrame, base: MUTED }), _jsx(Text, { color: MUTED, children: "\u2026 " }), _jsxs(Text, { color: DIM, children: ["(", fmtElapsed(elapsedMs), " | ", liveTokens > 0
1611
1810
  ? `${fmtTokens(liveTokens)} tokens`
1612
- : elapsedMs > 20_000 ? 'waiting for the first token' : 'starting', ")"] })] }) }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: DIM, children: "esc to cancel and keep your input \u00B7 ctrl+c to exit" }) })] })) }), updatePane && (() => {
1811
+ : elapsedMs > 20_000 ? 'waiting for the first token' : 'starting', ")"] })] }) }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: DIM, children: "esc to cancel and keep your input \u00B7 ctrl+c to exit" }) })] })) }), usagePane && (() => {
1812
+ const width = Math.min(barWidth, 66);
1813
+ const inner = width - 4;
1814
+ const BAR_W = Math.max(12, inner - 14);
1815
+ const tabs = USAGE_TABS.map(t => t === usagePane ? `**${t[0].toUpperCase() + t.slice(1)}**` : t[0].toUpperCase() + t.slice(1)).join(' ');
1816
+ /** A labelled bar with its percentage, the shape used for every proportion here. */
1817
+ const meter = (fraction, label) => [`${bar(fraction, BAR_W)} ${Math.round(fraction * 100)}%`, `${label}`, ''];
1818
+ const lines = [tabs, '---'];
1819
+ if (usagePane === 'session') {
1820
+ const wall = Date.now() - sessionStartRef.current;
1821
+ const cost = estimateCost(cfg.model, stats.promptTokens, stats.completionTokens);
1822
+ lines.push('**Session**', '');
1823
+ lines.push(`Total cost ${cost.known ? formatCost(cost.total) : 'no published price for this model'}`);
1824
+ lines.push(`Duration (API) ${humanDuration(apiMsRef.current)}`);
1825
+ lines.push(`Duration (wall) ${humanDuration(wall)}`);
1826
+ lines.push(`Turns ${stats.turns}`);
1827
+ lines.push(`Code changes ${usageDiff === undefined ? 'reading the working tree…'
1828
+ : usageDiff === null ? 'not a git repo — nothing to compare against'
1829
+ : `${usageDiff.added} added, ${usageDiff.removed} removed, ${usageDiff.files} file${usageDiff.files === 1 ? '' : 's'}`}`);
1830
+ lines.push(`Tokens ${humanTokens(stats.promptTokens)} in · ${humanTokens(stats.completionTokens)} out`);
1831
+ lines.push('');
1832
+ if (apiMsRef.current > 0 && wall > apiMsRef.current) {
1833
+ const share = apiMsRef.current / wall;
1834
+ lines.push(...meter(share, 'of the session was the model thinking.'));
1835
+ lines.push('The rest was tools running and your own typing.');
1836
+ }
1837
+ }
1838
+ if (usagePane === 'context') {
1839
+ const window = modelCatalog.find(m => m.id === cfg.model)?.contextLength ?? contextWindowFor(cfg.model);
1840
+ lines.push('**Context window**', '');
1841
+ if (window) {
1842
+ // Prompt tokens on the last turn approximate what is resident: history plus system
1843
+ // prompt plus tool results, which is exactly what fills a window.
1844
+ const used = stats.promptTokens;
1845
+ lines.push(...meter(used / window, `${humanTokens(used)} of ${humanTokens(window)} · ${cfg.model}`));
1846
+ if (used / window > 0.7) {
1847
+ lines.push('Close to the window. `/clear` starts fresh,');
1848
+ lines.push('`/rewind` drops just the last exchange.');
1849
+ }
1850
+ else {
1851
+ lines.push('Every turn re-sends the whole history, so a long');
1852
+ lines.push('session costs more even when little is said.');
1853
+ lines.push('`/clear` when you move to an unrelated task.');
1854
+ }
1855
+ }
1856
+ else {
1857
+ lines.push(`This provider publishes no window for **${cfg.model}**,`);
1858
+ lines.push('so there is nothing honest to draw a bar against.');
1859
+ }
1860
+ lines.push('');
1861
+ lines.push(`Last turn sent ${humanTokens(stats.promptTokens)} and received ${humanTokens(stats.completionTokens)}.`);
1862
+ }
1863
+ if (usagePane === 'models') {
1864
+ const statuses = readModelStatus();
1865
+ lines.push(`**Models** — ${cfg.provider}`, '');
1866
+ if (modelCatalog.length === 0) {
1867
+ lines.push('Could not read the model list from this provider.');
1868
+ }
1869
+ else {
1870
+ const free = modelCatalog.filter(m => m.free);
1871
+ // Status is keyed by provider, not by catalogue membership, so an alias that has
1872
+ // actually been used still counts as tried.
1873
+ const prefix = `${cfg.provider}:`;
1874
+ const tried = Object.values(statuses).filter(s => s.model.startsWith(prefix));
1875
+ const bad = tried.filter(s => s.state !== 'ok');
1876
+ lines.push(`Listed ${modelCatalog.length}`);
1877
+ lines.push(`Free ${free.length}`);
1878
+ lines.push(`Tried here ${tried.length} — ${tried.length - bad.length} worked, ${bad.length} failed`);
1879
+ lines.push('');
1880
+ // The model in use is not always a catalogue entry: `auto` and similar aliases are
1881
+ // routing instructions the gateway resolves per request, and never appear in a
1882
+ // listing. Showing only listed models would leave the commonest setting invisible.
1883
+ const current = modelCatalog.find(m => m.id === cfg.model);
1884
+ const st = statuses[`${cfg.provider}:${cfg.model}`];
1885
+ lines.push(`**In use** ${cfg.model}`);
1886
+ if (current) {
1887
+ lines.push(` ${current.free ? 'free' : 'paid'}` +
1888
+ (current.contextLength ? ` · ${humanTokens(current.contextLength)} context` : '') +
1889
+ (current.maxOutput ? ` · ${humanTokens(current.maxOutput)} max out` : '') +
1890
+ (current.toolCalling === false ? ' · no tool calling' : ''));
1891
+ }
1892
+ else {
1893
+ lines.push(' a routing alias — the gateway picks the model per request');
1894
+ }
1895
+ lines.push(` status: ${st ? stateLabel(st.state) : 'not tried yet'}`);
1896
+ if (bad.length) {
1897
+ lines.push('');
1898
+ lines.push('**Failing**');
1899
+ for (const s of bad.slice(0, 6)) {
1900
+ lines.push(` ${s.model.slice(prefix.length)} — ${stateLabel(s.state)}`);
1901
+ }
1902
+ }
1903
+ lines.push('');
1904
+ lines.push('Status accrues from use: a model is marked when it');
1905
+ lines.push('answers or refuses. `/models` shows the same tags.');
1906
+ }
1907
+ }
1908
+ if (usagePane === 'limits') {
1909
+ lines.push('**Limits and quota**', '');
1910
+ lines.push(`How much of your plan is left on **${cfg.provider}**`);
1911
+ lines.push('cannot be shown.');
1912
+ lines.push('');
1913
+ lines.push('That is the provider, not a missing feature. Its');
1914
+ lines.push('replies carry no rate-limit or balance headers, and');
1915
+ lines.push('its account API refuses the key a session holds.');
1916
+ lines.push('A number here would be invented, so there is none.');
1917
+ lines.push('');
1918
+ lines.push('**What is measured instead**');
1919
+ lines.push(' · tokens actually spent — Session tab');
1920
+ lines.push(' · context actually filled — Context tab');
1921
+ lines.push(' · which models actually answered — Models tab');
1922
+ lines.push('');
1923
+ lines.push('A provider that returns `x-ratelimit-*` headers will');
1924
+ lines.push('show a real bar here with no further work.');
1925
+ }
1926
+ lines.push('---');
1927
+ lines.push('**←/→** switch tabs · **esc** to close');
1928
+ return (_jsx(Box, { marginTop: 1, justifyContent: "flex-end", children: _jsx(Panel, { width: width, color: CYAN, title: "KONECK \u00B7 usage", children: lines }) }));
1929
+ })(), updatePane && (() => {
1613
1930
  const u = updatePane;
1614
1931
  const lines = [];
1615
1932
  lines.push(`Running **${u.current}**`);