reasonix 0.4.6 → 0.4.12

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/cli/index.js CHANGED
@@ -96,8 +96,8 @@ function computeWait(attempt, initial, cap, retryAfter) {
96
96
  }
97
97
  function sleep(ms, signal) {
98
98
  if (ms <= 0) return Promise.resolve();
99
- return new Promise((resolve4, reject) => {
100
- const timer = setTimeout(resolve4, ms);
99
+ return new Promise((resolve5, reject) => {
100
+ const timer = setTimeout(resolve5, ms);
101
101
  if (signal) {
102
102
  const onAbort = () => {
103
103
  clearTimeout(timer);
@@ -182,6 +182,27 @@ var DeepSeekClient = class {
182
182
  if (opts.responseFormat) payload.response_format = opts.responseFormat;
183
183
  return payload;
184
184
  }
185
+ /**
186
+ * Fetch the current DeepSeek account balance. Separate endpoint
187
+ * from chat completions, no billing impact. Returns null on any
188
+ * network/auth failure so callers can gate the balance display
189
+ * without a hard error — the rest of the session works regardless.
190
+ */
191
+ async getBalance(opts = {}) {
192
+ try {
193
+ const resp = await this._fetch(`${this.baseUrl}/user/balance`, {
194
+ method: "GET",
195
+ headers: { Authorization: `Bearer ${this.apiKey}` },
196
+ signal: opts.signal
197
+ });
198
+ if (!resp.ok) return null;
199
+ const data = await resp.json();
200
+ if (!data || !Array.isArray(data.balance_infos)) return null;
201
+ return data;
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
185
206
  async chat(opts) {
186
207
  const ctrl = new AbortController();
187
208
  const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
@@ -328,8 +349,9 @@ Constraints:
328
349
  - Each item is plain text, at most {maxItemLen} characters, no markdown.
329
350
  - Write in the same language as the trace (Chinese in \u2192 Chinese out, etc.).
330
351
  - Do not quote back the trace; write short, specific phrases.`;
331
- async function harvest(reasoningContent, client, options = {}) {
352
+ async function harvest(reasoningContent, client, options = {}, signal) {
332
353
  if (!client || !reasoningContent) return emptyPlanState();
354
+ if (signal?.aborted) return emptyPlanState();
333
355
  const minLen = options.minReasoningLen ?? 40;
334
356
  const trimmed = reasoningContent.trim();
335
357
  if (trimmed.length < minLen) return emptyPlanState();
@@ -349,7 +371,8 @@ async function harvest(reasoningContent, client, options = {}) {
349
371
  ],
350
372
  responseFormat: { type: "json_object" },
351
373
  temperature: 0,
352
- maxTokens: 600
374
+ maxTokens: 600,
375
+ signal
353
376
  });
354
377
  return parsePlanState(resp.content, maxItems, maxItemLen);
355
378
  } catch {
@@ -563,7 +586,7 @@ var ToolRegistry = class {
563
586
  }
564
587
  }));
565
588
  }
566
- async dispatch(name, argumentsRaw) {
589
+ async dispatch(name, argumentsRaw, opts = {}) {
567
590
  const tool = this._tools.get(name);
568
591
  if (!tool) {
569
592
  return JSON.stringify({ error: `unknown tool: ${name}` });
@@ -580,7 +603,7 @@ var ToolRegistry = class {
580
603
  args = nestArguments(args);
581
604
  }
582
605
  try {
583
- const result = await tool.fn(args);
606
+ const result = await tool.fn(args, { signal: opts.signal });
584
607
  return typeof result === "string" ? result : JSON.stringify(result);
585
608
  } catch (err) {
586
609
  return JSON.stringify({
@@ -614,8 +637,20 @@ async function bridgeMcpTools(client, opts = {}) {
614
637
  name: registeredName,
615
638
  description: mcpTool.description ?? "",
616
639
  parameters: mcpTool.inputSchema,
617
- fn: async (args) => {
618
- const toolResult = await client.callTool(mcpTool.name, args);
640
+ fn: async (args, ctx) => {
641
+ const toolResult = await client.callTool(mcpTool.name, args, {
642
+ // Forward server-side progress frames to the bridge caller,
643
+ // tagged with the registered name so multi-server UIs can
644
+ // disambiguate. No-op when `onProgress` isn't configured —
645
+ // the client then also omits the _meta.progressToken and
646
+ // the server won't emit progress.
647
+ onProgress: opts.onProgress ? (info) => opts.onProgress({ toolName: registeredName, ...info }) : void 0,
648
+ // Thread the tool-dispatch AbortSignal all the way down to
649
+ // the MCP request so Esc truly cancels in flight — the
650
+ // client will emit notifications/cancelled AND reject the
651
+ // pending promise immediately, no "wait for subprocess".
652
+ signal: ctx?.signal
653
+ });
619
654
  return flattenMcpResult(toolResult, { maxChars: maxResultChars });
620
655
  }
621
656
  });
@@ -1126,6 +1161,16 @@ function costUsd(model, usage) {
1126
1161
  if (!p) return 0;
1127
1162
  return (usage.promptCacheHitTokens * p.inputCacheHit + usage.promptCacheMissTokens * p.inputCacheMiss + usage.completionTokens * p.output) / 1e6;
1128
1163
  }
1164
+ function inputCostUsd(model, usage) {
1165
+ const p = DEEPSEEK_PRICING[model];
1166
+ if (!p) return 0;
1167
+ return (usage.promptCacheHitTokens * p.inputCacheHit + usage.promptCacheMissTokens * p.inputCacheMiss) / 1e6;
1168
+ }
1169
+ function outputCostUsd(model, usage) {
1170
+ const p = DEEPSEEK_PRICING[model];
1171
+ if (!p) return 0;
1172
+ return usage.completionTokens * p.output / 1e6;
1173
+ }
1129
1174
  function claudeEquivalentCost(usage) {
1130
1175
  return (usage.promptTokens * CLAUDE_SONNET_PRICING.input + usage.completionTokens * CLAUDE_SONNET_PRICING.output) / 1e6;
1131
1176
  }
@@ -1153,6 +1198,12 @@ var SessionStats = class {
1153
1198
  const c = this.totalClaudeEquivalent;
1154
1199
  return c > 0 ? 1 - this.totalCost / c : 0;
1155
1200
  }
1201
+ get totalInputCost() {
1202
+ return this.turns.reduce((sum, t) => sum + inputCostUsd(t.model, t.usage), 0);
1203
+ }
1204
+ get totalOutputCost() {
1205
+ return this.turns.reduce((sum, t) => sum + outputCostUsd(t.model, t.usage), 0);
1206
+ }
1156
1207
  get aggregateCacheHitRatio() {
1157
1208
  let hit = 0;
1158
1209
  let miss = 0;
@@ -1168,6 +1219,8 @@ var SessionStats = class {
1168
1219
  return {
1169
1220
  turns: this.turns.length,
1170
1221
  totalCostUsd: round(this.totalCost, 6),
1222
+ totalInputCostUsd: round(this.totalInputCost, 6),
1223
+ totalOutputCostUsd: round(this.totalOutputCost, 6),
1171
1224
  claudeEquivalentUsd: round(this.totalClaudeEquivalent, 6),
1172
1225
  savingsVsClaudePct: round(this.savingsVsClaude * 100, 2),
1173
1226
  cacheHitRatio: round(this.aggregateCacheHitRatio, 4),
@@ -1204,11 +1257,13 @@ var CacheFirstLoop = class {
1204
1257
  _turn = 0;
1205
1258
  _streamPreference;
1206
1259
  /**
1207
- * Set by {@link abort} to short-circuit the tool-call loop after the
1208
- * current iteration. Reset at the start of each `step()` so an Esc
1209
- * during one turn doesn't poison the next.
1260
+ * AbortController per active turn. Threaded through the DeepSeek
1261
+ * HTTP calls AND every tool dispatch so Esc actually cancels the
1262
+ * in-flight network/subprocess work — not "we'll get to it after
1263
+ * the current call finishes." Re-created at the start of each
1264
+ * `step()` (the prior turn's signal has already fired).
1210
1265
  */
1211
- _aborted = false;
1266
+ _turnAbort = new AbortController();
1212
1267
  constructor(opts) {
1213
1268
  this.client = opts.client;
1214
1269
  this.prefix = opts.prefix;
@@ -1240,8 +1295,12 @@ var CacheFirstLoop = class {
1240
1295
  for (const msg of messages) this.log.append(msg);
1241
1296
  this.resumedMessageCount = messages.length;
1242
1297
  if (healedCount > 0) {
1298
+ try {
1299
+ rewriteSession(this.sessionName, messages);
1300
+ } catch {
1301
+ }
1243
1302
  process.stderr.write(
1244
- `\u25B8 session "${this.sessionName}": healed ${healedCount} oversized tool result(s) (was ${healedFrom.toLocaleString()} chars total). Old payloads were truncated to fit DeepSeek's context window; the conversation is preserved.
1303
+ `\u25B8 session "${this.sessionName}": healed ${healedCount} entr${healedCount === 1 ? "y" : "ies"}${healedFrom > 0 ? ` (was ${healedFrom.toLocaleString()} chars oversized)` : " (dropped dangling tool_calls tail)"}. Rewrote session file.
1245
1304
  `
1246
1305
  );
1247
1306
  }
@@ -1262,7 +1321,7 @@ var CacheFirstLoop = class {
1262
1321
  */
1263
1322
  compact(tightCapChars = 4e3) {
1264
1323
  const before = this.log.toMessages();
1265
- const { messages, healedCount, healedFrom } = healLoadedMessages(before, tightCapChars);
1324
+ const { messages, healedCount, healedFrom } = shrinkOversizedToolResults(before, tightCapChars);
1266
1325
  const afterBytes = messages.filter((m) => m.role === "tool").reduce((s, m) => s + (typeof m.content === "string" ? m.content.length : 0), 0);
1267
1326
  const charsSaved = healedFrom - afterBytes;
1268
1327
  if (healedCount > 0) {
@@ -1285,6 +1344,29 @@ var CacheFirstLoop = class {
1285
1344
  }
1286
1345
  }
1287
1346
  }
1347
+ /**
1348
+ * Start a fresh conversation WITHOUT exiting. Drops every message
1349
+ * in the in-memory log AND rewrites the session file to empty so
1350
+ * a resume won't re-hydrate the old turns. Unlike `/forget`, which
1351
+ * deletes the session entirely, this keeps the session name and
1352
+ * config intact — it's the "new chat" button.
1353
+ *
1354
+ * The immutable prefix (system prompt + tool specs) is preserved
1355
+ * — that's the cache-first invariant, not part of the conversation.
1356
+ * Returns the number of messages dropped so the UI can show it.
1357
+ */
1358
+ clearLog() {
1359
+ const dropped = this.log.length;
1360
+ this.log.compactInPlace([]);
1361
+ if (this.sessionName) {
1362
+ try {
1363
+ rewriteSession(this.sessionName, []);
1364
+ } catch {
1365
+ }
1366
+ }
1367
+ this.scratch.reset();
1368
+ return { dropped };
1369
+ }
1288
1370
  /**
1289
1371
  * Reconfigure model/harvest/branch/stream mid-session. The loop's log,
1290
1372
  * scratch, and stats are preserved — only the per-turn behavior changes.
@@ -1316,19 +1398,21 @@ var CacheFirstLoop = class {
1316
1398
  this.stream = this.branchEnabled ? false : this._streamPreference;
1317
1399
  }
1318
1400
  buildMessages(pendingUser) {
1319
- const msgs = [...this.prefix.toMessages(), ...this.log.toMessages()];
1401
+ const healed = healLoadedMessages(this.log.toMessages(), DEFAULT_MAX_RESULT_CHARS);
1402
+ const msgs = [...this.prefix.toMessages(), ...healed.messages];
1320
1403
  if (pendingUser !== null) msgs.push({ role: "user", content: pendingUser });
1321
1404
  return msgs;
1322
1405
  }
1323
1406
  /**
1324
- * Signal the currently-running {@link step} that the user wants to
1325
- * stop exploring. Takes effect at the next iteration boundary — if a
1326
- * tool call is mid-flight it will be allowed to finish, then the
1327
- * loop diverts to the forced-summary path so the user gets an
1328
- * answer instead of a cliff. Called by the TUI on Esc.
1407
+ * Signal the currently-running {@link step} to stop **now**. Cancels
1408
+ * the in-flight network request (DeepSeek HTTP/SSE) AND any tool call
1409
+ * currently dispatching (MCP `notifications/cancelled` + promise
1410
+ * reject). The loop itself also sees `signal.aborted` at each
1411
+ * iteration boundary and exits quickly instead of looping again.
1412
+ * Called by the TUI on Esc.
1329
1413
  */
1330
1414
  abort() {
1331
- this._aborted = true;
1415
+ this._turnAbort.abort();
1332
1416
  }
1333
1417
  /**
1334
1418
  * Drop everything in the log after (and including) the most recent
@@ -1366,13 +1450,14 @@ var CacheFirstLoop = class {
1366
1450
  async *step(userInput) {
1367
1451
  this._turn++;
1368
1452
  this.scratch.reset();
1369
- this._aborted = false;
1453
+ this._turnAbort = new AbortController();
1454
+ const signal = this._turnAbort.signal;
1370
1455
  let pendingUser = userInput;
1371
1456
  const toolSpecs = this.prefix.tools();
1372
1457
  const warnAt = Math.max(1, Math.floor(this.maxToolIters * 0.7));
1373
1458
  let warnedForIterBudget = false;
1374
1459
  for (let iter = 0; iter < this.maxToolIters; iter++) {
1375
- if (this._aborted) {
1460
+ if (signal.aborted) {
1376
1461
  yield {
1377
1462
  turn: this._turn,
1378
1463
  role: "warning",
@@ -1389,6 +1474,13 @@ var CacheFirstLoop = class {
1389
1474
  yield { turn: this._turn, role: "done", content: stoppedMsg };
1390
1475
  return;
1391
1476
  }
1477
+ if (iter > 0) {
1478
+ yield {
1479
+ turn: this._turn,
1480
+ role: "status",
1481
+ content: "tool result uploaded \xB7 model thinking before next response\u2026"
1482
+ };
1483
+ }
1392
1484
  if (!warnedForIterBudget && iter >= warnAt) {
1393
1485
  warnedForIterBudget = true;
1394
1486
  yield {
@@ -1435,7 +1527,8 @@ var CacheFirstLoop = class {
1435
1527
  {
1436
1528
  model: this.model,
1437
1529
  messages,
1438
- tools: toolSpecs.length ? toolSpecs : void 0
1530
+ tools: toolSpecs.length ? toolSpecs : void 0,
1531
+ signal
1439
1532
  },
1440
1533
  {
1441
1534
  ...this.branchOptions,
@@ -1444,8 +1537,8 @@ var CacheFirstLoop = class {
1444
1537
  }
1445
1538
  );
1446
1539
  for (let k = 0; k < budget; k++) {
1447
- const sample = queue.shift() ?? await new Promise((resolve4) => {
1448
- waiter = resolve4;
1540
+ const sample = queue.shift() ?? await new Promise((resolve5) => {
1541
+ waiter = resolve5;
1449
1542
  });
1450
1543
  yield {
1451
1544
  turn: this._turn,
@@ -1485,7 +1578,8 @@ var CacheFirstLoop = class {
1485
1578
  for await (const chunk of this.client.stream({
1486
1579
  model: this.model,
1487
1580
  messages,
1488
- tools: toolSpecs.length ? toolSpecs : void 0
1581
+ tools: toolSpecs.length ? toolSpecs : void 0,
1582
+ signal
1489
1583
  })) {
1490
1584
  if (chunk.contentDelta) {
1491
1585
  assistantContent += chunk.contentDelta;
@@ -1524,7 +1618,8 @@ var CacheFirstLoop = class {
1524
1618
  const resp = await this.client.chat({
1525
1619
  model: this.model,
1526
1620
  messages,
1527
- tools: toolSpecs.length ? toolSpecs : void 0
1621
+ tools: toolSpecs.length ? toolSpecs : void 0,
1622
+ signal
1528
1623
  });
1529
1624
  assistantContent = resp.content;
1530
1625
  reasoningContent = resp.reasoningContent ?? "";
@@ -1546,7 +1641,14 @@ var CacheFirstLoop = class {
1546
1641
  pendingUser = null;
1547
1642
  }
1548
1643
  this.scratch.reasoning = reasoningContent || null;
1549
- const planState = preHarvestedPlanState ? preHarvestedPlanState : this.harvestEnabled ? await harvest(reasoningContent || null, this.client, this.harvestOptions) : emptyPlanState();
1644
+ if (!preHarvestedPlanState && this.harvestEnabled && (reasoningContent?.trim().length ?? 0) >= 40) {
1645
+ yield {
1646
+ turn: this._turn,
1647
+ role: "status",
1648
+ content: "extracting plan state from reasoning\u2026"
1649
+ };
1650
+ }
1651
+ const planState = preHarvestedPlanState ? preHarvestedPlanState : this.harvestEnabled ? await harvest(reasoningContent || null, this.client, this.harvestOptions, signal) : emptyPlanState();
1550
1652
  const { calls: repairedCalls, report } = this.repair.process(
1551
1653
  toolCalls,
1552
1654
  reasoningContent || null,
@@ -1568,15 +1670,38 @@ var CacheFirstLoop = class {
1568
1670
  }
1569
1671
  const ctxMax = DEEPSEEK_CONTEXT_TOKENS[this.model] ?? DEFAULT_CONTEXT_TOKENS;
1570
1672
  if (usage && usage.promptTokens / ctxMax > 0.8) {
1571
- yield {
1572
- turn: this._turn,
1573
- role: "warning",
1574
- content: `context ${usage.promptTokens}/${ctxMax} (${Math.round(
1575
- usage.promptTokens / ctxMax * 100
1576
- )}%) \u2014 more tools would overflow. Forcing summary from what was gathered.`
1577
- };
1578
- yield* this.forceSummaryAfterIterLimit({ reason: "context-guard" });
1579
- return;
1673
+ const before = usage.promptTokens;
1674
+ const compactResult = this.compact(4e3);
1675
+ if (compactResult.healedCount > 0) {
1676
+ const approxSaved = Math.round(compactResult.charsSaved / 4);
1677
+ const after = before - approxSaved;
1678
+ yield {
1679
+ turn: this._turn,
1680
+ role: "warning",
1681
+ content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} \u2014 auto-compacted ${compactResult.healedCount} oversized tool result(s), saved ~${approxSaved.toLocaleString()} tokens (now ~${after.toLocaleString()}). Continuing.`
1682
+ };
1683
+ } else {
1684
+ yield {
1685
+ turn: this._turn,
1686
+ role: "warning",
1687
+ content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} (${Math.round(
1688
+ before / ctxMax * 100
1689
+ )}%) \u2014 nothing to auto-compact. Forcing summary from what was gathered.`
1690
+ };
1691
+ const tail = this.log.entries[this.log.entries.length - 1];
1692
+ if (tail && tail.role === "assistant" && Array.isArray(tail.tool_calls) && tail.tool_calls.length > 0) {
1693
+ const kept = this.log.entries.slice(0, -1);
1694
+ this.log.compactInPlace([...kept]);
1695
+ if (this.sessionName) {
1696
+ try {
1697
+ rewriteSession(this.sessionName, kept);
1698
+ } catch {
1699
+ }
1700
+ }
1701
+ }
1702
+ yield* this.forceSummaryAfterIterLimit({ reason: "context-guard" });
1703
+ return;
1704
+ }
1580
1705
  }
1581
1706
  for (const call of repairedCalls) {
1582
1707
  const name = call.function?.name ?? "";
@@ -1588,7 +1713,7 @@ var CacheFirstLoop = class {
1588
1713
  toolName: name,
1589
1714
  toolArgs: args
1590
1715
  };
1591
- const result = await this.tools.dispatch(name, args);
1716
+ const result = await this.tools.dispatch(name, args, { signal });
1592
1717
  this.appendAndPersist({
1593
1718
  role: "tool",
1594
1719
  tool_call_id: call.id ?? "",
@@ -1608,6 +1733,11 @@ var CacheFirstLoop = class {
1608
1733
  }
1609
1734
  async *forceSummaryAfterIterLimit(opts = { reason: "budget" }) {
1610
1735
  try {
1736
+ yield {
1737
+ turn: this._turn,
1738
+ role: "status",
1739
+ content: "summarizing what was gathered\u2026"
1740
+ };
1611
1741
  const messages = this.buildMessages(null);
1612
1742
  messages.push({
1613
1743
  role: "user",
@@ -1615,8 +1745,9 @@ var CacheFirstLoop = class {
1615
1745
  });
1616
1746
  const resp = await this.client.chat({
1617
1747
  model: this.model,
1618
- messages
1748
+ messages,
1619
1749
  // no tools → model is forced to answer in text
1750
+ signal: this._turnAbort.signal
1620
1751
  });
1621
1752
  const rawContent = resp.content?.trim() ?? "";
1622
1753
  const cleaned = stripHallucinatedToolMarkup(rawContent);
@@ -1689,7 +1820,7 @@ function summarizeBranch(chosen, samples) {
1689
1820
  temperatures: samples.map((s) => s.temperature)
1690
1821
  };
1691
1822
  }
1692
- function healLoadedMessages(messages, maxChars) {
1823
+ function shrinkOversizedToolResults(messages, maxChars) {
1693
1824
  let healedCount = 0;
1694
1825
  let healedFrom = 0;
1695
1826
  const out = messages.map((msg) => {
@@ -1702,6 +1833,51 @@ function healLoadedMessages(messages, maxChars) {
1702
1833
  });
1703
1834
  return { messages: out, healedCount, healedFrom };
1704
1835
  }
1836
+ function healLoadedMessages(messages, maxChars) {
1837
+ const shrunk = shrinkOversizedToolResults(messages, maxChars);
1838
+ let healedCount = shrunk.healedCount;
1839
+ const out = [];
1840
+ const openCallIds = /* @__PURE__ */ new Set();
1841
+ let droppedAssistantCalls = 0;
1842
+ let droppedStrayTools = 0;
1843
+ for (let i = 0; i < shrunk.messages.length; i++) {
1844
+ const msg = shrunk.messages[i];
1845
+ if (msg.role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
1846
+ const needed = /* @__PURE__ */ new Set();
1847
+ for (const call of msg.tool_calls) {
1848
+ if (call?.id) needed.add(call.id);
1849
+ }
1850
+ const candidates = [];
1851
+ let j = i + 1;
1852
+ while (j < shrunk.messages.length && needed.size > 0) {
1853
+ const nxt = shrunk.messages[j];
1854
+ if (nxt.role !== "tool") break;
1855
+ const id = nxt.tool_call_id ?? "";
1856
+ if (!needed.has(id)) break;
1857
+ needed.delete(id);
1858
+ candidates.push(nxt);
1859
+ j++;
1860
+ }
1861
+ if (needed.size === 0) {
1862
+ out.push(msg);
1863
+ for (const r of candidates) out.push(r);
1864
+ i = j - 1;
1865
+ } else {
1866
+ droppedAssistantCalls += 1;
1867
+ droppedStrayTools += candidates.length;
1868
+ i = j - 1;
1869
+ }
1870
+ continue;
1871
+ }
1872
+ if (msg.role === "tool") {
1873
+ droppedStrayTools += 1;
1874
+ continue;
1875
+ }
1876
+ out.push(msg);
1877
+ }
1878
+ healedCount += droppedAssistantCalls + droppedStrayTools;
1879
+ return { messages: out, healedCount, healedFrom: shrunk.healedFrom };
1880
+ }
1705
1881
  function formatLoopError(err) {
1706
1882
  const msg = err.message ?? "";
1707
1883
  if (msg.includes("maximum context length")) {
@@ -1712,13 +1888,348 @@ function formatLoopError(err) {
1712
1888
  return msg;
1713
1889
  }
1714
1890
 
1891
+ // src/tools/filesystem.ts
1892
+ import { promises as fs } from "fs";
1893
+ import * as pathMod from "path";
1894
+ var DEFAULT_MAX_READ_BYTES = 2 * 1024 * 1024;
1895
+ var DEFAULT_MAX_LIST_BYTES = 256 * 1024;
1896
+ function registerFilesystemTools(registry, opts) {
1897
+ const rootDir = pathMod.resolve(opts.rootDir);
1898
+ const allowWriting = opts.allowWriting !== false;
1899
+ const maxReadBytes = opts.maxReadBytes ?? DEFAULT_MAX_READ_BYTES;
1900
+ const maxListBytes = opts.maxListBytes ?? DEFAULT_MAX_LIST_BYTES;
1901
+ const safePath = (raw) => {
1902
+ if (typeof raw !== "string" || raw.length === 0) {
1903
+ throw new Error("path must be a non-empty string");
1904
+ }
1905
+ const resolved = pathMod.resolve(rootDir, raw);
1906
+ const normRoot = pathMod.resolve(rootDir);
1907
+ const rel = pathMod.relative(normRoot, resolved);
1908
+ if (rel.startsWith("..") || pathMod.isAbsolute(rel)) {
1909
+ throw new Error(`path escapes sandbox root (${normRoot}): ${raw}`);
1910
+ }
1911
+ return resolved;
1912
+ };
1913
+ registry.register({
1914
+ name: "read_file",
1915
+ description: "Read a file under the sandbox root. Returns the full contents (truncated with a notice if larger than the per-call cap). Paths may be relative to the root or absolute-under-root.",
1916
+ parameters: {
1917
+ type: "object",
1918
+ properties: {
1919
+ path: { type: "string", description: "Path to read (relative to rootDir or absolute)." },
1920
+ head: { type: "integer", description: "If set, return only the first N lines." },
1921
+ tail: { type: "integer", description: "If set, return only the last N lines." }
1922
+ },
1923
+ required: ["path"]
1924
+ },
1925
+ fn: async (args) => {
1926
+ const abs = safePath(args.path);
1927
+ const stat = await fs.stat(abs);
1928
+ if (stat.isDirectory()) {
1929
+ throw new Error(`not a file: ${args.path} (it's a directory)`);
1930
+ }
1931
+ const raw = await fs.readFile(abs);
1932
+ if (raw.length > maxReadBytes) {
1933
+ const head = raw.slice(0, maxReadBytes).toString("utf8");
1934
+ return `${head}
1935
+
1936
+ [\u2026truncated ${raw.length - maxReadBytes} bytes \u2014 file is ${raw.length} B, cap ${maxReadBytes} B. Retry with head/tail for targeted view.]`;
1937
+ }
1938
+ const text = raw.toString("utf8");
1939
+ if (typeof args.head === "number" && args.head > 0) {
1940
+ return text.split(/\r?\n/).slice(0, args.head).join("\n");
1941
+ }
1942
+ if (typeof args.tail === "number" && args.tail > 0) {
1943
+ let lines = text.split(/\r?\n/);
1944
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines = lines.slice(0, -1);
1945
+ return lines.slice(Math.max(0, lines.length - args.tail)).join("\n");
1946
+ }
1947
+ return text;
1948
+ }
1949
+ });
1950
+ registry.register({
1951
+ name: "list_directory",
1952
+ description: "List entries in a directory under the sandbox root. Returns one line per entry, marking directories with a trailing slash. Not recursive \u2014 use directory_tree for that.",
1953
+ parameters: {
1954
+ type: "object",
1955
+ properties: {
1956
+ path: { type: "string", description: "Directory to list (default: root)." }
1957
+ }
1958
+ },
1959
+ fn: async (args) => {
1960
+ const abs = safePath(args.path ?? ".");
1961
+ const entries = await fs.readdir(abs, { withFileTypes: true });
1962
+ const lines = [];
1963
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
1964
+ lines.push(e.isDirectory() ? `${e.name}/` : e.name);
1965
+ }
1966
+ return lines.join("\n") || "(empty directory)";
1967
+ }
1968
+ });
1969
+ registry.register({
1970
+ name: "directory_tree",
1971
+ description: "Recursively list entries in a directory. Shows indented tree structure with directories marked '/'. Caps output so a huge tree doesn't drown the context.",
1972
+ parameters: {
1973
+ type: "object",
1974
+ properties: {
1975
+ path: { type: "string", description: "Root of the tree (default: sandbox root)." },
1976
+ maxDepth: { type: "integer", description: "Max recursion depth (default 4)." }
1977
+ }
1978
+ },
1979
+ fn: async (args) => {
1980
+ const startAbs = safePath(args.path ?? ".");
1981
+ const maxDepth = typeof args.maxDepth === "number" ? args.maxDepth : 4;
1982
+ const lines = [];
1983
+ let totalBytes = 0;
1984
+ let truncated = false;
1985
+ const walk2 = async (dir, depth) => {
1986
+ if (truncated) return;
1987
+ if (depth > maxDepth) return;
1988
+ let entries;
1989
+ try {
1990
+ entries = await fs.readdir(dir, { withFileTypes: true });
1991
+ } catch {
1992
+ return;
1993
+ }
1994
+ entries.sort((a, b) => a.name.localeCompare(b.name));
1995
+ for (const e of entries) {
1996
+ if (truncated) return;
1997
+ const indent = " ".repeat(depth);
1998
+ const line = e.isDirectory() ? `${indent}${e.name}/` : `${indent}${e.name}`;
1999
+ totalBytes += line.length + 1;
2000
+ if (totalBytes > maxListBytes) {
2001
+ lines.push(` [\u2026 tree truncated at ${maxListBytes} bytes \u2026]`);
2002
+ truncated = true;
2003
+ return;
2004
+ }
2005
+ lines.push(line);
2006
+ if (e.isDirectory()) {
2007
+ await walk2(pathMod.join(dir, e.name), depth + 1);
2008
+ }
2009
+ }
2010
+ };
2011
+ await walk2(startAbs, 0);
2012
+ return lines.join("\n") || "(empty tree)";
2013
+ }
2014
+ });
2015
+ registry.register({
2016
+ name: "search_files",
2017
+ description: "Find files whose NAME matches a substring or regex. Case-insensitive. Walks the directory recursively under the sandbox root. Returns one path per line.",
2018
+ parameters: {
2019
+ type: "object",
2020
+ properties: {
2021
+ path: { type: "string", description: "Directory to start the search at (default: root)." },
2022
+ pattern: {
2023
+ type: "string",
2024
+ description: "Substring (or regex) to match against filenames."
2025
+ }
2026
+ },
2027
+ required: ["pattern"]
2028
+ },
2029
+ fn: async (args) => {
2030
+ const startAbs = safePath(args.path ?? ".");
2031
+ const needle = args.pattern.toLowerCase();
2032
+ let re = null;
2033
+ try {
2034
+ re = new RegExp(args.pattern, "i");
2035
+ } catch {
2036
+ re = null;
2037
+ }
2038
+ const matches = [];
2039
+ let totalBytes = 0;
2040
+ const walk2 = async (dir) => {
2041
+ let entries;
2042
+ try {
2043
+ entries = await fs.readdir(dir, { withFileTypes: true });
2044
+ } catch {
2045
+ return;
2046
+ }
2047
+ for (const e of entries) {
2048
+ const full = pathMod.join(dir, e.name);
2049
+ const lower = e.name.toLowerCase();
2050
+ const hit = re ? re.test(e.name) : lower.includes(needle);
2051
+ if (hit) {
2052
+ const rel = pathMod.relative(rootDir, full);
2053
+ if (totalBytes + rel.length + 1 > maxListBytes) {
2054
+ matches.push("[\u2026 search truncated \u2014 refine pattern \u2026]");
2055
+ return;
2056
+ }
2057
+ matches.push(rel);
2058
+ totalBytes += rel.length + 1;
2059
+ }
2060
+ if (e.isDirectory()) await walk2(full);
2061
+ }
2062
+ };
2063
+ await walk2(startAbs);
2064
+ return matches.length === 0 ? "(no matches)" : matches.join("\n");
2065
+ }
2066
+ });
2067
+ registry.register({
2068
+ name: "get_file_info",
2069
+ description: "Stat a path under the sandbox root. Returns type (file|directory|symlink), size in bytes, mtime in ISO-8601.",
2070
+ parameters: {
2071
+ type: "object",
2072
+ properties: {
2073
+ path: { type: "string" }
2074
+ },
2075
+ required: ["path"]
2076
+ },
2077
+ fn: async (args) => {
2078
+ const abs = safePath(args.path);
2079
+ const st = await fs.lstat(abs);
2080
+ const type = st.isDirectory() ? "directory" : st.isSymbolicLink() ? "symlink" : "file";
2081
+ return JSON.stringify({
2082
+ type,
2083
+ size: st.size,
2084
+ mtime: st.mtime.toISOString()
2085
+ });
2086
+ }
2087
+ });
2088
+ if (!allowWriting) return registry;
2089
+ registry.register({
2090
+ name: "write_file",
2091
+ description: "Create or overwrite a file under the sandbox root with the given content. Parent directories are created as needed.",
2092
+ parameters: {
2093
+ type: "object",
2094
+ properties: {
2095
+ path: { type: "string" },
2096
+ content: { type: "string" }
2097
+ },
2098
+ required: ["path", "content"]
2099
+ },
2100
+ fn: async (args) => {
2101
+ const abs = safePath(args.path);
2102
+ await fs.mkdir(pathMod.dirname(abs), { recursive: true });
2103
+ await fs.writeFile(abs, args.content, "utf8");
2104
+ return `wrote ${args.content.length} chars to ${pathMod.relative(rootDir, abs)}`;
2105
+ }
2106
+ });
2107
+ registry.register({
2108
+ name: "edit_file",
2109
+ description: "Apply a SEARCH/REPLACE edit to an existing file. `search` must match exactly (whitespace sensitive) \u2014 no regex. The match must be unique in the file; otherwise the edit is refused to avoid surprise rewrites. This flat-string shape replaces the `{oldText, newText}[]` JSON array form that previously triggered R1 DSML hallucinations.",
2110
+ parameters: {
2111
+ type: "object",
2112
+ properties: {
2113
+ path: { type: "string" },
2114
+ search: { type: "string", description: "Exact text to find (must be unique)." },
2115
+ replace: { type: "string", description: "Text to substitute in place of `search`." }
2116
+ },
2117
+ required: ["path", "search", "replace"]
2118
+ },
2119
+ fn: async (args) => {
2120
+ const abs = safePath(args.path);
2121
+ const before = await fs.readFile(abs, "utf8");
2122
+ if (args.search.length === 0) {
2123
+ throw new Error("edit_file: search cannot be empty");
2124
+ }
2125
+ const firstIdx = before.indexOf(args.search);
2126
+ if (firstIdx < 0) {
2127
+ throw new Error(`edit_file: search text not found in ${pathMod.relative(rootDir, abs)}`);
2128
+ }
2129
+ const nextIdx = before.indexOf(args.search, firstIdx + 1);
2130
+ if (nextIdx >= 0) {
2131
+ throw new Error(
2132
+ `edit_file: search text appears multiple times in ${pathMod.relative(rootDir, abs)} \u2014 include more context to disambiguate`
2133
+ );
2134
+ }
2135
+ const after = before.slice(0, firstIdx) + args.replace + before.slice(firstIdx + args.search.length);
2136
+ await fs.writeFile(abs, after, "utf8");
2137
+ const rel = pathMod.relative(rootDir, abs);
2138
+ const header = `edited ${rel} (${args.search.length}\u2192${args.replace.length} chars)`;
2139
+ const startLine = before.slice(0, firstIdx).split(/\r?\n/).length;
2140
+ const diff = renderEditDiff(args.search, args.replace, startLine);
2141
+ return `${header}
2142
+ ${diff}`;
2143
+ }
2144
+ });
2145
+ registry.register({
2146
+ name: "create_directory",
2147
+ description: "Create a directory (and any missing parents) under the sandbox root.",
2148
+ parameters: {
2149
+ type: "object",
2150
+ properties: { path: { type: "string" } },
2151
+ required: ["path"]
2152
+ },
2153
+ fn: async (args) => {
2154
+ const abs = safePath(args.path);
2155
+ await fs.mkdir(abs, { recursive: true });
2156
+ return `created ${pathMod.relative(rootDir, abs)}/`;
2157
+ }
2158
+ });
2159
+ registry.register({
2160
+ name: "move_file",
2161
+ description: "Rename/move a file or directory under the sandbox root.",
2162
+ parameters: {
2163
+ type: "object",
2164
+ properties: {
2165
+ source: { type: "string" },
2166
+ destination: { type: "string" }
2167
+ },
2168
+ required: ["source", "destination"]
2169
+ },
2170
+ fn: async (args) => {
2171
+ const src = safePath(args.source);
2172
+ const dst = safePath(args.destination);
2173
+ await fs.mkdir(pathMod.dirname(dst), { recursive: true });
2174
+ await fs.rename(src, dst);
2175
+ return `moved ${pathMod.relative(rootDir, src)} \u2192 ${pathMod.relative(rootDir, dst)}`;
2176
+ }
2177
+ });
2178
+ return registry;
2179
+ }
2180
+ function renderEditDiff(search, replace, startLine) {
2181
+ const a = search.split(/\r?\n/);
2182
+ const b = replace.split(/\r?\n/);
2183
+ const diff = lineDiff(a, b);
2184
+ const hunk = `@@ -${startLine},${a.length} +${startLine},${b.length} @@`;
2185
+ const body = diff.map((d) => `${d.op === " " ? " " : d.op} ${d.line}`).join("\n");
2186
+ return `${hunk}
2187
+ ${body}`;
2188
+ }
2189
+ function lineDiff(a, b) {
2190
+ const n = a.length;
2191
+ const m = b.length;
2192
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
2193
+ for (let i2 = 1; i2 <= n; i2++) {
2194
+ for (let j2 = 1; j2 <= m; j2++) {
2195
+ if (a[i2 - 1] === b[j2 - 1]) dp[i2][j2] = dp[i2 - 1][j2 - 1] + 1;
2196
+ else dp[i2][j2] = Math.max(dp[i2 - 1][j2], dp[i2][j2 - 1]);
2197
+ }
2198
+ }
2199
+ const out = [];
2200
+ let i = n;
2201
+ let j = m;
2202
+ while (i > 0 && j > 0) {
2203
+ if (a[i - 1] === b[j - 1]) {
2204
+ out.unshift({ op: " ", line: a[i - 1] });
2205
+ i--;
2206
+ j--;
2207
+ } else if ((dp[i - 1][j] ?? 0) > (dp[i][j - 1] ?? 0)) {
2208
+ out.unshift({ op: "-", line: a[i - 1] });
2209
+ i--;
2210
+ } else {
2211
+ out.unshift({ op: "+", line: b[j - 1] });
2212
+ j--;
2213
+ }
2214
+ }
2215
+ while (i > 0) {
2216
+ out.unshift({ op: "-", line: a[i - 1] });
2217
+ i--;
2218
+ }
2219
+ while (j > 0) {
2220
+ out.unshift({ op: "+", line: b[j - 1] });
2221
+ j--;
2222
+ }
2223
+ return out;
2224
+ }
2225
+
1715
2226
  // src/env.ts
1716
2227
  import { readFileSync as readFileSync3 } from "fs";
1717
- import { resolve } from "path";
2228
+ import { resolve as resolve2 } from "path";
1718
2229
  function loadDotenv(path = ".env") {
1719
2230
  let raw;
1720
2231
  try {
1721
- raw = readFileSync3(resolve(process.cwd(), path), "utf8");
2232
+ raw = readFileSync3(resolve2(process.cwd(), path), "utf8");
1722
2233
  } catch {
1723
2234
  return;
1724
2235
  }
@@ -1896,6 +2407,8 @@ function computeReplayStats(records) {
1896
2407
  }
1897
2408
  function summarizeTurns(turns) {
1898
2409
  const totalCost = turns.reduce((s, t) => s + t.cost, 0);
2410
+ const totalInput = turns.reduce((s, t) => s + inputCostUsd(t.model, t.usage), 0);
2411
+ const totalOutput = turns.reduce((s, t) => s + outputCostUsd(t.model, t.usage), 0);
1899
2412
  const totalClaude = turns.reduce((s, t) => s + claudeEquivalentCost(t.usage), 0);
1900
2413
  let hit = 0;
1901
2414
  let miss = 0;
@@ -1909,6 +2422,8 @@ function summarizeTurns(turns) {
1909
2422
  return {
1910
2423
  turns: turns.length,
1911
2424
  totalCostUsd: round2(totalCost, 6),
2425
+ totalInputCostUsd: round2(totalInput, 6),
2426
+ totalOutputCostUsd: round2(totalOutput, 6),
1912
2427
  claudeEquivalentUsd: round2(totalClaude, 6),
1913
2428
  savingsVsClaudePct: round2(savingsVsClaude * 100, 2),
1914
2429
  cacheHitRatio: round2(cacheHitRatio, 4),
@@ -2279,6 +2794,13 @@ var McpClient = class {
2279
2794
  _serverInfo = { name: "", version: "" };
2280
2795
  _protocolVersion = "";
2281
2796
  _instructions;
2797
+ // Progress-token → handler for notifications/progress routing. Tokens
2798
+ // are minted per call when the caller supplies an onProgress
2799
+ // callback; cleared when the final response lands (or the pending
2800
+ // request rejects). No leaks — the `try/finally` in callTool
2801
+ // guarantees cleanup even on timeout.
2802
+ progressHandlers = /* @__PURE__ */ new Map();
2803
+ nextProgressToken = 1;
2282
2804
  constructor(opts) {
2283
2805
  this.transport = opts.transport;
2284
2806
  this.clientInfo = opts.clientInfo ?? { name: "reasonix", version: "0.3.0-dev" };
@@ -2333,13 +2855,36 @@ var McpClient = class {
2333
2855
  this.assertInitialized();
2334
2856
  return this.request("tools/list", {});
2335
2857
  }
2336
- /** Invoke a tool by name. Returns the raw MCP result (caller unwraps content). */
2337
- async callTool(name, args) {
2858
+ /**
2859
+ * Invoke a tool by name. When `onProgress` is supplied, attaches a
2860
+ * fresh progress token so the server can send incremental updates
2861
+ * via `notifications/progress`; they're routed to the callback until
2862
+ * the final response arrives (or the request times out, in which
2863
+ * case the handler is simply dropped — no extra notification).
2864
+ *
2865
+ * When `signal` is supplied, aborting it:
2866
+ * 1) fires `notifications/cancelled` to the server (MCP 2024-11-05
2867
+ * way of saying "forget this request, I no longer care"), and
2868
+ * 2) rejects the pending promise immediately with an AbortError,
2869
+ * so the caller doesn't have to wait for the subprocess to
2870
+ * finish its in-flight file write or network request.
2871
+ * The server MAY still emit a late response; we drop it in dispatch
2872
+ * since the request id is gone from `pending`.
2873
+ */
2874
+ async callTool(name, args, opts = {}) {
2338
2875
  this.assertInitialized();
2339
- return this.request("tools/call", {
2340
- name,
2341
- arguments: args ?? {}
2342
- });
2876
+ const params = { name, arguments: args ?? {} };
2877
+ let token;
2878
+ if (opts.onProgress) {
2879
+ token = this.nextProgressToken++;
2880
+ this.progressHandlers.set(token, opts.onProgress);
2881
+ params._meta = { progressToken: token };
2882
+ }
2883
+ try {
2884
+ return await this.request("tools/call", params, opts.signal);
2885
+ } finally {
2886
+ if (token !== void 0) this.progressHandlers.delete(token);
2887
+ }
2343
2888
  }
2344
2889
  /**
2345
2890
  * List resources the server exposes. Supports a pagination cursor;
@@ -2393,24 +2938,56 @@ var McpClient = class {
2393
2938
  assertInitialized() {
2394
2939
  if (!this.initialized) throw new Error("MCP client not initialized \u2014 call initialize() first");
2395
2940
  }
2396
- async request(method, params) {
2941
+ async request(method, params, signal) {
2397
2942
  const id = this.nextId++;
2398
2943
  const frame = { jsonrpc: "2.0", id, method, params };
2399
- const promise = new Promise((resolve4, reject) => {
2944
+ let abortHandler = null;
2945
+ const promise = new Promise((resolve5, reject) => {
2400
2946
  const timeout = setTimeout(() => {
2401
2947
  this.pending.delete(id);
2948
+ if (abortHandler && signal) signal.removeEventListener("abort", abortHandler);
2402
2949
  reject(
2403
2950
  new Error(`MCP request ${method} (id=${id}) timed out after ${this.requestTimeoutMs}ms`)
2404
2951
  );
2405
2952
  }, this.requestTimeoutMs);
2406
2953
  this.pending.set(id, {
2407
- resolve: resolve4,
2954
+ resolve: resolve5,
2408
2955
  reject,
2409
2956
  timeout
2410
2957
  });
2958
+ if (signal) {
2959
+ if (signal.aborted) {
2960
+ this.pending.delete(id);
2961
+ clearTimeout(timeout);
2962
+ reject(new Error(`MCP request ${method} (id=${id}) aborted before send`));
2963
+ return;
2964
+ }
2965
+ abortHandler = () => {
2966
+ this.pending.delete(id);
2967
+ clearTimeout(timeout);
2968
+ void this.transport.send({
2969
+ jsonrpc: "2.0",
2970
+ method: "notifications/cancelled",
2971
+ params: { requestId: id, reason: "aborted by user" }
2972
+ }).catch(() => {
2973
+ });
2974
+ reject(new Error(`MCP request ${method} (id=${id}) aborted by user`));
2975
+ };
2976
+ signal.addEventListener("abort", abortHandler, { once: true });
2977
+ }
2411
2978
  });
2412
- await this.transport.send(frame);
2413
- return promise;
2979
+ try {
2980
+ await this.transport.send(frame);
2981
+ } catch (err) {
2982
+ this.pending.delete(id);
2983
+ if (abortHandler && signal) signal.removeEventListener("abort", abortHandler);
2984
+ throw err;
2985
+ }
2986
+ try {
2987
+ return await promise;
2988
+ } finally {
2989
+ if (abortHandler && signal) signal.removeEventListener("abort", abortHandler);
2990
+ }
2414
2991
  }
2415
2992
  startReaderIfNeeded() {
2416
2993
  if (this.readerStarted) return;
@@ -2431,7 +3008,16 @@ var McpClient = class {
2431
3008
  }
2432
3009
  }
2433
3010
  dispatch(msg) {
2434
- if (!("id" in msg) || msg.id === null || msg.id === void 0) return;
3011
+ if (!("id" in msg) || msg.id === null || msg.id === void 0) {
3012
+ if ("method" in msg && msg.method === "notifications/progress") {
3013
+ const p = msg.params;
3014
+ if (!p || p.progressToken === void 0) return;
3015
+ const handler = this.progressHandlers.get(p.progressToken);
3016
+ if (!handler) return;
3017
+ handler({ progress: p.progress, total: p.total, message: p.message });
3018
+ }
3019
+ return;
3020
+ }
2435
3021
  if (!("result" in msg) && !("error" in msg)) return;
2436
3022
  const pending = this.pending.get(msg.id);
2437
3023
  if (!pending) return;
@@ -2488,12 +3074,12 @@ var StdioTransport = class {
2488
3074
  }
2489
3075
  async send(message) {
2490
3076
  if (this.closed) throw new Error("MCP transport is closed");
2491
- return new Promise((resolve4, reject) => {
3077
+ return new Promise((resolve5, reject) => {
2492
3078
  const line = `${JSON.stringify(message)}
2493
3079
  `;
2494
3080
  this.child.stdin.write(line, "utf8", (err) => {
2495
3081
  if (err) reject(err);
2496
- else resolve4();
3082
+ else resolve5();
2497
3083
  });
2498
3084
  });
2499
3085
  }
@@ -2504,8 +3090,8 @@ var StdioTransport = class {
2504
3090
  continue;
2505
3091
  }
2506
3092
  if (this.closed) return;
2507
- const next = await new Promise((resolve4) => {
2508
- this.waiters.push(resolve4);
3093
+ const next = await new Promise((resolve5) => {
3094
+ this.waiters.push(resolve5);
2509
3095
  });
2510
3096
  if (next === null) return;
2511
3097
  yield next;
@@ -2571,8 +3157,8 @@ var SseTransport = class {
2571
3157
  constructor(opts) {
2572
3158
  this.url = opts.url;
2573
3159
  this.headers = opts.headers ?? {};
2574
- this.endpointReady = new Promise((resolve4, reject) => {
2575
- this.resolveEndpoint = resolve4;
3160
+ this.endpointReady = new Promise((resolve5, reject) => {
3161
+ this.resolveEndpoint = resolve5;
2576
3162
  this.rejectEndpoint = reject;
2577
3163
  });
2578
3164
  this.endpointReady.catch(() => void 0);
@@ -2599,8 +3185,8 @@ var SseTransport = class {
2599
3185
  continue;
2600
3186
  }
2601
3187
  if (this.closed) return;
2602
- const next = await new Promise((resolve4) => {
2603
- this.waiters.push(resolve4);
3188
+ const next = await new Promise((resolve5) => {
3189
+ this.waiters.push(resolve5);
2604
3190
  });
2605
3191
  if (next === null) return;
2606
3192
  yield next;
@@ -2800,7 +3386,7 @@ async function trySection(load) {
2800
3386
 
2801
3387
  // src/code/edit-blocks.ts
2802
3388
  import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync5, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
2803
- import { dirname as dirname3, resolve as resolve2 } from "path";
3389
+ import { dirname as dirname4, resolve as resolve3 } from "path";
2804
3390
  var BLOCK_RE = /^(\S[^\n]*)\n<{7} SEARCH\n([\s\S]*?)\n?={7}\n([\s\S]*?)\n?>{7} REPLACE/gm;
2805
3391
  function parseEditBlocks(text) {
2806
3392
  const out = [];
@@ -2818,8 +3404,8 @@ function parseEditBlocks(text) {
2818
3404
  return out;
2819
3405
  }
2820
3406
  function applyEditBlock(block, rootDir) {
2821
- const absRoot = resolve2(rootDir);
2822
- const absTarget = resolve2(absRoot, block.path);
3407
+ const absRoot = resolve3(rootDir);
3408
+ const absTarget = resolve3(absRoot, block.path);
2823
3409
  if (absTarget !== absRoot && !absTarget.startsWith(`${absRoot}${sep()}`)) {
2824
3410
  return {
2825
3411
  path: block.path,
@@ -2838,7 +3424,7 @@ function applyEditBlock(block, rootDir) {
2838
3424
  message: "file does not exist; to create it, use an empty SEARCH block"
2839
3425
  };
2840
3426
  }
2841
- mkdirSync3(dirname3(absTarget), { recursive: true });
3427
+ mkdirSync3(dirname4(absTarget), { recursive: true });
2842
3428
  writeFileSync3(absTarget, block.replace, "utf8");
2843
3429
  return { path: block.path, status: "created" };
2844
3430
  }
@@ -2869,13 +3455,13 @@ function applyEditBlocks(blocks, rootDir) {
2869
3455
  return blocks.map((b) => applyEditBlock(b, rootDir));
2870
3456
  }
2871
3457
  function snapshotBeforeEdits(blocks, rootDir) {
2872
- const absRoot = resolve2(rootDir);
3458
+ const absRoot = resolve3(rootDir);
2873
3459
  const seen = /* @__PURE__ */ new Set();
2874
3460
  const snapshots = [];
2875
3461
  for (const b of blocks) {
2876
3462
  if (seen.has(b.path)) continue;
2877
3463
  seen.add(b.path);
2878
- const abs = resolve2(absRoot, b.path);
3464
+ const abs = resolve3(absRoot, b.path);
2879
3465
  if (!existsSync2(abs)) {
2880
3466
  snapshots.push({ path: b.path, prevContent: null });
2881
3467
  continue;
@@ -2889,9 +3475,9 @@ function snapshotBeforeEdits(blocks, rootDir) {
2889
3475
  return snapshots;
2890
3476
  }
2891
3477
  function restoreSnapshots(snapshots, rootDir) {
2892
- const absRoot = resolve2(rootDir);
3478
+ const absRoot = resolve3(rootDir);
2893
3479
  return snapshots.map((snap) => {
2894
- const abs = resolve2(absRoot, snap.path);
3480
+ const abs = resolve3(absRoot, snap.path);
2895
3481
  if (abs !== absRoot && !abs.startsWith(`${absRoot}${sep()}`)) {
2896
3482
  return {
2897
3483
  path: snap.path,
@@ -2928,11 +3514,11 @@ var VERSION = "0.4.3";
2928
3514
 
2929
3515
  // src/cli/commands/chat.tsx
2930
3516
  import { render } from "ink";
2931
- import React9, { useState as useState4 } from "react";
3517
+ import React9, { useState as useState5 } from "react";
2932
3518
 
2933
3519
  // src/cli/ui/App.tsx
2934
- import { Box as Box7, Static, Text as Text7, useApp, useInput } from "ink";
2935
- import React7, { useCallback, useEffect as useEffect2, useMemo, useRef, useState as useState2 } from "react";
3520
+ import { Box as Box7, Static, Text as Text7, useApp, useInput as useInput2 } from "ink";
3521
+ import React7, { useCallback, useEffect as useEffect3, useMemo, useRef, useState as useState3 } from "react";
2936
3522
 
2937
3523
  // src/cli/ui/EventLog.tsx
2938
3524
  import { Box as Box3, Text as Text3 } from "ink";
@@ -3125,6 +3711,25 @@ function parseBlocks(raw) {
3125
3711
  out.push({ kind: "heading", level: hm[1].length, text: hm[2].trim() });
3126
3712
  continue;
3127
3713
  }
3714
+ if (line.includes("|")) {
3715
+ const next = (lines[i + 1] ?? "").trim();
3716
+ if (/^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(next)) {
3717
+ flushPara();
3718
+ flushList();
3719
+ const header = splitTableRow(line);
3720
+ const rows = [];
3721
+ let j = i + 2;
3722
+ while (j < lines.length) {
3723
+ const r = lines[j].replace(/\s+$/g, "");
3724
+ if (r.trim() === "" || !r.includes("|")) break;
3725
+ rows.push(splitTableRow(r));
3726
+ j++;
3727
+ }
3728
+ out.push({ kind: "table", header, rows });
3729
+ i = j - 1;
3730
+ continue;
3731
+ }
3732
+ }
3128
3733
  const bm = line.match(/^\s*[-*+]\s+(.+)$/);
3129
3734
  if (bm) {
3130
3735
  flushPara();
@@ -3167,10 +3772,55 @@ function BlockView({ block }) {
3167
3772
  return /* @__PURE__ */ React2.createElement(Box2, { borderStyle: "single", borderColor: "gray", paddingX: 1 }, /* @__PURE__ */ React2.createElement(Text2, { color: "yellow" }, block.text));
3168
3773
  case "edit-block":
3169
3774
  return /* @__PURE__ */ React2.createElement(EditBlockRow, { block });
3775
+ case "table":
3776
+ return /* @__PURE__ */ React2.createElement(TableBlockRow, { block });
3170
3777
  case "hr":
3171
3778
  return /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
3172
3779
  }
3173
3780
  }
3781
+ function splitTableRow(line) {
3782
+ const SENTINEL = "\0";
3783
+ const masked = line.replace(/\\\|/g, SENTINEL);
3784
+ const trimmed = masked.trim().replace(/^\||\|$/g, "");
3785
+ return trimmed.split("|").map((c) => c.trim().replace(new RegExp(SENTINEL, "g"), "|"));
3786
+ }
3787
+ function TableBlockRow({ block }) {
3788
+ const colCount = Math.max(block.header.length, ...block.rows.map((r) => r.length));
3789
+ const widths = [];
3790
+ for (let c = 0; c < colCount; c++) {
3791
+ const cellLengths = [displayWidth(block.header[c] ?? "")];
3792
+ for (const r of block.rows) cellLengths.push(displayWidth(r[c] ?? ""));
3793
+ widths.push(Math.min(40, Math.max(3, ...cellLengths)));
3794
+ }
3795
+ const pad2 = (s, w) => {
3796
+ const dw = displayWidth(s);
3797
+ if (dw >= w) return s;
3798
+ return s + " ".repeat(w - dw);
3799
+ };
3800
+ const separator = widths.map((w) => "\u2500".repeat(w)).join("\u2500\u253C\u2500");
3801
+ return /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column" }, /* @__PURE__ */ React2.createElement(Box2, null, block.header.map((cell, ci) => (
3802
+ // biome-ignore lint/suspicious/noArrayIndexKey: table columns never reorder — derived from a static header array
3803
+ /* @__PURE__ */ React2.createElement(Text2, { key: `h-${ci}`, bold: true, color: "cyan" }, pad2(cell, widths[ci] ?? 3), ci < colCount - 1 ? " \u2502 " : "")
3804
+ ))), /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, separator), block.rows.map((row2, ri) => (
3805
+ // biome-ignore lint/suspicious/noArrayIndexKey: table rows render in source order and don't reorder
3806
+ /* @__PURE__ */ React2.createElement(Box2, { key: `r-${ri}` }, Array.from({ length: colCount }).map((_, ci) => (
3807
+ // biome-ignore lint/suspicious/noArrayIndexKey: same — column axis is fixed by the table shape
3808
+ /* @__PURE__ */ React2.createElement(Text2, { key: `c-${ri}-${ci}` }, pad2(row2[ci] ?? "", widths[ci] ?? 3), ci < colCount - 1 ? " \u2502 " : "")
3809
+ )))
3810
+ )));
3811
+ }
3812
+ function displayWidth(s) {
3813
+ let w = 0;
3814
+ for (const ch of s) {
3815
+ const code = ch.codePointAt(0) ?? 0;
3816
+ if (code >= 4352 && code <= 4447 || code >= 11904 && code <= 12350 || code >= 12353 && code <= 13311 || code >= 13312 && code <= 19903 || code >= 19968 && code <= 40959 || code >= 40960 && code <= 42191 || code >= 44032 && code <= 55203 || code >= 63744 && code <= 64255 || code >= 65072 && code <= 65103 || code >= 65280 && code <= 65376 || code >= 65504 && code <= 65510) {
3817
+ w += 2;
3818
+ } else {
3819
+ w += 1;
3820
+ }
3821
+ }
3822
+ return w;
3823
+ }
3174
3824
  function EditBlockRow({ block }) {
3175
3825
  const isNewFile = block.search.length === 0;
3176
3826
  const searchLines = block.search.split("\n");
@@ -3196,7 +3846,8 @@ var EventRow = React3.memo(function EventRow2({ event }) {
3196
3846
  const isError = event.text.startsWith("ERROR:");
3197
3847
  const color = isError ? "red" : "yellow";
3198
3848
  const marker = isError ? "\u2717" : "\u2192";
3199
- return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column", marginTop: 1 }, /* @__PURE__ */ React3.createElement(Text3, { color }, `tool<${event.toolName ?? "?"}> ${marker}`), /* @__PURE__ */ React3.createElement(Text3, { color: isError ? "red" : void 0, dimColor: !isError }, " ", truncate2(event.text, 400)));
3849
+ const isEditFile = (event.toolName === "edit_file" || event.toolName?.endsWith("_edit_file")) && !isError;
3850
+ return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column", marginTop: 1 }, /* @__PURE__ */ React3.createElement(Text3, { color }, `tool<${event.toolName ?? "?"}> ${marker}`), isEditFile ? /* @__PURE__ */ React3.createElement(EditFileDiff, { text: event.text }) : /* @__PURE__ */ React3.createElement(Text3, { color: isError ? "red" : void 0, dimColor: !isError }, " ", truncate2(event.text, 400)));
3200
3851
  }
3201
3852
  if (event.role === "error") {
3202
3853
  return /* @__PURE__ */ React3.createElement(Box3, { marginTop: 1 }, /* @__PURE__ */ React3.createElement(Text3, { color: "red", bold: true }, "error", " "), /* @__PURE__ */ React3.createElement(Text3, { color: "red" }, event.text));
@@ -3209,6 +3860,20 @@ var EventRow = React3.memo(function EventRow2({ event }) {
3209
3860
  }
3210
3861
  return /* @__PURE__ */ React3.createElement(Box3, null, /* @__PURE__ */ React3.createElement(Text3, null, event.text));
3211
3862
  });
3863
+ function EditFileDiff({ text }) {
3864
+ const lines = text.split(/\r?\n/);
3865
+ const [statusHeader, hunkHeader, ...body] = lines;
3866
+ return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column" }, /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, ` ${statusHeader ?? ""}`), hunkHeader !== void 0 ? /* @__PURE__ */ React3.createElement(Text3, { color: "cyan", bold: true }, hunkHeader) : null, body.map((line, i) => {
3867
+ const key = `${i}-${line.slice(0, 32)}`;
3868
+ if (line.startsWith("- ")) {
3869
+ return /* @__PURE__ */ React3.createElement(Text3, { key, color: "red" }, line);
3870
+ }
3871
+ if (line.startsWith("+ ")) {
3872
+ return /* @__PURE__ */ React3.createElement(Text3, { key, color: "green" }, line);
3873
+ }
3874
+ return /* @__PURE__ */ React3.createElement(Text3, { key, dimColor: true }, line);
3875
+ }));
3876
+ }
3212
3877
  function BranchBlock({ branch }) {
3213
3878
  const per = branch.uncertainties.map((u, i) => {
3214
3879
  const marker = i === branch.chosenIndex ? "\u25B8" : " ";
@@ -3245,8 +3910,21 @@ function StreamingAssistant({ event }) {
3245
3910
  }
3246
3911
  const tail = lastLine(event.text, 140);
3247
3912
  const reasoningTail = event.reasoning ? lastLine(event.reasoning, 120) : "";
3913
+ const preFirstByte = !event.text && !event.reasoning;
3248
3914
  const reasoningOnly = !event.text && !!event.reasoning;
3249
- return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column", marginTop: 1 }, /* @__PURE__ */ React3.createElement(Box3, null, /* @__PURE__ */ React3.createElement(Text3, { bold: true, color: "green" }, "assistant", " "), /* @__PURE__ */ React3.createElement(Pulse, null), /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, " ", "(", reasoningOnly ? "reasoning" : "streaming", " \xB7 ", event.text.length, event.reasoning ? ` + think ${event.reasoning.length}` : "", " chars)", " "), /* @__PURE__ */ React3.createElement(Elapsed, null)), reasoningTail ? /* @__PURE__ */ React3.createElement(Text3, { dimColor: true, italic: true }, "\u21B3 thinking: ", reasoningTail) : null, tail ? /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, "\u25B8 ", tail) : reasoningOnly ? /* @__PURE__ */ React3.createElement(Text3, { color: "yellow", dimColor: true }, " R1 is thinking before it speaks \u2014 body text starts when reasoning completes (typically 20-90s).") : /* @__PURE__ */ React3.createElement(Text3, { dimColor: true, italic: true }, " (waiting for first byte \u2014 connection is open)"));
3915
+ let label;
3916
+ let labelColor;
3917
+ if (preFirstByte) {
3918
+ label = "request sent \xB7 waiting for server";
3919
+ labelColor = "yellow";
3920
+ } else if (reasoningOnly) {
3921
+ label = `R1 reasoning \xB7 ${event.reasoning?.length ?? 0} chars of thought`;
3922
+ labelColor = "cyan";
3923
+ } else {
3924
+ label = event.reasoning ? `writing response \xB7 ${event.text.length} chars \xB7 after ${event.reasoning.length} chars of reasoning` : `writing response \xB7 ${event.text.length} chars`;
3925
+ labelColor = "green";
3926
+ }
3927
+ return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column", marginTop: 1 }, /* @__PURE__ */ React3.createElement(Box3, null, /* @__PURE__ */ React3.createElement(Text3, { bold: true, color: "green" }, "assistant", " "), /* @__PURE__ */ React3.createElement(Pulse, null), /* @__PURE__ */ React3.createElement(Text3, { color: labelColor }, ` ${label} `), /* @__PURE__ */ React3.createElement(Elapsed, null)), reasoningTail ? /* @__PURE__ */ React3.createElement(Text3, { dimColor: true, italic: true }, "\u21B3 thinking: ", reasoningTail) : null, tail ? /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, "\u25B8 ", tail) : reasoningOnly ? /* @__PURE__ */ React3.createElement(Text3, { color: "yellow", dimColor: true }, " R1 is thinking before it speaks \u2014 body text starts when reasoning completes (typically 20-90s).") : /* @__PURE__ */ React3.createElement(Text3, { dimColor: true, italic: true }, " connection open, first byte typically in 5-60s depending on model + load"));
3250
3928
  }
3251
3929
  function Pulse() {
3252
3930
  const [tick, setTick] = useState(0);
@@ -3271,9 +3949,44 @@ function truncate2(s, max) {
3271
3949
  }
3272
3950
 
3273
3951
  // src/cli/ui/PromptInput.tsx
3274
- import { Box as Box4, Text as Text4 } from "ink";
3275
- import TextInput from "ink-text-input";
3276
- import React4 from "react";
3952
+ import { Box as Box4, Text as Text4, useInput } from "ink";
3953
+ import React4, { useEffect as useEffect2, useState as useState2 } from "react";
3954
+
3955
+ // src/cli/ui/multiline-keys.ts
3956
+ var BACKSLASH_SUFFIX = /\\$/;
3957
+ function processMultilineKey(value, key) {
3958
+ if (key.tab || key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.escape || key.pageUp || key.pageDown) {
3959
+ return { next: null, submit: false };
3960
+ }
3961
+ if (key.input === "\n" || key.ctrl && key.input === "j") {
3962
+ return { next: `${value}
3963
+ `, submit: false };
3964
+ }
3965
+ if (key.return) {
3966
+ if (key.shift) {
3967
+ return { next: `${value}
3968
+ `, submit: false };
3969
+ }
3970
+ if (BACKSLASH_SUFFIX.test(value)) {
3971
+ return { next: `${value.slice(0, -1)}
3972
+ `, submit: false };
3973
+ }
3974
+ return { next: null, submit: true, submitValue: value };
3975
+ }
3976
+ if (key.backspace || key.delete) {
3977
+ if (value.length === 0) return { next: null, submit: false };
3978
+ return { next: value.slice(0, -1), submit: false };
3979
+ }
3980
+ if ((key.ctrl || key.meta) && key.input.length === 0) {
3981
+ return { next: null, submit: false };
3982
+ }
3983
+ if (key.input.length > 0 && !key.ctrl && !key.meta) {
3984
+ return { next: value + key.input, submit: false };
3985
+ }
3986
+ return { next: null, submit: false };
3987
+ }
3988
+
3989
+ // src/cli/ui/PromptInput.tsx
3277
3990
  function PromptInput({
3278
3991
  value,
3279
3992
  onChange,
@@ -3281,17 +3994,53 @@ function PromptInput({
3281
3994
  disabled,
3282
3995
  placeholder
3283
3996
  }) {
3284
- const effectivePlaceholder = disabled ? placeholder ?? "\u2026waiting for response\u2026" : placeholder ?? "type a message, or /command";
3285
- return /* @__PURE__ */ React4.createElement(Box4, { borderStyle: "round", borderColor: disabled ? "gray" : "cyan", paddingX: 1 }, /* @__PURE__ */ React4.createElement(Text4, { bold: true, color: disabled ? "gray" : "cyan" }, "you \u203A", " "), /* @__PURE__ */ React4.createElement(
3286
- TextInput,
3287
- {
3288
- value,
3289
- onChange,
3290
- onSubmit,
3291
- focus: !disabled,
3292
- placeholder: effectivePlaceholder
3997
+ const [showCursor, setShowCursor] = useState2(true);
3998
+ useEffect2(() => {
3999
+ if (disabled) {
4000
+ setShowCursor(false);
4001
+ return;
3293
4002
  }
3294
- ));
4003
+ setShowCursor(true);
4004
+ const id = setInterval(() => setShowCursor((s) => !s), 500);
4005
+ return () => clearInterval(id);
4006
+ }, [disabled]);
4007
+ useInput(
4008
+ (input, key) => {
4009
+ const keyEvent = {
4010
+ input,
4011
+ return: key.return,
4012
+ shift: key.shift,
4013
+ ctrl: key.ctrl,
4014
+ meta: key.meta,
4015
+ backspace: key.backspace,
4016
+ delete: key.delete,
4017
+ tab: key.tab,
4018
+ upArrow: key.upArrow,
4019
+ downArrow: key.downArrow,
4020
+ leftArrow: key.leftArrow,
4021
+ rightArrow: key.rightArrow,
4022
+ escape: key.escape,
4023
+ pageUp: key.pageUp,
4024
+ pageDown: key.pageDown
4025
+ };
4026
+ const action = processMultilineKey(value, keyEvent);
4027
+ if (action.next !== null) onChange(action.next);
4028
+ if (action.submit) onSubmit(action.submitValue ?? value);
4029
+ },
4030
+ { isActive: !disabled }
4031
+ );
4032
+ const effectivePlaceholder = disabled ? placeholder ?? "\u2026waiting for response\u2026" : placeholder ?? "type a message, or /command \xB7 Ctrl+J for newline";
4033
+ const lines = value.length > 0 ? value.split("\n") : [""];
4034
+ const borderColor = disabled ? "gray" : "cyan";
4035
+ return /* @__PURE__ */ React4.createElement(Box4, { borderStyle: "round", borderColor, paddingX: 1, flexDirection: "column" }, lines.map((line, i) => {
4036
+ const isLast = i === lines.length - 1;
4037
+ const isFirst = i === 0;
4038
+ const showPlaceholder = isFirst && value.length === 0;
4039
+ return (
4040
+ // biome-ignore lint/suspicious/noArrayIndexKey: stable by construction — lines are derived from `value.split("\n")` and never reordered
4041
+ /* @__PURE__ */ React4.createElement(Box4, { key: i }, isFirst ? /* @__PURE__ */ React4.createElement(Text4, { bold: true, color: borderColor }, "you \u203A", " ") : /* @__PURE__ */ React4.createElement(Text4, { dimColor: true }, " "), showPlaceholder && isLast && !disabled ? /* @__PURE__ */ React4.createElement(Text4, { color: borderColor }, showCursor ? "\u258C" : " ") : null, showPlaceholder ? /* @__PURE__ */ React4.createElement(Text4, { dimColor: true }, effectivePlaceholder) : /* @__PURE__ */ React4.createElement(Text4, null, line), !showPlaceholder && isLast && !disabled ? /* @__PURE__ */ React4.createElement(Text4, { color: borderColor }, showCursor ? "\u258C" : " ") : null)
4042
+ );
4043
+ }));
3295
4044
  }
3296
4045
 
3297
4046
  // src/cli/ui/SlashSuggestions.tsx
@@ -3331,7 +4080,8 @@ function StatsPanel({
3331
4080
  model,
3332
4081
  prefixHash,
3333
4082
  harvestOn,
3334
- branchBudget
4083
+ branchBudget,
4084
+ balance
3335
4085
  }) {
3336
4086
  const hitPct = (summary.cacheHitRatio * 100).toFixed(1);
3337
4087
  const hitColor = summary.cacheHitRatio >= 0.7 ? "green" : summary.cacheHitRatio >= 0.4 ? "yellow" : "red";
@@ -3339,7 +4089,7 @@ function StatsPanel({
3339
4089
  const ctxMax = DEEPSEEK_CONTEXT_TOKENS[model] ?? DEFAULT_CONTEXT_TOKENS;
3340
4090
  const ctxRatio = summary.lastPromptTokens / ctxMax;
3341
4091
  const ctxColor = ctxRatio >= 0.8 ? "red" : ctxRatio >= 0.5 ? "yellow" : void 0;
3342
- return /* @__PURE__ */ React6.createElement(Box6, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1 }, /* @__PURE__ */ React6.createElement(Box6, { justifyContent: "space-between" }, /* @__PURE__ */ React6.createElement(Text6, null, /* @__PURE__ */ React6.createElement(Text6, { color: "cyan", bold: true }, "Reasonix"), /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, " \xB7 model "), /* @__PURE__ */ React6.createElement(Text6, { color: "yellow" }, model), /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, " \xB7 prefix "), /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, prefixHash), harvestOn ? /* @__PURE__ */ React6.createElement(Text6, { color: "magenta" }, " \xB7 harvest") : null, branchOn ? /* @__PURE__ */ React6.createElement(Text6, { color: "blue" }, " \xB7 branch", branchBudget) : null), /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "turns ", summary.turns, " \xB7 type /help")), /* @__PURE__ */ React6.createElement(Box6, { marginTop: 1, gap: 3 }, /* @__PURE__ */ React6.createElement(Text6, null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "cache hit "), /* @__PURE__ */ React6.createElement(Text6, { color: hitColor, bold: true }, hitPct, "%")), /* @__PURE__ */ React6.createElement(Text6, null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "cost "), /* @__PURE__ */ React6.createElement(Text6, { color: "green" }, "$", summary.totalCostUsd.toFixed(6))), /* @__PURE__ */ React6.createElement(Text6, null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "vs Claude "), /* @__PURE__ */ React6.createElement(Text6, null, "$", summary.claudeEquivalentUsd.toFixed(6))), /* @__PURE__ */ React6.createElement(Text6, null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "saving "), /* @__PURE__ */ React6.createElement(Text6, { color: "green", bold: true }, summary.savingsVsClaudePct.toFixed(1), "%")), summary.lastPromptTokens > 0 ? /* @__PURE__ */ React6.createElement(Text6, null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "ctx "), /* @__PURE__ */ React6.createElement(Text6, { color: ctxColor, bold: ctxColor !== void 0 }, formatTokens(summary.lastPromptTokens), "/", formatTokens(ctxMax)), /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, " (", (ctxRatio * 100).toFixed(0), "%)"), ctxRatio >= 0.8 ? /* @__PURE__ */ React6.createElement(Text6, { color: "red", bold: true }, " ", "\xB7 /compact") : null) : null));
4092
+ return /* @__PURE__ */ React6.createElement(Box6, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1 }, /* @__PURE__ */ React6.createElement(Box6, { justifyContent: "space-between" }, /* @__PURE__ */ React6.createElement(Text6, null, /* @__PURE__ */ React6.createElement(Text6, { color: "cyan", bold: true }, "Reasonix"), /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, " \xB7 model "), /* @__PURE__ */ React6.createElement(Text6, { color: "yellow" }, model), /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, " \xB7 prefix "), /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, prefixHash), harvestOn ? /* @__PURE__ */ React6.createElement(Text6, { color: "magenta" }, " \xB7 harvest") : null, branchOn ? /* @__PURE__ */ React6.createElement(Text6, { color: "blue" }, " \xB7 branch", branchBudget) : null), /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "turns ", summary.turns, " \xB7 type /help")), /* @__PURE__ */ React6.createElement(Box6, { marginTop: 1, gap: 3 }, /* @__PURE__ */ React6.createElement(Text6, null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "cache hit "), /* @__PURE__ */ React6.createElement(Text6, { color: hitColor, bold: true }, hitPct, "%")), /* @__PURE__ */ React6.createElement(Text6, null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "cost "), /* @__PURE__ */ React6.createElement(Text6, { color: "green", bold: true }, "$", summary.totalCostUsd.toFixed(6)), /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, " (in ", "$", summary.totalInputCostUsd.toFixed(6), " \xB7 out ", "$", summary.totalOutputCostUsd.toFixed(6), ")")), summary.lastPromptTokens > 0 ? /* @__PURE__ */ React6.createElement(Text6, null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "ctx "), /* @__PURE__ */ React6.createElement(Text6, { color: ctxColor, bold: ctxColor !== void 0 }, formatTokens(summary.lastPromptTokens), "/", formatTokens(ctxMax)), /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, " (", (ctxRatio * 100).toFixed(0), "%)"), ctxRatio >= 0.8 ? /* @__PURE__ */ React6.createElement(Text6, { color: "red", bold: true }, " ", "\xB7 /compact") : null) : null, balance ? /* @__PURE__ */ React6.createElement(Text6, null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "balance "), /* @__PURE__ */ React6.createElement(Text6, { color: balance.total < 1 ? "red" : balance.total < 5 ? "yellow" : "green", bold: true }, balance.currency === "USD" ? "$" : "", balance.total.toFixed(2), balance.currency !== "USD" ? ` ${balance.currency}` : "")) : null));
3343
4093
  }
3344
4094
  function formatTokens(n) {
3345
4095
  if (n < 1e3) return String(n);
@@ -3368,7 +4118,8 @@ var SLASH_COMMANDS = [
3368
4118
  { cmd: "sessions", summary: "list saved sessions (current marked with \u25B8)" },
3369
4119
  { cmd: "forget", summary: "delete the current session from disk" },
3370
4120
  { cmd: "setup", summary: "reminds you to exit and run `reasonix setup`" },
3371
- { cmd: "clear", summary: "clear the visible scrollback (log + session kept)" },
4121
+ { cmd: "clear", summary: "clear visible scrollback only (log/context kept)" },
4122
+ { cmd: "new", summary: "start a fresh conversation (clear context + scrollback)" },
3372
4123
  { cmd: "exit", summary: "quit the TUI" },
3373
4124
  // Code-mode only
3374
4125
  { cmd: "apply", summary: "commit pending edit blocks to disk", contextual: "code" },
@@ -3401,7 +4152,18 @@ function handleSlash(cmd, args, loop, ctx = {}) {
3401
4152
  case "quit":
3402
4153
  return { exit: true };
3403
4154
  case "clear":
3404
- return { clear: true };
4155
+ return {
4156
+ clear: true,
4157
+ info: "\u25B8 cleared visible scrollback only. Context (message log) is intact \u2014 next turn still sees everything. Use /new to start fresh, or /forget to delete the session entirely."
4158
+ };
4159
+ case "new":
4160
+ case "reset": {
4161
+ const { dropped } = loop.clearLog();
4162
+ return {
4163
+ clear: true,
4164
+ info: `\u25B8 new conversation \u2014 dropped ${dropped} message(s) from context. Same session, fresh slate.`
4165
+ };
4166
+ }
3405
4167
  case "help":
3406
4168
  case "?":
3407
4169
  return {
@@ -3425,7 +4187,8 @@ function handleSlash(cmd, args, loop, ctx = {}) {
3425
4187
  ' /commit "msg" (code mode) git add -A && git commit -m "msg"',
3426
4188
  " /sessions list saved sessions (current is marked with \u25B8)",
3427
4189
  " /forget delete the current session from disk",
3428
- " /clear clear displayed history (log + session kept)",
4190
+ " /new start fresh: drop all context + clear scrollback",
4191
+ " /clear clear displayed scrollback only (context kept \u2014 model still sees it)",
3429
4192
  " /exit quit",
3430
4193
  "",
3431
4194
  "Presets:",
@@ -3785,24 +4548,30 @@ function App({
3785
4548
  tools,
3786
4549
  mcpSpecs,
3787
4550
  mcpServers,
4551
+ progressSink,
3788
4552
  codeMode
3789
4553
  }) {
3790
4554
  const { exit } = useApp();
3791
- const [historical, setHistorical] = useState2([]);
3792
- const [streaming, setStreaming] = useState2(null);
3793
- const [input, setInput] = useState2("");
3794
- const [busy, setBusy] = useState2(false);
4555
+ const [historical, setHistorical] = useState3([]);
4556
+ const [streaming, setStreaming] = useState3(null);
4557
+ const [input, setInput] = useState3("");
4558
+ const [busy, setBusy] = useState3(false);
3795
4559
  const abortedThisTurn = useRef(false);
3796
- const [ongoingTool, setOngoingTool] = useState2(null);
4560
+ const [ongoingTool, setOngoingTool] = useState3(null);
4561
+ const [toolProgress, setToolProgress] = useState3(null);
4562
+ const [statusLine, setStatusLine] = useState3(null);
4563
+ const [balance, setBalance] = useState3(null);
3797
4564
  const lastEditSnapshots = useRef(null);
3798
4565
  const pendingEdits = useRef([]);
3799
4566
  const promptHistory = useRef([]);
3800
4567
  const historyCursor = useRef(-1);
3801
4568
  const toolHistoryRef = useRef([]);
3802
- const [slashSelected, setSlashSelected] = useState2(0);
3803
- const [summary, setSummary] = useState2({
4569
+ const [slashSelected, setSlashSelected] = useState3(0);
4570
+ const [summary, setSummary] = useState3({
3804
4571
  turns: 0,
3805
4572
  totalCostUsd: 0,
4573
+ totalInputCostUsd: 0,
4574
+ totalOutputCostUsd: 0,
3806
4575
  claudeEquivalentUsd: 0,
3807
4576
  savingsVsClaudePct: 0,
3808
4577
  cacheHitRatio: 0,
@@ -3817,7 +4586,7 @@ function App({
3817
4586
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
3818
4587
  });
3819
4588
  }
3820
- useEffect2(() => {
4589
+ useEffect3(() => {
3821
4590
  return () => {
3822
4591
  transcriptRef.current?.end();
3823
4592
  };
@@ -3826,7 +4595,7 @@ function App({
3826
4595
  if (!input.startsWith("/") || input.includes(" ")) return null;
3827
4596
  return suggestSlashCommands(input.slice(1), !!codeMode);
3828
4597
  }, [input, codeMode]);
3829
- useEffect2(() => {
4598
+ useEffect3(() => {
3830
4599
  setSlashSelected((prev) => {
3831
4600
  if (!slashMatches || slashMatches.length === 0) return 0;
3832
4601
  if (prev >= slashMatches.length) return slashMatches.length - 1;
@@ -3845,8 +4614,33 @@ function App({
3845
4614
  loopRef.current = l;
3846
4615
  return l;
3847
4616
  }, [model, system, harvest2, branch, session, tools]);
4617
+ useEffect3(() => {
4618
+ let cancelled = false;
4619
+ void (async () => {
4620
+ const bal = await loop.client.getBalance().catch(() => null);
4621
+ if (cancelled || !bal || !bal.balance_infos.length) return;
4622
+ const primary = bal.balance_infos[0];
4623
+ setBalance({ currency: primary.currency, total: Number(primary.total_balance) });
4624
+ })();
4625
+ return () => {
4626
+ cancelled = true;
4627
+ };
4628
+ }, [loop]);
4629
+ useEffect3(() => {
4630
+ if (!progressSink) return;
4631
+ progressSink.current = (info) => {
4632
+ setToolProgress({
4633
+ progress: info.progress,
4634
+ total: info.total,
4635
+ message: info.message
4636
+ });
4637
+ };
4638
+ return () => {
4639
+ if (progressSink.current) progressSink.current = null;
4640
+ };
4641
+ }, [progressSink]);
3848
4642
  const sessionBannerShown = useRef(false);
3849
- useEffect2(() => {
4643
+ useEffect3(() => {
3850
4644
  if (sessionBannerShown.current) return;
3851
4645
  sessionBannerShown.current = true;
3852
4646
  if (!session) {
@@ -3878,7 +4672,7 @@ function App({
3878
4672
  ]);
3879
4673
  }
3880
4674
  }, [session, loop]);
3881
- useInput((_input, key) => {
4675
+ useInput2((_input, key) => {
3882
4676
  if (key.escape && busy) {
3883
4677
  if (abortedThisTurn.current) return;
3884
4678
  abortedThisTurn.current = true;
@@ -3993,6 +4787,16 @@ function App({
3993
4787
  exit();
3994
4788
  return;
3995
4789
  }
4790
+ if (result.clear && result.info) {
4791
+ setHistorical([
4792
+ {
4793
+ id: `sys-${Date.now()}`,
4794
+ role: "info",
4795
+ text: result.info
4796
+ }
4797
+ ]);
4798
+ return;
4799
+ }
3996
4800
  if (result.clear) {
3997
4801
  setHistorical([]);
3998
4802
  return;
@@ -4041,7 +4845,12 @@ function App({
4041
4845
  try {
4042
4846
  for await (const ev of loop.step(text)) {
4043
4847
  writeTranscript(ev);
4044
- if (ev.role === "assistant_delta") {
4848
+ if (ev.role !== "status") {
4849
+ setStatusLine((cur) => cur ? null : cur);
4850
+ }
4851
+ if (ev.role === "status") {
4852
+ setStatusLine(ev.content);
4853
+ } else if (ev.role === "assistant_delta") {
4045
4854
  if (ev.content) contentBuf.current += ev.content;
4046
4855
  if (ev.reasoningDelta) reasoningBuf.current += ev.reasoningDelta;
4047
4856
  } else if (ev.role === "branch_start") {
@@ -4065,6 +4874,7 @@ function App({
4065
4874
  flush();
4066
4875
  const repairNote = ev.repair ? describeRepair(ev.repair) : "";
4067
4876
  setStreaming(null);
4877
+ setSummary(loop.stats.summary());
4068
4878
  const finalText = ev.content || streamRef.text;
4069
4879
  setHistorical((prev) => [
4070
4880
  ...prev,
@@ -4096,9 +4906,11 @@ function App({
4096
4906
  }
4097
4907
  } else if (ev.role === "tool_start") {
4098
4908
  setOngoingTool({ name: ev.toolName ?? "?", args: ev.toolArgs });
4909
+ setToolProgress(null);
4099
4910
  } else if (ev.role === "tool") {
4100
4911
  flush();
4101
4912
  setOngoingTool(null);
4913
+ setToolProgress(null);
4102
4914
  toolHistoryRef.current.push({
4103
4915
  toolName: ev.toolName ?? "?",
4104
4916
  text: ev.content
@@ -4129,8 +4941,17 @@ function App({
4129
4941
  clearInterval(timer);
4130
4942
  setStreaming(null);
4131
4943
  setOngoingTool(null);
4944
+ setToolProgress(null);
4945
+ setStatusLine(null);
4132
4946
  setSummary(loop.stats.summary());
4133
4947
  setBusy(false);
4948
+ void (async () => {
4949
+ const bal = await loop.client.getBalance().catch(() => null);
4950
+ if (bal?.balance_infos.length) {
4951
+ const p = bal.balance_infos[0];
4952
+ setBalance({ currency: p.currency, total: Number(p.total_balance) });
4953
+ }
4954
+ })();
4134
4955
  }
4135
4956
  },
4136
4957
  [
@@ -4154,14 +4975,33 @@ function App({
4154
4975
  model: loop.model,
4155
4976
  prefixHash,
4156
4977
  harvestOn: loop.harvestEnabled,
4157
- branchBudget: loop.branchOptions.budget
4978
+ branchBudget: loop.branchOptions.budget,
4979
+ balance
4158
4980
  }
4159
- ), /* @__PURE__ */ React7.createElement(Static, { items: historical }, (item) => /* @__PURE__ */ React7.createElement(EventRow, { key: item.id, event: item })), streaming ? /* @__PURE__ */ React7.createElement(Box7, { marginY: 1 }, /* @__PURE__ */ React7.createElement(EventRow, { event: streaming })) : null, ongoingTool ? /* @__PURE__ */ React7.createElement(OngoingToolRow, { tool: ongoingTool }) : null, /* @__PURE__ */ React7.createElement(PromptInput, { value: input, onChange: setInput, onSubmit: handleSubmit, disabled: busy }), /* @__PURE__ */ React7.createElement(SlashSuggestions, { matches: slashMatches, selectedIndex: slashSelected }));
4981
+ ), /* @__PURE__ */ React7.createElement(Static, { items: historical }, (item) => /* @__PURE__ */ React7.createElement(EventRow, { key: item.id, event: item })), streaming ? /* @__PURE__ */ React7.createElement(Box7, { marginY: 1 }, /* @__PURE__ */ React7.createElement(EventRow, { event: streaming })) : null, ongoingTool ? /* @__PURE__ */ React7.createElement(OngoingToolRow, { tool: ongoingTool, progress: toolProgress }) : null, !ongoingTool && statusLine ? /* @__PURE__ */ React7.createElement(StatusRow, { text: statusLine }) : null, busy && !streaming && !ongoingTool && !statusLine ? /* @__PURE__ */ React7.createElement(StatusRow, { text: "processing\u2026" }) : null, /* @__PURE__ */ React7.createElement(PromptInput, { value: input, onChange: setInput, onSubmit: handleSubmit, disabled: busy }), /* @__PURE__ */ React7.createElement(SlashSuggestions, { matches: slashMatches, selectedIndex: slashSelected }));
4160
4982
  }
4161
- function OngoingToolRow({ tool }) {
4162
- const [tick, setTick] = useState2(0);
4163
- const [elapsed, setElapsed] = useState2(0);
4164
- useEffect2(() => {
4983
+ function StatusRow({ text }) {
4984
+ const [tick, setTick] = useState3(0);
4985
+ const [elapsed, setElapsed] = useState3(0);
4986
+ useEffect3(() => {
4987
+ const start = Date.now();
4988
+ const frameId = setInterval(() => setTick((t) => t + 1), 120);
4989
+ const secId = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1e3)), 1e3);
4990
+ return () => {
4991
+ clearInterval(frameId);
4992
+ clearInterval(secId);
4993
+ };
4994
+ }, []);
4995
+ const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
4996
+ return /* @__PURE__ */ React7.createElement(Box7, { marginY: 1 }, /* @__PURE__ */ React7.createElement(Text7, { color: "magenta" }, frames[tick % frames.length]), /* @__PURE__ */ React7.createElement(Text7, { color: "magenta" }, ` ${text}`), /* @__PURE__ */ React7.createElement(Text7, { dimColor: true }, ` ${elapsed}s`));
4997
+ }
4998
+ function OngoingToolRow({
4999
+ tool,
5000
+ progress
5001
+ }) {
5002
+ const [tick, setTick] = useState3(0);
5003
+ const [elapsed, setElapsed] = useState3(0);
5004
+ useEffect3(() => {
4165
5005
  const start = Date.now();
4166
5006
  const frameId = setInterval(() => setTick((t) => t + 1), 120);
4167
5007
  const secId = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1e3)), 1e3);
@@ -4172,7 +5012,19 @@ function OngoingToolRow({ tool }) {
4172
5012
  }, []);
4173
5013
  const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
4174
5014
  const summary = summarizeToolArgs(tool.name, tool.args);
4175
- return /* @__PURE__ */ React7.createElement(Box7, { marginY: 1, flexDirection: "column" }, /* @__PURE__ */ React7.createElement(Box7, null, /* @__PURE__ */ React7.createElement(Text7, { color: "cyan" }, frames[tick % frames.length]), /* @__PURE__ */ React7.createElement(Text7, { color: "yellow" }, ` tool<${tool.name}> running\u2026`), /* @__PURE__ */ React7.createElement(Text7, { dimColor: true }, ` ${elapsed}s`)), summary ? /* @__PURE__ */ React7.createElement(Box7, { paddingLeft: 2 }, /* @__PURE__ */ React7.createElement(Text7, { dimColor: true }, summary)) : null);
5015
+ return /* @__PURE__ */ React7.createElement(Box7, { marginY: 1, flexDirection: "column" }, /* @__PURE__ */ React7.createElement(Box7, null, /* @__PURE__ */ React7.createElement(Text7, { color: "cyan" }, frames[tick % frames.length]), /* @__PURE__ */ React7.createElement(Text7, { color: "yellow" }, ` tool<${tool.name}> running\u2026`), /* @__PURE__ */ React7.createElement(Text7, { dimColor: true }, ` ${elapsed}s`)), progress ? /* @__PURE__ */ React7.createElement(Box7, { paddingLeft: 2 }, /* @__PURE__ */ React7.createElement(Text7, { color: "cyan" }, renderProgressLine(progress))) : null, summary ? /* @__PURE__ */ React7.createElement(Box7, { paddingLeft: 2 }, /* @__PURE__ */ React7.createElement(Text7, { dimColor: true }, summary)) : null);
5016
+ }
5017
+ function renderProgressLine(p) {
5018
+ const msg = p.message ? ` ${p.message}` : "";
5019
+ if (p.total && p.total > 0) {
5020
+ const ratio = Math.max(0, Math.min(1, p.progress / p.total));
5021
+ const width = 20;
5022
+ const filled = Math.round(ratio * width);
5023
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
5024
+ const pct2 = (ratio * 100).toFixed(0);
5025
+ return `[${bar}] ${p.progress}/${p.total} ${pct2}%${msg}`;
5026
+ }
5027
+ return `progress: ${p.progress}${msg}`;
4176
5028
  }
4177
5029
  function summarizeToolArgs(name, args) {
4178
5030
  if (!args || args === "{}") return "";
@@ -4257,11 +5109,11 @@ function describeRepair(repair) {
4257
5109
 
4258
5110
  // src/cli/ui/Setup.tsx
4259
5111
  import { Box as Box8, Text as Text8, useApp as useApp2 } from "ink";
4260
- import TextInput2 from "ink-text-input";
4261
- import React8, { useState as useState3 } from "react";
5112
+ import TextInput from "ink-text-input";
5113
+ import React8, { useState as useState4 } from "react";
4262
5114
  function Setup({ onReady }) {
4263
- const [value, setValue] = useState3("");
4264
- const [error, setError] = useState3(null);
5115
+ const [value, setValue] = useState4("");
5116
+ const [error, setError] = useState4(null);
4265
5117
  const { exit } = useApp2();
4266
5118
  const handleSubmit = (raw) => {
4267
5119
  const trimmed = raw.trim();
@@ -4283,7 +5135,7 @@ function Setup({ onReady }) {
4283
5135
  onReady(trimmed);
4284
5136
  };
4285
5137
  return /* @__PURE__ */ React8.createElement(Box8, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1 }, /* @__PURE__ */ React8.createElement(Text8, { bold: true, color: "cyan" }, "Welcome to Reasonix."), /* @__PURE__ */ React8.createElement(Box8, { marginTop: 1 }, /* @__PURE__ */ React8.createElement(Text8, null, "Paste your DeepSeek API key to get started.")), /* @__PURE__ */ React8.createElement(Text8, { dimColor: true }, "Get one (free credit on signup): https://platform.deepseek.com/api_keys"), /* @__PURE__ */ React8.createElement(Text8, { dimColor: true }, "Saved locally to ", defaultConfigPath()), /* @__PURE__ */ React8.createElement(Box8, { marginTop: 1 }, /* @__PURE__ */ React8.createElement(Text8, { bold: true, color: "cyan" }, "key \u203A "), /* @__PURE__ */ React8.createElement(
4286
- TextInput2,
5138
+ TextInput,
4287
5139
  {
4288
5140
  value,
4289
5141
  onChange: setValue,
@@ -4295,8 +5147,8 @@ function Setup({ onReady }) {
4295
5147
  }
4296
5148
 
4297
5149
  // src/cli/commands/chat.tsx
4298
- function Root({ initialKey, tools, mcpSpecs, mcpServers, ...appProps }) {
4299
- const [key, setKey] = useState4(initialKey);
5150
+ function Root({ initialKey, tools, mcpSpecs, mcpServers, progressSink, ...appProps }) {
5151
+ const [key, setKey] = useState5(initialKey);
4300
5152
  if (!key) {
4301
5153
  return /* @__PURE__ */ React9.createElement(
4302
5154
  Setup,
@@ -4321,6 +5173,7 @@ function Root({ initialKey, tools, mcpSpecs, mcpServers, ...appProps }) {
4321
5173
  tools,
4322
5174
  mcpSpecs,
4323
5175
  mcpServers,
5176
+ progressSink,
4324
5177
  codeMode: appProps.codeMode
4325
5178
  }
4326
5179
  );
@@ -4333,9 +5186,10 @@ async function chatCommand(opts) {
4333
5186
  const successfulSpecs = [];
4334
5187
  const failedSpecs = [];
4335
5188
  const mcpServers = [];
4336
- let tools;
5189
+ const progressSink = { current: null };
5190
+ let tools = opts.seedTools;
4337
5191
  if (requestedSpecs.length > 0) {
4338
- tools = new ToolRegistry();
5192
+ if (!tools) tools = new ToolRegistry();
4339
5193
  for (const raw of requestedSpecs) {
4340
5194
  try {
4341
5195
  const spec = parseMcpSpec(raw);
@@ -4343,7 +5197,11 @@ async function chatCommand(opts) {
4343
5197
  const transport = spec.transport === "sse" ? new SseTransport({ url: spec.url }) : new StdioTransport({ command: spec.command, args: spec.args });
4344
5198
  const mcp2 = new McpClient({ transport });
4345
5199
  await mcp2.initialize();
4346
- const bridge = await bridgeMcpTools(mcp2, { registry: tools, namePrefix: prefix });
5200
+ const bridge = await bridgeMcpTools(mcp2, {
5201
+ registry: tools,
5202
+ namePrefix: prefix,
5203
+ onProgress: (info) => progressSink.current?.(info)
5204
+ });
4347
5205
  let report;
4348
5206
  try {
4349
5207
  report = await inspectMcpServer(mcp2);
@@ -4381,7 +5239,7 @@ async function chatCommand(opts) {
4381
5239
  );
4382
5240
  }
4383
5241
  }
4384
- if (successfulSpecs.length === 0) {
5242
+ if (successfulSpecs.length === 0 && !opts.seedTools) {
4385
5243
  tools = void 0;
4386
5244
  }
4387
5245
  }
@@ -4394,6 +5252,7 @@ async function chatCommand(opts) {
4394
5252
  tools,
4395
5253
  mcpSpecs,
4396
5254
  mcpServers,
5255
+ progressSink,
4397
5256
  ...opts
4398
5257
  }
4399
5258
  ),
@@ -4407,14 +5266,15 @@ async function chatCommand(opts) {
4407
5266
  }
4408
5267
 
4409
5268
  // src/cli/commands/code.tsx
4410
- import { basename, resolve as resolve3 } from "path";
5269
+ import { basename, resolve as resolve4 } from "path";
4411
5270
  async function codeCommand(opts = {}) {
4412
5271
  const { codeSystemPrompt: codeSystemPrompt2 } = await import("./prompt-MMANQ36Z.js");
4413
- const rootDir = resolve3(opts.dir ?? process.cwd());
5272
+ const rootDir = resolve4(opts.dir ?? process.cwd());
4414
5273
  const session = opts.noSession ? void 0 : `code-${sanitizeName(basename(rootDir))}`;
4415
- const fsSpec = `filesystem=npx -y @modelcontextprotocol/server-filesystem ${quoteIfNeeded(rootDir)}`;
5274
+ const tools = new ToolRegistry();
5275
+ registerFilesystemTools(tools, { rootDir });
4416
5276
  process.stderr.write(
4417
- `\u25B8 reasonix code: rooted at ${rootDir}, session "${session ?? "(ephemeral)"}"
5277
+ `\u25B8 reasonix code: rooted at ${rootDir}, session "${session ?? "(ephemeral)"}" \xB7 ${tools.size} native fs tool(s)
4418
5278
  `
4419
5279
  );
4420
5280
  await chatCommand({
@@ -4424,13 +5284,10 @@ async function codeCommand(opts = {}) {
4424
5284
  system: codeSystemPrompt2(rootDir),
4425
5285
  transcript: opts.transcript,
4426
5286
  session,
4427
- mcp: [fsSpec],
5287
+ seedTools: tools,
4428
5288
  codeMode: { rootDir }
4429
5289
  });
4430
5290
  }
4431
- function quoteIfNeeded(s) {
4432
- return /\s|"/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
4433
- }
4434
5291
 
4435
5292
  // src/cli/commands/diff.ts
4436
5293
  import { writeFileSync as writeFileSync4 } from "fs";
@@ -4439,8 +5296,8 @@ import { render as render2 } from "ink";
4439
5296
  import React12 from "react";
4440
5297
 
4441
5298
  // src/cli/ui/DiffApp.tsx
4442
- import { Box as Box10, Static as Static2, Text as Text10, useApp as useApp3, useInput as useInput2 } from "ink";
4443
- import React11, { useState as useState5 } from "react";
5299
+ import { Box as Box10, Static as Static2, Text as Text10, useApp as useApp3, useInput as useInput3 } from "ink";
5300
+ import React11, { useState as useState6 } from "react";
4444
5301
 
4445
5302
  // src/cli/ui/RecordView.tsx
4446
5303
  import { Box as Box9, Text as Text9 } from "ink";
@@ -4483,8 +5340,8 @@ function DiffApp({ report }) {
4483
5340
  const { exit } = useApp3();
4484
5341
  const maxIdx = Math.max(0, report.pairs.length - 1);
4485
5342
  const initialIdx = report.firstDivergenceTurn ? report.pairs.findIndex((p) => p.turn === report.firstDivergenceTurn) : 0;
4486
- const [idx, setIdx] = useState5(Math.max(0, initialIdx));
4487
- useInput2((input, key) => {
5343
+ const [idx, setIdx] = useState6(Math.max(0, initialIdx));
5344
+ useInput3((input, key) => {
4488
5345
  if (input === "q" || key.ctrl && input === "c") {
4489
5346
  exit();
4490
5347
  return;
@@ -4728,13 +5585,13 @@ import { render as render3 } from "ink";
4728
5585
  import React14 from "react";
4729
5586
 
4730
5587
  // src/cli/ui/ReplayApp.tsx
4731
- import { Box as Box11, Static as Static3, Text as Text11, useApp as useApp4, useInput as useInput3 } from "ink";
4732
- import React13, { useMemo as useMemo2, useState as useState6 } from "react";
5588
+ import { Box as Box11, Static as Static3, Text as Text11, useApp as useApp4, useInput as useInput4 } from "ink";
5589
+ import React13, { useMemo as useMemo2, useState as useState7 } from "react";
4733
5590
  function ReplayApp({ meta, pages }) {
4734
5591
  const { exit } = useApp4();
4735
5592
  const maxIdx = Math.max(0, pages.length - 1);
4736
- const [idx, setIdx] = useState6(maxIdx);
4737
- useInput3((input, key) => {
5593
+ const [idx, setIdx] = useState7(maxIdx);
5594
+ useInput4((input, key) => {
4738
5595
  if (input === "q" || key.ctrl && input === "c") {
4739
5596
  exit();
4740
5597
  return;
@@ -4757,6 +5614,8 @@ function ReplayApp({ meta, pages }) {
4757
5614
  const summary = {
4758
5615
  turns: cumStats.turns,
4759
5616
  totalCostUsd: cumStats.totalCostUsd,
5617
+ totalInputCostUsd: cumStats.totalInputCostUsd,
5618
+ totalOutputCostUsd: cumStats.totalOutputCostUsd,
4760
5619
  claudeEquivalentUsd: cumStats.claudeEquivalentUsd,
4761
5620
  savingsVsClaudePct: cumStats.savingsVsClaudePct,
4762
5621
  cacheHitRatio: cumStats.cacheHitRatio,
@@ -5089,13 +5948,13 @@ import { render as render4 } from "ink";
5089
5948
  import React17 from "react";
5090
5949
 
5091
5950
  // src/cli/ui/Wizard.tsx
5092
- import { Box as Box13, Text as Text13, useApp as useApp5, useInput as useInput5 } from "ink";
5093
- import TextInput3 from "ink-text-input";
5094
- import React16, { useState as useState8 } from "react";
5951
+ import { Box as Box13, Text as Text13, useApp as useApp5, useInput as useInput6 } from "ink";
5952
+ import TextInput2 from "ink-text-input";
5953
+ import React16, { useState as useState9 } from "react";
5095
5954
 
5096
5955
  // src/cli/ui/Select.tsx
5097
- import { Box as Box12, Text as Text12, useInput as useInput4 } from "ink";
5098
- import React15, { useState as useState7 } from "react";
5956
+ import { Box as Box12, Text as Text12, useInput as useInput5 } from "ink";
5957
+ import React15, { useState as useState8 } from "react";
5099
5958
  function SingleSelect({
5100
5959
  items,
5101
5960
  initialValue,
@@ -5106,8 +5965,8 @@ function SingleSelect({
5106
5965
  0,
5107
5966
  items.findIndex((i) => i.value === initialValue && !i.disabled)
5108
5967
  );
5109
- const [index, setIndex] = useState7(initialIndex === -1 ? 0 : initialIndex);
5110
- useInput4((_input, key) => {
5968
+ const [index, setIndex] = useState8(initialIndex === -1 ? 0 : initialIndex);
5969
+ useInput5((_input, key) => {
5111
5970
  if (key.upArrow) {
5112
5971
  setIndex((i) => findNextEnabled(items, i, -1));
5113
5972
  } else if (key.downArrow) {
@@ -5136,12 +5995,12 @@ function MultiSelect({
5136
5995
  onCancel,
5137
5996
  footer
5138
5997
  }) {
5139
- const [index, setIndex] = useState7(() => {
5998
+ const [index, setIndex] = useState8(() => {
5140
5999
  const first = items.findIndex((i) => !i.disabled);
5141
6000
  return first === -1 ? 0 : first;
5142
6001
  });
5143
- const [selected, setSelected] = useState7(new Set(initialSelected));
5144
- useInput4((input, key) => {
6002
+ const [selected, setSelected] = useState8(new Set(initialSelected));
6003
+ useInput5((input, key) => {
5145
6004
  if (key.upArrow) {
5146
6005
  setIndex((i) => findNextEnabled(items, i, -1));
5147
6006
  } else if (key.downArrow) {
@@ -5219,15 +6078,15 @@ var PRESET_DESCRIPTIONS = {
5219
6078
  var CATALOG_BY_NAME = new Map(MCP_CATALOG.map((e) => [e.name, e]));
5220
6079
  function Wizard({ onComplete, onCancel, existingApiKey, initial }) {
5221
6080
  const { exit } = useApp5();
5222
- const [step, setStep] = useState8(existingApiKey ? "preset" : "apiKey");
5223
- const [data, setData] = useState8({
6081
+ const [step, setStep] = useState9(existingApiKey ? "preset" : "apiKey");
6082
+ const [data, setData] = useState9({
5224
6083
  apiKey: existingApiKey ?? "",
5225
6084
  preset: initial?.preset ?? "fast",
5226
6085
  selectedCatalog: deriveInitialCatalog(initial?.mcp ?? []),
5227
6086
  catalogArgs: {}
5228
6087
  });
5229
- const [error, setError] = useState8(null);
5230
- useInput5((_input, key) => {
6088
+ const [error, setError] = useState9(null);
6089
+ useInput6((_input, key) => {
5231
6090
  if (key.escape && step !== "saved" && onCancel) onCancel();
5232
6091
  });
5233
6092
  if (step === "apiKey") {
@@ -5343,9 +6202,9 @@ function ApiKeyStep({
5343
6202
  error,
5344
6203
  onError
5345
6204
  }) {
5346
- const [value, setValue] = useState8("");
6205
+ const [value, setValue] = useState9("");
5347
6206
  return /* @__PURE__ */ React16.createElement(Box13, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1 }, /* @__PURE__ */ React16.createElement(Text13, { bold: true, color: "cyan" }, "Welcome to Reasonix."), /* @__PURE__ */ React16.createElement(Box13, { marginTop: 1 }, /* @__PURE__ */ React16.createElement(Text13, null, "Paste your DeepSeek API key to get started.")), /* @__PURE__ */ React16.createElement(Text13, { dimColor: true }, "Get one (free credit on signup): https://platform.deepseek.com/api_keys"), /* @__PURE__ */ React16.createElement(Text13, { dimColor: true }, "Saved locally to ", defaultConfigPath()), /* @__PURE__ */ React16.createElement(Box13, { marginTop: 1 }, /* @__PURE__ */ React16.createElement(Text13, { bold: true, color: "cyan" }, "key \u203A "), /* @__PURE__ */ React16.createElement(
5348
- TextInput3,
6207
+ TextInput2,
5349
6208
  {
5350
6209
  value,
5351
6210
  onChange: setValue,
@@ -5369,9 +6228,9 @@ function McpArgsStep({
5369
6228
  onSubmit,
5370
6229
  onError
5371
6230
  }) {
5372
- const [value, setValue] = useState8("");
6231
+ const [value, setValue] = useState9("");
5373
6232
  return /* @__PURE__ */ React16.createElement(StepFrame, { title: `Configure ${entry.name}`, step: 2, total: 3 }, /* @__PURE__ */ React16.createElement(Box13, { flexDirection: "column" }, /* @__PURE__ */ React16.createElement(Text13, null, entry.summary), entry.note ? /* @__PURE__ */ React16.createElement(Box13, { marginTop: 1 }, /* @__PURE__ */ React16.createElement(Text13, { dimColor: true }, entry.note)) : null, /* @__PURE__ */ React16.createElement(Box13, { marginTop: 1 }, /* @__PURE__ */ React16.createElement(Text13, null, "Required parameter: "), /* @__PURE__ */ React16.createElement(Text13, { bold: true }, entry.userArgs)), /* @__PURE__ */ React16.createElement(Box13, { marginTop: 1 }, /* @__PURE__ */ React16.createElement(Text13, { bold: true, color: "cyan" }, entry.userArgs, " \u203A "), /* @__PURE__ */ React16.createElement(
5374
- TextInput3,
6233
+ TextInput2,
5375
6234
  {
5376
6235
  value,
5377
6236
  onChange: setValue,
@@ -5389,13 +6248,13 @@ function McpArgsStep({
5389
6248
  )), error ? /* @__PURE__ */ React16.createElement(Box13, { marginTop: 1 }, /* @__PURE__ */ React16.createElement(Text13, { color: "red" }, error)) : null));
5390
6249
  }
5391
6250
  function ReviewConfirm({ onConfirm }) {
5392
- useInput5((_i, key) => {
6251
+ useInput6((_i, key) => {
5393
6252
  if (key.return) onConfirm();
5394
6253
  });
5395
6254
  return null;
5396
6255
  }
5397
6256
  function ExitOnEnter({ onExit }) {
5398
- useInput5((_i, key) => {
6257
+ useInput6((_i, key) => {
5399
6258
  if (key.return) onExit();
5400
6259
  });
5401
6260
  return null;
@@ -5452,10 +6311,10 @@ function buildSpec(name, argsByName) {
5452
6311
  const entry = CATALOG_BY_NAME.get(name);
5453
6312
  if (!entry) return name;
5454
6313
  const userArg = entry.userArgs ? argsByName[name] : void 0;
5455
- const tail = userArg ? ` ${quoteIfNeeded2(userArg)}` : "";
6314
+ const tail = userArg ? ` ${quoteIfNeeded(userArg)}` : "";
5456
6315
  return `${entry.name}=npx -y ${entry.package}${tail}`;
5457
6316
  }
5458
- function quoteIfNeeded2(s) {
6317
+ function quoteIfNeeded(s) {
5459
6318
  return /\s|"/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
5460
6319
  }
5461
6320