koneck 2.123.0 → 2.125.0

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,8 +2,10 @@ 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 { createAgentSession, buildClient, resolveAgentConcurrency, toolsUnsupportedNotice } from './engine.js';
6
- import { estimateCost, formatCost } from './pricing.js';
5
+ import { resolveAgentConcurrency, toolsUnsupportedNotice } from './engine-facts.js';
6
+ import { withSynchronizedFrames } from './frame-sync.js';
7
+ import { pacedInterval } from './frame-pace.js';
8
+ import { estimateCost, formatCost, rateText } from './pricing.js';
7
9
  import { generateSessionId, saveSession, listSessions, loadSession, relativeAge, renameSession, forkSession, archiveSession, findSession, } from './session.js';
8
10
  import { loadMemory } from './memory.js';
9
11
  import { loadKoneckConfig, saveKoneckConfig, setConfigKey, configKeyDescriptions, CONFIG_FILE, CONFIG_SCHEMA, stepValue, displayValue } from './config-store.js';
@@ -49,16 +51,18 @@ import { logoFor } from './logo.js';
49
51
  import { describeActivity } from './activity.js';
50
52
  import { redactSecrets, containsSecret } from './redact.js';
51
53
  import { renderTrajectory } from './trajectory.js';
52
- import { describeSandbox, resolveSandbox } from './sandbox.js';
53
- import { doctorChecks, doctorReport } from './doctor.js';
54
- import { runQualityGate } from './quality-gate.js';
55
- import { runPreflight, preflightReport, isPreflightReady } from './preflight.js';
54
+ // sandbox.js and doctor.js are loaded by /status and /doctor. sandbox.js imports execaSync to run
55
+ // bubblewrap and doctor.js shells out to check the environment, and both are reached only from
56
+ // handleCommand, which is async. Measured: 122ms and 150ms.
57
+ // quality-gate.js, mcp.js and refactor.js are loaded at the one command that uses each. Each
58
+ // reaches something large — quality-gate takes shellFor from tools.js, refactor takes runAgent
59
+ // from engine.js and ts-morph with it — and an eager import here undid the engine being lazy,
60
+ // because the graph simply arrived by another road. Measured: 339ms, 141ms and 75ms.
61
+ // preflight.js is loaded by the command that runs it: it imports quality-gate.js, and so
62
+ // tools.js, for 505ms that a session which never types /preflight should not pay.
56
63
  import { listAuditEvents } from './audit.js';
57
64
  import { initializePolicy, loadPolicy, POLICY_FILE } from './policy.js';
58
65
  import { isWorkspaceTrusted, trustWorkspace, untrustWorkspace } from './workspace-trust.js';
59
- import { loadMCPConfig } from './mcp.js';
60
- import { renameSymbol, extractInterface, moveSymbol } from './refactor.js';
61
- import { rateText } from './model-catalog.js';
62
66
  const CYAN = '#54D7E7';
63
67
  const GREEN = '#9BCB8F';
64
68
  const CRIMSON = '#E8878D';
@@ -382,9 +386,62 @@ export function liveReplyLines(termRows) {
382
386
  const rows = termRows && termRows > 0 ? termRows : 24;
383
387
  return rows < 12 ? 1 : 2;
384
388
  }
