koneck 2.128.33 → 2.128.35

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
@@ -2,6 +2,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import React, { useState, useEffect, useRef } from 'react';
3
3
  import { Box, Static, Text, render, useApp, useInput, useStdin } from 'ink';
4
4
  import { execa } from 'execa';
5
+ import chalk from 'chalk';
5
6
  import { resolveAgentConcurrency, toolsUnsupportedNotice } from './engine-facts.js';
6
7
  import { withSynchronizedFrames, probeSynchronizedOutput } from './frame-sync.js';
7
8
  import { pacedInterval, startingInterval, floorFor, shouldDrawFrame } from './frame-pace.js';
@@ -48,6 +49,7 @@ import { startBus, listSessions as listBusSessions, resolveTarget, deliver, } fr
48
49
  import { MODES as MODE_SPECS, modeSpec, decide, loadRules, saveRules, addRule, commandPrefixSuggestion, describeRule, ruleMatches, toolRisk, isReadOnlyCommand, } from './permissions.js';
49
50
  import { AskQueue } from './ask-queue.js';
50
51
  import { planSummary } from './plan.js';
52
+ import { savePlan, plansDir, PLAN_CHOICES, PROCEED_PROMPT } from './plan-file.js';
51
53
  import { logoFor } from './logo.js';
52
54
  import { describeActivity } from './activity.js';
53
55
  import { redactSecrets, containsSecret } from './redact.js';
@@ -502,6 +504,41 @@ export const WORD_HOLD_MS = 60_000;
502
504
  * appears frozen for seven seconds is worse than either.
503
505
  */
504
506
  export const HEARTBEAT_MS = 1_000;
507
+ /**
508
+ * One row of the fork tree: the task on the left, what it has cost on the right.
509
+ *
510
+ * Right-aligning the cost is the whole point of doing this by hand. Laid out as flex siblings the
511
+ * numbers sat wherever the task text left them, so a column of forks had its timings in a ragged
512
+ * diagonal and could not be read down. Composed into one string they line up at the right edge —
513
+ * and it is one node per row rather than five, which is what a live list of eight agents can
514
+ * afford to repaint.
515
+ *
516
+ * The task gives way first: it is the part a reader can still recognise from its opening words,
517
+ * while a truncated token count is worth nothing at all.
518
+ */
519
+ /** The width of what precedes the task on a fork row: a status glyph, a space, and "fork ". */
520
+ export const FORK_LEAD = 8;
521
+ export function forkRow(task, meta, width, indent = FORK_LEAD) {
522
+ /*
523
+ * Nothing here may add up to more than the width it was given.
524
+ *
525
+ * The first version floored the task at eight columns and the gap at one, which on a narrow
526
+ * terminal produced a row a character longer than the space it had. An overflowing row is not a
527
+ * cosmetic problem in this interface — it is how the live region grows past the window, and a
528
+ * live region past the window is what makes Ink wipe the whole terminal on every render.
529
+ *
530
+ * So the budget is spent in order of what is worth keeping: the cost first, because a truncated
531
+ * token count says nothing, then whatever is left goes to the task, which stays recognisable
532
+ * from its opening words. Where there is not even room for the cost, it is cut too — a row that
533
+ * fits and says less beats a row that says more and breaks the screen.
534
+ */
535
+ const avail = Math.max(0, width - indent);
536
+ const shownMeta = meta.length <= avail ? meta : meta.slice(0, avail);
537
+ const room = Math.max(0, avail - shownMeta.length - 1);
538
+ const shownTask = room > 0 ? fitCells(task, room, true) : '';
539
+ const gap = ' '.repeat(Math.max(0, avail - displayWidth(shownTask) - shownMeta.length));
540
+ return { task: shownTask, gap, meta: shownMeta };
541
+ }
505
542
  /**
506
543
  * The cursor, drawn as a painted cell rather than as a character.
507
544
  *
@@ -635,6 +672,7 @@ const COMMANDS = [
635
672
  { cmd: '/thinking', desc: 'Show or hide the model\'s reasoning as it arrives' },
636
673
  { cmd: '/activity', desc: 'What the model has done this session, and what it is on now' },
637
674
  { cmd: '/trajectory', desc: 'Where the time went: per-request timings, tool durations, waits' },
675
+ { cmd: '/compact', desc: 'Summarise the conversation so far to free up context' },
638
676
  { cmd: '/update', desc: 'Check for a new version and update' },
639
677
  { cmd: '/name', desc: 'Name this session, so /resume can find it by name' },
640
678
  { cmd: '/fork', desc: 'Copy this session and continue on the copy' },
@@ -1225,6 +1263,7 @@ Type / on an empty line to browse every command; keep typing to filter.
1225
1263
 
1226
1264
  Slash commands:
1227
1265
  /help Show this help
1266
+ /compact Summarise the conversation so far to free up context
1228
1267
  /update Updates and a key guide, in a side panel
1229
1268
  /models [filter] Browse models from the connected provider and switch
1230
1269
  /connect [provider] Connect a provider (picker when no name given)
@@ -1422,16 +1461,42 @@ export function initialAltScreen(cfg, compositesRedraws, stdout = process.stdout
1422
1461
  * to the full width first so the tint is a solid block, and the padding is emitted as its own
1423
1462
  * span so it takes the background without taking a foreground colour.
1424
1463
  */
