mindweave 3.2.7 → 3.3.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 +536 -36
- 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/blockBoxes.js +57 -0
- package/dist/cli/blockBoxes.js.map +1 -0
- package/dist/cli/blockHeights.js +3 -0
- package/dist/cli/blockHeights.js.map +1 -1
- package/dist/cli/chatAnchor.js +51 -0
- package/dist/cli/chatAnchor.js.map +1 -1
- package/dist/cli/commandBackground.js +16 -0
- package/dist/cli/commandBackground.js.map +1 -0
- package/dist/cli/components/BlockView.js +10 -1
- package/dist/cli/components/BlockView.js.map +1 -1
- 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 +57 -12
- package/dist/cli/components/PromptInput.js.map +1 -1
- package/dist/cli/components/ToolLine.js +6 -0
- package/dist/cli/components/ToolLine.js.map +1 -1
- package/dist/cli/components/WorkGroup.js +90 -19
- package/dist/cli/components/WorkGroup.js.map +1 -1
- package/dist/cli/expandHits.js +18 -0
- package/dist/cli/expandHits.js.map +1 -1
- package/dist/cli/framebuffer/frameHold.js +58 -0
- package/dist/cli/framebuffer/frameHold.js.map +1 -0
- package/dist/cli/framebuffer/writer.js +47 -6
- package/dist/cli/framebuffer/writer.js.map +1 -1
- package/dist/cli/help.js +9 -1
- 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 +327 -0
- package/dist/cli/selectionGrow.js.map +1 -0
- package/dist/cli/toolDisplay.js +40 -7
- 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 +107 -6
- 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/dynamo/engine.js +21 -7
- 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/dynamo/verify.js +21 -1
- package/dist/dynamo/verify.js.map +1 -1
- package/dist/mcp/connection.js +52 -9
- package/dist/mcp/connection.js.map +1 -1
- package/dist/mcp/manager.js +61 -42
- package/dist/mcp/manager.js.map +1 -1
- package/dist/mcp/resultView.js +116 -0
- package/dist/mcp/resultView.js.map +1 -0
- package/dist/mcp/transport/http.js +12 -4
- package/dist/mcp/transport/http.js.map +1 -1
- package/dist/mcp/transport/stdio.js +30 -5
- package/dist/mcp/transport/stdio.js.map +1 -1
- package/dist/mcp/transport/types.js +14 -0
- package/dist/mcp/transport/types.js.map +1 -1
- package/dist/memory/compaction.js +15 -3
- package/dist/memory/compaction.js.map +1 -1
- package/dist/memory/localCommand.js +138 -0
- package/dist/memory/localCommand.js.map +1 -0
- package/dist/memory/session.js +2 -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/detail.js +2 -0
- package/dist/tools/detail.js.map +1 -1
- package/dist/tools/foregroundCommands.js +67 -0
- package/dist/tools/foregroundCommands.js.map +1 -0
- package/dist/tools/mcpAdd.js +124 -16
- package/dist/tools/mcpAdd.js.map +1 -1
- package/dist/tools/mindweaveStatus.js +1 -1
- package/dist/tools/mindweaveStatus.js.map +1 -1
- package/dist/tools/rebuildDetail.js +49 -1
- package/dist/tools/rebuildDetail.js.map +1 -1
- package/dist/tools/runCommand.js +69 -28
- package/dist/tools/runCommand.js.map +1 -1
- package/dist/tools/untrusted.js +7 -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
|
@@ -77,10 +77,13 @@ import { parseScreenArg, screenChoices, screenNotice, startupMode } from "./scre
|
|
|
77
77
|
import { applyScreenMode } from "./screenShell.js";
|
|
78
78
|
import { saveScreenMode } from "./screenStore.js";
|
|
79
79
|
import { FULL_TAIL_BLOCKS, needsMeasure, pruneHeights, seedHeights } from "./blockHeights.js";
|
|
80
|
-
import { expandableAt, hitBlock, hitItem } from "./expandHits.js";
|
|
80
|
+
import { expandableAt, hitBlock, hitItem, revealNode } from "./expandHits.js";
|
|
81
81
|
import { shellNote } from "./shellNotes.js";
|
|
82
|
+
import { pressBackground } from "./commandBackground.js";
|
|
82
83
|
import { readFileSync, statSync } from "node:fs";
|
|
83
|
-
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";
|
|
84
87
|
import { shownText } from "../memory/compaction.js";
|
|
85
88
|
import { BASE_COMMANDS } from "./commands.js";
|
|
86
89
|
import { attachmentBudget, dismissMarathon } from "../core/turnRunner.js";
|
|
@@ -92,10 +95,13 @@ import { currentInstall, requestRestart, runUpdate } from "./updateRunner.js";
|
|
|
92
95
|
import { enableMouse, readMouse, readWheel } from "./mouse.js";
|
|
93
96
|
import { wheelLines } from "./wheelStep.js";
|
|
94
97
|
import { applySelection, ctrlCShouldCopy, isEmpty, selectionText } from "./selection.js";
|
|
98
|
+
import { applyGrowSelection, edgeLines, focusFromPointer, isEmptyGrow, lineBounds, nextClick, RowStore, rowOfKey, wordBounds } from "./selectionGrow.js";
|
|
99
|
+
import { blockSpans, registerBlockBox } from "./blockBoxes.js";
|
|
100
|
+
import { holdFrames, releaseFrames } from "./framebuffer/frameHold.js";
|
|
95
101
|
import { latestScreen, repaintOverlay, setFrameOverlay } from "./framebuffer/overlay.js";
|
|
96
102
|
import { requestFullRepaint } from "./framebuffer/writer.js";
|
|
97
103
|
import { copyToClipboard } from "./clipboard.js";
|
|
98
|
-
import { chatLayout, reflowScroll, growScroll } from "./chatAnchor.js";
|
|
104
|
+
import { chatLayout, reflowScroll, growScroll, revealScroll, viewportScroll } from "./chatAnchor.js";
|
|
99
105
|
import { growFill, INLINE_LIVE_RESERVE, NO_FILL } from "./startupFill.js";
|
|
100
106
|
import { setRowsBelowCaret } from "./exitCursor.js";
|
|
101
107
|
import { caretCell } from "./caretPark.js";
|
|
@@ -220,6 +226,26 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
220
226
|
// state: it is painted by the framebuffer overlay rather than by React, so changing it
|
|
221
227
|
// must not cost a render (see the pointer effect).
|
|
222
228
|
const selection = useRef(null);
|
|
229
|
+
// The selection that FOLLOWS THE CONVERSATION, for a drag that begins in the chat (see
|
|
230
|
+
// selectionGrow.ts): it keeps growing while the view scrolls, and copies all of it. A drag
|
|
231
|
+
// that begins anywhere else (the input box, the banner) is the screen selection above.
|
|
232
|
+
const grow = useRef(null);
|
|
233
|
+
// The rows that passed through the screen while it was alive, which is what gets copied.
|
|
234
|
+
const rowStore = useRef(new RowStore());
|
|
235
|
+
// Where the pointer is while the button is down, so scrolling can move the end of the
|
|
236
|
+
// selection even when the pointer itself does not move.
|
|
237
|
+
const pointer = useRef(null);
|
|
238
|
+
const edgeTimer = useRef(null);
|
|
239
|
+
const stopEdge = useCallback(() => {
|
|
240
|
+
if (edgeTimer.current)
|
|
241
|
+
clearInterval(edgeTimer.current);
|
|
242
|
+
edgeTimer.current = null;
|
|
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
|
+
}, []);
|
|
223
249
|
/**
|
|
224
250
|
* Drop the highlight, if there is one, and stop tinting frames.
|
|
225
251
|
*
|
|
@@ -230,12 +256,17 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
230
256
|
* then uninstall: the repaint is what takes the highlight off the screen.
|
|
231
257
|
*/
|
|
232
258
|
const clearSelection = useCallback(() => {
|
|
233
|
-
if (!selection.current)
|
|
259
|
+
if (!selection.current && !grow.current)
|
|
234
260
|
return;
|
|
235
261
|
selection.current = null;
|
|
262
|
+
grow.current = null;
|
|
263
|
+
rowStore.current.clear();
|
|
264
|
+
pointer.current = null;
|
|
265
|
+
stopEdge();
|
|
266
|
+
dropPendingThird();
|
|
236
267
|
repaintOverlay();
|
|
237
268
|
setFrameOverlay(null);
|
|
238
|
-
}, []);
|
|
269
|
+
}, [stopEdge, dropPendingThird]);
|
|
239
270
|
// PromptInput installs the handler that turns a click into a caret position; only it
|
|
240
271
|
// knows what its rows currently hold. Null until the input is on screen.
|
|
241
272
|
const caretClick = useRef(null);
|
|
@@ -336,12 +367,21 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
336
367
|
const [hoverId, setHoverId] = useState(null);
|
|
337
368
|
// Set when a row is opened or folded, read once by the height effect.
|
|
338
369
|
const rowToggled = useRef(false);
|
|
370
|
+
// Which place was pressed, and where the view stood before it was opened and after the view
|
|
371
|
+
// moved to show it, so that folding the same row can put the view back where it was.
|
|
372
|
+
const toggledHit = useRef(null);
|
|
373
|
+
// Whether the row pressed is the NEWEST one that can be opened, the only kind the view follows.
|
|
374
|
+
const toggledNewest = useRef(false);
|
|
375
|
+
const openedFrom = useRef(null);
|
|
339
376
|
const [footerHeight, setFooterHeight] = useState(0);
|
|
340
377
|
// The chat viewport's REAL height. Yoga decides it now (flexGrow beside a
|
|
341
378
|
// flexShrink:0 footer); this is read back purely so the scroll maths knows how
|
|
342
379
|
// much of the content is on screen.
|
|
343
380
|
const chatRef = useRef(null);
|
|
344
381
|
const [chatHeight, setChatHeight] = useState(0);
|
|
382
|
+
/** The chat viewport's height and the width it had when last measured, so a change of the
|
|
383
|
+
* footer's height can move the scroll with it. See viewportScroll. */
|
|
384
|
+
const chatRowsSeen = useRef(null);
|
|
345
385
|
// Bumped by PromptInput when its suggestion menu changes size. Its only job is
|
|
346
386
|
// to re-render App so the footer measurement below re-runs: the menu is
|
|
347
387
|
// PromptInput's own state, so App is not re-rendered by it and would otherwise
|
|
@@ -390,6 +430,17 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
390
430
|
}, []);
|
|
391
431
|
// Aborts the in-flight turn when the user presses Esc (created fresh per turn).
|
|
392
432
|
const abortRef = useRef(null);
|
|
433
|
+
/** A command the person typed after `!` is running (not an agent turn). Esc stops just that. */
|
|
434
|
+
const userCommandRunning = useRef(false);
|
|
435
|
+
/** The turn in progress is the person's own `!` command, not the agent working. */
|
|
436
|
+
const userTurn = useRef(false);
|
|
437
|
+
/**
|
|
438
|
+
* A command the person ran with `!` has just ended and the agent has not answered it yet. The agent
|
|
439
|
+
* is woken for it the way it is when a command of its own finishes: given the result, it carries on
|
|
440
|
+
* (that is the point: it can ask the person to run something and go on from what comes back).
|
|
441
|
+
* Cleared when any turn starts, since that turn reads the result anyway.
|
|
442
|
+
*/
|
|
443
|
+
const wakeAfterUserCommand = useRef(false);
|
|
393
444
|
// Which provider's key we still need, or null once we have it. Not a bare
|
|
394
445
|
// boolean: with more than one provider, the key we must ask for depends on the
|
|
395
446
|
// model the user is about to run, and switching models can make a different key
|
|
@@ -660,6 +711,7 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
660
711
|
// Each call's name and arguments by id, so a result from a session saved before the uncut
|
|
661
712
|
// text was kept can have it put back together (see rebuildDetail.ts).
|
|
662
713
|
const callsById = new Map();
|
|
714
|
+
let userRows = 0; // commands the person ran with `!`, to give each replayed row its own id
|
|
663
715
|
for (const e of transcript) {
|
|
664
716
|
if (e.role === "user") {
|
|
665
717
|
if (!e.synthetic)
|
|
@@ -670,6 +722,23 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
670
722
|
// calls…" sitting in their own chat history.
|
|
671
723
|
if (!e.synthetic)
|
|
672
724
|
dispatch({ type: "user", text: collapsePastes(stripAttachments(e.content)) });
|
|
725
|
+
// A command the person ran with `!` comes back as the row it was: what they ran, how it ended,
|
|
726
|
+
// and the output behind the click. The screen's record is kept whole even when the model's copy
|
|
727
|
+
// of the output was cleared to save context.
|
|
728
|
+
else if (e.localCommand) {
|
|
729
|
+
const rec = e.localCommand;
|
|
730
|
+
const toolId = `user-replay-${userRows++}`;
|
|
731
|
+
dispatch({ type: "toolStart", toolId, name: "Run", arg: rec.command, action: "run", group: true, byUser: true });
|
|
732
|
+
dispatch({
|
|
733
|
+
type: "toolEnd",
|
|
734
|
+
toolId,
|
|
735
|
+
ok: rec.ok,
|
|
736
|
+
summary: rec.summary,
|
|
737
|
+
detail: rec.detail,
|
|
738
|
+
detailKind: "shell",
|
|
739
|
+
...(rec.detailFull ? { detailFull: rec.detailFull } : {}),
|
|
740
|
+
});
|
|
741
|
+
}
|
|
673
742
|
}
|
|
674
743
|
else if (e.role === "summary") {
|
|
675
744
|
dispatch({ type: "note", text: "— resumed; earlier context summarized —" });
|
|
@@ -694,7 +763,7 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
694
763
|
// that was not in the live one.
|
|
695
764
|
if (call.name === "spawn_subagent" || neverShown(call.name))
|
|
696
765
|
continue;
|
|
697
|
-
const d = toolDisplay(call.name, parseToolArgs(call.arguments));
|
|
766
|
+
const d = toolDisplay(call.name, parseToolArgs(call.arguments), session.current?.cwd ?? process.cwd());
|
|
698
767
|
dispatch({
|
|
699
768
|
type: "toolStart",
|
|
700
769
|
toolId: call.id,
|
|
@@ -723,6 +792,12 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
723
792
|
if (compact)
|
|
724
793
|
return { detail: compact.detail, ...(compact.detailFull ? { detailFull: compact.detailFull } : {}) };
|
|
725
794
|
}
|
|
795
|
+
// A web search saved before it became a short row that opens on a click comes back as one.
|
|
796
|
+
if ((call?.name === "web_search" || call?.name === "web") && !e.detailFull) {
|
|
797
|
+
const compact = compactSearchDetail(e.detail);
|
|
798
|
+
if (compact)
|
|
799
|
+
return { detail: compact.detail, ...(compact.detailFull ? { detailFull: compact.detailFull } : {}) };
|
|
800
|
+
}
|
|
726
801
|
let full = e.detailFull ?? rebuildFull(call?.name, call?.args, e);
|
|
727
802
|
// A write whose text was cleared to save context has only the file left to open.
|
|
728
803
|
if (!full && call?.name === "write_file" && typeof call.args?.path === "string") {
|
|
@@ -969,10 +1044,14 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
969
1044
|
needsKey,
|
|
970
1045
|
reacting: reactingRef.current,
|
|
971
1046
|
modalOpen,
|
|
972
|
-
|
|
1047
|
+
// A command the person ran counts as one event worth waking for, unless something they typed
|
|
1048
|
+
// is already queued: that message's turn reads the command's result too.
|
|
1049
|
+
pending: (mgr?.pendingCount() ?? 0) + (wakeAfterUserCommand.current && queueRef.current.length === 0 ? 1 : 0),
|
|
973
1050
|
});
|
|
974
|
-
if (wake)
|
|
1051
|
+
if (wake) {
|
|
1052
|
+
wakeAfterUserCommand.current = false;
|
|
975
1053
|
void reactToBackground();
|
|
1054
|
+
}
|
|
976
1055
|
}, [bgTick, busy, ready, needsKey, modalOpen]);
|
|
977
1056
|
// When the turn ends, send what's queued — CONSECUTIVE plain messages together, as
|
|
978
1057
|
// one turn (see messageQueue.ts for why), a slash command on its own. Chains: each
|
|
@@ -1025,6 +1104,7 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1025
1104
|
}, [busy]);
|
|
1026
1105
|
// Start/stop a turn's timer (drives the persistent status line).
|
|
1027
1106
|
function startTurn() {
|
|
1107
|
+
wakeAfterUserCommand.current = false;
|
|
1028
1108
|
turnStart.current = Date.now();
|
|
1029
1109
|
waitedMs.current = 0;
|
|
1030
1110
|
waitSince.current = null;
|
|
@@ -1035,6 +1115,20 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1035
1115
|
abortRef.current = new AbortController();
|
|
1036
1116
|
setBusy(true);
|
|
1037
1117
|
}
|
|
1118
|
+
/**
|
|
1119
|
+
* The start of a `!` command's time on screen. Like `startTurn` for what the box needs (busy, a way
|
|
1120
|
+
* to stop it, anything typed meanwhile queued) and nothing of the agent's: the task's usage and
|
|
1121
|
+
* its "Worked for" receipt belong to the last time the agent worked, and are left as they were.
|
|
1122
|
+
*/
|
|
1123
|
+
function startUserTurn() {
|
|
1124
|
+
turnStart.current = Date.now();
|
|
1125
|
+
waitedMs.current = 0;
|
|
1126
|
+
waitSince.current = null;
|
|
1127
|
+
interrupting.current = false;
|
|
1128
|
+
abortRef.current = new AbortController();
|
|
1129
|
+
userTurn.current = true;
|
|
1130
|
+
setBusy(true);
|
|
1131
|
+
}
|
|
1038
1132
|
// Esc interrupts the current turn: it aborts the model call AND kills a running
|
|
1039
1133
|
// command — run_command listens to this same signal (see runShell), so a hung
|
|
1040
1134
|
// command (e.g. an installer waiting on a GUI) can no longer freeze the agent.
|
|
@@ -1062,6 +1156,12 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1062
1156
|
useInput((input, key) => {
|
|
1063
1157
|
if (!key.ctrl || input !== "c")
|
|
1064
1158
|
return;
|
|
1159
|
+
const g = grow.current;
|
|
1160
|
+
if (g && !isEmptyGrow(g)) {
|
|
1161
|
+
copyGrown(g);
|
|
1162
|
+
clearSelection();
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1065
1165
|
const sel = selection.current;
|
|
1066
1166
|
if (ctrlCShouldCopy(sel)) {
|
|
1067
1167
|
const screen = latestScreen();
|
|
@@ -1078,6 +1178,9 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1078
1178
|
useInput((_input, key) => {
|
|
1079
1179
|
if (key.escape) {
|
|
1080
1180
|
abortRef.current?.abort();
|
|
1181
|
+
// The person's own `!` command: Esc stops just that (no turn was cut off, no shells below).
|
|
1182
|
+
if (userCommandRunning.current)
|
|
1183
|
+
return;
|
|
1081
1184
|
// From here until the next turn starts, anything typed is for the NEXT turn.
|
|
1082
1185
|
interrupting.current = true;
|
|
1083
1186
|
// Anything still waiting to be asked is answered as declined. A queued approval
|
|
@@ -1087,8 +1190,10 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1087
1190
|
// Esc means STOP — including anything running in the background (a starting app,
|
|
1088
1191
|
// a dev server). Aborting the turn alone left those alive, so the app still opened.
|
|
1089
1192
|
const mgr = session.current?.toolContext.backgroundShells;
|
|
1193
|
+
// Not an app the person started themselves with `!`: that one is theirs, and stops from its own window or /shells.
|
|
1090
1194
|
for (const sh of mgr?.running() ?? [])
|
|
1091
|
-
mgr
|
|
1195
|
+
if (!isUserShell(mgr, sh.id))
|
|
1196
|
+
mgr?.kill(sh.id, "user");
|
|
1092
1197
|
// And the app it was testing, with its live view and any hidden browser.
|
|
1093
1198
|
if (session.current)
|
|
1094
1199
|
void stopUi(session.current.toolContext);
|
|
@@ -1219,6 +1324,14 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1219
1324
|
requestFullRepaint();
|
|
1220
1325
|
setScrollUp(maxScrollRef.current);
|
|
1221
1326
|
}
|
|
1327
|
+
// Move what the turn is waiting on to the background, without waiting for the agent
|
|
1328
|
+
// to decide it. Silent when nothing is running (a stray press needs no comment); it
|
|
1329
|
+
// speaks only when something WAS running and could not be moved.
|
|
1330
|
+
else if (key.ctrl && _input === "x") {
|
|
1331
|
+
if (pressBackground(session.current?.toolContext.foregroundCommands) === "stuck") {
|
|
1332
|
+
note("That command could not be moved to the background.");
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1222
1335
|
}, { isActive: ready && overlay === null });
|
|
1223
1336
|
// Leaving the reading view when it reaches the bottom needs no effect of its own:
|
|
1224
1337
|
// `reading` is `scrollUp > 0`, so landing on zero — the wheel, the keys, ctrl+End, or
|
|
@@ -1322,17 +1435,45 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1322
1435
|
// reader's absolute position rather than leave `scrollUp` where it was.
|
|
1323
1436
|
const prev = prevContentHeight.current;
|
|
1324
1437
|
let next = growScroll(scrollUp, prev, height);
|
|
1325
|
-
// A row was just opened or folded.
|
|
1326
|
-
//
|
|
1327
|
-
//
|
|
1328
|
-
//
|
|
1438
|
+
// A row was just opened or folded.
|
|
1439
|
+
//
|
|
1440
|
+
// OPENED, while pinned to the newest: the view moves just far enough to show what opened
|
|
1441
|
+
// (see revealScroll). It used to hold the pressed row in place and let the contents appear
|
|
1442
|
+
// below it, which for the newest row is below the screen: the reader had to scroll to see
|
|
1443
|
+
// what they had asked for. A row opened with the view scrolled back is NOT touched.
|
|
1444
|
+
// FOLDED: the view goes back to where it was before this row was opened, if nothing has
|
|
1445
|
+
// moved it since; otherwise it slides up by what was taken away, so the row stays put.
|
|
1329
1446
|
if (rowToggled.current && height !== prev) {
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1447
|
+
const hit = toggledHit.current;
|
|
1448
|
+
if (height > prev) {
|
|
1449
|
+
// Only for the NEWEST row that can be opened, with the reader pinned to the bottom: that
|
|
1450
|
+
// is the one whose contents would be pushed below the screen. Every other row, even one
|
|
1451
|
+
// that is on screen at the time, keeps the old rule (held in place, contents below it).
|
|
1452
|
+
const node = hit !== null && scrollUp === 0 && toggledNewest.current ? revealNode(hit) : undefined;
|
|
1453
|
+
if (hit !== null && node && contentRef.current) {
|
|
1454
|
+
const n = measureElement(node);
|
|
1455
|
+
const c = measureElement(contentRef.current);
|
|
1456
|
+
next = revealScroll(scrollUp, height, chatRows, n.y - c.y, n.height);
|
|
1457
|
+
openedFrom.current = { hit, before: scrollUp, after: next };
|
|
1458
|
+
}
|
|
1459
|
+
else {
|
|
1460
|
+
openedFrom.current = null;
|
|
1461
|
+
if (scrollUp === 0)
|
|
1462
|
+
next = height - prev;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
else {
|
|
1466
|
+
const from = openedFrom.current;
|
|
1467
|
+
if (from && from.hit === hit && from.after === scrollUp)
|
|
1468
|
+
next = Math.min(from.before, Math.max(0, height - chatRows));
|
|
1469
|
+
else
|
|
1470
|
+
next = Math.max(0, scrollUp - (prev - height));
|
|
1471
|
+
openedFrom.current = null;
|
|
1472
|
+
}
|
|
1334
1473
|
}
|
|
1335
1474
|
rowToggled.current = false;
|
|
1475
|
+
// The view is now placed for the new height; the next frame is the right one.
|
|
1476
|
+
releaseFrames();
|
|
1336
1477
|
if (next !== scrollUp)
|
|
1337
1478
|
setScrollUp(next);
|
|
1338
1479
|
}
|
|
@@ -1394,6 +1535,15 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1394
1535
|
if (!chatRef.current)
|
|
1395
1536
|
return;
|
|
1396
1537
|
const { height } = measureElement(chatRef.current);
|
|
1538
|
+
// The footer grew or shrank. The frame already drawn kept the top of the window where it
|
|
1539
|
+
// was; the numbers for the next one hold the bottom. Moving the scroll by the same
|
|
1540
|
+
// amount in this update makes both show the same rows (see viewportScroll). A change of
|
|
1541
|
+
// WIDTH is the reflow's to handle, not this.
|
|
1542
|
+
const prev = chatRowsSeen.current;
|
|
1543
|
+
if (prev !== null && prev.width === width && prev.rows !== height) {
|
|
1544
|
+
setScrollUp((s) => viewportScroll(s, prev.rows, height));
|
|
1545
|
+
}
|
|
1546
|
+
chatRowsSeen.current = { rows: height, width };
|
|
1397
1547
|
setChatHeight((h) => (h === height ? h : height));
|
|
1398
1548
|
});
|
|
1399
1549
|
/**
|
|
@@ -1448,14 +1598,149 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1448
1598
|
* chance to reflow the screen under a user who is only trying to highlight a word.
|
|
1449
1599
|
* `repaintOverlay` re-tints the frame already on screen instead.
|
|
1450
1600
|
*/
|
|
1601
|
+
// ── the selection that follows the conversation ──────────────────────────────────────────
|
|
1602
|
+
/** The screen rows that show the conversation. */
|
|
1603
|
+
const chatViewRows = useCallback(() => {
|
|
1604
|
+
if (!chatRef.current)
|
|
1605
|
+
return null;
|
|
1606
|
+
const m = measureElement(chatRef.current);
|
|
1607
|
+
return { top: frameTopRef.current + m.y, bottom: frameTopRef.current + m.y + m.height };
|
|
1608
|
+
}, []);
|
|
1609
|
+
/** Runs on every frame while a selection of this kind exists: keep the rows, then paint it. */
|
|
1610
|
+
const growOverlay = useCallback((screen) => {
|
|
1611
|
+
const g = grow.current;
|
|
1612
|
+
const view = chatViewRows();
|
|
1613
|
+
if (!g || !view)
|
|
1614
|
+
return;
|
|
1615
|
+
const spans = blockSpans(frameTopRef.current);
|
|
1616
|
+
if (g.dragging) {
|
|
1617
|
+
// The pointer has not moved but the view has, so what is under it is a different
|
|
1618
|
+
// row: the end of the selection is wherever the pointer is NOW.
|
|
1619
|
+
const p = pointer.current;
|
|
1620
|
+
if (p) {
|
|
1621
|
+
const focus = focusFromPointer(spans, view, p.x, p.y, screen.width);
|
|
1622
|
+
if (focus)
|
|
1623
|
+
g.focus = focus;
|
|
1624
|
+
}
|
|
1625
|
+
const chip = pillHit.current;
|
|
1626
|
+
rowStore.current.capture(screen, spans, view, chip ? { y: chip.row, x0: chip.left, x1: chip.right + 1 } : null);
|
|
1627
|
+
}
|
|
1628
|
+
applyGrowSelection(screen, g, spans, view);
|
|
1629
|
+
}, [chatViewRows]);
|
|
1630
|
+
/** Begin a selection that follows the conversation, if the press landed on it. */
|
|
1631
|
+
const startGrow = useCallback((x, y) => {
|
|
1632
|
+
const view = chatViewRows();
|
|
1633
|
+
const screen = latestScreen();
|
|
1634
|
+
if (!view || !screen || y < view.top || y >= view.bottom)
|
|
1635
|
+
return false;
|
|
1636
|
+
const focus = focusFromPointer(blockSpans(frameTopRef.current), view, x, y, screen.width);
|
|
1637
|
+
if (!focus)
|
|
1638
|
+
return false;
|
|
1639
|
+
grow.current = { anchor: focus, focus, dragging: true };
|
|
1640
|
+
rowStore.current.clear();
|
|
1641
|
+
pointer.current = { x, y };
|
|
1642
|
+
setFrameOverlay(growOverlay);
|
|
1643
|
+
repaintOverlay();
|
|
1644
|
+
return true;
|
|
1645
|
+
}, [chatViewRows, growOverlay]);
|
|
1646
|
+
/** While the pointer is held past an edge, keep scrolling, so the selection keeps growing. */
|
|
1647
|
+
const keepScrolling = useCallback(() => {
|
|
1648
|
+
if (edgeTimer.current)
|
|
1649
|
+
return;
|
|
1650
|
+
edgeTimer.current = setInterval(() => {
|
|
1651
|
+
const view = chatViewRows();
|
|
1652
|
+
const p = pointer.current;
|
|
1653
|
+
if (!grow.current?.dragging || !view || !p)
|
|
1654
|
+
return stopEdge();
|
|
1655
|
+
const lines = edgeLines(p.y, view, latestScreen()?.height ?? process.stdout.rows ?? 24);
|
|
1656
|
+
if (lines === 0)
|
|
1657
|
+
return stopEdge();
|
|
1658
|
+
scrollBy(lines);
|
|
1659
|
+
}, EDGE_SCROLL_MS);
|
|
1660
|
+
}, [chatViewRows, scrollBy, stopEdge]);
|
|
1661
|
+
/** Copy everything between the two ends, and say so if some of it could not be. */
|
|
1662
|
+
function copyGrown(g) {
|
|
1663
|
+
const s = stateRef.current;
|
|
1664
|
+
const ids = [...s.committed, ...s.tail].map((b) => b.id);
|
|
1665
|
+
const grown = rowStore.current.text(g, ids, (id) => blockHeights.current.get(id)?.height);
|
|
1666
|
+
// Only real text: a drag over empty space gathers nothing but line breaks, and copies nothing.
|
|
1667
|
+
if (grown.text.trim())
|
|
1668
|
+
copyToClipboard(grown.text);
|
|
1669
|
+
if (!grown.complete)
|
|
1670
|
+
note("Part of what you selected scrolled past too fast to be copied. Select it again, scrolling more slowly.");
|
|
1671
|
+
}
|
|
1672
|
+
/**
|
|
1673
|
+
* The third click: the selected word becomes the whole line, in one step. The word stayed
|
|
1674
|
+
* highlighted until now (the press did not take it down), so there is no moment with nothing
|
|
1675
|
+
* selected between the two. What is copied is the line.
|
|
1676
|
+
*/
|
|
1677
|
+
function selectLineOfWord() {
|
|
1678
|
+
const g = grow.current;
|
|
1679
|
+
const view = chatViewRows();
|
|
1680
|
+
const screen = latestScreen();
|
|
1681
|
+
if (!g || !view || !screen)
|
|
1682
|
+
return;
|
|
1683
|
+
const spans = blockSpans(frameTopRef.current);
|
|
1684
|
+
const y = rowOfKey(spans, { id: g.anchor.id, off: g.anchor.off });
|
|
1685
|
+
const line = y === null ? null : lineBounds(Array.from({ length: screen.width }, (_, cx) => screen.chars[screen.index(cx, y)]));
|
|
1686
|
+
if (line) {
|
|
1687
|
+
g.anchor = { ...g.anchor, x: line.from };
|
|
1688
|
+
g.focus = { ...g.focus, x: line.to };
|
|
1689
|
+
}
|
|
1690
|
+
rowStore.current.clear();
|
|
1691
|
+
const chip = pillHit.current;
|
|
1692
|
+
rowStore.current.capture(screen, spans, view, chip ? { y: chip.row, x0: chip.left, x1: chip.right + 1 } : null);
|
|
1693
|
+
repaintOverlay();
|
|
1694
|
+
copyGrown(g);
|
|
1695
|
+
}
|
|
1696
|
+
/** The last click on the conversation, to tell clicks in a row from separate ones. */
|
|
1697
|
+
const lastClick = useRef(null);
|
|
1698
|
+
/**
|
|
1699
|
+
* Clicking text selects it, and copies it, as a drag does. One click does nothing, so a stray
|
|
1700
|
+
* click never takes anything; two clicks in a row, each soon after the last, take the word under
|
|
1701
|
+
* the pointer; three take the whole line. A click that comes too late starts the count again.
|
|
1702
|
+
* Returns false when nothing was taken (a first click, or a blank), so the click can do what it
|
|
1703
|
+
* did before.
|
|
1704
|
+
*/
|
|
1705
|
+
function selectClicked(x, y) {
|
|
1706
|
+
const click = nextClick(lastClick.current, Date.now(), x, y, DOUBLE_CLICK_MS);
|
|
1707
|
+
// Three is the most there is: the next click begins again.
|
|
1708
|
+
lastClick.current = click.count >= 3 ? null : click;
|
|
1709
|
+
if (click.count < 2)
|
|
1710
|
+
return false;
|
|
1711
|
+
const view = chatViewRows();
|
|
1712
|
+
const screen = latestScreen();
|
|
1713
|
+
if (!view || !screen || y < view.top || y >= view.bottom)
|
|
1714
|
+
return false;
|
|
1715
|
+
const spans = blockSpans(frameTopRef.current);
|
|
1716
|
+
const at = focusFromPointer(spans, view, x, y, screen.width);
|
|
1717
|
+
if (!at)
|
|
1718
|
+
return false;
|
|
1719
|
+
const cells = Array.from({ length: screen.width }, (_, cx) => screen.chars[screen.index(cx, y)]);
|
|
1720
|
+
const range = click.count >= 3 ? lineBounds(cells) : wordBounds(cells, x);
|
|
1721
|
+
if (!range)
|
|
1722
|
+
return false;
|
|
1723
|
+
const g = { anchor: { id: at.id, off: at.off, x: range.from }, focus: { id: at.id, off: at.off, x: range.to }, dragging: false };
|
|
1724
|
+
grow.current = g;
|
|
1725
|
+
rowStore.current.clear();
|
|
1726
|
+
const chip = pillHit.current;
|
|
1727
|
+
rowStore.current.capture(screen, spans, view, chip ? { y: chip.row, x0: chip.left, x1: chip.right + 1 } : null);
|
|
1728
|
+
setFrameOverlay(growOverlay);
|
|
1729
|
+
repaintOverlay();
|
|
1730
|
+
copyGrown(g);
|
|
1731
|
+
return true;
|
|
1732
|
+
}
|
|
1451
1733
|
// Mouse reporting itself is switched on and off by — it belongs to
|
|
1452
1734
|
// the shell, not to this component, because the inline shell must never have it on.
|
|
1453
1735
|
// What is left here is the highlight, which has to come down when the app unmounts.
|
|
1454
1736
|
useEffect(() => {
|
|
1455
1737
|
if (!ready)
|
|
1456
1738
|
return;
|
|
1457
|
-
return () =>
|
|
1458
|
-
|
|
1739
|
+
return () => {
|
|
1740
|
+
stopEdge();
|
|
1741
|
+
setFrameOverlay(null);
|
|
1742
|
+
};
|
|
1743
|
+
}, [ready, stopEdge]);
|
|
1459
1744
|
/**
|
|
1460
1745
|
* Everything the pointer does, read through `useInput`.
|
|
1461
1746
|
*
|
|
@@ -1485,14 +1770,23 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1485
1770
|
// This is the same rule the input box already follows for keystrokes — one event
|
|
1486
1771
|
// in, one render out — applied to the other thing that arrives in bursts.
|
|
1487
1772
|
if (notches.length > 0) {
|
|
1488
|
-
// Content moves out from under a selection when the view scrolls, so the
|
|
1489
|
-
// highlight would be sitting on text that is no longer the text it copied.
|
|
1490
|
-
|
|
1773
|
+
// Content moves out from under a SCREEN selection when the view scrolls, so the
|
|
1774
|
+
// highlight would be sitting on text that is no longer the text it copied. One that
|
|
1775
|
+
// follows the conversation stays, and grows if the button is still down.
|
|
1776
|
+
if (!grow.current)
|
|
1777
|
+
clearSelection();
|
|
1491
1778
|
// Signed notch count, then the distance for it. The same distance for every notch,
|
|
1492
1779
|
// applied at once (wheelStep.ts): how far the view goes follows how far the wheel
|
|
1493
1780
|
// was turned, and nothing else.
|
|
1494
1781
|
const net = notches.reduce((n, dir) => n + (dir === "up" ? 1 : -1), 0);
|
|
1495
|
-
|
|
1782
|
+
let lines = wheelLines(net);
|
|
1783
|
+
// With a selection alive no row may be scrolled past unseen: a step is never more than
|
|
1784
|
+
// the view is tall, or the rows in between could not be copied.
|
|
1785
|
+
if (grow.current) {
|
|
1786
|
+
const view = chatViewRows();
|
|
1787
|
+
const room = view ? Math.max(1, view.bottom - view.top - 1) : 16;
|
|
1788
|
+
lines = Math.max(-room, Math.min(room, lines));
|
|
1789
|
+
}
|
|
1496
1790
|
// The wheel is how anyone actually scrolls, so in the inline shell it is what
|
|
1497
1791
|
// opens the reading view. Turning it UP is the gesture: the reader is going back
|
|
1498
1792
|
// through the conversation and wants the prompt to stay where they can type into
|
|
@@ -1536,12 +1830,53 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1536
1830
|
setScrollUp(0);
|
|
1537
1831
|
continue;
|
|
1538
1832
|
}
|
|
1833
|
+
// The press that may be the THIRD click: a word is selected from the second, and this
|
|
1834
|
+
// lands where it did, soon enough. The word is left up (a new selection would take
|
|
1835
|
+
// it down) and the release decides: a click grows it to the line, a drag starts over.
|
|
1836
|
+
const before = lastClick.current;
|
|
1837
|
+
const word = grow.current;
|
|
1838
|
+
if (before &&
|
|
1839
|
+
before.count === 2 &&
|
|
1840
|
+
Date.now() - before.at <= DOUBLE_CLICK_MS &&
|
|
1841
|
+
before.y === event.y &&
|
|
1842
|
+
Math.abs(before.x - event.x) <= 2 &&
|
|
1843
|
+
word &&
|
|
1844
|
+
!word.dragging &&
|
|
1845
|
+
!isEmptyGrow(word)) {
|
|
1846
|
+
pendingThird.current = { x: event.x, y: event.y };
|
|
1847
|
+
continue;
|
|
1848
|
+
}
|
|
1849
|
+
// A press on the conversation starts a selection that follows it. Anywhere else (the
|
|
1850
|
+
// input box, the banner) it is the screen selection, which is what edits the input.
|
|
1851
|
+
if (startGrow(event.x, event.y)) {
|
|
1852
|
+
selection.current = null;
|
|
1853
|
+
continue;
|
|
1854
|
+
}
|
|
1855
|
+
grow.current = null;
|
|
1856
|
+
rowStore.current.clear();
|
|
1857
|
+
pointer.current = null;
|
|
1858
|
+
stopEdge();
|
|
1539
1859
|
selection.current = { anchor: { x: event.x, y: event.y }, focus: { x: event.x, y: event.y } };
|
|
1540
1860
|
// Installed here rather than for the life of the session: see clearSelection.
|
|
1541
1861
|
setFrameOverlay((screen) => applySelection(screen, selection.current));
|
|
1542
1862
|
repaintOverlay();
|
|
1543
1863
|
}
|
|
1544
1864
|
else if (event.kind === "drag") {
|
|
1865
|
+
// The press that might have been a third click was the start of a drag: begin the
|
|
1866
|
+
// selection it was always going to be, from where it was pressed.
|
|
1867
|
+
if (pendingThird.current) {
|
|
1868
|
+
const at = pendingThird.current;
|
|
1869
|
+
pendingThird.current = null;
|
|
1870
|
+
lastClick.current = null;
|
|
1871
|
+
if (startGrow(at.x, at.y))
|
|
1872
|
+
selection.current = null;
|
|
1873
|
+
}
|
|
1874
|
+
if (grow.current?.dragging) {
|
|
1875
|
+
pointer.current = { x: event.x, y: event.y };
|
|
1876
|
+
pendingDrag = true;
|
|
1877
|
+
keepScrolling();
|
|
1878
|
+
continue;
|
|
1879
|
+
}
|
|
1545
1880
|
if (!selection.current)
|
|
1546
1881
|
continue;
|
|
1547
1882
|
selection.current = { anchor: selection.current.anchor, focus: { x: event.x, y: event.y } };
|
|
@@ -1549,6 +1884,53 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1549
1884
|
}
|
|
1550
1885
|
else {
|
|
1551
1886
|
pendingDrag = false;
|
|
1887
|
+
// The release of a press that kept the word up: a third click in time takes the line;
|
|
1888
|
+
// anything else is an ordinary first click.
|
|
1889
|
+
if (pendingThird.current) {
|
|
1890
|
+
pendingThird.current = null;
|
|
1891
|
+
const click = nextClick(lastClick.current, Date.now(), event.x, event.y, DOUBLE_CLICK_MS);
|
|
1892
|
+
if (click.count === 3) {
|
|
1893
|
+
lastClick.current = null;
|
|
1894
|
+
selectLineOfWord();
|
|
1895
|
+
}
|
|
1896
|
+
else {
|
|
1897
|
+
lastClick.current = click;
|
|
1898
|
+
clearSelection();
|
|
1899
|
+
}
|
|
1900
|
+
continue;
|
|
1901
|
+
}
|
|
1902
|
+
const g = grow.current;
|
|
1903
|
+
if (g) {
|
|
1904
|
+
stopEdge();
|
|
1905
|
+
pointer.current = null;
|
|
1906
|
+
g.dragging = false;
|
|
1907
|
+
const view = chatViewRows();
|
|
1908
|
+
const screen = latestScreen();
|
|
1909
|
+
if (view && screen) {
|
|
1910
|
+
const focus = focusFromPointer(blockSpans(frameTopRef.current), view, event.x, event.y, screen.width);
|
|
1911
|
+
if (focus)
|
|
1912
|
+
g.focus = focus;
|
|
1913
|
+
}
|
|
1914
|
+
if (isEmptyGrow(g)) {
|
|
1915
|
+
// A click, not a drag: take the highlight down, then do what a click does.
|
|
1916
|
+
clearSelection();
|
|
1917
|
+
const hitId = expandableAt(event.y, frameTopRef.current, chatRef.current);
|
|
1918
|
+
if (hitId !== null) {
|
|
1919
|
+
lastClick.current = null;
|
|
1920
|
+
toggleRow(hitId);
|
|
1921
|
+
continue;
|
|
1922
|
+
}
|
|
1923
|
+
// Plain text: two clicks in a row take the word, three take the line.
|
|
1924
|
+
if (selectClicked(event.x, event.y))
|
|
1925
|
+
continue;
|
|
1926
|
+
placeCaretAt(event.x, event.y);
|
|
1927
|
+
continue;
|
|
1928
|
+
}
|
|
1929
|
+
// Copied on release, so selecting IS copying; the highlight stays as the receipt.
|
|
1930
|
+
copyGrown(g);
|
|
1931
|
+
repaintOverlay();
|
|
1932
|
+
continue;
|
|
1933
|
+
}
|
|
1552
1934
|
const sel = selection.current;
|
|
1553
1935
|
if (!sel)
|
|
1554
1936
|
continue;
|
|
@@ -1602,6 +1984,11 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1602
1984
|
/** Open or fold a row, keeping it where the reader pressed it. See the height effect. */
|
|
1603
1985
|
function toggleRow(hit) {
|
|
1604
1986
|
rowToggled.current = true;
|
|
1987
|
+
toggledHit.current = hit;
|
|
1988
|
+
toggledNewest.current = lastExpandable(stateRef.current) === hitBlock(hit);
|
|
1989
|
+
// Opening or folding takes two renders, and the first is drawn with the view still placed
|
|
1990
|
+
// for the old height. Keep that one off the screen; the effect below releases it.
|
|
1991
|
+
holdFrames();
|
|
1605
1992
|
// A hit on one command inside a commands row opens that command; anything else is a row.
|
|
1606
1993
|
if (hitItem(hit) >= 0)
|
|
1607
1994
|
dispatch({ type: "toggleItem", id: hitBlock(hit), index: hitItem(hit) });
|
|
@@ -1610,8 +1997,10 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1610
1997
|
}
|
|
1611
1998
|
function endTurn() {
|
|
1612
1999
|
releaseLiveGates();
|
|
1613
|
-
|
|
2000
|
+
// A `!` command is not the agent working: no "Worked for" receipt for it.
|
|
2001
|
+
if (turnStart.current != null && !userTurn.current)
|
|
1614
2002
|
setLastMs(Date.now() - turnStart.current);
|
|
2003
|
+
userTurn.current = false;
|
|
1615
2004
|
// Settle every tool row this turn produced into its past-tense verb. The single
|
|
1616
2005
|
// funnel for a turn ending (normal completion, error, interrupt), so no row is
|
|
1617
2006
|
// left reading "Reading" once nothing is being read.
|
|
@@ -2037,7 +2426,7 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
2037
2426
|
// loading a tool is never shown at all (see mcpSearch.ts).
|
|
2038
2427
|
if (e.name === "spawn_subagent" || neverShown(e.name))
|
|
2039
2428
|
return;
|
|
2040
|
-
const d = toolDisplay(e.name, e.args);
|
|
2429
|
+
const d = toolDisplay(e.name, e.args, session.current?.cwd ?? process.cwd());
|
|
2041
2430
|
if (e.agent) {
|
|
2042
2431
|
// A sub-agent's own tool call — fold it into that worker's nested rail.
|
|
2043
2432
|
enqueueReveal({ type: "subToolStart", agentId: e.agent, toolId: e.id, name: d.name, arg: d.arg, action: d.kind });
|
|
@@ -2159,6 +2548,54 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
2159
2548
|
pump(); // ensure we drain to endTurn even if idle now
|
|
2160
2549
|
}
|
|
2161
2550
|
}
|
|
2551
|
+
/**
|
|
2552
|
+
* A command the person typed after `!`: run it now, in the project's shell, as THEIRS.
|
|
2553
|
+
*
|
|
2554
|
+
* It is drawn as a row of its own, `You ran 1 command`, that opens on the command and its output
|
|
2555
|
+
* exactly as a command the agent ran does. It goes through the same pipeline as a turn only as far
|
|
2556
|
+
* as the screen is concerned: the box is busy while it runs (so Esc stops it, and anything typed
|
|
2557
|
+
* meanwhile is queued), and the row is revealed in order. No turn starts and the model is not
|
|
2558
|
+
* called: the agent reads the command and its output on its next turn, from the entry
|
|
2559
|
+
* `runUserCommand` adds to the conversation.
|
|
2560
|
+
*/
|
|
2561
|
+
async function runShellForUser(s, command) {
|
|
2562
|
+
startUserTurn();
|
|
2563
|
+
userCommandRunning.current = true;
|
|
2564
|
+
resetPacer();
|
|
2565
|
+
lastRevealAt.current = 0;
|
|
2566
|
+
streamDone.current = false;
|
|
2567
|
+
flush.current = false;
|
|
2568
|
+
const toolId = `user-${crypto.randomUUID()}`;
|
|
2569
|
+
enqueueReveal({ type: "toolStart", toolId, name: "Run", arg: command, action: "run", group: true, at: Date.now(), byUser: true });
|
|
2570
|
+
try {
|
|
2571
|
+
const { result } = await runUserCommand(s, command, {
|
|
2572
|
+
signal: abortRef.current?.signal,
|
|
2573
|
+
onProgress: (text) => enqueueReveal({ type: "toolProgress", toolId, text }),
|
|
2574
|
+
});
|
|
2575
|
+
enqueueReveal({
|
|
2576
|
+
type: "toolEnd",
|
|
2577
|
+
toolId,
|
|
2578
|
+
ok: !result.isError,
|
|
2579
|
+
summary: result.summary,
|
|
2580
|
+
detail: result.detail,
|
|
2581
|
+
detailKind: result.detailKind,
|
|
2582
|
+
detailFull: result.detailFull,
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2585
|
+
catch (error) {
|
|
2586
|
+
enqueueReveal({ type: "toolEnd", toolId, ok: false, summary: `could not run: ${errText(error)}` });
|
|
2587
|
+
}
|
|
2588
|
+
finally {
|
|
2589
|
+
userCommandRunning.current = false;
|
|
2590
|
+
// The agent answers it, unless the person stopped it themselves with Esc (they ended it on purpose).
|
|
2591
|
+
if (!abortRef.current?.signal.aborted)
|
|
2592
|
+
wakeAfterUserCommand.current = true;
|
|
2593
|
+
await saveSession(s);
|
|
2594
|
+
streamDone.current = true;
|
|
2595
|
+
if (!pumpTimer.current)
|
|
2596
|
+
pump(); // drain to endTurn even if the row is already shown
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2162
2599
|
/**
|
|
2163
2600
|
* Run a Marathon through this screen's own turn pipeline. It is the same `respond` loop
|
|
2164
2601
|
* as any message, driven by `startMarathon`/`resumeMarathon`, so every tool row, token and
|
|
@@ -2616,7 +3053,11 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
2616
3053
|
if (name === "/help") {
|
|
2617
3054
|
const skills = s.governance.skills ?? [];
|
|
2618
3055
|
const mcpPrompts = s.toolContext.mcp?.promptCatalog() ?? [];
|
|
2619
|
-
|
|
3056
|
+
// In the full-screen shell the list is set apart from the conversation (a click takes it
|
|
3057
|
+
// away) instead of said into it: a list this long, drawn like the agent's own words, was
|
|
3058
|
+
// hard to tell from what the agent said. The inline shell prints into the terminal's own
|
|
3059
|
+
// scrollback, where nothing can be taken back, so it keeps saying it.
|
|
3060
|
+
const helpText = formatHelp([
|
|
2620
3061
|
{ title: "Commands", commands: BASE_COMMANDS },
|
|
2621
3062
|
{
|
|
2622
3063
|
title: "Project skills",
|
|
@@ -2629,7 +3070,11 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
2629
3070
|
description: p.description || `prompt from ${p.server}`,
|
|
2630
3071
|
})),
|
|
2631
3072
|
},
|
|
2632
|
-
])
|
|
3073
|
+
]);
|
|
3074
|
+
if (shell === "inline")
|
|
3075
|
+
say(helpText);
|
|
3076
|
+
else
|
|
3077
|
+
dispatch({ type: "help", text: helpText });
|
|
2633
3078
|
return;
|
|
2634
3079
|
}
|
|
2635
3080
|
// /update — take the newer version and come back to this same conversation.
|
|
@@ -3309,6 +3754,20 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
3309
3754
|
const trimmed = value.trim();
|
|
3310
3755
|
if (trimmed.length === 0 || busy || !ready)
|
|
3311
3756
|
return;
|
|
3757
|
+
// `!` runs what follows in the project's shell, as the person's own command: no question, no
|
|
3758
|
+
// turn, nothing said to the model. See runShellForUser.
|
|
3759
|
+
if (isUserCommandText(trimmed)) {
|
|
3760
|
+
const s = session.current;
|
|
3761
|
+
if (!s)
|
|
3762
|
+
return;
|
|
3763
|
+
const command = parseUserCommand(trimmed);
|
|
3764
|
+
if (!command) {
|
|
3765
|
+
note("Type the command after the !, for example: ! git status");
|
|
3766
|
+
return;
|
|
3767
|
+
}
|
|
3768
|
+
await runShellForUser(s, command);
|
|
3769
|
+
return;
|
|
3770
|
+
}
|
|
3312
3771
|
if (trimmed.startsWith("/")) {
|
|
3313
3772
|
// Submitting clears the input, which closes the command list; the surface the command
|
|
3314
3773
|
// opens can only arrive after the work it does first (reading sessions, refreshing
|
|
@@ -3439,7 +3898,7 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
3439
3898
|
const completions = [
|
|
3440
3899
|
...BASE_COMMANDS,
|
|
3441
3900
|
...skills.map((s) => ({ name: `/${s.name}`, description: s.description || "project skill" })),
|
|
3442
|
-
...mcpPrompts.map((p) => ({ name: promptCommand(p), description: p.description || `prompt from ${p.server}
|
|
3901
|
+
...mcpPrompts.map((p) => ({ name: promptCommand(p), description: p.description || `prompt from ${p.server}`, quiet: true })),
|
|
3443
3902
|
];
|
|
3444
3903
|
// While an overlay is open it renders in the menu slot below the input and owns the
|
|
3445
3904
|
// keyboard; the input box itself stays visible but inert. `maxRows` is the App's
|
|
@@ -3533,7 +3992,7 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
3533
3992
|
const here = p.id === active ? ` · on ${activeLabel}` : "";
|
|
3534
3993
|
return { label: p.label + (p.id === active ? " ✓" : ""), description: `${models} · ${key}${here}` };
|
|
3535
3994
|
});
|
|
3536
|
-
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 }));
|
|
3995
|
+
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 }));
|
|
3537
3996
|
}
|
|
3538
3997
|
if (overlay.kind === "model") {
|
|
3539
3998
|
const id = cur?.modelConfig.model ?? DEFAULT_MODEL_CONFIG.model;
|
|
@@ -3557,7 +4016,7 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
3557
4016
|
keywords: m.id,
|
|
3558
4017
|
};
|
|
3559
4018
|
});
|
|
3560
|
-
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 }));
|
|
4019
|
+
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 }));
|
|
3561
4020
|
}
|
|
3562
4021
|
if (overlay.kind === "think") {
|
|
3563
4022
|
const model = cur?.modelConfig.model ?? DEFAULT_MODEL_CONFIG.model;
|
|
@@ -3670,13 +4129,28 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
3670
4129
|
// surface — the pickers, the key manager, and the approval prompt — is content-only and
|
|
3671
4130
|
// renders inside the input's one menu box below the input line. There is no separate
|
|
3672
4131
|
// "boxed" overlay any more; the box is always the same, only its contents change.
|
|
4132
|
+
// A list of models is searched, not scrolled (OpenRouter's catalogue is nearly three hundred),
|
|
4133
|
+
// and the words are typed in the input box, which becomes the search field for as long as the
|
|
4134
|
+
// list is open (see pickerSearch.ts). The same for every provider, so typing never lands on
|
|
4135
|
+
// the picker's own bottom line while the chat box sits empty.
|
|
4136
|
+
const modelSearchLabel = overlay?.kind === "model"
|
|
4137
|
+
? (overlay.providerId ? allProviders().find((p) => p.id === overlay.providerId)?.label : undefined) ??
|
|
4138
|
+
providerOf(session.current?.modelConfig.model ?? DEFAULT_MODEL_CONFIG.model).label
|
|
4139
|
+
: undefined;
|
|
4140
|
+
const modelSearch = modelSearchLabel !== undefined;
|
|
4141
|
+
// The provider list is searched the same way.
|
|
4142
|
+
const providerSearch = overlay?.kind === "provider";
|
|
4143
|
+
// The keys manager opens on its provider list, which is searched the same way, unless it was
|
|
4144
|
+
// reached by picking a provider that has no key (it opens on that provider's key field).
|
|
4145
|
+
const keysSearch = keysOpen && !pendingSwitch.current;
|
|
4146
|
+
const searchPlaceholder = modelSearch ? `Search ${modelSearchLabel} models…` : providerSearch || keysSearch ? "Search providers…" : undefined;
|
|
3673
4147
|
const overlayView = buildOverlayView(maxMenuItems);
|
|
3674
4148
|
const runningShells = session.current?.toolContext.backgroundShells?.running() ?? [];
|
|
3675
4149
|
// Where the transcript sits in the viewport, and how far it can travel. Extracted
|
|
3676
4150
|
// to `chatAnchor.ts` so the rule is unit-tested rather than eyeballed — see there
|
|
3677
4151
|
// for why a short transcript now rests ON the input box instead of stranding
|
|
3678
4152
|
// itself at the top of the screen, and why that cannot disturb a scrolled frame.
|
|
3679
|
-
const { marginTop: chatOffset,
|
|
4153
|
+
const { marginTop: chatOffset, maxScroll, scrolled } = chatLayout(contentHeight, chatRows, scrollUp);
|
|
3680
4154
|
// Published for the scroll handlers, which run between frames and cannot compute it.
|
|
3681
4155
|
maxScrollRef.current = maxScroll;
|
|
3682
4156
|
// The chip that says the view is not at the bottom. `scrolled` and not `scrollUp`:
|
|
@@ -3753,6 +4227,15 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
3753
4227
|
// carried off by a resize they did not intend as navigation. Applied once the new
|
|
3754
4228
|
// height has actually been measured — see the effect that consumes this.
|
|
3755
4229
|
reflowFrom.current = { scrolled: scrollUp, maxScroll: Math.max(0, contentHeight - chatRows) };
|
|
4230
|
+
// A re-wrap changes how many rows every block has, so the rows kept for a selection no
|
|
4231
|
+
// longer line up with what is on screen. Dropped without a repaint (this is a render).
|
|
4232
|
+
if (grow.current) {
|
|
4233
|
+
grow.current = null;
|
|
4234
|
+
rowStore.current.clear();
|
|
4235
|
+
pointer.current = null;
|
|
4236
|
+
stopEdge();
|
|
4237
|
+
setFrameOverlay(null);
|
|
4238
|
+
}
|
|
3756
4239
|
}
|
|
3757
4240
|
// Only a MEASURED prefix can be virtualized: a block whose height is unknown cannot
|
|
3758
4241
|
// be replaced by a spacer, because there is no honest number to give the spacer.
|
|
@@ -3798,11 +4281,11 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
3798
4281
|
// The status line, the queued bar, the input and whatever sits under it. Built once
|
|
3799
4282
|
// and rendered by BOTH shells: the only thing that differs between them is where it
|
|
3800
4283
|
// ends up — pinned to the bottom of a frame we own, or simply the last thing printed.
|
|
3801
|
-
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) => {
|
|
4284
|
+
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, 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) }) }), _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) => {
|
|
3802
4285
|
caretClick.current = place;
|
|
3803
4286
|
}, registerTextSelect: (select) => {
|
|
3804
4287
|
textSelect.current = select;
|
|
3805
|
-
}, 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 ? (
|
|
4288
|
+
}, 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 ? (
|
|
3806
4289
|
// A compaction is near. This takes the line over the tip and the background bar:
|
|
3807
4290
|
// it is the one thing here the user needs BEFORE it happens, since the summarizing
|
|
3808
4291
|
// pass rewrites the conversation. Dim as it approaches, then a plain warning colour
|
|
@@ -3815,18 +4298,31 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
3815
4298
|
// Yoga sizes against a pinned footer, with the transcript offset inside it. Kept as
|
|
3816
4299
|
// two copies they would drift apart the first time either was touched, and the drift
|
|
3817
4300
|
// would show as scrolling behaving differently in one shell than in the other.
|
|
3818
|
-
|
|
4301
|
+
// Pinned to the newest, the transcript is ALIGNED to the bottom of the viewport by the layout
|
|
4302
|
+
// itself (flex-end) instead of being offset by a number. The number comes from measurements
|
|
4303
|
+
// that lag a frame: when the footer grows (a menu opens, the input takes a second line) or a
|
|
4304
|
+
// row lands, the first frame was drawn with the old numbers, so for a moment the view showed
|
|
4305
|
+
// the wrong part of the conversation. Yoga decides alignment in the same pass that sizes the
|
|
4306
|
+
// frame, so there is nothing stale to be wrong about. A short conversation rests on the
|
|
4307
|
+
// footer by the same rule, which is what the spacer that used to be here did.
|
|
4308
|
+
//
|
|
4309
|
+
// Scrolled back, the offset has to be a number again (nothing but a distance from the top
|
|
4310
|
+
// can hold a position while content is appended), and `growScroll` keeps it right.
|
|
4311
|
+
const pinnedView = scrolled === 0;
|
|
4312
|
+
const chatView = (_jsxs(Box, { ref: chatRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 1, overflow: "hidden", justifyContent: pinnedView ? "flex-end" : "flex-start", children: [_jsx(Box, { flexDirection: "column", flexShrink: 0, marginTop: pinnedView ? 0 : chatOffset, children: _jsxs(Box, { ref: contentRef, flexDirection: "column", flexShrink: 0, children: [win.padTop > 0 ? _jsx(Box, { flexShrink: 0, height: win.padTop }) : null, rendered.slice(win.start, win.end).map((b, i) => {
|
|
3819
4313
|
const idx = win.start + i;
|
|
3820
4314
|
return (
|
|
3821
4315
|
// flexShrink:0 is load-bearing — without it Yoga compresses an
|
|
3822
4316
|
// overfull column and silently drops rows out of the middle.
|
|
3823
4317
|
_jsx(Box, { ref: (node) => {
|
|
4318
|
+
registerBlockBox(b.id, node);
|
|
3824
4319
|
if (node && needsMeasure(blockHeights.current.get(b.id), b))
|
|
3825
4320
|
toMeasure.current.set(b, node);
|
|
3826
4321
|
}, flexShrink: 0, flexDirection: "column", children: _jsx(BlockView, { block: b, columns: width, tightTop: isTight(allBlocks, offset + idx), hovered: hoverId !== null && hitBlock(hoverId) === b.id && hitItem(hoverId) < 0, hoveredItem: hoverId !== null && hitBlock(hoverId) === b.id ? hitItem(hoverId) : -1 }) }, b.id));
|
|
3827
4322
|
}), padMiddle > 0 ? _jsx(Box, { flexShrink: 0, height: padMiddle }) : null, rendered.slice(known).map((b, i) => {
|
|
3828
4323
|
const idx = known + i;
|
|
3829
4324
|
return (_jsx(Box, { ref: (node) => {
|
|
4325
|
+
registerBlockBox(b.id, node);
|
|
3830
4326
|
if (node && needsMeasure(blockHeights.current.get(b.id), b))
|
|
3831
4327
|
toMeasure.current.set(b, node);
|
|
3832
4328
|
}, flexShrink: 0, flexDirection: "column", children: _jsx(BlockView, { block: b, columns: width, tightTop: isTight(allBlocks, offset + idx), hovered: hoverId !== null && hitBlock(hoverId) === b.id && hitItem(hoverId) < 0, hoveredItem: hoverId !== null && hitBlock(hoverId) === b.id ? hitItem(hoverId) : -1 }) }, b.id));
|
|
@@ -4287,7 +4783,7 @@ function timeAgo(ms) {
|
|
|
4287
4783
|
* line above separating it from the conversation, where the
|
|
4288
4784
|
* receipt belongs to the prompt area, not glued to the last reply.
|
|
4289
4785
|
*/
|
|
4290
|
-
function StatusLine({ busy, startedAt, lastMs, usage, received, advance, waiting, waited, }) {
|
|
4786
|
+
function StatusLine({ busy, userCommand, startedAt, lastMs, usage, received, advance, waiting, waited, }) {
|
|
4291
4787
|
// The render clock while busy: it advances the elapsed timer AND steps the eased token
|
|
4292
4788
|
// counter one frame. It runs at about the display's pace (16ms): the number reads as counting
|
|
4293
4789
|
// rather than as jumping, and the working dot moves smoothly. It was 50ms (20 frames a
|
|
@@ -4320,7 +4816,7 @@ function StatusLine({ busy, startedAt, lastMs, usage, received, advance, waiting
|
|
|
4320
4816
|
// did. Output is
|
|
4321
4817
|
// also the only thing that grows continuously while a turn runs, so it animates cleanly.
|
|
4322
4818
|
const got = received();
|
|
4323
|
-
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` : "", " >"] })] }));
|
|
4819
|
+
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` : "", " >"] })] }));
|
|
4324
4820
|
}
|
|
4325
4821
|
else if (lastMs != null) {
|
|
4326
4822
|
// Settled receipt: elapsed time and the turn's real token cost, shown at once,
|
|
@@ -4347,6 +4843,10 @@ function StatusLine({ busy, startedAt, lastMs, usage, received, advance, waiting
|
|
|
4347
4843
|
// component rendering nothing at all. Two would read as a hole.
|
|
4348
4844
|
return (_jsxs(Box, { marginBottom: 0, children: [dot, label] }));
|
|
4349
4845
|
}
|
|
4846
|
+
/** How often the view scrolls while a drag is held past the edge of the conversation. */
|
|
4847
|
+
/** The longest wait between two clicks for them to count as a double (or triple) click. */
|
|
4848
|
+
const DOUBLE_CLICK_MS = 500;
|
|
4849
|
+
const EDGE_SCROLL_MS = 45;
|
|
4350
4850
|
/** The working line's render clock, in milliseconds. See StatusLine's tick. */
|
|
4351
4851
|
const LIVE_TICK_MS = 16;
|
|
4352
4852
|
function fmtElapsed(s) {
|