koneck 2.25.7 → 2.25.9

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
@@ -10,8 +10,9 @@ import { loadKoneckConfig, saveKoneckConfig, setConfigKey, configKeyDescriptions
10
10
  import { PROVIDERS, resolveProvider, getApiKey, addUserProvider, userProvidersPath } from './providers.js';
11
11
  import { listCheckpoints, revertCheckpoint, snapshotTree, filesTouchedBy } from './checkpoint.js';
12
12
  import { fileDiff, diffSummary } from './diff-view.js';
13
+ import { listWorkspaceEntries, filterEntries, activeReference } from './workspace-files.js';
13
14
  import { bar, humanTokens, humanDuration, diffStatSince, modelInfoFrom, contextWindowFor, recordStatus, classifyResponse, stateLabel, readModelStatus, computeUiWidth, } from './usage.js';
14
- import { Markdown, Panel, panelWidth, safeCommitPoint } from './markdown-ink.js';
15
+ import { Markdown, Panel, panelWidth, safeCommitPoint, displayWidth, sliceToWidth } from './markdown-ink.js';
15
16
  import { fetchUpdateStatus, updateCommand } from './update-check.js';
16
17
  import { readFileSync, statSync } from 'fs';
17
18
  import { fileURLToPath } from 'url';
@@ -94,6 +95,24 @@ function fmtTokens(n) {
94
95
  * Greedy word wrap. The user's message is painted as a filled bar, so each line has to be
95
96
  * padded to the same width — a background colour only covers the characters actually drawn.
96
97
  */
98
+ /**
99
+ * Pads a string to an exact number of terminal cells, clipping it if it overflows.
100
+ *
101
+ * `padEnd` counts code units, so one emoji in a line made the padding a cell too long and pushed
102
+ * the right edge of a highlighted bar past where it was drawn. Measuring cells keeps the block
103
+ * rectangular whatever it contains.
104
+ */
105
+ function fitCells(text, width, ellipsis = false) {
106
+ const w = displayWidth(text);
107
+ if (w === width)
108
+ return text;
109
+ if (w < width)
110
+ return text + ' '.repeat(width - w);
111
+ if (!ellipsis)
112
+ return sliceToWidth(text, width).text;
113
+ const head = sliceToWidth(text, Math.max(0, width - 3));
114
+ return head.text + '.'.repeat(Math.min(3, width)) + ' '.repeat(Math.max(0, width - head.width - 3));
115
+ }
97
116
  function wrapToWidth(text, width) {
98
117
  const out = [];
99
118
  for (const paragraph of text.split('\n')) {
@@ -106,7 +125,7 @@ function wrapToWidth(text, width) {
106
125
  if (line === '') {
107
126
  line = word;
108
127
  }
109
- else if (line.length + 1 + word.length <= width) {
128
+ else if (displayWidth(line) + 1 + displayWidth(word) <= width) {
110
129
  line += ' ' + word;
111
130
  }
112
131
  else {
@@ -265,6 +284,7 @@ const COMMANDS = [
265
284
  { cmd: '/ask', desc: 'Ask another session and wait: /ask <repo> <question>' },
266
285
  { cmd: '/agents', desc: 'How parallel sub-agents are configured' },
267
286
  { cmd: '/subtask', desc: 'Run a task in an isolated sub-agent' },
287
+ { cmd: '/btw', desc: 'Ask a side question without interrupting the running task' },
268
288
  { cmd: '/clear', desc: 'Clear conversation history' },
269
289
  { cmd: '/new', desc: 'New session (alias of /clear)' },
270
290
  { cmd: '/reset', desc: 'Reset session (alias of /clear)' },
@@ -292,6 +312,30 @@ const EFFORT_BLURB = {
292
312
  high: 'Thorough — covers edge cases, costs more tokens',
293
313
  max: 'Most rigorous analysis available; slowest and priciest',
294
314
  };
315
+ // Bracketed paste. A terminal that has it enabled wraps pasted text in these markers, which is
316
+ // the only reliable way to tell a paste from very fast typing. Ink strips the leading escape of
317
+ // the opening marker before handing input to useInput, so the literal it sees is "[200~".
318
+ const PASTE_ON = '\u001b[?2004h';
319
+ const PASTE_OFF = '\u001b[?2004l';
320
+ const PASTE_BEGIN = '[200~';
321
+ const PASTE_END = '\u001b[201~';
322
+ /** How a held-back paste appears in the composer. */
323
+ export function pasteToken(index, lines) {
324
+ return `[Pasted text #${index} +${lines} lines]`;
325
+ }
326
+ /**
327
+ * Puts held-back pastes back into the text before it is sent.
328
+ *
329
+ * The token is what the user sees; the model must receive what they actually copied. Anything
330
+ * typed around a token is preserved in place, so a paste can be introduced and followed by a
331
+ * question in one prompt.
332
+ */
333
+ export function expandPastes(draft, pastes) {
334
+ return draft.replace(/\[Pasted text #(\d+) \+\d+ lines\]/g, (whole, n) => {
335
+ const text = pastes[Number(n) - 1];
336
+ return text === undefined ? whole : text;
337
+ });
338
+ }
295
339
  /** Past this, a file is not read for diffing — the render would be useless anyway. */
296
340
  const MAX_DIFF_BYTES = 512 * 1024;
297
341
  /** One tool rarely touches more; the cap stops a bulk move from flooding scrollback. */
@@ -344,6 +388,7 @@ Slash commands:
344
388
  /mode [auto|plan|code] Show or set mode
345
389
  /effort [low|med|high|max] Set reasoning effort
346
390
  /config [key] [value] View or set persistent config
391
+ /btw <question> Ask a side question without interrupting the running task
347
392
  /usage Usage pane: session, context, models, limits
348
393
  /tokens Show token usage breakdown
349
394
  /cost Show session cost
@@ -374,8 +419,33 @@ function App({ config: initialConfig }) {
374
419
  const replyStartedRef = useRef(false);
375
420
  /** Everything streamed this turn. Empty means the provider did not stream at all. */
376
421
  const streamedRef = useRef('');
422
+ /** When the live region last had something new to show, used to pick the repaint rate. */
423
+ const lastActivityRef = useRef(Date.now());
377
424
  /** Frames received before any content — proof the provider has the request and is working. */
378
425
  const [providerFrames, setProviderFrames] = useState(0);
426
+ /**
427
+ * Text pasted into the composer, held aside so the prompt stays readable.
428
+ *
429
+ * A thirty-line paste in the input box buries whatever the user meant to type around it, and the
430
+ * paragraph structure is lost the moment it is folded into one wrapped line. Each paste is kept
431
+ * verbatim here and stands in the draft as a short token, which is expanded again on submit — so
432
+ * the model receives exactly what was copied, blank lines and all.
433
+ */
434
+ const pastesRef = useRef([]);
435
+ const [pasteCount, setPasteCount] = useState(0);
436
+ /** Accumulates a paste that spans more than one stdin chunk. */
437
+ const pasteBufRef = useRef(null);
438
+ /** Workspace paths for the `@` picker, read once and reused. */
439
+ const entriesRef = useRef(null);
440
+ /** Where the `@` being completed starts in the draft. */
441
+ const atStartRef = useRef(-1);
442
+ /**
443
+ * Prompts typed while a turn was running.
444
+ *
445
+ * Enter used to be swallowed outright, so a thought had to be held until the agent finished or
446
+ * typed again from memory. Queued prompts run in order as soon as the current turn ends.
447
+ */
448
+ const [queued, setQueued] = useState([]);
379
449
  /** Pre-edit contents of the files the running tool said it would touch. */
380
450
  const pendingDiffRef = useRef([]);
381
451
  const charsRef = useRef(0); // streamed chars, for the live token estimate
@@ -397,6 +467,7 @@ function App({ config: initialConfig }) {
397
467
  * whatever is left in the buffer.
398
468
  */
399
469
  function pushSay(text) {
470
+ lastActivityRef.current = Date.now();
400
471
  sayRef.current += text;
401
472
  streamedRef.current += text;
402
473
  charsRef.current += text.length;
@@ -441,6 +512,7 @@ function App({ config: initialConfig }) {
441
512
  function pushTool(name, args) {
442
513
  commitSay();
443
514
  // Photograph every file this tool has announced it will touch, before it touches it.
515
+ lastActivityRef.current = Date.now();
444
516
  pendingDiffRef.current = filesTouchedBy(name, args).slice(0, MAX_DIFF_FILES)
445
517
  .map(rel => ({ rel, before: readForDiff(rel) }));
446
518
  liveToolRef.current = { kind: 'tool', name, detail: toolDetail(name, args), startedAt: Date.now() };
@@ -488,6 +560,11 @@ function App({ config: initialConfig }) {
488
560
  onChunk: (text) => pushSay(text),
489
561
  onToolCall: (name, args) => pushTool(name, args),
490
562
  onToolResult: (name, ok, ms) => finishTool(name, ok, ms),
563
+ onToolOutput: (_name, line) => {
564
+ lastActivityRef.current = Date.now();
565
+ if (liveToolRef.current)
566
+ liveToolRef.current.output = line;
567
+ },
491
568
  onStreamActivity: (frames) => setProviderFrames(frames),
492
569
  onAgentProgress: (list) => { agentsRef.current = list; setAgents(list); },
493
570
  onTokens: (s) => {
@@ -673,20 +750,54 @@ function App({ config: initialConfig }) {
673
750
  }
674
751
  busyStart.current = Date.now();
675
752
  wordTimer.current = 0;
676
- // 150ms, not 100ms: every tick repaints the whole live region, so the animation frame rate
677
- // is also the flicker rate. Six-and-a-bit frames a second still reads as a smooth sweep.
678
- const id = setInterval(() => {
753
+ // The frame rate is the flicker rate. Every tick makes Ink erase and rewrite the whole live
754
+ // region measured at 188 line-erases a second, which on a modest terminal reads as the
755
+ // bottom of the screen flashing. So the rate follows how much is actually happening: quick
756
+ // while output is arriving, and much slower once a single long command is all that is left,
757
+ // where a spinner turning four times a second says as much as one turning seven.
758
+ let timer;
759
+ const tick = () => {
760
+ const elapsed = Date.now() - busyStart.current;
679
761
  setSpinFrame(f => (f + 1) % SPINNER.length);
680
762
  setShimmerFrame(f => f + 1);
681
- setElapsedMs(Date.now() - busyStart.current);
763
+ setElapsedMs(elapsed);
682
764
  wordTimer.current += 1;
683
- // 150 ticks = 15s. Long enough to read and settle on, short enough that a slow turn
684
- // still shows signs of life.
685
- if (wordTimer.current % 100 === 0)
686
- setSpinWord(w => w + 1); // ~15s per word
687
- }, 150);
688
- return () => clearInterval(id);
765
+ // Roughly every 15 seconds, whatever the current rate.
766
+ if (wordTimer.current % Math.max(20, Math.round(15_000 / currentDelay())) === 0) {
767
+ setSpinWord(w => w + 1);
768
+ }
769
+ timer = setTimeout(tick, currentDelay());
770
+ };
771
+ const currentDelay = () => {
772
+ const idleFor = Date.now() - lastActivityRef.current;
773
+ // Something is still arriving, so keep it lively.
774
+ if (idleFor < 2_000)
775
+ return 150;
776
+ // A long wait with nothing to show: slow right down.
777
+ return idleFor > 15_000 ? 500 : 250;
778
+ };
779
+ timer = setTimeout(tick, 150);
780
+ return () => clearTimeout(timer);
689
781
  }, [busy]);
782
+ // A queued prompt runs the moment the agent is free again. Keyed on `busy` rather than done
783
+ // inside the turn's own exit paths, so every way a turn can end — success, failure, cancel —
784
+ // drains the queue the same way.
785
+ useEffect(() => {
786
+ if (busy || queued.length === 0)
787
+ return;
788
+ const [next, ...rest] = queued;
789
+ setQueued(rest);
790
+ if (next)
791
+ submitDraft(next);
792
+ }, [busy, queued]);
793
+ // Ask the terminal to bracket pasted text. Without this a paste is indistinguishable from
794
+ // typing, and there is no way to keep it out of the composer or preserve its line breaks.
795
+ useEffect(() => {
796
+ process.stdout.write(PASTE_ON);
797
+ const off = () => { process.stdout.write(PASTE_OFF); };
798
+ process.on('exit', off);
799
+ return () => { off(); process.off('exit', off); };
800
+ }, []);
690
801
  // The tree as it stood before the session touched anything. Recorded once, so "code changes"
691
802
  // stays a comparison against the starting point however long the session runs.
692
803
  useEffect(() => { void snapshotTree(cfg.cwd).then(t => { baseTreeRef.current = t; }); }, [cfg.cwd]);
@@ -811,6 +922,35 @@ function App({ config: initialConfig }) {
811
922
  return []; // the pane says so rather than showing zeroes
812
923
  }
813
924
  }
925
+ /**
926
+ * Answers a question without disturbing the turn in flight.
927
+ *
928
+ * The agent's own conversation is left untouched: the question runs in a separate, isolated
929
+ * session, so asking "which model am I on?" mid-build neither interrupts the build nor pollutes
930
+ * the history the agent is reasoning over. It is the one thing that may jump the queue.
931
+ */
932
+ async function askAside(question) {
933
+ if (!question.trim()) {
934
+ addSystem('Usage: /btw <question> — answers alongside the running task, without interrupting it.');
935
+ return;
936
+ }
937
+ addRow({ role: 'user', text: `/btw ${question}` });
938
+ try {
939
+ const { runAgent } = await import('./engine.js');
940
+ const result = await runAgent(question, {
941
+ ...cfg, ci: true, silent: true,
942
+ // A side question answers from what it is told; it must not edit files behind the
943
+ // running task's back, so it is given no reason to and kept to a couple of turns.
944
+ maxTurns: Math.min(cfg.maxTurns, 3),
945
+ });
946
+ const last = [...result.messages].reverse()
947
+ .find(m => m.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
948
+ addRow({ role: 'agent', text: typeof last?.content === 'string' ? last.content : '(no answer)' });
949
+ }
950
+ catch (e) {
951
+ addSystem(`/btw failed: ${e instanceof Error ? e.message : String(e)}`);
952
+ }
953
+ }
814
954
  /** Opens the update panel, checking the registry each time it is opened deliberately. */
815
955
  async function openUpdatePane() {
816
956
  setUpdatePane({ current: VERSION, latest: null, behind: false, manager: 'npm' });
@@ -872,6 +1012,30 @@ function App({ config: initialConfig }) {
872
1012
  current: p.name === cfg.provider,
873
1013
  }));
874
1014
  }
1015
+ /**
1016
+ * Offers workspace paths for an `@` reference. The listing is read on first use rather than at
1017
+ * startup, so a session in a large repository is not delayed by a scan it may never need.
1018
+ */
1019
+ async function openFilePicker(query) {
1020
+ if (entriesRef.current === null)
1021
+ entriesRef.current = await listWorkspaceEntries(cfg.cwd);
1022
+ const all = entriesRef.current;
1023
+ if (all.length === 0) {
1024
+ addSystem('No files found to reference here.');
1025
+ return;
1026
+ }
1027
+ setPicker({ kind: 'file', items: fileItems(all, query) });
1028
+ setPickQuery(query);
1029
+ setPickIndex(0);
1030
+ }
1031
+ /** The picker rows for a query, ranked by how well each path matches. */
1032
+ function fileItems(all, query) {
1033
+ return filterEntries(all, query, 200).map(entry => ({
1034
+ value: entry,
1035
+ label: entry,
1036
+ desc: entry.endsWith('/') ? 'directory' : '',
1037
+ }));
1038
+ }
875
1039
  function openPicker(kind, items, query = '') {
876
1040
  setPicker({ kind, items });
877
1041
  setPickQuery(query);
@@ -886,6 +1050,18 @@ function App({ config: initialConfig }) {
886
1050
  async function choosePick(item, kindOverride) {
887
1051
  const kind = kindOverride ?? picker?.kind;
888
1052
  closePicker();
1053
+ if (kind === 'file') {
1054
+ // Replace the partial "@query" with the full path, leaving a trailing space so the next
1055
+ // word can be typed straight away. A directory keeps its slash so more can be typed after.
1056
+ const start = atStartRef.current;
1057
+ atStartRef.current = -1;
1058
+ if (start >= 0) {
1059
+ const suffix = item.value.endsWith('/') ? '' : ' ';
1060
+ setDraft(d => d.slice(0, start) + '@' + item.value + suffix + d.slice(caret));
1061
+ setCaret(start + 1 + item.value.length + suffix.length);
1062
+ }
1063
+ return;
1064
+ }
889
1065
  if (kind === 'command') {
890
1066
  // Leave it in the composer so arguments can still be typed before submitting.
891
1067
  setDraft(item.value + ' ');
@@ -967,7 +1143,13 @@ function App({ config: initialConfig }) {
967
1143
  const cost = estimateCost(cfg.model, stats.promptTokens, stats.completionTokens);
968
1144
  const costStr = cost.known ? formatCost(cost.total) : 'unavailable';
969
1145
  const ws = cfg.cwd.replace(process.env['HOME'] ?? '', '~');
1146
+ // The endpoint is resolved from four places — an explicit override, an env var, the
1147
+ // config file, then the built-in default — so showing the name alone leaves the one
1148
+ // question that actually matters when a provider misbehaves unanswered: where did the
1149
+ // request go? Working that out took a proxy and half an hour.
1150
+ const endpoint = resolveProvider(cfg.provider, cfg.baseURL).baseURL;
970
1151
  addSystem(`Provider : ${cfg.provider}\n` +
1152
+ `Endpoint : ${endpoint}\n` +
971
1153
  `Model : ${cfg.model}\n` +
972
1154
  `Mode : ${mode} effort: ${effort}\n` +
973
1155
  `Approval : ${cfg.requireApproval ? 'on' : 'off'}\n` +
@@ -1042,6 +1224,11 @@ function App({ config: initialConfig }) {
1042
1224
  addSystem(`Effort → ${arg}`);
1043
1225
  return;
1044
1226
  }
1227
+ case '/btw': {
1228
+ // Also available when nothing is running, where it simply answers in a clean context.
1229
+ await askAside(arg);
1230
+ return;
1231
+ }
1045
1232
  case '/usage': {
1046
1233
  const wanted = USAGE_TABS.find(t => t.startsWith(arg.trim().toLowerCase()));
1047
1234
  await openUsagePane(wanted ?? 'session');
@@ -1424,7 +1611,53 @@ function App({ config: initialConfig }) {
1424
1611
  addSystem(`Unknown command: ${cmd}. Type /help for the full list.`);
1425
1612
  }
1426
1613
  }
1614
+ /**
1615
+ * Files a completed paste and puts a token in the composer.
1616
+ *
1617
+ * A short single-line paste is inserted literally — turning "npm test" into a token would be
1618
+ * obstructive. Anything with a line break is held back, because that is the case where the text
1619
+ * would both swamp the composer and lose its structure.
1620
+ */
1621
+ function filePaste(text) {
1622
+ const clean = text.replace(/\r\n?/g, '\n');
1623
+ const lines = clean.split('\n').length;
1624
+ if (!clean.includes('\n') && clean.length <= 200) {
1625
+ setDraft(d => d.slice(0, caret) + clean + d.slice(caret));
1626
+ setCaret(c => c + clean.length);
1627
+ return;
1628
+ }
1629
+ pastesRef.current = [...pastesRef.current, clean];
1630
+ const token = pasteToken(pastesRef.current.length, lines);
1631
+ setPasteCount(pastesRef.current.length);
1632
+ setDraft(d => d.slice(0, caret) + token + d.slice(caret));
1633
+ setCaret(c => c + token.length);
1634
+ }
1427
1635
  useInput((input, key) => {
1636
+ // ── Bracketed paste ─────────────────────────────────────────────────────
1637
+ // This runs before every other branch. A paste carries newlines, and any handler that treats
1638
+ // a newline as "submit" would fire partway through one.
1639
+ if (pasteBufRef.current !== null) {
1640
+ const end = input.indexOf(PASTE_END);
1641
+ if (end === -1) {
1642
+ pasteBufRef.current += input;
1643
+ return;
1644
+ }
1645
+ const whole = pasteBufRef.current + input.slice(0, end);
1646
+ pasteBufRef.current = null;
1647
+ filePaste(whole);
1648
+ return;
1649
+ }
1650
+ const begin = input.indexOf(PASTE_BEGIN);
1651
+ if (begin !== -1) {
1652
+ const after = input.slice(begin + PASTE_BEGIN.length);
1653
+ const end = after.indexOf(PASTE_END);
1654
+ if (end === -1) {
1655
+ pasteBufRef.current = after;
1656
+ return;
1657
+ } // more chunks to come
1658
+ filePaste(after.slice(0, end));
1659
+ return;
1660
+ }
1428
1661
  // Esc abandons the turn in flight and hands the keyboard straight back. The composer is
1429
1662
  // deliberately left alone: cancelling should not cost whatever was typed while waiting.
1430
1663
  if (key.escape && busy && !picker && !keyPrompt && !updatePane && !usagePane) {
@@ -1470,6 +1703,8 @@ function App({ config: initialConfig }) {
1470
1703
  setDraft('');
1471
1704
  setCaret(0);
1472
1705
  }
1706
+ if (picker.kind === 'file')
1707
+ atStartRef.current = -1;
1473
1708
  closePicker();
1474
1709
  return;
1475
1710
  }
@@ -1502,6 +1737,21 @@ function App({ config: initialConfig }) {
1502
1737
  setCaret(0);
1503
1738
  return;
1504
1739
  }
1740
+ if (picker.kind === 'file') {
1741
+ // Deleting the "@" itself is how the picker is dismissed without choosing anything.
1742
+ setDraft(d => d.slice(0, Math.max(0, caret - 1)) + d.slice(caret));
1743
+ setCaret(c => Math.max(0, c - 1));
1744
+ if (pickQuery === '') {
1745
+ closePicker();
1746
+ atStartRef.current = -1;
1747
+ return;
1748
+ }
1749
+ const q = pickQuery.slice(0, -1);
1750
+ setPickQuery(q);
1751
+ setPickIndex(0);
1752
+ setPicker(p => (p ? { ...p, items: fileItems(entriesRef.current ?? [], q) } : p));
1753
+ return;
1754
+ }
1505
1755
  setPickQuery(q => q.slice(0, -1));
1506
1756
  setPickIndex(0);
1507
1757
  if (picker.kind === 'command') {
@@ -1514,6 +1764,13 @@ function App({ config: initialConfig }) {
1514
1764
  // A space means the command name is finished and arguments follow, so the palette
1515
1765
  // steps out of the way. Keeping it open sent "model gpt-4o" into the filter, emptied
1516
1766
  // the list, and left Enter with nothing to select.
1767
+ if (input === ' ' && picker.kind === 'file') {
1768
+ closePicker();
1769
+ atStartRef.current = -1;
1770
+ setDraft(d => d.slice(0, caret) + ' ' + d.slice(caret));
1771
+ setCaret(c => c + 1);
1772
+ return;
1773
+ }
1517
1774
  if (input === ' ' && picker.kind === 'command') {
1518
1775
  closePicker();
1519
1776
  setDraft(d => { const t = d.endsWith(' ') ? d : d + ' '; setCaret(t.length); return t; });
@@ -1527,6 +1784,12 @@ function App({ config: initialConfig }) {
1527
1784
  setDraft(t);
1528
1785
  setCaret(t.length);
1529
1786
  }
1787
+ if (picker.kind === 'file') {
1788
+ const q = pickQuery + input;
1789
+ setPicker(p => (p ? { ...p, items: fileItems(entriesRef.current ?? [], q) } : p));
1790
+ setDraft(d => d.slice(0, caret) + input + d.slice(caret));
1791
+ setCaret(c => c + input.length);
1792
+ }
1530
1793
  return;
1531
1794
  }
1532
1795
  return;
@@ -1584,8 +1847,24 @@ function App({ config: initialConfig }) {
1584
1847
  // while it works can be typed as it arrives rather than held until the turn ends — and
1585
1848
  // cancelling with esc then hands the keyboard back to a composer that still has it.
1586
1849
  if (key.return) {
1587
- if (busy)
1850
+ if (busy) {
1851
+ const text = draft.trim();
1852
+ if (!text)
1853
+ return;
1854
+ // A side question is answered straight away and separately; everything else waits its turn.
1855
+ if (/^\/btw(\s|$)/.test(text)) {
1856
+ setDraft('');
1857
+ setCaret(0);
1858
+ void askAside(text.replace(/^\/btw\s*/, ''));
1859
+ return;
1860
+ }
1861
+ setQueued(q => [...q, expandPastes(text, pastesRef.current)]);
1862
+ setDraft('');
1863
+ setCaret(0);
1864
+ pastesRef.current = [];
1865
+ setPasteCount(0);
1588
1866
  return;
1867
+ }
1589
1868
  submitDraft(draft);
1590
1869
  return;
1591
1870
  }
@@ -1667,8 +1946,19 @@ function App({ config: initialConfig }) {
1667
1946
  const text = input.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '');
1668
1947
  if (text === '')
1669
1948
  return;
1670
- setDraft(d => d.slice(0, caret) + text + d.slice(caret));
1671
- setCaret(c => c + text.length);
1949
+ const next = draft.slice(0, caret) + text + draft.slice(caret);
1950
+ const nextCaret = caret + text.length;
1951
+ setDraft(next);
1952
+ setCaret(nextCaret);
1953
+ // A lone "@" at a word boundary offers workspace paths. Checking the draft as it will be,
1954
+ // rather than the character typed, means it also fires when "@" arrives mid-chunk.
1955
+ if (text.endsWith('@')) {
1956
+ const ref = activeReference(next, nextCaret);
1957
+ if (ref) {
1958
+ atStartRef.current = ref.start;
1959
+ void openFilePicker(ref.query);
1960
+ }
1961
+ }
1672
1962
  }
1673
1963
  });
1674
1964
  /** Start of the word at or before `i`, for word-wise movement and deletion. */
@@ -1692,12 +1982,20 @@ function App({ config: initialConfig }) {
1692
1982
  /** Sends the composer contents: a slash command, or a task for the agent. */
1693
1983
  function submitDraft(raw) {
1694
1984
  {
1695
- const task = raw.trim();
1985
+ // Tokens are expanded here, at the last moment. The model gets the text as it was copied —
1986
+ // blank lines, indentation and all — while the transcript shows the compact form so a
1987
+ // thirty-line paste does not bury the question asked about it.
1988
+ const shown = raw.trim();
1989
+ if (!shown)
1990
+ return;
1991
+ const task = expandPastes(shown, pastesRef.current).trim();
1696
1992
  if (!task)
1697
1993
  return;
1698
1994
  setDraft('');
1699
1995
  setCaret(0);
1700
- addRow({ role: 'user', text: task });
1996
+ pastesRef.current = [];
1997
+ setPasteCount(0);
1998
+ addRow({ role: 'user', text: shown });
1701
1999
  if (task.startsWith('/')) {
1702
2000
  const spaceIdx = task.indexOf(' ');
1703
2001
  const cmd = spaceIdx === -1 ? task : task.slice(0, spaceIdx);
@@ -1713,6 +2011,7 @@ function App({ config: initialConfig }) {
1713
2011
  sayRef.current = '';
1714
2012
  streamedRef.current = '';
1715
2013
  replyStartedRef.current = false;
2014
+ lastActivityRef.current = Date.now();
1716
2015
  setProviderFrames(0);
1717
2016
  liveToolRef.current = null;
1718
2017
  charsRef.current = 0;
@@ -1864,7 +2163,7 @@ function App({ config: initialConfig }) {
1864
2163
  const bg = l.kind === 'add' ? DIFF_ADD_BG : l.kind === 'del' ? DIFF_DEL_BG : undefined;
1865
2164
  // Tabs would make the tinted block a ragged width, so they are expanded.
1866
2165
  const body = `${sign} ${l.text}`.replace(/\t/g, ' ');
1867
- const cell = body.length > width ? body.slice(0, width - 3) + '...' : body.padEnd(width);
2166
+ const cell = fitCells(body, width, true);
1868
2167
  return (_jsxs(Box, { children: [_jsxs(Text, { color: DIM, children: [String(shown ?? '').padStart(gutter), " "] }), _jsx(Text, { color: fg, ...(bg ? { backgroundColor: bg } : {}), children: cell })] }, li));
1869
2168
  })] }, hi))), d.hiddenLines > 0 && (_jsxs(Text, { color: DIM, children: [' '.repeat(gutter), " ... ", d.hiddenLines, " more changed lines"] }))] }, key));
1870
2169
  };
@@ -1876,12 +2175,12 @@ function App({ config: initialConfig }) {
1876
2175
  const ms = (step.endedAt ?? Date.now()) - step.startedAt;
1877
2176
  const glyph = running ? SPINNER[spinFrame] : step.ok === false ? '✖' : '✓';
1878
2177
  const color = running ? AMBER : step.ok === false ? CRIMSON : GREEN;
1879
- 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));
2178
+ 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 })] })), running && step.output && (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { color: DIM, children: fitCells(step.output, Math.max(20, barWidth - 6)) }) })), (step.diffs ?? []).map((d, i) => renderDiff(d, i))] }, key));
1880
2179
  };