1464
+ /**
1465
+ * One diff line, coloured, as a single string.
1466
+ *
1467
+ * It used to return one element per token, so a forty-row panel of ten-token lines handed Ink
1468
+ * several hundred nodes to render and lay out on every frame. Measured on a Raspberry Pi 5 with
1469
+ * two 200-line diffs: an open panel cost 281ms a repaint when the whole diff was built, 41ms once
1470
+ * only the visible rows were, and 32ms once each line was one node instead of a box of them —
1471
+ * against 1ms with the panel closed.
1472
+ *
1473
+ * A string is the end of that road: the colours are written into it with the same escapes Ink
1474
+ * would have emitted, and the line becomes a single Text. It is also then cacheable, which is
1475
+ * what makes a still panel free rather than merely cheap — the text of a finished edit does not
1476
+ * change, so it is coloured once and read back on every frame after.
1477
+ */
1478
+ const CODE_LINE_CACHE = new Map();
1479
+ const CODE_LINE_CACHE_MAX = 4000;
1425
1480
  function renderCodeLine(text, kind, width, lang) {
1481
+ const key = `${kind}|${width}|${lang}|${text}`;
1482
+ const hit = CODE_LINE_CACHE.get(key);
1483
+ if (hit !== undefined)
1484
+ return hit;
1426
1485
  const bg = kind === 'add' ? DIFF_ADD_BG : kind === 'del' ? DIFF_DEL_BG : undefined;
1486
+ const paint = bg ? chalk.bgHex(bg) : chalk;
1427
1487
  const body = text.replace(/\t/g, ' ');
1428
1488
  const cell = fitCells(body, width, true);
1429
1489
  const spans = tokenizeLine(cell.replace(/\s+$/, ''), lang);
1430
1490
  const drawn = spans.map(sp => sp.text).join('');
1431
1491
  const pad = cell.length > drawn.length ? cell.slice(drawn.length) : '';
1432
- const out = spans.map((sp, i) => (_jsx(Text, { color: SYNTAX[sp.kind], ...(bg ? { backgroundColor: bg } : {}), children: sp.text }, i)));
1433
- if (pad)
1434
- out.push(_jsx(Text, { ...(bg ? { backgroundColor: bg } : {}), children: pad }, "pad"));
1492
+ const out = spans.map(sp => paint.hex(SYNTAX[sp.kind])(sp.text)).join('')
1493
+ + (pad ? paint(pad) : '');
1494
+ if (CODE_LINE_CACHE.size >= CODE_LINE_CACHE_MAX) {
1495
+ const oldest = CODE_LINE_CACHE.keys().next().value;
1496
+ if (oldest !== undefined)
1497
+ CODE_LINE_CACHE.delete(oldest);
1498
+ }
1499
+ CODE_LINE_CACHE.set(key, out);
1435
1500
  return out;
1436
1501
  }
1437
1502
  /**
@@ -1450,10 +1515,7 @@ export function DiffPanel({ files, width, height, activity, activityRows = 0, sc
1450
1515
  // empty state was a single easy-to-miss line and looked as if `/diff` had done nothing.
1451
1516
  const inner = Math.max(20, width - 4);
1452
1517
  const bodyHeight = Math.max(1, height - 4 - activityRows);
1453
- // Rows are laid out against the height available, newest file first, so the thing just
1454
- // written is the thing on screen.
1455
- const body = [];
1456
- let used = 0;
1518
+ const rows = [];
1457
1519
  let shownFiles = 0;
1458
1520
  for (const d of [...files].reverse()) {
1459
1521
  /*
@@ -1464,57 +1526,72 @@ export function DiffPanel({ files, width, height, activity, activityRows = 0, sc
1464
1526
  * blank line as the only separation two files' diffs ran together and the filename headers had
1465
1527
  * nothing to sit under. Drawn only between files, never above the first.
1466
1528
  */
1467
- if (shownFiles > 0) {
1468
- body.push(_jsx(Text, { color: DIM, children: '─'.repeat(inner) }, `r-${d.path}`));
1469
- used += 1;
1470
- }
1529
+ if (shownFiles > 0)
1530
+ rows.push({ t: 'rule', key: `r-${d.path}` });
1471
1531
  const numbers = d.hunks.flatMap(h => h.lines.map(l => l.after ?? l.before ?? 0));
1472
1532
  const gutter = Math.max(3, String(Math.max(0, ...numbers)).length);
1473
1533
  const codeWidth = Math.max(12, inner - gutter - 2);
1474
1534
  const lang = spanLangFor(d.path);
1475
1535
  const tag = d.created ? 'new' : d.deleted ? 'deleted' : `+${d.added}${d.removed ? ` -${d.removed}` : ''}`;
