koneck 2.25.6 → 2.25.8

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 {
@@ -292,6 +311,30 @@ const EFFORT_BLURB = {
292
311
  high: 'Thorough — covers edge cases, costs more tokens',
293
312
  max: 'Most rigorous analysis available; slowest and priciest',
294
313
  };
314
+ // Bracketed paste. A terminal that has it enabled wraps pasted text in these markers, which is
315
+ // the only reliable way to tell a paste from very fast typing. Ink strips the leading escape of
316
+ // the opening marker before handing input to useInput, so the literal it sees is "[200~".
317
+ const PASTE_ON = '\u001b[?2004h';
318
+ const PASTE_OFF = '\u001b[?2004l';
319
+ const PASTE_BEGIN = '[200~';
320
+ const PASTE_END = '\u001b[201~';
321
+ /** How a held-back paste appears in the composer. */
322
+ export function pasteToken(index, lines) {
323
+ return `[Pasted text #${index} +${lines} lines]`;
324
+ }
325
+ /**
326
+ * Puts held-back pastes back into the text before it is sent.
327
+ *
328
+ * The token is what the user sees; the model must receive what they actually copied. Anything
329
+ * typed around a token is preserved in place, so a paste can be introduced and followed by a
330
+ * question in one prompt.
331
+ */
332
+ export function expandPastes(draft, pastes) {
333
+ return draft.replace(/\[Pasted text #(\d+) \+\d+ lines\]/g, (whole, n) => {
334
+ const text = pastes[Number(n) - 1];
335
+ return text === undefined ? whole : text;
336
+ });
337
+ }
295
338
  /** Past this, a file is not read for diffing — the render would be useless anyway. */
296
339
  const MAX_DIFF_BYTES = 512 * 1024;
297
340
  /** One tool rarely touches more; the cap stops a bulk move from flooding scrollback. */
@@ -374,6 +417,24 @@ function App({ config: initialConfig }) {
374
417
  const replyStartedRef = useRef(false);
375
418
  /** Everything streamed this turn. Empty means the provider did not stream at all. */
376
419
  const streamedRef = useRef('');
420
+ /** Frames received before any content — proof the provider has the request and is working. */
421
+ const [providerFrames, setProviderFrames] = useState(0);
422
+ /**
423
+ * Text pasted into the composer, held aside so the prompt stays readable.
424
+ *
425
+ * A thirty-line paste in the input box buries whatever the user meant to type around it, and the
426
+ * paragraph structure is lost the moment it is folded into one wrapped line. Each paste is kept
427
+ * verbatim here and stands in the draft as a short token, which is expanded again on submit — so
428
+ * the model receives exactly what was copied, blank lines and all.
429
+ */
430
+ const pastesRef = useRef([]);
431
+ const [pasteCount, setPasteCount] = useState(0);
432
+ /** Accumulates a paste that spans more than one stdin chunk. */
433
+ const pasteBufRef = useRef(null);
434
+ /** Workspace paths for the `@` picker, read once and reused. */
435
+ const entriesRef = useRef(null);
436
+ /** Where the `@` being completed starts in the draft. */
437
+ const atStartRef = useRef(-1);
377
438
  /** Pre-edit contents of the files the running tool said it would touch. */
378
439
  const pendingDiffRef = useRef([]);
379
440
  const charsRef = useRef(0); // streamed chars, for the live token estimate
@@ -486,6 +547,7 @@ function App({ config: initialConfig }) {
486
547
  onChunk: (text) => pushSay(text),
487
548
  onToolCall: (name, args) => pushTool(name, args),
488
549
  onToolResult: (name, ok, ms) => finishTool(name, ok, ms),
550
+ onStreamActivity: (frames) => setProviderFrames(frames),
489
551
  onAgentProgress: (list) => { agentsRef.current = list; setAgents(list); },
490
552
  onTokens: (s) => {
491
553
  realTokRef.current = Math.max(0, s.completionTokens - tokBaseRef.current);
@@ -684,6 +746,14 @@ function App({ config: initialConfig }) {
684
746
  }, 150);
685
747
  return () => clearInterval(id);
686
748
  }, [busy]);
749
+ // Ask the terminal to bracket pasted text. Without this a paste is indistinguishable from
750
+ // typing, and there is no way to keep it out of the composer or preserve its line breaks.
751
+ useEffect(() => {
752
+ process.stdout.write(PASTE_ON);
753
+ const off = () => { process.stdout.write(PASTE_OFF); };
754
+ process.on('exit', off);
755
+ return () => { off(); process.off('exit', off); };
756
+ }, []);
687
757
  // The tree as it stood before the session touched anything. Recorded once, so "code changes"
688
758
  // stays a comparison against the starting point however long the session runs.
689
759
  useEffect(() => { void snapshotTree(cfg.cwd).then(t => { baseTreeRef.current = t; }); }, [cfg.cwd]);
@@ -869,6 +939,30 @@ function App({ config: initialConfig }) {
869
939
  current: p.name === cfg.provider,
870
940
  }));
