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/diff-view.d.ts +52 -0
- package/dist/diff-view.d.ts.map +1 -0
- package/dist/diff-view.js +208 -0
- package/dist/diff-view.js.map +1 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +31 -6
- package/dist/engine.js.map +1 -1
- package/dist/ink-chat.d.ts.map +1 -1
- package/dist/ink-chat.js +335 -18
- package/dist/ink-chat.js.map +1 -1
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/usage.d.ts +94 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +201 -0
- package/dist/usage.js.map +1 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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),
|
|
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" }) })] })) }),
|
|
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}**`);
|