1476
- body.push(_jsxs(Box, { justifyContent: "space-between", width: inner, children: [_jsx(Text, { color: CYAN, wrap: "truncate-start", children: d.path }), _jsxs(Text, { color: d.deleted ? DIFF_DEL : DIFF_ADD, children: [" ", tag] })] }, `h-${d.path}`));
1477
- used += 1;
1536
+ rows.push({ t: 'head', key: `h-${d.path}`, path: d.path, tag, deleted: d.deleted });
1478
1537
  if (d.binary) {
1479
- body.push(_jsx(Text, { color: DIM, children: " binary \u2014 not shown" }, `b-${d.path}`));
1480
- used += 1;
1538
+ rows.push({ t: 'binary', key: `b-${d.path}` });
1481
1539
  shownFiles++;
1482
1540
  continue;
1483
1541
  }
1484
1542
  for (const [hi, hunk] of d.hunks.entries()) {
1485
- if (hi > 0) {
1486
- body.push(_jsx(Text, { color: DIM, children: `${' '.repeat(gutter)} ⋯` }, `g-${d.path}-${hi}`));
1487
- used += 1;
1488
- }
1543
+ if (hi > 0)
1544
+ rows.push({ t: 'gap', key: `g-${d.path}-${hi}`, gutter });
1489
1545
  for (const [li, l] of hunk.lines.entries()) {
1490
- const shown = l.kind === 'del' ? l.before : l.after;
1491
- const sign = l.kind === 'add' ? '+' : l.kind === 'del' ? '-' : ' ';
1492
- body.push(_jsxs(Box, { children: [_jsx(Text, { color: DIM, children: String(shown ?? '').padStart(gutter) }), _jsx(Text, { color: l.kind === 'add' ? DIFF_ADD : l.kind === 'del' ? DIFF_DEL : DIM, ...(l.kind !== 'ctx'
1493
- ? { backgroundColor: l.kind === 'add' ? DIFF_ADD_BG : DIFF_DEL_BG } : {}), children: sign }), renderCodeLine(l.text, l.kind, codeWidth, lang)] }, `l-${d.path}-${hi}-${li}`));
1494
- used += 1;
1546
+ rows.push({
1547
+ t: 'line', key: `l-${d.path}-${hi}-${li}`, gutter, codeWidth, lang,
1548
+ shown: (l.kind === 'del' ? l.before : l.after) ?? undefined,
1549
+ sign: l.kind === 'add' ? '+' : l.kind === 'del' ? '-' : ' ',
1550
+ kind: l.kind, text: l.text,
1551
+ });
1495
1552
  }
1496
1553
  }
1497
1554
  if (d.hiddenLines > 0) {
1498
- body.push(_jsx(Text, { color: DIM, children: `${' '.repeat(gutter)} ⋯ ${d.hiddenLines} more` }, `m-${d.path}`));
1499
- used += 1;
1555
+ rows.push({ t: 'more', key: `m-${d.path}`, gutter, count: d.hiddenLines });
1500
1556
  }
1501
1557
  shownFiles++;
1502
- used += 1;
1503
- body.push(_jsx(Text, { children: " " }, `s-${d.path}`));
1558
+ rows.push({ t: 'spacer', key: `s-${d.path}` });
1504
1559
  }
1505
- /*
1506
- * Built in full, then windowed — rather than stopping at the height, which is what it used to
1507
- * do. A panel that only ever builds what fits cannot be scrolled, because the rows below the
1508
- * fold were never made.
1509
- */
1510
- const maxScroll = Math.max(0, body.length - bodyHeight);
1560
+ const maxScroll = Math.max(0, rows.length - bodyHeight);
1511
1561
  const from = Math.max(0, Math.min(scroll, maxScroll));
1512
- const shownRows = body.slice(from, from + bodyHeight);
1562
+ const body = rows.slice(from, from + bodyHeight).map(r => {
1563
+ switch (r.t) {
1564
+ case 'rule':
1565
+ return _jsx(Text, { color: DIM, children: '─'.repeat(inner) }, r.key);
1566
+ case 'head':
1567
+ return (_jsxs(Box, { justifyContent: "space-between", width: inner, children: [_jsx(Text, { color: CYAN, wrap: "truncate-start", children: r.path }), _jsxs(Text, { color: r.deleted ? DIFF_DEL : DIFF_ADD, children: [" ", r.tag] })] }, r.key));
1568
+ case 'binary':
1569
+ return _jsx(Text, { color: DIM, children: " binary \u2014 not shown" }, r.key);
1570
+ case 'gap':
1571
+ return _jsx(Text, { color: DIM, children: `${' '.repeat(r.gutter)} ⋯` }, r.key);
1572
+ case 'more':
1573
+ return _jsx(Text, { color: DIM, children: `${' '.repeat(r.gutter)} ⋯ ${r.count} more` }, r.key);
1574
+ case 'spacer':
1575
+ return _jsx(Text, { children: " " }, r.key);
1576
+ default:
1577
+ /*
1578
+ * One Text per line, with the pieces nested inside it, rather than a Box of siblings.
1579
+ *
1580
+ * A Box is a layout node: the engine measures and places it and every child. A Text with
1581
+ * Texts inside it is one node holding spans. With forty lines on screen and half a dozen
1582
+ * pieces each that is the difference between laying out two hundred and fifty nodes a
1583
+ * frame and laying out forty — and it is the same mistake that stranded the composer's
1584
+ * cursor on the first row, in a place where it costs time rather than correctness.
1585
+ */
1586
+ return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: DIM, children: String(r.shown ?? '').padStart(r.gutter) }), _jsx(Text, { color: r.kind === 'add' ? DIFF_ADD : r.kind === 'del' ? DIFF_DEL : DIM, ...(r.kind !== 'ctx'
1587
+ ? { backgroundColor: r.kind === 'add' ? DIFF_ADD_BG : DIFF_DEL_BG } : {}), children: r.sign }), renderCodeLine(r.text, r.kind, r.codeWidth, r.lang)] }, r.key));
1588
+ }
1589
+ });
1513
1590
  const summary = files.length === 0