871
941
  }
942
+ /**
943
+ * Offers workspace paths for an `@` reference. The listing is read on first use rather than at
944
+ * startup, so a session in a large repository is not delayed by a scan it may never need.
945
+ */
946
+ async function openFilePicker(query) {
947
+ if (entriesRef.current === null)
948
+ entriesRef.current = await listWorkspaceEntries(cfg.cwd);
949
+ const all = entriesRef.current;
950
+ if (all.length === 0) {
951
+ addSystem('No files found to reference here.');
952
+ return;
953
+ }
954
+ setPicker({ kind: 'file', items: fileItems(all, query) });
955
+ setPickQuery(query);
956
+ setPickIndex(0);
957
+ }
958
+ /** The picker rows for a query, ranked by how well each path matches. */
959
+ function fileItems(all, query) {
960
+ return filterEntries(all, query, 200).map(entry => ({
961
+ value: entry,
962
+ label: entry,
963
+ desc: entry.endsWith('/') ? 'directory' : '',
964
+ }));
965
+ }
872
966
  function openPicker(kind, items, query = '') {
873
967
  setPicker({ kind, items });
874
968
  setPickQuery(query);
@@ -883,6 +977,18 @@ function App({ config: initialConfig }) {
883
977
  async function choosePick(item, kindOverride) {
884
978
  const kind = kindOverride ?? picker?.kind;
885
979
  closePicker();
980
+ if (kind === 'file') {
981
+ // Replace the partial "@query" with the full path, leaving a trailing space so the next
982
+ // word can be typed straight away. A directory keeps its slash so more can be typed after.
983
+ const start = atStartRef.current;
984
+ atStartRef.current = -1;
985
+ if (start >= 0) {
986
+ const suffix = item.value.endsWith('/') ? '' : ' ';
987
+ setDraft(d => d.slice(0, start) + '@' + item.value + suffix + d.slice(caret));
988
+ setCaret(start + 1 + item.value.length + suffix.length);
989
+ }
990
+ return;
991
+ }
886
992
  if (kind === 'command') {
887
993
  // Leave it in the composer so arguments can still be typed before submitting.
888
994
  setDraft(item.value + ' ');
@@ -964,7 +1070,13 @@ function App({ config: initialConfig }) {
964
1070
  const cost = estimateCost(cfg.model, stats.promptTokens, stats.completionTokens);
965
1071
  const costStr = cost.known ? formatCost(cost.total) : 'unavailable';
966
1072
  const ws = cfg.cwd.replace(process.env['HOME'] ?? '', '~');
1073
+ // The endpoint is resolved from four places — an explicit override, an env var, the
1074
+ // config file, then the built-in default — so showing the name alone leaves the one
1075
+ // question that actually matters when a provider misbehaves unanswered: where did the
1076
+ // request go? Working that out took a proxy and half an hour.
1077
+ const endpoint = resolveProvider(cfg.provider, cfg.baseURL).baseURL;
967
1078
  addSystem(`Provider : ${cfg.provider}\n` +
1079
+ `Endpoint : ${endpoint}\n` +
968
1080
  `Model : ${cfg.model}\n` +
969
1081
  `Mode : ${mode} effort: ${effort}\n` +
970
1082
  `Approval : ${cfg.requireApproval ? 'on' : 'off'}\n` +
@@ -1421,7 +1533,53 @@ function App({ config: initialConfig }) {
1421
1533
  addSystem(`Unknown command: ${cmd}. Type /help for the full list.`);
1422
1534
  }
1423
1535
  }
1536
+ /**
1537
+ * Files a completed paste and puts a token in the composer.
1538
+ *
1539
+ * A short single-line paste is inserted literally — turning "npm test" into a token would be
1540
+ * obstructive. Anything with a line break is held back, because that is the case where the text
1541
+ * would both swamp the composer and lose its structure.
1542
+ */
1543
+ function filePaste(text) {
1544
+ const clean = text.replace(/\r\n?/g, '\n');
1545
+ const lines = clean.split('\n').length;
1546
+ if (!clean.includes('\n') && clean.length <= 200) {
1547
+ setDraft(d => d.slice(0, caret) + clean + d.slice(caret));
1548
+ setCaret(c => c + clean.length);
1549
+ return;
1550
+ }
1551
+ pastesRef.current = [...pastesRef.current, clean];
1552
+ const token = pasteToken(pastesRef.current.length, lines);
1553
+ setPasteCount(pastesRef.current.length);
1554
+ setDraft(d => d.slice(0, caret) + token + d.slice(caret));
1555
+ setCaret(c => c + token.length);
1556
+ }
1424
1557
  useInput((input, key) => {
1558
+ // ── Bracketed paste ─────────────────────────────────────────────────────
1559
+ // This runs before every other branch. A paste carries newlines, and any handler that treats
1560
+ // a newline as "submit" would fire partway through one.
1561
+ if (pasteBufRef.current !== null) {
1562
+ const end = input.indexOf(PASTE_END);
1563
+ if (end === -1) {
1564
+ pasteBufRef.current += input;
1565
+ return;
1566
+ }
1567
+ const whole = pasteBufRef.current + input.slice(0, end);
1568
+ pasteBufRef.current = null;
1569
+ filePaste(whole);
1570
+ return;
1571
+ }
1572
+ const begin = input.indexOf(PASTE_BEGIN);
1573
+ if (begin !== -1) {
1574
+ const after = input.slice(begin + PASTE_BEGIN.length);
1575
+ const end = after.indexOf(PASTE_END);
1576
+ if (end === -1) {
1577
+ pasteBufRef.current = after;
1578
+ return;
1579
+ } // more chunks to come
1580
+ filePaste(after.slice(0, end));
1581
+ return;
1582
+ }
1425
1583
  // Esc abandons the turn in flight and hands the keyboard straight back. The composer is
1426
1584
  // deliberately left alone: cancelling should not cost whatever was typed while waiting.
1427
1585
  if (key.escape && busy && !picker && !keyPrompt && !updatePane && !usagePane) {
@@ -1467,6 +1625,8 @@ function App({ config: initialConfig }) {
1467
1625
  setDraft('');
1468
1626
  setCaret(0);
1469
1627
  }
1628
+ if (picker.kind === 'file')
1629
+ atStartRef.current = -1;
1470
1630
  closePicker();
1471
1631
  return;
1472
1632
  }
@@ -1499,6 +1659,21 @@ function App({ config: initialConfig }) {
1499
1659
  setCaret(0);
1500
1660
  return;
1501
1661
  }
1662
+ if (picker.kind === 'file') {
1663
+ // Deleting the "@" itself is how the picker is dismissed without choosing anything.
1664
+ setDraft(d => d.slice(0, Math.max(0, caret - 1)) + d.slice(caret));
1665
+ setCaret(c => Math.max(0, c - 1));
1666
+ if (pickQuery === '') {
1667
+ closePicker();
1668
+ atStartRef.current = -1;
1669
+ return;
1670
+ }
1671
+ const q = pickQuery.slice(0, -1);
1672
+ setPickQuery(q);
1673
+ setPickIndex(0);
1674
+ setPicker(p => (p ? { ...p, items: fileItems(entriesRef.current ?? [], q) } : p));
1675
+ return;
1676
+ }
1502
1677
  setPickQuery(q => q.slice(0, -1));
1503
1678
  setPickIndex(0);
1504
1679
  if (picker.kind === 'command') {
@@ -1511,6 +1686,13 @@ function App({ config: initialConfig }) {
1511
1686
  // A space means the command name is finished and arguments follow, so the palette
1512
1687
  // steps out of the way. Keeping it open sent "model gpt-4o" into the filter, emptied
1513
1688
  // the list, and left Enter with nothing to select.
1689
+ if (input === ' ' && picker.kind === 'file') {
1690
+ closePicker();
1691
+ atStartRef.current = -1;
1692
+ setDraft(d => d.slice(0, caret) + ' ' + d.slice(caret));
1693
+ setCaret(c => c + 1);
1694
+ return;
1695
+ }
1514
1696
  if (input === ' ' && picker.kind === 'command') {
1515
1697
  closePicker();
1516
1698
  setDraft(d => { const t = d.endsWith(' ') ? d : d + ' '; setCaret(t.length); return t; });
@@ -1524,6 +1706,12 @@ function App({ config: initialConfig }) {
1524
1706
  setDraft(t);
1525
1707
  setCaret(t.length);
1526
1708
  }
1709
+ if (picker.kind === 'file') {
1710
+ const q = pickQuery + input;
1711
+ setPicker(p => (p ? { ...p, items: fileItems(entriesRef.current ?? [], q) } : p));
1712
+ setDraft(d => d.slice(0, caret) + input + d.slice(caret));
1713
+ setCaret(c => c + input.length);
1714
+ }
1527
1715
  return;
1528
1716
  }
1529
1717
  return;
@@ -1664,8 +1852,19 @@ function App({ config: initialConfig }) {
1664
1852
  const text = input.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '');
1665
1853
  if (text === '')
1666
1854
  return;
1667
- setDraft(d => d.slice(0, caret) + text + d.slice(caret));
1668
- setCaret(c => c + text.length);
1855
+ const next = draft.slice(0, caret) + text + draft.slice(caret);
1856
+ const nextCaret = caret + text.length;
1857
+ setDraft(next);
1858
+ setCaret(nextCaret);
1859
+ // A lone "@" at a word boundary offers workspace paths. Checking the draft as it will be,
1860
+ // rather than the character typed, means it also fires when "@" arrives mid-chunk.
1861
+ if (text.endsWith('@')) {
1862
+ const ref = activeReference(next, nextCaret);
1863
+ if (ref) {
1864
+ atStartRef.current = ref.start;
1865
+ void openFilePicker(ref.query);
1866
+ }
1867
+ }
1669
1868
  }
1670
1869
  });
1671
1870
  /** Start of the word at or before `i`, for word-wise movement and deletion. */
@@ -1689,12 +1888,20 @@ function App({ config: initialConfig }) {
1689
1888
  /** Sends the composer contents: a slash command, or a task for the agent. */
1690
1889
  function submitDraft(raw) {
1691
1890
  {
1692
- const task = raw.trim();
1891
+ // Tokens are expanded here, at the last moment. The model gets the text as it was copied —
1892
+ // blank lines, indentation and all — while the transcript shows the compact form so a
1893
+ // thirty-line paste does not bury the question asked about it.
1894
+ const shown = raw.trim();
1895
+ if (!shown)
1896
+ return;
1897
+ const task = expandPastes(shown, pastesRef.current).trim();
1693
1898
  if (!task)
1694
1899
  return;
1695
1900
  setDraft('');
1696
1901
  setCaret(0);
1697
- addRow({ role: 'user', text: task });
1902
+ pastesRef.current = [];
1903
+ setPasteCount(0);
1904
+ addRow({ role: 'user', text: shown });
1698
1905
  if (task.startsWith('/')) {
1699
1906
  const spaceIdx = task.indexOf(' ');
1700
1907
  const cmd = spaceIdx === -1 ? task : task.slice(0, spaceIdx);
@@ -1710,6 +1917,7 @@ function App({ config: initialConfig }) {
1710
1917
  sayRef.current = '';
1711
1918
  streamedRef.current = '';
1712
1919
  replyStartedRef.current = false;
1920
+ setProviderFrames(0);
1713
1921
  liveToolRef.current = null;
1714
1922
  charsRef.current = 0;
1715
1923
  realTokRef.current = 0;
@@ -1860,7 +2068,7 @@ function App({ config: initialConfig }) {
1860
2068
  const bg = l.kind === 'add' ? DIFF_ADD_BG : l.kind === 'del' ? DIFF_DEL_BG : undefined;
1861
2069
  // Tabs would make the tinted block a ragged width, so they are expanded.
1862
2070
  const body = `${sign} ${l.text}`.replace(/\t/g, ' ');
1863
- const cell = body.length > width ? body.slice(0, width - 3) + '...' : body.padEnd(width);
2071
+ const cell = fitCells(body, width, true);
1864
2072
  return (_jsxs(Box, { children: [_jsxs(Text, { color: DIM, children: [String(shown ?? '').padStart(gutter), " "] }), _jsx(Text, { color: fg, ...(bg ? { backgroundColor: bg } : {}), children: cell })] }, li));
1865
2073
  })] }, hi))), d.hiddenLines > 0 && (_jsxs(Text, { color: DIM, children: [' '.repeat(gutter), " ... ", d.hiddenLines, " more changed lines"] }))] }, key));
1866
2074
  };
