reasonix 0.4.1 → 0.4.5

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
@@ -722,7 +722,19 @@ function scavengeToolCalls(reasoningContent, opts) {
722
722
  const max = opts.maxCalls ?? 4;
723
723
  const notes = [];
724
724
  const out = [];
725
- for (const candidate of iterateJsonObjects(reasoningContent)) {
725
+ for (const invoke of iterateDsmlInvokes(reasoningContent)) {
726
+ if (out.length >= max) break;
727
+ if (!opts.allowedNames.has(invoke.name)) continue;
728
+ out.push({
729
+ function: {
730
+ name: invoke.name,
731
+ arguments: JSON.stringify(invoke.args)
732
+ }
733
+ });
734
+ notes.push(`scavenged DSML call: ${invoke.name}`);
735
+ }
736
+ const nonDsml = stripDsmlBlocks(reasoningContent);
737
+ for (const candidate of iterateJsonObjects(nonDsml)) {
726
738
  if (out.length >= max) break;
727
739
  const call = coerceToToolCall(candidate, opts.allowedNames);
728
740
  if (call) {
@@ -732,6 +744,40 @@ function scavengeToolCalls(reasoningContent, opts) {
732
744
  }
733
745
  return { calls: out, notes };
734
746
  }
747
+ function stripDsmlBlocks(text) {
748
+ let out = text;
749
+ out = out.replace(/<[||]DSML[||]function_calls>[\s\S]*?<\/?[||]DSML[||]function_calls>/g, "");
750
+ out = out.replace(/<[||]DSML[||]invoke\s+[^>]*>[\s\S]*?<\/[||]DSML[||]invoke>/g, "");
751
+ return out;
752
+ }
753
+ function* iterateDsmlInvokes(text) {
754
+ const INVOKE_RE = /<[||]DSML[||]invoke\s+name="([^"]+)">([\s\S]*?)<\/[||]DSML[||]invoke>/g;
755
+ for (const match of text.matchAll(INVOKE_RE)) {
756
+ const name = match[1];
757
+ const body = match[2];
758
+ if (!name || body === void 0) continue;
759
+ yield { name, args: parseDsmlParameters(body) };
760
+ }
761
+ }
762
+ function parseDsmlParameters(body) {
763
+ const PARAM_RE = /<[||]DSML[||]parameter\s+name="([^"]+)"(?:\s+string="(true|false)")?\s*>([\s\S]*?)<\/[||]DSML[||]parameter>/g;
764
+ const args = {};
765
+ for (const m of body.matchAll(PARAM_RE)) {
766
+ const key = m[1];
767
+ const stringFlag = m[2];
768
+ const raw = (m[3] ?? "").trim();
769
+ if (!key) continue;
770
+ if (stringFlag === "false") {
771
+ try {
772
+ args[key] = JSON.parse(raw);
773
+ continue;
774
+ } catch {
775
+ }
776
+ }
777
+ args[key] = raw;
778
+ }
779
+ return args;
780
+ }
735
781
  function* iterateJsonObjects(text) {
736
782
  for (let i = 0; i < text.length; i++) {
737
783
  if (text[i] !== "{") continue;
@@ -917,14 +963,15 @@ var ToolCallRepair = class {
917
963
  this.opts = opts;
918
964
  this.storm = new StormBreaker(opts.stormWindow ?? 6, opts.stormThreshold ?? 3);
919
965
  }
920
- process(declaredCalls, reasoningContent) {
966
+ process(declaredCalls, reasoningContent, content = null) {
921
967
  const report = {
922
968
  scavenged: 0,
923
969
  truncationsFixed: 0,
924
970
  stormsBroken: 0,
925
971
  notes: []
926
972
  };
927
- const scavenged = scavengeToolCalls(reasoningContent, {
973
+ const combined = [reasoningContent ?? "", content ?? ""].filter(Boolean).join("\n");
974
+ const scavenged = scavengeToolCalls(combined || null, {
928
975
  allowedNames: this.opts.allowedToolNames,
929
976
  maxCalls: this.opts.maxScavenge ?? 4
930
977
  });
@@ -1283,6 +1330,39 @@ var CacheFirstLoop = class {
1283
1330
  abort() {
1284
1331
  this._aborted = true;
1285
1332
  }
1333
+ /**
1334
+ * Drop everything in the log after (and including) the most recent
1335
+ * user message. Used by `/retry` so the caller can re-send that
1336
+ * message with a fresh turn instead of layering another response on
1337
+ * top of the prior exchange. Returns the content of the dropped user
1338
+ * message, or `null` if there isn't one yet.
1339
+ *
1340
+ * Persists by rewriting the session file — otherwise the next
1341
+ * launch would rehydrate the old exchange and `/retry` would seem
1342
+ * to have done nothing.
1343
+ */
1344
+ retryLastUser() {
1345
+ const entries = this.log.entries;
1346
+ let lastUserIdx = -1;
1347
+ for (let i = entries.length - 1; i >= 0; i--) {
1348
+ if (entries[i].role === "user") {
1349
+ lastUserIdx = i;
1350
+ break;
1351
+ }
1352
+ }
1353
+ if (lastUserIdx < 0) return null;
1354
+ const raw = entries[lastUserIdx].content;
1355
+ const userText = typeof raw === "string" ? raw : "";
1356
+ const preserved = entries.slice(0, lastUserIdx).map((m) => ({ ...m }));
1357
+ this.log.compactInPlace(preserved);
1358
+ if (this.sessionName) {
1359
+ try {
1360
+ rewriteSession(this.sessionName, preserved);
1361
+ } catch {
1362
+ }
1363
+ }
1364
+ return userText;
1365
+ }
1286
1366
  async *step(userInput) {
1287
1367
  this._turn++;
1288
1368
  this.scratch.reset();
@@ -1296,9 +1376,17 @@ var CacheFirstLoop = class {
1296
1376
  yield {
1297
1377
  turn: this._turn,
1298
1378
  role: "warning",
1299
- content: `aborted at iter ${iter}/${this.maxToolIters} \u2014 forcing summary from what was gathered`
1379
+ content: `aborted at iter ${iter}/${this.maxToolIters} \u2014 stopped without producing a summary (press \u2191 + Enter or /retry to resume)`
1380
+ };
1381
+ const stoppedMsg = "[aborted by user (Esc) \u2014 no summary produced. Ask again or /retry when ready; prior tool output is still in the log.]";
1382
+ this.appendAndPersist({ role: "assistant", content: stoppedMsg });
1383
+ yield {
1384
+ turn: this._turn,
1385
+ role: "assistant_final",
1386
+ content: stoppedMsg,
1387
+ forcedSummary: true
1300
1388
  };
1301
- yield* this.forceSummaryAfterIterLimit({ reason: "aborted" });
1389
+ yield { turn: this._turn, role: "done", content: stoppedMsg };
1302
1390
  return;
1303
1391
  }
1304
1392
  if (!warnedForIterBudget && iter >= warnAt) {
@@ -1461,7 +1549,8 @@ var CacheFirstLoop = class {
1461
1549
  const planState = preHarvestedPlanState ? preHarvestedPlanState : this.harvestEnabled ? await harvest(reasoningContent || null, this.client, this.harvestOptions) : emptyPlanState();
1462
1550
  const { calls: repairedCalls, report } = this.repair.process(
1463
1551
  toolCalls,
1464
- reasoningContent || null
1552
+ reasoningContent || null,
1553
+ assistantContent || null
1465
1554
  );
1466
1555
  this.appendAndPersist(this.assistantMessage(assistantContent, repairedCalls));
1467
1556
  yield {
@@ -1520,12 +1609,18 @@ var CacheFirstLoop = class {
1520
1609
  async *forceSummaryAfterIterLimit(opts = { reason: "budget" }) {
1521
1610
  try {
1522
1611
  const messages = this.buildMessages(null);
1612
+ messages.push({
1613
+ role: "user",
1614
+ content: "I'm out of tool-call budget for this turn. Summarize in plain prose what you learned from the tool results above. Do NOT emit any tool calls, function-call markup, DSML invocations, or SEARCH/REPLACE edit blocks \u2014 they will be silently discarded. Just plain text."
1615
+ });
1523
1616
  const resp = await this.client.chat({
1524
1617
  model: this.model,
1525
1618
  messages
1526
1619
  // no tools → model is forced to answer in text
1527
1620
  });
1528
- const summary = resp.content?.trim() || "(model returned no text; try a narrower question or raise --max-tool-iters)";
1621
+ const rawContent = resp.content?.trim() ?? "";
1622
+ const cleaned = stripHallucinatedToolMarkup(rawContent);
1623
+ const summary = cleaned || "(model emitted fake tool-call markup instead of a prose summary \u2014 try /retry with a narrower question, or /think to inspect R1's reasoning)";
1529
1624
  const reasonPrefix = reasonPrefixFor(opts.reason, this.maxToolIters);
1530
1625
  const annotated = `${reasonPrefix}
1531
1626
 
@@ -1566,6 +1661,14 @@ ${summary}`;
1566
1661
  return msg;
1567
1662
  }
1568
1663
  };
1664
+ function stripHallucinatedToolMarkup(s) {
1665
+ let out = s;
1666
+ out = out.replace(/<|DSML|function_calls>[\s\S]*?<\/?|DSML|function_calls>/g, "");
1667
+ out = out.replace(/<\|DSML\|function_calls>[\s\S]*?<\/?\|DSML\|function_calls>/g, "");
1668
+ out = out.replace(/<function_calls>[\s\S]*?<\/function_calls>/g, "");
1669
+ out = out.replace(/<|DSML|[\s\S]*$/g, "");
1670
+ return out.trim();
1671
+ }
1569
1672
  function reasonPrefixFor(reason, iterCap) {
1570
1673
  if (reason === "aborted") return "[aborted by user (Esc) \u2014 summarizing what I found so far]";
1571
1674
  if (reason === "context-guard") {
@@ -2191,7 +2294,12 @@ var McpClient = class {
2191
2294
  this.startReaderIfNeeded();
2192
2295
  const result = await this.request("initialize", {
2193
2296
  protocolVersion: MCP_PROTOCOL_VERSION,
2194
- capabilities: { tools: {} },
2297
+ // Advertise every method the client can consume so servers know
2298
+ // they can send listChanged notifications etc. Sub-feature flags
2299
+ // (e.g. `resources.subscribe`) are omitted — we don't implement
2300
+ // those yet and the empty object means "method-level support, no
2301
+ // sub-features."
2302
+ capabilities: { tools: {}, resources: {}, prompts: {} },
2195
2303
  clientInfo: this.clientInfo
2196
2304
  });
2197
2305
  this._serverCapabilities = result.capabilities ?? {};
@@ -2215,6 +2323,45 @@ var McpClient = class {
2215
2323
  arguments: args ?? {}
2216
2324
  });
2217
2325
  }
2326
+ /**
2327
+ * List resources the server exposes. Supports a pagination cursor;
2328
+ * callers interested in the full set should loop on `nextCursor`.
2329
+ * Servers that don't support resources respond with method-not-found
2330
+ * (−32601) — we surface that as a thrown Error so callers can gate
2331
+ * on the `serverCapabilities.resources` field first.
2332
+ */
2333
+ async listResources(cursor) {
2334
+ this.assertInitialized();
2335
+ return this.request("resources/list", {
2336
+ ...cursor ? { cursor } : {}
2337
+ });
2338
+ }
2339
+ /** Read the contents of a resource by URI. */
2340
+ async readResource(uri) {
2341
+ this.assertInitialized();
2342
+ return this.request("resources/read", {
2343
+ uri
2344
+ });
2345
+ }
2346
+ /** List prompt templates the server exposes. */
2347
+ async listPrompts(cursor) {
2348
+ this.assertInitialized();
2349
+ return this.request("prompts/list", {
2350
+ ...cursor ? { cursor } : {}
2351
+ });
2352
+ }
2353
+ /**
2354
+ * Fetch a rendered prompt by name. `args` supplies values for any
2355
+ * required template arguments; the server validates. Returns messages
2356
+ * ready to prepend to the model's input.
2357
+ */
2358
+ async getPrompt(name, args) {
2359
+ this.assertInitialized();
2360
+ return this.request("prompts/get", {
2361
+ name,
2362
+ ...args ? { arguments: args } : {}
2363
+ });
2364
+ }
2218
2365
  /** Close the transport and reject any outstanding requests. */
2219
2366
  async close() {
2220
2367
  for (const [, pending] of this.pending) {
@@ -2729,7 +2876,7 @@ function sep() {
2729
2876
  }
2730
2877
 
2731
2878
  // src/index.ts
2732
- var VERSION = "0.4.1";
2879
+ var VERSION = "0.4.3";
2733
2880
 
2734
2881
  // src/cli/commands/chat.tsx
2735
2882
  import { render } from "ink";
@@ -2998,7 +3145,10 @@ var EventRow = React3.memo(function EventRow2({ event }) {
2998
3145
  return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column", marginTop: 1 }, /* @__PURE__ */ React3.createElement(Box3, null, /* @__PURE__ */ React3.createElement(Text3, { bold: true, color: "green" }, "assistant")), event.branch ? /* @__PURE__ */ React3.createElement(BranchBlock, { branch: event.branch }) : null, event.reasoning ? /* @__PURE__ */ React3.createElement(ReasoningBlock, { reasoning: event.reasoning }) : null, !isPlanStateEmpty(event.planState) ? /* @__PURE__ */ React3.createElement(PlanStateBlock, { planState: event.planState }) : null, event.text ? /* @__PURE__ */ React3.createElement(Markdown, { text: event.text }) : /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, "(no content)"), event.stats ? /* @__PURE__ */ React3.createElement(StatsLine, { stats: event.stats }) : null, event.repair ? /* @__PURE__ */ React3.createElement(Text3, { color: "magenta" }, event.repair) : null);
2999
3146
  }
3000
3147
  if (event.role === "tool") {
3001
- return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column", marginTop: 1 }, /* @__PURE__ */ React3.createElement(Text3, { color: "yellow" }, `tool<${event.toolName ?? "?"}> \u2192`), /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, " ", truncate2(event.text, 400)));
3148
+ const isError = event.text.startsWith("ERROR:");
3149
+ const color = isError ? "red" : "yellow";
3150
+ const marker = isError ? "\u2717" : "\u2192";
3151
+ 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)));
3002
3152
  }
3003
3153
  if (event.role === "error") {
3004
3154
  return /* @__PURE__ */ React3.createElement(Box3, { marginTop: 1 }, /* @__PURE__ */ React3.createElement(Text3, { color: "red", bold: true }, "error", " "), /* @__PURE__ */ React3.createElement(Text3, { color: "red" }, event.text));
@@ -3020,9 +3170,9 @@ function BranchBlock({ branch }) {
3020
3170
  return /* @__PURE__ */ React3.createElement(Box3, null, /* @__PURE__ */ React3.createElement(Text3, { color: "blue" }, "\u{1F500} branched ", /* @__PURE__ */ React3.createElement(Text3, { bold: true }, branch.budget), ` samples \u2192 picked #${branch.chosenIndex} `, /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, per)));
3021
3171
  }
3022
3172
  function ReasoningBlock({ reasoning }) {
3023
- const max = 220;
3173
+ const max = 260;
3024
3174
  const flat = reasoning.replace(/\s+/g, " ").trim();
3025
- const preview = flat.length <= max ? flat : `${flat.slice(0, max)}\u2026 (+${flat.length - max} chars)`;
3175
+ const preview = flat.length <= max ? flat : `\u2026 (+${flat.length - max} earlier chars) ${flat.slice(-max)}`;
3026
3176
  return /* @__PURE__ */ React3.createElement(Box3, { marginBottom: 1 }, /* @__PURE__ */ React3.createElement(Text3, { dimColor: true, italic: true }, "\u21B3 thinking: ", preview));
3027
3177
  }
3028
3178
  function Elapsed() {
@@ -3150,6 +3300,9 @@ function handleSlash(cmd, args, loop, ctx = {}) {
3150
3300
  " /mcp list MCP servers + tools attached to this session",
3151
3301
  " /setup (exit + reconfigure) \u2192 run `reasonix setup`",
3152
3302
  " /compact [cap] shrink large tool results in history (default 4k/result)",
3303
+ " /think dump the most recent turn's full R1 reasoning (reasoner only)",
3304
+ " /tool [N] list tool calls (or dump full output of #N, 1=most recent)",
3305
+ " /retry truncate & resend your last message (fresh sample from the model)",
3153
3306
  " /apply (code mode) commit the pending edit blocks to disk",
3154
3307
  " /discard (code mode) drop pending edits without writing",
3155
3308
  " /undo (code mode) roll back the last applied edit batch",
@@ -3195,6 +3348,63 @@ function handleSlash(cmd, args, loop, ctx = {}) {
3195
3348
  return {
3196
3349
  info: "To reconfigure (preset, MCP servers, API key), exit this chat and run `reasonix setup`. Changes take effect on next launch."
3197
3350
  };
3351
+ case "retry": {
3352
+ const prev = loop.retryLastUser();
3353
+ if (!prev) {
3354
+ return {
3355
+ info: "nothing to retry \u2014 no prior user message in this session's log."
3356
+ };
3357
+ }
3358
+ const preview = prev.length > 80 ? `${prev.slice(0, 80)}\u2026` : prev;
3359
+ return {
3360
+ info: `\u25B8 retrying: "${preview}"`,
3361
+ resubmit: prev
3362
+ };
3363
+ }
3364
+ case "think":
3365
+ case "reasoning": {
3366
+ const raw = loop.scratch.reasoning;
3367
+ if (!raw || !raw.trim()) {
3368
+ return {
3369
+ info: "no reasoning cached. `/think` shows the full R1 thought for the most recent turn \u2014 only `deepseek-reasoner` produces it, and only once the turn completes."
3370
+ };
3371
+ }
3372
+ return { info: `\u21B3 full thinking (${raw.length} chars):
3373
+
3374
+ ${raw.trim()}` };
3375
+ }
3376
+ case "tool": {
3377
+ const history = ctx.toolHistory?.() ?? [];
3378
+ if (history.length === 0) {
3379
+ return {
3380
+ info: "no tool calls yet in this session. `/tool` lists them once the model has actually used a tool; `/tool N` dumps the full (untruncated) output of the Nth-most-recent."
3381
+ };
3382
+ }
3383
+ const raw = (args[0] ?? "").toLowerCase();
3384
+ if (raw === "" || raw === "list" || raw === "ls") {
3385
+ return { info: formatToolList(history) };
3386
+ }
3387
+ const n = Number.parseInt(raw, 10);
3388
+ if (!Number.isFinite(n) || n < 1) {
3389
+ return {
3390
+ info: "usage: /tool [N] (no arg \u2192 list; N=1 \u2192 most recent result in full, N=2 \u2192 previous, \u2026)"
3391
+ };
3392
+ }
3393
+ if (n > history.length) {
3394
+ return {
3395
+ info: `only ${history.length} tool call(s) in history \u2014 asked for #${n}. Try /tool with no arg to see the list.`
3396
+ };
3397
+ }
3398
+ const entry = history[history.length - n];
3399
+ if (!entry) {
3400
+ return { info: `could not read tool call #${n}` };
3401
+ }
3402
+ return {
3403
+ info: `\u21B3 tool<${entry.toolName}> #${n} (${entry.text.length} chars):
3404
+
3405
+ ${entry.text}`
3406
+ };
3407
+ }
3198
3408
  case "undo": {
3199
3409
  if (!ctx.codeUndo) {
3200
3410
  return {
@@ -3279,9 +3489,25 @@ function handleSlash(cmd, args, loop, ctx = {}) {
3279
3489
  }
3280
3490
  case "status": {
3281
3491
  const branchBudget = loop.branchOptions.budget ?? 1;
3282
- return {
3283
- info: `model=${loop.model} harvest=${loop.harvestEnabled ? "on" : "off"} branch=${branchBudget > 1 ? branchBudget : "off"} stream=${loop.stream ? "on" : "off"}`
3284
- };
3492
+ const ctxMax = DEEPSEEK_CONTEXT_TOKENS[loop.model] ?? DEFAULT_CONTEXT_TOKENS;
3493
+ const lastPromptTokens = loop.stats.summary().lastPromptTokens;
3494
+ const ctxPct = ctxMax > 0 ? Math.round(lastPromptTokens / ctxMax * 100) : 0;
3495
+ const ctxLine = lastPromptTokens > 0 ? ` ctx ${compactNum(lastPromptTokens)}/${compactNum(ctxMax)} (${ctxPct}%)` : " ctx no turns yet";
3496
+ const pending = ctx.pendingEditCount ?? 0;
3497
+ const sessionLine = loop.sessionName ? ` session "${loop.sessionName}" \xB7 ${loop.log.length} messages in log (resumed ${loop.resumedMessageCount})` : " session (ephemeral \u2014 no persistence)";
3498
+ const mcpCount = ctx.mcpSpecs?.length ?? 0;
3499
+ const toolCount = loop.prefix.toolSpecs.length;
3500
+ const mcpLine = ` mcp ${mcpCount} server(s), ${toolCount} tool(s) in registry`;
3501
+ const pendingLine = pending > 0 ? ` edits ${pending} pending (/apply to commit, /discard to drop)` : "";
3502
+ const lines = [
3503
+ ` model ${loop.model}`,
3504
+ ` flags harvest=${loop.harvestEnabled ? "on" : "off"} \xB7 branch=${branchBudget > 1 ? branchBudget : "off"} \xB7 stream=${loop.stream ? "on" : "off"}`,
3505
+ ctxLine,
3506
+ mcpLine,
3507
+ sessionLine
3508
+ ];
3509
+ if (pendingLine) lines.push(pendingLine);
3510
+ return { info: lines.join("\n") };
3285
3511
  }
3286
3512
  case "model": {
3287
3513
  const id = args[0];
@@ -3333,6 +3559,34 @@ function handleSlash(cmd, args, loop, ctx = {}) {
3333
3559
  return { unknown: true, info: `unknown command: /${cmd} (try /help)` };
3334
3560
  }
3335
3561
  }
3562
+ function formatToolList(history) {
3563
+ const total = history.length;
3564
+ const header = `Tool calls in this session (${total}, most recent first):`;
3565
+ const shown = Math.min(total, 10);
3566
+ const lines = [header];
3567
+ for (let i = 0; i < shown; i++) {
3568
+ const entry = history[total - 1 - i];
3569
+ if (!entry) continue;
3570
+ const idx = i + 1;
3571
+ const flat = entry.text.replace(/\s+/g, " ").trim();
3572
+ const preview = flat.length > 80 ? `${flat.slice(0, 80)}\u2026` : flat;
3573
+ const name = entry.toolName.length > 24 ? `${entry.toolName.slice(0, 23)}\u2026` : entry.toolName;
3574
+ lines.push(
3575
+ ` #${String(idx).padStart(2)} ${name.padEnd(24)} ${String(entry.text.length).padStart(6)} chars ${preview}`
3576
+ );
3577
+ }
3578
+ if (total > shown) {
3579
+ lines.push(` \u2026 (${total - shown} earlier, reach with /tool N)`);
3580
+ }
3581
+ lines.push("");
3582
+ lines.push("View full output: /tool N (N=1 \u2192 most recent)");
3583
+ return lines.join("\n");
3584
+ }
3585
+ function compactNum(n) {
3586
+ if (n < 1e3) return String(n);
3587
+ const k = n / 1e3;
3588
+ return k >= 100 ? `${Math.round(k)}k` : `${k.toFixed(1)}k`;
3589
+ }
3336
3590
  function stripOuterQuotes(s) {
3337
3591
  if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
3338
3592
  return s.slice(1, -1);
@@ -3388,6 +3642,9 @@ function App({
3388
3642
  const [ongoingTool, setOngoingTool] = useState2(null);
3389
3643
  const lastEditSnapshots = useRef(null);
3390
3644
  const pendingEdits = useRef([]);
3645
+ const promptHistory = useRef([]);
3646
+ const historyCursor = useRef(-1);
3647
+ const toolHistoryRef = useRef([]);
3391
3648
  const [summary, setSummary] = useState2({
3392
3649
  turns: 0,
3393
3650
  totalCostUsd: 0,
@@ -3456,11 +3713,28 @@ function App({
3456
3713
  }
3457
3714
  }, [session, loop]);
3458
3715
  useInput((_input, key) => {
3459
- if (!key.escape) return;
3460
- if (!busy) return;
3461
- if (abortedThisTurn.current) return;
3462
- abortedThisTurn.current = true;
3463
- loop.abort();
3716
+ if (key.escape && busy) {
3717
+ if (abortedThisTurn.current) return;
3718
+ abortedThisTurn.current = true;
3719
+ loop.abort();
3720
+ return;
3721
+ }
3722
+ if (busy) return;
3723
+ const hist = promptHistory.current;
3724
+ if (key.upArrow) {
3725
+ if (hist.length === 0) return;
3726
+ const nextCursor = Math.min(historyCursor.current + 1, hist.length - 1);
3727
+ historyCursor.current = nextCursor;
3728
+ setInput(hist[hist.length - 1 - nextCursor] ?? "");
3729
+ return;
3730
+ }
3731
+ if (key.downArrow) {
3732
+ if (historyCursor.current < 0) return;
3733
+ const nextCursor = historyCursor.current - 1;
3734
+ historyCursor.current = nextCursor;
3735
+ setInput(nextCursor < 0 ? "" : hist[hist.length - 1 - nextCursor] ?? "");
3736
+ return;
3737
+ }
3464
3738
  });
3465
3739
  const codeUndo = useCallback(() => {
3466
3740
  if (!codeMode) return "not in code mode";
@@ -3502,9 +3776,16 @@ function App({
3502
3776
  );
3503
3777
  const handleSubmit = useCallback(
3504
3778
  async (raw) => {
3505
- const text = raw.trim();
3779
+ let text = raw.trim();
3506
3780
  if (!text || busy) return;
3507
3781
  setInput("");
3782
+ historyCursor.current = -1;
3783
+ if (codeMode && pendingEdits.current.length > 0 && (text === "y" || text === "n")) {
3784
+ const out = text === "y" ? codeApply() : codeDiscard();
3785
+ setHistorical((prev) => [...prev, { id: `sys-${Date.now()}`, role: "info", text: out }]);
3786
+ promptHistory.current.push(text);
3787
+ return;
3788
+ }
3508
3789
  const slash = parseSlash(text);
3509
3790
  if (slash) {
3510
3791
  const result = handleSlash(slash.cmd, slash.args, loop, {
@@ -3512,7 +3793,9 @@ function App({
3512
3793
  codeUndo: codeMode ? codeUndo : void 0,
3513
3794
  codeApply: codeMode ? codeApply : void 0,
3514
3795
  codeDiscard: codeMode ? codeDiscard : void 0,
3515
- codeRoot: codeMode?.rootDir
3796
+ codeRoot: codeMode?.rootDir,
3797
+ pendingEditCount: codeMode ? pendingEdits.current.length : void 0,
3798
+ toolHistory: () => toolHistoryRef.current
3516
3799
  });
3517
3800
  if (result.exit) {
3518
3801
  transcriptRef.current?.end();
@@ -3533,8 +3816,14 @@ function App({
3533
3816
  }
3534
3817
  ]);
3535
3818
  }
3536
- return;
3819
+ if (result.resubmit) {
3820
+ text = result.resubmit;
3821
+ } else {
3822
+ promptHistory.current.push(text);
3823
+ return;
3824
+ }
3537
3825
  }
3826
+ promptHistory.current.push(text);
3538
3827
  setHistorical((prev) => [...prev, { id: `u-${Date.now()}`, role: "user", text }]);
3539
3828
  const assistantId = `a-${Date.now()}`;
3540
3829
  const streamRef = { id: assistantId, text: "", reasoning: "" };
@@ -3619,6 +3908,10 @@ function App({
3619
3908
  } else if (ev.role === "tool") {
3620
3909
  flush();
3621
3910
  setOngoingTool(null);
3911
+ toolHistoryRef.current.push({
3912
+ toolName: ev.toolName ?? "?",
3913
+ text: ev.content
3914
+ });
3622
3915
  setHistorical((prev) => [
3623
3916
  ...prev,
3624
3917
  {
@@ -3664,16 +3957,62 @@ function App({
3664
3957
  }
3665
3958
  function OngoingToolRow({ tool }) {
3666
3959
  const [tick, setTick] = useState2(0);
3960
+ const [elapsed, setElapsed] = useState2(0);
3667
3961
  useEffect2(() => {
3668
- const id = setInterval(() => setTick((t) => t + 1), 120);
3669
- return () => clearInterval(id);
3962
+ const start = Date.now();
3963
+ const frameId = setInterval(() => setTick((t) => t + 1), 120);
3964
+ const secId = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1e3)), 1e3);
3965
+ return () => {
3966
+ clearInterval(frameId);
3967
+ clearInterval(secId);
3968
+ };
3670
3969
  }, []);
3671
3970
  const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
3672
- const argsPreview = tool.args && tool.args.length > 0 && tool.args !== "{}" ? ` ${tool.args.length > 60 ? `${tool.args.slice(0, 60)}\u2026` : tool.args}` : "";
3673
- return /* @__PURE__ */ React6.createElement(Box6, { marginY: 1 }, /* @__PURE__ */ React6.createElement(Text6, { color: "cyan" }, frames[tick % frames.length]), /* @__PURE__ */ React6.createElement(Text6, { color: "yellow" }, ` tool<${tool.name}> running\u2026`), argsPreview ? /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, argsPreview) : null);
3971
+ const summary = summarizeToolArgs(tool.name, tool.args);
3972
+ return /* @__PURE__ */ React6.createElement(Box6, { marginY: 1, flexDirection: "column" }, /* @__PURE__ */ React6.createElement(Box6, null, /* @__PURE__ */ React6.createElement(Text6, { color: "cyan" }, frames[tick % frames.length]), /* @__PURE__ */ React6.createElement(Text6, { color: "yellow" }, ` tool<${tool.name}> running\u2026`), /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, ` ${elapsed}s`)), summary ? /* @__PURE__ */ React6.createElement(Box6, { paddingLeft: 2 }, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, summary)) : null);
3973
+ }
3974
+ function summarizeToolArgs(name, args) {
3975
+ if (!args || args === "{}") return "";
3976
+ let parsed;
3977
+ try {
3978
+ parsed = JSON.parse(args);
3979
+ } catch {
3980
+ return args.length > 80 ? `${args.slice(0, 80)}\u2026` : args;
3981
+ }
3982
+ const hasSuffix = (s) => name === s || name.endsWith(`_${s}`);
3983
+ const path = typeof parsed.path === "string" ? parsed.path : void 0;
3984
+ if (hasSuffix("read_file")) {
3985
+ const head = typeof parsed.head === "number" ? `, head=${parsed.head}` : "";
3986
+ const tail = typeof parsed.tail === "number" ? `, tail=${parsed.tail}` : "";
3987
+ return `path: ${path ?? "?"}${head}${tail}`;
3988
+ }
3989
+ if (hasSuffix("write_file")) {
3990
+ const content = typeof parsed.content === "string" ? parsed.content : "";
3991
+ return `path: ${path ?? "?"} (${content.length} chars)`;
3992
+ }
3993
+ if (hasSuffix("edit_file")) {
3994
+ const edits = Array.isArray(parsed.edits) ? parsed.edits.length : 0;
3995
+ return `path: ${path ?? "?"} (${edits} edit${edits === 1 ? "" : "s"})`;
3996
+ }
3997
+ if (hasSuffix("list_directory") || hasSuffix("directory_tree")) {
3998
+ return `path: ${path ?? "?"}`;
3999
+ }
4000
+ if (hasSuffix("search_files")) {
4001
+ const pattern = typeof parsed.pattern === "string" ? parsed.pattern : "?";
4002
+ return `path: ${path ?? "?"} \xB7 pattern: ${pattern}`;
4003
+ }
4004
+ if (hasSuffix("move_file")) {
4005
+ const src = typeof parsed.source === "string" ? parsed.source : "?";
4006
+ const dst = typeof parsed.destination === "string" ? parsed.destination : "?";
4007
+ return `${src} \u2192 ${dst}`;
4008
+ }
4009
+ if (hasSuffix("get_file_info")) {
4010
+ return `path: ${path ?? "?"}`;
4011
+ }
4012
+ return args.length > 80 ? `${args.slice(0, 80)}\u2026` : args;
3674
4013
  }
3675
4014
  function CommandStrip({ codeMode }) {
3676
- return /* @__PURE__ */ React6.createElement(Box6, { paddingX: 2, flexDirection: "column" }, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "/help \xB7 /preset ", "<fast|smart|max>", " \xB7 /mcp \xB7 /compact \xB7 /sessions \xB7 /setup \xB7 /clear \xB7 /exit"), codeMode ? /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, 'code mode: /apply \xB7 /discard \xB7 /undo \xB7 /commit "msg" \u2014 edits NEVER write without /apply') : null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "Esc (while thinking) \u2014 abort & summarize what was found so far"));
4015
+ return /* @__PURE__ */ React6.createElement(Box6, { paddingX: 2, flexDirection: "column" }, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "/help \xB7 /preset ", "<fast|smart|max>", " \xB7 /mcp \xB7 /compact \xB7 /sessions \xB7 /setup \xB7 /clear \xB7 /exit"), codeMode ? /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, 'code mode: /apply (y) \xB7 /discard (n) \xB7 /undo \xB7 /commit "msg" \u2014 edits NEVER write without /apply') : null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "\u2191/\u2193 recall prompts \xB7 /retry re-send last \xB7 /think see R1 reasoning \xB7 /tool N full tool output"), /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "Esc (while thinking) \u2014 abort & summarize what was found so far"));
3677
4016
  }
3678
4017
  function formatEditResults(results) {
3679
4018
  const lines = results.map((r) => {
@@ -3693,7 +4032,7 @@ function formatPendingPreview(blocks) {
3693
4032
  const tag = b.search === "" ? "NEW " : " ";
3694
4033
  return ` ${tag}${b.path} (-${removed} +${added} lines)`;
3695
4034
  });
3696
- const header = `\u25B8 ${blocks.length} pending edit block(s) \u2014 /apply to commit to disk, /discard to drop`;
4035
+ const header = `\u25B8 ${blocks.length} pending edit block(s) \u2014 /apply (or y) to commit \xB7 /discard (or n) to drop`;
3697
4036
  return [header, ...lines].join("\n");
3698
4037
  }
3699
4038
  function countLines2(s) {