1881
2180
  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' ? (
1882
2181
  // Painted as a filled bar so the user's own words are the most findable thing
1883
2182
  // on screen when scrolling back through a long session.
1884
- _jsx(Box, { marginTop: 1, flexDirection: "column", children: wrapToWidth(row.text ?? '', barWidth - 2).map((line, li) => (_jsx(Text, { backgroundColor: USER_BG, color: USER_FG, bold: li === 0, children: (li === 0 ? '❯ ' : ' ') + line.padEnd(barWidth - 2) }, li))) }, index)) : row.role === 'error' ? (() => {
2183
+ _jsx(Box, { marginTop: 1, flexDirection: "column", children: wrapToWidth(row.text ?? '', barWidth - 2).map((line, li) => (_jsx(Text, { backgroundColor: USER_BG, color: USER_FG, bold: li === 0, children: (li === 0 ? '❯ ' : ' ') + fitCells(line, barWidth - 2) }, li))) }, index)) : row.role === 'error' ? (() => {
1885
2184
  const lines = [row.text ?? '', '', 'Fix the above, then retry. /status shows the active provider and model.'];
1886
2185
  return (_jsx(Box, { marginTop: 1, children: _jsx(Panel, { width: panelWidth(lines, barWidth, 40), color: CRIMSON, title: "[!] ERROR", children: lines }) }, index));
1887
2186
  })() : row.role === 'system' ? (() => {
@@ -1893,7 +2192,7 @@ function App({ config: initialConfig }) {
1893
2192
  // markdown tables needlessly narrow. A marker plus the content reads better and
1894
2193
  // cannot misalign, which is how Claude Code presents its own answers.
1895
2194
  _jsxs(Box, { flexDirection: "column", marginTop: row.continued ? 0 : 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: row.continued ? ' ' : 'ꞰK' }), _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() !== '' && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: replyStartedRef.current ? ' ' : 'ꞰK' }), _jsx(Box, { flexDirection: "column", children: tailLines(sayRef.current, liveReplyLines(termRows), replyWidth - 3)
1896
- .split('\n').map((line, i) => _jsx(Text, { color: INK, children: line }, i)) })] })), liveToolRef.current && renderStep(liveToolRef.current, 1), agents.length > 0 && (() => {
2195
+ .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 /btw 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)))] })), agents.length > 0 && (() => {
1897
2196
  const done = agents.filter(a => a.status !== 'running').length;
1898
2197
  const failed = agents.filter(a => a.status === 'failed').length;
1899
2198
  const tokens = agents.reduce((n, a) => n + a.tokens, 0);
@@ -1908,7 +2207,7 @@ function App({ config: initialConfig }) {
1908
2207
  : a.status === 'failed' ? CRIMSON : GREEN;
1909
2208
  const took = (a.endedAt ?? Date.now()) - a.startedAt;
1910
2209
  const task = a.task.length > taskWidth ? a.task.slice(0, taskWidth - 3) + '...' : a.task;
1911
- 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));
2210
+ 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));
1912
2211
  })] }));