385
- /** Sampling cadence for live stream refs: quick enough to read, slow enough not to flash. */
386
- export function liveRefreshDelay(idleForMs) {
387
- return idleForMs < 2_000 ? 500 : 1_000;
389
+ /**
390
+ * The engine, loaded the first time a turn needs it.
391
+ *
392
+ * Importing it at the top of this module cost 645ms of the 1,163ms it took to load the terminal
393
+ * app on a Raspberry Pi 5 — it reaches the OpenAI SDK, execa and ts-morph through fifty-seven
394
+ * modules — and not one line of it is needed to draw the banner, the prompt or the status bar.
395
+ * Nothing here is on a render path: every caller already runs inside an async handler, and the
396
+ * two things the interface prints synchronously are in engine-facts.js for that reason.
397
+ *
398
+ * The promise is cached, so concurrent callers share one import and later turns pay nothing. It is
399
+ * also warmed in the background as soon as the interface is up, which is why the first turn does
400
+ * not feel the move either.
401
+ */
402
+ let enginePromise;
403
+ function engine() {
404
+ return (enginePromise ??= import('./engine.js'));
405
+ }
406
+ /**
407
+ * How long one animation frame lasts.
408
+ *
409
+ * Every tick both advances the animation and reveals whatever the stream has added since the
410
+ * last one, so this is the interface's frame rate. It was 500ms while a stream was active and
411
+ * 1,000ms once it went quiet, which is where "the loader is moving slowly ... even 10x slower
412
+ * than a tortoise" came from: the spinner took ten seconds to turn once and the shimmer
413
+ * forty-eight to cross a word.
414
+ *
415
+ * The reason it was that slow was flicker, and the flicker was real — but the rate was never its
416
+ * cause. Ink writes a frame by erasing the previous one and drawing the next, and the terminal
417
+ * showed the gap between the two. Repainting less often only made the flash rarer. It is now
418
+ * fixed where it happens, in frame-sync.ts, by presenting each frame atomically, which leaves the
419
+ * frame rate free to be a frame rate.
420
+ *
421
+ * 80ms is what cli-spinners and ora use for braille spinners, and it is comfortably above Ink's
422
+ * own 32ms write throttle (`throttle(this.onRender, 32)` in ink/build/ink.js), so each tick
423
+ * becomes exactly one write rather than being coalesced with its neighbours. Measured cost of a
424
+ * tick on a Raspberry Pi 5: 0.7ms of React render and about 700 bytes written, or roughly 1% of
425
+ * one core at this rate.
426
+ */
427
+ export const FRAME_MS = 80;
428
+ /**
429
+ * How long one work word is held before the next is shown.
430
+ *
431
+ * The word is there to be read and enjoyed, not to reassure by moving, so it outlasts the
432
+ * animation by a long way.
433
+ */
434
+ export const WORD_HOLD_MS = 60_000;
435
+ /**
436
+ * Which spinner cell and shimmer column a turn is showing after this much work.
437
+ *
438
+ * One function so the two cannot drift apart, and pure so the rates can be asserted rather than
439
+ * eyeballed: both advance one step per frame, the spinner wrapping around its glyphs and the
440
+ * shimmer running on for `Shimmer` to wrap over the word plus its trailing gap.
441
+ */
442
+ export function animFrames(elapsedMs, spinnerLength) {
443
+ const frame = Math.floor(Math.max(0, elapsedMs) / FRAME_MS);
444
+ return { spin: frame % spinnerLength, shimmer: frame };
388
445
  }
389
446
  const SPINNER = G.spinner;
390
447
  /**
@@ -1215,7 +1272,7 @@ export function App({ config: initialConfig, clearFrame }) {
1215
1272
  // Tool approval callbacks belong to a session created on an earlier render. Keep the control
1216
1273
  // posture live so turning approval on during a turn governs its next consequential tool call.
1217
1274
  const cfgRef = useRef(initialConfig);
1218
- // Live activity accumulates in a ref and is painted by the 100 ms ticker. Mutating a ref
1275
+ // Live activity accumulates in a ref and is painted by the FRAME_MS ticker. Mutating a ref
1219
1276
  // instead of calling setState per token keeps a Raspberry Pi from re-rendering on every chunk.
1220
1277
  const sayRef = useRef(''); // narration not yet committed
1221
1278
  const liveToolRef = useRef(null); // the one tool still running
@@ -1223,8 +1280,6 @@ export function App({ config: initialConfig, clearFrame }) {
1223
1280
  const replyStartedRef = useRef(false);
1224
1281
  /** Everything streamed this turn. Empty means the provider did not stream at all. */
1225
1282
  const streamedRef = useRef('');
1226
- /** When the live region last had something new to show, used to pick the repaint rate. */
1227
- const lastActivityRef = useRef(Date.now());
1228
1283
  /**
1229
1284
  * Frames received before any content — proof the provider has the request and is working.
1230
1285
  *
@@ -1336,7 +1391,6 @@ export function App({ config: initialConfig, clearFrame }) {
1336
1391
  * whatever is left in the buffer.
1337
1392
  */
1338
1393
  function pushSay(text) {
1339
- lastActivityRef.current = Date.now();
1340
1394
  sayRef.current += text;
1341
1395
  streamedRef.current += text;
1342
1396
  charsRef.current += text.length;
@@ -1391,7 +1445,6 @@ export function App({ config: initialConfig, clearFrame }) {
1391
1445
  function pushTool(name, args) {
1392
1446
  commitSay();
1393
1447
  // Photograph every file this tool has announced it will touch, before it touches it.
1394
- lastActivityRef.current = Date.now();
1395
1448
  turnToolsRef.current += 1;
1396
1449
  pendingDiffRef.current = filesTouchedBy(name, args).slice(0, MAX_DIFF_FILES)
1397
1450
  .map(rel => ({ rel, before: readForDiff(rel) }));
@@ -1545,10 +1598,9 @@ export function App({ config: initialConfig, clearFrame }) {
1545
1598
  },
1546
1599
  onToolResult: (name, ok, ms, detail) => finishTool(name, ok, ms, detail),
1547
1600
  onToolOutput: (_name, line) => {
1548
- // Deliberately does not count as activity for the frame rate. A dev server logs a request
1549
- // every second or two, and treating each one as "something is happening" held the UI in its
1550
- // fastest repaint band for as long as the server ran — which is the flicker. The line still
1551
- // updates; the whole region just stops being redrawn eight times a second to show it.
1601
+ // Written to the ref rather than to state: a dev server logs a request every second or two,
1602
+ // and a setState per line re-rendered the interface on each one. The ticker paints whatever
1603
+ // the ref holds when the next frame comes round, so a chatty tool costs nothing extra.
1552
1604
  if (liveToolRef.current)
1553
1605
  liveToolRef.current.output = line;
1554
1606
  },
@@ -1597,7 +1649,6 @@ export function App({ config: initialConfig, clearFrame }) {
1597
1649
  // produce tens of thousands of characters per turn, and what anyone watching wants is what
1598
1650
  // it is thinking now.
1599
1651
  thinkingTextRef.current = (thinkingTextRef.current + text).slice(-THINKING_TAIL_CHARS);
1600
- lastActivityRef.current = Date.now();
1601
1652
  },
1602
1653
  onAgentProgress: (list) => { agentsRef.current = list; setAgents(list); },
1603
1654
  onWindowDetected: (limit, source) => setDetectedContext({ limit, source }),
@@ -1923,11 +1974,14 @@ export function App({ config: initialConfig, clearFrame }) {
1923
1974
  */
1924
1975
  const sessionNow = () => {
1925
1976
  if (!activeSession.p) {
1926
- activeSession.p = hold(createAgentSession({
1977
+ // The options are built here, not inside the then, so the hooks and refs are read at the
1978
+ // moment the session was asked for rather than a microtask later.
1979
+ const opts = {
1927
1980
  ...initialConfig,
1928
1981
  pace: paceFrom(initialConfig.pace ?? savedEffort(undefined), 'medium'),
1929
1982
  ...failoverHooks(), silent: true, ...sessionOpts,
1930
- }));
1983
+ };
1984
+ activeSession.p = hold(engine().then(m => m.createAgentSession(opts)));
1931
1985
  }
1932
1986
  return activeSession.p;
1933
1987
  };
@@ -2207,36 +2261,38 @@ export function App({ config: initialConfig, clearFrame }) {
2207
2261
  }
2208
2262
  busyStart.current = Date.now();
2209
2263
  wordTimer.current = 0;
2210
- // The frame rate is the flicker rate. Every tick makes Ink update the live region, so network
2211
- // activity must be sampled for a human reader rather than mirrored frame-for-frame. Half a
2212
- // second keeps streamed prose feeling live; an idle tool only needs a once-a-second
2213
- // heartbeat. This is particularly important in terminals that repaint an ANSI line update as
2214
- // a flash.
2264
+ // One rate, whether or not the stream is saying anything. The spinner earns its place
2265
+ // precisely when nothing is arriving — the 2m 44s wait on a provider that had gone quiet is
2266
+ // what this was reported from — so backing the animation off when a turn goes idle stopped
2267
+ // it in the one situation it exists for. Flicker is handled where it is caused, by writing
2268
+ // each frame atomically; see frame-sync.ts.
2215
2269
  let timer;
2270
+ const wordEvery = Math.max(1, Math.round(WORD_HOLD_MS / FRAME_MS));
2271
+ /*
2272
+ * The rate this machine can actually sustain, not the one we would like.
2273
+ *
2274
+ * Each frame records how long the timer really took to arrive. On time means the loop is
2275
+ * keeping up and the rate stands; late means the event loop had more work than the frame's own
2276
+ * budget, and asking again just as soon only queues a frame that cannot be painted — which is
2277
+ * how a Pi ends up lagging while the same build feels instant on a laptop. See frame-pace.ts.
2278
+ */
2279
+ let interval = FRAME_MS;
2280
+ let asked = Date.now();
2216
2281
  const tick = () => {
2217
- const elapsed = Date.now() - busyStart.current;
2218
- const delay = currentDelay();
2219
- // Do not animate several cells for every content refresh. The spinner moves once a second
2220
- // and the shimmer once every two seconds; the intervening refreshes are solely to reveal
2221
- // new streamed text.
2222
- setAnim({
2223
- spin: Math.floor(elapsed / 1_000) % SPINNER.length,
2224
- shimmer: Math.floor(elapsed / 2_000),
2225
- elapsed,
2226
- });
2282
+ const now = Date.now();
2283
+ interval = pacedInterval(interval, now - asked);
2284
+ // Elapsed is read from the clock rather than counted in ticks, so a frame the event loop
2285
+ // delivers late moves the animation on by what actually passed instead of falling behind.
2286
+ const elapsed = now - busyStart.current;
2287
+ const { spin, shimmer } = animFrames(elapsed, SPINNER.length);
2288
+ setAnim({ spin, shimmer, elapsed });
2227
2289
  wordTimer.current += 1;
2228
- // Roughly every 60 seconds, whatever the current rate. Twenty-five felt churned on a long
2229
- // run — the word is there to be read and enjoyed, not to reassure by moving.
2230
- if (wordTimer.current % Math.max(20, Math.round(60_000 / delay)) === 0) {
2290
+ if (wordTimer.current % wordEvery === 0)
2231
2291
  setSpinWord(w => w + 1);
2232
- }
2233
- timer = setTimeout(tick, delay);
2234
- };
2235
- const currentDelay = () => {
2236
- const idleFor = Date.now() - lastActivityRef.current;
2237
- return liveRefreshDelay(idleFor);
2292
+ asked = Date.now();
2293
+ timer = setTimeout(tick, interval);
2238
2294
  };
2239
- timer = setTimeout(tick, liveRefreshDelay(0));
2295
+ timer = setTimeout(tick, interval);
2240
2296
  return () => clearTimeout(timer);
2241
2297
  }, [busy]);
2242
2298
  // A queued prompt runs the moment the agent is free again. Keyed on `busy` rather than done
@@ -2325,7 +2381,8 @@ export function App({ config: initialConfig, clearFrame }) {
2325
2381
  const c = newCfg ?? cfg;
2326
2382
  cfgRef.current = c;
2327
2383
  setDetectedContext(null);
2328
- activeSession.p = hold(createAgentSession({ ...c, pace: paceFrom(effortRef.current, 'medium'), ...failoverHooks(), silent: true, ...sessionOpts }));
2384
+ const opts = { ...c, pace: paceFrom(effortRef.current, 'medium'), ...failoverHooks(), silent: true, ...sessionOpts };
2385
+ activeSession.p = hold(engine().then(m => m.createAgentSession(opts)));
2329
2386
  if (newCfg)
2330
2387
  setCfg(newCfg);
2331
2388
  }
@@ -2419,7 +2476,7 @@ export function App({ config: initialConfig, clearFrame }) {
2419
2476
  : 'The sending session has not done any work yet; this is a fresh instruction.';
2420
2477
  if (transcript.trim() !== '') {
2421
2478
  try {
2422
- const client = buildClient(cfg);
2479
+ const client = (await engine()).buildClient(cfg);
2423
2480
  const res = await client.chat.completions.create({
2424
2481
  model: cfg.model,
2425
2482
  max_tokens: 220,
@@ -2481,7 +2538,8 @@ export function App({ config: initialConfig, clearFrame }) {
2481
2538
  ...(st.note ? { note: st.note } : {}),
2482
2539
  })), updatedAt: Date.now() }
2483
2540
  : undefined;
2484
- activeSession.p = hold(createAgentSession({ ...restoredCfg, silent: true, ...sessionOpts }, messages, { turns: meta.turns, totalTokens: meta.totalTokens }, restoredPlan));
2541
+ const resumeOpts = { ...restoredCfg, silent: true, ...sessionOpts };
2542
+ activeSession.p = hold(engine().then(m => m.createAgentSession(resumeOpts, messages, { turns: meta.turns, totalTokens: meta.totalTokens }, restoredPlan)));
2485
2543
  if (restoredPlan)
2486
2544
  setPlan(restoredPlan);
2487
2545
  setSessionGoal(meta.goal ?? null);
@@ -3104,6 +3162,7 @@ export function App({ config: initialConfig, clearFrame }) {
3104
3162
  // question that actually matters when a provider misbehaves unanswered: where did the
3105
3163
  // request go? Working that out took a proxy and half an hour.
3106
3164
  const endpoint = resolveProvider(cfg.provider, cfg.baseURL).baseURL;
3165
+ const { describeSandbox, resolveSandbox } = await import('./sandbox.js');
3107
3166
  addSystem(`Provider : ${cfg.provider}\n` +
3108
3167
  `Endpoint : ${endpoint}\n` +
3109
3168
  `Model : ${cfg.model}\n` +
@@ -3199,6 +3258,7 @@ export function App({ config: initialConfig, clearFrame }) {
3199
3258
  // rather than a stale global default.
3200
3259
  addSystem(`**KONECK Doctor** — checking ${cfg.provider} and this workspace…`);
3201
3260
  try {
3261
+ const { doctorChecks, doctorReport } = await import('./doctor.js');
3202
3262
  const checks = await doctorChecks(cfg.cwd, cfg.provider, cfg.baseURL, cfg.apiKey);
3203
3263
  setRows(prev => prev.slice(0, -1));
3204
3264
  addSystem(`**KONECK Doctor**\n\n${doctorReport(checks)}`);
@@ -3210,6 +3270,7 @@ export function App({ config: initialConfig, clearFrame }) {
3210
3270
  return;
3211
3271
  }
3212
3272
  case '/mcp': {
3273
+ const { loadMCPConfig } = await import('./mcp.js');
3213
3274
  const configured = await loadMCPConfig(cfg.cwd);
3214
3275
  const session = await sessionNow().catch(() => null);
3215
3276
  addSystem(mcpStatusText(Object.keys(configured?.servers ?? {}), session?.mcpServers ?? []));
@@ -3306,6 +3367,7 @@ export function App({ config: initialConfig, clearFrame }) {
3306
3367
  addSystem(`Running native TypeScript refactor: ${cmd}…`);
3307
3368
  try {
3308
3369
  if (cmd === '/rename') {
3370
+ const { renameSymbol } = await import('./refactor.js');
3309
3371
  const result = await renameSymbol(parts[0], parts[1], cfg.cwd, { announce: false });
3310
3372
  addSystem(`Renamed \`${parts[0]}\` → \`${parts[1]}\` in ${result.filesChanged.length} file(s) (${result.occurrences} occurrence(s)).` +
3311
3373
  (checkpoint
@@ -3313,11 +3375,13 @@ export function App({ config: initialConfig, clearFrame }) {
3313
3375
  : '\n\nNo Git restore point was available. Run `/preflight` before committing.'));
3314
3376
  }
3315
3377
  else if (cmd === '/extract') {
3378
+ const { extractInterface } = await import('./refactor.js');
3316
3379
  await extractInterface(parts[0], parts[1], cfg.cwd, { announce: false });
3317
3380
  addSystem(`Extracted interface for \`${parts[1]}\` from \`${parts[0]}\`.` +
3318
3381
  (checkpoint ? `\n\nRestore point: \`${checkpoint.id}\` — run \`/preflight\`, or \`/revert ${checkpoint.id} --confirm\` to undo.` : '\n\nRun `/preflight` before committing.'));
3319
3382
  }
3320
3383
  else {
3384
+ const { moveSymbol } = await import('./refactor.js');
3321
3385
  await moveSymbol(parts[0], parts[1], parts[2], cfg.cwd, { announce: false });
3322
3386
  addSystem(`Moved \`${parts[0]}\` from \`${parts[1]}\` to \`${parts[2]}\` and updated imports.` +
3323
3387
  (checkpoint ? `\n\nRestore point: \`${checkpoint.id}\` — run \`/preflight\`, or \`/revert ${checkpoint.id} --confirm\` to undo.` : '\n\nRun `/preflight` before committing.'));
@@ -3835,6 +3899,7 @@ export function App({ config: initialConfig, clearFrame }) {
3835
3899
  // the same independent proof on demand, without the legacy TUI writing over Ink's frame.
3836
3900
  addSystem('**Quality gate** — running detected lint and test checks…');
3837
3901
  try {
3902
+ const { runQualityGate } = await import('./quality-gate.js');
3838
3903
  const result = await runQualityGate(cfg.cwd, { report: false });
3839
3904
  setRows(prev => prev.slice(0, -1));
3840
3905
  if (result.checks.length === 0) {
@@ -3861,6 +3926,7 @@ export function App({ config: initialConfig, clearFrame }) {
3861
3926
  case '/preflight': {
3862
3927
  addSystem('**Preflight** — checking patch integrity and detected quality gates…');
3863
3928
  try {
3929
+ const { runPreflight, preflightReport } = await import('./preflight.js');
3864
3930
  const result = await runPreflight(cfg.cwd);
3865
3931
  setRows(prev => prev.slice(0, -1));
3866
3932
  addSystem(preflightReport(result));
@@ -3878,6 +3944,7 @@ export function App({ config: initialConfig, clearFrame }) {
3878
3944
  }
3879
3945
  addSystem('**Ship check** — running deterministic readiness checks…');
3880
3946
  try {
3947
+ const { runPreflight, preflightReport, isPreflightReady } = await import('./preflight.js');
3881
3948
  const result = await runPreflight(cfg.cwd);
3882
3949
  setRows(prev => prev.slice(0, -1));
3883
3950
  addSystem(preflightReport(result));
@@ -5130,7 +5197,6 @@ export function App({ config: initialConfig, clearFrame }) {
5130
5197
  thinkingRef.current = { chars: 0, at: 0 };
5131
5198
  thinkingTextRef.current = '';
5132
5199
  incompleteRef.current = null;
5133
- lastActivityRef.current = Date.now();
5134
5200
  providerFramesRef.current = 0;
5135
5201
  liveToolRef.current = null;
5136
5202
  charsRef.current = 0;
@@ -5422,7 +5488,7 @@ export function App({ config: initialConfig, clearFrame }) {
5422
5488
  //
5423
5489
  // Both halves matter. Clipping the bottom would hide what just happened; anchoring
5424
5490
  // always to the bottom is what put the banner there.
5425
- _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: renderRow(row, i) }, i))) })), altScreen && scrollBack > 0 && (_jsx(Text, { color: AMBER, children: `${G.caret} scrolled back ${scrollBack} rows — PageDown, or just type, to follow again` })), 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: 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 ? ' ' : G.brand }), _jsx(Box, { flexDirection: "column", children: tailLines(sayRef.current, liveReplyLines(termRows), replyWidth - 3)
5491
+ _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: renderRow(row, i) }, i))) })), altScreen && scrollBack > 0 && (_jsx(Text, { color: AMBER, children: `${G.caret} scrolled back ${scrollBack} rows — PageDown, or just type, to follow again` })), 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: 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)
5426
5492
  .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)))] })), agents.length > 0 && (() => {
5427
5493
  const done = agents.filter(a => a.status !== 'running').length;
5428
5494
  const failed = agents.filter(a => a.status === 'failed').length;
@@ -5452,7 +5518,7 @@ export function App({ config: initialConfig, clearFrame }) {
5452
5518
  : elapsedMs > 8_000 ? `no response from ${cfg.provider} yet`
5453
5519
  : 'starting', thinkingRef.current.chars > 0 && Date.now() - thinkingRef.current.at < 2_000
5454
5520
  ? ` | thinking, ${fmtTokens(Math.round(thinkingRef.current.chars / 4))} reasoning tokens`
5455
- : '', ")"] })] }) }), showThinking && thinkingTextRef.current.trim() !== '' && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: thinkingTail(thinkingTextRef.current, THINKING_LINES, barWidth - 6).map((line, i) => (_jsx(Text, { color: DIM, italic: true, children: line }, i))) })), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: DIM, children: "esc to cancel and keep your input \u00B7 ctrl+c to exit" }) })] })) }), usagePane && (() => {
5521
+ : '', ")"] })] }) }), showThinking && !ask && thinkingTextRef.current.trim() !== '' && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: thinkingTail(thinkingTextRef.current, THINKING_LINES, barWidth - 6).map((line, i) => (_jsx(Text, { color: DIM, italic: true, children: line }, i))) })), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: DIM, children: "esc to cancel and keep your input \u00B7 ctrl+c to exit" }) })] })) }), usagePane && (() => {
5456
5522
  const width = Math.min(barWidth, usagePane === 'settings' ? 96 : 66);
5457
5523
  const inner = width - 4;
5458
5524
  const BAR_W = Math.max(12, inner - 14);
@@ -5789,8 +5855,19 @@ export async function runInkChatMode(config) {
5789
5855
  // appearing at the top with a dead copy of the interface below it — caused by the first version
5790
5856
  // of this repaint, which did exactly that.
5791
5857
  let inkClear;
5792
- const app = render(_jsx(App, { config: config, clearFrame: () => inkClear?.() }), { exitOnCtrlC: false, patchConsole: false });
5858
+ // Frames go out bracketed as synchronized updates, so the erase-then-redraw Ink performs on
5859
+ // every repaint is composited by the terminal instead of being shown as a blank flash. That is
5860
+ // what allows the animation to run at FRAME_MS; see frame-sync.ts.
5861
+ const app = render(_jsx(App, { config: config, clearFrame: () => inkClear?.() }), { stdout: withSynchronizedFrames(process.stdout),
5862
+ exitOnCtrlC: false, patchConsole: false });
5793
5863
  inkClear = app.clear;
5864
+ // The engine is imported on demand so the banner and the prompt are not waiting on the OpenAI
5865
+ // SDK, execa and ts-morph — 645ms of the 1,163ms it took to load this module on a Raspberry Pi
5866
+ // 5. Warmed here, one tick after the interface is up, so the first turn does not pay for it
5867
+ // either: by the time anything has been typed it is already in memory. Failures are ignored
5868
+ // because this is only a head start — the real import happens at the call site and reports
5869
+ // there.
5870
+ setTimeout(() => { void engine().catch(() => { }); }, 0);
5794
5871
  await app.waitUntilExit();
5795
5872
  }
5796
5873
  //# sourceMappingURL=ink-chat.js.map