1514
1591
  ? 'no changes yet'
1515
1592
  : `${files.length} file${files.length === 1 ? '' : 's'} changed`
1516
1593
  + (maxScroll > 0 ? ` ${from > 0 ? `↑${from}` : ''}${from < maxScroll ? ` ↓${maxScroll - from}` : ''}`.trimEnd() : '');
1517
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, borderStyle: "round", borderColor: CYAN, paddingX: 1, flexShrink: 0, overflowY: "hidden", children: [_jsxs(Box, { justifyContent: "space-between", width: inner, children: [_jsxs(Box, { children: [_jsx(Text, { color: INK, bold: true, children: summary }), added > 0 && _jsxs(Text, { color: DIFF_ADD, children: [" +", added] }), removed > 0 && _jsxs(Text, { color: DIFF_DEL, children: [" -", removed] })] }), _jsx(Text, { color: DIM, children: "/diff" })] }), activity && _jsxs(_Fragment, { children: [_jsx(Text, { color: DIM, children: '─'.repeat(inner) }), _jsx(Box, { flexDirection: "column", height: Math.max(1, activityRows - 1), overflowY: "hidden", children: activity })] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflowY: "hidden", children: shownRows }), shownFiles < files.length && (_jsx(Text, { color: DIM, children: `… ${files.length - shownFiles} more file(s) changed` }))] }));
1594
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, borderStyle: "round", borderColor: CYAN, paddingX: 1, flexShrink: 0, overflowY: "hidden", children: [_jsxs(Box, { justifyContent: "space-between", width: inner, children: [_jsxs(Box, { children: [_jsx(Text, { color: INK, bold: true, children: summary }), added > 0 && _jsxs(Text, { color: DIFF_ADD, children: [" +", added] }), removed > 0 && _jsxs(Text, { color: DIFF_DEL, children: [" -", removed] })] }), _jsx(Text, { color: DIM, children: "/diff" })] }), activity && _jsxs(_Fragment, { children: [_jsx(Text, { color: DIM, children: '─'.repeat(inner) }), _jsx(Box, { flexDirection: "column", height: Math.max(1, activityRows - 1), overflowY: "hidden", children: activity })] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflowY: "hidden", children: body }), shownFiles < files.length && (_jsx(Text, { color: DIM, children: `… ${files.length - shownFiles} more file(s) changed` }))] }));
1518
1595
  }
1519
1596
  /**
1520
1597
  * Exported for one reason: so a test can mount it.
@@ -1923,6 +2000,22 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
1923
2000
  // run is the record of what the agent thought it was doing.
1924
2001
  addRow({ role: 'plan', plan: next });
1925
2002
  },
2003
+ /*
2004
+ * Compaction is announced, because it is not a detail.
2005
+ *
2006
+ * It already ran on its own when the window filled — `onCompress` has been in the config all
2007
+ * along and this interface never wired it, so the conversation was summarised and replaced
2008
+ * underneath somebody mid-task with nothing on screen to say so. The next answer would then
2009
+ * be missing something said an hour earlier and look like the model forgetting.
2010
+ */
2011
+ onCompress: (before, after, limit) => {
2012
+ addSystem(`Context compacted — ${before} messages summarised into ${after}, to stay inside `
2013
+ + `${limit.toLocaleString()} tokens. Earlier detail is now a summary; `
2014
+ + '`/compact` forces this, and a new session starts clean.');
2015
+ },
2016
+ onCompactNoop: (reason) => {
2017
+ addSystem(`Nothing was compacted — ${reason}.`);
2018
+ },
1926
2019
  onChunk: (text) => pushSay(text),
1927
2020
  onToolCall: (name, args) => pushTool(name, args),
1928
2021
  /*
@@ -2517,6 +2610,14 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
2517
2610
  })();
2518
2611
  }, [effort]);
2519
2612
  const [showAnalytics, setShowAnalytics] = useState(false);
2613
+ /**
2614
+ * A finished plan, waiting to be answered.
2615
+ *
2616
+ * Plan mode used to end with the plan in the transcript and nothing else — you changed mode
2617
+ * yourself and asked again, from memory, for the thing you had just read. This is the handover
2618
+ * that was missing: the plan, where it was saved, and the obvious question.
2619
+ */
2620
+ const [planAsk, setPlanAsk] = useState(null);
2520
2621
  const [updatePane, setUpdatePane] = useState(null);
