mindweave 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -43
- package/dist/alternator/chassis/lsp.js +67 -0
- package/dist/alternator/chassis/lsp.js.map +1 -1
- package/dist/cli/App.js +1211 -100
- package/dist/cli/App.js.map +1 -1
- package/dist/cli/altScreen.js +80 -10
- package/dist/cli/altScreen.js.map +1 -1
- package/dist/cli/analytics.js +83 -0
- package/dist/cli/analytics.js.map +1 -0
- package/dist/cli/appIdentity.js +54 -0
- package/dist/cli/appIdentity.js.map +1 -0
- package/dist/cli/attachments.js +27 -3
- package/dist/cli/attachments.js.map +1 -1
- package/dist/cli/blockHeights.js +58 -0
- package/dist/cli/blockHeights.js.map +1 -0
- package/dist/cli/caretPark.js +94 -0
- package/dist/cli/caretPark.js.map +1 -0
- package/dist/cli/clipboard.js +68 -0
- package/dist/cli/clipboard.js.map +1 -0
- package/dist/cli/commandLabel.js +19 -2
- package/dist/cli/commandLabel.js.map +1 -1
- package/dist/cli/commands.js +2 -0
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/components/BlockView.js +1 -1
- package/dist/cli/components/BlockView.js.map +1 -1
- package/dist/cli/components/KeyManager.js +12 -6
- package/dist/cli/components/KeyManager.js.map +1 -1
- package/dist/cli/components/Picker.js +26 -7
- package/dist/cli/components/Picker.js.map +1 -1
- package/dist/cli/components/PromptInput.js +300 -74
- package/dist/cli/components/PromptInput.js.map +1 -1
- package/dist/cli/components/TipLine.js +33 -0
- package/dist/cli/components/TipLine.js.map +1 -0
- package/dist/cli/components/ToolLine.js +30 -3
- package/dist/cli/components/ToolLine.js.map +1 -1
- package/dist/cli/dropHandles.js +76 -0
- package/dist/cli/dropHandles.js.map +1 -0
- package/dist/cli/exitCursor.js +56 -0
- package/dist/cli/exitCursor.js.map +1 -0
- package/dist/cli/framebuffer/overlay.js +42 -0
- package/dist/cli/framebuffer/overlay.js.map +1 -0
- package/dist/cli/framebuffer/paint.js +8 -1
- package/dist/cli/framebuffer/paint.js.map +1 -1
- package/dist/cli/framebuffer/parse.js +54 -4
- package/dist/cli/framebuffer/parse.js.map +1 -1
- package/dist/cli/framebuffer/writer.js +357 -6
- package/dist/cli/framebuffer/writer.js.map +1 -1
- package/dist/cli/groupReveal.js +29 -3
- package/dist/cli/groupReveal.js.map +1 -1
- package/dist/cli/inputView.js +45 -0
- package/dist/cli/inputView.js.map +1 -1
- package/dist/cli/livePad.js +53 -0
- package/dist/cli/livePad.js.map +1 -0
- package/dist/cli/memoryTuning.js +31 -0
- package/dist/cli/memoryTuning.js.map +1 -0
- package/dist/cli/messageQueue.js +76 -7
- package/dist/cli/messageQueue.js.map +1 -1
- package/dist/cli/mouse.js +40 -7
- package/dist/cli/mouse.js.map +1 -1
- package/dist/cli/quietConsole.js +50 -0
- package/dist/cli/quietConsole.js.map +1 -0
- package/dist/cli/screenMode.js +118 -0
- package/dist/cli/screenMode.js.map +1 -0
- package/dist/cli/screenShell.js +67 -0
- package/dist/cli/screenShell.js.map +1 -0
- package/dist/cli/screenStore.js +46 -0
- package/dist/cli/screenStore.js.map +1 -0
- package/dist/cli/scrollPill.js +101 -0
- package/dist/cli/scrollPill.js.map +1 -0
- package/dist/cli/selection.js +130 -0
- package/dist/cli/selection.js.map +1 -0
- package/dist/cli/startupFill.js +48 -0
- package/dist/cli/startupFill.js.map +1 -0
- package/dist/cli/terminalRestore.js +1 -1
- package/dist/cli/terminalRestore.js.map +1 -1
- package/dist/cli/toolDisplay.js +11 -2
- package/dist/cli/toolDisplay.js.map +1 -1
- package/dist/cli/toolItems.js +1 -0
- package/dist/cli/toolItems.js.map +1 -1
- package/dist/cli/transcript.js +54 -3
- package/dist/cli/transcript.js.map +1 -1
- package/dist/cli/wordEdit.js +95 -0
- package/dist/cli/wordEdit.js.map +1 -0
- package/dist/drivers/openai/manifest.js +4 -1
- package/dist/drivers/openai/manifest.js.map +1 -1
- package/dist/dynamo/engine.js +126 -20
- package/dist/dynamo/engine.js.map +1 -1
- package/dist/index.js +42 -3
- package/dist/index.js.map +1 -1
- package/dist/memory/pastedText.js +37 -0
- package/dist/memory/pastedText.js.map +1 -0
- package/dist/memory/presence.js +47 -0
- package/dist/memory/presence.js.map +1 -1
- package/dist/memory/session.js +78 -4
- package/dist/memory/session.js.map +1 -1
- package/dist/memory/store.js +22 -1
- package/dist/memory/store.js.map +1 -1
- package/dist/memory/types.js.map +1 -1
- package/dist/tools/backgroundShells.js +105 -18
- package/dist/tools/backgroundShells.js.map +1 -1
- package/dist/tools/commandOutput.js +207 -0
- package/dist/tools/commandOutput.js.map +1 -0
- package/dist/tools/detail.js +2 -2
- package/dist/tools/detail.js.map +1 -1
- package/dist/tools/openDefault.js +150 -0
- package/dist/tools/openDefault.js.map +1 -0
- package/dist/tools/outputShape.js +31 -6
- package/dist/tools/outputShape.js.map +1 -1
- package/dist/tools/pathList.js +32 -0
- package/dist/tools/pathList.js.map +1 -0
- package/dist/tools/readFile.js +3 -15
- package/dist/tools/readFile.js.map +1 -1
- package/dist/tools/registry.js +3 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/runCommand.js +154 -58
- package/dist/tools/runCommand.js.map +1 -1
- package/dist/tools/screenshot.js +35 -3
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/viewImage.js +88 -0
- package/dist/tools/viewImage.js.map +1 -0
- package/dist/tools/writeFile.js +9 -1
- package/dist/tools/writeFile.js.map +1 -1
- package/package.json +2 -2
package/dist/cli/App.js
CHANGED
|
@@ -23,7 +23,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
23
23
|
*/
|
|
24
24
|
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
|
25
25
|
import { isAbsolute, resolve } from "node:path";
|
|
26
|
-
import { Box, Text, measureElement, useApp, useInput, useStdout } from "ink";
|
|
26
|
+
import { Box, Static, Text, measureElement, useApp, useInput, useStdout } from "ink";
|
|
27
27
|
import { compactNow, contextUsed, respond } from "../dynamo/engine.js";
|
|
28
28
|
import { contextPressure, sharpContextWindow } from "../dynamo/contextWindow.js";
|
|
29
29
|
import { createSession, resumeSession, reloadProjectMemory } from "../memory/session.js";
|
|
@@ -48,26 +48,41 @@ import { DEFAULT_MODEL_CONFIG, thinkLevels, thinkLabel, modelLabel, modelsOfProv
|
|
|
48
48
|
import { allProviders, manifestForModel, modelsOf } from "../drivers/registry.js";
|
|
49
49
|
import { accessRefusal } from "../drivers/providerError.js";
|
|
50
50
|
import { resolveAttachments, stripAttachments } from "./attachments.js";
|
|
51
|
+
import { collapsePastes, wrapPastedText } from "../memory/pastedText.js";
|
|
52
|
+
import { createDropHandles, expandHandles } from "./dropHandles.js";
|
|
53
|
+
import { TIPS, TipLine, nextTip, randomTipIndex } from "./components/TipLine.js";
|
|
51
54
|
import { completePath } from "./pathComplete.js";
|
|
52
55
|
import { formatHelp } from "./help.js";
|
|
53
56
|
import { hasApiKey, saveApiKey, removeApiKey, useApiKey, globalEnvPath, reloadConfig } from "./bootstrap.js";
|
|
54
57
|
import { versionLabel, appVersion } from "./version.js";
|
|
55
58
|
import { checkForUpdate } from "./updateCheck.js";
|
|
59
|
+
import { ANALYTICS_EXPLANATION, analyticsEnabled, sendAnalyticsPing, setAnalyticsEnabled, startupStatusLine, } from "./analytics.js";
|
|
56
60
|
import { PromptInput } from "./components/PromptInput.js";
|
|
57
61
|
import { Picker } from "./components/Picker.js";
|
|
58
62
|
import { ApprovalBox } from "./components/ApprovalBox.js";
|
|
59
63
|
import { BlockView } from "./components/BlockView.js";
|
|
60
64
|
import { initialState, reduce, trimNarration } from "./transcript.js";
|
|
61
65
|
import { isTight } from "./blockSpacing.js";
|
|
66
|
+
import { parseScreenArg, screenChoices, screenNotice, startupMode } from "./screenMode.js";
|
|
67
|
+
import { applyScreenMode } from "./screenShell.js";
|
|
68
|
+
import { saveScreenMode } from "./screenStore.js";
|
|
69
|
+
import { needsMeasure, pruneHeights } from "./blockHeights.js";
|
|
62
70
|
import { BASE_COMMANDS } from "./commands.js";
|
|
63
71
|
import { manualCommand, refusalReason } from "./selfUpdate.js";
|
|
64
72
|
import { currentInstall, requestRestart, runUpdate } from "./updateRunner.js";
|
|
65
|
-
import { enableMouse, readWheel } from "./mouse.js";
|
|
73
|
+
import { enableMouse, readMouse, readWheel } from "./mouse.js";
|
|
74
|
+
import { applySelection, ctrlCShouldCopy, isEmpty, selectionText } from "./selection.js";
|
|
75
|
+
import { latestScreen, repaintOverlay, setFrameOverlay } from "./framebuffer/overlay.js";
|
|
76
|
+
import { copyToClipboard } from "./clipboard.js";
|
|
66
77
|
import { chatLayout, reflowScroll } from "./chatAnchor.js";
|
|
78
|
+
import { growFill, INLINE_LIVE_RESERVE, NO_FILL } from "./startupFill.js";
|
|
79
|
+
import { setRowsBelowCaret } from "./exitCursor.js";
|
|
80
|
+
import { caretCell } from "./caretPark.js";
|
|
81
|
+
import { countNewReplies, hitsPill, pillBounds, scrollPill } from "./scrollPill.js";
|
|
67
82
|
import { virtualWindow } from "./virtualWindow.js";
|
|
68
83
|
import { perf, perfEnabled } from "./perfLog.js";
|
|
69
|
-
import { isGroupMember, groupSettled, planGroupReveal, resultQueued } from "./groupReveal.js";
|
|
70
|
-
import { drain as drainQueue, popAll as popAllQueued, visibleQueue } from "./messageQueue.js";
|
|
84
|
+
import { isGroupMember, groupSettled, planGroupReveal, planStandaloneReveal, resultQueued, STANDALONE_HOLD_MS } from "./groupReveal.js";
|
|
85
|
+
import { drain as drainQueue, popAll as popAllQueued, queueMessage, takeSteerable, visibleQueue } from "./messageQueue.js";
|
|
71
86
|
import { routeCommand, parseCommandLine, unknownCommandMessage } from "./commandRoute.js";
|
|
72
87
|
import { resolveChoice } from "./commandArgs.js";
|
|
73
88
|
import { carryAcrossFreshSession } from "./sessionCarry.js";
|
|
@@ -81,10 +96,14 @@ import { mapPromptArguments, promptCommand, promptUsage } from "../mcp/prompts.j
|
|
|
81
96
|
import { DEFAULT_MODE, modeById, modeFromFlags, nextMode } from "./modes.js";
|
|
82
97
|
import { ApprovalChannel } from "./approvalChannel.js";
|
|
83
98
|
const MINDWEAVE_DOCS_URL = "https://mindweave.dev";
|
|
99
|
+
/** How long each hint under the input box stays up. Long enough to read twice without
|
|
100
|
+
* hurrying, short enough that a session sees the whole set rather than one of them. */
|
|
101
|
+
const TIP_ROTATE_MS = 12_000;
|
|
84
102
|
/** Commands whose whole job is to open a surface in the box under the input. Written out
|
|
85
103
|
* in full, because only a bare invocation opens anything: given an argument each of these
|
|
86
104
|
* acts directly and there is no surface to hold the frame for. */
|
|
87
105
|
const OVERLAY_COMMANDS = new Set([
|
|
106
|
+
"/analytics",
|
|
88
107
|
"/continue",
|
|
89
108
|
"/key",
|
|
90
109
|
"/mcp",
|
|
@@ -110,7 +129,7 @@ const RESUME_MODES = [
|
|
|
110
129
|
{ label: "Continue as-is", description: "resume the full conversation unchanged" },
|
|
111
130
|
{ label: "Fresh start", description: "leave it and start a new empty session here instead" },
|
|
112
131
|
];
|
|
113
|
-
export function App({ resumeSessionId }) {
|
|
132
|
+
export function App({ resumeSessionId, initialScreen }) {
|
|
114
133
|
// The transcript state machine lives in a ref and is advanced by the reducer as
|
|
115
134
|
// the stream arrives; `render` forces a paint. A ref (not useState) so the async
|
|
116
135
|
// streaming loop always reads/writes the latest state without stale closures.
|
|
@@ -149,12 +168,68 @@ export function App({ resumeSessionId }) {
|
|
|
149
168
|
// flag the engine actually acts on (set by applyMode / attachApproval).
|
|
150
169
|
const [mode, setMode] = useState(DEFAULT_MODE);
|
|
151
170
|
const modeRef = useRef(DEFAULT_MODE);
|
|
152
|
-
//
|
|
153
|
-
|
|
171
|
+
// The hint under the input box. It ADVANCES (see TipLine): picking one at startup and
|
|
172
|
+
// holding it meant a whole session showed a single hint out of the set, so the rest were
|
|
173
|
+
// written and never read. Starts somewhere random so consecutive launches differ.
|
|
174
|
+
// The drag in progress, or the one just finished and still highlighted. A REF and not
|
|
175
|
+
// state: it is painted by the framebuffer overlay rather than by React, so changing it
|
|
176
|
+
// must not cost a render (see the pointer effect).
|
|
177
|
+
const selection = useRef(null);
|
|
178
|
+
/**
|
|
179
|
+
* Drop the highlight, if there is one, and stop tinting frames.
|
|
180
|
+
*
|
|
181
|
+
* The overlay is installed only for as long as a selection exists, which matters
|
|
182
|
+
* because the renderer keeps a spare copy of every frame while one is installed (so a
|
|
183
|
+
* drag can be re-tinted without a re-render). That copy is worth its cost during a drag
|
|
184
|
+
* and is pure waste the rest of the time, which is nearly all of it. Repaint FIRST,
|
|
185
|
+
* then uninstall: the repaint is what takes the highlight off the screen.
|
|
186
|
+
*/
|
|
187
|
+
const clearSelection = useCallback(() => {
|
|
188
|
+
if (!selection.current)
|
|
189
|
+
return;
|
|
190
|
+
selection.current = null;
|
|
191
|
+
repaintOverlay();
|
|
192
|
+
setFrameOverlay(null);
|
|
193
|
+
}, []);
|
|
194
|
+
// PromptInput installs the handler that turns a click into a caret position; only it
|
|
195
|
+
// knows what its rows currently hold. Null until the input is on screen.
|
|
196
|
+
const caretClick = useRef(null);
|
|
197
|
+
/** Offers a finished drag to the input as an editable range; false if it was not text
|
|
198
|
+
* the input owns. */
|
|
199
|
+
const textSelect = useRef(null);
|
|
200
|
+
const placeCaretAt = useCallback((x, y) => {
|
|
201
|
+
caretClick.current?.(x, y);
|
|
202
|
+
}, []);
|
|
203
|
+
const [tipIdx, setTipIdx] = useState(randomTipIndex);
|
|
204
|
+
// Slow on purpose. The line sits under the box the user is typing in, so it has to read
|
|
205
|
+
// as something that changed while they were not looking, never as movement competing
|
|
206
|
+
// for attention. One interval for the process, not one per render.
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
const timer = setInterval(() => setTipIdx((i) => nextTip(i)), TIP_ROTATE_MS);
|
|
209
|
+
return () => clearInterval(timer);
|
|
210
|
+
}, []);
|
|
154
211
|
// How far the transcript is scrolled back, in LINES from the bottom. Alt-screen
|
|
155
212
|
// has no terminal scrollback of its own (altScreen.ts), so this is ours to
|
|
156
213
|
// implement; 0 means pinned to the newest.
|
|
157
214
|
const [scrollUp, setScrollUp] = useState(0);
|
|
215
|
+
/**
|
|
216
|
+
* The newest block id at the moment the view left the bottom, or null while pinned.
|
|
217
|
+
*
|
|
218
|
+
* This is what "new since you scrolled away" is counted from. A REF rather than
|
|
219
|
+
* state, and that is the point: it is written in an effect and read during render,
|
|
220
|
+
* so recording it costs no re-render of its own. The count it feeds only changes
|
|
221
|
+
* when a block arrives — which is a render already.
|
|
222
|
+
*/
|
|
223
|
+
const scrollMark = useRef(null);
|
|
224
|
+
/**
|
|
225
|
+
* How far the transcript can actually travel, from the last frame.
|
|
226
|
+
*
|
|
227
|
+
* Only the render knows it — it needs the measured content and viewport heights —
|
|
228
|
+
* but the scroll handlers, which run between frames, are what have to respect it.
|
|
229
|
+
* A ref is the one thing both can reach without the handlers being rebuilt on every
|
|
230
|
+
* height change.
|
|
231
|
+
*/
|
|
232
|
+
const maxScrollRef = useRef(0);
|
|
158
233
|
// The transcript's real rendered height, from measureElement — never estimated.
|
|
159
234
|
const contentRef = useRef(null);
|
|
160
235
|
const [contentHeight, setContentHeight] = useState(0);
|
|
@@ -166,18 +241,25 @@ export function App({ resumeSessionId }) {
|
|
|
166
241
|
// `virtualWindow.ts` for why that is the whole performance story, and why exact
|
|
167
242
|
// is the word that matters.
|
|
168
243
|
//
|
|
169
|
-
//
|
|
244
|
+
// Each entry keeps the BLOCK OBJECT it was measured from, and a lookup only counts
|
|
245
|
+
// when that object is still the current one. That is load-bearing rather than
|
|
170
246
|
// stylistic: the transcript reducer returns a NEW object whenever a block changes
|
|
171
247
|
// (streaming text growing, `live` flipping at turn end) and the same object when it
|
|
172
|
-
// does not. So a changed block simply has no
|
|
248
|
+
// does not. So a changed block simply has no usable height, is rendered in full, and
|
|
173
249
|
// is re-measured — cache invalidation falls out of the data model instead of needing
|
|
174
|
-
// a rule that could be forgotten for some future block type.
|
|
175
|
-
//
|
|
176
|
-
|
|
250
|
+
// a rule that could be forgotten for some future block type.
|
|
251
|
+
//
|
|
252
|
+
// A Map keyed by id rather than a WeakMap keyed by the object, for one reason: a
|
|
253
|
+
// WeakMap cannot be iterated, and a resize needs to walk every height to rescale it
|
|
254
|
+
// (see the width-change block below). The identity check gives the same invalidation
|
|
255
|
+
// a WeakMap gave for free; `pruneHeights` gives the same bounded memory.
|
|
256
|
+
/** `scaled` marks a height that was RESCALED by a width change rather than measured:
|
|
257
|
+
* good enough to size a spacer with, and still owed a real measurement. See the
|
|
258
|
+
* width-change branch in the render. */
|
|
259
|
+
const blockHeights = useRef(new Map());
|
|
177
260
|
// Nodes captured this render, waiting to be measured once Yoga has laid them out.
|
|
178
261
|
const toMeasure = useRef(new Map());
|
|
179
|
-
//
|
|
180
|
-
// the whole table away rather than scrolling against stale numbers.
|
|
262
|
+
// The width every cached height was measured at. A height is only true for one width.
|
|
181
263
|
const heightsWidth = useRef(0);
|
|
182
264
|
// Bumped when a measurement lands, purely to re-render so the new height can be
|
|
183
265
|
// used. Never read.
|
|
@@ -190,6 +272,14 @@ export function App({ resumeSessionId }) {
|
|
|
190
272
|
// the terminal, which corrupts the whole frame if that's the row that tips
|
|
191
273
|
// outputHeight to stdout.rows. Measured, this can't drift.
|
|
192
274
|
const footerRef = useRef(null);
|
|
275
|
+
/** The inline shell's whole live region, measured so the exit path knows how far the
|
|
276
|
+
* caret sits above the last row drawn. See exitCursor.ts. */
|
|
277
|
+
const liveRef = useRef(null);
|
|
278
|
+
/** The chip's row WITHIN the live region, from the layout, or null when it is not up. */
|
|
279
|
+
const pillRow = useRef(null);
|
|
280
|
+
/** The chip's cells in SCREEN coordinates, for the pointer handler. Null when there is
|
|
281
|
+
* nothing to click. Published by the render, read between frames. */
|
|
282
|
+
const pillHit = useRef(null);
|
|
193
283
|
const [footerHeight, setFooterHeight] = useState(0);
|
|
194
284
|
// The chat viewport's REAL height. Yoga decides it now (flexGrow beside a
|
|
195
285
|
// flexShrink:0 footer); this is read back purely so the scroll maths knows how
|
|
@@ -261,10 +351,137 @@ export function App({ resumeSessionId }) {
|
|
|
261
351
|
const needsKey = setupOpen || keysOpen;
|
|
262
352
|
// Sent-message history, oldest-first — walked with ↑/↓ in the input.
|
|
263
353
|
const [history, setHistory] = useState([]);
|
|
264
|
-
//
|
|
265
|
-
//
|
|
354
|
+
// Which shell the app is wearing. See `screenMode.ts`; `/screen` switches it.
|
|
355
|
+
//
|
|
356
|
+
// State rather than a ref, because the render branches on it. The terminal side of the
|
|
357
|
+
// switch — alternate screen, mouse, framebuffer — is applied by the effect below, not
|
|
358
|
+
// here, so the escape codes never go out during a render.
|
|
359
|
+
const [shell, setShell] = useState(() => initialScreen ?? startupMode());
|
|
360
|
+
/**
|
|
361
|
+
* Reading mode: the inline shell, scrolling with the prompt PINNED.
|
|
362
|
+
*
|
|
363
|
+
* The inline shell prints into the terminal's scrollback and the terminal owns the
|
|
364
|
+
* wheel, so looking back at anything carries the prompt off the top of the screen
|
|
365
|
+
* with everything else. That is how a shell prompt behaves and it is what the shell
|
|
366
|
+
* is for — right up until you want to read the middle of a long answer and reply to
|
|
367
|
+
* it, which is most of the time.
|
|
368
|
+
*
|
|
369
|
+
* A pinned prompt is normally something only a full-screen layout offers, because
|
|
370
|
+
* pinning means owning the screen. Offering it here without owning the screen is what
|
|
371
|
+
* this mode is, and it is built to cost nothing while it is not in use.
|
|
372
|
+
*
|
|
373
|
+
* While reading, the app renders a frame of its own into the live region and scrolls
|
|
374
|
+
* INSIDE it — the same viewport, offset and measurement the full-screen shell uses,
|
|
375
|
+
* with the footer pinned under it. Leaving it hands the terminal back.
|
|
376
|
+
*
|
|
377
|
+
* DERIVED from `scrollUp`, not its own state, and that is what fixes the flicker an
|
|
378
|
+
* earlier version had. That version tracked reading separately and opened it with
|
|
379
|
+
* `setReading(true)` followed by `setScrollUp(...)` — two calls, and Ink runs React
|
|
380
|
+
* in LegacyRoot mode, where state updates outside a React event are NOT batched. Each
|
|
381
|
+
* call flushed its own synchronous render: one frame painted with reading true and
|
|
382
|
+
* `scrollUp` still at its old value (0, on the way in — the "at rest" shape), and the
|
|
383
|
+
* very next painted the real scrolled position. Two different frames for one
|
|
384
|
+
* keystroke is a flicker by definition, and the same shape hit on the way out — a
|
|
385
|
+
* separate effect watched for `scrollUp` reaching 0 and called `setReading(false)` a
|
|
386
|
+
* render late, so the screen showed the framed view sitting at the bottom for one
|
|
387
|
+
* frame before dropping to the tail view.
|
|
388
|
+
*
|
|
389
|
+
* `scrollUp > 0` means the same thing `reading` did, computed in the SAME render as
|
|
390
|
+
* the scroll position that decides it, in the same commit. Entering and leaving both
|
|
391
|
+
* become the ordinary case of one state value changing once — no closing effect, no
|
|
392
|
+
* second render, nothing for the terminal to paint twice.
|
|
393
|
+
*/
|
|
394
|
+
const reading = shell === "inline" && scrollUp > 0;
|
|
395
|
+
// Where the reprint starts, and how many blank rows go above it. Both are decided ONCE
|
|
396
|
+
// when the inline shell is entered and then held: <Static> prints its items a single
|
|
397
|
+
// time, so anything that changed between renders would either never be printed or be
|
|
398
|
+
// printed twice.
|
|
399
|
+
const reprintFrom = useRef(0);
|
|
400
|
+
const startFill = useRef(0);
|
|
401
|
+
// Bumped to remount <Static> when the inline shell is entered. See below.
|
|
402
|
+
const [staticEpoch, setStaticEpoch] = useState(0);
|
|
403
|
+
/**
|
|
404
|
+
* A second remount counter, bumped DURING RENDER when the reading view closes.
|
|
405
|
+
*
|
|
406
|
+
* Separate from `staticEpoch` because of WHEN it changes, not what it means. The shell
|
|
407
|
+
* switch can afford an effect; closing the reading view cannot — see the block that
|
|
408
|
+
* writes this, next to the inline return.
|
|
409
|
+
*/
|
|
410
|
+
const closeEpoch = useRef(0);
|
|
411
|
+
/** The inline startup fill and the terminal height it was sized for. Declared here,
|
|
412
|
+
* above the effects that also seat the fill, so all of them share one basis and the
|
|
413
|
+
* render-phase grow does not re-fire on a switch that already sized it. See
|
|
414
|
+
* startupFill.ts. */
|
|
415
|
+
const fillState = useRef(NO_FILL);
|
|
416
|
+
/** Whether the inline reading view was open on the previous render — declared here,
|
|
417
|
+
* above the first-run gates, because a hook below them changes the hook count when a
|
|
418
|
+
* gate closes and React crashes the app. Its render-phase logic stays near the inline
|
|
419
|
+
* return. */
|
|
420
|
+
const wasReadingInline = useRef(false);
|
|
421
|
+
/** How far <Static> was allowed to reach while the reading viewport is open, or null
|
|
422
|
+
* when closed. Declared above the gates for the same reason as wasReadingInline. */
|
|
423
|
+
const frozenStatic = useRef(null);
|
|
424
|
+
/**
|
|
425
|
+
* Whether a `<Static>` remount should reprint the one-time header.
|
|
426
|
+
*
|
|
427
|
+
* True for a remount that is replacing a screen the header is genuinely absent from —
|
|
428
|
+
* arriving from the full-screen shell, whose alternate buffer discarded it. False for
|
|
429
|
+
* one that is only refilling rows below a header still sitting in scrollback, where
|
|
430
|
+
* printing it again would put a second banner in the middle of the conversation.
|
|
431
|
+
*/
|
|
432
|
+
const showHeader = useRef(true);
|
|
433
|
+
const shellBefore = useRef(null);
|
|
434
|
+
useEffect(() => {
|
|
435
|
+
applyScreenMode(shell);
|
|
436
|
+
// Arriving in the inline shell from the other one, the conversation so far has to be
|
|
437
|
+
// REPRINTED, and nothing else will do it.
|
|
438
|
+
//
|
|
439
|
+
// Ink's <Static> keeps a count of how many items it has already emitted and renders
|
|
440
|
+
// only `items.slice(index)` — printed once is its whole contract. Every one of those
|
|
441
|
+
// items was written into the ALTERNATE screen buffer, which leaving just discarded.
|
|
442
|
+
// So the terminal came back to the primary buffer holding whatever was there before
|
|
443
|
+
// the session started, and the transcript existed only in a counter's memory: no
|
|
444
|
+
// banner, no history, and nothing to scroll back to.
|
|
445
|
+
//
|
|
446
|
+
// A new key remounts it, which resets that counter to zero and prints the whole list
|
|
447
|
+
// into the buffer the user is actually looking at. Only on the transition, never on
|
|
448
|
+
// first mount — there, <Static> has printed nothing yet and remounting would emit
|
|
449
|
+
// every block a second time.
|
|
450
|
+
if (shell === "inline" && (shellBefore.current === null || shellBefore.current !== shell)) {
|
|
451
|
+
// Only the RECENT conversation is reprinted, not the whole session.
|
|
452
|
+
//
|
|
453
|
+
// Everything printed while fullscreen went into the alternate screen buffer and is
|
|
454
|
+
// gone whatever we do; reprinting all of it costs about 1.7ms a block, measured, so
|
|
455
|
+
// a long session spent a third of a second on a blank screen printing scrollback
|
|
456
|
+
// nobody asked to see. A couple of screens is all that can be looked at anyway.
|
|
457
|
+
reprintFrom.current = Math.max(0, committed.length - INLINE_REPRINT_BLOCKS);
|
|
458
|
+
// Blank rows so the conversation lands at the BOTTOM of the screen rather than the
|
|
459
|
+
// top. A terminal prints from wherever the cursor is, which after leaving the
|
|
460
|
+
// alternate screen is wherever the shell left it — usually near the top, with the
|
|
461
|
+
// prompt then floating in the middle of an empty window. These push it down. They
|
|
462
|
+
// are printed ONCE, into scrollback, so they cost nothing after the first screen
|
|
463
|
+
// and disappear the moment there is enough conversation to fill it.
|
|
464
|
+
startFill.current = Math.max(0, rows - INLINE_LIVE_RESERVE);
|
|
465
|
+
fillState.current = { fill: startFill.current, basis: rows };
|
|
466
|
+
// Keep the render-phase grow in step, so it does not treat this as a fresh void.
|
|
467
|
+
fillState.current = { fill: startFill.current, basis: rows };
|
|
468
|
+
// This reprint IS replacing a discarded screen, so it owns the header.
|
|
469
|
+
showHeader.current = true;
|
|
470
|
+
}
|
|
471
|
+
if (shellBefore.current !== null && shellBefore.current !== shell && shell === "inline") {
|
|
472
|
+
setStaticEpoch((n) => n + 1);
|
|
473
|
+
}
|
|
474
|
+
shellBefore.current = shell;
|
|
475
|
+
}, [shell]);
|
|
476
|
+
// Messages typed while Mindweave is working — the input stays live. Ordinary prose is
|
|
477
|
+
// handed to the RUNNING turn at its next step boundary; a slash command, and anything
|
|
478
|
+
// typed after Esc, waits for the turn to be over. See messageQueue.ts.
|
|
266
479
|
const queueRef = useRef([]);
|
|
267
480
|
const [queued, setQueued] = useState([]);
|
|
481
|
+
// Esc has been pressed and the turn is still winding down. Anything typed in that gap
|
|
482
|
+
// belongs to the NEXT turn: steering it would carry out a correction inside the very
|
|
483
|
+
// turn the user just stopped. Cleared when the next turn starts.
|
|
484
|
+
const interrupting = useRef(false);
|
|
268
485
|
// An interactive overlay (session picker, model/think chooser, or an approval
|
|
269
486
|
// prompt). When set, it owns the keyboard and the input box is hidden.
|
|
270
487
|
const [overlay, setOverlay] = useState(null);
|
|
@@ -341,7 +558,7 @@ export function App({ resumeSessionId }) {
|
|
|
341
558
|
// the user never typed — seen live as "> That was 3 sentences between tool
|
|
342
559
|
// calls…" sitting in their own chat history.
|
|
343
560
|
if (!e.synthetic)
|
|
344
|
-
dispatch({ type: "user", text: stripAttachments(e.content) });
|
|
561
|
+
dispatch({ type: "user", text: collapsePastes(stripAttachments(e.content)) });
|
|
345
562
|
}
|
|
346
563
|
else if (e.role === "summary") {
|
|
347
564
|
dispatch({ type: "note", text: "— resumed; earlier context summarized —" });
|
|
@@ -386,6 +603,7 @@ export function App({ resumeSessionId }) {
|
|
|
386
603
|
...(e.quiet ? { quiet: true } : {}),
|
|
387
604
|
...(e.displayName ? { name: e.displayName } : {}),
|
|
388
605
|
...(e.displayKind ? { action: e.displayKind } : {}),
|
|
606
|
+
...(e.awaitsModel ? { awaitsModel: true } : {}),
|
|
389
607
|
});
|
|
390
608
|
}
|
|
391
609
|
}
|
|
@@ -492,10 +710,14 @@ export function App({ resumeSessionId }) {
|
|
|
492
710
|
let out = text;
|
|
493
711
|
for (const [chip, content] of pasteStore.current) {
|
|
494
712
|
if (out.includes(chip))
|
|
495
|
-
out = out.split(chip).join(content);
|
|
713
|
+
out = out.split(chip).join(wrapPastedText(content));
|
|
496
714
|
}
|
|
497
715
|
return out;
|
|
498
716
|
}
|
|
717
|
+
// The same trade for dropped files: the buffer holds `mwimg1`, this holds the path it
|
|
718
|
+
// stands for. Resolution against the session's cwd happens here so the store is keyed
|
|
719
|
+
// by one canonical form however the path was spelled when it landed.
|
|
720
|
+
const dropHandles = useRef(createDropHandles((p) => (isAbsolute(p) ? resolve(p) : resolve(session.current?.cwd ?? process.cwd(), p))));
|
|
499
721
|
// File-path completion for the input's `@mention` picker (primary root).
|
|
500
722
|
const pathComplete = useRef((prefix) => {
|
|
501
723
|
const s = session.current;
|
|
@@ -503,11 +725,40 @@ export function App({ resumeSessionId }) {
|
|
|
503
725
|
});
|
|
504
726
|
// Live terminal width — drives message wrapping (Static items capture it at
|
|
505
727
|
// commit time; the live input reflows on resize for free).
|
|
506
|
-
|
|
728
|
+
// The inline shell defers a resize until the drag settles; the full-screen one takes
|
|
729
|
+
// it immediately. See useTerminalSize for why the two differ.
|
|
730
|
+
const { columns: width, rows } = useTerminalSize(shell === "inline");
|
|
507
731
|
// Read live at render time as well as from the polled state above: mid-resize
|
|
508
732
|
// the state can lag the real terminal by a tick, and a frame one row too TALL
|
|
509
733
|
// is the failure that corrupts the screen (see the layout comment below).
|
|
510
734
|
const { stdout } = useStdout();
|
|
735
|
+
// A width change in the inline shell: reprint, rather than trust the erase.
|
|
736
|
+
//
|
|
737
|
+
// Ink redraws its live region by erasing the number of LINES it last wrote. After a
|
|
738
|
+
// resize that number is wrong — the same content wraps differently at the new width, and
|
|
739
|
+
// the terminal has reflowed what was already on screen — so it erases too few and leaves
|
|
740
|
+
// half of the old region behind: a second status line, a fragment of the input box's
|
|
741
|
+
// border, a ladder of them down a slow drag.
|
|
742
|
+
//
|
|
743
|
+
// Predicting the right number means predicting the post-resize wrapping of everything on
|
|
744
|
+
// screen, which is the layout itself. So it is not predicted. The region is printed
|
|
745
|
+
// again, below the mess, with the usual fill above it — the stale copy goes up into
|
|
746
|
+
// scrollback where it belongs and the screen comes back clean with the conversation at
|
|
747
|
+
// the bottom. A resize is rare enough to pay for that.
|
|
748
|
+
//
|
|
749
|
+
// Only on WIDTH. Height changes do not re-wrap anything, and reprinting on one would
|
|
750
|
+
// fire on every vertical drag for nothing.
|
|
751
|
+
const widthBefore = useRef(width);
|
|
752
|
+
useEffect(() => {
|
|
753
|
+
if (widthBefore.current === width)
|
|
754
|
+
return;
|
|
755
|
+
widthBefore.current = width;
|
|
756
|
+
if (shell !== "inline")
|
|
757
|
+
return;
|
|
758
|
+
reprintFrom.current = Math.max(0, committed.length - INLINE_REPRINT_BLOCKS);
|
|
759
|
+
startFill.current = Math.max(0, rows - INLINE_LIVE_RESERVE);
|
|
760
|
+
setStaticEpoch((n) => n + 1);
|
|
761
|
+
}, [width, shell]);
|
|
511
762
|
// One session for the whole conversation (a ref so it survives re-renders).
|
|
512
763
|
const session = useRef(null);
|
|
513
764
|
useEffect(() => {
|
|
@@ -548,6 +799,12 @@ export function App({ resumeSessionId }) {
|
|
|
548
799
|
setSetupOpen(true);
|
|
549
800
|
});
|
|
550
801
|
}, []);
|
|
802
|
+
// Anonymous usage ping — on by default. The status line shows every launch, not just
|
|
803
|
+
// the first one; the full explanation lives in the /analytics box itself.
|
|
804
|
+
useEffect(() => {
|
|
805
|
+
note(startupStatusLine());
|
|
806
|
+
sendAnalyticsPing(appVersion());
|
|
807
|
+
}, []);
|
|
551
808
|
// A quiet, backgrounded check for a newer release — independent of session startup so a
|
|
552
809
|
// slow or unreachable registry can never delay the first prompt. checkForUpdate() is
|
|
553
810
|
// itself the one that fails silent on every network path; a note only appears when a
|
|
@@ -578,7 +835,7 @@ export function App({ resumeSessionId }) {
|
|
|
578
835
|
return;
|
|
579
836
|
queueRef.current = next.rest;
|
|
580
837
|
setQueued(next.rest);
|
|
581
|
-
void handleSubmit(next.send);
|
|
838
|
+
void handleSubmit(next.send, { arrival: next.priority === "now" ? "interrupting" : undefined });
|
|
582
839
|
}, [busy, ready, needsKey, overlay]);
|
|
583
840
|
// ↑ or Esc takes the queue back into the input box, editable, and empties it. This
|
|
584
841
|
// is the ONLY way to change your mind about something already queued, so it has to
|
|
@@ -604,17 +861,55 @@ export function App({ resumeSessionId }) {
|
|
|
604
861
|
usageSamples.current = [];
|
|
605
862
|
meter.current = meterReset();
|
|
606
863
|
setTaskUsage(null); // clear the previous task's summary while this one runs
|
|
864
|
+
interrupting.current = false;
|
|
607
865
|
abortRef.current = new AbortController();
|
|
608
866
|
setBusy(true);
|
|
609
867
|
}
|
|
610
868
|
// Esc interrupts the current turn: it aborts the model call AND kills a running
|
|
611
869
|
// command — run_command listens to this same signal (see runShell), so a hung
|
|
612
870
|
// command (e.g. an installer waiting on a GUI) can no longer freeze the agent.
|
|
871
|
+
/**
|
|
872
|
+
* Ctrl+C quits, all the way.
|
|
873
|
+
*
|
|
874
|
+
* Ink no longer does anything with it (`exitOnCtrlC: false` in index.ts), because what
|
|
875
|
+
* it did was unmount and stop there: the process stayed up with the turn still running,
|
|
876
|
+
* and the two `exit` hooks that matter — restoring the terminal, and synchronously
|
|
877
|
+
* killing background shells — never ran, because nothing exited.
|
|
878
|
+
*
|
|
879
|
+
* `process.exit` is what runs them. 130 is the conventional code for a program ended by
|
|
880
|
+
* SIGINT, so a shell script wrapping this reads the interruption correctly.
|
|
881
|
+
*
|
|
882
|
+
* Esc remains the way to stop a TURN without leaving. This is the way to leave.
|
|
883
|
+
*
|
|
884
|
+
* EXCEPT while a selection is on screen. The app owns the mouse in the full-screen
|
|
885
|
+
* shell, so text is selected by dragging and Ctrl+C is the reflex to copy it — and
|
|
886
|
+
* quitting on that reflex, right after someone highlighted something to keep, loses
|
|
887
|
+
* both the selection and the session. So a Ctrl+C with a highlight up COPIES it (the
|
|
888
|
+
* drag already did on release; this re-copies so the keystroke is never a no-op) and
|
|
889
|
+
* takes the highlight down, and does not quit. With nothing selected it quits as
|
|
890
|
+
* before — so a second Ctrl+C, once the highlight is gone, still leaves.
|
|
891
|
+
*/
|
|
892
|
+
useInput((input, key) => {
|
|
893
|
+
if (!key.ctrl || input !== "c")
|
|
894
|
+
return;
|
|
895
|
+
const sel = selection.current;
|
|
896
|
+
if (ctrlCShouldCopy(sel)) {
|
|
897
|
+
const screen = latestScreen();
|
|
898
|
+
if (screen)
|
|
899
|
+
copyToClipboard(selectionText(screen, sel));
|
|
900
|
+
clearSelection();
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
abortRef.current?.abort();
|
|
904
|
+
process.exit(130);
|
|
905
|
+
}, { isActive: true });
|
|
613
906
|
// Only while working AND no overlay is open (an open Picker owns Esc for its own
|
|
614
907
|
// cancel). The input ignores Esc, so typing-while-busy is safe.
|
|
615
908
|
useInput((_input, key) => {
|
|
616
909
|
if (key.escape) {
|
|
617
910
|
abortRef.current?.abort();
|
|
911
|
+
// From here until the next turn starts, anything typed is for the NEXT turn.
|
|
912
|
+
interrupting.current = true;
|
|
618
913
|
// Anything still waiting to be asked is answered as declined. A queued approval
|
|
619
914
|
// has no overlay to press Esc on, so without this the tool holding it would wait
|
|
620
915
|
// for the rest of the session on a question the user has already stopped.
|
|
@@ -662,10 +957,49 @@ export function App({ resumeSessionId }) {
|
|
|
662
957
|
// the animation explains a movement the user did not make. A wheel notch is a direct
|
|
663
958
|
// manipulation, and direct manipulation must be 1:1
|
|
664
959
|
// with the input or it reads as lag, because it IS lag. Do not re-add it here.
|
|
960
|
+
//
|
|
961
|
+
// CLAMPED AT BOTH ENDS, and the top one is not cosmetic. `chatLayout` clamps for
|
|
962
|
+
// DISPLAY, so scrolling up past the first line looked like it had stopped while the
|
|
963
|
+
// counter kept climbing — and every one of those phantom lines then had to be
|
|
964
|
+
// scrolled back down before the view moved at all. A flick or two past the top bought
|
|
965
|
+
// a second of a wheel that did nothing, which reads as the app having frozen.
|
|
665
966
|
const scrollBy = useCallback((lines) => {
|
|
666
|
-
setScrollUp((s) => Math.max(0, s + lines));
|
|
967
|
+
setScrollUp((s) => Math.max(0, Math.min(maxScrollRef.current, s + lines)));
|
|
968
|
+
}, []);
|
|
969
|
+
/**
|
|
970
|
+
* Open the inline shell's reading view, moving by `lines` in the same gesture.
|
|
971
|
+
*
|
|
972
|
+
* ONE state change, `scrollUp` alone — `reading` is derived from it, so this cannot
|
|
973
|
+
* reintroduce the two-render flicker a separate `setReading(true)` used to cause.
|
|
974
|
+
*
|
|
975
|
+
* UNCLAMPED, and that half has to stay. `maxScrollRef` is published by the render,
|
|
976
|
+
* and until this frame exists there is no viewport, nothing measured, and the last
|
|
977
|
+
* value it holds is zero. Routed through `scrollBy`, the very first notch would
|
|
978
|
+
* therefore be clamped to nothing — `reading` would compute false, and the wheel
|
|
979
|
+
* would appear to do nothing at all.
|
|
980
|
+
*
|
|
981
|
+
* Overshooting is the safe direction and it is self-correcting: `chatLayout` clamps
|
|
982
|
+
* for display, so the frame shows the top rather than anything invalid, and the next
|
|
983
|
+
* notch goes through `scrollBy` with a real measurement and pulls the number back to
|
|
984
|
+
* what actually exists.
|
|
985
|
+
*/
|
|
986
|
+
const openReading = useCallback((lines) => {
|
|
987
|
+
setScrollUp((s) => Math.max(1, s + Math.abs(lines)));
|
|
667
988
|
}, []);
|
|
668
989
|
useInput((_input, key) => {
|
|
990
|
+
// In the inline shell, scrolling back is a MODE, and any of these opens it.
|
|
991
|
+
//
|
|
992
|
+
// Nothing below can do anything until the app is drawing its own frame — the
|
|
993
|
+
// inline shell has no viewport to offset — so the first press has to build one.
|
|
994
|
+
// The scroll it was asking for then happens in the same keystroke, because a key
|
|
995
|
+
// that only "gets ready" and moves nothing reads as a key that did nothing.
|
|
996
|
+
const back = key.pageUp || (key.upArrow && key.shift);
|
|
997
|
+
if (shell === "inline" && back && !reading) {
|
|
998
|
+
// Same first-notch problem the wheel has: there is no viewport yet, so nothing
|
|
999
|
+
// is measured and a clamped scroll would move nothing. See openReading.
|
|
1000
|
+
openReading(key.pageUp ? PAGE_LINES : 1);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
669
1003
|
// Shift+arrows as well as PageUp/PageDown: Windows consoles routinely eat
|
|
670
1004
|
// the paging keys before an app sees them, so there has to be a second way in.
|
|
671
1005
|
if (key.pageUp)
|
|
@@ -676,7 +1010,94 @@ export function App({ resumeSessionId }) {
|
|
|
676
1010
|
scrollBy(1);
|
|
677
1011
|
else if (key.downArrow && key.shift)
|
|
678
1012
|
scrollBy(-1);
|
|
1013
|
+
// Back to the newest in one keystroke, and back to the start of the conversation
|
|
1014
|
+
// in the other. Without these, the only way out of a long scroll was to scroll
|
|
1015
|
+
// the whole distance again by hand — and the chip that appears while scrolled
|
|
1016
|
+
// back (see scrollPill.ts) names ctrl+End, so this is the half that makes the
|
|
1017
|
+
// chip true. CTRL is what keeps them off the input: plain End and Home belong to
|
|
1018
|
+
// the caret, whether or not the input claims them yet.
|
|
1019
|
+
else if (key.end && key.ctrl)
|
|
1020
|
+
setScrollUp(0);
|
|
1021
|
+
else if (key.home && key.ctrl)
|
|
1022
|
+
setScrollUp(maxScrollRef.current);
|
|
679
1023
|
}, { isActive: ready && overlay === null });
|
|
1024
|
+
// Leaving the reading view when it reaches the bottom needs no effect of its own:
|
|
1025
|
+
// `reading` is `scrollUp > 0`, so landing on zero — the wheel, the keys, ctrl+End, or
|
|
1026
|
+
// a sent message snapping the view back before it delivers — closes it in the same
|
|
1027
|
+
// render that moved the scroll, not a render later. See the derivation above for why
|
|
1028
|
+
// a separate effect here was the other half of the flicker.
|
|
1029
|
+
/**
|
|
1030
|
+
* The wheel, for as long as the reading view is up.
|
|
1031
|
+
*
|
|
1032
|
+
* ON FOR THE WHOLE INLINE SESSION, not only while the reading view is up, and the
|
|
1033
|
+
* reason is that the wheel is how anyone actually scrolls.
|
|
1034
|
+
*
|
|
1035
|
+
* Reporting is what makes a wheel notch reach this process at all. Switched on only
|
|
1036
|
+
* once reading had already started, the gesture that starts reading could never be the
|
|
1037
|
+
* wheel — the first notch went to the terminal, which scrolled its own buffer and
|
|
1038
|
+
* carried the prompt off the top, which is the whole thing being fixed. There is no
|
|
1039
|
+
* way to watch for a wheel notch without taking the wheel.
|
|
1040
|
+
*
|
|
1041
|
+
* So the trade is made openly: while an inline session is running, the terminal's own
|
|
1042
|
+
* wheel scrolls nothing and this app scrolls instead. Its scrollbar still drags and
|
|
1043
|
+
* Shift still selects, both being the terminal's own doing, and everything printed is
|
|
1044
|
+
* still in the terminal's scrollback where it has always been.
|
|
1045
|
+
*
|
|
1046
|
+
* The full-screen shell is untouched here: it takes the mouse through
|
|
1047
|
+
* `applyScreenMode`, which is also what releases it on the way into this one — so this
|
|
1048
|
+
* effect runs after that release and is what puts it back for the inline shell.
|
|
1049
|
+
*/
|
|
1050
|
+
useEffect(() => {
|
|
1051
|
+
if (shell !== "inline")
|
|
1052
|
+
return;
|
|
1053
|
+
const off = enableMouse();
|
|
1054
|
+
return () => off();
|
|
1055
|
+
}, [shell]);
|
|
1056
|
+
// Leaving the reading view needs no reprint, and an earlier version of this that
|
|
1057
|
+
// forced one is what actually caused the reported flicker — a full transcript area
|
|
1058
|
+
// going black for a frame, footer untouched, right at the instant the view landed on
|
|
1059
|
+
// the bottom.
|
|
1060
|
+
//
|
|
1061
|
+
// The reasoning that led there was borrowed from the wrong case. Coming back from the
|
|
1062
|
+
// FULL-SCREEN shell genuinely needs a reprint: everything <Static> had printed went
|
|
1063
|
+
// into the ALTERNATE screen buffer, which leaving it discards outright — nothing of
|
|
1064
|
+
// it survives in the terminal the user is now looking at. The reading view never
|
|
1065
|
+
// leaves the primary buffer at all. Every line it ever showed was already sitting in
|
|
1066
|
+
// real scrollback the moment <Static> printed it, before reading even opened, and nothing
|
|
1067
|
+
// about opening or closing the reading frame touches that. Shrinking the live region
|
|
1068
|
+
// from the frame's height back down to the ordinary tail is exactly the same erase the
|
|
1069
|
+
// live region already goes through many times an ordinary conversation — a reply
|
|
1070
|
+
// finishing and its block draining into <Static> shrinks the tail the same way, with
|
|
1071
|
+
// no special handling, because Ink's own line-count bookkeeping is what makes an
|
|
1072
|
+
// ordinary shrink safe.
|
|
1073
|
+
//
|
|
1074
|
+
// What the reprint bought instead was a REMOUNT: a new `<Static>` key forces every
|
|
1075
|
+
// held item — up to `INLINE_REPRINT_BLOCKS` of them, full diffs, syntax highlighting
|
|
1076
|
+
// and all — to be laid out and printed again, all in the same instant the frame is
|
|
1077
|
+
// already shrinking. That is real, synchronous work sitting between the erase and the
|
|
1078
|
+
// redraw, for content the terminal already had. Removed rather than budgeted, since
|
|
1079
|
+
// there was never a hole here to fill.
|
|
1080
|
+
// Reading mode belongs to the inline shell alone: the full-screen one is always
|
|
1081
|
+
// drawing its own frame, so there is no mode to be in.
|
|
1082
|
+
const readingInline = shell === "inline" && reading;
|
|
1083
|
+
/**
|
|
1084
|
+
* Where "new since you scrolled away" counts from.
|
|
1085
|
+
*
|
|
1086
|
+
* Set on the frame the view leaves the bottom and cleared the moment it returns, so
|
|
1087
|
+
* a reader who scrolls back, reads, and comes back down starts the next scroll with
|
|
1088
|
+
* a clean count rather than one carried over from the last.
|
|
1089
|
+
*
|
|
1090
|
+
* Depends on `scrollUp` alone. The transcript's own id is read at effect time, which
|
|
1091
|
+
* is after the render that moved the view — the same frame, nothing appended in
|
|
1092
|
+
* between, so the mark is exactly the newest block the reader had seen.
|
|
1093
|
+
*
|
|
1094
|
+
*/
|
|
1095
|
+
useEffect(() => {
|
|
1096
|
+
if (scrollUp === 0)
|
|
1097
|
+
scrollMark.current = null;
|
|
1098
|
+
else if (scrollMark.current === null)
|
|
1099
|
+
scrollMark.current = stateRef.current.seq;
|
|
1100
|
+
}, [scrollUp]);
|
|
680
1101
|
// Measure the transcript's real rendered height after every render. Deliberately
|
|
681
1102
|
// has no dependency list: the height changes for reasons no dep could name — a
|
|
682
1103
|
// reply landing, a terminal resize re-wrapping every paragraph — and the guard
|
|
@@ -710,20 +1131,35 @@ export function App({ resumeSessionId }) {
|
|
|
710
1131
|
return;
|
|
711
1132
|
let learned = false;
|
|
712
1133
|
for (const [block, node] of toMeasure.current) {
|
|
713
|
-
|
|
1134
|
+
// A block that is still OPEN is deliberately not recorded.
|
|
1135
|
+
//
|
|
1136
|
+
// Its content changes on every delta, so the reducer hands back a new object each
|
|
1137
|
+
// time and the height taken a moment ago is already wrong. Recording it anyway
|
|
1138
|
+
// cost a measurement AND a re-render per delta — the state bump below — which on
|
|
1139
|
+
// a streaming reply is the hottest path in the app. An open block is always the
|
|
1140
|
+
// last one, so leaving it out of the table costs a single block laid out in full.
|
|
1141
|
+
if (!block.done)
|
|
1142
|
+
continue;
|
|
1143
|
+
// The same rule the ref callback used to queue it, so the two can never disagree
|
|
1144
|
+
// about what still owes a measurement. See blockHeights.needsMeasure.
|
|
1145
|
+
if (!needsMeasure(blockHeights.current.get(block.id), block))
|
|
714
1146
|
continue;
|
|
715
1147
|
const { height } = measureElement(node);
|
|
716
1148
|
// A height of 0 is not a measurement, it is a block that has not been laid out
|
|
717
1149
|
// yet. Recording it would collapse the block to nothing the moment it scrolled
|
|
718
1150
|
// off — the exact class of silent, permanent corruption this cache must not have.
|
|
719
1151
|
if (height > 0) {
|
|
720
|
-
blockHeights.current.set(block, height);
|
|
1152
|
+
blockHeights.current.set(block.id, { height, block });
|
|
721
1153
|
learned = true;
|
|
722
1154
|
}
|
|
723
1155
|
}
|
|
724
1156
|
toMeasure.current.clear();
|
|
725
|
-
|
|
1157
|
+
// Only when something was actually recorded, which is now once per block rather
|
|
1158
|
+
// than once per delta: a height that nothing can use is not worth a frame.
|
|
1159
|
+
if (learned) {
|
|
1160
|
+
pruneHeights(blockHeights.current);
|
|
726
1161
|
bumpHeights((t) => t + 1);
|
|
1162
|
+
}
|
|
727
1163
|
});
|
|
728
1164
|
// Same measurement, for the footer — see footerHeight above.
|
|
729
1165
|
useEffect(() => {
|
|
@@ -741,24 +1177,170 @@ export function App({ resumeSessionId }) {
|
|
|
741
1177
|
const { height } = measureElement(chatRef.current);
|
|
742
1178
|
setChatHeight((h) => (h === height ? h : height));
|
|
743
1179
|
});
|
|
744
|
-
|
|
745
|
-
|
|
1180
|
+
/**
|
|
1181
|
+
* How far the caret sits above the last row this app drew.
|
|
1182
|
+
*
|
|
1183
|
+
* Published for the exit path, which writes it as a cursor move so the shell that
|
|
1184
|
+
* takes the terminal back prints its prompt BELOW the conversation instead of on top
|
|
1185
|
+
* of it. See exitCursor.ts for what that fixes; the number has to be measured here
|
|
1186
|
+
* because only the layout knows it.
|
|
1187
|
+
*
|
|
1188
|
+
* No dependency list, like the measurements around it: what is under the caret changes
|
|
1189
|
+
* for reasons no dep could name — a wrapped input line, an opened palette, a picker, an
|
|
1190
|
+
* approval — and each one is a different distance.
|
|
1191
|
+
*
|
|
1192
|
+
* Zero for the full-screen shell, which needs no correction: it hands the terminal back
|
|
1193
|
+
* by leaving the alternate screen, and that restores the primary buffer's cursor too.
|
|
1194
|
+
*/
|
|
1195
|
+
useEffect(() => {
|
|
1196
|
+
if (shell !== "inline" || !liveRef.current) {
|
|
1197
|
+
setRowsBelowCaret(0);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
const caret = caretCell();
|
|
1201
|
+
if (!caret) {
|
|
1202
|
+
setRowsBelowCaret(0);
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
const { height, y } = measureElement(liveRef.current);
|
|
1206
|
+
// `caretCell` reports the caret's row within the live region; `liveRef` measures that
|
|
1207
|
+
// region. The last row it drew is `height - 1`, so the gap is what remains below.
|
|
1208
|
+
setRowsBelowCaret(height - 1 - (caret.y - (y ?? 0)));
|
|
1209
|
+
});
|
|
1210
|
+
/**
|
|
1211
|
+
* The pointer: the wheel, and dragging to select.
|
|
1212
|
+
*
|
|
1213
|
+
* Read straight off stdin rather than through useInput, because a mouse report is not
|
|
1214
|
+
* a keypress and Ink's key parser has no notion of one.
|
|
1215
|
+
*
|
|
1216
|
+
* The selection lives in a REF and is painted by the framebuffer overlay, so a drag
|
|
1217
|
+
* never causes a React render. That is deliberate: the pointer moves a column at a
|
|
1218
|
+
* time, and re-rendering the whole app on each of those would be both wasteful and a
|
|
1219
|
+
* chance to reflow the screen under a user who is only trying to highlight a word.
|
|
1220
|
+
* `repaintOverlay` re-tints the frame already on screen instead.
|
|
1221
|
+
*/
|
|
1222
|
+
// Mouse reporting itself is switched on and off by — it belongs to
|
|
1223
|
+
// the shell, not to this component, because the inline shell must never have it on.
|
|
1224
|
+
// What is left here is the highlight, which has to come down when the app unmounts.
|
|
746
1225
|
useEffect(() => {
|
|
747
1226
|
if (!ready)
|
|
748
1227
|
return;
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
1228
|
+
return () => setFrameOverlay(null);
|
|
1229
|
+
}, [ready]);
|
|
1230
|
+
/**
|
|
1231
|
+
* Everything the pointer does, read through `useInput`.
|
|
1232
|
+
*
|
|
1233
|
+
* NOT through a `data` listener on stdin, which is where this lived and why none of it
|
|
1234
|
+
* worked: Ink 7 pulls input by calling `read()` on a `readable` event, so it has taken
|
|
1235
|
+
* the bytes before a `data` handler is ever offered them. A listener attached that way
|
|
1236
|
+
* is not called at all — no error, no warning, simply nothing, which is the hardest
|
|
1237
|
+
* kind of wrong to see. Ink hands the same bytes here instead, with the ESC of a report
|
|
1238
|
+
* already eaten, which is why the parser in mouse.ts matches it optionally.
|
|
1239
|
+
*
|
|
1240
|
+
* One handler for the wheel, the drag and the keystroke that dismisses a highlight,
|
|
1241
|
+
* because they have to agree about what just happened: a mouse report arrives as
|
|
1242
|
+
* "input" too, and a separate handler that treated any input as a keystroke would clear
|
|
1243
|
+
* the selection on the very first drag event.
|
|
1244
|
+
*/
|
|
1245
|
+
useInput((input) => {
|
|
1246
|
+
const events = readMouse(input);
|
|
1247
|
+
const notches = readWheel(input);
|
|
1248
|
+
// ONE scroll for the whole flick, not one per notch.
|
|
1249
|
+
//
|
|
1250
|
+
// A single turn of the wheel arrives as several reports in one chunk (see
|
|
1251
|
+
// mouse.ts), and Ink runs React in LegacyRoot mode, so state updates from here are
|
|
1252
|
+
// NOT batched: every setState flushes its own synchronous render and its own full
|
|
1253
|
+
// terminal redraw. Scrolling a notch at a time therefore did three renders for one
|
|
1254
|
+
// flick, back to back, and the scroll lagged behind the hand turning the wheel.
|
|
1255
|
+
//
|
|
1256
|
+
// This is the same rule the input box already follows for keystrokes — one event
|
|
1257
|
+
// in, one render out — applied to the other thing that arrives in bursts.
|
|
1258
|
+
if (notches.length > 0) {
|
|
1259
|
+
// Content moves out from under a selection when the view scrolls, so the
|
|
1260
|
+
// highlight would be sitting on text that is no longer the text it copied.
|
|
1261
|
+
clearSelection();
|
|
1262
|
+
const lines = notches.reduce((n, dir) => n + (dir === "up" ? WHEEL_LINES : -WHEEL_LINES), 0);
|
|
1263
|
+
// The wheel is how anyone actually scrolls, so in the inline shell it is what
|
|
1264
|
+
// opens the reading view. Turning it UP is the gesture: the reader is going back
|
|
1265
|
+
// through the conversation and wants the prompt to stay where they can type into
|
|
1266
|
+
// it. Turning it down while already at the bottom is not — there is nothing
|
|
1267
|
+
// below to go to, and building a frame for it would mean the view snapping open
|
|
1268
|
+
// on a flick in the direction of the newest line.
|
|
1269
|
+
if (shell === "inline" && !reading && lines > 0)
|
|
1270
|
+
openReading(lines);
|
|
1271
|
+
else if (lines !== 0)
|
|
1272
|
+
scrollBy(lines);
|
|
1273
|
+
}
|
|
1274
|
+
// ONE re-tint for a whole drag, not one per motion report — the same rule the
|
|
1275
|
+
// wheel follows above, for the same reason. A terminal reports motion continuously
|
|
1276
|
+
// while a button is held, so a single sweep of the hand arrives as a chunk of
|
|
1277
|
+
// several reports; each one used to re-tint the entire screen and write it out,
|
|
1278
|
+
// which is thousands of cells re-scanned per report. Only the LAST focus position
|
|
1279
|
+
// in a chunk is on screen at the end of it, so the ones before it are painted for
|
|
1280
|
+
// nobody. Cleared by press and release, which do their own painting.
|
|
1281
|
+
let pendingDrag = false;
|
|
1282
|
+
for (const event of events) {
|
|
1283
|
+
if (event.kind === "press") {
|
|
1284
|
+
pendingDrag = false;
|
|
1285
|
+
// The chip is a button. It is the only thing on screen that says what it does,
|
|
1286
|
+
// so a press on it does that and nothing else — no selection is begun, because
|
|
1287
|
+
// starting one under a click that just moved the view would leave a highlight
|
|
1288
|
+
// sitting on text that is no longer there.
|
|
1289
|
+
//
|
|
1290
|
+
// On PRESS rather than release: the chip is a single row and the view moves out
|
|
1291
|
+
// from under the pointer the instant it is hit, so a release-matched-to-press
|
|
1292
|
+
// would be testing the pointer against a screen that had already changed.
|
|
1293
|
+
const hit = pillHit.current;
|
|
1294
|
+
if (hit && hitsPill(hit, event.x, event.y)) {
|
|
1295
|
+
clearSelection();
|
|
1296
|
+
setScrollUp(0);
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
selection.current = { anchor: { x: event.x, y: event.y }, focus: { x: event.x, y: event.y } };
|
|
1300
|
+
// Installed here rather than for the life of the session: see clearSelection.
|
|
1301
|
+
setFrameOverlay((screen) => applySelection(screen, selection.current));
|
|
1302
|
+
repaintOverlay();
|
|
754
1303
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1304
|
+
else if (event.kind === "drag") {
|
|
1305
|
+
if (!selection.current)
|
|
1306
|
+
continue;
|
|
1307
|
+
selection.current = { anchor: selection.current.anchor, focus: { x: event.x, y: event.y } };
|
|
1308
|
+
pendingDrag = true;
|
|
1309
|
+
}
|
|
1310
|
+
else {
|
|
1311
|
+
pendingDrag = false;
|
|
1312
|
+
const sel = selection.current;
|
|
1313
|
+
if (!sel)
|
|
1314
|
+
continue;
|
|
1315
|
+
if (isEmpty(sel)) {
|
|
1316
|
+
// A click, not a drag. Nothing to copy; put the caret where it landed, and
|
|
1317
|
+
// take the overlay back down — a press installs it, and a click that selects
|
|
1318
|
+
// nothing would otherwise leave it running for the rest of the session.
|
|
1319
|
+
selection.current = null;
|
|
1320
|
+
repaintOverlay();
|
|
1321
|
+
setFrameOverlay(null);
|
|
1322
|
+
placeCaretAt(event.x, event.y);
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1325
|
+
// Copied on release, so selecting IS copying. The highlight stays up afterwards
|
|
1326
|
+
// as the receipt for it, and goes when the next thing happens.
|
|
1327
|
+
const screen = latestScreen();
|
|
1328
|
+
if (screen)
|
|
1329
|
+
copyToClipboard(selectionText(screen, sel));
|
|
1330
|
+
// If what was dragged is text in the input box, hand the range to the input as
|
|
1331
|
+
// well, so Backspace takes the whole selection and typing replaces it — what
|
|
1332
|
+
// selecting text means anywhere else. A drag over the transcript is not editable
|
|
1333
|
+
// and the input declines it, leaving the selection as a copy and nothing more.
|
|
1334
|
+
textSelect.current?.(sel.anchor, sel.focus);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
if (pendingDrag)
|
|
1338
|
+
repaintOverlay();
|
|
1339
|
+
// A real keystroke, so put any highlight away — the same as a terminal's own
|
|
1340
|
+
// selection does the moment you type.
|
|
1341
|
+
if (events.length === 0 && notches.length === 0)
|
|
1342
|
+
clearSelection();
|
|
1343
|
+
}, { isActive: true });
|
|
762
1344
|
function endTurn() {
|
|
763
1345
|
if (turnStart.current != null)
|
|
764
1346
|
setLastMs(Date.now() - turnStart.current);
|
|
@@ -881,6 +1463,10 @@ export function App({ resumeSessionId }) {
|
|
|
881
1463
|
// decision it drives (does the NEXT grouped toolStart need to be held) has to
|
|
882
1464
|
// be made before that action is even dispatched.
|
|
883
1465
|
const groupOpen = useRef(false);
|
|
1466
|
+
// When the row currently at the front of the queue began waiting for its own result,
|
|
1467
|
+
// and the timer that gives up on it. See the hold in pump().
|
|
1468
|
+
const heldStart = useRef(null);
|
|
1469
|
+
const holdTimer = useRef(null);
|
|
884
1470
|
// A new block appears (paced); a token (silent), a tool resolution (in place), a
|
|
885
1471
|
// discovery call folding into an ALREADY-OPEN group, or a sub-agent's nested
|
|
886
1472
|
// activity (folds into / resolves its rail in place) is not.
|
|
@@ -898,12 +1484,20 @@ export function App({ resumeSessionId }) {
|
|
|
898
1484
|
return a.group ? !groupOpen.current : true;
|
|
899
1485
|
return (a.type !== "token" &&
|
|
900
1486
|
a.type !== "toolEnd" &&
|
|
1487
|
+
a.type !== "toolProgress" &&
|
|
901
1488
|
a.type !== "subToolStart" &&
|
|
902
1489
|
a.type !== "subToolEnd" &&
|
|
903
1490
|
a.type !== "subagentEnd");
|
|
904
1491
|
};
|
|
905
1492
|
function enqueueReveal(a) {
|
|
906
1493
|
revealQ.current.push(a);
|
|
1494
|
+
// A result arriving is exactly what the hold below is waiting for, so its deadline is
|
|
1495
|
+
// cancelled rather than waited out — otherwise every quick tool would sit out the
|
|
1496
|
+
// full grace before its pair could be shown.
|
|
1497
|
+
if (holdTimer.current) {
|
|
1498
|
+
clearTimeout(holdTimer.current);
|
|
1499
|
+
holdTimer.current = null;
|
|
1500
|
+
}
|
|
907
1501
|
if (!pumpTimer.current)
|
|
908
1502
|
pump();
|
|
909
1503
|
}
|
|
@@ -980,8 +1574,36 @@ export function App({ resumeSessionId }) {
|
|
|
980
1574
|
if (planGroupReveal(groupSettled(revealQ.current.slice(1)), flush.current) === "hold")
|
|
981
1575
|
return;
|
|
982
1576
|
}
|
|
983
|
-
else
|
|
984
|
-
|
|
1577
|
+
else {
|
|
1578
|
+
// A standalone row is held for its own result, but only for so long.
|
|
1579
|
+
//
|
|
1580
|
+
// Held with no limit, a row was invisible for the ten
|
|
1581
|
+
// minutes the build took: the last thing on screen stayed the tool before it, and
|
|
1582
|
+
// an agent working steadily was indistinguishable from one that had hung. It was
|
|
1583
|
+
// reported as a hang. It was not one — the command ran, the timeout fired, the
|
|
1584
|
+
// shell was backgrounded, all of it correct and none of it visible.
|
|
1585
|
+
if (heldStart.current?.toolId !== front.toolId) {
|
|
1586
|
+
heldStart.current = { toolId: front.toolId, at: Date.now() };
|
|
1587
|
+
}
|
|
1588
|
+
const heldForMs = Date.now() - heldStart.current.at;
|
|
1589
|
+
const plan = planStandaloneReveal({
|
|
1590
|
+
resultQueued: resultQueued(front.toolId, revealQ.current),
|
|
1591
|
+
flushing: flush.current,
|
|
1592
|
+
streamDone: streamDone.current,
|
|
1593
|
+
heldForMs,
|
|
1594
|
+
});
|
|
1595
|
+
if (plan === "hold") {
|
|
1596
|
+
// Re-enter when the deadline passes. Its OWN timer, not the pacing one: the
|
|
1597
|
+
// result arriving must be able to cancel this and reveal the pair at once, and
|
|
1598
|
+
// clearing the pacing timer instead would drop the beat.
|
|
1599
|
+
if (!holdTimer.current) {
|
|
1600
|
+
holdTimer.current = setTimeout(() => {
|
|
1601
|
+
holdTimer.current = null;
|
|
1602
|
+
pump();
|
|
1603
|
+
}, Math.max(0, STANDALONE_HOLD_MS - heldForMs));
|
|
1604
|
+
}
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
985
1607
|
}
|
|
986
1608
|
schedulePaced(() => {
|
|
987
1609
|
// Measured HERE, not when the beat was scheduled: the queue keeps growing
|
|
@@ -1038,6 +1660,60 @@ export function App({ resumeSessionId }) {
|
|
|
1038
1660
|
* reveal), each tool bookends a `toolStart`/`toolEnd`, and the reply seals on
|
|
1039
1661
|
* completion. busy stays true until every paced reveal has been shown.
|
|
1040
1662
|
*/
|
|
1663
|
+
/**
|
|
1664
|
+
* Turn typed text into what the model gets and what the chat shows.
|
|
1665
|
+
*
|
|
1666
|
+
* Shared by the two ways a message reaches a turn — submitted when idle, and steered
|
|
1667
|
+
* into one already running — because they must resolve identically. A dropped path is
|
|
1668
|
+
* a short handle in the buffer either way, a paste is collapsed either way, and an
|
|
1669
|
+
* image only rides along if the model running RIGHT NOW can see one. Two copies of
|
|
1670
|
+
* that would drift, and the drift would show up as a queued message behaving unlike
|
|
1671
|
+
* the same message typed a second later.
|
|
1672
|
+
*/
|
|
1673
|
+
async function prepareMessage(s, text) {
|
|
1674
|
+
// Whether an attached image is sent or merely named depends on the running model,
|
|
1675
|
+
// and that is a fact we ask the driver for — never a provider name in this file.
|
|
1676
|
+
const manifest = manifestForModel(s.modelConfig.model);
|
|
1677
|
+
const canSeeImages = manifest.acceptsImages?.(s.modelConfig.model) ?? false;
|
|
1678
|
+
// Dropped files are carried in the buffer as short handles. Put the real paths back
|
|
1679
|
+
// before anything is resolved against the disk, and hand the same handle back as the
|
|
1680
|
+
// label so the chat shows what the user typed rather than a third name for the file.
|
|
1681
|
+
const { modelText, displayText, notes, images } = await resolveAttachments(expandHandles(text, dropHandles.current), s.cwd, canSeeImages, (abs) => dropHandles.current.labelFor(abs));
|
|
1682
|
+
// Restore any collapsed pastes into the model's copy only (the chat keeps chips).
|
|
1683
|
+
return { content: expandPastes(modelText), displayText, notes, images };
|
|
1684
|
+
}
|
|
1685
|
+
/**
|
|
1686
|
+
* Hand the running turn whatever was typed at it, at a step boundary.
|
|
1687
|
+
*
|
|
1688
|
+
* Called by the engine, not by us, and only at the one moment a user message may be
|
|
1689
|
+
* appended without malforming the conversation. The queue is drained SYNCHRONOUSLY
|
|
1690
|
+
* first and resolved after, so a ↑ that pulls the queue back mid-resolution takes the
|
|
1691
|
+
* messages that are still queued rather than racing the ones already on their way.
|
|
1692
|
+
*
|
|
1693
|
+
* Each message gets its own chat line, queued through the reveal pacer like every
|
|
1694
|
+
* other row so it lands in the order it happened instead of jumping ahead of the tool
|
|
1695
|
+
* rows around it.
|
|
1696
|
+
*/
|
|
1697
|
+
async function steerRunningTurn(s) {
|
|
1698
|
+
const { send, rest } = takeSteerable(queueRef.current);
|
|
1699
|
+
if (send.length === 0)
|
|
1700
|
+
return [];
|
|
1701
|
+
queueRef.current = rest;
|
|
1702
|
+
setQueued(rest);
|
|
1703
|
+
const out = [];
|
|
1704
|
+
for (const { text } of send) {
|
|
1705
|
+
// No history write here: `onSend` records every message the moment it is typed,
|
|
1706
|
+
// queued or not, so ↑ walks them in the order they were written rather than the
|
|
1707
|
+
// order they happened to go out. Recording again on the way out appended each one
|
|
1708
|
+
// a second time.
|
|
1709
|
+
const { content, displayText, notes, images } = await prepareMessage(s, text);
|
|
1710
|
+
enqueueReveal({ type: "user", text: displayText });
|
|
1711
|
+
for (const n of notes)
|
|
1712
|
+
enqueueReveal({ type: "note", text: n });
|
|
1713
|
+
out.push({ content, ...(images.length > 0 ? { images } : {}) });
|
|
1714
|
+
}
|
|
1715
|
+
return out;
|
|
1716
|
+
}
|
|
1041
1717
|
async function streamRespond(s) {
|
|
1042
1718
|
startTurn();
|
|
1043
1719
|
// Pick up an edit the model made to MINDWEAVE.md, but only if one actually happened
|
|
@@ -1049,8 +1725,18 @@ export function App({ resumeSessionId }) {
|
|
|
1049
1725
|
lastRevealAt.current = 0;
|
|
1050
1726
|
streamDone.current = false;
|
|
1051
1727
|
flush.current = false;
|
|
1728
|
+
// A hold belongs to one turn. Left standing, its deadline fires into the next one and
|
|
1729
|
+
// pumps a queue that has nothing to do with it.
|
|
1730
|
+
heldStart.current = null;
|
|
1731
|
+
if (holdTimer.current) {
|
|
1732
|
+
clearTimeout(holdTimer.current);
|
|
1733
|
+
holdTimer.current = null;
|
|
1734
|
+
}
|
|
1052
1735
|
try {
|
|
1053
1736
|
await respond(s, {
|
|
1737
|
+
// Messages typed while this turn runs reach it here, at each step boundary,
|
|
1738
|
+
// rather than waiting for it to end and starting another one.
|
|
1739
|
+
steer: () => steerRunningTurn(s),
|
|
1054
1740
|
onActivity: (line, opts) => enqueueReveal(opts?.context ? { type: "context", text: line } : opts?.error ? { type: "error", text: line } : { type: "note", text: line }),
|
|
1055
1741
|
// An AUTOMATIC compaction, mid-turn. Queued like everything else so it appears
|
|
1056
1742
|
// in the order it happened, rather than jumping ahead of the rows around it.
|
|
@@ -1081,6 +1767,11 @@ export function App({ resumeSessionId }) {
|
|
|
1081
1767
|
enqueueReveal({ type: "toolStart", toolId: e.id, name: d.name, arg: d.arg, meta: d.meta, action: d.kind, group: isGroupable(e.name), ...(d.covers ? { covers: d.covers } : {}) });
|
|
1082
1768
|
}
|
|
1083
1769
|
}
|
|
1770
|
+
else if (e.type === "tool" && e.phase === "progress") {
|
|
1771
|
+
// A worker's own calls fold into its rail, which has no room for output.
|
|
1772
|
+
if (!e.agent)
|
|
1773
|
+
enqueueReveal({ type: "toolProgress", toolId: e.id, text: e.text });
|
|
1774
|
+
}
|
|
1084
1775
|
else if (e.type === "tool" && e.phase === "end") {
|
|
1085
1776
|
if (e.name === "spawn_subagent")
|
|
1086
1777
|
return;
|
|
@@ -1353,6 +2044,27 @@ export function App({ resumeSessionId }) {
|
|
|
1353
2044
|
}
|
|
1354
2045
|
// Route a Picker selection/cancel back to whatever opened the overlay. Picking a
|
|
1355
2046
|
// session opens the second step — the three resume choices.
|
|
2047
|
+
/**
|
|
2048
|
+
* Move to a shell, from either route into `/screen` — the chooser or a named argument.
|
|
2049
|
+
*
|
|
2050
|
+
* One function because the two routes must not drift: they save the same preference,
|
|
2051
|
+
* announce the same line, and both have to leave the terminal work to the effect that
|
|
2052
|
+
* watches `shell`. Two copies is how one of them ends up switching without remembering.
|
|
2053
|
+
*/
|
|
2054
|
+
function applyScreen(next) {
|
|
2055
|
+
if (next === shell) {
|
|
2056
|
+
note(`already ${screenNotice(next)}`);
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
// The terminal is moved by the effect that watches this, not from here: the escapes
|
|
2060
|
+
// must not go out in the middle of handling a keypress, with a render still to come.
|
|
2061
|
+
setShell(next);
|
|
2062
|
+
// Remembered for the project, so the choice is made once rather than at the start of
|
|
2063
|
+
// every session. Best-effort and not awaited: the switch has already happened, and a
|
|
2064
|
+
// preference that failed to save is not worth holding the UI for.
|
|
2065
|
+
void saveScreenMode(session.current?.cwd ?? process.cwd(), next);
|
|
2066
|
+
note(screenNotice(next));
|
|
2067
|
+
}
|
|
1356
2068
|
function onOverlaySelect(index) {
|
|
1357
2069
|
const o = overlay;
|
|
1358
2070
|
if (!o)
|
|
@@ -1362,7 +2074,11 @@ export function App({ resumeSessionId }) {
|
|
|
1362
2074
|
return;
|
|
1363
2075
|
}
|
|
1364
2076
|
setOverlay(null);
|
|
1365
|
-
if (o.kind === "
|
|
2077
|
+
if (o.kind === "analytics") {
|
|
2078
|
+
setAnalyticsEnabled(index === 0);
|
|
2079
|
+
note(`Analytics turned ${index === 0 ? "on" : "off"}.`);
|
|
2080
|
+
}
|
|
2081
|
+
else if (o.kind === "resumeMode")
|
|
1366
2082
|
void applyResume(o.meta, index);
|
|
1367
2083
|
else if (o.kind === "provider")
|
|
1368
2084
|
void applyProvider(index);
|
|
@@ -1370,6 +2086,11 @@ export function App({ resumeSessionId }) {
|
|
|
1370
2086
|
void applyModel(index);
|
|
1371
2087
|
else if (o.kind === "think")
|
|
1372
2088
|
void applyThink(index);
|
|
2089
|
+
else if (o.kind === "screen") {
|
|
2090
|
+
const picked = screenChoices(shell)[index];
|
|
2091
|
+
if (picked)
|
|
2092
|
+
applyScreen(picked.mode);
|
|
2093
|
+
}
|
|
1373
2094
|
else if (o.kind === "shells") {
|
|
1374
2095
|
const sh = o.items[index];
|
|
1375
2096
|
if (sh && sh.status === "running" && session.current?.toolContext.backgroundShells?.kill(sh.id, "user")) {
|
|
@@ -1506,6 +2227,21 @@ export function App({ resumeSessionId }) {
|
|
|
1506
2227
|
exit();
|
|
1507
2228
|
return;
|
|
1508
2229
|
}
|
|
2230
|
+
// /analytics — the anonymous usage ping. Bare opens the on/off switch, the same
|
|
2231
|
+
// fixed box every other setting uses; an argument acts directly, same as /model.
|
|
2232
|
+
if (name === "/analytics") {
|
|
2233
|
+
const verb = arg.trim().toLowerCase();
|
|
2234
|
+
if (verb === "on" || verb === "off") {
|
|
2235
|
+
setAnalyticsEnabled(verb === "on");
|
|
2236
|
+
note(`Analytics turned ${verb}.`);
|
|
2237
|
+
return;
|
|
2238
|
+
}
|
|
2239
|
+
if (verb) {
|
|
2240
|
+
return say("/analytics on|off, or bare to open the switch.");
|
|
2241
|
+
}
|
|
2242
|
+
setOverlay({ kind: "analytics" });
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
1509
2245
|
// /clear — a fresh conversation without leaving the folder. Until now the only
|
|
1510
2246
|
// way to start over was to quit and relaunch, or to go through /continue and pick
|
|
1511
2247
|
// its third option, which is not somewhere anyone looks for "start over".
|
|
@@ -1732,6 +2468,26 @@ export function App({ resumeSessionId }) {
|
|
|
1732
2468
|
setOverlay({ kind: "model" });
|
|
1733
2469
|
return;
|
|
1734
2470
|
}
|
|
2471
|
+
if (name === "/screen") {
|
|
2472
|
+
// Bare `/screen` now CHOOSES rather than swaps. Swapping was fine while the two
|
|
2473
|
+
// shells were equals; they are not, and a toggle gives no room to say so. The two
|
|
2474
|
+
// differ in what they take from the terminal — the inline one takes the mouse, so
|
|
2475
|
+
// the scrollbar and text selection stop working — and that is worth reading before
|
|
2476
|
+
// picking rather than discovering afterwards.
|
|
2477
|
+
//
|
|
2478
|
+
// Naming a mode still applies it directly: `/screen inline` is someone who already
|
|
2479
|
+
// knows which they want, and making them confirm through a list would be the
|
|
2480
|
+
// command asking a question it was just given the answer to.
|
|
2481
|
+
if (!arg?.trim()) {
|
|
2482
|
+
setOverlay({ kind: "screen" });
|
|
2483
|
+
return;
|
|
2484
|
+
}
|
|
2485
|
+
const next = parseScreenArg(arg);
|
|
2486
|
+
if (!next)
|
|
2487
|
+
return say("`/screen` takes `fullscreen` or `inline`, or nothing at all to choose.");
|
|
2488
|
+
applyScreen(next);
|
|
2489
|
+
return;
|
|
2490
|
+
}
|
|
1735
2491
|
if (name === "/think") {
|
|
1736
2492
|
if (arg) {
|
|
1737
2493
|
const picked = resolveChoice(arg, thinkLevels(s.modelConfig.model), "reasoning level");
|
|
@@ -1950,7 +2706,7 @@ export function App({ resumeSessionId }) {
|
|
|
1950
2706
|
}
|
|
1951
2707
|
say(unknownCommandMessage(name));
|
|
1952
2708
|
}
|
|
1953
|
-
async function handleSubmit(value) {
|
|
2709
|
+
async function handleSubmit(value, opts = {}) {
|
|
1954
2710
|
const trimmed = value.trim();
|
|
1955
2711
|
if (trimmed.length === 0 || busy || !ready)
|
|
1956
2712
|
return;
|
|
@@ -1981,18 +2737,17 @@ export function App({ resumeSessionId }) {
|
|
|
1981
2737
|
// file path collapses to just its name — never the file dump. The model gets
|
|
1982
2738
|
// the full content via resolved <attached_file> blocks, and each attachment
|
|
1983
2739
|
// leaves one compact activity note (counts only).
|
|
1984
|
-
|
|
1985
|
-
// and that is a fact we ask the driver for — never a provider name in this file.
|
|
1986
|
-
const manifest = manifestForModel(s.modelConfig.model);
|
|
1987
|
-
const canSeeImages = manifest.acceptsImages?.(s.modelConfig.model) ?? false;
|
|
1988
|
-
const { modelText, displayText, notes, images } = await resolveAttachments(trimmed, s.cwd, canSeeImages);
|
|
2740
|
+
const { content, displayText, notes, images } = await prepareMessage(s, trimmed);
|
|
1989
2741
|
dispatch({ type: "user", text: displayText });
|
|
1990
2742
|
for (const n of notes)
|
|
1991
2743
|
note(n);
|
|
1992
|
-
// Restore any collapsed pastes into the model's copy only (the chat keeps chips).
|
|
1993
2744
|
s.transcript.push({
|
|
1994
2745
|
role: "user",
|
|
1995
|
-
content
|
|
2746
|
+
content,
|
|
2747
|
+
// Sent straight after an Esc. The model is told the work was cut off on purpose,
|
|
2748
|
+
// or a message landing after a half-finished round of tools reads as if it had
|
|
2749
|
+
// always been the request and it picks up where it was stopped.
|
|
2750
|
+
...(opts.arrival ? { arrival: opts.arrival } : {}),
|
|
1996
2751
|
...(images.length > 0 ? { images } : {}),
|
|
1997
2752
|
});
|
|
1998
2753
|
await streamRespond(s);
|
|
@@ -2060,7 +2815,7 @@ export function App({ resumeSessionId }) {
|
|
|
2060
2815
|
setScrollUp(0);
|
|
2061
2816
|
setHistory((h) => (h[h.length - 1] === text ? h : [...h, text]));
|
|
2062
2817
|
if (busy) {
|
|
2063
|
-
queueRef.current.push(text);
|
|
2818
|
+
queueRef.current.push(queueMessage(text, { interrupting: interrupting.current }));
|
|
2064
2819
|
setQueued([...queueRef.current]);
|
|
2065
2820
|
return;
|
|
2066
2821
|
}
|
|
@@ -2113,12 +2868,20 @@ export function App({ resumeSessionId }) {
|
|
|
2113
2868
|
if (!overlay)
|
|
2114
2869
|
return null;
|
|
2115
2870
|
const cur = session.current;
|
|
2871
|
+
if (overlay.kind === "analytics") {
|
|
2872
|
+
const enabled = analyticsEnabled();
|
|
2873
|
+
const items = [
|
|
2874
|
+
{ label: "On" + (enabled ? " ✓" : ""), description: "sends the ping" },
|
|
2875
|
+
{ label: "Off" + (!enabled ? " ✓" : ""), description: "sends nothing" },
|
|
2876
|
+
];
|
|
2877
|
+
return (_jsx(Picker, { title: "Anonymous usage analytics", note: ANALYTICS_EXPLANATION, items: items, width: width, maxRows: maxRows, initialIndex: enabled ? 0 : 1, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
|
|
2878
|
+
}
|
|
2116
2879
|
if (overlay.kind === "sessions") {
|
|
2117
2880
|
const items = overlay.items.map((m) => ({
|
|
2118
2881
|
label: sessionTitle(m),
|
|
2119
2882
|
description: `${timeAgo(m.updatedAt)} · ${m.entryCount} msg${m.entryCount === 1 ? "" : "s"}`,
|
|
2120
2883
|
}));
|
|
2121
|
-
return (_jsx(Picker, { title: "Continue which session?", items: items, width: width, maxRows: maxRows, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
|
|
2884
|
+
return (_jsx(Picker, { title: "Continue which session?", items: items, width: width, maxRows: maxRows, rightAlignDescription: true, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
|
|
2122
2885
|
}
|
|
2123
2886
|
if (overlay.kind === "resumeMode") {
|
|
2124
2887
|
return (_jsx(Picker, { title: `Continue “${sessionTitle(overlay.meta)}” — how?`, items: RESUME_MODES, width: width, maxRows: maxRows, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
|
|
@@ -2183,6 +2946,16 @@ export function App({ resumeSessionId }) {
|
|
|
2183
2946
|
const items = levels.map((l) => ({ label: l.label + (l.label === curLabel ? " ✓" : ""), description: l.description }));
|
|
2184
2947
|
return (_jsx(Picker, { title: `Reasoning for ${modelLabel(model)}`, items: items, width: width, maxRows: maxRows, initialIndex: Math.max(0, levels.findIndex((l) => l.label === curLabel)), onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
|
|
2185
2948
|
}
|
|
2949
|
+
if (overlay.kind === "screen") {
|
|
2950
|
+
const choices = screenChoices(shell);
|
|
2951
|
+
return (_jsx(Picker, { title: "Which shell?", items: choices.map((c) => ({ label: c.label, description: c.description })), width: width, maxRows: maxRows,
|
|
2952
|
+
// The shell descriptions explain a real trade-off, so they are shown in full
|
|
2953
|
+
// below the list — wrapping down rather than truncating on the row.
|
|
2954
|
+
describeSelection: true,
|
|
2955
|
+
// Opens on the one in use, so Enter alone changes nothing. A chooser that opens
|
|
2956
|
+
// somewhere else turns a glance at the options into an accidental switch.
|
|
2957
|
+
initialIndex: Math.max(0, choices.findIndex((c) => c.mode === shell)), onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
|
|
2958
|
+
}
|
|
2186
2959
|
// approval — a plan, a Sentinel action, a forbidden-path lift. It interrupts the user's
|
|
2187
2960
|
// work and the answer commits them to something, so it reads as a stop; it renders in
|
|
2188
2961
|
// the same fixed menu box as everything else, the answers always visible.
|
|
@@ -2216,12 +2989,23 @@ export function App({ resumeSessionId }) {
|
|
|
2216
2989
|
const committed = stateRef.current.committed;
|
|
2217
2990
|
const tail = stateRef.current.tail;
|
|
2218
2991
|
const allBlocks = [...committed, ...tail];
|
|
2219
|
-
//
|
|
2220
|
-
//
|
|
2221
|
-
//
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2992
|
+
// The terminal's height RIGHT NOW, by live syscall — see liveTerminalSize. The SOLE
|
|
2993
|
+
// authority for the frame: no min or max against the polled state, which can lag a
|
|
2994
|
+
// resize, and a live syscall never can.
|
|
2995
|
+
const liveRows = liveTerminalSize(stdout).rows;
|
|
2996
|
+
// The FULL height, so the footer sits on the very last row with nothing below it.
|
|
2997
|
+
//
|
|
2998
|
+
// This was `liveRows - 1` for a real reason that no longer applies. Ink's own renderer
|
|
2999
|
+
// switches, at a frame as tall as the terminal, from erasing and redrawing to clearing
|
|
3000
|
+
// the whole screen — and that clear desynchronises its line bookkeeping, so the next
|
|
3001
|
+
// ordinary frame erases the wrong count and old text is left behind under new. The one
|
|
3002
|
+
// row held back kept the frame under that threshold. But the framebuffer replaces that
|
|
3003
|
+
// renderer entirely in the full-screen shell: it addresses cells absolutely and diffs
|
|
3004
|
+
// its own model, never leaning on Ink's erase-and-redraw, so the threshold is not
|
|
3005
|
+
// reached and the row is pure dead space at the bottom edge. Verified by driving a
|
|
3006
|
+
// full-height frame and a change through the real framebuffer — the footer lands on the
|
|
3007
|
+
// last row and nothing is left behind.
|
|
3008
|
+
const frameHeight = Math.max(3, liveRows);
|
|
2225
3009
|
// The chat's HEIGHT is no longer computed here — Yoga is given the job instead
|
|
2226
3010
|
// (the viewport below is flexGrow:1 beside a flexShrink:0 footer), and this
|
|
2227
3011
|
// measurement is now only read for the SCROLL maths.
|
|
@@ -2246,7 +3030,17 @@ export function App({ resumeSessionId }) {
|
|
|
2246
3030
|
// border/title/hint. What's left becomes item rows, floored at a usable
|
|
2247
3031
|
// minimum and capped so a huge terminal doesn't show an ungainly wall.
|
|
2248
3032
|
const menuBudget = frameHeight - BANNER_ROWS - MIN_CHAT_ROWS - FOOTER_BASE_ROWS - MENU_CHROME_ROWS;
|
|
2249
|
-
|
|
3033
|
+
//
|
|
3034
|
+
// The inline shell gets a much smaller window, and the reason is not taste. There is no
|
|
3035
|
+
// frame to shrink there: every row the palette adds makes the live region taller than
|
|
3036
|
+
// the room below it, so the TERMINAL scrolls to fit — and that scroll is one-way. Twelve
|
|
3037
|
+
// rows is twelve rows of the conversation gone up past the top edge, for a list nobody
|
|
3038
|
+
// reads twelve of. Three plus the hint is about one line of visible movement, and the
|
|
3039
|
+
// list is not shortened by it: `SuggestionMenu` windows around the selection, so the
|
|
3040
|
+
// whole catalog is still reachable with the arrows, three at a time.
|
|
3041
|
+
const maxMenuItems = shell === "inline"
|
|
3042
|
+
? Math.max(2, Math.min(INLINE_MENU_ROWS, menuBudget))
|
|
3043
|
+
: Math.max(3, Math.min(12, menuBudget));
|
|
2250
3044
|
// Built here, after maxMenuItems, so a picker's contents respect the same row budget as
|
|
2251
3045
|
// the command menu and can never grow the footer past the screen. EVERY interactive
|
|
2252
3046
|
// surface — the pickers, the key manager, and the approval prompt — is content-only and
|
|
@@ -2258,7 +3052,34 @@ export function App({ resumeSessionId }) {
|
|
|
2258
3052
|
// to `chatAnchor.ts` so the rule is unit-tested rather than eyeballed — see there
|
|
2259
3053
|
// for why a short transcript now rests ON the input box instead of stranding
|
|
2260
3054
|
// itself at the top of the screen, and why that cannot disturb a scrolled frame.
|
|
2261
|
-
const { marginTop: chatOffset, restsOnFooter } = chatLayout(contentHeight, chatRows, scrollUp);
|
|
3055
|
+
const { marginTop: chatOffset, restsOnFooter, maxScroll, scrolled } = chatLayout(contentHeight, chatRows, scrollUp);
|
|
3056
|
+
// Published for the scroll handlers, which run between frames and cannot compute it.
|
|
3057
|
+
maxScrollRef.current = maxScroll;
|
|
3058
|
+
// The chip that says the view is not at the bottom. `scrolled` and not `scrollUp`:
|
|
3059
|
+
// the clamped number is the one that is zero whenever the whole transcript already
|
|
3060
|
+
// fits, which is exactly when there is nothing to jump to. See scrollPill.ts.
|
|
3061
|
+
const pill = scrollPill({
|
|
3062
|
+
scrolled,
|
|
3063
|
+
newReplies: countNewReplies(allBlocks, scrollMark.current),
|
|
3064
|
+
overlayOpen: overlay !== null,
|
|
3065
|
+
width,
|
|
3066
|
+
});
|
|
3067
|
+
/**
|
|
3068
|
+
* The chip's cells in SCREEN coordinates, so a click can be tested against them.
|
|
3069
|
+
*
|
|
3070
|
+
* The layout reports the chip's row within the LIVE REGION, and a mouse report gives a
|
|
3071
|
+
* row on the screen. Those are the same number in the full-screen shell, where the app
|
|
3072
|
+
* owns every row and Ink draws from the top — and they are NOT in the inline shell,
|
|
3073
|
+
* where the live region is the last `frameHeight` rows of a terminal full of
|
|
3074
|
+
* scrollback. Confusing the two is the same mistake that once had the cursor parked in
|
|
3075
|
+
* the middle of the conversation; the offset is applied once, here, rather than being
|
|
3076
|
+
* rediscovered by whatever reads this.
|
|
3077
|
+
*
|
|
3078
|
+
* Published during the render, because a pointer handler runs BETWEEN frames and can
|
|
3079
|
+
* measure nothing for itself.
|
|
3080
|
+
*/
|
|
3081
|
+
const frameTop = readingInline ? Math.max(0, rows - frameHeight) : 0;
|
|
3082
|
+
pillHit.current = pill !== null && pillRow.current !== null ? pillBounds(pill, width, frameTop + pillRow.current) : null;
|
|
2262
3083
|
// Only the blocks that can still be reached are worth laying out. Yoga lays
|
|
2263
3084
|
// out every child on every render — including one caused by a keystroke — so
|
|
2264
3085
|
// an unbounded transcript makes typing slower the longer you have been
|
|
@@ -2270,9 +3091,36 @@ export function App({ resumeSessionId }) {
|
|
|
2270
3091
|
// above, and the whole scroll mechanism — is bit-for-bit what it was when every
|
|
2271
3092
|
// block was laid out in full. See `virtualWindow.ts`.
|
|
2272
3093
|
if (heightsWidth.current !== width) {
|
|
2273
|
-
// Every recorded height was measured at a different width and is now
|
|
2274
|
-
//
|
|
2275
|
-
|
|
3094
|
+
// Every recorded height was measured at a different width and is now wrong. RESCALED
|
|
3095
|
+
// rather than thrown away, and the difference is the whole cost of a resize.
|
|
3096
|
+
//
|
|
3097
|
+
// Discarding leaves nothing measured, and a block with no height cannot become a
|
|
3098
|
+
// spacer — so the very next frame lays out the entire scrollback at once. Measured
|
|
3099
|
+
// on this machine, idle: about 1.7ms per block, 253ms for a full window, in one
|
|
3100
|
+
// synchronous commit. That is the freeze, and it happened on every resize.
|
|
3101
|
+
//
|
|
3102
|
+
// Narrower text wraps to more rows and wider to fewer, in roughly that proportion,
|
|
3103
|
+
// so the old height times old/new width is close enough to keep the scroll maths
|
|
3104
|
+
// sane for the frame it takes to measure the blocks that are actually on screen.
|
|
3105
|
+
//
|
|
3106
|
+
// Only ROUGHLY, and the gap is why every scaled entry is marked. A paragraph that
|
|
3107
|
+
// wrapped to one row at the old width does not become 1.4 rows at a narrower one, it
|
|
3108
|
+
// becomes two; the error is worst exactly where the terminal is narrowest, and it
|
|
3109
|
+
// does not average out, because each block is rounded on its own. Left as the final
|
|
3110
|
+
// answer those estimates size every spacer in the virtual window, and blocks land
|
|
3111
|
+
// one or two rows from where they belong — text that will not settle.
|
|
3112
|
+
//
|
|
3113
|
+
// `scaled` is what stops that being permanent. The entry stays usable, so the window
|
|
3114
|
+
// is still virtualized and no resize lays out the whole scrollback at once; but it no
|
|
3115
|
+
// longer counts as measured, so the blocks that get RENDERED are laid out again and
|
|
3116
|
+
// replace their estimate with a fact. Bounded by what is on screen, not by the
|
|
3117
|
+
// length of the session.
|
|
3118
|
+
const ratio = heightsWidth.current > 0 ? heightsWidth.current / width : 1;
|
|
3119
|
+
for (const entry of blockHeights.current.values()) {
|
|
3120
|
+
if (ratio !== 1)
|
|
3121
|
+
entry.height = Math.max(1, Math.round(entry.height * ratio));
|
|
3122
|
+
entry.scaled = true;
|
|
3123
|
+
}
|
|
2276
3124
|
heightsWidth.current = width;
|
|
2277
3125
|
// Remember WHERE the reader was, as a proportion of the scrollable range, before
|
|
2278
3126
|
// the re-wrap changes what a line means. `scrollUp` is a line count, and a line is
|
|
@@ -2289,10 +3137,13 @@ export function App({ resumeSessionId }) {
|
|
|
2289
3137
|
let known = 0;
|
|
2290
3138
|
const knownHeights = [];
|
|
2291
3139
|
while (known < rendered.length) {
|
|
2292
|
-
const
|
|
2293
|
-
|
|
3140
|
+
const block = rendered[known];
|
|
3141
|
+
const entry = blockHeights.current.get(block.id);
|
|
3142
|
+
// The identity check IS the invalidation: a block that changed is a new object, so
|
|
3143
|
+
// its recorded height belongs to the version before the change.
|
|
3144
|
+
if (entry === undefined || entry.block !== block)
|
|
2294
3145
|
break;
|
|
2295
|
-
knownHeights.push(
|
|
3146
|
+
knownHeights.push(entry.height);
|
|
2296
3147
|
known++;
|
|
2297
3148
|
}
|
|
2298
3149
|
const win = virtualWindow(knownHeights, -chatOffset, chatRows);
|
|
@@ -2307,28 +3158,232 @@ export function App({ resumeSessionId }) {
|
|
|
2307
3158
|
// Rows between the end of the window and the first unmeasured block. Rendering the
|
|
2308
3159
|
// unmeasured tail is not optional — it is how those blocks get measured at all.
|
|
2309
3160
|
const padMiddle = win.padBottom;
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
3161
|
+
// The status line, the queued bar, the input and whatever sits under it. Built once
|
|
3162
|
+
// and rendered by BOTH shells: the only thing that differs between them is where it
|
|
3163
|
+
// ends up — pinned to the bottom of a frame we own, or simply the last thing printed.
|
|
3164
|
+
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 }) }), _jsx(Box, { flexShrink: 0, children: _jsx(QueuedBar, { queued: queued }) }), _jsx(Box, { flexShrink: 0, flexDirection: "column", children: ready ? (_jsx(PromptInput, { onSubmit: onSend, opening: opening, disabled: false, placeholder: 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) => {
|
|
3165
|
+
caretClick.current = place;
|
|
3166
|
+
}, registerTextSelect: (select) => {
|
|
3167
|
+
textSelect.current = select;
|
|
3168
|
+
}, maxMenuRows: maxMenuItems, menuAbove: shell === "inline", settleKey: committed.length, onMenuChange: onMenuChange, placeCursor: shell === "inline", onQueuePop: popQueue, overlay: overlayView })) : (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "starting\u2026" }) })) }), ctxWarn ? (
|
|
3169
|
+
// A compaction is near. This takes the line over the tip and the background bar:
|
|
3170
|
+
// it is the one thing here the user needs BEFORE it happens, since the summarizing
|
|
3171
|
+
// pass rewrites the conversation. Dim as it approaches, then a plain warning colour
|
|
3172
|
+
// with the manual way out once it is about to fire on its own.
|
|
3173
|
+
_jsx(Box, { flexShrink: 0, children: ctxWarn.percentLeft <= 5 ? (_jsx(Text, { color: "yellow", children: " context low · /compact to summarize now" })) : (_jsx(Text, { dimColor: true, children: ` ${ctxWarn.percentLeft}% until auto-compact` })) })) : !overlayView && runningShells.length > 0 ? (_jsx(Box, { flexShrink: 0, children: _jsx(BackgroundBar, { shells: runningShells }) })) : (_jsx(TipLine, { tip: TIPS[tipIdx % TIPS.length] }))] }));
|
|
3174
|
+
// The transcript viewport: the scrolled window, its measurements, and the chip.
|
|
3175
|
+
//
|
|
3176
|
+
// Built once and rendered by BOTH shells — the full-screen frame, and the inline
|
|
3177
|
+
// shell's reading view. The two want exactly the same thing there: a clipped box that
|
|
3178
|
+
// Yoga sizes against a pinned footer, with the transcript offset inside it. Kept as
|
|
3179
|
+
// two copies they would drift apart the first time either was touched, and the drift
|
|
3180
|
+
// would show as scrolling behaving differently in one shell than in the other.
|
|
3181
|
+
const chatView = (_jsxs(Box, { ref: chatRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 1, overflow: "hidden", children: [restsOnFooter ? _jsx(Box, { flexGrow: 1, flexShrink: 1 }) : null, _jsx(Box, { flexDirection: "column", flexShrink: 0, marginTop: 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) => {
|
|
3182
|
+
const idx = win.start + i;
|
|
3183
|
+
return (
|
|
3184
|
+
// flexShrink:0 is load-bearing — without it Yoga compresses an
|
|
3185
|
+
// overfull column and silently drops rows out of the middle.
|
|
3186
|
+
_jsx(Box, { ref: (node) => {
|
|
3187
|
+
if (node && needsMeasure(blockHeights.current.get(b.id), b))
|
|
3188
|
+
toMeasure.current.set(b, node);
|
|
3189
|
+
}, flexShrink: 0, flexDirection: "column", children: _jsx(BlockView, { block: b, columns: width, tightTop: isTight(allBlocks, offset + idx) }) }, b.id));
|
|
3190
|
+
}), padMiddle > 0 ? _jsx(Box, { flexShrink: 0, height: padMiddle }) : null, rendered.slice(known).map((b, i) => {
|
|
3191
|
+
const idx = known + i;
|
|
3192
|
+
return (_jsx(Box, { ref: (node) => {
|
|
3193
|
+
if (node && needsMeasure(blockHeights.current.get(b.id), b))
|
|
3194
|
+
toMeasure.current.set(b, node);
|
|
3195
|
+
}, flexShrink: 0, flexDirection: "column", children: _jsx(BlockView, { block: b, columns: width, tightTop: isTight(allBlocks, offset + idx) }) }, b.id));
|
|
3196
|
+
})] }) }), pill ? (_jsx(Box
|
|
3197
|
+
// Measured so a CLICK can find it. The row is the only part of the chip's
|
|
3198
|
+
// position the layout owns — the columns fall out of centring, which
|
|
3199
|
+
// `pillBounds` reproduces — and a pointer handler runs between frames, where
|
|
3200
|
+
// it could not measure anything for itself.
|
|
3201
|
+
, {
|
|
3202
|
+
// Measured so a CLICK can find it. The row is the only part of the chip's
|
|
3203
|
+
// position the layout owns — the columns fall out of centring, which
|
|
3204
|
+
// `pillBounds` reproduces — and a pointer handler runs between frames, where
|
|
3205
|
+
// it could not measure anything for itself.
|
|
3206
|
+
ref: (node) => {
|
|
3207
|
+
pillRow.current = node ? measureElement(node).y : null;
|
|
3208
|
+
}, position: "absolute", bottom: 0, left: 0, right: 0, justifyContent: "center", children: _jsx(Text, { inverse: true, children: pill }) })) : null] }));
|
|
3209
|
+
/**
|
|
3210
|
+
* Refilling the rows the reading frame leaves behind — IN THE SAME COMMIT.
|
|
3211
|
+
*
|
|
3212
|
+
* Closing the reading view shrinks the live region from a near-full-height frame back
|
|
3213
|
+
* to a couple of rows, and a terminal cannot un-scroll. Measured on the real renderer,
|
|
3214
|
+
* the shrink emits `eraseLine` twenty-four times and then writes two lines: twenty-two
|
|
3215
|
+
* rows erased with nothing put back, which is the band of empty screen below the
|
|
3216
|
+
* prompt. Ink is right to do this — it is how every live region shrinks — and it is
|
|
3217
|
+
* ordinarily invisible because a shrink normally happens when a block DRAINS into
|
|
3218
|
+
* <Static>, which prints the same rows permanently on its way past. Nothing drains
|
|
3219
|
+
* when a viewport closes, so nothing refills them.
|
|
3220
|
+
*
|
|
3221
|
+
* So the close reprints, and the reprint has to be part of the SAME frame as the
|
|
3222
|
+
* shrink. Driven from an effect it was one render late, and that one render is a real
|
|
3223
|
+
* frame the terminal paints: the erased band flashed empty and was then filled — the
|
|
3224
|
+
* blink reported on the way back to the bottom. Refs mutated during render are what
|
|
3225
|
+
* put both in one commit, the same way `maxScrollRef` above is published to handlers
|
|
3226
|
+
* that run between frames.
|
|
3227
|
+
*
|
|
3228
|
+
* The header is suppressed on this one, because unlike the shell switch this replaces
|
|
3229
|
+
* nothing — the original is still in scrollback a screen up, and a second copy in the
|
|
3230
|
+
* middle of the conversation reads as the session having restarted.
|
|
3231
|
+
*/
|
|
3232
|
+
/**
|
|
3233
|
+
* The startup fill, decided during the RENDER — which is the only moment it can be.
|
|
3234
|
+
*
|
|
3235
|
+
* `<Static>` prints each item ONCE, in the render that first sees it, and the fill is
|
|
3236
|
+
* one of its items. An effect runs after that render has already been committed and
|
|
3237
|
+
* written, so a height an effect assigns arrives too late by construction: the item has
|
|
3238
|
+
* been printed at whatever the ref held during the render, and Static will never render
|
|
3239
|
+
* it again. Set from an effect, the fill was therefore printed as ZERO rows on first
|
|
3240
|
+
* mount, every time — verified by rendering the same shape and counting the rows it
|
|
3241
|
+
* emitted before the first item.
|
|
3242
|
+
*
|
|
3243
|
+
* The visible cost was the whole reason the fill exists going unpaid: the first screen
|
|
3244
|
+
* sat at the TOP of the terminal, with the prompt part-way up and empty rows below it,
|
|
3245
|
+
* because a terminal prints from wherever the cursor happens to be.
|
|
3246
|
+
*
|
|
3247
|
+
* Only for the FIRST print. Every later remount of `<Static>` — a shell switch, a
|
|
3248
|
+
* width-change reprint, leaving the reading view — sets the fill for its own reasons
|
|
3249
|
+
* before bumping the key, and those must not be overwritten here.
|
|
3250
|
+
*/
|
|
3251
|
+
/**
|
|
3252
|
+
* The startup fill, GROWN whenever a void opens below the footer.
|
|
3253
|
+
*
|
|
3254
|
+
* The fill is a run of blank rows printed above the first screen so a short
|
|
3255
|
+
* conversation lands at the BOTTOM of the terminal rather than floating part-way up —
|
|
3256
|
+
* a terminal prints from wherever the cursor is, and prints nothing below. `<Static>`
|
|
3257
|
+
* emits each item once, so the fill's height is whatever `rows` held the render it was
|
|
3258
|
+
* first printed on, and can never change for that mount.
|
|
3259
|
+
*
|
|
3260
|
+
* That is the whole bug behind the void. The first render often runs before the real
|
|
3261
|
+
* terminal height is known — the size hook re-reads a moment later — so the fill was
|
|
3262
|
+
* frozen at a stale, small number: blank rows at the top, the conversation, and then a
|
|
3263
|
+
* band of empty screen all the way to the bottom edge that nothing ever filled.
|
|
3264
|
+
*
|
|
3265
|
+
* `fillBasis` is the height the current fill was sized for. When the terminal turns
|
|
3266
|
+
* out to be TALLER than that — the settle after a wrong first read, or the window
|
|
3267
|
+
* genuinely dragged bigger — the fill is regrown and `<Static>` remounted, which
|
|
3268
|
+
* reprints the recent conversation with the footer back at the edge. Only ever grown,
|
|
3269
|
+
* never shrunk: once the conversation is long enough to overflow the screen the footer
|
|
3270
|
+
* sits at the bottom on its own, and shrinking the fill then would reprint on every
|
|
3271
|
+
* small drag for a void that is not there.
|
|
3272
|
+
*/
|
|
3273
|
+
if (shell === "inline") {
|
|
3274
|
+
const grown = growFill(fillState.current, rows);
|
|
3275
|
+
fillState.current = { fill: grown.fill, basis: grown.basis };
|
|
3276
|
+
startFill.current = grown.fill;
|
|
3277
|
+
// A remount reprints the recent conversation with the footer back at the edge —
|
|
3278
|
+
// wanted when a void has opened, skipped on the very first sizing (nothing on screen
|
|
3279
|
+
// yet). See startupFill.ts.
|
|
3280
|
+
if (grown.remount)
|
|
3281
|
+
closeEpoch.current += 1;
|
|
3282
|
+
}
|
|
3283
|
+
/**
|
|
3284
|
+
* Where `<Static>` was allowed to reach when the reading view opened, or null while
|
|
3285
|
+
* the view is closed.
|
|
3286
|
+
*
|
|
3287
|
+
* A SCROLLBACK PRINTER AND A VIEWPORT CANNOT BOTH BE WRITING. Every finished block
|
|
3288
|
+
* normally goes to `<Static>`, which prints it into the terminal permanently and
|
|
3289
|
+
* scrolls everything up to make room. That is exactly right when the live region below
|
|
3290
|
+
* it is two rows of prompt. It is destructive when the live region is a near
|
|
3291
|
+
* full-height viewport, because each print scrolls the terminal under a frame that Ink
|
|
3292
|
+
* then has to erase and lay down again from its new position — measured on the real
|
|
3293
|
+
* renderer, three blocks arriving during an open viewport erased seventy-two rows of a
|
|
3294
|
+
* twenty-four row terminal. What that looks like on screen is bands of blank where a
|
|
3295
|
+
* block is about to appear, which is the reported glitch, and it only happens while a
|
|
3296
|
+
* turn is still running because that is the only time new blocks arrive.
|
|
3297
|
+
*
|
|
3298
|
+
* The conflict only exists because this shell has BOTH. A scrolling viewport normally
|
|
3299
|
+
* holds the whole transcript itself and prints nothing permanently; a shell built on
|
|
3300
|
+
* `<Static>` has no viewport for a print to collide with. Running the two together is
|
|
3301
|
+
* this shell's own doing, so the rule it needs has to be stated here: while the
|
|
3302
|
+
* viewport is open, the printer holds.
|
|
3303
|
+
*
|
|
3304
|
+
* Nothing is lost by holding. The close already reprints from `reprintFrom`, which is
|
|
3305
|
+
* behind everything that arrived while the view was open, so those blocks reach the
|
|
3306
|
+
* terminal on the way out — in one frame, with the refill that fills the viewport's
|
|
3307
|
+
* rows, rather than a print at a time underneath it.
|
|
3308
|
+
*/
|
|
3309
|
+
if (readingInline && frozenStatic.current === null)
|
|
3310
|
+
frozenStatic.current = committed.length;
|
|
3311
|
+
if (wasReadingInline.current && !readingInline) {
|
|
3312
|
+
reprintFrom.current = Math.max(0, committed.length - INLINE_REPRINT_BLOCKS);
|
|
3313
|
+
startFill.current = 0;
|
|
3314
|
+
showHeader.current = false;
|
|
3315
|
+
closeEpoch.current += 1;
|
|
3316
|
+
frozenStatic.current = null;
|
|
3317
|
+
}
|
|
3318
|
+
wasReadingInline.current = readingInline;
|
|
3319
|
+
// ── the inline shell ──────────────────────────────────────────────────────
|
|
3320
|
+
//
|
|
3321
|
+
// Finished blocks go to <Static>, which Ink prints ONCE and never renders again:
|
|
3322
|
+
// they become the terminal's own scrollback. Only the live tail and the footer are
|
|
3323
|
+
// re-rendered, so a frame costs what is happening now rather than what has happened
|
|
3324
|
+
// all session — a four-hour conversation renders exactly as fast as a four-minute one.
|
|
3325
|
+
//
|
|
3326
|
+
// Everything the fullscreen shell exists to do is simply absent here, on purpose.
|
|
3327
|
+
// There is no frame height, because we are not claiming the screen; no virtual window,
|
|
3328
|
+
// because nothing off screen is being re-laid-out; no scroll offset, because scrolling
|
|
3329
|
+
// is the terminal's scrollbar; and no selection layer, because selecting is the
|
|
3330
|
+
// terminal's selection. Each of those is faster than what we would write, and behaves
|
|
3331
|
+
// the way the rest of the user's terminal already does.
|
|
3332
|
+
//
|
|
3333
|
+
// The banner rides as a sentinel item rather than sitting above the list, so it prints
|
|
3334
|
+
// exactly once and scrolls away with the conversation instead of being reprinted at
|
|
3335
|
+
// the top of every frame.
|
|
3336
|
+
if (shell === "inline") {
|
|
3337
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static
|
|
3338
|
+
// Two counters, because the two remounts happen at different MOMENTS: the
|
|
3339
|
+
// shell switch can settle in an effect, the reading-view close has to land in
|
|
3340
|
+
// the frame that shrinks the live region.
|
|
3341
|
+
, {
|
|
3342
|
+
// `frozenStatic` caps the list while the reading viewport is open — see above.
|
|
3343
|
+
// `slice(from, undefined)` is `slice(from)`, so the closed case is unchanged.
|
|
3344
|
+
items: [
|
|
3345
|
+
FILL_ITEM,
|
|
3346
|
+
BANNER_ITEM,
|
|
3347
|
+
...committed.slice(reprintFrom.current, frozenStatic.current ?? undefined),
|
|
3348
|
+
], children: (item, index) => {
|
|
3349
|
+
if (item === FILL_ITEM)
|
|
3350
|
+
return _jsx(Box, { height: startFill.current }, "fill");
|
|
3351
|
+
// Kept as an ITEM even when it renders nothing, so the `index - 2` the
|
|
3352
|
+
// blocks below count back is the same either way.
|
|
3353
|
+
if (item === BANNER_ITEM)
|
|
3354
|
+
return showHeader.current ? _jsx(InlineHeader, {}, "banner") : null;
|
|
3355
|
+
return (_jsx(BlockView, { block: item, columns: width, tightTop: isTight(allBlocks, reprintFrom.current + index - 2) }, item.id));
|
|
3356
|
+
} }, `${staticEpoch}:${closeEpoch.current}`), _jsxs(Box, { ref: liveRef, flexDirection: "column", flexShrink: 0,
|
|
3357
|
+
// `frameHeight` and the clip are what makes reading a VIEWPORT rather than a
|
|
3358
|
+
// wall of text — see the shared `chatView` above. Applied to the SAME element
|
|
3359
|
+
// in both states, not one that only exists in one of them.
|
|
3360
|
+
height: readingInline ? frameHeight : undefined, overflow: readingInline ? "hidden" : "visible", children: [readingInline ? (
|
|
3361
|
+
// <Static> stays mounted above and prints nothing new: it has already
|
|
3362
|
+
// emitted every item it holds, and unmounting it would make the next mount
|
|
3363
|
+
// reprint the whole conversation underneath this frame.
|
|
3364
|
+
chatView) : (tail.map((b, i) => (_jsx(BlockView, { block: b, columns: width, tightTop: isTight(allBlocks, committed.length + i) }, b.id)))), footerView] })] }));
|
|
3365
|
+
}
|
|
3366
|
+
return (_jsxs(Box, { flexDirection: "column", height: frameHeight, overflow: "hidden", children: [_jsx(Box, { flexShrink: 0, children: _jsx(Banner, { width: width, mode: mode, modelConfig: session.current?.modelConfig, busy: busy }) }), chatView, footerView] }));
|
|
3367
|
+
}
|
|
3368
|
+
/**
|
|
3369
|
+
* The inline shell's header, printed once at the top of the conversation.
|
|
3370
|
+
*
|
|
3371
|
+
* Deliberately NOT the fullscreen banner. That one is a live status bar — mode, model,
|
|
3372
|
+
* whether a turn is running — and <Static> prints a thing once and never touches it
|
|
3373
|
+
* again, so all three would be frozen at whatever they happened to be when the session
|
|
3374
|
+
* opened. A header that quietly lies about which model is answering is worse than no
|
|
3375
|
+
* header. What belongs in scrollback is the part that cannot go stale.
|
|
3376
|
+
*/
|
|
3377
|
+
function InlineHeader() {
|
|
3378
|
+
return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "yellow", children: "Mindweave" }), _jsxs(Text, { dimColor: true, children: [" ", versionLabel()] })] }));
|
|
2331
3379
|
}
|
|
3380
|
+
/** A sentinel <Static> item: the one-time header, printed with the transcript so it
|
|
3381
|
+
* scrolls away rather than being redrawn above every frame. */
|
|
3382
|
+
const BANNER_ITEM = "__banner__";
|
|
3383
|
+
/** A sentinel for the blank rows that push the first screen of conversation down to the
|
|
3384
|
+
* bottom of the window. In the list rather than above it so it is printed exactly once,
|
|
3385
|
+
* the same as the header. */
|
|
3386
|
+
const FILL_ITEM = "__fill__";
|
|
2332
3387
|
// The banner's own rows: the title line, the rule under it, and its bottom
|
|
2333
3388
|
// margin. Subtracted from the frame so the chat gets exactly what's left —
|
|
2334
3389
|
// see the layout comment at the render site.
|
|
@@ -2340,6 +3395,9 @@ const MIN_CHAT_ROWS = 3;
|
|
|
2340
3395
|
const FOOTER_BASE_ROWS = 7;
|
|
2341
3396
|
/** The palette's own chrome: title, the "Tab completes" hint, top+bottom border. */
|
|
2342
3397
|
const MENU_CHROME_ROWS = 4;
|
|
3398
|
+
/** Item rows the command palette shows in the INLINE shell. Small on purpose — see the
|
|
3399
|
+
* note where it is used: there, rows are paid for in terminal scroll. */
|
|
3400
|
+
const INLINE_MENU_ROWS = 3;
|
|
2343
3401
|
/** Lines PageUp/PageDown move per press. */
|
|
2344
3402
|
const PAGE_LINES = 10;
|
|
2345
3403
|
/** Lines one wheel notch moves. Three is the usual terminal step, and a flick
|
|
@@ -2348,6 +3406,15 @@ const WHEEL_LINES = 3;
|
|
|
2348
3406
|
/** How much of the transcript stays scrollable. Every rendered block is laid out
|
|
2349
3407
|
* on every render, so this bounds what typing costs in a long conversation. */
|
|
2350
3408
|
const SCROLLBACK_BLOCKS = 150;
|
|
3409
|
+
/** Blocks reprinted when the inline shell is entered. Two screens or so: enough to look
|
|
3410
|
+
* back over, few enough that the reprint is not a visible pause. */
|
|
3411
|
+
/** How long the INLINE shell waits for a drag to settle before re-reading the size.
|
|
3412
|
+
* The full-screen shell does not wait — see useTerminalSize. */
|
|
3413
|
+
const RESIZE_SETTLE_MS = 150;
|
|
3414
|
+
/** How often the size is polled, for Windows consoles where the resize event may never
|
|
3415
|
+
* fire at all (nodejs/node#13197). Two integer reads; an unchanged size costs nothing. */
|
|
3416
|
+
const RESIZE_POLL_MS = 250;
|
|
3417
|
+
const INLINE_REPRINT_BLOCKS = 40;
|
|
2351
3418
|
/**
|
|
2352
3419
|
* The loom shuttle that runs beside the name while a turn is working.
|
|
2353
3420
|
*
|
|
@@ -2431,14 +3498,42 @@ export function Banner({ width, mode, modelConfig, busy }) {
|
|
|
2431
3498
|
* whatever the header/footer don't use), so a stale row count leaves dead
|
|
2432
3499
|
* space at the bottom instead of the frame reaching the terminal's actual edge.
|
|
2433
3500
|
*/
|
|
2434
|
-
|
|
3501
|
+
/**
|
|
3502
|
+
* The terminal's size RIGHT NOW, by live syscall where the platform offers one.
|
|
3503
|
+
*
|
|
3504
|
+
* `stdout.columns` / `stdout.rows` are getters that, on Windows, can hand back a value
|
|
3505
|
+
* cached at the last `resize` event — and that event frequently never fires there
|
|
3506
|
+
* (nodejs/node#13197). So a window dragged taller leaves those properties reporting the
|
|
3507
|
+
* old height forever, and everything sized from them, the full-screen frame included,
|
|
3508
|
+
* stops short of the real bottom edge with dead space below it.
|
|
3509
|
+
*
|
|
3510
|
+
* `getWindowSize()` asks the OS for the size on the spot (`uv_tty_get_winsize`), which is
|
|
3511
|
+
* not cached and not tied to the event. Preferred when present; the plain getters are the
|
|
3512
|
+
* fallback for a stream that has no `getWindowSize` (a pipe, a test double).
|
|
3513
|
+
*/
|
|
3514
|
+
export function liveTerminalSize(stream) {
|
|
3515
|
+
const win = stream?.getWindowSize?.();
|
|
3516
|
+
if (win)
|
|
3517
|
+
return { columns: win[0], rows: win[1] };
|
|
3518
|
+
return { columns: stream?.columns ?? 80, rows: stream?.rows ?? 24 };
|
|
3519
|
+
}
|
|
3520
|
+
export function useTerminalSize(defer) {
|
|
2435
3521
|
const { stdout } = useStdout();
|
|
2436
|
-
const [size, setSize] = useState(
|
|
3522
|
+
const [size, setSize] = useState(() => liveTerminalSize(stdout));
|
|
3523
|
+
// Read live so the listeners below never have to be torn down and rebuilt when the
|
|
3524
|
+
// shell changes — resubscribing mid-drag would drop the very events being handled.
|
|
3525
|
+
const deferRef = useRef(defer);
|
|
3526
|
+
deferRef.current = defer;
|
|
2437
3527
|
useEffect(() => {
|
|
2438
3528
|
if (!stdout)
|
|
2439
3529
|
return;
|
|
3530
|
+
// Identical sizes end here, and that matters more than it looks: terminals emit two
|
|
3531
|
+
// or more resize events for a single user action as the window settles, and each one
|
|
3532
|
+
// that reached state would be a re-layout of the whole frame for no change at all.
|
|
2440
3533
|
const read = () => setSize((prev) => {
|
|
2441
|
-
|
|
3534
|
+
// A LIVE query, so a Windows window dragged bigger is detected even though the
|
|
3535
|
+
// resize event never fired and the cached getters still report the old size.
|
|
3536
|
+
const next = liveTerminalSize(stdout);
|
|
2442
3537
|
return next.columns === prev.columns && next.rows === prev.rows ? prev : next;
|
|
2443
3538
|
});
|
|
2444
3539
|
// The 'resize' event is NOT reliable on native Windows consoles — Node has a
|
|
@@ -2449,13 +3544,37 @@ function useTerminalSize() {
|
|
|
2449
3544
|
// integer reads, ~4x/sec) and it's the standard workaround for that exact gap,
|
|
2450
3545
|
// not a hack: it's what a resize event is supposed to give us, gotten a
|
|
2451
3546
|
// different way when the event can't be trusted to arrive at all.
|
|
3547
|
+
// NOT debounced when the app owns the screen, and the debounce that used to be here
|
|
3548
|
+
// unconditionally is what made a drag look broken.
|
|
3549
|
+
//
|
|
3550
|
+
// A debounce opens a window where the terminal has already resized but this app still
|
|
3551
|
+
// believes the old size. Anything that renders during it — the spinner, the clock, a
|
|
3552
|
+
// streaming delta — lays a frame out at dimensions the terminal no longer has, and
|
|
3553
|
+
// the result is the half-drawn shapes that appear while dragging and tidy themselves
|
|
3554
|
+
// up the moment the drag stops. The frame is not settling late; it is being drawn
|
|
3555
|
+
// wrong and then drawn again. Handling the event as it arrives keeps the app's idea
|
|
3556
|
+
// of the size and the terminal's the same at every instant, which is the only state
|
|
3557
|
+
// in which a frame can be right.
|
|
3558
|
+
//
|
|
3559
|
+
// The INLINE shell still defers, and for a reason that does not apply to the other
|
|
3560
|
+
// one. There the transcript is the terminal's own scrollback, printed once; a
|
|
3561
|
+
// re-render mid-drag leaves a stale copy of the live region behind it, so a slow drag
|
|
3562
|
+
// left a ladder of half-drawn input boxes down the screen. Nothing is printed
|
|
3563
|
+
// permanently in the full-screen shell, so nothing can be left behind.
|
|
2452
3564
|
let debounce;
|
|
2453
3565
|
const onResize = () => {
|
|
2454
3566
|
clearTimeout(debounce);
|
|
2455
|
-
|
|
3567
|
+
if (!deferRef.current) {
|
|
3568
|
+
read();
|
|
3569
|
+
return;
|
|
3570
|
+
}
|
|
3571
|
+
debounce = setTimeout(read, RESIZE_SETTLE_MS);
|
|
2456
3572
|
};
|
|
2457
3573
|
stdout.on("resize", onResize);
|
|
2458
|
-
|
|
3574
|
+
// The poll exists for Windows, where the event cannot be relied on at all. It goes
|
|
3575
|
+
// through the same handler, so it inherits whichever policy the shell is using, and
|
|
3576
|
+
// the identical-size check above makes a poll that finds nothing free.
|
|
3577
|
+
const poll = setInterval(onResize, RESIZE_POLL_MS);
|
|
2459
3578
|
// The size read at THIS exact instant can be stale too: entering alt-screen
|
|
2460
3579
|
// (a raw escape code written before Ink even mounts, see altScreen.ts) makes
|
|
2461
3580
|
// the terminal reconfigure its buffer, and querying dimensions mid-reconfigure
|
|
@@ -2638,14 +3757,6 @@ function BackgroundBar({ shells }) {
|
|
|
2638
3757
|
// mode/model/thinking readout already lives in the header, so this slot is
|
|
2639
3758
|
// free for the shift-tab hint (nothing else states it anymore) and a few of
|
|
2640
3759
|
// the less-obvious commands.
|
|
2641
|
-
const TIPS = [
|
|
2642
|
-
"shift+tab cycles Lightning / Architect / Sentinel",
|
|
2643
|
-
"/help lists every command",
|
|
2644
|
-
"/model switches which model answers, /think sets its reasoning level",
|
|
2645
|
-
"@ mentions a file to attach it",
|
|
2646
|
-
"esc interrupts a running turn",
|
|
2647
|
-
"/undo restores the last checkpoint",
|
|
2648
|
-
];
|
|
2649
3760
|
/**
|
|
2650
3761
|
* Messages typed while Mindweave is working, waiting to be sent when the turn ends.
|
|
2651
3762
|
*
|
|
@@ -2662,7 +3773,7 @@ function QueuedBar({ queued }) {
|
|
|
2662
3773
|
if (queued.length === 0)
|
|
2663
3774
|
return null;
|
|
2664
3775
|
const { rows, hidden } = visibleQueue(queued);
|
|
2665
|
-
return (_jsxs(Box, { flexDirection: "column", children: [rows.map((q, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["⏎ queued: ", q] }, i))), hidden > 0 ? (_jsx(Text, { dimColor: true, children: ` …and ${hidden} more` })) : null, _jsx(Text, { dimColor: true, children: ` ↑ to edit ${queued.length === 1 ? "it" : "them"}` })] }));
|
|
3776
|
+
return (_jsxs(Box, { flexDirection: "column", children: [rows.map((q, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["⏎ queued: ", q.text] }, i))), hidden > 0 ? (_jsx(Text, { dimColor: true, children: ` …and ${hidden} more` })) : null, _jsx(Text, { dimColor: true, children: ` ↑ to edit ${queued.length === 1 ? "it" : "them"}` })] }));
|
|
2666
3777
|
}
|
|
2667
3778
|
/**
|
|
2668
3779
|
* Whether some OTHER installed provider has a key, so `/provider` is worth
|