mindweave 2.3.1 → 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 +1175 -99
- 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/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 +1 -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 +11 -5
- package/dist/cli/components/KeyManager.js.map +1 -1
- package/dist/cli/components/Picker.js +18 -3
- 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 +74 -74
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,6 +48,9 @@ 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";
|
|
@@ -60,15 +63,26 @@ import { ApprovalBox } from "./components/ApprovalBox.js";
|
|
|
60
63
|
import { BlockView } from "./components/BlockView.js";
|
|
61
64
|
import { initialState, reduce, trimNarration } from "./transcript.js";
|
|
62
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";
|
|
63
70
|
import { BASE_COMMANDS } from "./commands.js";
|
|
64
71
|
import { manualCommand, refusalReason } from "./selfUpdate.js";
|
|
65
72
|
import { currentInstall, requestRestart, runUpdate } from "./updateRunner.js";
|
|
66
|
-
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";
|
|
67
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";
|
|
68
82
|
import { virtualWindow } from "./virtualWindow.js";
|
|
69
83
|
import { perf, perfEnabled } from "./perfLog.js";
|
|
70
|
-
import { isGroupMember, groupSettled, planGroupReveal, resultQueued } from "./groupReveal.js";
|
|
71
|
-
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";
|
|
72
86
|
import { routeCommand, parseCommandLine, unknownCommandMessage } from "./commandRoute.js";
|
|
73
87
|
import { resolveChoice } from "./commandArgs.js";
|
|
74
88
|
import { carryAcrossFreshSession } from "./sessionCarry.js";
|
|
@@ -82,6 +96,9 @@ import { mapPromptArguments, promptCommand, promptUsage } from "../mcp/prompts.j
|
|
|
82
96
|
import { DEFAULT_MODE, modeById, modeFromFlags, nextMode } from "./modes.js";
|
|
83
97
|
import { ApprovalChannel } from "./approvalChannel.js";
|
|
84
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;
|
|
85
102
|
/** Commands whose whole job is to open a surface in the box under the input. Written out
|
|
86
103
|
* in full, because only a bare invocation opens anything: given an argument each of these
|
|
87
104
|
* acts directly and there is no surface to hold the frame for. */
|
|
@@ -112,7 +129,7 @@ const RESUME_MODES = [
|
|
|
112
129
|
{ label: "Continue as-is", description: "resume the full conversation unchanged" },
|
|
113
130
|
{ label: "Fresh start", description: "leave it and start a new empty session here instead" },
|
|
114
131
|
];
|
|
115
|
-
export function App({ resumeSessionId }) {
|
|
132
|
+
export function App({ resumeSessionId, initialScreen }) {
|
|
116
133
|
// The transcript state machine lives in a ref and is advanced by the reducer as
|
|
117
134
|
// the stream arrives; `render` forces a paint. A ref (not useState) so the async
|
|
118
135
|
// streaming loop always reads/writes the latest state without stale closures.
|
|
@@ -151,12 +168,68 @@ export function App({ resumeSessionId }) {
|
|
|
151
168
|
// flag the engine actually acts on (set by applyMode / attachApproval).
|
|
152
169
|
const [mode, setMode] = useState(DEFAULT_MODE);
|
|
153
170
|
const modeRef = useRef(DEFAULT_MODE);
|
|
154
|
-
//
|
|
155
|
-
|
|
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
|
+
}, []);
|
|
156
211
|
// How far the transcript is scrolled back, in LINES from the bottom. Alt-screen
|
|
157
212
|
// has no terminal scrollback of its own (altScreen.ts), so this is ours to
|
|
158
213
|
// implement; 0 means pinned to the newest.
|
|
159
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);
|
|
160
233
|
// The transcript's real rendered height, from measureElement — never estimated.
|
|
161
234
|
const contentRef = useRef(null);
|
|
162
235
|
const [contentHeight, setContentHeight] = useState(0);
|
|
@@ -168,18 +241,25 @@ export function App({ resumeSessionId }) {
|
|
|
168
241
|
// `virtualWindow.ts` for why that is the whole performance story, and why exact
|
|
169
242
|
// is the word that matters.
|
|
170
243
|
//
|
|
171
|
-
//
|
|
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
|
|
172
246
|
// stylistic: the transcript reducer returns a NEW object whenever a block changes
|
|
173
247
|
// (streaming text growing, `live` flipping at turn end) and the same object when it
|
|
174
|
-
// 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
|
|
175
249
|
// is re-measured — cache invalidation falls out of the data model instead of needing
|
|
176
|
-
// a rule that could be forgotten for some future block type.
|
|
177
|
-
//
|
|
178
|
-
|
|
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());
|
|
179
260
|
// Nodes captured this render, waiting to be measured once Yoga has laid them out.
|
|
180
261
|
const toMeasure = useRef(new Map());
|
|
181
|
-
//
|
|
182
|
-
// 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.
|
|
183
263
|
const heightsWidth = useRef(0);
|
|
184
264
|
// Bumped when a measurement lands, purely to re-render so the new height can be
|
|
185
265
|
// used. Never read.
|
|
@@ -192,6 +272,14 @@ export function App({ resumeSessionId }) {
|
|
|
192
272
|
// the terminal, which corrupts the whole frame if that's the row that tips
|
|
193
273
|
// outputHeight to stdout.rows. Measured, this can't drift.
|
|
194
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);
|
|
195
283
|
const [footerHeight, setFooterHeight] = useState(0);
|
|
196
284
|
// The chat viewport's REAL height. Yoga decides it now (flexGrow beside a
|
|
197
285
|
// flexShrink:0 footer); this is read back purely so the scroll maths knows how
|
|
@@ -263,10 +351,137 @@ export function App({ resumeSessionId }) {
|
|
|
263
351
|
const needsKey = setupOpen || keysOpen;
|
|
264
352
|
// Sent-message history, oldest-first — walked with ↑/↓ in the input.
|
|
265
353
|
const [history, setHistory] = useState([]);
|
|
266
|
-
//
|
|
267
|
-
//
|
|
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.
|
|
268
479
|
const queueRef = useRef([]);
|
|
269
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);
|
|
270
485
|
// An interactive overlay (session picker, model/think chooser, or an approval
|
|
271
486
|
// prompt). When set, it owns the keyboard and the input box is hidden.
|
|
272
487
|
const [overlay, setOverlay] = useState(null);
|
|
@@ -343,7 +558,7 @@ export function App({ resumeSessionId }) {
|
|
|
343
558
|
// the user never typed — seen live as "> That was 3 sentences between tool
|
|
344
559
|
// calls…" sitting in their own chat history.
|
|
345
560
|
if (!e.synthetic)
|
|
346
|
-
dispatch({ type: "user", text: stripAttachments(e.content) });
|
|
561
|
+
dispatch({ type: "user", text: collapsePastes(stripAttachments(e.content)) });
|
|
347
562
|
}
|
|
348
563
|
else if (e.role === "summary") {
|
|
349
564
|
dispatch({ type: "note", text: "— resumed; earlier context summarized —" });
|
|
@@ -388,6 +603,7 @@ export function App({ resumeSessionId }) {
|
|
|
388
603
|
...(e.quiet ? { quiet: true } : {}),
|
|
389
604
|
...(e.displayName ? { name: e.displayName } : {}),
|
|
390
605
|
...(e.displayKind ? { action: e.displayKind } : {}),
|
|
606
|
+
...(e.awaitsModel ? { awaitsModel: true } : {}),
|
|
391
607
|
});
|
|
392
608
|
}
|
|
393
609
|
}
|
|
@@ -494,10 +710,14 @@ export function App({ resumeSessionId }) {
|
|
|
494
710
|
let out = text;
|
|
495
711
|
for (const [chip, content] of pasteStore.current) {
|
|
496
712
|
if (out.includes(chip))
|
|
497
|
-
out = out.split(chip).join(content);
|
|
713
|
+
out = out.split(chip).join(wrapPastedText(content));
|
|
498
714
|
}
|
|
499
715
|
return out;
|
|
500
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))));
|
|
501
721
|
// File-path completion for the input's `@mention` picker (primary root).
|
|
502
722
|
const pathComplete = useRef((prefix) => {
|
|
503
723
|
const s = session.current;
|
|
@@ -505,11 +725,40 @@ export function App({ resumeSessionId }) {
|
|
|
505
725
|
});
|
|
506
726
|
// Live terminal width — drives message wrapping (Static items capture it at
|
|
507
727
|
// commit time; the live input reflows on resize for free).
|
|
508
|
-
|
|
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");
|
|
509
731
|
// Read live at render time as well as from the polled state above: mid-resize
|
|
510
732
|
// the state can lag the real terminal by a tick, and a frame one row too TALL
|
|
511
733
|
// is the failure that corrupts the screen (see the layout comment below).
|
|
512
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]);
|
|
513
762
|
// One session for the whole conversation (a ref so it survives re-renders).
|
|
514
763
|
const session = useRef(null);
|
|
515
764
|
useEffect(() => {
|
|
@@ -586,7 +835,7 @@ export function App({ resumeSessionId }) {
|
|
|
586
835
|
return;
|
|
587
836
|
queueRef.current = next.rest;
|
|
588
837
|
setQueued(next.rest);
|
|
589
|
-
void handleSubmit(next.send);
|
|
838
|
+
void handleSubmit(next.send, { arrival: next.priority === "now" ? "interrupting" : undefined });
|
|
590
839
|
}, [busy, ready, needsKey, overlay]);
|
|
591
840
|
// ↑ or Esc takes the queue back into the input box, editable, and empties it. This
|
|
592
841
|
// is the ONLY way to change your mind about something already queued, so it has to
|
|
@@ -612,17 +861,55 @@ export function App({ resumeSessionId }) {
|
|
|
612
861
|
usageSamples.current = [];
|
|
613
862
|
meter.current = meterReset();
|
|
614
863
|
setTaskUsage(null); // clear the previous task's summary while this one runs
|
|
864
|
+
interrupting.current = false;
|
|
615
865
|
abortRef.current = new AbortController();
|
|
616
866
|
setBusy(true);
|
|
617
867
|
}
|
|
618
868
|
// Esc interrupts the current turn: it aborts the model call AND kills a running
|
|
619
869
|
// command — run_command listens to this same signal (see runShell), so a hung
|
|
620
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 });
|
|
621
906
|
// Only while working AND no overlay is open (an open Picker owns Esc for its own
|
|
622
907
|
// cancel). The input ignores Esc, so typing-while-busy is safe.
|
|
623
908
|
useInput((_input, key) => {
|
|
624
909
|
if (key.escape) {
|
|
625
910
|
abortRef.current?.abort();
|
|
911
|
+
// From here until the next turn starts, anything typed is for the NEXT turn.
|
|
912
|
+
interrupting.current = true;
|
|
626
913
|
// Anything still waiting to be asked is answered as declined. A queued approval
|
|
627
914
|
// has no overlay to press Esc on, so without this the tool holding it would wait
|
|
628
915
|
// for the rest of the session on a question the user has already stopped.
|
|
@@ -670,10 +957,49 @@ export function App({ resumeSessionId }) {
|
|
|
670
957
|
// the animation explains a movement the user did not make. A wheel notch is a direct
|
|
671
958
|
// manipulation, and direct manipulation must be 1:1
|
|
672
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.
|
|
673
966
|
const scrollBy = useCallback((lines) => {
|
|
674
|
-
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)));
|
|
675
988
|
}, []);
|
|
676
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
|
+
}
|
|
677
1003
|
// Shift+arrows as well as PageUp/PageDown: Windows consoles routinely eat
|
|
678
1004
|
// the paging keys before an app sees them, so there has to be a second way in.
|
|
679
1005
|
if (key.pageUp)
|
|
@@ -684,7 +1010,94 @@ export function App({ resumeSessionId }) {
|
|
|
684
1010
|
scrollBy(1);
|
|
685
1011
|
else if (key.downArrow && key.shift)
|
|
686
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);
|
|
687
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]);
|
|
688
1101
|
// Measure the transcript's real rendered height after every render. Deliberately
|
|
689
1102
|
// has no dependency list: the height changes for reasons no dep could name — a
|
|
690
1103
|
// reply landing, a terminal resize re-wrapping every paragraph — and the guard
|
|
@@ -718,20 +1131,35 @@ export function App({ resumeSessionId }) {
|
|
|
718
1131
|
return;
|
|
719
1132
|
let learned = false;
|
|
720
1133
|
for (const [block, node] of toMeasure.current) {
|
|
721
|
-
|
|
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))
|
|
722
1146
|
continue;
|
|
723
1147
|
const { height } = measureElement(node);
|
|
724
1148
|
// A height of 0 is not a measurement, it is a block that has not been laid out
|
|
725
1149
|
// yet. Recording it would collapse the block to nothing the moment it scrolled
|
|
726
1150
|
// off — the exact class of silent, permanent corruption this cache must not have.
|
|
727
1151
|
if (height > 0) {
|
|
728
|
-
blockHeights.current.set(block, height);
|
|
1152
|
+
blockHeights.current.set(block.id, { height, block });
|
|
729
1153
|
learned = true;
|
|
730
1154
|
}
|
|
731
1155
|
}
|
|
732
1156
|
toMeasure.current.clear();
|
|
733
|
-
|
|
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);
|
|
734
1161
|
bumpHeights((t) => t + 1);
|
|
1162
|
+
}
|
|
735
1163
|
});
|
|
736
1164
|
// Same measurement, for the footer — see footerHeight above.
|
|
737
1165
|
useEffect(() => {
|
|
@@ -749,24 +1177,170 @@ export function App({ resumeSessionId }) {
|
|
|
749
1177
|
const { height } = measureElement(chatRef.current);
|
|
750
1178
|
setChatHeight((h) => (h === height ? h : height));
|
|
751
1179
|
});
|
|
752
|
-
|
|
753
|
-
|
|
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.
|
|
754
1225
|
useEffect(() => {
|
|
755
1226
|
if (!ready)
|
|
756
1227
|
return;
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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();
|
|
762
1303
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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 });
|
|
770
1344
|
function endTurn() {
|
|
771
1345
|
if (turnStart.current != null)
|
|
772
1346
|
setLastMs(Date.now() - turnStart.current);
|
|
@@ -889,6 +1463,10 @@ export function App({ resumeSessionId }) {
|
|
|
889
1463
|
// decision it drives (does the NEXT grouped toolStart need to be held) has to
|
|
890
1464
|
// be made before that action is even dispatched.
|
|
891
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);
|
|
892
1470
|
// A new block appears (paced); a token (silent), a tool resolution (in place), a
|
|
893
1471
|
// discovery call folding into an ALREADY-OPEN group, or a sub-agent's nested
|
|
894
1472
|
// activity (folds into / resolves its rail in place) is not.
|
|
@@ -906,12 +1484,20 @@ export function App({ resumeSessionId }) {
|
|
|
906
1484
|
return a.group ? !groupOpen.current : true;
|
|
907
1485
|
return (a.type !== "token" &&
|
|
908
1486
|
a.type !== "toolEnd" &&
|
|
1487
|
+
a.type !== "toolProgress" &&
|
|
909
1488
|
a.type !== "subToolStart" &&
|
|
910
1489
|
a.type !== "subToolEnd" &&
|
|
911
1490
|
a.type !== "subagentEnd");
|
|
912
1491
|
};
|
|
913
1492
|
function enqueueReveal(a) {
|
|
914
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
|
+
}
|
|
915
1501
|
if (!pumpTimer.current)
|
|
916
1502
|
pump();
|
|
917
1503
|
}
|
|
@@ -988,8 +1574,36 @@ export function App({ resumeSessionId }) {
|
|
|
988
1574
|
if (planGroupReveal(groupSettled(revealQ.current.slice(1)), flush.current) === "hold")
|
|
989
1575
|
return;
|
|
990
1576
|
}
|
|
991
|
-
else
|
|
992
|
-
|
|
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
|
+
}
|
|
993
1607
|
}
|
|
994
1608
|
schedulePaced(() => {
|
|
995
1609
|
// Measured HERE, not when the beat was scheduled: the queue keeps growing
|
|
@@ -1046,6 +1660,60 @@ export function App({ resumeSessionId }) {
|
|
|
1046
1660
|
* reveal), each tool bookends a `toolStart`/`toolEnd`, and the reply seals on
|
|
1047
1661
|
* completion. busy stays true until every paced reveal has been shown.
|
|
1048
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
|
+
}
|
|
1049
1717
|
async function streamRespond(s) {
|
|
1050
1718
|
startTurn();
|
|
1051
1719
|
// Pick up an edit the model made to MINDWEAVE.md, but only if one actually happened
|
|
@@ -1057,8 +1725,18 @@ export function App({ resumeSessionId }) {
|
|
|
1057
1725
|
lastRevealAt.current = 0;
|
|
1058
1726
|
streamDone.current = false;
|
|
1059
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
|
+
}
|
|
1060
1735
|
try {
|
|
1061
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),
|
|
1062
1740
|
onActivity: (line, opts) => enqueueReveal(opts?.context ? { type: "context", text: line } : opts?.error ? { type: "error", text: line } : { type: "note", text: line }),
|
|
1063
1741
|
// An AUTOMATIC compaction, mid-turn. Queued like everything else so it appears
|
|
1064
1742
|
// in the order it happened, rather than jumping ahead of the rows around it.
|
|
@@ -1089,6 +1767,11 @@ export function App({ resumeSessionId }) {
|
|
|
1089
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 } : {}) });
|
|
1090
1768
|
}
|
|
1091
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
|
+
}
|
|
1092
1775
|
else if (e.type === "tool" && e.phase === "end") {
|
|
1093
1776
|
if (e.name === "spawn_subagent")
|
|
1094
1777
|
return;
|
|
@@ -1361,6 +2044,27 @@ export function App({ resumeSessionId }) {
|
|
|
1361
2044
|
}
|
|
1362
2045
|
// Route a Picker selection/cancel back to whatever opened the overlay. Picking a
|
|
1363
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
|
+
}
|
|
1364
2068
|
function onOverlaySelect(index) {
|
|
1365
2069
|
const o = overlay;
|
|
1366
2070
|
if (!o)
|
|
@@ -1382,6 +2086,11 @@ export function App({ resumeSessionId }) {
|
|
|
1382
2086
|
void applyModel(index);
|
|
1383
2087
|
else if (o.kind === "think")
|
|
1384
2088
|
void applyThink(index);
|
|
2089
|
+
else if (o.kind === "screen") {
|
|
2090
|
+
const picked = screenChoices(shell)[index];
|
|
2091
|
+
if (picked)
|
|
2092
|
+
applyScreen(picked.mode);
|
|
2093
|
+
}
|
|
1385
2094
|
else if (o.kind === "shells") {
|
|
1386
2095
|
const sh = o.items[index];
|
|
1387
2096
|
if (sh && sh.status === "running" && session.current?.toolContext.backgroundShells?.kill(sh.id, "user")) {
|
|
@@ -1759,6 +2468,26 @@ export function App({ resumeSessionId }) {
|
|
|
1759
2468
|
setOverlay({ kind: "model" });
|
|
1760
2469
|
return;
|
|
1761
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
|
+
}
|
|
1762
2491
|
if (name === "/think") {
|
|
1763
2492
|
if (arg) {
|
|
1764
2493
|
const picked = resolveChoice(arg, thinkLevels(s.modelConfig.model), "reasoning level");
|
|
@@ -1977,7 +2706,7 @@ export function App({ resumeSessionId }) {
|
|
|
1977
2706
|
}
|
|
1978
2707
|
say(unknownCommandMessage(name));
|
|
1979
2708
|
}
|
|
1980
|
-
async function handleSubmit(value) {
|
|
2709
|
+
async function handleSubmit(value, opts = {}) {
|
|
1981
2710
|
const trimmed = value.trim();
|
|
1982
2711
|
if (trimmed.length === 0 || busy || !ready)
|
|
1983
2712
|
return;
|
|
@@ -2008,18 +2737,17 @@ export function App({ resumeSessionId }) {
|
|
|
2008
2737
|
// file path collapses to just its name — never the file dump. The model gets
|
|
2009
2738
|
// the full content via resolved <attached_file> blocks, and each attachment
|
|
2010
2739
|
// leaves one compact activity note (counts only).
|
|
2011
|
-
|
|
2012
|
-
// and that is a fact we ask the driver for — never a provider name in this file.
|
|
2013
|
-
const manifest = manifestForModel(s.modelConfig.model);
|
|
2014
|
-
const canSeeImages = manifest.acceptsImages?.(s.modelConfig.model) ?? false;
|
|
2015
|
-
const { modelText, displayText, notes, images } = await resolveAttachments(trimmed, s.cwd, canSeeImages);
|
|
2740
|
+
const { content, displayText, notes, images } = await prepareMessage(s, trimmed);
|
|
2016
2741
|
dispatch({ type: "user", text: displayText });
|
|
2017
2742
|
for (const n of notes)
|
|
2018
2743
|
note(n);
|
|
2019
|
-
// Restore any collapsed pastes into the model's copy only (the chat keeps chips).
|
|
2020
2744
|
s.transcript.push({
|
|
2021
2745
|
role: "user",
|
|
2022
|
-
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 } : {}),
|
|
2023
2751
|
...(images.length > 0 ? { images } : {}),
|
|
2024
2752
|
});
|
|
2025
2753
|
await streamRespond(s);
|
|
@@ -2087,7 +2815,7 @@ export function App({ resumeSessionId }) {
|
|
|
2087
2815
|
setScrollUp(0);
|
|
2088
2816
|
setHistory((h) => (h[h.length - 1] === text ? h : [...h, text]));
|
|
2089
2817
|
if (busy) {
|
|
2090
|
-
queueRef.current.push(text);
|
|
2818
|
+
queueRef.current.push(queueMessage(text, { interrupting: interrupting.current }));
|
|
2091
2819
|
setQueued([...queueRef.current]);
|
|
2092
2820
|
return;
|
|
2093
2821
|
}
|
|
@@ -2153,7 +2881,7 @@ export function App({ resumeSessionId }) {
|
|
|
2153
2881
|
label: sessionTitle(m),
|
|
2154
2882
|
description: `${timeAgo(m.updatedAt)} · ${m.entryCount} msg${m.entryCount === 1 ? "" : "s"}`,
|
|
2155
2883
|
}));
|
|
2156
|
-
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 }));
|
|
2157
2885
|
}
|
|
2158
2886
|
if (overlay.kind === "resumeMode") {
|
|
2159
2887
|
return (_jsx(Picker, { title: `Continue “${sessionTitle(overlay.meta)}” — how?`, items: RESUME_MODES, width: width, maxRows: maxRows, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
|
|
@@ -2218,6 +2946,16 @@ export function App({ resumeSessionId }) {
|
|
|
2218
2946
|
const items = levels.map((l) => ({ label: l.label + (l.label === curLabel ? " ✓" : ""), description: l.description }));
|
|
2219
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 }));
|
|
2220
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
|
+
}
|
|
2221
2959
|
// approval — a plan, a Sentinel action, a forbidden-path lift. It interrupts the user's
|
|
2222
2960
|
// work and the answer commits them to something, so it reads as a stop; it renders in
|
|
2223
2961
|
// the same fixed menu box as everything else, the answers always visible.
|
|
@@ -2251,12 +2989,23 @@ export function App({ resumeSessionId }) {
|
|
|
2251
2989
|
const committed = stateRef.current.committed;
|
|
2252
2990
|
const tail = stateRef.current.tail;
|
|
2253
2991
|
const allBlocks = [...committed, ...tail];
|
|
2254
|
-
//
|
|
2255
|
-
//
|
|
2256
|
-
//
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
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);
|
|
2260
3009
|
// The chat's HEIGHT is no longer computed here — Yoga is given the job instead
|
|
2261
3010
|
// (the viewport below is flexGrow:1 beside a flexShrink:0 footer), and this
|
|
2262
3011
|
// measurement is now only read for the SCROLL maths.
|
|
@@ -2281,7 +3030,17 @@ export function App({ resumeSessionId }) {
|
|
|
2281
3030
|
// border/title/hint. What's left becomes item rows, floored at a usable
|
|
2282
3031
|
// minimum and capped so a huge terminal doesn't show an ungainly wall.
|
|
2283
3032
|
const menuBudget = frameHeight - BANNER_ROWS - MIN_CHAT_ROWS - FOOTER_BASE_ROWS - MENU_CHROME_ROWS;
|
|
2284
|
-
|
|
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));
|
|
2285
3044
|
// Built here, after maxMenuItems, so a picker's contents respect the same row budget as
|
|
2286
3045
|
// the command menu and can never grow the footer past the screen. EVERY interactive
|
|
2287
3046
|
// surface — the pickers, the key manager, and the approval prompt — is content-only and
|
|
@@ -2293,7 +3052,34 @@ export function App({ resumeSessionId }) {
|
|
|
2293
3052
|
// to `chatAnchor.ts` so the rule is unit-tested rather than eyeballed — see there
|
|
2294
3053
|
// for why a short transcript now rests ON the input box instead of stranding
|
|
2295
3054
|
// itself at the top of the screen, and why that cannot disturb a scrolled frame.
|
|
2296
|
-
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;
|
|
2297
3083
|
// Only the blocks that can still be reached are worth laying out. Yoga lays
|
|
2298
3084
|
// out every child on every render — including one caused by a keystroke — so
|
|
2299
3085
|
// an unbounded transcript makes typing slower the longer you have been
|
|
@@ -2305,9 +3091,36 @@ export function App({ resumeSessionId }) {
|
|
|
2305
3091
|
// above, and the whole scroll mechanism — is bit-for-bit what it was when every
|
|
2306
3092
|
// block was laid out in full. See `virtualWindow.ts`.
|
|
2307
3093
|
if (heightsWidth.current !== width) {
|
|
2308
|
-
// Every recorded height was measured at a different width and is now
|
|
2309
|
-
//
|
|
2310
|
-
|
|
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
|
+
}
|
|
2311
3124
|
heightsWidth.current = width;
|
|
2312
3125
|
// Remember WHERE the reader was, as a proportion of the scrollable range, before
|
|
2313
3126
|
// the re-wrap changes what a line means. `scrollUp` is a line count, and a line is
|
|
@@ -2324,10 +3137,13 @@ export function App({ resumeSessionId }) {
|
|
|
2324
3137
|
let known = 0;
|
|
2325
3138
|
const knownHeights = [];
|
|
2326
3139
|
while (known < rendered.length) {
|
|
2327
|
-
const
|
|
2328
|
-
|
|
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)
|
|
2329
3145
|
break;
|
|
2330
|
-
knownHeights.push(
|
|
3146
|
+
knownHeights.push(entry.height);
|
|
2331
3147
|
known++;
|
|
2332
3148
|
}
|
|
2333
3149
|
const win = virtualWindow(knownHeights, -chatOffset, chatRows);
|
|
@@ -2342,28 +3158,232 @@ export function App({ resumeSessionId }) {
|
|
|
2342
3158
|
// Rows between the end of the window and the first unmeasured block. Rendering the
|
|
2343
3159
|
// unmeasured tail is not optional — it is how those blocks get measured at all.
|
|
2344
3160
|
const padMiddle = win.padBottom;
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
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] }));
|
|
2366
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()] })] }));
|
|
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__";
|
|
2367
3387
|
// The banner's own rows: the title line, the rule under it, and its bottom
|
|
2368
3388
|
// margin. Subtracted from the frame so the chat gets exactly what's left —
|
|
2369
3389
|
// see the layout comment at the render site.
|
|
@@ -2375,6 +3395,9 @@ const MIN_CHAT_ROWS = 3;
|
|
|
2375
3395
|
const FOOTER_BASE_ROWS = 7;
|
|
2376
3396
|
/** The palette's own chrome: title, the "Tab completes" hint, top+bottom border. */
|
|
2377
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;
|
|
2378
3401
|
/** Lines PageUp/PageDown move per press. */
|
|
2379
3402
|
const PAGE_LINES = 10;
|
|
2380
3403
|
/** Lines one wheel notch moves. Three is the usual terminal step, and a flick
|
|
@@ -2383,6 +3406,15 @@ const WHEEL_LINES = 3;
|
|
|
2383
3406
|
/** How much of the transcript stays scrollable. Every rendered block is laid out
|
|
2384
3407
|
* on every render, so this bounds what typing costs in a long conversation. */
|
|
2385
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;
|
|
2386
3418
|
/**
|
|
2387
3419
|
* The loom shuttle that runs beside the name while a turn is working.
|
|
2388
3420
|
*
|
|
@@ -2466,14 +3498,42 @@ export function Banner({ width, mode, modelConfig, busy }) {
|
|
|
2466
3498
|
* whatever the header/footer don't use), so a stale row count leaves dead
|
|
2467
3499
|
* space at the bottom instead of the frame reaching the terminal's actual edge.
|
|
2468
3500
|
*/
|
|
2469
|
-
|
|
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) {
|
|
2470
3521
|
const { stdout } = useStdout();
|
|
2471
|
-
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;
|
|
2472
3527
|
useEffect(() => {
|
|
2473
3528
|
if (!stdout)
|
|
2474
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.
|
|
2475
3533
|
const read = () => setSize((prev) => {
|
|
2476
|
-
|
|
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);
|
|
2477
3537
|
return next.columns === prev.columns && next.rows === prev.rows ? prev : next;
|
|
2478
3538
|
});
|
|
2479
3539
|
// The 'resize' event is NOT reliable on native Windows consoles — Node has a
|
|
@@ -2484,13 +3544,37 @@ function useTerminalSize() {
|
|
|
2484
3544
|
// integer reads, ~4x/sec) and it's the standard workaround for that exact gap,
|
|
2485
3545
|
// not a hack: it's what a resize event is supposed to give us, gotten a
|
|
2486
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.
|
|
2487
3564
|
let debounce;
|
|
2488
3565
|
const onResize = () => {
|
|
2489
3566
|
clearTimeout(debounce);
|
|
2490
|
-
|
|
3567
|
+
if (!deferRef.current) {
|
|
3568
|
+
read();
|
|
3569
|
+
return;
|
|
3570
|
+
}
|
|
3571
|
+
debounce = setTimeout(read, RESIZE_SETTLE_MS);
|
|
2491
3572
|
};
|
|
2492
3573
|
stdout.on("resize", onResize);
|
|
2493
|
-
|
|
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);
|
|
2494
3578
|
// The size read at THIS exact instant can be stale too: entering alt-screen
|
|
2495
3579
|
// (a raw escape code written before Ink even mounts, see altScreen.ts) makes
|
|
2496
3580
|
// the terminal reconfigure its buffer, and querying dimensions mid-reconfigure
|
|
@@ -2673,14 +3757,6 @@ function BackgroundBar({ shells }) {
|
|
|
2673
3757
|
// mode/model/thinking readout already lives in the header, so this slot is
|
|
2674
3758
|
// free for the shift-tab hint (nothing else states it anymore) and a few of
|
|
2675
3759
|
// the less-obvious commands.
|
|
2676
|
-
const TIPS = [
|
|
2677
|
-
"shift+tab cycles Lightning / Architect / Sentinel",
|
|
2678
|
-
"/help lists every command",
|
|
2679
|
-
"/model switches which model answers, /think sets its reasoning level",
|
|
2680
|
-
"@ mentions a file to attach it",
|
|
2681
|
-
"esc interrupts a running turn",
|
|
2682
|
-
"/undo restores the last checkpoint",
|
|
2683
|
-
];
|
|
2684
3760
|
/**
|
|
2685
3761
|
* Messages typed while Mindweave is working, waiting to be sent when the turn ends.
|
|
2686
3762
|
*
|
|
@@ -2697,7 +3773,7 @@ function QueuedBar({ queued }) {
|
|
|
2697
3773
|
if (queued.length === 0)
|
|
2698
3774
|
return null;
|
|
2699
3775
|
const { rows, hidden } = visibleQueue(queued);
|
|
2700
|
-
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"}` })] }));
|
|
2701
3777
|
}
|
|
2702
3778
|
/**
|
|
2703
3779
|
* Whether some OTHER installed provider has a key, so `/provider` is worth
|