koneck 2.25.2 → 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,11 +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, snapshotTree } from './checkpoint.js';
11
+ import { listCheckpoints, revertCheckpoint, snapshotTree, filesTouchedBy } from './checkpoint.js';
12
+ import { fileDiff, diffSummary } from './diff-view.js';
12
13
  import { bar, humanTokens, humanDuration, diffStatSince, modelInfoFrom, contextWindowFor, recordStatus, classifyResponse, stateLabel, readModelStatus, } from './usage.js';
13
14
  import { Markdown, Panel } from './markdown-ink.js';
14
15
  import { fetchUpdateStatus, updateCommand } from './update-check.js';
15
- import { readFileSync } from 'fs';
16
+ import { readFileSync, statSync } from 'fs';
16
17
  import { fileURLToPath } from 'url';
17
18
  import path from 'path';
18
19
  /** Read once at load so the running version can always be shown without a lookup per render. */
@@ -33,6 +34,13 @@ const AMBER = '#F6C453';
33
34
  const MUTED = '#9AAAB2';
34
35
  const INK = '#F4F1EA';
35
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';
36
44
  // The user's message bar: light-on-dark, high contrast against the terminal ground so an
37
45
  // input is easy to find when scrolling back. Deliberately not the cyan used for KONECK.
38
46
  const USER_BG = '#3A434A';
@@ -257,6 +265,10 @@ const EFFORT_BLURB = {
257
265
  high: 'Thorough — covers edge cases, costs more tokens',
258
266
  max: 'Most rigorous analysis available; slowest and priciest',
259
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;
260
272
  /** The tabs of the usage pane, in the order they are shown. */
261
273
  const USAGE_TABS = ['session', 'context', 'models', 'limits'];
262
274
  const MIN_REPLY_WIDTH = 24; // below this a table cannot render legibly anyway
@@ -331,6 +343,8 @@ function App({ config: initialConfig }) {
331
343
  // instead of calling setState per token keeps a Raspberry Pi from re-rendering on every chunk.
332
344
  const sayRef = useRef(''); // narration not yet committed
333
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([]);
334
348
  const charsRef = useRef(0); // streamed chars, for the live token estimate
335
349
  const realTokRef = useRef(0); // real output tokens this turn, once the provider reports usage
336
350
  const tokBaseRef = useRef(0); // session output-token count at the start of this turn
@@ -349,25 +363,73 @@ function App({ config: initialConfig }) {
349
363
  if (text !== '')
350
364
  setRows(prev => [...prev, { role: 'steps', steps: [{ kind: 'say', text }] }]);
351
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
+ }
352
382
  function pushTool(name, args) {
353
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) }));
354
387
  liveToolRef.current = { kind: 'tool', name, detail: toolDetail(name, args), startedAt: Date.now() };
355
388
  }
356
389
  function finishTool(name, ok, ms) {
357
390
  if (!ok)
358
391
  turnRecoveredRef.current = true;
359
392
  const live = liveToolRef.current;
393
+ const pending = pendingDiffRef.current;
360
394
  liveToolRef.current = null;
395
+ pendingDiffRef.current = [];
361
396
  if (!live)
362
397
  return;
363
- 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 } : {}) };
364
405
  setRows(prev => [...prev, { role: 'steps', steps: [done] }]);
365
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
+ }
366
427
  // Session Promise is created eagerly at mount so it resolves before the user's first message.
367
428
  const sessionOpts = {
368
429
  onChunk: (text) => pushSay(text),
369
430
  onToolCall: (name, args) => pushTool(name, args),
370
431
  onToolResult: (name, ok, ms) => finishTool(name, ok, ms),
432
+ onAgentProgress: (list) => { agentsRef.current = list; setAgents(list); },
371
433
  onTokens: (s) => {
372
434
  realTokRef.current = Math.max(0, s.completionTokens - tokBaseRef.current);
373
435
  },
@@ -404,6 +466,11 @@ function App({ config: initialConfig }) {
404
466
  // compare against. Collapsing the two would flash "not a git repo" at every open.
405
467
  const [usageDiff, setUsageDiff] = useState(undefined);
406
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([]);
407
474
  // Masked credential entry. The value is held in component state for the session and passed
408
475
  // to the client as a runtime override — KONECK never writes it to disk, and it is never put
409
476
  // into a transcript row, so it cannot end up in a saved session file.
@@ -1576,6 +1643,7 @@ function App({ config: initialConfig }) {
1576
1643
  if (controller.signal.aborted) {
1577
1644
  sayRef.current = '';
1578
1645
  liveToolRef.current = null;
1646
+ flushAgents();
1579
1647
  addRow({ role: 'system', text: `Cancelled after ${fmtElapsed(Date.now() - started)}. Anything already done is above; ` +
1580
1648
  'your composer is untouched.' });
1581
1649
  setAgentState('ready');
@@ -1588,6 +1656,7 @@ function App({ config: initialConfig }) {
1588
1656
  const content = last && typeof last.content === 'string'
1589
1657
  ? last.content : 'Task finished with no written response.';
1590
1658
  setLastAgentText(content);
1659
+ flushAgents();
1591
1660
  const elapsed = Date.now() - started;
1592
1661
  apiMsRef.current += elapsed;
1593
1662
  // A turn that completed is the only proof a model actually works that costs nothing
@@ -1627,6 +1696,7 @@ function App({ config: initialConfig }) {
1627
1696
  const trailing = sayRef.current.trim();
1628
1697
  sayRef.current = '';
1629
1698
  liveToolRef.current = null;
1699
+ flushAgents();
1630
1700
  setRows(prev => [
1631
1701
  ...prev,
1632
1702
  ...(trailing ? [{ role: 'steps', steps: [{ kind: 'say', text: trailing }] }] : []),
@@ -1678,6 +1748,29 @@ function App({ config: initialConfig }) {
1678
1748
  // Live output token count: real usage when the provider reports it, else a char estimate
1679
1749
  const liveTokens = Math.max(realTokRef.current, Math.round(charsRef.current / 4));
1680
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
+ };
1681
1774
  const renderStep = (step, key) => {
1682
1775
  if (step.kind === 'say') {
1683
1776
  return (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: CYAN, children: "\u25CF" }), _jsx(Text, { color: INK, children: step.text.trim() })] }, key));
@@ -1686,7 +1779,7 @@ function App({ config: initialConfig }) {
1686
1779
  const ms = (step.endedAt ?? Date.now()) - step.startedAt;
1687
1780
  const glyph = running ? SPINNER[spinFrame] : step.ok === false ? '✖' : '✓';
1688
1781
  const color = running ? AMBER : step.ok === false ? CRIMSON : GREEN;
1689
- 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));
1690
1783
  };
1691
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' ? (
1692
1785
  // Painted as a filled bar so the user's own words are the most findable thing
@@ -1696,7 +1789,24 @@ function App({ config: initialConfig }) {
1696
1789
  // content, so the right edge stepped in and out; and a box inside a box left
1697
1790
  // markdown tables needlessly narrow. A marker plus the content reads better and
1698
1791
  // cannot misalign, which is how Claude Code presents its own answers.
1699
- _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
1700
1810
  ? `${fmtTokens(liveTokens)} tokens`
1701
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 && (() => {
1702
1812
  const width = Math.min(barWidth, 66);