mindweave 3.2.8 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/cli/App.js +410 -38
  2. package/dist/cli/App.js.map +1 -1
  3. package/dist/cli/altScreen.js +6 -0
  4. package/dist/cli/altScreen.js.map +1 -1
  5. package/dist/cli/blockHeights.js +3 -0
  6. package/dist/cli/blockHeights.js.map +1 -1
  7. package/dist/cli/chatAnchor.js +17 -0
  8. package/dist/cli/chatAnchor.js.map +1 -1
  9. package/dist/cli/components/BlockView.js +10 -1
  10. package/dist/cli/components/BlockView.js.map +1 -1
  11. package/dist/cli/components/FadeText.js +47 -0
  12. package/dist/cli/components/FadeText.js.map +1 -0
  13. package/dist/cli/components/KeyManager.js +63 -7
  14. package/dist/cli/components/KeyManager.js.map +1 -1
  15. package/dist/cli/components/Picker.js +31 -5
  16. package/dist/cli/components/Picker.js.map +1 -1
  17. package/dist/cli/components/PromptInput.js +62 -9
  18. package/dist/cli/components/PromptInput.js.map +1 -1
  19. package/dist/cli/components/ToolLine.js +11 -3
  20. package/dist/cli/components/ToolLine.js.map +1 -1
  21. package/dist/cli/components/WorkGroup.js +27 -10
  22. package/dist/cli/components/WorkGroup.js.map +1 -1
  23. package/dist/cli/diffRows.js +16 -0
  24. package/dist/cli/diffRows.js.map +1 -1
  25. package/dist/cli/entrance.js +95 -0
  26. package/dist/cli/entrance.js.map +1 -0
  27. package/dist/cli/framebuffer/parse.js +99 -16
  28. package/dist/cli/framebuffer/parse.js.map +1 -1
  29. package/dist/cli/framebuffer/writer.js +75 -34
  30. package/dist/cli/framebuffer/writer.js.map +1 -1
  31. package/dist/cli/help.js +7 -0
  32. package/dist/cli/help.js.map +1 -1
  33. package/dist/cli/messageQueue.js +7 -2
  34. package/dist/cli/messageQueue.js.map +1 -1
  35. package/dist/cli/pickerSearch.js +59 -0
  36. package/dist/cli/pickerSearch.js.map +1 -0
  37. package/dist/cli/selectionGrow.js +86 -3
  38. package/dist/cli/selectionGrow.js.map +1 -1
  39. package/dist/cli/toolDisplay.js +20 -5
  40. package/dist/cli/toolDisplay.js.map +1 -1
  41. package/dist/cli/transcript.js +24 -7
  42. package/dist/cli/transcript.js.map +1 -1
  43. package/dist/cli/wheelStep.js +2 -2
  44. package/dist/cli/workLines.js +26 -7
  45. package/dist/cli/workLines.js.map +1 -1
  46. package/dist/core/turnRunner.js +7 -1
  47. package/dist/core/turnRunner.js.map +1 -1
  48. package/dist/drivers/anthropic/client.js +16 -3
  49. package/dist/drivers/anthropic/client.js.map +1 -1
  50. package/dist/drivers/anthropic/manifest.js +10 -0
  51. package/dist/drivers/anthropic/manifest.js.map +1 -1
  52. package/dist/drivers/glm/manifest.js +9 -0
  53. package/dist/drivers/glm/manifest.js.map +1 -1
  54. package/dist/drivers/groq/manifest.js +5 -0
  55. package/dist/drivers/groq/manifest.js.map +1 -1
  56. package/dist/drivers/mistral/client.js +3 -0
  57. package/dist/drivers/mistral/client.js.map +1 -1
  58. package/dist/drivers/openai/client.js +3 -0
  59. package/dist/drivers/openai/client.js.map +1 -1
  60. package/dist/drivers/openai/manifest.js +5 -0
  61. package/dist/drivers/openai/manifest.js.map +1 -1
  62. package/dist/drivers/openaiCompat/wire.js +7 -2
  63. package/dist/drivers/openaiCompat/wire.js.map +1 -1
  64. package/dist/drivers/openrouter/client.js +6 -0
  65. package/dist/drivers/openrouter/client.js.map +1 -1
  66. package/dist/drivers/xai/client.js +9 -0
  67. package/dist/drivers/xai/client.js.map +1 -1
  68. package/dist/dynamo/contextWindow.js +64 -12
  69. package/dist/dynamo/contextWindow.js.map +1 -1
  70. package/dist/dynamo/engine.js +72 -22
  71. package/dist/dynamo/engine.js.map +1 -1
  72. package/dist/dynamo/prompt.js +9 -0
  73. package/dist/dynamo/prompt.js.map +1 -1
  74. package/dist/memory/compaction.js +17 -3
  75. package/dist/memory/compaction.js.map +1 -1
  76. package/dist/memory/localCommand.js +165 -0
  77. package/dist/memory/localCommand.js.map +1 -0
  78. package/dist/memory/session.js +4 -0
  79. package/dist/memory/session.js.map +1 -1
  80. package/dist/memory/store.js +4 -1
  81. package/dist/memory/store.js.map +1 -1
  82. package/dist/memory/types.js.map +1 -1
  83. package/dist/tools/backgroundShells.js +35 -7
  84. package/dist/tools/backgroundShells.js.map +1 -1
  85. package/dist/tools/deferredNative.js +24 -0
  86. package/dist/tools/deferredNative.js.map +1 -1
  87. package/dist/tools/detail.js +2 -0
  88. package/dist/tools/detail.js.map +1 -1
  89. package/dist/tools/mcpSearch.js +8 -2
  90. package/dist/tools/mcpSearch.js.map +1 -1
  91. package/dist/tools/rebuildDetail.js +49 -1
  92. package/dist/tools/rebuildDetail.js.map +1 -1
  93. package/dist/tools/registry.js +12 -12
  94. package/dist/tools/registry.js.map +1 -1
  95. package/dist/tools/runCommand.js +17 -6
  96. package/dist/tools/runCommand.js.map +1 -1
  97. package/dist/tools/shellTools.js +10 -23
  98. package/dist/tools/shellTools.js.map +1 -1
  99. package/dist/tools/untrusted.js +31 -0
  100. package/dist/tools/untrusted.js.map +1 -1
  101. package/dist/tools/userCommand.js +52 -0
  102. package/dist/tools/userCommand.js.map +1 -0
  103. package/dist/tools/viewImage.js +14 -2
  104. package/dist/tools/viewImage.js.map +1 -1
  105. package/dist/tools/webSearch.js +42 -20
  106. package/dist/tools/webSearch.js.map +1 -1
  107. package/package.json +3 -3
package/dist/cli/App.js CHANGED
@@ -81,7 +81,9 @@ import { expandableAt, hitBlock, hitItem, revealNode } from "./expandHits.js";
81
81
  import { shellNote } from "./shellNotes.js";
82
82
  import { pressBackground } from "./commandBackground.js";
83
83
  import { readFileSync, statSync } from "node:fs";
84
- import { compactUiDetail, rebuildFull, writtenFromDisk } from "../tools/rebuildDetail.js";
84
+ import { compactSearchDetail, compactUiDetail, rebuildFull, writtenFromDisk } from "../tools/rebuildDetail.js";
85
+ import { isUserShell, runUserCommand } from "../tools/userCommand.js";
86
+ import { isUserCommandText, parseUserCommand } from "../memory/localCommand.js";
85
87
  import { shownText } from "../memory/compaction.js";
86
88
  import { BASE_COMMANDS } from "./commands.js";
87
89
  import { attachmentBudget, dismissMarathon } from "../core/turnRunner.js";
@@ -93,13 +95,13 @@ import { currentInstall, requestRestart, runUpdate } from "./updateRunner.js";
93
95
  import { enableMouse, readMouse, readWheel } from "./mouse.js";
94
96
  import { wheelLines } from "./wheelStep.js";
95
97
  import { applySelection, ctrlCShouldCopy, isEmpty, selectionText } from "./selection.js";
96
- import { applyGrowSelection, edgeLines, focusFromPointer, isEmptyGrow, RowStore } from "./selectionGrow.js";
98
+ import { applyGrowSelection, edgeLines, focusFromPointer, isEmptyGrow, lineBounds, nextClick, RowStore, rowOfKey, wordBounds } from "./selectionGrow.js";
97
99
  import { blockSpans, registerBlockBox } from "./blockBoxes.js";