2521
2622
  /**
2522
2623
  * The version installed in the background, if one was; see auto-update.ts.
@@ -2561,6 +2662,26 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
2561
2662
  // kept the width it booted with until restarted.
2562
2663
  // `|| 80` not `?? 80`: a pty with no negotiated size reports 0 columns, and 0 would collapse
2563
2664
  // the layout to a single character.
2665
+ /**
2666
+ * Bumped to make <Static> write the whole transcript out again.
2667
+ *
2668
+ * Ink renders a Static item once and never touches it after, which is exactly what makes the
2669
+ * transcript cheap — and also what leaves it wrapped to whatever the terminal was when it was
2670
+ * written. Open KONECK in a small window, maximise it, and every finished row keeps the narrow
2671
+ * shape it was born with while the composer and status bar spread to the new width. Reported
2672
+ * exactly that way.
2673
+ *
2674
+ * A new key is a new Static as far as Ink is concerned, so it renders all of its items afresh.
2675
+ * Paired with a clear, that re-flows the conversation to the width the window has now.
2676
+ */
2677
+ const [staticEpoch, setStaticEpoch] = useState(0);
2678
+ /**
2679
+ * The redraw, in a ref.
2680
+ *
2681
+ * The resize listener is attached once and outlives every render, so it cannot close over a
2682
+ * function defined further down the component without holding the first one forever.
2683
+ */
2684
+ const redrawScreenRef = useRef(null);
2564
2685
  const [termCols, setTermCols] = useState(() => process.stdout.columns || 80);
2565
2686
  const [termRows, setTermRows] = useState(() => process.stdout.rows || 24);
2566
2687
  /** Optional user cap on the UI width. Undefined means fill the terminal. */
@@ -2618,8 +2739,21 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
2618
2739
  }