1913
2212
  })(), _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
1914
2213
  ? `${fmtTokens(liveTokens)} tokens`
@@ -2083,28 +2382,31 @@ function App({ config: initialConfig }) {
2083
2382
  : picker.kind === 'model' ? `Models - ${cfg.provider}`
2084
2383
  : picker.kind === 'effort' ? 'Reasoning effort'
2085
2384
  : picker.kind === 'resume' ? 'Resume a saved session'
2086
- : 'Connect provider';
2385
+ : picker.kind === 'file' ? 'Reference a file or directory'
2386
+ : 'Connect provider';
2087
2387
  // The palette is sized to the rows it is showing rather than to the terminal. On a wide
2088
2388
  // screen a list of short model names in a full-width box is mostly empty box.
2089
- const headWidth = title.length + 24;
2389
+ // The header is a title on the left and a count on the right, on one line. Estimating the
2390
+ // right-hand side at a constant made "Reference a file or directory" wrap onto two rows.
2391
+ const countText = `${list.length} match${list.length === 1 ? '' : 'es'} · esc to close`;
2392
+ const headWidth = title.length + countText.length + 3;
2090
2393
  const rowWidth = Math.max(headWidth, ...shown.map(it => 38 + (it.current ? 10 : 0) + it.desc.length));
2091
2394
  const boxWidth = Math.max(40, Math.min(barWidth, rowWidth + 4));
2092
2395
  const width = boxWidth - 4;
2093
2396
  let lastGroup;
2094
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: CYAN, paddingX: 1, marginTop: 1, width: boxWidth, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: CYAN, bold: true, children: title }), _jsxs(Text, { color: DIM, children: [list.length, " match", list.length === 1 ? '' : 'es', " \u00B7 esc to close"] })] }), list.length === 0 && _jsxs(Text, { color: MUTED, children: ["No match for \"", pickQuery, "\""] }), shown.map((item, i) => {
2397
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: CYAN, paddingX: 1, marginTop: 1, width: boxWidth, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: CYAN, bold: true, children: title }), _jsx(Text, { color: DIM, children: countText })] }), list.length === 0 && _jsxs(Text, { color: MUTED, children: ["No match for \"", pickQuery, "\""] }), shown.map((item, i) => {
2095
2398
  const absolute = Math.max(0, start) + i;
2096
2399
  const selected = absolute === pickIndex;
2097
2400
  const header = item.group && item.group !== lastGroup ? item.group : null;
2098
2401
  lastGroup = item.group;
2099
2402
  const label = item.label.length > 34 ? item.label.slice(0, 31) + '...' : item.label;
2100
- return (_jsxs(Box, { flexDirection: "column", children: [header && _jsx(Text, { color: DIM, children: header }), _jsx(Text, { backgroundColor: selected ? CYAN : undefined, color: selected ? '#10222A' : INK, bold: selected, children: (`${selected ? '❯ ' : ' '}${label.padEnd(36)}${item.current ? '(current) ' : ''}${item.desc}`)
2101
- .slice(0, width).padEnd(width) })] }, item.value + absolute));
2403
+ return (_jsxs(Box, { flexDirection: "column", children: [header && _jsx(Text, { color: DIM, children: header }), _jsx(Text, { backgroundColor: selected ? CYAN : undefined, color: selected ? '#10222A' : INK, bold: selected, children: fitCells(`${selected ? '❯ ' : ' '}${fitCells(label, 36)}${item.current ? '(current) ' : ''}${item.desc}`, width) })] }, item.value + absolute));
2102
2404
  }), list.length > PICKER_ROWS && (_jsxs(Text, { color: DIM, children: ["\u2191\u2193 navigate \u00B7 enter select \u00B7 showing ", Math.max(0, start) + 1, "\u2013", Math.max(0, start) + shown.length, " of ", list.length] }))] }));