@@ -1877,7 +2085,7 @@ function App({ config: initialConfig }) {
1877
2085
  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' ? (
1878
2086
  // Painted as a filled bar so the user's own words are the most findable thing
1879
2087
  // on screen when scrolling back through a long session.
1880
- _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' ? (() => {
2088
+ _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' ? (() => {
1881
2089
  const lines = [row.text ?? '', '', 'Fix the above, then retry. /status shows the active provider and model.'];
1882
2090
  return (_jsx(Box, { marginTop: 1, children: _jsx(Panel, { width: panelWidth(lines, barWidth, 40), color: CRIMSON, title: "[!] ERROR", children: lines }) }, index));
1883
2091
  })() : row.role === 'system' ? (() => {
@@ -1904,11 +2112,16 @@ function App({ config: initialConfig }) {
1904
2112
  : a.status === 'failed' ? CRIMSON : GREEN;
1905
2113
  const took = (a.endedAt ?? Date.now()) - a.startedAt;
1906
2114
  const task = a.task.length > taskWidth ? a.task.slice(0, taskWidth - 3) + '...' : a.task;
1907
- 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));
2115
+ 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));
1908
2116
  })] }));
1909
2117
  })(), _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
1910
2118
  ? `${fmtTokens(liveTokens)} tokens`