98
100
  import { holdFrames, releaseFrames } from "./framebuffer/frameHold.js";
99
101
  import { latestScreen, repaintOverlay, setFrameOverlay } from "./framebuffer/overlay.js";
100
102
  import { requestFullRepaint } from "./framebuffer/writer.js";
101
103
  import { copyToClipboard } from "./clipboard.js";
102
- import { chatLayout, reflowScroll, growScroll, revealScroll } from "./chatAnchor.js";
104
+ import { chatLayout, reflowScroll, growScroll, revealScroll, viewportScroll } from "./chatAnchor.js";
103
105
  import { growFill, INLINE_LIVE_RESERVE, NO_FILL } from "./startupFill.js";
104
106
  import { setRowsBelowCaret } from "./exitCursor.js";
105
107
  import { caretCell } from "./caretPark.js";
@@ -107,7 +109,7 @@ import { countNewReplies, hitsPill, pillBounds, scrollPill } from "./scrollPill.
107
109
  import { SCROLLBACK_BLOCKS, virtualWindow } from "./virtualWindow.js";
108
110
  import { perf, perfEnabled } from "./perfLog.js";
109
111
  import { newPacer, nextHoldAt, nextMove, takeImmediate, takePaced } from "./revealQueue.js";
110
- import { drain as drainQueue, popAll as popAllQueued, queueMessage, takeSteerable, visibleQueue } from "./messageQueue.js";
112
+ import { drain as drainQueue, popAll as popAllQueued, queueMessage, takeSteerable } from "./messageQueue.js";
111
113
  import { routeCommand, parseCommandLine, unknownCommandMessage } from "./commandRoute.js";
112
114
  import { resolveChoice, splitModelArg } from "./commandArgs.js";
113
115
  import { carryAcrossFreshSession } from "./sessionCarry.js";
@@ -239,6 +241,11 @@ export function App({ resumeSessionId, initialScreen }) {
239
241
  clearInterval(edgeTimer.current);
240
242
  edgeTimer.current = null;
241
243
  }, []);
244
+ /** A press that may be the third click of a row, waiting for its release to say which it was. */
245
+ const pendingThird = useRef(null);
246
+ const dropPendingThird = useCallback(() => {
247
+ pendingThird.current = null;
248
+ }, []);
242
249
  /**
243
250
  * Drop the highlight, if there is one, and stop tinting frames.
244
251
  *
@@ -256,9 +263,10 @@ export function App({ resumeSessionId, initialScreen }) {
256
263
  rowStore.current.clear();
257
264
  pointer.current = null;
258
265
  stopEdge();
266
+ dropPendingThird();
259
267
  repaintOverlay();
260
268
  setFrameOverlay(null);
261
- }, [stopEdge]);
269
+ }, [stopEdge, dropPendingThird]);
262
270
  // PromptInput installs the handler that turns a click into a caret position; only it
263
271
  // knows what its rows currently hold. Null until the input is on screen.
264
272
  const caretClick = useRef(null);
@@ -351,6 +359,14 @@ export function App({ resumeSessionId, initialScreen }) {
351
359
  /** The chip's cells in SCREEN coordinates, for the pointer handler. Null when there is
352
360
  * nothing to click. Published by the render, read between frames. */
353
361
  const pillHit = useRef(null);
362
+ /** The queued line's row within the live region (measured as it is drawn), and its screen row for a click. */
363
+ const queueRow = useRef(null);
364
+ // Steered messages that left the queue but have not reached the chat yet (see steerRunningTurn).
365
+ const [departing, setDeparting] = useState([]);
366
+ const departingRef = useRef(0);
367
+ const queueHitRow = useRef(null);
368
+ /** Pulls the queue back into the input box to edit (installed by the input, as the caret click is). */
369
+ const pullQueue = useRef(null);
354
370
  // Where the layout's first row sits on the screen, published each render for the pointer.
355
371
  const frameTopRef = useRef(0);
356
372
  // The row the pointer is over, if it can be pressed. State rather than a ref: it is what
@@ -371,6 +387,9 @@ export function App({ resumeSessionId, initialScreen }) {
371
387
  // much of the content is on screen.
372
388
  const chatRef = useRef(null);
373
389
  const [chatHeight, setChatHeight] = useState(0);
390
+ /** The chat viewport's height and the width it had when last measured, so a change of the
391
+ * footer's height can move the scroll with it. See viewportScroll. */
392
+ const chatRowsSeen = useRef(null);
374
393
  // Bumped by PromptInput when its suggestion menu changes size. Its only job is
375
394
  // to re-render App so the footer measurement below re-runs: the menu is
376
395
  // PromptInput's own state, so App is not re-rendered by it and would otherwise
@@ -419,6 +438,17 @@ export function App({ resumeSessionId, initialScreen }) {
419
438
  }, []);
420
439
  // Aborts the in-flight turn when the user presses Esc (created fresh per turn).
421
440
  const abortRef = useRef(null);
441
+ /** A command the person typed after `!` is running (not an agent turn). Esc stops just that. */
442
+ const userCommandRunning = useRef(false);
443
+ /** The turn in progress is the person's own `!` command, not the agent working. */
444
+ const userTurn = useRef(false);
445
+ /**
446
+ * A command the person ran with `!` has just ended and the agent has not answered it yet. The agent
447
+ * is woken for it the way it is when a command of its own finishes: given the result, it carries on
448
+ * (that is the point: it can ask the person to run something and go on from what comes back).
449
+ * Cleared when any turn starts, since that turn reads the result anyway.
450
+ */
451
+ const wakeAfterUserCommand = useRef(false);
422
452
  // Which provider's key we still need, or null once we have it. Not a bare
423
453
  // boolean: with more than one provider, the key we must ask for depends on the
424
454
  // model the user is about to run, and switching models can make a different key