2103
2405
  })(), 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. Held in memory for this session only; esc to cancel." }), _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(Text, { color: CYAN, children: "\u2588" })] })] })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: "\u276F " }), _jsx(Text, { color: INK, children: draft.slice(0, caret) }), busy
2104
2406
  ? _jsx(Text, { color: INK, children: draft.slice(caret) })
2105
2407
  : caret < draft.length
2106
2408
  ? _jsxs(_Fragment, { children: [_jsx(Text, { backgroundColor: CYAN, color: "#10222A", children: draft[caret] }), _jsx(Text, { color: INK, children: draft.slice(caret + 1) })] })
2107
- : _jsx(Text, { color: CYAN, children: "\u2588" })] }), _jsx(Box, { marginTop: 1, paddingX: 1, children: _jsxs(Text, { wrap: "truncate", backgroundColor: "#24343B", children: [_jsx(Text, { color: MUTED, children: "TAB " }), _jsx(Text, { color: INK, children: "Analytics" }), _jsx(Text, { color: MUTED, children: " / " }), _jsx(Text, { color: CYAN, children: "/HELP" }), _jsx(Text, { color: INK, children: " Commands" }), _jsx(Text, { color: MUTED, children: " / SHIFT+TAB " }), _jsx(Text, { color: INK, children: "Mode" }), _jsx(Text, { color: MUTED, children: " / " }), _jsx(Text, { color: statusColor, children: statusLabel.toLowerCase() }), _jsx(Text, { color: MUTED, children: " / MODE: " }), _jsx(Text, { color: INK, children: mode }), _jsx(Text, { color: MUTED, children: " / EFFORT: " }), _jsx(Text, { color: effort === 'medium' ? INK : AMBER, children: effort }), _jsx(Text, { color: MUTED, children: " / MODEL: " }), _jsx(Text, { color: INK, children: modelShort }), _jsx(Text, { color: MUTED, children: " / v" }), _jsx(Text, { color: updatePane?.behind ? AMBER : INK, children: VERSION }), updatePane?.behind && _jsx(Text, { color: AMBER, children: " \u2191" })] }) })] }));
2409
+ : _jsx(Text, { color: CYAN, children: "\u2588" })] }), _jsx(Box, { marginTop: 1, paddingX: 1, children: _jsxs(Text, { wrap: "truncate", backgroundColor: "#24343B", children: [_jsx(Text, { color: MUTED, children: "TAB " }), _jsx(Text, { color: INK, children: "Analytics" }), _jsx(Text, { color: MUTED, children: " / " }), _jsx(Text, { color: CYAN, children: "/HELP" }), _jsx(Text, { color: INK, children: " Commands" }), _jsx(Text, { color: MUTED, children: " / SHIFT+TAB " }), _jsx(Text, { color: INK, children: "Mode" }), _jsx(Text, { color: MUTED, children: " / " }), _jsx(Text, { color: statusColor, children: statusLabel.toLowerCase() }), _jsx(Text, { color: MUTED, children: " / MODE: " }), _jsx(Text, { color: INK, children: mode }), _jsx(Text, { color: MUTED, children: " / EFFORT: " }), _jsx(Text, { color: effort === 'medium' ? INK : AMBER, children: effort }), _jsx(Text, { color: MUTED, children: " / MODEL: " }), _jsx(Text, { color: INK, children: modelShort }), _jsx(Text, { color: MUTED, children: " / v" }), _jsx(Text, { color: updatePane?.behind ? AMBER : INK, children: VERSION }), updatePane?.behind && _jsx(Text, { color: AMBER, children: " \u2191" }), pasteCount > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { color: MUTED, children: " / " }), _jsxs(Text, { color: AMBER, children: [pasteCount, " paste", pasteCount === 1 ? '' : 's', " held, sent in full"] })] }))] }) })] }));
2108
2410
  }
2109
2411
  export async function runInkChatMode(config) {
2110
2412
  // resolveConfig has already layered CLI flags above the /config store, so the config