2619
2740
  useEffect(() => {
2620
2741
  const onResize = () => {
2621
- setTermCols(process.stdout.columns || 80);
2742
+ const cols = process.stdout.columns || 80;
2622
2743
  setTermRows(process.stdout.rows || 24);
2744
+ setTermCols(prev => {
2745
+ /*
2746
+ * A width change re-flows the conversation; a height change does not need to.
2747
+ *
2748
+ * Rows already committed keep the wrapping they were written with, so widening the window
2749
+ * leaves the whole transcript in a narrow column beside a full-width composer. Redrawing
2750
+ * costs one clear and one re-emission, and a resize is not something that happens during
2751
+ * a keystroke.
2752
+ */
2753
+ if (prev !== cols)
2754
+ redrawScreenRef.current?.();
2755
+ return cols;
2756
+ });
2623
2757
  };
2624
2758
  process.stdout.on('resize', onResize);
2625
2759
  return () => { process.stdout.off('resize', onResize); };
@@ -3074,6 +3208,7 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
3074
3208
  * it at all. It wipes the screen and its scrollback; the conversation is untouched, since it
3075
3209
  * lives in the session rather than in the terminal's memory of it.
3076
3210
  */
3211
+ redrawScreenRef.current = redrawScreen;
3077
3212
  function redrawScreen() {
3078
3213
  if (!process.stdout.isTTY)
3079
3214
  return;
@@ -3085,6 +3220,9 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
3085
3220
  // is still in the buffer, and leaving it there means it comes back on the next scroll — and
3086
3221
  // home the cursor, which is where Ink will now draw.
3087
3222
  process.stdout.write('\u001b[2J\u001b[3J\u001b[H');
3223
+ // The transcript has just been erased along with everything else, so it has to be written
3224
+ // again — and writing it again is also what re-flows it to the current width.
3225
+ setStaticEpoch(n => n + 1);
3088
3226
  // Ink writes a frame only when its content differs from the last one, and the content has not
3089
3227
  // changed — the screen it was drawn on has. Bumping this forces the write.
3090
3228
  setRepaint(n => n + 1);
@@ -4578,6 +4716,26 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
4578
4716
  + '`/altscreen on` moves them into a pinned panel instead.');
4579
4717
  return;
4580
4718
  }
4719
+ case '/compact': {
4720
+ /*
4721
+ * Compacting on purpose, rather than waiting for the window to force it.
4722
+ *
4723
+ * The automatic kind exists to stop a request being rejected. This is the other reason:
4724
+ * a session about to change subject carries the whole of the last one into every turn to
4725
+ * come, and that is paid for on each request. Deferred to the next send because the
4726
+ * summary is itself a model call.
4727
+ */
4728
+ const active = await getSession().catch(() => null);
4729
+ if (!active) {
4730
+ addSystem('Nothing to compact yet — no turn has run in this session.');
4731
+ return;
4732
+ }
4733
+ active.compactNow();
4734
+ addSystem('The conversation will be compacted before the next message, if there is '
4735
+ + 'anything to summarise — it will say either way. What was said stays as a '
4736
+ + 'summary; nothing is lost from the transcript above.');
4737
+ return;
4738
+ }
4581
4739
  case '/changes': {
4582
4740
  try {
4583
4741
  const [status, unstaged, staged] = await Promise.all([
@@ -5510,6 +5668,48 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
5510
5668
  answer(typed);
5511
5669
  return;
5512
5670
  }
5671
+ /*
5672
+ * A plan waiting for an answer owns the keyboard.
5673
+ *
5674
+ * Placed before the composer for the same reason the permission question is: an answer to a
5675
+ * question on screen should not also be typed into a draft behind it. Escape puts it away
5676
+ * without choosing, which leaves the plan in the transcript and the mode alone.
5677
+ */
5678
+ if (planAsk) {
5679
+ if (key.escape) {
5680
+ setPlanAsk(null);
5681
+ addSystem('Left in plan mode. The plan is above'
5682
+ + (planAsk.file ? ` and saved to \`${planAsk.file}\`` : '')
5683
+ + ' — say what to change, or shift+tab to a mode that can act on it.');
5684
+ return;
5685
+ }
5686
+ if (key.upArrow) {
5687
+ setPlanAsk(p => (p ? { ...p, choice: Math.max(0, p.choice - 1) } : p));
5688
+ return;
5689
+ }
5690
+ if (key.downArrow) {
5691
+ setPlanAsk(p => (p ? { ...p, choice: Math.min(PLAN_CHOICES.length - 1, p.choice + 1) } : p));
5692
+ return;
5693
+ }
5694
+ const typed = PLAN_CHOICES.findIndex(c => c.key === input);
5695
+ const index = typed >= 0 ? typed : key.return ? planAsk.choice : -1;
5696
+ if (index < 0)
5697
+ return;
5698
+ const chosen = PLAN_CHOICES[index];
5699
+ setPlanAsk(null);
5700
+ if (chosen.mode === null) {
5701
+ addSystem('Tell it what to change, and it will plan again.');
5702
+ return;
5703
+ }
5704
+ setMode(chosen.mode);
5705
+ modeRef.current = chosen.mode;
5706
+ addSystem(`Carrying out the plan in **${modeSpec(chosen.mode).label}** mode.`
5707
+ + (planAsk.file ? `\nSaved to \`${planAsk.file}\`` : ''));
5708
+ // The mode has to be in force before the turn starts, which is why it is set on the ref as
5709
+ // well as in state — the turn reads the ref, and state has not settled by the time it does.
5710
+ setTimeout(() => submitDraft(PROCEED_PROMPT), 0);
5711
+ return;
5712
+ }
5513
5713
  if (addProv) {
5514
5714
  if (key.escape) {
5515
5715
  setAddProv(null);
@@ -6144,7 +6344,16 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
6144
6344
  const active = await getSession();
6145
6345
  const persistentGoal = goalRef.current;
6146
6346
  const prefix = (persistentGoal ? `[Persistent session goal: ${persistentGoal}]\n` : '') +
6147
- modeSpec(mode).prefix +
6347
+ /*
6348
+ * The ref, not the state.
6349
+ *
6350
+ * This closure is made when the turn is submitted, and a mode chosen a moment earlier
6351
+ * is not in it — React has not re-rendered yet. Approving a plan set the mode to auto
6352
+ * and then sent the request with plan mode's prefix still attached: "you cannot write
6353
+ * files or run commands in this mode", to a model that had just been told to carry
6354
+ * the plan out. The permission checks already read the ref for exactly this reason.
6355
+ */
6356
+ modeSpec(modeRef.current).prefix +
6148
6357
  (effort !== 'medium' ? `[Effort: ${effort}]\n` : '');
6149
6358
  const controller = new AbortController();
6150
6359
  abortRef.current = controller;
@@ -6227,6 +6436,22 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
6227
6436
  addSystem(incompleteRef.current);
6228
6437
  incompleteRef.current = null;
6229
6438
  }
6439
+ /*
6440
+ * A plan-mode turn ends with a question, not with silence.
6441
+ *
6442
+ * Only in plan mode, and only when something was actually written: the mode denies
6443
+ * writes and commands, so whatever came back is a proposal by construction. Saved
6444
+ * first, so the offer can name the file it is offering.
6445
+ */
6446
+ if (modeRef.current === 'plan' && content.trim() !== '') {
6447
+ // Under the session's state home, not always the real one: a run pointed at a
6448
+ // different directory should not write into the home directory behind it.
6449
+ const saved = await savePlan(content, {
6450
+ goal: goalRef.current, at: Date.now(),
6451
+ ...(cfg.stateHome ? { dir: plansDir(cfg.stateHome) } : {}),
6452
+ });
6453
+ setPlanAsk({ text: content, file: 'path' in saved ? saved.path : null, choice: 0 });
6454
+ }
6230
6455
  setAgentState('completed');
6231
6456
  setTimeout(() => setAgentState('ready'), 700);
6232
6457
  }
@@ -6568,23 +6793,34 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
6568
6793
  //
6569
6794
  // Both halves matter. Clipping the bottom would hide what just happened; anchoring
6570
6795
  // always to the bottom is what put the banner there.
6571
- _jsx(Box, { flexGrow: 0, flexShrink: 1, flexDirection: "column", justifyContent: "flex-end", overflowY: "hidden", children: visibleRows.map((row, i) => (_jsx(Box, { flexDirection: "column", flexShrink: 0, children: renderRowCached(row, i) }, i))) })), altScreen && !ask && scrollBack > 0 && (_jsx(Text, { color: AMBER, children: `${G.caret} scrolled back ${scrollBack} rows — Ctrl+End follows live · Ctrl+Up/Down moves by section` })), 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: altScreen ? [] : rows, children: (row, index) => renderRow(row, index) }), _jsx(Box, { flexDirection: "column", flexShrink: 0, children: !ask && busy && (agentState === 'processing' || agentState === 'syncing') && (_jsxs(Box, { flexDirection: "column", children: [!ask && sayRef.current.trim() !== '' && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: replyStartedRef.current ? ' ' : G.brand }), _jsx(Box, { flexDirection: "column", children: tailLines(sayRef.current, liveReplyLines(termRows), replyWidth - 3)
6796
+ _jsx(Box, { flexGrow: 0, flexShrink: 1, flexDirection: "column", justifyContent: "flex-end", overflowY: "hidden", children: visibleRows.map((row, i) => (_jsx(Box, { flexDirection: "column", flexShrink: 0, children: renderRowCached(row, i) }, i))) })), altScreen && !ask && scrollBack > 0 && (_jsx(Text, { color: AMBER, children: `${G.caret} scrolled back ${scrollBack} rows — Ctrl+End follows live · Ctrl+Up/Down moves by section` })), 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: altScreen ? [] : rows, children: (row, index) => renderRow(row, index) }, staticEpoch), _jsx(Box, { flexDirection: "column", flexShrink: 0, children: !ask && busy && (agentState === 'processing' || agentState === 'syncing') && (_jsxs(Box, { flexDirection: "column", children: [!ask && sayRef.current.trim() !== '' && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: replyStartedRef.current ? ' ' : G.brand }), _jsx(Box, { flexDirection: "column", children: tailLines(sayRef.current, liveReplyLines(termRows), replyWidth - 3)
6572
6797
  .split('\n').map((line, i) => _jsx(Text, { color: INK, children: line }, i)) })] })), liveToolRef.current && renderStep(liveToolRef.current, 1), queued.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: AMBER, children: "\u25AA" }), _jsxs(Text, { color: MUTED, children: [queued.length, " queued, will run when this finishes \u00B7 /aside to ask without waiting"] })] }), queued.map((q, i) => (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: DIM, children: fitCells(q.split('\n')[0] ?? '', Math.max(20, barWidth - 6), true) }) }, i)))] })), !activityPanelOpen && agents.length > 0 && (() => {
6573
6798
  const done = agents.filter(a => a.status !== 'running').length;
6574
6799
  const failed = agents.filter(a => a.status === 'failed').length;
6575
6800
  const tokens = agents.reduce((n, a) => n + a.tokens, 0);
6576
6801
  const started = Math.min(...agents.map(a => a.startedAt));
6577
- // The task text is clipped to whatever is left after the fixed columns, so a long
6578
- // task never wraps and breaks the alignment of the rows under it.
6579
- const taskWidth = Math.max(16, barWidth - 34);
6580
- 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 => {
6802
+ /*
6803
+ * A tree, because that is what it is.
6804
+ *
6805
+ * This was a flat list with its columns laid out as flex siblings, so the timings
6806
+ * and token counts landed wherever each task's text happened to end — a ragged
6807
+ * diagonal down the right-hand side that could not be read as a column. Naming the
6808
+ * session as the root and the sub-agents as forks of it also says the thing the
6809
+ * list left implicit: these are branches of the work you asked for, not separate
6810
+ * conversations.
6811
+ */
6812
+ const width = Math.max(30, barWidth - 2);
6813
+ 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.length === 1 ? 'fork' : 'forks'] }), _jsx(Text, { color: DIM, children: "\u00B7" }), _jsxs(Text, { color: done === agents.length ? GREEN : AMBER, children: [done, "/", agents.length, " done"] }), _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"] })] }), _jsxs(Text, { children: [_jsxs(Text, { color: CYAN, children: [G.ok === '✓' ? '●' : '*', " "] }), _jsx(Text, { color: INK, children: "main" })] }), agents.map(a => {
6581
6814
  const glyph = a.status === 'running' ? SPINNER[spinFrame]