@@ -689,6 +719,7 @@ export function App({ resumeSessionId, initialScreen }) {
689
719
  // Each call's name and arguments by id, so a result from a session saved before the uncut
690
720
  // text was kept can have it put back together (see rebuildDetail.ts).
691
721
  const callsById = new Map();
722
+ let userRows = 0; // commands the person ran with `!`, to give each replayed row its own id
692
723
  for (const e of transcript) {
693
724
  if (e.role === "user") {
694
725
  if (!e.synthetic)
@@ -699,6 +730,23 @@ export function App({ resumeSessionId, initialScreen }) {
699
730
  // calls…" sitting in their own chat history.
700
731
  if (!e.synthetic)
701
732
  dispatch({ type: "user", text: collapsePastes(stripAttachments(e.content)) });
733
+ // A command the person ran with `!` comes back as the row it was: what they ran, how it ended,
734
+ // and the output behind the click. The screen's record is kept whole even when the model's copy
735
+ // of the output was cleared to save context.
736
+ else if (e.localCommand) {
737
+ const rec = e.localCommand;
738
+ const toolId = `user-replay-${userRows++}`;
739
+ dispatch({ type: "toolStart", toolId, name: "Run", arg: rec.command, action: "run", group: true, byUser: true });
740
+ dispatch({
741
+ type: "toolEnd",
742
+ toolId,
743
+ ok: rec.ok,
744
+ summary: rec.summary,
745
+ detail: rec.detail,
746
+ detailKind: "shell",
747
+ ...(rec.detailFull ? { detailFull: rec.detailFull } : {}),
748
+ });
749
+ }
702
750
  }
703
751
  else if (e.role === "summary") {
704
752
  dispatch({ type: "note", text: "— resumed; earlier context summarized —" });
@@ -723,7 +771,7 @@ export function App({ resumeSessionId, initialScreen }) {
723
771
  // that was not in the live one.
724
772
  if (call.name === "spawn_subagent" || neverShown(call.name))
725
773
  continue;
726
- const d = toolDisplay(call.name, parseToolArgs(call.arguments));
774
+ const d = toolDisplay(call.name, parseToolArgs(call.arguments), session.current?.cwd ?? process.cwd());
727
775
  dispatch({
728
776
  type: "toolStart",
729
777
  toolId: call.id,
@@ -752,6 +800,12 @@ export function App({ resumeSessionId, initialScreen }) {
752
800
  if (compact)
753
801
  return { detail: compact.detail, ...(compact.detailFull ? { detailFull: compact.detailFull } : {}) };
754
802
  }
803
+ // A web search saved before it became a short row that opens on a click comes back as one.
804
+ if ((call?.name === "web_search" || call?.name === "web") && !e.detailFull) {
805
+ const compact = compactSearchDetail(e.detail);
806
+ if (compact)
807
+ return { detail: compact.detail, ...(compact.detailFull ? { detailFull: compact.detailFull } : {}) };
808
+ }
755
809
  let full = e.detailFull ?? rebuildFull(call?.name, call?.args, e);
756
810
  // A write whose text was cleared to save context has only the file left to open.
757
811
  if (!full && call?.name === "write_file" && typeof call.args?.path === "string") {
@@ -998,10 +1052,14 @@ export function App({ resumeSessionId, initialScreen }) {
998
1052
  needsKey,
999
1053
  reacting: reactingRef.current,
1000
1054
  modalOpen,
1001
- pending: mgr?.pendingCount() ?? 0,
1055
+ // A command the person ran counts as one event worth waking for, unless something they typed
1056
+ // is already queued: that message's turn reads the command's result too.
1057
+ pending: (mgr?.pendingCount() ?? 0) + (wakeAfterUserCommand.current && queueRef.current.length === 0 ? 1 : 0),
1002
1058
  });
1003
- if (wake)
1059
+ if (wake) {
1060
+ wakeAfterUserCommand.current = false;
1004
1061
  void reactToBackground();
1062
+ }
1005
1063
  }, [bgTick, busy, ready, needsKey, modalOpen]);
1006
1064
  // When the turn ends, send what's queued — CONSECUTIVE plain messages together, as
1007
1065
  // one turn (see messageQueue.ts for why), a slash command on its own. Chains: each
@@ -1054,6 +1112,7 @@ export function App({ resumeSessionId, initialScreen }) {
1054
1112
  }, [busy]);
1055
1113
  // Start/stop a turn's timer (drives the persistent status line).
1056
1114
  function startTurn() {
1115
+ wakeAfterUserCommand.current = false;
1057
1116
  turnStart.current = Date.now();
1058
1117
  waitedMs.current = 0;
1059
1118
  waitSince.current = null;
@@ -1064,6 +1123,20 @@ export function App({ resumeSessionId, initialScreen }) {
1064
1123
  abortRef.current = new AbortController();
1065
1124
  setBusy(true);
1066
1125
  }
1126
+ /**
1127
+ * The start of a `!` command's time on screen. Like `startTurn` for what the box needs (busy, a way
1128
+ * to stop it, anything typed meanwhile queued) and nothing of the agent's: the task's usage and
1129
+ * its "Worked for" receipt belong to the last time the agent worked, and are left as they were.
1130
+ */
1131
+ function startUserTurn() {
1132
+ turnStart.current = Date.now();
1133
+ waitedMs.current = 0;
1134
+ waitSince.current = null;
1135
+ interrupting.current = false;
1136
+ abortRef.current = new AbortController();
1137
+ userTurn.current = true;
1138
+ setBusy(true);
1139
+ }
1067
1140
  // Esc interrupts the current turn: it aborts the model call AND kills a running
1068
1141
  // command — run_command listens to this same signal (see runShell), so a hung
1069
1142
  // command (e.g. an installer waiting on a GUI) can no longer freeze the agent.
@@ -1113,6 +1186,9 @@ export function App({ resumeSessionId, initialScreen }) {
1113
1186
  useInput((_input, key) => {
1114
1187
  if (key.escape) {
1115
1188
  abortRef.current?.abort();
1189
+ // The person's own `!` command: Esc stops just that (no turn was cut off, no shells below).
1190
+ if (userCommandRunning.current)
1191
+ return;
1116
1192
  // From here until the next turn starts, anything typed is for the NEXT turn.
1117
1193
  interrupting.current = true;
1118
1194
  // Anything still waiting to be asked is answered as declined. A queued approval
@@ -1122,8 +1198,10 @@ export function App({ resumeSessionId, initialScreen }) {
1122
1198
  // Esc means STOP — including anything running in the background (a starting app,
1123
1199
  // a dev server). Aborting the turn alone left those alive, so the app still opened.
1124
1200
  const mgr = session.current?.toolContext.backgroundShells;
1201
+ // Not an app the person started themselves with `!`: that one is theirs, and stops from its own window or /shells.
1125
1202
  for (const sh of mgr?.running() ?? [])
1126
- mgr?.kill(sh.id, "user");
1203
+ if (!isUserShell(mgr, sh.id))
1204
+ mgr?.kill(sh.id, "user");
1127
1205
  // And the app it was testing, with its live view and any hidden browser.
1128
1206
  if (session.current)
1129
1207
  void stopUi(session.current.toolContext);
@@ -1465,6 +1543,15 @@ export function App({ resumeSessionId, initialScreen }) {
1465
1543
  if (!chatRef.current)
1466
1544
  return;
1467
1545
  const { height } = measureElement(chatRef.current);
1546
+ // The footer grew or shrank. The frame already drawn kept the top of the window where it
1547
+ // was; the numbers for the next one hold the bottom. Moving the scroll by the same
1548
+ // amount in this update makes both show the same rows (see viewportScroll). A change of
1549
+ // WIDTH is the reflow's to handle, not this.
1550
+ const prev = chatRowsSeen.current;
1551
+ if (prev !== null && prev.width === width && prev.rows !== height) {
1552
+ setScrollUp((s) => viewportScroll(s, prev.rows, height));
1553
+ }
1554
+ chatRowsSeen.current = { rows: height, width };
1468
1555
  setChatHeight((h) => (h === height ? h : height));
1469
1556
  });
1470
1557
  /**
@@ -1584,11 +1671,73 @@ export function App({ resumeSessionId, initialScreen }) {
1584
1671
  const s = stateRef.current;
1585
1672
  const ids = [...s.committed, ...s.tail].map((b) => b.id);
1586
1673
  const grown = rowStore.current.text(g, ids, (id) => blockHeights.current.get(id)?.height);
1587
- if (grown.text)
1674
+ // Only real text: a drag over empty space gathers nothing but line breaks, and copies nothing.
1675
+ if (grown.text.trim())
1588
1676
  copyToClipboard(grown.text);
1589
1677
  if (!grown.complete)
1590
1678
  note("Part of what you selected scrolled past too fast to be copied. Select it again, scrolling more slowly.");
1591
1679
  }
1680
+ /**
1681
+ * The third click: the selected word becomes the whole line, in one step. The word stayed
1682
+ * highlighted until now (the press did not take it down), so there is no moment with nothing
1683
+ * selected between the two. What is copied is the line.
1684
+ */
1685
+ function selectLineOfWord() {
1686
+ const g = grow.current;
1687
+ const view = chatViewRows();
1688
+ const screen = latestScreen();
1689
+ if (!g || !view || !screen)
1690
+ return;
1691
+ const spans = blockSpans(frameTopRef.current);
1692
+ const y = rowOfKey(spans, { id: g.anchor.id, off: g.anchor.off });
1693
+ const line = y === null ? null : lineBounds(Array.from({ length: screen.width }, (_, cx) => screen.chars[screen.index(cx, y)]));
1694
+ if (line) {
1695
+ g.anchor = { ...g.anchor, x: line.from };
1696
+ g.focus = { ...g.focus, x: line.to };
1697
+ }
1698
+ rowStore.current.clear();
1699
+ const chip = pillHit.current;
1700
+ rowStore.current.capture(screen, spans, view, chip ? { y: chip.row, x0: chip.left, x1: chip.right + 1 } : null);
1701
+ repaintOverlay();
1702
+ copyGrown(g);
1703
+ }
1704
+ /** The last click on the conversation, to tell clicks in a row from separate ones. */
1705
+ const lastClick = useRef(null);
1706
+ /**
1707
+ * Clicking text selects it, and copies it, as a drag does. One click does nothing, so a stray
1708
+ * click never takes anything; two clicks in a row, each soon after the last, take the word under
1709
+ * the pointer; three take the whole line. A click that comes too late starts the count again.
1710
+ * Returns false when nothing was taken (a first click, or a blank), so the click can do what it
1711
+ * did before.
1712
+ */
1713
+ function selectClicked(x, y) {
1714
+ const click = nextClick(lastClick.current, Date.now(), x, y, DOUBLE_CLICK_MS);
1715
+ // Three is the most there is: the next click begins again.
1716
+ lastClick.current = click.count >= 3 ? null : click;
1717
+ if (click.count < 2)
1718
+ return false;
1719
+ const view = chatViewRows();
1720
+ const screen = latestScreen();
1721
+ if (!view || !screen || y < view.top || y >= view.bottom)
1722
+ return false;
1723
+ const spans = blockSpans(frameTopRef.current);
1724
+ const at = focusFromPointer(spans, view, x, y, screen.width);
1725
+ if (!at)
1726
+ return false;
1727
+ const cells = Array.from({ length: screen.width }, (_, cx) => screen.chars[screen.index(cx, y)]);
1728
+ const range = click.count >= 3 ? lineBounds(cells) : wordBounds(cells, x);
1729
+ if (!range)
1730
+ return false;
1731
+ const g = { anchor: { id: at.id, off: at.off, x: range.from }, focus: { id: at.id, off: at.off, x: range.to }, dragging: false };
1732
+ grow.current = g;
1733
+ rowStore.current.clear();
1734
+ const chip = pillHit.current;
1735
+ rowStore.current.capture(screen, spans, view, chip ? { y: chip.row, x0: chip.left, x1: chip.right + 1 } : null);
1736
+ setFrameOverlay(growOverlay);
1737
+ repaintOverlay();
1738
+ copyGrown(g);
1739
+ return true;
1740
+ }
1592
1741
  // Mouse reporting itself is switched on and off by — it belongs to
1593
1742
  // the shell, not to this component, because the inline shell must never have it on.
1594
1743
  // What is left here is the highlight, which has to come down when the app unmounts.
@@ -1682,6 +1831,12 @@ export function App({ resumeSessionId, initialScreen }) {
1682
1831
  // On PRESS rather than release: the chip is a single row and the view moves out
1683
1832
  // from under the pointer the instant it is hit, so a release-matched-to-press
1684
1833
  // would be testing the pointer against a screen that had already changed.
1834
+ // The queued line is a button too: a click on it takes the queue back into the box to edit.
1835
+ if (queueHitRow.current !== null && event.y === queueHitRow.current && pullQueue.current) {
1836
+ clearSelection();
1837
+ pullQueue.current();
1838
+ continue;
1839
+ }
1685
1840
  const hit = pillHit.current;
1686
1841
  if (hit && hitsPill(hit, event.x, event.y)) {
1687
1842
  clearSelection();
@@ -1689,6 +1844,22 @@ export function App({ resumeSessionId, initialScreen }) {
1689
1844
  setScrollUp(0);
1690
1845
  continue;
1691
1846
  }
1847
+ // The press that may be the THIRD click: a word is selected from the second, and this
1848
+ // lands where it did, soon enough. The word is left up (a new selection would take
1849
+ // it down) and the release decides: a click grows it to the line, a drag starts over.
1850
+ const before = lastClick.current;
1851
+ const word = grow.current;
1852
+ if (before &&
1853
+ before.count === 2 &&
1854
+ Date.now() - before.at <= DOUBLE_CLICK_MS &&
1855
+ before.y === event.y &&
1856
+ Math.abs(before.x - event.x) <= 2 &&
1857
+ word &&
1858
+ !word.dragging &&
1859
+ !isEmptyGrow(word)) {
1860
+ pendingThird.current = { x: event.x, y: event.y };
1861
+ continue;
1862
+ }
1692
1863
  // A press on the conversation starts a selection that follows it. Anywhere else (the
1693
1864
  // input box, the banner) it is the screen selection, which is what edits the input.
1694
1865
  if (startGrow(event.x, event.y)) {
@@ -1705,6 +1876,15 @@ export function App({ resumeSessionId, initialScreen }) {
1705
1876
  repaintOverlay();
1706
1877
  }
1707
1878
  else if (event.kind === "drag") {
1879
+ // The press that might have been a third click was the start of a drag: begin the
1880
+ // selection it was always going to be, from where it was pressed.
1881
+ if (pendingThird.current) {
1882
+ const at = pendingThird.current;
1883
+ pendingThird.current = null;
1884
+ lastClick.current = null;
1885
+ if (startGrow(at.x, at.y))
1886
+ selection.current = null;
1887
+ }
1708
1888
  if (grow.current?.dragging) {
1709
1889
  pointer.current = { x: event.x, y: event.y };
1710
1890
  pendingDrag = true;
@@ -1718,6 +1898,21 @@ export function App({ resumeSessionId, initialScreen }) {
1718
1898
  }
1719
1899
  else {
1720
1900
  pendingDrag = false;
1901
+ // The release of a press that kept the word up: a third click in time takes the line;
1902
+ // anything else is an ordinary first click.
1903
+ if (pendingThird.current) {
1904
+ pendingThird.current = null;
1905
+ const click = nextClick(lastClick.current, Date.now(), event.x, event.y, DOUBLE_CLICK_MS);
1906
+ if (click.count === 3) {
1907
+ lastClick.current = null;
1908
+ selectLineOfWord();
1909
+ }
1910
+ else {
1911
+ lastClick.current = click;
1912
+ clearSelection();
1913
+ }
1914
+ continue;
1915
+ }
1721
1916
  const g = grow.current;
1722
1917
  if (g) {
1723
1918
  stopEdge();
@@ -1735,9 +1930,13 @@ export function App({ resumeSessionId, initialScreen }) {
1735
1930
  clearSelection();
1736
1931
  const hitId = expandableAt(event.y, frameTopRef.current, chatRef.current);
1737
1932
  if (hitId !== null) {
1933
+ lastClick.current = null;
1738
1934
  toggleRow(hitId);
1739
1935
  continue;
1740
1936
  }
1937
+ // Plain text: two clicks in a row take the word, three take the line.
1938
+ if (selectClicked(event.x, event.y))
1939
+ continue;
1741
1940
  placeCaretAt(event.x, event.y);
1742
1941
  continue;
1743
1942
  }
@@ -1812,8 +2011,10 @@ export function App({ resumeSessionId, initialScreen }) {
1812
2011
  }
1813
2012
  function endTurn() {
1814
2013
  releaseLiveGates();
1815
- if (turnStart.current != null)
2014
+ // A `!` command is not the agent working: no "Worked for" receipt for it.
2015
+ if (turnStart.current != null && !userTurn.current)
1816
2016
  setLastMs(Date.now() - turnStart.current);
2017
+ userTurn.current = false;
1817
2018
  // Settle every tool row this turn produced into its past-tense verb. The single
1818
2019
  // funnel for a turn ending (normal completion, error, interrupt), so no row is
1819
2020
  // left reading "Reading" once nothing is being read.
@@ -2041,6 +2242,8 @@ export function App({ resumeSessionId, initialScreen }) {
2041
2242
  holdTimer.current = null;
2042
2243
  revealQ.current = [];
2043
2244
  pacer.current = newPacer(revealQ.current);
2245
+ departingRef.current = 0;
2246
+ setDeparting([]);
2044
2247
  }
2045
2248
  function enqueueReveal(a) {
2046
2249
  revealQ.current.push(a);
@@ -2090,6 +2293,13 @@ export function App({ resumeSessionId, initialScreen }) {
2090
2293
  // same de-sync risk a reveal carries, so the banner is healed on the same frame.
2091
2294
  requestFullRepaint();
2092
2295
  applyBatch(actions);
2296
+ // A steered message that has just reached the chat stops showing in the queue line.
2297
+ const arrived = departingRef.current > 0 ? actions.filter((a) => a.type === "user").length : 0;
2298
+ if (arrived > 0) {
2299
+ const n = Math.min(arrived, departingRef.current);
2300
+ departingRef.current -= n;
2301
+ setDeparting((d) => d.slice(n));
2302
+ }
2093
2303
  }
2094
2304
  function pace() {
2095
2305
  return {
@@ -2177,23 +2387,35 @@ export function App({ resumeSessionId, initialScreen }) {
2177
2387
  * rows around it.
2178
2388
  */
2179
2389
  async function steerRunningTurn(s) {
2390
+ // Never into a turn that has been stopped (Esc, ctrl+F, an interrupted command). Handed over at its
2391
+ // last step boundary, a message queued BEFORE the stop was drawn in the chat, put in the transcript
2392
+ // and never answered, because nothing runs after a stopped turn. It stays queued and goes out as
2393
+ // the next turn, which is what the person who typed it expects.
2394
+ if (abortRef.current?.signal.aborted)
2395
+ return [];
2180
2396
  const { send, rest } = takeSteerable(queueRef.current);
2181
2397
  if (send.length === 0)
2182
2398
  return [];
2183
2399
  queueRef.current = rest;
2184
- setQueued(rest);
2185
2400
  const out = [];
2401
+ const shown = [];
2186
2402
  for (const { text } of send) {
2187
2403
  // No history write here: `onSend` records every message the moment it is typed,
2188
2404
  // queued or not, so ↑ walks them in the order they were written rather than the
2189
2405
  // order they happened to go out. Recording again on the way out appended each one
2190
2406
  // a second time.
2191
2407
  const { content, displayText, notes, images } = await prepareMessage(s, text);
2192
- enqueueReveal({ type: "user", text: displayText });
2193
- for (const n of notes)
2194
- enqueueReveal({ type: "note", text: n });
2408
+ shown.push({ type: "user", text: displayText }, ...notes.map((n) => ({ type: "note", text: n })));
2195
2409
  out.push({ content, ...(images.length > 0 ? { images } : {}) });
2196
2410
  }
2411
+ // The message leaves the queue line in the same paint it reaches the chat, so it is never on
2412
+ // neither. Behind other rows it waits its beat in the chat's order, so the queue line keeps
2413
+ // showing it (`departing`) until then.
2414
+ setDeparting((d) => [...d, ...send]);
2415
+ departingRef.current += send.length;
2416
+ setQueued([...queueRef.current]);
2417
+ for (const a of shown)
2418
+ enqueueReveal(a);
2197
2419
  return out;
2198
2420
  }
2199
2421
  async function streamRespond(s, run) {
@@ -2239,7 +2461,7 @@ export function App({ resumeSessionId, initialScreen }) {
2239
2461
  // loading a tool is never shown at all (see mcpSearch.ts).
2240
2462
  if (e.name === "spawn_subagent" || neverShown(e.name))
2241
2463
  return;
2242
- const d = toolDisplay(e.name, e.args);
2464
+ const d = toolDisplay(e.name, e.args, session.current?.cwd ?? process.cwd());
2243
2465
  if (e.agent) {
2244
2466
  // A sub-agent's own tool call — fold it into that worker's nested rail.
2245
2467
  enqueueReveal({ type: "subToolStart", agentId: e.agent, toolId: e.id, name: d.name, arg: d.arg, action: d.kind });
@@ -2361,6 +2583,54 @@ export function App({ resumeSessionId, initialScreen }) {
2361
2583
  pump(); // ensure we drain to endTurn even if idle now
2362
2584
  }
2363
2585
  }
2586
+ /**
2587
+ * A command the person typed after `!`: run it now, in the project's shell, as THEIRS.
2588
+ *
2589
+ * It is drawn as a row of its own, `You ran 1 command`, that opens on the command and its output
2590
+ * exactly as a command the agent ran does. It goes through the same pipeline as a turn only as far
2591
+ * as the screen is concerned: the box is busy while it runs (so Esc stops it, and anything typed
2592
+ * meanwhile is queued), and the row is revealed in order. No turn starts and the model is not
2593
+ * called: the agent reads the command and its output on its next turn, from the entry
2594
+ * `runUserCommand` adds to the conversation.
2595
+ */
2596
+ async function runShellForUser(s, command) {
2597
+ startUserTurn();
2598
+ userCommandRunning.current = true;
2599
+ resetPacer();
2600
+ lastRevealAt.current = 0;
2601
+ streamDone.current = false;
2602
+ flush.current = false;
2603
+ const toolId = `user-${crypto.randomUUID()}`;
2604
+ enqueueReveal({ type: "toolStart", toolId, name: "Run", arg: command, action: "run", group: true, at: Date.now(), byUser: true });
2605
+ try {
2606
+ const { result } = await runUserCommand(s, command, {
2607
+ signal: abortRef.current?.signal,
2608
+ onProgress: (text) => enqueueReveal({ type: "toolProgress", toolId, text }),
2609
+ });
2610
+ enqueueReveal({
2611
+ type: "toolEnd",
2612
+ toolId,
2613
+ ok: !result.isError,
2614
+ summary: result.summary,
2615
+ detail: result.detail,
2616
+ detailKind: result.detailKind,
2617
+ detailFull: result.detailFull,
2618
+ });
2619
+ }
2620
+ catch (error) {
2621
+ enqueueReveal({ type: "toolEnd", toolId, ok: false, summary: `could not run: ${errText(error)}` });
2622
+ }
2623
+ finally {
2624
+ userCommandRunning.current = false;
2625
+ // The agent answers it, unless the person stopped it themselves with Esc (they ended it on purpose).
2626
+ if (!abortRef.current?.signal.aborted)
2627
+ wakeAfterUserCommand.current = true;
2628
+ await saveSession(s);
2629
+ streamDone.current = true;
2630
+ if (!pumpTimer.current)
2631
+ pump(); // drain to endTurn even if the row is already shown
2632
+ }
2633
+ }
2364
2634
  /**
2365
2635
  * Run a Marathon through this screen's own turn pipeline. It is the same `respond` loop
2366
2636
  * as any message, driven by `startMarathon`/`resumeMarathon`, so every tool row, token and
@@ -2818,7 +3088,11 @@ export function App({ resumeSessionId, initialScreen }) {
2818
3088
  if (name === "/help") {
2819
3089
  const skills = s.governance.skills ?? [];
2820
3090
  const mcpPrompts = s.toolContext.mcp?.promptCatalog() ?? [];
2821
- say(formatHelp([
3091
+ // In the full-screen shell the list is set apart from the conversation (a click takes it
3092
+ // away) instead of said into it: a list this long, drawn like the agent's own words, was
3093
+ // hard to tell from what the agent said. The inline shell prints into the terminal's own
3094
+ // scrollback, where nothing can be taken back, so it keeps saying it.
3095
+ const helpText = formatHelp([
2822
3096
  { title: "Commands", commands: BASE_COMMANDS },
2823
3097
  {
2824
3098
  title: "Project skills",
@@ -2831,7 +3105,11 @@ export function App({ resumeSessionId, initialScreen }) {
2831
3105
  description: p.description || `prompt from ${p.server}`,
2832
3106
  })),
2833
3107
  },
2834
- ]));
3108
+ ]);
3109
+ if (shell === "inline")
3110
+ say(helpText);
3111
+ else
3112
+ dispatch({ type: "help", text: helpText });
2835
3113
  return;
2836
3114
  }
2837
3115
  // /update — take the newer version and come back to this same conversation.
@@ -3511,6 +3789,20 @@ export function App({ resumeSessionId, initialScreen }) {
3511
3789
  const trimmed = value.trim();
3512
3790
  if (trimmed.length === 0 || busy || !ready)
3513
3791
  return;
3792
+ // `!` runs what follows in the project's shell, as the person's own command: no question, no
3793
+ // turn, nothing said to the model. See runShellForUser.
3794
+ if (isUserCommandText(trimmed)) {
3795
+ const s = session.current;
3796
+ if (!s)
3797
+ return;
3798
+ const command = parseUserCommand(trimmed);
3799
+ if (!command) {
3800
+ note("Type the command after the !, for example: ! git status");
3801
+ return;
3802
+ }
3803
+ await runShellForUser(s, command);
3804
+ return;
3805
+ }
3514
3806
  if (trimmed.startsWith("/")) {
3515
3807
  // Submitting clears the input, which closes the command list; the surface the command
3516
3808
  // opens can only arrive after the work it does first (reading sessions, refreshing
@@ -3633,6 +3925,36 @@ export function App({ resumeSessionId, initialScreen }) {
3633
3925
  }
3634
3926
  void handleSubmit(text);
3635
3927
  }
3928
+ /**
3929
+ * ctrl+F: send NOW, the way the queue sends, at the first moment that loses nothing.
3930
+ *
3931
+ * It is the queue, not a second mechanism: the message goes in the same queue and reaches the agent
3932
+ * by the same road (handed to the running turn when its step ends). What it adds is a way to make
3933
+ * that moment come SOONER, and the only thing that holds a step up for long without anyone's
3934
+ * leave is a command the turn is waiting on. That is moved to the background, as ctrl+X moves it:
3935
+ * it keeps running, the step ends at once, the message arrives, and the agent is told the command
3936
+ * is still going and hears when it ends. Nothing is stopped, killed or thrown away; only Esc, or
3937
+ * the person asking, does that.
3938
+ *
3939
+ * A reply being written, a compaction or a sub-agent has no such handle and is left alone: the
3940
+ * message arrives the moment it ends, exactly as it would have through Enter.
3941
+ */
3942
+ function forceSend(text) {
3943
+ if (!busy && queueRef.current.length === 0) {
3944
+ // Idle: nothing is in the way, so it is Enter.
3945
+ if (text)
3946
+ onSend(text);
3947
+ return;
3948
+ }
3949
+ if (text) {
3950
+ setScrollUp(0);
3951
+ setHistory((h) => (h[h.length - 1] === text ? h : [...h, text]));
3952
+ queueRef.current.push(queueMessage(text, { interrupting: interrupting.current }));
3953
+ setQueued([...queueRef.current]);
3954
+ }
3955
+ if (busy)
3956
+ pressBackground(session.current?.toolContext.foregroundCommands);
3957
+ }
3636
3958
  // Autocomplete entries: the built-in slash commands, this project's skills, and any
3637
3959
  // prompts the connected MCP servers offer (all read from the live session, so a freshly
3638
3960
  // created skill — or a server that just finished connecting — shows up next render).
@@ -3685,36 +4007,36 @@ export function App({ resumeSessionId, initialScreen }) {
3685
4007
  return null;
3686
4008
  const cur = session.current;
3687
4009
  if (overlay.kind === "app") {
3688
- return (_jsx(Picker, { title: "Mindweave desktop app", note: APP_EXPLANATION, items: [{ label: "Open the download page", description: "opens your default browser" }], width: width, maxRows: maxRows, initialIndex: 0, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
4010
+ return (_jsx(Picker, { title: "Mindweave desktop app", note: APP_EXPLANATION, items: [{ label: "Open the download page", description: "opens your default browser" }], width: width, maxRows: maxRows, initialIndex: 0, onSelect: onOverlaySelect, onCancel: onOverlayCancel }, overlay.kind));
3689
4011
  }
3690
4012
  if (overlay.kind === "rewind") {
3691
4013
  const items = overlay.items.map((p) => ({
3692
4014
  label: p.text,
3693
4015
  description: [timeAgo(p.at), changeSummary(p)].filter(Boolean).join(" · "),
3694
4016
  }));
3695
- return (_jsx(Picker, { title: "Rewind to before which message?", items: items, width: width, maxRows: maxRows, rightAlignDescription: true, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
4017
+ return (_jsx(Picker, { title: "Rewind to before which message?", items: items, width: width, maxRows: maxRows, rightAlignDescription: true, onSelect: onOverlaySelect, onCancel: onOverlayCancel }, overlay.kind));
3696
4018
  }
3697
4019
  if (overlay.kind === "rewindConfirm") {
3698
4020
  const p = overlay.point;
3699
4021
  const quoted = p.text.length > 48 ? `${p.text.slice(0, 47)}…` : p.text;
3700
- return (_jsx(Picker, { title: `Rewind to before “${quoted}”?`, items: rewindChoices(p).map(({ label, description }) => ({ label, description })), describeSelection: true, width: width, maxRows: maxRows, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
4022
+ return (_jsx(Picker, { title: `Rewind to before “${quoted}”?`, items: rewindChoices(p).map(({ label, description }) => ({ label, description })), describeSelection: true, width: width, maxRows: maxRows, onSelect: onOverlaySelect, onCancel: onOverlayCancel }, overlay.kind));
3701
4023
  }
3702
4024
  if (overlay.kind === "sessions") {
3703
4025
  const items = overlay.items.map((m) => ({
3704
4026
  label: sessionTitle(m),
3705
4027
  description: `${timeAgo(m.updatedAt)} · ${m.entryCount} msg${m.entryCount === 1 ? "" : "s"}`,
3706
4028
  }));
3707
- return (_jsx(Picker, { title: "Continue which session?", items: items, width: width, maxRows: maxRows, rightAlignDescription: true, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
4029
+ return (_jsx(Picker, { title: "Continue which session?", items: items, width: width, maxRows: maxRows, rightAlignDescription: true, onSelect: onOverlaySelect, onCancel: onOverlayCancel }, overlay.kind));
3708
4030
  }
3709
4031
  if (overlay.kind === "resumeMode") {
3710
- return (_jsx(Picker, { title: `Continue “${sessionTitle(overlay.meta)}” — how?`, items: RESUME_MODES, width: width, maxRows: maxRows, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
4032
+ return (_jsx(Picker, { title: `Continue “${sessionTitle(overlay.meta)}” — how?`, items: RESUME_MODES, width: width, maxRows: maxRows, onSelect: onOverlaySelect, onCancel: onOverlayCancel }, overlay.kind));
3711
4033
  }
3712
4034
  if (overlay.kind === "shells") {
3713
4035
  const items = overlay.items.map((sh) => ({
3714
4036
  label: `#${sh.id} ${clipCmd(sh.command)}`,
3715
4037
  description: sh.status === "running" ? `running ${shellElapsed(sh)} — Enter to stop` : sh.status === "killed" ? "killed" : `exited ${sh.exitCode}`,
3716
4038
  }));
3717
- return (_jsx(Picker, { title: "Background shells", items: items, width: width, maxRows: maxRows, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
4039
+ return (_jsx(Picker, { title: "Background shells", items: items, width: width, maxRows: maxRows, onSelect: onOverlaySelect, onCancel: onOverlayCancel }, overlay.kind));
3718
4040
  }
3719
4041
  if (overlay.kind === "provider") {
3720
4042
  const activeModel = cur?.modelConfig.model ?? DEFAULT_MODEL_CONFIG.model;
@@ -3735,7 +4057,7 @@ export function App({ resumeSessionId, initialScreen }) {
3735
4057
  const here = p.id === active ? ` · on ${activeLabel}` : "";
3736
4058
  return { label: p.label + (p.id === active ? " ✓" : ""), description: `${models} · ${key}${here}` };
3737
4059
  });
3738
- return (_jsx(Picker, { title: "Choose a provider", items: items, width: width, maxRows: maxRows, initialIndex: Math.max(0, providers.findIndex((p) => p.id === active)), onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
4060
+ return (_jsx(Picker, { title: "Choose a provider", items: items, width: width, maxRows: maxRows, searchBox: providerSearch, initialIndex: Math.max(0, providers.findIndex((p) => p.id === active)), onSelect: onOverlaySelect, onCancel: onOverlayCancel }, overlay.kind));
3739
4061
  }
3740
4062
  if (overlay.kind === "model") {
3741
4063
  const id = cur?.modelConfig.model ?? DEFAULT_MODEL_CONFIG.model;
@@ -3759,14 +4081,14 @@ export function App({ resumeSessionId, initialScreen }) {
3759
4081
  keywords: m.id,
3760
4082
  };
3761
4083
  });
3762
- return (_jsx(Picker, { title: `Choose ${/^[aeiou]/i.test(pickerProvider) ? "an" : "a"} ${pickerProvider} model`, items: items, width: width, maxRows: maxRows, initialIndex: Math.max(0, models.findIndex((m) => m.id === id)), initialFilter: overlay.filter, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
4084
+ return (_jsx(Picker, { title: `Choose ${/^[aeiou]/i.test(pickerProvider) ? "an" : "a"} ${pickerProvider} model`, items: items, width: width, maxRows: maxRows, initialIndex: Math.max(0, models.findIndex((m) => m.id === id)), initialFilter: overlay.filter, searchBox: modelSearch, onSelect: onOverlaySelect, onCancel: onOverlayCancel }, overlay.kind));
3763
4085
  }
3764
4086
  if (overlay.kind === "think") {
3765
4087
  const model = cur?.modelConfig.model ?? DEFAULT_MODEL_CONFIG.model;
3766
4088
  const levels = thinkLevels(model);
3767
4089
  const curLabel = cur ? thinkLabel(cur.modelConfig) : "";
3768
4090
  const items = levels.map((l) => ({ label: l.label + (l.label === curLabel ? " ✓" : ""), description: l.description }));
3769
- return (_jsx(Picker, { title: `Reasoning for ${modelLabel(model)}`, items: items, width: width, maxRows: maxRows, initialIndex: Math.max(0, levels.findIndex((l) => l.label === curLabel)), onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
4091
+ return (_jsx(Picker, { title: `Reasoning for ${modelLabel(model)}`, items: items, width: width, maxRows: maxRows, initialIndex: Math.max(0, levels.findIndex((l) => l.label === curLabel)), onSelect: onOverlaySelect, onCancel: onOverlayCancel }, overlay.kind));
3770
4092
  }
3771
4093
  if (overlay.kind === "screen") {
3772
4094
  const choices = screenChoices(shell);
@@ -3776,7 +4098,7 @@ export function App({ resumeSessionId, initialScreen }) {
3776
4098
  describeSelection: true,
3777
4099
  // Opens on the one in use, so Enter alone changes nothing. A chooser that opens
3778
4100
  // somewhere else turns a glance at the options into an accidental switch.
3779
- initialIndex: Math.max(0, choices.findIndex((c) => c.mode === shell)), onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
4101
+ initialIndex: Math.max(0, choices.findIndex((c) => c.mode === shell)), onSelect: onOverlaySelect, onCancel: onOverlayCancel }, overlay.kind));
3780
4102
  }
3781
4103
  // approval — a plan, a Sentinel action, a forbidden-path lift. It interrupts the user's
3782
4104
  // work and the answer commits them to something, so it reads as a stop; it renders in
@@ -3872,6 +4194,21 @@ export function App({ resumeSessionId, initialScreen }) {
3872
4194
  // surface — the pickers, the key manager, and the approval prompt — is content-only and
3873
4195
  // renders inside the input's one menu box below the input line. There is no separate
3874
4196
  // "boxed" overlay any more; the box is always the same, only its contents change.
4197
+ // A list of models is searched, not scrolled (OpenRouter's catalogue is nearly three hundred),
4198
+ // and the words are typed in the input box, which becomes the search field for as long as the
4199
+ // list is open (see pickerSearch.ts). The same for every provider, so typing never lands on
4200
+ // the picker's own bottom line while the chat box sits empty.
4201
+ const modelSearchLabel = overlay?.kind === "model"
4202
+ ? (overlay.providerId ? allProviders().find((p) => p.id === overlay.providerId)?.label : undefined) ??
4203
+ providerOf(session.current?.modelConfig.model ?? DEFAULT_MODEL_CONFIG.model).label
4204
+ : undefined;
4205
+ const modelSearch = modelSearchLabel !== undefined;
4206
+ // The provider list is searched the same way.
4207
+ const providerSearch = overlay?.kind === "provider";
4208
+ // The keys manager opens on its provider list, which is searched the same way, unless it was
4209
+ // reached by picking a provider that has no key (it opens on that provider's key field).
4210
+ const keysSearch = keysOpen && !pendingSwitch.current;
4211
+ const searchPlaceholder = modelSearch ? `Search ${modelSearchLabel} models…` : providerSearch || keysSearch ? "Search providers…" : undefined;
3875
4212
  const overlayView = buildOverlayView(maxMenuItems);
3876
4213
  const runningShells = session.current?.toolContext.backgroundShells?.running() ?? [];
3877
4214
  // Where the transcript sits in the viewport, and how far it can travel. Extracted
@@ -3908,6 +4245,7 @@ export function App({ resumeSessionId, initialScreen }) {
3908
4245
  const frameTop = readingInline ? Math.max(0, rows - frameHeight) : 0;
3909
4246
  frameTopRef.current = frameTop;
3910
4247
  pillHit.current = pill !== null && pillRow.current !== null ? pillBounds(pill, width, frameTop + pillRow.current) : null;
4248
+ queueHitRow.current = queueRow.current !== null ? frameTop + queueRow.current : null;
3911
4249
  // The whole conversation stays reachable: the cap is only a safety valve (see
3912
4250
  // SCROLLBACK_BLOCKS). Blocks outside the window cost one number each here and nothing
3913
4251
  // in Yoga, which only ever sees the window and its spacers.
@@ -4009,11 +4347,15 @@ export function App({ resumeSessionId, initialScreen }) {
4009
4347
  // The status line, the queued bar, the input and whatever sits under it. Built once
4010
4348
  // and rendered by BOTH shells: the only thing that differs between them is where it
4011
4349
  // ends up — pinned to the bottom of a frame we own, or simply the last thing printed.
4012
- const footerView = (_jsxs(Box, { ref: footerRef, flexDirection: "column", flexShrink: 0, children: [_jsx(Box, { flexShrink: 0, children: _jsx(Text, { children: " " }) }), _jsx(Box, { flexShrink: 0, children: _jsx(StatusLine, { busy: busy, startedAt: turnStart.current, lastMs: lastMs, usage: taskUsage, received: liveTokens, advance: advanceTokens, waiting: overlay?.kind === "approval", waited: () => waitedMs.current + (waitSince.current != null ? Date.now() - waitSince.current : 0) }) }), _jsx(Box, { flexShrink: 0, children: _jsx(QueuedBar, { queued: queued }) }), marathonUi ? (_jsx(Box, { flexShrink: 0, children: _jsx(MarathonBox, { ui: marathonUi, width: width, cap: shell === "inline" ? 4 : 7 }) })) : null, _jsx(Box, { flexShrink: 0, flexDirection: "column", children: ready ? (_jsx(PromptInput, { onSubmit: onSend, opening: opening, disabled: false, placeholder: marathonUi?.phase === "armed" ? "describe the goal…" : busy ? "type to queue a message…" : "say something…", width: width, history: history, completions: completions, pathComplete: pathComplete.current, onLargePaste: registerPaste, onDroppedPaths: (text) => dropHandles.current.register(text), registerCaretClick: (place) => {
4350
+ const footerView = (_jsxs(Box, { ref: footerRef, flexDirection: "column", flexShrink: 0, children: [_jsx(Box, { flexShrink: 0, children: _jsx(Text, { children: " " }) }), _jsx(Box, { flexShrink: 0, marginBottom: queued.length + departing.length > 0 ? 1 : 0, ref: (node) => {
4351
+ queueRow.current = node && queued.length + departing.length > 0 ? measureElement(node).y : null;
4352
+ }, children: _jsx(QueuedBar, { queued: [...departing, ...queued] }) }), _jsx(Box, { flexShrink: 0, children: _jsx(StatusLine, { busy: busy, userCommand: busy && userTurn.current, startedAt: turnStart.current, lastMs: lastMs, usage: taskUsage, received: liveTokens, advance: advanceTokens, waiting: overlay?.kind === "approval", waited: () => waitedMs.current + (waitSince.current != null ? Date.now() - waitSince.current : 0) }) }), marathonUi ? (_jsx(Box, { flexShrink: 0, children: _jsx(MarathonBox, { ui: marathonUi, width: width, cap: shell === "inline" ? 4 : 7 }) })) : null, _jsx(Box, { flexShrink: 0, flexDirection: "column", children: ready ? (_jsx(PromptInput, { onSubmit: onSend, onForceSend: forceSend, opening: opening, disabled: false, placeholder: marathonUi?.phase === "armed" ? "describe the goal…" : busy ? "type to queue a message…" : "say something…", width: width, history: history, completions: completions, pathComplete: pathComplete.current, onLargePaste: registerPaste, onDroppedPaths: (text) => dropHandles.current.register(text), registerCaretClick: (place) => {
4013
4353
  caretClick.current = place;
4014
4354
  }, registerTextSelect: (select) => {
4015
4355
  textSelect.current = select;
4016
- }, maxMenuRows: maxMenuItems, menuAbove: shell === "inline", settleKey: committed.length, onMenuChange: onMenuChange, placeCursor: shell === "inline", onQueuePop: popQueue, overlay: overlayView, fill: fill })) : (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "starting\u2026" }) })) }), ctxWarn ? (
4356
+ }, registerQueuePull: (pull) => {
4357
+ pullQueue.current = pull;
4358
+ }, maxMenuRows: maxMenuItems, menuAbove: shell === "inline", settleKey: committed.length, onMenuChange: onMenuChange, placeCursor: shell === "inline", onQueuePop: popQueue, overlay: overlayView, ...(searchPlaceholder ? { searchPlaceholder } : {}), fill: fill })) : (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "starting\u2026" }) })) }), ctxWarn ? (
4017
4359
  // A compaction is near. This takes the line over the tip and the background bar:
4018
4360
  // it is the one thing here the user needs BEFORE it happens, since the summarizing
4019
4361
  // pass rewrites the conversation. Dim as it approaches, then a plain warning colour
@@ -4511,22 +4853,33 @@ function timeAgo(ms) {
4511
4853
  * line above separating it from the conversation, where the
4512
4854
  * receipt belongs to the prompt area, not glued to the last reply.
4513
4855
  */
4514
- function StatusLine({ busy, startedAt, lastMs, usage, received, advance, waiting, waited, }) {
4856
+ function StatusLine({ busy, userCommand, startedAt, lastMs, usage, received, advance, waiting, waited, }) {
4515
4857
  // The render clock while busy: it advances the elapsed timer AND steps the eased token
4516
4858
  // counter one frame. It runs at about the display's pace (16ms): the number reads as counting
4517
4859
  // rather than as jumping, and the working dot moves smoothly. It was 50ms (20 frames a
4518
4860
  // second), which was the slowest-looking thing on a screen that could draw three times as
4519
4861
  // often, and at 1Hz before that it moved once a second in whatever lump had arrived, which
4520
- // is a stutter, not an animation. A tick costs about a millisecond now that unchanged rows
4521
- // are not rebuilt (see inkOutputPatch.ts). Nothing else re-renders with it: the tick is this
4522
- // component's own state and the figure is read through a getter, so the cost is one
4523
- // small subtree per frame.
4862
+ // is a stutter, not an animation. Only the easing advances on every tick; a render is asked for
4863
+ // only when the line's text would change (see below), because a tick that draws the same text
4864
+ // still costs a whole pass through layout and the terminal writer, about a millisecond each,
4865
+ // sixty times a second, for the whole time the agent works.
4524
4866
  const [, tick] = useState(0);
4867
+ // The line as the last render drew it, and the line as it would be drawn now. The clock below runs
4868
+ // at the display's pace to keep the counter easing smoothly, but a render that would draw the same
4869
+ // text is a whole pass through layout and the terminal writer for nothing, and most ticks are that:
4870
+ // the seconds change once a second and the count only when the eased figure moves a whole step.
4871
+ // Read through a ref so the interval never has to be torn down when the props change.
4872
+ const drawn = useRef("");
4873
+ const nowKey = useRef(() => "");
4874
+ nowKey.current = () => (busy && startedAt != null ? statusKey(userCommand, waiting, startedAt, waited(), received()) : "");
4875
+ drawn.current = nowKey.current();
4525
4876
  useEffect(() => {
4526
4877
  if (!busy)
4527
4878
  return;
4528
4879
  const id = setInterval(() => {
4529
4880
  advance();
4881
+ if (nowKey.current() === drawn.current)
4882
+ return;
4530
4883
  tick((t) => t + 1);
4531
4884
  }, LIVE_TICK_MS);
4532
4885
  return () => clearInterval(id);
@@ -4544,7 +4897,7 @@ function StatusLine({ busy, startedAt, lastMs, usage, received, advance, waiting
4544
4897
  // did. Output is
4545
4898
  // also the only thing that grows continuously while a turn runs, so it animates cleanly.
4546
4899
  const got = received();
4547
- label = (_jsxs(Text, { children: [" ", waiting ? "Waiting for your answer" : workingVerb(startedAt, Date.now() - startedAt - waited()), "\u2026", " ", _jsxs(Text, { dimColor: true, children: ["< ", fmtElapsed(secs), got > 0 ? ` · ↓ ${formatTokens(got)} tokens` : "", " >"] })] }));
4900
+ label = userCommand ? (_jsxs(Text, { children: [" ", "Running your command\u2026", " ", _jsxs(Text, { dimColor: true, children: ["< ", fmtElapsed(secs), " >"] })] })) : (_jsxs(Text, { children: [" ", waiting ? "Waiting for your answer" : workingVerb(startedAt, Date.now() - startedAt - waited()), "\u2026", " ", _jsxs(Text, { dimColor: true, children: ["< ", fmtElapsed(secs), got > 0 ? ` · ↓ ${formatTokens(got)} tokens` : "", " >"] })] }));
4548
4901
  }
4549
4902
  else if (lastMs != null) {
4550
4903
  // Settled receipt: elapsed time and the turn's real token cost, shown at once,
@@ -4572,9 +4925,21 @@ function StatusLine({ busy, startedAt, lastMs, usage, received, advance, waiting
4572
4925
  return (_jsxs(Box, { marginBottom: 0, children: [dot, label] }));
4573
4926
  }
4574
4927
  /** How often the view scrolls while a drag is held past the edge of the conversation. */
4928
+ /** The longest wait between two clicks for them to count as a double (or triple) click. */
4929
+ const DOUBLE_CLICK_MS = 500;
4575
4930
  const EDGE_SCROLL_MS = 45;
4576
4931
  /** The working line's render clock, in milliseconds. See StatusLine's tick. */
4577
4932
  const LIVE_TICK_MS = 16;
4933
+ /**
4934
+ * Everything the working line's text is made of, as one string (pure). Two equal keys draw the same
4935
+ * line, so a render for an unchanged key can be skipped. Built from the same calls the line uses.
4936
+ */
4937
+ export function statusKey(userCommand, waiting, startedAt, waitedMs, got) {
4938
+ const secs = workedSeconds(Date.now(), startedAt, waitedMs);
4939
+ if (userCommand)
4940
+ return `u|${fmtElapsed(secs)}`;
4941
+ return `${waiting ? "w" : workingVerb(startedAt, Date.now() - startedAt - waitedMs)}|${fmtElapsed(secs)}|${got > 0 ? formatTokens(got) : ""}`;
4942
+ }
4578
4943
  function fmtElapsed(s) {
4579
4944
  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
4580
4945
  }
@@ -4640,11 +5005,18 @@ function bgClock(ms) {
4640
5005
  * you cannot see the handle on — ↑ is not a thing anyone guesses, and the cost of not
4641
5006
  * guessing it is a message you no longer wanted being sent anyway.
4642
5007
  */
5008
+ /**
5009
+ * What is waiting to be sent, on ONE line shaped like a work row: the marker, what it is, a dot
5010
+ * between the parts, the key that sends it, and the arrow that opens it (a click pulls the queue back
5011
+ * into the box to edit, as the up key does). Long text is cut so the key and the arrow always stay
5012
+ * on the line.
5013
+ */
4643
5014
  function QueuedBar({ queued }) {
4644
5015
  if (queued.length === 0)
4645
5016
  return null;
4646
- const { rows, hidden } = visibleQueue(queued);
4647
- return (_jsxs(Box, { flexDirection: "column", children: [rows.map((q, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["⏎ queued: ", q.text] }, i))), hidden > 0 ? (_jsx(Text, { dimColor: true, children: ` …and ${hidden} more` })) : null, _jsx(Text, { dimColor: true, children: ` ↑ to edit ${queued.length === 1 ? "it" : "them"}` })] }));
5017
+ const first = queued[0].text.replace(/\s+/g, " ").trim();
5018
+ const more = queued.length - 1;
5019
+ return (_jsxs(Box, { flexShrink: 0, children: [_jsx(Text, { dimColor: true, children: "● " }), _jsx(Box, { flexShrink: 1, children: _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["queued: ", first] }) }), _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { dimColor: true, children: [more > 0 ? ` · ${more} more` : "", " · ctrl+F to send now ", "▸"] }) })] }));
4648
5020
  }
4649
5021
  /**
4650
5022
  * Whether some OTHER installed provider has a key, so `/provider` is worth