1911
- : 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 && (() => {
2119
+ // A gateway routing to several backends sends content-free frames while it
2120
+ // finds one. Saying "waiting for the first token" through that reads as a
2121
+ // hang, when in fact the request was accepted and is being worked on.
2122
+ : providerFrames > 0 ? `${cfg.provider} is holding the line, no output yet`
2123
+ : elapsedMs > 8_000 ? `no response from ${cfg.provider} yet`
2124
+ : 'starting', ")"] })] }) }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: DIM, children: "esc to cancel and keep your input \u00B7 ctrl+c to exit" }) })] })) }), usagePane && (() => {
1912
2125
  const width = Math.min(barWidth, 66);
1913
2126
  const inner = width - 4;
1914
2127
  const BAR_W = Math.max(12, inner - 14);
@@ -2074,28 +2287,31 @@ function App({ config: initialConfig }) {
2074
2287
  : picker.kind === 'model' ? `Models - ${cfg.provider}`
2075
2288
  : picker.kind === 'effort' ? 'Reasoning effort'
2076
2289
  : picker.kind === 'resume' ? 'Resume a saved session'
2077
- : 'Connect provider';
2290
+ : picker.kind === 'file' ? 'Reference a file or directory'
2291
+ : 'Connect provider';
2078
2292
  // The palette is sized to the rows it is showing rather than to the terminal. On a wide
2079
2293
  // screen a list of short model names in a full-width box is mostly empty box.
2080
- const headWidth = title.length + 24;
2294
+ // The header is a title on the left and a count on the right, on one line. Estimating the
2295
+ // right-hand side at a constant made "Reference a file or directory" wrap onto two rows.
2296
+ const countText = `${list.length} match${list.length === 1 ? '' : 'es'} · esc to close`;
2297
+ const headWidth = title.length + countText.length + 3;
2081
2298
  const rowWidth = Math.max(headWidth, ...shown.map(it => 38 + (it.current ? 10 : 0) + it.desc.length));
2082
2299
  const boxWidth = Math.max(40, Math.min(barWidth, rowWidth + 4));
2083
2300
  const width = boxWidth - 4;
2084
2301
  let lastGroup;
2085
- 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) => {
2302
+ 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) => {
2086
2303
  const absolute = Math.max(0, start) + i;
2087
2304
  const selected = absolute === pickIndex;
2088
2305
  const header = item.group && item.group !== lastGroup ? item.group : null;
2089
2306
  lastGroup = item.group;
2090
2307
  const label = item.label.length > 34 ? item.label.slice(0, 31) + '...' : item.label;
2091
- 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}`)
2092
- .slice(0, width).padEnd(width) })] }, item.value + absolute));
2308
+ 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));
2093
2309
  }), 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] }))] }));
2094
2310
  })(), 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
2095
2311
  ? _jsx(Text, { color: INK, children: draft.slice(caret) })
2096
2312
  : caret < draft.length
2097
2313
  ? _jsxs(_Fragment, { children: [_jsx(Text, { backgroundColor: CYAN, color: "#10222A", children: draft[caret] }), _jsx(Text, { color: INK, children: draft.slice(caret + 1) })] })
2098
- : _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" })] }) })] }));
2314
+ : _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"] })] }))] }) })] }));
2099
2315
  }
2100
2316
  export async function runInkChatMode(config) {
2101
2317
  // resolveConfig has already layered CLI flags above the /config store, so the config