6582
6815
  : a.status === 'failed' ? G.fail : G.ok;
6583
6816
  const color = a.status === 'running' ? AMBER
6584
6817
  : a.status === 'failed' ? CRIMSON : GREEN;
6585
6818
  const took = (a.endedAt ?? Date.now()) - a.startedAt;
6586
- const task = a.task.length > taskWidth ? a.task.slice(0, taskWidth - 3) + '...' : a.task;
6587
- 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: fitCells(task, taskWidth) }), _jsx(Text, { color: DIM, children: humanTokens(a.tokens).padStart(6) }), _jsx(Text, { color: DIM, children: fmtElapsed(took).padStart(6) })] }, a.index));
6819
+ const meta = `${fmtElapsed(took)} · ${G.ok === '✓' ? '↓' : 'v'} ${humanTokens(a.tokens)} tokens`;
6820
+ const row = forkRow(a.task, meta, width, FORK_LEAD);
6821
+ // One Text, not a Box of them: five layout nodes per agent times eight agents
6822
+ // is a repaint cost for a list that changes every second anyway.
6823
+ return (_jsxs(Text, { children: [_jsxs(Text, { color: color, children: [glyph, " "] }), _jsx(Text, { color: DIM, children: "fork " }), _jsx(Text, { color: a.status === 'running' ? INK : MUTED, children: row.task }), _jsx(Text, { children: row.gap }), _jsx(Text, { color: DIM, children: row.meta })] }, a.index));
6588
6824
  })] }));
