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.
- package/dist/cli/App.js +410 -38
- package/dist/cli/App.js.map +1 -1
- package/dist/cli/altScreen.js +6 -0
- package/dist/cli/altScreen.js.map +1 -1
- package/dist/cli/blockHeights.js +3 -0
- package/dist/cli/blockHeights.js.map +1 -1
- package/dist/cli/chatAnchor.js +17 -0
- package/dist/cli/chatAnchor.js.map +1 -1
- package/dist/cli/components/BlockView.js +10 -1
- package/dist/cli/components/BlockView.js.map +1 -1
- package/dist/cli/components/FadeText.js +47 -0
- package/dist/cli/components/FadeText.js.map +1 -0
- package/dist/cli/components/KeyManager.js +63 -7
- package/dist/cli/components/KeyManager.js.map +1 -1
- package/dist/cli/components/Picker.js +31 -5
- package/dist/cli/components/Picker.js.map +1 -1
- package/dist/cli/components/PromptInput.js +62 -9
- package/dist/cli/components/PromptInput.js.map +1 -1
- package/dist/cli/components/ToolLine.js +11 -3
- package/dist/cli/components/ToolLine.js.map +1 -1
- package/dist/cli/components/WorkGroup.js +27 -10
- package/dist/cli/components/WorkGroup.js.map +1 -1
- package/dist/cli/diffRows.js +16 -0
- package/dist/cli/diffRows.js.map +1 -1
- package/dist/cli/entrance.js +95 -0
- package/dist/cli/entrance.js.map +1 -0
- package/dist/cli/framebuffer/parse.js +99 -16
- package/dist/cli/framebuffer/parse.js.map +1 -1
- package/dist/cli/framebuffer/writer.js +75 -34
- package/dist/cli/framebuffer/writer.js.map +1 -1
- package/dist/cli/help.js +7 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/messageQueue.js +7 -2
- package/dist/cli/messageQueue.js.map +1 -1
- package/dist/cli/pickerSearch.js +59 -0
- package/dist/cli/pickerSearch.js.map +1 -0
- package/dist/cli/selectionGrow.js +86 -3
- package/dist/cli/selectionGrow.js.map +1 -1
- package/dist/cli/toolDisplay.js +20 -5
- package/dist/cli/toolDisplay.js.map +1 -1
- package/dist/cli/transcript.js +24 -7
- package/dist/cli/transcript.js.map +1 -1
- package/dist/cli/wheelStep.js +2 -2
- package/dist/cli/workLines.js +26 -7
- package/dist/cli/workLines.js.map +1 -1
- package/dist/core/turnRunner.js +7 -1
- package/dist/core/turnRunner.js.map +1 -1
- package/dist/drivers/anthropic/client.js +16 -3
- package/dist/drivers/anthropic/client.js.map +1 -1
- package/dist/drivers/anthropic/manifest.js +10 -0
- package/dist/drivers/anthropic/manifest.js.map +1 -1
- package/dist/drivers/glm/manifest.js +9 -0
- package/dist/drivers/glm/manifest.js.map +1 -1
- package/dist/drivers/groq/manifest.js +5 -0
- package/dist/drivers/groq/manifest.js.map +1 -1
- package/dist/drivers/mistral/client.js +3 -0
- package/dist/drivers/mistral/client.js.map +1 -1
- package/dist/drivers/openai/client.js +3 -0
- package/dist/drivers/openai/client.js.map +1 -1
- package/dist/drivers/openai/manifest.js +5 -0
- package/dist/drivers/openai/manifest.js.map +1 -1
- package/dist/drivers/openaiCompat/wire.js +7 -2
- package/dist/drivers/openaiCompat/wire.js.map +1 -1
- package/dist/drivers/openrouter/client.js +6 -0
- package/dist/drivers/openrouter/client.js.map +1 -1
- package/dist/drivers/xai/client.js +9 -0
- package/dist/drivers/xai/client.js.map +1 -1
- package/dist/dynamo/contextWindow.js +64 -12
- package/dist/dynamo/contextWindow.js.map +1 -1
- package/dist/dynamo/engine.js +72 -22
- package/dist/dynamo/engine.js.map +1 -1
- package/dist/dynamo/prompt.js +9 -0
- package/dist/dynamo/prompt.js.map +1 -1
- package/dist/memory/compaction.js +17 -3
- package/dist/memory/compaction.js.map +1 -1
- package/dist/memory/localCommand.js +165 -0
- package/dist/memory/localCommand.js.map +1 -0
- package/dist/memory/session.js +4 -0
- package/dist/memory/session.js.map +1 -1
- package/dist/memory/store.js +4 -1
- package/dist/memory/store.js.map +1 -1
- package/dist/memory/types.js.map +1 -1
- package/dist/tools/backgroundShells.js +35 -7
- package/dist/tools/backgroundShells.js.map +1 -1
- package/dist/tools/deferredNative.js +24 -0
- package/dist/tools/deferredNative.js.map +1 -1
- package/dist/tools/detail.js +2 -0
- package/dist/tools/detail.js.map +1 -1
- package/dist/tools/mcpSearch.js +8 -2
- package/dist/tools/mcpSearch.js.map +1 -1
- package/dist/tools/rebuildDetail.js +49 -1
- package/dist/tools/rebuildDetail.js.map +1 -1
- package/dist/tools/registry.js +12 -12
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/runCommand.js +17 -6
- package/dist/tools/runCommand.js.map +1 -1
- package/dist/tools/shellTools.js +10 -23
- package/dist/tools/shellTools.js.map +1 -1
- package/dist/tools/untrusted.js +31 -0
- package/dist/tools/untrusted.js.map +1 -1
- package/dist/tools/userCommand.js +52 -0
- package/dist/tools/userCommand.js.map +1 -0
- package/dist/tools/viewImage.js +14 -2
- package/dist/tools/viewImage.js.map +1 -1
- package/dist/tools/webSearch.js +42 -20
- package/dist/tools/webSearch.js.map +1 -1
- 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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
},
|
|
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.
|
|
4521
|
-
//
|
|
4522
|
-
//
|
|
4523
|
-
//
|
|
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
|
|
4647
|
-
|
|
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
|