mindweave 3.2.8 → 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 +293 -19
- 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/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 +41 -9
- 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 +27 -10
- package/dist/cli/components/WorkGroup.js.map +1 -1
- package/dist/cli/framebuffer/writer.js +29 -6
- package/dist/cli/framebuffer/writer.js.map +1 -1
- package/dist/cli/help.js +1 -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/dynamo/engine.js +8 -4
- 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 +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/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/rebuildDetail.js +49 -1
- package/dist/tools/rebuildDetail.js.map +1 -1
- package/dist/tools/runCommand.js +8 -4
- 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
|
@@ -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";
|
|
@@ -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);
|
|
@@ -371,6 +379,9 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
371
379
|
// much of the content is on screen.
|
|
372
380
|
const chatRef = useRef(null);
|
|
373
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);
|
|
374
385
|
// Bumped by PromptInput when its suggestion menu changes size. Its only job is
|
|
375
386
|
// to re-render App so the footer measurement below re-runs: the menu is
|
|
376
387
|
// PromptInput's own state, so App is not re-rendered by it and would otherwise
|
|
@@ -419,6 +430,17 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
419
430
|
}, []);
|
|
420
431
|
// Aborts the in-flight turn when the user presses Esc (created fresh per turn).
|
|
421
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);
|
|
422
444
|
// Which provider's key we still need, or null once we have it. Not a bare
|
|
423
445
|
// boolean: with more than one provider, the key we must ask for depends on the
|
|
424
446
|
// model the user is about to run, and switching models can make a different key
|
|
@@ -689,6 +711,7 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
689
711
|
// Each call's name and arguments by id, so a result from a session saved before the uncut
|
|
690
712
|
// text was kept can have it put back together (see rebuildDetail.ts).
|
|
691
713
|
const callsById = new Map();
|
|
714
|
+
let userRows = 0; // commands the person ran with `!`, to give each replayed row its own id
|
|
692
715
|
for (const e of transcript) {
|
|
693
716
|
if (e.role === "user") {
|
|
694
717
|
if (!e.synthetic)
|
|
@@ -699,6 +722,23 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
699
722
|
// calls…" sitting in their own chat history.
|
|
700
723
|
if (!e.synthetic)
|
|
701
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
|
+
}
|
|
702
742
|
}
|
|
703
743
|
else if (e.role === "summary") {
|
|
704
744
|
dispatch({ type: "note", text: "— resumed; earlier context summarized —" });
|
|
@@ -723,7 +763,7 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
723
763
|
// that was not in the live one.
|
|
724
764
|
if (call.name === "spawn_subagent" || neverShown(call.name))
|
|
725
765
|
continue;
|
|
726
|
-
const d = toolDisplay(call.name, parseToolArgs(call.arguments));
|
|
766
|
+
const d = toolDisplay(call.name, parseToolArgs(call.arguments), session.current?.cwd ?? process.cwd());
|
|
727
767
|
dispatch({
|
|
728
768
|
type: "toolStart",
|
|
729
769
|
toolId: call.id,
|
|
@@ -752,6 +792,12 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
752
792
|
if (compact)
|
|
753
793
|
return { detail: compact.detail, ...(compact.detailFull ? { detailFull: compact.detailFull } : {}) };
|
|
754
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
|
+
}
|
|
755
801
|
let full = e.detailFull ?? rebuildFull(call?.name, call?.args, e);
|
|
756
802
|
// A write whose text was cleared to save context has only the file left to open.
|
|
757
803
|
if (!full && call?.name === "write_file" && typeof call.args?.path === "string") {
|
|
@@ -998,10 +1044,14 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
998
1044
|
needsKey,
|
|
999
1045
|
reacting: reactingRef.current,
|
|
1000
1046
|
modalOpen,
|
|
1001
|
-
|
|
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),
|
|
1002
1050
|
});
|
|
1003
|
-
if (wake)
|
|
1051
|
+
if (wake) {
|
|
1052
|
+
wakeAfterUserCommand.current = false;
|
|
1004
1053
|
void reactToBackground();
|
|
1054
|
+
}
|
|
1005
1055
|
}, [bgTick, busy, ready, needsKey, modalOpen]);
|
|
1006
1056
|
// When the turn ends, send what's queued — CONSECUTIVE plain messages together, as
|
|
1007
1057
|
// one turn (see messageQueue.ts for why), a slash command on its own. Chains: each
|
|
@@ -1054,6 +1104,7 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1054
1104
|
}, [busy]);
|
|
1055
1105
|
// Start/stop a turn's timer (drives the persistent status line).
|
|
1056
1106
|
function startTurn() {
|
|
1107
|
+
wakeAfterUserCommand.current = false;
|
|
1057
1108
|
turnStart.current = Date.now();
|
|
1058
1109
|
waitedMs.current = 0;
|
|
1059
1110
|
waitSince.current = null;
|
|
@@ -1064,6 +1115,20 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1064
1115
|
abortRef.current = new AbortController();
|
|
1065
1116
|
setBusy(true);
|
|
1066
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
|
+
}
|
|
1067
1132
|
// Esc interrupts the current turn: it aborts the model call AND kills a running
|
|
1068
1133
|
// command — run_command listens to this same signal (see runShell), so a hung
|
|
1069
1134
|
// command (e.g. an installer waiting on a GUI) can no longer freeze the agent.
|
|
@@ -1113,6 +1178,9 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1113
1178
|
useInput((_input, key) => {
|
|
1114
1179
|
if (key.escape) {
|
|
1115
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;
|
|
1116
1184
|
// From here until the next turn starts, anything typed is for the NEXT turn.
|
|
1117
1185
|
interrupting.current = true;
|
|
1118
1186
|
// Anything still waiting to be asked is answered as declined. A queued approval
|
|
@@ -1122,8 +1190,10 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1122
1190
|
// Esc means STOP — including anything running in the background (a starting app,
|
|
1123
1191
|
// a dev server). Aborting the turn alone left those alive, so the app still opened.
|
|
1124
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.
|
|
1125
1194
|
for (const sh of mgr?.running() ?? [])
|
|
1126
|
-
mgr
|
|
1195
|
+
if (!isUserShell(mgr, sh.id))
|
|
1196
|
+
mgr?.kill(sh.id, "user");
|
|
1127
1197
|
// And the app it was testing, with its live view and any hidden browser.
|
|
1128
1198
|
if (session.current)
|
|
1129
1199
|
void stopUi(session.current.toolContext);
|
|
@@ -1465,6 +1535,15 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1465
1535
|
if (!chatRef.current)
|
|
1466
1536
|
return;
|
|
1467
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 };
|
|
1468
1547
|
setChatHeight((h) => (h === height ? h : height));
|
|
1469
1548
|
});
|
|
1470
1549
|
/**
|
|
@@ -1584,11 +1663,73 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1584
1663
|
const s = stateRef.current;
|
|
1585
1664
|
const ids = [...s.committed, ...s.tail].map((b) => b.id);
|
|
1586
1665
|
const grown = rowStore.current.text(g, ids, (id) => blockHeights.current.get(id)?.height);
|
|
1587
|
-
|
|
1666
|
+
// Only real text: a drag over empty space gathers nothing but line breaks, and copies nothing.
|
|
1667
|
+
if (grown.text.trim())
|
|
1588
1668
|
copyToClipboard(grown.text);
|
|
1589
1669
|
if (!grown.complete)
|
|
1590
1670
|
note("Part of what you selected scrolled past too fast to be copied. Select it again, scrolling more slowly.");
|
|
1591
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
|
+
}
|
|
1592
1733
|
// Mouse reporting itself is switched on and off by — it belongs to
|
|
1593
1734
|
// the shell, not to this component, because the inline shell must never have it on.
|
|
1594
1735
|
// What is left here is the highlight, which has to come down when the app unmounts.
|
|
@@ -1689,6 +1830,22 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1689
1830
|
setScrollUp(0);
|
|
1690
1831
|
continue;
|
|
1691
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
|
+
}
|
|
1692
1849
|
// A press on the conversation starts a selection that follows it. Anywhere else (the
|
|
1693
1850
|
// input box, the banner) it is the screen selection, which is what edits the input.
|
|
1694
1851
|
if (startGrow(event.x, event.y)) {
|
|
@@ -1705,6 +1862,15 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1705
1862
|
repaintOverlay();
|
|
1706
1863
|
}
|
|
1707
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
|
+
}
|
|
1708
1874
|
if (grow.current?.dragging) {
|
|
1709
1875
|
pointer.current = { x: event.x, y: event.y };
|
|
1710
1876
|
pendingDrag = true;
|
|
@@ -1718,6 +1884,21 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1718
1884
|
}
|
|
1719
1885
|
else {
|
|
1720
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
|
+
}
|
|
1721
1902
|
const g = grow.current;
|
|
1722
1903
|
if (g) {
|
|
1723
1904
|
stopEdge();
|
|
@@ -1735,9 +1916,13 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1735
1916
|
clearSelection();
|
|
1736
1917
|
const hitId = expandableAt(event.y, frameTopRef.current, chatRef.current);
|
|
1737
1918
|
if (hitId !== null) {
|
|
1919
|
+
lastClick.current = null;
|
|
1738
1920
|
toggleRow(hitId);
|
|
1739
1921
|
continue;
|
|
1740
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;
|
|
1741
1926
|
placeCaretAt(event.x, event.y);
|
|
1742
1927
|
continue;
|
|
1743
1928
|
}
|
|
@@ -1812,8 +1997,10 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1812
1997
|
}
|
|
1813
1998
|
function endTurn() {
|
|
1814
1999
|
releaseLiveGates();
|
|
1815
|
-
|
|
2000
|
+
// A `!` command is not the agent working: no "Worked for" receipt for it.
|
|
2001
|
+
if (turnStart.current != null && !userTurn.current)
|
|
1816
2002
|
setLastMs(Date.now() - turnStart.current);
|
|
2003
|
+
userTurn.current = false;
|
|
1817
2004
|
// Settle every tool row this turn produced into its past-tense verb. The single
|
|
1818
2005
|
// funnel for a turn ending (normal completion, error, interrupt), so no row is
|
|
1819
2006
|
// left reading "Reading" once nothing is being read.
|
|
@@ -2239,7 +2426,7 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
2239
2426
|
// loading a tool is never shown at all (see mcpSearch.ts).
|
|
2240
2427
|
if (e.name === "spawn_subagent" || neverShown(e.name))
|
|
2241
2428
|
return;
|
|
2242
|
-
const d = toolDisplay(e.name, e.args);
|
|
2429
|
+
const d = toolDisplay(e.name, e.args, session.current?.cwd ?? process.cwd());
|
|
2243
2430
|
if (e.agent) {
|
|
2244
2431
|
// A sub-agent's own tool call — fold it into that worker's nested rail.
|
|
2245
2432
|
enqueueReveal({ type: "subToolStart", agentId: e.agent, toolId: e.id, name: d.name, arg: d.arg, action: d.kind });
|
|
@@ -2361,6 +2548,54 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
2361
2548
|
pump(); // ensure we drain to endTurn even if idle now
|
|
2362
2549
|
}
|
|
2363
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
|
+
}
|
|
2364
2599
|
/**
|
|
2365
2600
|
* Run a Marathon through this screen's own turn pipeline. It is the same `respond` loop
|
|
2366
2601
|
* as any message, driven by `startMarathon`/`resumeMarathon`, so every tool row, token and
|
|
@@ -2818,7 +3053,11 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
2818
3053
|
if (name === "/help") {
|
|
2819
3054
|
const skills = s.governance.skills ?? [];
|
|
2820
3055
|
const mcpPrompts = s.toolContext.mcp?.promptCatalog() ?? [];
|
|
2821
|
-
|
|
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([
|
|
2822
3061
|
{ title: "Commands", commands: BASE_COMMANDS },
|
|
2823
3062
|
{
|
|
2824
3063
|
title: "Project skills",
|
|
@@ -2831,7 +3070,11 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
2831
3070
|
description: p.description || `prompt from ${p.server}`,
|
|
2832
3071
|
})),
|
|
2833
3072
|
},
|
|
2834
|
-
])
|
|
3073
|
+
]);
|
|
3074
|
+
if (shell === "inline")
|
|
3075
|
+
say(helpText);
|
|
3076
|
+
else
|
|
3077
|
+
dispatch({ type: "help", text: helpText });
|
|
2835
3078
|
return;
|
|
2836
3079
|
}
|
|
2837
3080
|
// /update — take the newer version and come back to this same conversation.
|
|
@@ -3511,6 +3754,20 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
3511
3754
|
const trimmed = value.trim();
|
|
3512
3755
|
if (trimmed.length === 0 || busy || !ready)
|
|
3513
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
|
+
}
|
|
3514
3771
|
if (trimmed.startsWith("/")) {
|
|
3515
3772
|
// Submitting clears the input, which closes the command list; the surface the command
|
|
3516
3773
|
// opens can only arrive after the work it does first (reading sessions, refreshing
|
|
@@ -3735,7 +3992,7 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
3735
3992
|
const here = p.id === active ? ` · on ${activeLabel}` : "";
|
|
3736
3993
|
return { label: p.label + (p.id === active ? " ✓" : ""), description: `${models} · ${key}${here}` };
|
|
3737
3994
|
});
|
|
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 }));
|
|
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 }));
|
|
3739
3996
|
}
|
|
3740
3997
|
if (overlay.kind === "model") {
|
|
3741
3998
|
const id = cur?.modelConfig.model ?? DEFAULT_MODEL_CONFIG.model;
|
|
@@ -3759,7 +4016,7 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
3759
4016
|
keywords: m.id,
|
|
3760
4017
|
};
|
|
3761
4018
|
});
|
|
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 }));
|
|
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 }));
|
|
3763
4020
|
}
|
|
3764
4021
|
if (overlay.kind === "think") {
|
|
3765
4022
|
const model = cur?.modelConfig.model ?? DEFAULT_MODEL_CONFIG.model;
|
|
@@ -3872,6 +4129,21 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
3872
4129
|
// surface — the pickers, the key manager, and the approval prompt — is content-only and
|
|
3873
4130
|
// renders inside the input's one menu box below the input line. There is no separate
|
|
3874
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;
|
|
3875
4147
|
const overlayView = buildOverlayView(maxMenuItems);
|
|
3876
4148
|
const runningShells = session.current?.toolContext.backgroundShells?.running() ?? [];
|
|
3877
4149
|
// Where the transcript sits in the viewport, and how far it can travel. Extracted
|
|
@@ -4009,11 +4281,11 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
4009
4281
|
// The status line, the queued bar, the input and whatever sits under it. Built once
|
|
4010
4282
|
// and rendered by BOTH shells: the only thing that differs between them is where it
|
|
4011
4283
|
// ends up — pinned to the bottom of a frame we own, or simply the last thing printed.
|
|
4012
|
-
const footerView = (_jsxs(Box, { ref: footerRef, flexDirection: "column", flexShrink: 0, children: [_jsx(Box, { flexShrink: 0, children: _jsx(Text, { children: " " }) }), _jsx(Box, { flexShrink: 0, children: _jsx(StatusLine, { busy: busy, startedAt: turnStart.current, lastMs: lastMs, usage: taskUsage, received: liveTokens, advance: advanceTokens, waiting: overlay?.kind === "approval", waited: () => waitedMs.current + (waitSince.current != null ? Date.now() - waitSince.current : 0) }) }), _jsx(Box, { flexShrink: 0, children: _jsx(QueuedBar, { queued: queued }) }), marathonUi ? (_jsx(Box, { flexShrink: 0, children: _jsx(MarathonBox, { ui: marathonUi, width: width, cap: shell === "inline" ? 4 : 7 }) })) : null, _jsx(Box, { flexShrink: 0, flexDirection: "column", children: ready ? (_jsx(PromptInput, { onSubmit: onSend, opening: opening, disabled: false, placeholder: marathonUi?.phase === "armed" ? "describe the goal…" : busy ? "type to queue a message…" : "say something…", width: width, history: history, completions: completions, pathComplete: pathComplete.current, onLargePaste: registerPaste, onDroppedPaths: (text) => dropHandles.current.register(text), registerCaretClick: (place) => {
|
|
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) => {
|
|
4013
4285
|
caretClick.current = place;
|
|
4014
4286
|
}, registerTextSelect: (select) => {
|
|
4015
4287
|
textSelect.current = select;
|
|
4016
|
-
}, maxMenuRows: maxMenuItems, menuAbove: shell === "inline", settleKey: committed.length, onMenuChange: onMenuChange, placeCursor: shell === "inline", onQueuePop: popQueue, overlay: overlayView, fill: fill })) : (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "starting\u2026" }) })) }), ctxWarn ? (
|
|
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 ? (
|
|
4017
4289
|
// A compaction is near. This takes the line over the tip and the background bar:
|
|
4018
4290
|
// it is the one thing here the user needs BEFORE it happens, since the summarizing
|
|
4019
4291
|
// pass rewrites the conversation. Dim as it approaches, then a plain warning colour
|
|
@@ -4511,7 +4783,7 @@ function timeAgo(ms) {
|
|
|
4511
4783
|
* line above separating it from the conversation, where the
|
|
4512
4784
|
* receipt belongs to the prompt area, not glued to the last reply.
|
|
4513
4785
|
*/
|
|
4514
|
-
function StatusLine({ busy, startedAt, lastMs, usage, received, advance, waiting, waited, }) {
|
|
4786
|
+
function StatusLine({ busy, userCommand, startedAt, lastMs, usage, received, advance, waiting, waited, }) {
|
|
4515
4787
|
// The render clock while busy: it advances the elapsed timer AND steps the eased token
|
|
4516
4788
|
// counter one frame. It runs at about the display's pace (16ms): the number reads as counting
|
|
4517
4789
|
// rather than as jumping, and the working dot moves smoothly. It was 50ms (20 frames a
|
|
@@ -4544,7 +4816,7 @@ function StatusLine({ busy, startedAt, lastMs, usage, received, advance, waiting
|
|
|
4544
4816
|
// did. Output is
|
|
4545
4817
|
// also the only thing that grows continuously while a turn runs, so it animates cleanly.
|
|
4546
4818
|
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` : "", " >"] })] }));
|
|
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` : "", " >"] })] }));
|
|
4548
4820
|
}
|
|
4549
4821
|
else if (lastMs != null) {
|
|
4550
4822
|
// Settled receipt: elapsed time and the turn's real token cost, shown at once,
|
|
@@ -4572,6 +4844,8 @@ function StatusLine({ busy, startedAt, lastMs, usage, received, advance, waiting
|
|
|
4572
4844
|
return (_jsxs(Box, { marginBottom: 0, children: [dot, label] }));
|
|
4573
4845
|
}
|
|
4574
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;
|
|
4575
4849
|
const EDGE_SCROLL_MS = 45;
|
|
4576
4850
|
/** The working line's render clock, in milliseconds. See StatusLine's tick. */
|
|
4577
4851
|
const LIVE_TICK_MS = 16;
|