6589
6825
  })(), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsxs(Text, { color: GREEN, children: [animate ? SPINNER[spinFrame] : '●', " "] }), _jsx(Shimmer, { text: workWord({
6590
6826
  elapsedMs, tools: turnToolsRef.current,
@@ -6918,7 +7154,24 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
6918
7154
  : { label: 'repoint ' + addProv.name + '? (y/n)',
6919
7155
  hint: `it ships with KONECK, pointing at ${shadowed?.baseURL}` };
6920
7156
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: CYAN, paddingX: 1, marginTop: 1, width: barWidth, children: [_jsx(Text, { color: CYAN, bold: true, children: "Declare a provider" }), _jsxs(Text, { color: MUTED, children: [ask.hint, " \u00B7 esc to cancel"] }), addProv.name !== '' && (_jsxs(Text, { color: DIM, children: [addProv.name, addProv.url ? ' ' + addProv.url : ''] })), addProv.error && _jsx(Text, { color: CRIMSON, children: addProv.error }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: MUTED, children: [ask.label, ": "] }), _jsx(Text, { color: INK, children: addProv.value }), _jsx(Cursor, {})] })] }));
6921
- })(), keyPrompt && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: AMBER, paddingX: 1, marginTop: 1, width: barWidth, children: [_jsxs(Text, { color: AMBER, bold: true, children: ["API key for ", keyPrompt.provider] }), _jsx(Text, { color: MUTED, children: "Paste it and press enter, or ctrl+v to read the clipboard. Held in memory for this session only; esc to cancel." }), keyPrompt.note && _jsx(Text, { color: CYAN, children: keyPrompt.note }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: MUTED, children: [keyPrompt.env, ": "] }), _jsx(Text, { color: INK, children: '*'.repeat(Math.min(keyPrompt.value.length, 48)) }), _jsx(Cursor, {}), keyPrompt.value.length > 0 && (_jsxs(Text, { color: DIM, children: [" ", keyPrompt.value.length, " characters"] }))] })] })), ask && (() => {
7157
+ })(), keyPrompt && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: AMBER, paddingX: 1, marginTop: 1, width: barWidth, children: [_jsxs(Text, { color: AMBER, bold: true, children: ["API key for ", keyPrompt.provider] }), _jsx(Text, { color: MUTED, children: "Paste it and press enter, or ctrl+v to read the clipboard. Held in memory for this session only; esc to cancel." }), keyPrompt.note && _jsx(Text, { color: CYAN, children: keyPrompt.note }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: MUTED, children: [keyPrompt.env, ": "] }), _jsx(Text, { color: INK, children: '*'.repeat(Math.min(keyPrompt.value.length, 48)) }), _jsx(Cursor, {}), keyPrompt.value.length > 0 && (_jsxs(Text, { color: DIM, children: [" ", keyPrompt.value.length, " characters"] }))] })] })), planAsk && (() => {
7158
+ /*
7159
+ * The question, straight after the plan rather than instead of it.
7160
+ *
7161
+ * The plan itself is already in the transcript above — repeating it here would push the
7162
+ * choices off a short screen, which is the one thing this panel must not do. So it says
7163
+ * where the plan was saved and asks.
7164
+ */
7165
+ const lines = [
7166
+ 'A plan is ready. Carry it out?',
7167
+ '',
7168
+ ...PLAN_CHOICES.map((c, i) => i === planAsk.choice ? `**${G.caret} ${c.key}. ${c.label}**` : ` ${c.key}. ${c.label}`),
7169
+ '',
7170
+ 'Up/down and enter, or press the number. Esc leaves it in plan mode.',
7171
+ ...(planAsk.file ? [`Saved to \`${planAsk.file}\``] : ['This plan could not be written to disk.']),
7172
+ ];
7173
+ return (_jsx(Box, { marginTop: 1, children: _jsx(Panel, { width: Math.min(barWidth, 84), color: CYAN, title: "Plan ready", children: lines }) }));
7174
+ })(), ask && (() => {
6922
7175
  let detail = '';
6923
7176
  let isCommand = false;
6924
7177
  try {