mindweave 2.3.1 → 2.4.1
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 +1201 -108
- 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/keyManager.js +12 -2
- package/dist/cli/keyManager.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/pickerOrder.js +49 -0
- package/dist/cli/pickerOrder.js.map +1 -0
- 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/deepseek/manifest.js +54 -56
- package/dist/drivers/deepseek/manifest.js.map +1 -1
- package/dist/drivers/openai/manifest.js +4 -1
- package/dist/drivers/openai/manifest.js.map +1 -1
- package/dist/drivers/registry.js +7 -1
- package/dist/drivers/registry.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";
|
|
@@ -46,8 +46,12 @@ import { projectDir } from "../memory/store.js";
|
|
|
46
46
|
import { parseUndoArg, undoNotice } from "../tools/checkpoints.js";
|
|
47
47
|
import { DEFAULT_MODEL_CONFIG, thinkLevels, thinkLabel, modelLabel, modelsOfProvider, providerOf, usableFallback, needsKeySetup, withModel, saveModelConfig, refreshModels } from "../dynamo/model.js";
|
|
48
48
|
import { allProviders, manifestForModel, modelsOf } from "../drivers/registry.js";
|
|
49
|
+
import { orderProviders, orderModels } from "./pickerOrder.js";
|
|
49
50
|
import { accessRefusal } from "../drivers/providerError.js";
|
|
50
51
|
import { resolveAttachments, stripAttachments } from "./attachments.js";
|
|
52
|
+
import { collapsePastes, wrapPastedText } from "../memory/pastedText.js";
|
|
53
|
+
import { createDropHandles, expandHandles } from "./dropHandles.js";
|
|
54
|
+
import { TIPS, TipLine, nextTip, randomTipIndex } from "./components/TipLine.js";
|
|
51
55
|
import { completePath } from "./pathComplete.js";
|
|
52
56
|
import { formatHelp } from "./help.js";
|
|
53
57
|
import { hasApiKey, saveApiKey, removeApiKey, useApiKey, globalEnvPath, reloadConfig } from "./bootstrap.js";
|
|
@@ -60,15 +64,26 @@ import { ApprovalBox } from "./components/ApprovalBox.js";
|
|
|
60
64
|
import { BlockView } from "./components/BlockView.js";
|
|
61
65
|
import { initialState, reduce, trimNarration } from "./transcript.js";
|
|
62
66
|
import { isTight } from "./blockSpacing.js";
|
|
67
|
+
import { parseScreenArg, screenChoices, screenNotice, startupMode } from "./screenMode.js";
|
|
68
|
+
import { applyScreenMode } from "./screenShell.js";
|
|
69
|
+
import { saveScreenMode } from "./screenStore.js";
|
|
70
|
+
import { needsMeasure, pruneHeights } from "./blockHeights.js";
|
|
63
71
|
import { BASE_COMMANDS } from "./commands.js";
|
|
64
72
|
import { manualCommand, refusalReason } from "./selfUpdate.js";
|
|
65
73
|
import { currentInstall, requestRestart, runUpdate } from "./updateRunner.js";
|
|
66
|
-
import { enableMouse, readWheel } from "./mouse.js";
|
|
74
|
+
import { enableMouse, readMouse, readWheel } from "./mouse.js";
|
|
75
|
+
import { applySelection, ctrlCShouldCopy, isEmpty, selectionText } from "./selection.js";
|
|
76
|
+
import { latestScreen, repaintOverlay, setFrameOverlay } from "./framebuffer/overlay.js";
|
|
77
|
+
import { copyToClipboard } from "./clipboard.js";
|
|
67
78
|
import { chatLayout, reflowScroll } from "./chatAnchor.js";
|
|
79
|
+
import { growFill, INLINE_LIVE_RESERVE, NO_FILL } from "./startupFill.js";
|
|
80
|
+
import { setRowsBelowCaret } from "./exitCursor.js";
|
|
81
|
+
import { caretCell } from "./caretPark.js";
|
|
82
|
+
import { countNewReplies, hitsPill, pillBounds, scrollPill } from "./scrollPill.js";
|
|
68
83
|
import { virtualWindow } from "./virtualWindow.js";
|
|
69
84
|
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";
|
|
85
|
+
import { isGroupMember, groupSettled, planGroupReveal, planStandaloneReveal, resultQueued, STANDALONE_HOLD_MS } from "./groupReveal.js";
|
|
86
|
+
import { drain as drainQueue, popAll as popAllQueued, queueMessage, takeSteerable, visibleQueue } from "./messageQueue.js";
|
|
72
87
|
import { routeCommand, parseCommandLine, unknownCommandMessage } from "./commandRoute.js";
|
|
73
88
|
import { resolveChoice } from "./commandArgs.js";
|
|
74
89
|
import { carryAcrossFreshSession } from "./sessionCarry.js";
|
|
@@ -82,6 +97,9 @@ import { mapPromptArguments, promptCommand, promptUsage } from "../mcp/prompts.j
|
|
|
82
97
|
import { DEFAULT_MODE, modeById, modeFromFlags, nextMode } from "./modes.js";
|
|
83
98
|
import { ApprovalChannel } from "./approvalChannel.js";
|
|
84
99
|
const MINDWEAVE_DOCS_URL = "https://mindweave.dev";
|
|
100
|
+
/** How long each hint under the input box stays up. Long enough to read twice without
|
|
101
|
+
* hurrying, short enough that a session sees the whole set rather than one of them. */
|
|
102
|
+
const TIP_ROTATE_MS = 12_000;
|
|
85
103
|
/** Commands whose whole job is to open a surface in the box under the input. Written out
|
|
86
104
|
* in full, because only a bare invocation opens anything: given an argument each of these
|
|
87
105
|
* acts directly and there is no surface to hold the frame for. */
|
|
@@ -106,13 +124,26 @@ function missingKeyFor(model) {
|
|
|
106
124
|
return null;
|
|
107
125
|
return { envVar: provider.apiKeyEnv, label: provider.label, keysUrl: provider.keysUrl };
|
|
108
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* The providers `/provider` lists, in display order: the default first, then the ones
|
|
129
|
+
* you have a key for, then the rest, each group alphabetical. Called by the render, the
|
|
130
|
+
* selection handler and the initial-cursor lookup, so all three index the same order —
|
|
131
|
+
* see pickerOrder.
|
|
132
|
+
*/
|
|
133
|
+
function orderedProviderList() {
|
|
134
|
+
return orderProviders(allProviders(), (p) => hasApiKey(p.apiKeyEnv), providerOf(DEFAULT_MODEL_CONFIG.model).id);
|
|
135
|
+
}
|
|
136
|
+
/** One provider's models in display order: its default first, the rest alphabetical. */
|
|
137
|
+
function orderedModelList(model) {
|
|
138
|
+
return orderModels(modelsOfProvider(model));
|
|
139
|
+
}
|
|
109
140
|
// After you pick a session in /continue, the three ways to resume it.
|
|
110
141
|
const RESUME_MODES = [
|
|
111
142
|
{ label: "Compact & continue", description: "summarize the old chat first so it won't eat your context, then pick up where you left off" },
|
|
112
143
|
{ label: "Continue as-is", description: "resume the full conversation unchanged" },
|
|
113
144
|
{ label: "Fresh start", description: "leave it and start a new empty session here instead" },
|
|
114
145
|
];
|
|
115
|
-
export function App({ resumeSessionId }) {
|
|
146
|
+
export function App({ resumeSessionId, initialScreen }) {
|
|
116
147
|
// The transcript state machine lives in a ref and is advanced by the reducer as
|
|
117
148
|
// the stream arrives; `render` forces a paint. A ref (not useState) so the async
|
|
118
149
|
// streaming loop always reads/writes the latest state without stale closures.
|
|
@@ -151,12 +182,68 @@ export function App({ resumeSessionId }) {
|
|
|
151
182
|
// flag the engine actually acts on (set by applyMode / attachApproval).
|
|
152
183
|
const [mode, setMode] = useState(DEFAULT_MODE);
|
|
153
184
|
const modeRef = useRef(DEFAULT_MODE);
|
|
154
|
-
//
|
|
155
|
-
|
|
185
|
+
// The hint under the input box. It ADVANCES (see TipLine): picking one at startup and
|
|
186
|
+
// holding it meant a whole session showed a single hint out of the set, so the rest were
|
|
187
|
+
// written and never read. Starts somewhere random so consecutive launches differ.
|
|
188
|
+
// The drag in progress, or the one just finished and still highlighted. A REF and not
|
|
189
|
+
// state: it is painted by the framebuffer overlay rather than by React, so changing it
|
|
190
|
+
// must not cost a render (see the pointer effect).
|
|
191
|
+
const selection = useRef(null);
|
|
192
|
+
/**
|
|
193
|
+
* Drop the highlight, if there is one, and stop tinting frames.
|
|
194
|
+
*
|
|
195
|
+
* The overlay is installed only for as long as a selection exists, which matters
|
|
196
|
+
* because the renderer keeps a spare copy of every frame while one is installed (so a
|
|
197
|
+
* drag can be re-tinted without a re-render). That copy is worth its cost during a drag
|
|
198
|
+
* and is pure waste the rest of the time, which is nearly all of it. Repaint FIRST,
|
|
199
|
+
* then uninstall: the repaint is what takes the highlight off the screen.
|
|
200
|
+
*/
|
|
201
|
+
const clearSelection = useCallback(() => {
|
|
202
|
+
if (!selection.current)
|
|
203
|
+
return;
|
|
204
|
+
selection.current = null;
|
|
205
|
+
repaintOverlay();
|
|
206
|
+
setFrameOverlay(null);
|
|
207
|
+
}, []);
|
|
208
|
+
// PromptInput installs the handler that turns a click into a caret position; only it
|
|
209
|
+
// knows what its rows currently hold. Null until the input is on screen.
|
|
210
|
+
const caretClick = useRef(null);
|
|
211
|
+
/** Offers a finished drag to the input as an editable range; false if it was not text
|
|
212
|
+
* the input owns. */
|
|
213
|
+
const textSelect = useRef(null);
|
|
214
|
+
const placeCaretAt = useCallback((x, y) => {
|
|
215
|
+
caretClick.current?.(x, y);
|
|
216
|
+
}, []);
|
|
217
|
+
const [tipIdx, setTipIdx] = useState(randomTipIndex);
|
|
218
|
+
// Slow on purpose. The line sits under the box the user is typing in, so it has to read
|
|
219
|
+
// as something that changed while they were not looking, never as movement competing
|
|
220
|
+
// for attention. One interval for the process, not one per render.
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
const timer = setInterval(() => setTipIdx((i) => nextTip(i)), TIP_ROTATE_MS);
|
|
223
|
+
return () => clearInterval(timer);
|
|
224
|
+
}, []);
|
|
156
225
|
// How far the transcript is scrolled back, in LINES from the bottom. Alt-screen
|
|
157
226
|
// has no terminal scrollback of its own (altScreen.ts), so this is ours to
|
|
158
227
|
// implement; 0 means pinned to the newest.
|
|
159
228
|
const [scrollUp, setScrollUp] = useState(0);
|
|
229
|
+
/**
|
|
230
|
+
* The newest block id at the moment the view left the bottom, or null while pinned.
|
|
231
|
+
*
|
|
232
|
+
* This is what "new since you scrolled away" is counted from. A REF rather than
|
|
233
|
+
* state, and that is the point: it is written in an effect and read during render,
|
|
234
|
+
* so recording it costs no re-render of its own. The count it feeds only changes
|
|
235
|
+
* when a block arrives — which is a render already.
|
|
236
|
+
*/
|
|
237
|
+
const scrollMark = useRef(null);
|
|
238
|
+
/**
|
|
239
|
+
* How far the transcript can actually travel, from the last frame.
|
|
240
|
+
*
|
|
241
|
+
* Only the render knows it — it needs the measured content and viewport heights —
|
|
242
|
+
* but the scroll handlers, which run between frames, are what have to respect it.
|
|
243
|
+
* A ref is the one thing both can reach without the handlers being rebuilt on every
|
|
244
|
+
* height change.
|
|
245
|
+
*/
|
|
246
|
+
const maxScrollRef = useRef(0);
|
|
160
247
|
// The transcript's real rendered height, from measureElement — never estimated.
|
|
161
248
|
const contentRef = useRef(null);
|
|
162
249
|
const [contentHeight, setContentHeight] = useState(0);
|
|
@@ -168,18 +255,25 @@ export function App({ resumeSessionId }) {
|
|
|
168
255
|
// `virtualWindow.ts` for why that is the whole performance story, and why exact
|
|
169
256
|
// is the word that matters.
|
|
170
257
|
//
|
|
171
|
-
//
|
|
258
|
+
// Each entry keeps the BLOCK OBJECT it was measured from, and a lookup only counts
|
|
259
|
+
// when that object is still the current one. That is load-bearing rather than
|
|
172
260
|
// stylistic: the transcript reducer returns a NEW object whenever a block changes
|
|
173
261
|
// (streaming text growing, `live` flipping at turn end) and the same object when it
|
|
174
|
-
// does not. So a changed block simply has no
|
|
262
|
+
// does not. So a changed block simply has no usable height, is rendered in full, and
|
|
175
263
|
// 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
|
-
|
|
264
|
+
// a rule that could be forgotten for some future block type.
|
|
265
|
+
//
|
|
266
|
+
// A Map keyed by id rather than a WeakMap keyed by the object, for one reason: a
|
|
267
|
+
// WeakMap cannot be iterated, and a resize needs to walk every height to rescale it
|
|
268
|
+
// (see the width-change block below). The identity check gives the same invalidation
|
|
269
|
+
// a WeakMap gave for free; `pruneHeights` gives the same bounded memory.
|
|
270
|
+
/** `scaled` marks a height that was RESCALED by a width change rather than measured:
|
|
271
|
+
* good enough to size a spacer with, and still owed a real measurement. See the
|
|
272
|
+
* width-change branch in the render. */
|
|
273
|
+
const blockHeights = useRef(new Map());
|
|
179
274
|
// Nodes captured this render, waiting to be measured once Yoga has laid them out.
|
|
180
275
|
const toMeasure = useRef(new Map());
|
|
181
|
-
//
|
|
182
|
-
// the whole table away rather than scrolling against stale numbers.
|
|
276
|
+
// The width every cached height was measured at. A height is only true for one width.
|
|
183
277
|
const heightsWidth = useRef(0);
|
|
184
278
|
// Bumped when a measurement lands, purely to re-render so the new height can be
|
|
185
279
|
// used. Never read.
|
|
@@ -192,6 +286,14 @@ export function App({ resumeSessionId }) {
|
|
|
192
286
|
// the terminal, which corrupts the whole frame if that's the row that tips
|
|
193
287
|
// outputHeight to stdout.rows. Measured, this can't drift.
|
|
194
288
|
const footerRef = useRef(null);
|
|
289
|
+
/** The inline shell's whole live region, measured so the exit path knows how far the
|
|
290
|
+
* caret sits above the last row drawn. See exitCursor.ts. */
|
|
291
|
+
const liveRef = useRef(null);
|
|
292
|
+
/** The chip's row WITHIN the live region, from the layout, or null when it is not up. */
|
|
293
|
+
const pillRow = useRef(null);
|
|
294
|
+
/** The chip's cells in SCREEN coordinates, for the pointer handler. Null when there is
|
|
295
|
+
* nothing to click. Published by the render, read between frames. */
|
|
296
|
+
const pillHit = useRef(null);
|
|
195
297
|
const [footerHeight, setFooterHeight] = useState(0);
|
|
196
298
|
// The chat viewport's REAL height. Yoga decides it now (flexGrow beside a
|
|
197
299
|
// flexShrink:0 footer); this is read back purely so the scroll maths knows how
|
|
@@ -263,10 +365,137 @@ export function App({ resumeSessionId }) {
|
|
|
263
365
|
const needsKey = setupOpen || keysOpen;
|
|
264
366
|
// Sent-message history, oldest-first — walked with ↑/↓ in the input.
|
|
265
367
|
const [history, setHistory] = useState([]);
|
|
266
|
-
//
|
|
267
|
-
//
|
|
368
|
+
// Which shell the app is wearing. See `screenMode.ts`; `/screen` switches it.
|
|
369
|
+
//
|
|
370
|
+
// State rather than a ref, because the render branches on it. The terminal side of the
|
|
371
|
+
// switch — alternate screen, mouse, framebuffer — is applied by the effect below, not
|
|
372
|
+
// here, so the escape codes never go out during a render.
|
|
373
|
+
const [shell, setShell] = useState(() => initialScreen ?? startupMode());
|
|
374
|
+
/**
|
|
375
|
+
* Reading mode: the inline shell, scrolling with the prompt PINNED.
|
|
376
|
+
*
|
|
377
|
+
* The inline shell prints into the terminal's scrollback and the terminal owns the
|
|
378
|
+
* wheel, so looking back at anything carries the prompt off the top of the screen
|
|
379
|
+
* with everything else. That is how a shell prompt behaves and it is what the shell
|
|
380
|
+
* is for — right up until you want to read the middle of a long answer and reply to
|
|
381
|
+
* it, which is most of the time.
|
|
382
|
+
*
|
|
383
|
+
* A pinned prompt is normally something only a full-screen layout offers, because
|
|
384
|
+
* pinning means owning the screen. Offering it here without owning the screen is what
|
|
385
|
+
* this mode is, and it is built to cost nothing while it is not in use.
|
|
386
|
+
*
|
|
387
|
+
* While reading, the app renders a frame of its own into the live region and scrolls
|
|
388
|
+
* INSIDE it — the same viewport, offset and measurement the full-screen shell uses,
|
|
389
|
+
* with the footer pinned under it. Leaving it hands the terminal back.
|
|
390
|
+
*
|
|
391
|
+
* DERIVED from `scrollUp`, not its own state, and that is what fixes the flicker an
|
|
392
|
+
* earlier version had. That version tracked reading separately and opened it with
|
|
393
|
+
* `setReading(true)` followed by `setScrollUp(...)` — two calls, and Ink runs React
|
|
394
|
+
* in LegacyRoot mode, where state updates outside a React event are NOT batched. Each
|
|
395
|
+
* call flushed its own synchronous render: one frame painted with reading true and
|
|
396
|
+
* `scrollUp` still at its old value (0, on the way in — the "at rest" shape), and the
|
|
397
|
+
* very next painted the real scrolled position. Two different frames for one
|
|
398
|
+
* keystroke is a flicker by definition, and the same shape hit on the way out — a
|
|
399
|
+
* separate effect watched for `scrollUp` reaching 0 and called `setReading(false)` a
|
|
400
|
+
* render late, so the screen showed the framed view sitting at the bottom for one
|
|
401
|
+
* frame before dropping to the tail view.
|
|
402
|
+
*
|
|
403
|
+
* `scrollUp > 0` means the same thing `reading` did, computed in the SAME render as
|
|
404
|
+
* the scroll position that decides it, in the same commit. Entering and leaving both
|
|
405
|
+
* become the ordinary case of one state value changing once — no closing effect, no
|
|
406
|
+
* second render, nothing for the terminal to paint twice.
|
|
407
|
+
*/
|
|
408
|
+
const reading = shell === "inline" && scrollUp > 0;
|
|
409
|
+
// Where the reprint starts, and how many blank rows go above it. Both are decided ONCE
|
|
410
|
+
// when the inline shell is entered and then held: <Static> prints its items a single
|
|
411
|
+
// time, so anything that changed between renders would either never be printed or be
|
|
412
|
+
// printed twice.
|
|
413
|
+
const reprintFrom = useRef(0);
|
|
414
|
+
const startFill = useRef(0);
|
|
415
|
+
// Bumped to remount <Static> when the inline shell is entered. See below.
|
|
416
|
+
const [staticEpoch, setStaticEpoch] = useState(0);
|
|
417
|
+
/**
|
|
418
|
+
* A second remount counter, bumped DURING RENDER when the reading view closes.
|
|
419
|
+
*
|
|
420
|
+
* Separate from `staticEpoch` because of WHEN it changes, not what it means. The shell
|
|
421
|
+
* switch can afford an effect; closing the reading view cannot — see the block that
|
|
422
|
+
* writes this, next to the inline return.
|
|
423
|
+
*/
|
|
424
|
+
const closeEpoch = useRef(0);
|
|
425
|
+
/** The inline startup fill and the terminal height it was sized for. Declared here,
|
|
426
|
+
* above the effects that also seat the fill, so all of them share one basis and the
|
|
427
|
+
* render-phase grow does not re-fire on a switch that already sized it. See
|
|
428
|
+
* startupFill.ts. */
|
|
429
|
+
const fillState = useRef(NO_FILL);
|
|
430
|
+
/** Whether the inline reading view was open on the previous render — declared here,
|
|
431
|
+
* above the first-run gates, because a hook below them changes the hook count when a
|
|
432
|
+
* gate closes and React crashes the app. Its render-phase logic stays near the inline
|
|
433
|
+
* return. */
|
|
434
|
+
const wasReadingInline = useRef(false);
|
|
435
|
+
/** How far <Static> was allowed to reach while the reading viewport is open, or null
|
|
436
|
+
* when closed. Declared above the gates for the same reason as wasReadingInline. */
|
|
437
|
+
const frozenStatic = useRef(null);
|
|
438
|
+
/**
|
|
439
|
+
* Whether a `<Static>` remount should reprint the one-time header.
|
|
440
|
+
*
|
|
441
|
+
* True for a remount that is replacing a screen the header is genuinely absent from —
|
|
442
|
+
* arriving from the full-screen shell, whose alternate buffer discarded it. False for
|
|
443
|
+
* one that is only refilling rows below a header still sitting in scrollback, where
|
|
444
|
+
* printing it again would put a second banner in the middle of the conversation.
|
|
445
|
+
*/
|
|
446
|
+
const showHeader = useRef(true);
|
|
447
|
+
const shellBefore = useRef(null);
|
|
448
|
+
useEffect(() => {
|
|
449
|
+
applyScreenMode(shell);
|
|
450
|
+
// Arriving in the inline shell from the other one, the conversation so far has to be
|
|
451
|
+
// REPRINTED, and nothing else will do it.
|
|
452
|
+
//
|
|
453
|
+
// Ink's <Static> keeps a count of how many items it has already emitted and renders
|
|
454
|
+
// only `items.slice(index)` — printed once is its whole contract. Every one of those
|
|
455
|
+
// items was written into the ALTERNATE screen buffer, which leaving just discarded.
|
|
456
|
+
// So the terminal came back to the primary buffer holding whatever was there before
|
|
457
|
+
// the session started, and the transcript existed only in a counter's memory: no
|
|
458
|
+
// banner, no history, and nothing to scroll back to.
|
|
459
|
+
//
|
|
460
|
+
// A new key remounts it, which resets that counter to zero and prints the whole list
|
|
461
|
+
// into the buffer the user is actually looking at. Only on the transition, never on
|
|
462
|
+
// first mount — there, <Static> has printed nothing yet and remounting would emit
|
|
463
|
+
// every block a second time.
|
|
464
|
+
if (shell === "inline" && (shellBefore.current === null || shellBefore.current !== shell)) {
|
|
465
|
+
// Only the RECENT conversation is reprinted, not the whole session.
|
|
466
|
+
//
|
|
467
|
+
// Everything printed while fullscreen went into the alternate screen buffer and is
|
|
468
|
+
// gone whatever we do; reprinting all of it costs about 1.7ms a block, measured, so
|
|
469
|
+
// a long session spent a third of a second on a blank screen printing scrollback
|
|
470
|
+
// nobody asked to see. A couple of screens is all that can be looked at anyway.
|
|
471
|
+
reprintFrom.current = Math.max(0, committed.length - INLINE_REPRINT_BLOCKS);
|
|
472
|
+
// Blank rows so the conversation lands at the BOTTOM of the screen rather than the
|
|
473
|
+
// top. A terminal prints from wherever the cursor is, which after leaving the
|
|
474
|
+
// alternate screen is wherever the shell left it — usually near the top, with the
|
|
475
|
+
// prompt then floating in the middle of an empty window. These push it down. They
|
|
476
|
+
// are printed ONCE, into scrollback, so they cost nothing after the first screen
|
|
477
|
+
// and disappear the moment there is enough conversation to fill it.
|
|
478
|
+
startFill.current = Math.max(0, rows - INLINE_LIVE_RESERVE);
|
|
479
|
+
fillState.current = { fill: startFill.current, basis: rows };
|
|
480
|
+
// Keep the render-phase grow in step, so it does not treat this as a fresh void.
|
|
481
|
+
fillState.current = { fill: startFill.current, basis: rows };
|
|
482
|
+
// This reprint IS replacing a discarded screen, so it owns the header.
|
|
483
|
+
showHeader.current = true;
|
|
484
|
+
}
|
|
485
|
+
if (shellBefore.current !== null && shellBefore.current !== shell && shell === "inline") {
|
|
486
|
+
setStaticEpoch((n) => n + 1);
|
|
487
|
+
}
|
|
488
|
+
shellBefore.current = shell;
|
|
489
|
+
}, [shell]);
|
|
490
|
+
// Messages typed while Mindweave is working — the input stays live. Ordinary prose is
|
|
491
|
+
// handed to the RUNNING turn at its next step boundary; a slash command, and anything
|
|
492
|
+
// typed after Esc, waits for the turn to be over. See messageQueue.ts.
|
|
268
493
|
const queueRef = useRef([]);
|
|
269
494
|
const [queued, setQueued] = useState([]);
|
|
495
|
+
// Esc has been pressed and the turn is still winding down. Anything typed in that gap
|
|
496
|
+
// belongs to the NEXT turn: steering it would carry out a correction inside the very
|
|
497
|
+
// turn the user just stopped. Cleared when the next turn starts.
|
|
498
|
+
const interrupting = useRef(false);
|
|
270
499
|
// An interactive overlay (session picker, model/think chooser, or an approval
|
|
271
500
|
// prompt). When set, it owns the keyboard and the input box is hidden.
|
|
272
501
|
const [overlay, setOverlay] = useState(null);
|
|
@@ -343,7 +572,7 @@ export function App({ resumeSessionId }) {
|
|
|
343
572
|
// the user never typed — seen live as "> That was 3 sentences between tool
|
|
344
573
|
// calls…" sitting in their own chat history.
|
|
345
574
|
if (!e.synthetic)
|
|
346
|
-
dispatch({ type: "user", text: stripAttachments(e.content) });
|
|
575
|
+
dispatch({ type: "user", text: collapsePastes(stripAttachments(e.content)) });
|
|
347
576
|
}
|
|
348
577
|
else if (e.role === "summary") {
|
|
349
578
|
dispatch({ type: "note", text: "— resumed; earlier context summarized —" });
|
|
@@ -388,6 +617,7 @@ export function App({ resumeSessionId }) {
|
|
|
388
617
|
...(e.quiet ? { quiet: true } : {}),
|
|
389
618
|
...(e.displayName ? { name: e.displayName } : {}),
|
|
390
619
|
...(e.displayKind ? { action: e.displayKind } : {}),
|
|
620
|
+
...(e.awaitsModel ? { awaitsModel: true } : {}),
|
|
391
621
|
});
|
|
392
622
|
}
|
|
393
623
|
}
|
|
@@ -494,10 +724,14 @@ export function App({ resumeSessionId }) {
|
|
|
494
724
|
let out = text;
|
|
495
725
|
for (const [chip, content] of pasteStore.current) {
|
|
496
726
|
if (out.includes(chip))
|
|
497
|
-
out = out.split(chip).join(content);
|
|
727
|
+
out = out.split(chip).join(wrapPastedText(content));
|
|
498
728
|
}
|
|
499
729
|
return out;
|
|
500
730
|
}
|
|
731
|
+
// The same trade for dropped files: the buffer holds `mwimg1`, this holds the path it
|
|
732
|
+
// stands for. Resolution against the session's cwd happens here so the store is keyed
|
|
733
|
+
// by one canonical form however the path was spelled when it landed.
|
|
734
|
+
const dropHandles = useRef(createDropHandles((p) => (isAbsolute(p) ? resolve(p) : resolve(session.current?.cwd ?? process.cwd(), p))));
|
|
501
735
|
// File-path completion for the input's `@mention` picker (primary root).
|
|
502
736
|
const pathComplete = useRef((prefix) => {
|
|
503
737
|
const s = session.current;
|
|
@@ -505,11 +739,40 @@ export function App({ resumeSessionId }) {
|
|
|
505
739
|
});
|
|
506
740
|
// Live terminal width — drives message wrapping (Static items capture it at
|
|
507
741
|
// commit time; the live input reflows on resize for free).
|
|
508
|
-
|
|
742
|
+
// The inline shell defers a resize until the drag settles; the full-screen one takes
|
|
743
|
+
// it immediately. See useTerminalSize for why the two differ.
|
|
744
|
+
const { columns: width, rows } = useTerminalSize(shell === "inline");
|
|
509
745
|
// Read live at render time as well as from the polled state above: mid-resize
|
|
510
746
|
// the state can lag the real terminal by a tick, and a frame one row too TALL
|
|
511
747
|
// is the failure that corrupts the screen (see the layout comment below).
|
|
512
748
|
const { stdout } = useStdout();
|
|
749
|
+
// A width change in the inline shell: reprint, rather than trust the erase.
|
|
750
|
+
//
|
|
751
|
+
// Ink redraws its live region by erasing the number of LINES it last wrote. After a
|
|
752
|
+
// resize that number is wrong — the same content wraps differently at the new width, and
|
|
753
|
+
// the terminal has reflowed what was already on screen — so it erases too few and leaves
|
|
754
|
+
// half of the old region behind: a second status line, a fragment of the input box's
|
|
755
|
+
// border, a ladder of them down a slow drag.
|
|
756
|
+
//
|
|
757
|
+
// Predicting the right number means predicting the post-resize wrapping of everything on
|
|
758
|
+
// screen, which is the layout itself. So it is not predicted. The region is printed
|
|
759
|
+
// again, below the mess, with the usual fill above it — the stale copy goes up into
|
|
760
|
+
// scrollback where it belongs and the screen comes back clean with the conversation at
|
|
761
|
+
// the bottom. A resize is rare enough to pay for that.
|
|
762
|
+
//
|
|
763
|
+
// Only on WIDTH. Height changes do not re-wrap anything, and reprinting on one would
|
|
764
|
+
// fire on every vertical drag for nothing.
|
|
765
|
+
const widthBefore = useRef(width);
|
|
766
|
+
useEffect(() => {
|
|
767
|
+
if (widthBefore.current === width)
|
|
768
|
+
return;
|
|
769
|
+
widthBefore.current = width;
|
|
770
|
+
if (shell !== "inline")
|
|
771
|
+
return;
|
|
772
|
+
reprintFrom.current = Math.max(0, committed.length - INLINE_REPRINT_BLOCKS);
|
|
773
|
+
startFill.current = Math.max(0, rows - INLINE_LIVE_RESERVE);
|
|
774
|
+
setStaticEpoch((n) => n + 1);
|
|
775
|
+
}, [width, shell]);
|
|
513
776
|
// One session for the whole conversation (a ref so it survives re-renders).
|
|
514
777
|
const session = useRef(null);
|
|
515
778
|
useEffect(() => {
|
|
@@ -586,7 +849,7 @@ export function App({ resumeSessionId }) {
|
|
|
586
849
|
return;
|
|
587
850
|
queueRef.current = next.rest;
|
|
588
851
|
setQueued(next.rest);
|
|
589
|
-
void handleSubmit(next.send);
|
|
852
|
+
void handleSubmit(next.send, { arrival: next.priority === "now" ? "interrupting" : undefined });
|
|
590
853
|
}, [busy, ready, needsKey, overlay]);
|
|
591
854
|
// ↑ or Esc takes the queue back into the input box, editable, and empties it. This
|
|
592
855
|
// is the ONLY way to change your mind about something already queued, so it has to
|
|
@@ -612,17 +875,55 @@ export function App({ resumeSessionId }) {
|
|
|
612
875
|
usageSamples.current = [];
|
|
613
876
|
meter.current = meterReset();
|
|
614
877
|
setTaskUsage(null); // clear the previous task's summary while this one runs
|
|
878
|
+
interrupting.current = false;
|
|
615
879
|
abortRef.current = new AbortController();
|
|
616
880
|
setBusy(true);
|
|
617
881
|
}
|
|
618
882
|
// Esc interrupts the current turn: it aborts the model call AND kills a running
|
|
619
883
|
// command — run_command listens to this same signal (see runShell), so a hung
|
|
620
884
|
// command (e.g. an installer waiting on a GUI) can no longer freeze the agent.
|
|
885
|
+
/**
|
|
886
|
+
* Ctrl+C quits, all the way.
|
|
887
|
+
*
|
|
888
|
+
* Ink no longer does anything with it (`exitOnCtrlC: false` in index.ts), because what
|
|
889
|
+
* it did was unmount and stop there: the process stayed up with the turn still running,
|
|
890
|
+
* and the two `exit` hooks that matter — restoring the terminal, and synchronously
|
|
891
|
+
* killing background shells — never ran, because nothing exited.
|
|
892
|
+
*
|
|
893
|
+
* `process.exit` is what runs them. 130 is the conventional code for a program ended by
|
|
894
|
+
* SIGINT, so a shell script wrapping this reads the interruption correctly.
|
|
895
|
+
*
|
|
896
|
+
* Esc remains the way to stop a TURN without leaving. This is the way to leave.
|
|
897
|
+
*
|
|
898
|
+
* EXCEPT while a selection is on screen. The app owns the mouse in the full-screen
|
|
899
|
+
* shell, so text is selected by dragging and Ctrl+C is the reflex to copy it — and
|
|
900
|
+
* quitting on that reflex, right after someone highlighted something to keep, loses
|
|
901
|
+
* both the selection and the session. So a Ctrl+C with a highlight up COPIES it (the
|
|
902
|
+
* drag already did on release; this re-copies so the keystroke is never a no-op) and
|
|
903
|
+
* takes the highlight down, and does not quit. With nothing selected it quits as
|
|
904
|
+
* before — so a second Ctrl+C, once the highlight is gone, still leaves.
|
|
905
|
+
*/
|
|
906
|
+
useInput((input, key) => {
|
|
907
|
+
if (!key.ctrl || input !== "c")
|
|
908
|
+
return;
|
|
909
|
+
const sel = selection.current;
|
|
910
|
+
if (ctrlCShouldCopy(sel)) {
|
|
911
|
+
const screen = latestScreen();
|
|
912
|
+
if (screen)
|
|
913
|
+
copyToClipboard(selectionText(screen, sel));
|
|
914
|
+
clearSelection();
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
abortRef.current?.abort();
|
|
918
|
+
process.exit(130);
|
|
919
|
+
}, { isActive: true });
|
|
621
920
|
// Only while working AND no overlay is open (an open Picker owns Esc for its own
|
|
622
921
|
// cancel). The input ignores Esc, so typing-while-busy is safe.
|
|
623
922
|
useInput((_input, key) => {
|
|
624
923
|
if (key.escape) {
|
|
625
924
|
abortRef.current?.abort();
|
|
925
|
+
// From here until the next turn starts, anything typed is for the NEXT turn.
|
|
926
|
+
interrupting.current = true;
|
|
626
927
|
// Anything still waiting to be asked is answered as declined. A queued approval
|
|
627
928
|
// has no overlay to press Esc on, so without this the tool holding it would wait
|
|
628
929
|
// for the rest of the session on a question the user has already stopped.
|
|
@@ -670,10 +971,49 @@ export function App({ resumeSessionId }) {
|
|
|
670
971
|
// the animation explains a movement the user did not make. A wheel notch is a direct
|
|
671
972
|
// manipulation, and direct manipulation must be 1:1
|
|
672
973
|
// with the input or it reads as lag, because it IS lag. Do not re-add it here.
|
|
974
|
+
//
|
|
975
|
+
// CLAMPED AT BOTH ENDS, and the top one is not cosmetic. `chatLayout` clamps for
|
|
976
|
+
// DISPLAY, so scrolling up past the first line looked like it had stopped while the
|
|
977
|
+
// counter kept climbing — and every one of those phantom lines then had to be
|
|
978
|
+
// scrolled back down before the view moved at all. A flick or two past the top bought
|
|
979
|
+
// a second of a wheel that did nothing, which reads as the app having frozen.
|
|
673
980
|
const scrollBy = useCallback((lines) => {
|
|
674
|
-
setScrollUp((s) => Math.max(0, s + lines));
|
|
981
|
+
setScrollUp((s) => Math.max(0, Math.min(maxScrollRef.current, s + lines)));
|
|
982
|
+
}, []);
|
|
983
|
+
/**
|
|
984
|
+
* Open the inline shell's reading view, moving by `lines` in the same gesture.
|
|
985
|
+
*
|
|
986
|
+
* ONE state change, `scrollUp` alone — `reading` is derived from it, so this cannot
|
|
987
|
+
* reintroduce the two-render flicker a separate `setReading(true)` used to cause.
|
|
988
|
+
*
|
|
989
|
+
* UNCLAMPED, and that half has to stay. `maxScrollRef` is published by the render,
|
|
990
|
+
* and until this frame exists there is no viewport, nothing measured, and the last
|
|
991
|
+
* value it holds is zero. Routed through `scrollBy`, the very first notch would
|
|
992
|
+
* therefore be clamped to nothing — `reading` would compute false, and the wheel
|
|
993
|
+
* would appear to do nothing at all.
|
|
994
|
+
*
|
|
995
|
+
* Overshooting is the safe direction and it is self-correcting: `chatLayout` clamps
|
|
996
|
+
* for display, so the frame shows the top rather than anything invalid, and the next
|
|
997
|
+
* notch goes through `scrollBy` with a real measurement and pulls the number back to
|
|
998
|
+
* what actually exists.
|
|
999
|
+
*/
|
|
1000
|
+
const openReading = useCallback((lines) => {
|
|
1001
|
+
setScrollUp((s) => Math.max(1, s + Math.abs(lines)));
|
|
675
1002
|
}, []);
|
|
676
1003
|
useInput((_input, key) => {
|
|
1004
|
+
// In the inline shell, scrolling back is a MODE, and any of these opens it.
|
|
1005
|
+
//
|
|
1006
|
+
// Nothing below can do anything until the app is drawing its own frame — the
|
|
1007
|
+
// inline shell has no viewport to offset — so the first press has to build one.
|
|
1008
|
+
// The scroll it was asking for then happens in the same keystroke, because a key
|
|
1009
|
+
// that only "gets ready" and moves nothing reads as a key that did nothing.
|
|
1010
|
+
const back = key.pageUp || (key.upArrow && key.shift);
|
|
1011
|
+
if (shell === "inline" && back && !reading) {
|
|
1012
|
+
// Same first-notch problem the wheel has: there is no viewport yet, so nothing
|
|
1013
|
+
// is measured and a clamped scroll would move nothing. See openReading.
|
|
1014
|
+
openReading(key.pageUp ? PAGE_LINES : 1);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
677
1017
|
// Shift+arrows as well as PageUp/PageDown: Windows consoles routinely eat
|
|
678
1018
|
// the paging keys before an app sees them, so there has to be a second way in.
|
|
679
1019
|
if (key.pageUp)
|
|
@@ -684,7 +1024,94 @@ export function App({ resumeSessionId }) {
|
|
|
684
1024
|
scrollBy(1);
|
|
685
1025
|
else if (key.downArrow && key.shift)
|
|
686
1026
|
scrollBy(-1);
|
|
1027
|
+
// Back to the newest in one keystroke, and back to the start of the conversation
|
|
1028
|
+
// in the other. Without these, the only way out of a long scroll was to scroll
|
|
1029
|
+
// the whole distance again by hand — and the chip that appears while scrolled
|
|
1030
|
+
// back (see scrollPill.ts) names ctrl+End, so this is the half that makes the
|
|
1031
|
+
// chip true. CTRL is what keeps them off the input: plain End and Home belong to
|
|
1032
|
+
// the caret, whether or not the input claims them yet.
|
|
1033
|
+
else if (key.end && key.ctrl)
|
|
1034
|
+
setScrollUp(0);
|
|
1035
|
+
else if (key.home && key.ctrl)
|
|
1036
|
+
setScrollUp(maxScrollRef.current);
|
|
687
1037
|
}, { isActive: ready && overlay === null });
|
|
1038
|
+
// Leaving the reading view when it reaches the bottom needs no effect of its own:
|
|
1039
|
+
// `reading` is `scrollUp > 0`, so landing on zero — the wheel, the keys, ctrl+End, or
|
|
1040
|
+
// a sent message snapping the view back before it delivers — closes it in the same
|
|
1041
|
+
// render that moved the scroll, not a render later. See the derivation above for why
|
|
1042
|
+
// a separate effect here was the other half of the flicker.
|
|
1043
|
+
/**
|
|
1044
|
+
* The wheel, for as long as the reading view is up.
|
|
1045
|
+
*
|
|
1046
|
+
* ON FOR THE WHOLE INLINE SESSION, not only while the reading view is up, and the
|
|
1047
|
+
* reason is that the wheel is how anyone actually scrolls.
|
|
1048
|
+
*
|
|
1049
|
+
* Reporting is what makes a wheel notch reach this process at all. Switched on only
|
|
1050
|
+
* once reading had already started, the gesture that starts reading could never be the
|
|
1051
|
+
* wheel — the first notch went to the terminal, which scrolled its own buffer and
|
|
1052
|
+
* carried the prompt off the top, which is the whole thing being fixed. There is no
|
|
1053
|
+
* way to watch for a wheel notch without taking the wheel.
|
|
1054
|
+
*
|
|
1055
|
+
* So the trade is made openly: while an inline session is running, the terminal's own
|
|
1056
|
+
* wheel scrolls nothing and this app scrolls instead. Its scrollbar still drags and
|
|
1057
|
+
* Shift still selects, both being the terminal's own doing, and everything printed is
|
|
1058
|
+
* still in the terminal's scrollback where it has always been.
|
|
1059
|
+
*
|
|
1060
|
+
* The full-screen shell is untouched here: it takes the mouse through
|
|
1061
|
+
* `applyScreenMode`, which is also what releases it on the way into this one — so this
|
|
1062
|
+
* effect runs after that release and is what puts it back for the inline shell.
|
|
1063
|
+
*/
|
|
1064
|
+
useEffect(() => {
|
|
1065
|
+
if (shell !== "inline")
|
|
1066
|
+
return;
|
|
1067
|
+
const off = enableMouse();
|
|
1068
|
+
return () => off();
|
|
1069
|
+
}, [shell]);
|
|
1070
|
+
// Leaving the reading view needs no reprint, and an earlier version of this that
|
|
1071
|
+
// forced one is what actually caused the reported flicker — a full transcript area
|
|
1072
|
+
// going black for a frame, footer untouched, right at the instant the view landed on
|
|
1073
|
+
// the bottom.
|
|
1074
|
+
//
|
|
1075
|
+
// The reasoning that led there was borrowed from the wrong case. Coming back from the
|
|
1076
|
+
// FULL-SCREEN shell genuinely needs a reprint: everything <Static> had printed went
|
|
1077
|
+
// into the ALTERNATE screen buffer, which leaving it discards outright — nothing of
|
|
1078
|
+
// it survives in the terminal the user is now looking at. The reading view never
|
|
1079
|
+
// leaves the primary buffer at all. Every line it ever showed was already sitting in
|
|
1080
|
+
// real scrollback the moment <Static> printed it, before reading even opened, and nothing
|
|
1081
|
+
// about opening or closing the reading frame touches that. Shrinking the live region
|
|
1082
|
+
// from the frame's height back down to the ordinary tail is exactly the same erase the
|
|
1083
|
+
// live region already goes through many times an ordinary conversation — a reply
|
|
1084
|
+
// finishing and its block draining into <Static> shrinks the tail the same way, with
|
|
1085
|
+
// no special handling, because Ink's own line-count bookkeeping is what makes an
|
|
1086
|
+
// ordinary shrink safe.
|
|
1087
|
+
//
|
|
1088
|
+
// What the reprint bought instead was a REMOUNT: a new `<Static>` key forces every
|
|
1089
|
+
// held item — up to `INLINE_REPRINT_BLOCKS` of them, full diffs, syntax highlighting
|
|
1090
|
+
// and all — to be laid out and printed again, all in the same instant the frame is
|
|
1091
|
+
// already shrinking. That is real, synchronous work sitting between the erase and the
|
|
1092
|
+
// redraw, for content the terminal already had. Removed rather than budgeted, since
|
|
1093
|
+
// there was never a hole here to fill.
|
|
1094
|
+
// Reading mode belongs to the inline shell alone: the full-screen one is always
|
|
1095
|
+
// drawing its own frame, so there is no mode to be in.
|
|
1096
|
+
const readingInline = shell === "inline" && reading;
|
|
1097
|
+
/**
|
|
1098
|
+
* Where "new since you scrolled away" counts from.
|
|
1099
|
+
*
|
|
1100
|
+
* Set on the frame the view leaves the bottom and cleared the moment it returns, so
|
|
1101
|
+
* a reader who scrolls back, reads, and comes back down starts the next scroll with
|
|
1102
|
+
* a clean count rather than one carried over from the last.
|
|
1103
|
+
*
|
|
1104
|
+
* Depends on `scrollUp` alone. The transcript's own id is read at effect time, which
|
|
1105
|
+
* is after the render that moved the view — the same frame, nothing appended in
|
|
1106
|
+
* between, so the mark is exactly the newest block the reader had seen.
|
|
1107
|
+
*
|
|
1108
|
+
*/
|
|
1109
|
+
useEffect(() => {
|
|
1110
|
+
if (scrollUp === 0)
|
|
1111
|
+
scrollMark.current = null;
|
|
1112
|
+
else if (scrollMark.current === null)
|
|
1113
|
+
scrollMark.current = stateRef.current.seq;
|
|
1114
|
+
}, [scrollUp]);
|
|
688
1115
|
// Measure the transcript's real rendered height after every render. Deliberately
|
|
689
1116
|
// has no dependency list: the height changes for reasons no dep could name — a
|
|
690
1117
|
// reply landing, a terminal resize re-wrapping every paragraph — and the guard
|
|
@@ -718,20 +1145,35 @@ export function App({ resumeSessionId }) {
|
|
|
718
1145
|
return;
|
|
719
1146
|
let learned = false;
|
|
720
1147
|
for (const [block, node] of toMeasure.current) {
|
|
721
|
-
|
|
1148
|
+
// A block that is still OPEN is deliberately not recorded.
|
|
1149
|
+
//
|
|
1150
|
+
// Its content changes on every delta, so the reducer hands back a new object each
|
|
1151
|
+
// time and the height taken a moment ago is already wrong. Recording it anyway
|
|
1152
|
+
// cost a measurement AND a re-render per delta — the state bump below — which on
|
|
1153
|
+
// a streaming reply is the hottest path in the app. An open block is always the
|
|
1154
|
+
// last one, so leaving it out of the table costs a single block laid out in full.
|
|
1155
|
+
if (!block.done)
|
|
1156
|
+
continue;
|
|
1157
|
+
// The same rule the ref callback used to queue it, so the two can never disagree
|
|
1158
|
+
// about what still owes a measurement. See blockHeights.needsMeasure.
|
|
1159
|
+
if (!needsMeasure(blockHeights.current.get(block.id), block))
|
|
722
1160
|
continue;
|
|
723
1161
|
const { height } = measureElement(node);
|
|
724
1162
|
// A height of 0 is not a measurement, it is a block that has not been laid out
|
|
725
1163
|
// yet. Recording it would collapse the block to nothing the moment it scrolled
|
|
726
1164
|
// off — the exact class of silent, permanent corruption this cache must not have.
|
|
727
1165
|
if (height > 0) {
|
|
728
|
-
blockHeights.current.set(block, height);
|
|
1166
|
+
blockHeights.current.set(block.id, { height, block });
|
|
729
1167
|
learned = true;
|
|
730
1168
|
}
|
|
731
1169
|
}
|
|
732
1170
|
toMeasure.current.clear();
|
|
733
|
-
|
|
1171
|
+
// Only when something was actually recorded, which is now once per block rather
|
|
1172
|
+
// than once per delta: a height that nothing can use is not worth a frame.
|
|
1173
|
+
if (learned) {
|
|
1174
|
+
pruneHeights(blockHeights.current);
|
|
734
1175
|
bumpHeights((t) => t + 1);
|
|
1176
|
+
}
|
|
735
1177
|
});
|
|
736
1178
|
// Same measurement, for the footer — see footerHeight above.
|
|
737
1179
|
useEffect(() => {
|
|
@@ -749,24 +1191,170 @@ export function App({ resumeSessionId }) {
|
|
|
749
1191
|
const { height } = measureElement(chatRef.current);
|
|
750
1192
|
setChatHeight((h) => (h === height ? h : height));
|
|
751
1193
|
});
|
|
752
|
-
|
|
753
|
-
|
|
1194
|
+
/**
|
|
1195
|
+
* How far the caret sits above the last row this app drew.
|
|
1196
|
+
*
|
|
1197
|
+
* Published for the exit path, which writes it as a cursor move so the shell that
|
|
1198
|
+
* takes the terminal back prints its prompt BELOW the conversation instead of on top
|
|
1199
|
+
* of it. See exitCursor.ts for what that fixes; the number has to be measured here
|
|
1200
|
+
* because only the layout knows it.
|
|
1201
|
+
*
|
|
1202
|
+
* No dependency list, like the measurements around it: what is under the caret changes
|
|
1203
|
+
* for reasons no dep could name — a wrapped input line, an opened palette, a picker, an
|
|
1204
|
+
* approval — and each one is a different distance.
|
|
1205
|
+
*
|
|
1206
|
+
* Zero for the full-screen shell, which needs no correction: it hands the terminal back
|
|
1207
|
+
* by leaving the alternate screen, and that restores the primary buffer's cursor too.
|
|
1208
|
+
*/
|
|
1209
|
+
useEffect(() => {
|
|
1210
|
+
if (shell !== "inline" || !liveRef.current) {
|
|
1211
|
+
setRowsBelowCaret(0);
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
const caret = caretCell();
|
|
1215
|
+
if (!caret) {
|
|
1216
|
+
setRowsBelowCaret(0);
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
const { height, y } = measureElement(liveRef.current);
|
|
1220
|
+
// `caretCell` reports the caret's row within the live region; `liveRef` measures that
|
|
1221
|
+
// region. The last row it drew is `height - 1`, so the gap is what remains below.
|
|
1222
|
+
setRowsBelowCaret(height - 1 - (caret.y - (y ?? 0)));
|
|
1223
|
+
});
|
|
1224
|
+
/**
|
|
1225
|
+
* The pointer: the wheel, and dragging to select.
|
|
1226
|
+
*
|
|
1227
|
+
* Read straight off stdin rather than through useInput, because a mouse report is not
|
|
1228
|
+
* a keypress and Ink's key parser has no notion of one.
|
|
1229
|
+
*
|
|
1230
|
+
* The selection lives in a REF and is painted by the framebuffer overlay, so a drag
|
|
1231
|
+
* never causes a React render. That is deliberate: the pointer moves a column at a
|
|
1232
|
+
* time, and re-rendering the whole app on each of those would be both wasteful and a
|
|
1233
|
+
* chance to reflow the screen under a user who is only trying to highlight a word.
|
|
1234
|
+
* `repaintOverlay` re-tints the frame already on screen instead.
|
|
1235
|
+
*/
|
|
1236
|
+
// Mouse reporting itself is switched on and off by — it belongs to
|
|
1237
|
+
// the shell, not to this component, because the inline shell must never have it on.
|
|
1238
|
+
// What is left here is the highlight, which has to come down when the app unmounts.
|
|
754
1239
|
useEffect(() => {
|
|
755
1240
|
if (!ready)
|
|
756
1241
|
return;
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1242
|
+
return () => setFrameOverlay(null);
|
|
1243
|
+
}, [ready]);
|
|
1244
|
+
/**
|
|
1245
|
+
* Everything the pointer does, read through `useInput`.
|
|
1246
|
+
*
|
|
1247
|
+
* NOT through a `data` listener on stdin, which is where this lived and why none of it
|
|
1248
|
+
* worked: Ink 7 pulls input by calling `read()` on a `readable` event, so it has taken
|
|
1249
|
+
* the bytes before a `data` handler is ever offered them. A listener attached that way
|
|
1250
|
+
* is not called at all — no error, no warning, simply nothing, which is the hardest
|
|
1251
|
+
* kind of wrong to see. Ink hands the same bytes here instead, with the ESC of a report
|
|
1252
|
+
* already eaten, which is why the parser in mouse.ts matches it optionally.
|
|
1253
|
+
*
|
|
1254
|
+
* One handler for the wheel, the drag and the keystroke that dismisses a highlight,
|
|
1255
|
+
* because they have to agree about what just happened: a mouse report arrives as
|
|
1256
|
+
* "input" too, and a separate handler that treated any input as a keystroke would clear
|
|
1257
|
+
* the selection on the very first drag event.
|
|
1258
|
+
*/
|
|
1259
|
+
useInput((input) => {
|
|
1260
|
+
const events = readMouse(input);
|
|
1261
|
+
const notches = readWheel(input);
|
|
1262
|
+
// ONE scroll for the whole flick, not one per notch.
|
|
1263
|
+
//
|
|
1264
|
+
// A single turn of the wheel arrives as several reports in one chunk (see
|
|
1265
|
+
// mouse.ts), and Ink runs React in LegacyRoot mode, so state updates from here are
|
|
1266
|
+
// NOT batched: every setState flushes its own synchronous render and its own full
|
|
1267
|
+
// terminal redraw. Scrolling a notch at a time therefore did three renders for one
|
|
1268
|
+
// flick, back to back, and the scroll lagged behind the hand turning the wheel.
|
|
1269
|
+
//
|
|
1270
|
+
// This is the same rule the input box already follows for keystrokes — one event
|
|
1271
|
+
// in, one render out — applied to the other thing that arrives in bursts.
|
|
1272
|
+
if (notches.length > 0) {
|
|
1273
|
+
// Content moves out from under a selection when the view scrolls, so the
|
|
1274
|
+
// highlight would be sitting on text that is no longer the text it copied.
|
|
1275
|
+
clearSelection();
|
|
1276
|
+
const lines = notches.reduce((n, dir) => n + (dir === "up" ? WHEEL_LINES : -WHEEL_LINES), 0);
|
|
1277
|
+
// The wheel is how anyone actually scrolls, so in the inline shell it is what
|
|
1278
|
+
// opens the reading view. Turning it UP is the gesture: the reader is going back
|
|
1279
|
+
// through the conversation and wants the prompt to stay where they can type into
|
|
1280
|
+
// it. Turning it down while already at the bottom is not — there is nothing
|
|
1281
|
+
// below to go to, and building a frame for it would mean the view snapping open
|
|
1282
|
+
// on a flick in the direction of the newest line.
|
|
1283
|
+
if (shell === "inline" && !reading && lines > 0)
|
|
1284
|
+
openReading(lines);
|
|
1285
|
+
else if (lines !== 0)
|
|
1286
|
+
scrollBy(lines);
|
|
1287
|
+
}
|
|
1288
|
+
// ONE re-tint for a whole drag, not one per motion report — the same rule the
|
|
1289
|
+
// wheel follows above, for the same reason. A terminal reports motion continuously
|
|
1290
|
+
// while a button is held, so a single sweep of the hand arrives as a chunk of
|
|
1291
|
+
// several reports; each one used to re-tint the entire screen and write it out,
|
|
1292
|
+
// which is thousands of cells re-scanned per report. Only the LAST focus position
|
|
1293
|
+
// in a chunk is on screen at the end of it, so the ones before it are painted for
|
|
1294
|
+
// nobody. Cleared by press and release, which do their own painting.
|
|
1295
|
+
let pendingDrag = false;
|
|
1296
|
+
for (const event of events) {
|
|
1297
|
+
if (event.kind === "press") {
|
|
1298
|
+
pendingDrag = false;
|
|
1299
|
+
// The chip is a button. It is the only thing on screen that says what it does,
|
|
1300
|
+
// so a press on it does that and nothing else — no selection is begun, because
|
|
1301
|
+
// starting one under a click that just moved the view would leave a highlight
|
|
1302
|
+
// sitting on text that is no longer there.
|
|
1303
|
+
//
|
|
1304
|
+
// On PRESS rather than release: the chip is a single row and the view moves out
|
|
1305
|
+
// from under the pointer the instant it is hit, so a release-matched-to-press
|
|
1306
|
+
// would be testing the pointer against a screen that had already changed.
|
|
1307
|
+
const hit = pillHit.current;
|
|
1308
|
+
if (hit && hitsPill(hit, event.x, event.y)) {
|
|
1309
|
+
clearSelection();
|
|
1310
|
+
setScrollUp(0);
|
|
1311
|
+
continue;
|
|
1312
|
+
}
|
|
1313
|
+
selection.current = { anchor: { x: event.x, y: event.y }, focus: { x: event.x, y: event.y } };
|
|
1314
|
+
// Installed here rather than for the life of the session: see clearSelection.
|
|
1315
|
+
setFrameOverlay((screen) => applySelection(screen, selection.current));
|
|
1316
|
+
repaintOverlay();
|
|
762
1317
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1318
|
+
else if (event.kind === "drag") {
|
|
1319
|
+
if (!selection.current)
|
|
1320
|
+
continue;
|
|
1321
|
+
selection.current = { anchor: selection.current.anchor, focus: { x: event.x, y: event.y } };
|
|
1322
|
+
pendingDrag = true;
|
|
1323
|
+
}
|
|
1324
|
+
else {
|
|
1325
|
+
pendingDrag = false;
|
|
1326
|
+
const sel = selection.current;
|
|
1327
|
+
if (!sel)
|
|
1328
|
+
continue;
|
|
1329
|
+
if (isEmpty(sel)) {
|
|
1330
|
+
// A click, not a drag. Nothing to copy; put the caret where it landed, and
|
|
1331
|
+
// take the overlay back down — a press installs it, and a click that selects
|
|
1332
|
+
// nothing would otherwise leave it running for the rest of the session.
|
|
1333
|
+
selection.current = null;
|
|
1334
|
+
repaintOverlay();
|
|
1335
|
+
setFrameOverlay(null);
|
|
1336
|
+
placeCaretAt(event.x, event.y);
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
// Copied on release, so selecting IS copying. The highlight stays up afterwards
|
|
1340
|
+
// as the receipt for it, and goes when the next thing happens.
|
|
1341
|
+
const screen = latestScreen();
|
|
1342
|
+
if (screen)
|
|
1343
|
+
copyToClipboard(selectionText(screen, sel));
|
|
1344
|
+
// If what was dragged is text in the input box, hand the range to the input as
|
|
1345
|
+
// well, so Backspace takes the whole selection and typing replaces it — what
|
|
1346
|
+
// selecting text means anywhere else. A drag over the transcript is not editable
|
|
1347
|
+
// and the input declines it, leaving the selection as a copy and nothing more.
|
|
1348
|
+
textSelect.current?.(sel.anchor, sel.focus);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
if (pendingDrag)
|
|
1352
|
+
repaintOverlay();
|
|
1353
|
+
// A real keystroke, so put any highlight away — the same as a terminal's own
|
|
1354
|
+
// selection does the moment you type.
|
|
1355
|
+
if (events.length === 0 && notches.length === 0)
|
|
1356
|
+
clearSelection();
|
|
1357
|
+
}, { isActive: true });
|
|
770
1358
|
function endTurn() {
|
|
771
1359
|
if (turnStart.current != null)
|
|
772
1360
|
setLastMs(Date.now() - turnStart.current);
|
|
@@ -889,6 +1477,10 @@ export function App({ resumeSessionId }) {
|
|
|
889
1477
|
// decision it drives (does the NEXT grouped toolStart need to be held) has to
|
|
890
1478
|
// be made before that action is even dispatched.
|
|
891
1479
|
const groupOpen = useRef(false);
|
|
1480
|
+
// When the row currently at the front of the queue began waiting for its own result,
|
|
1481
|
+
// and the timer that gives up on it. See the hold in pump().
|
|
1482
|
+
const heldStart = useRef(null);
|
|
1483
|
+
const holdTimer = useRef(null);
|
|
892
1484
|
// A new block appears (paced); a token (silent), a tool resolution (in place), a
|
|
893
1485
|
// discovery call folding into an ALREADY-OPEN group, or a sub-agent's nested
|
|
894
1486
|
// activity (folds into / resolves its rail in place) is not.
|
|
@@ -906,12 +1498,20 @@ export function App({ resumeSessionId }) {
|
|
|
906
1498
|
return a.group ? !groupOpen.current : true;
|
|
907
1499
|
return (a.type !== "token" &&
|
|
908
1500
|
a.type !== "toolEnd" &&
|
|
1501
|
+
a.type !== "toolProgress" &&
|
|
909
1502
|
a.type !== "subToolStart" &&
|
|
910
1503
|
a.type !== "subToolEnd" &&
|
|
911
1504
|
a.type !== "subagentEnd");
|
|
912
1505
|
};
|
|
913
1506
|
function enqueueReveal(a) {
|
|
914
1507
|
revealQ.current.push(a);
|
|
1508
|
+
// A result arriving is exactly what the hold below is waiting for, so its deadline is
|
|
1509
|
+
// cancelled rather than waited out — otherwise every quick tool would sit out the
|
|
1510
|
+
// full grace before its pair could be shown.
|
|
1511
|
+
if (holdTimer.current) {
|
|
1512
|
+
clearTimeout(holdTimer.current);
|
|
1513
|
+
holdTimer.current = null;
|
|
1514
|
+
}
|
|
915
1515
|
if (!pumpTimer.current)
|
|
916
1516
|
pump();
|
|
917
1517
|
}
|
|
@@ -988,8 +1588,36 @@ export function App({ resumeSessionId }) {
|
|
|
988
1588
|
if (planGroupReveal(groupSettled(revealQ.current.slice(1)), flush.current) === "hold")
|
|
989
1589
|
return;
|
|
990
1590
|
}
|
|
991
|
-
else
|
|
992
|
-
|
|
1591
|
+
else {
|
|
1592
|
+
// A standalone row is held for its own result, but only for so long.
|
|
1593
|
+
//
|
|
1594
|
+
// Held with no limit, a row was invisible for the ten
|
|
1595
|
+
// minutes the build took: the last thing on screen stayed the tool before it, and
|
|
1596
|
+
// an agent working steadily was indistinguishable from one that had hung. It was
|
|
1597
|
+
// reported as a hang. It was not one — the command ran, the timeout fired, the
|
|
1598
|
+
// shell was backgrounded, all of it correct and none of it visible.
|
|
1599
|
+
if (heldStart.current?.toolId !== front.toolId) {
|
|
1600
|
+
heldStart.current = { toolId: front.toolId, at: Date.now() };
|
|
1601
|
+
}
|
|
1602
|
+
const heldForMs = Date.now() - heldStart.current.at;
|
|
1603
|
+
const plan = planStandaloneReveal({
|
|
1604
|
+
resultQueued: resultQueued(front.toolId, revealQ.current),
|
|
1605
|
+
flushing: flush.current,
|
|
1606
|
+
streamDone: streamDone.current,
|
|
1607
|
+
heldForMs,
|
|
1608
|
+
});
|
|
1609
|
+
if (plan === "hold") {
|
|
1610
|
+
// Re-enter when the deadline passes. Its OWN timer, not the pacing one: the
|
|
1611
|
+
// result arriving must be able to cancel this and reveal the pair at once, and
|
|
1612
|
+
// clearing the pacing timer instead would drop the beat.
|
|
1613
|
+
if (!holdTimer.current) {
|
|
1614
|
+
holdTimer.current = setTimeout(() => {
|
|
1615
|
+
holdTimer.current = null;
|
|
1616
|
+
pump();
|
|
1617
|
+
}, Math.max(0, STANDALONE_HOLD_MS - heldForMs));
|
|
1618
|
+
}
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
993
1621
|
}
|
|
994
1622
|
schedulePaced(() => {
|
|
995
1623
|
// Measured HERE, not when the beat was scheduled: the queue keeps growing
|
|
@@ -1046,6 +1674,60 @@ export function App({ resumeSessionId }) {
|
|
|
1046
1674
|
* reveal), each tool bookends a `toolStart`/`toolEnd`, and the reply seals on
|
|
1047
1675
|
* completion. busy stays true until every paced reveal has been shown.
|
|
1048
1676
|
*/
|
|
1677
|
+
/**
|
|
1678
|
+
* Turn typed text into what the model gets and what the chat shows.
|
|
1679
|
+
*
|
|
1680
|
+
* Shared by the two ways a message reaches a turn — submitted when idle, and steered
|
|
1681
|
+
* into one already running — because they must resolve identically. A dropped path is
|
|
1682
|
+
* a short handle in the buffer either way, a paste is collapsed either way, and an
|
|
1683
|
+
* image only rides along if the model running RIGHT NOW can see one. Two copies of
|
|
1684
|
+
* that would drift, and the drift would show up as a queued message behaving unlike
|
|
1685
|
+
* the same message typed a second later.
|
|
1686
|
+
*/
|
|
1687
|
+
async function prepareMessage(s, text) {
|
|
1688
|
+
// Whether an attached image is sent or merely named depends on the running model,
|
|
1689
|
+
// and that is a fact we ask the driver for — never a provider name in this file.
|
|
1690
|
+
const manifest = manifestForModel(s.modelConfig.model);
|
|
1691
|
+
const canSeeImages = manifest.acceptsImages?.(s.modelConfig.model) ?? false;
|
|
1692
|
+
// Dropped files are carried in the buffer as short handles. Put the real paths back
|
|
1693
|
+
// before anything is resolved against the disk, and hand the same handle back as the
|
|
1694
|
+
// label so the chat shows what the user typed rather than a third name for the file.
|
|
1695
|
+
const { modelText, displayText, notes, images } = await resolveAttachments(expandHandles(text, dropHandles.current), s.cwd, canSeeImages, (abs) => dropHandles.current.labelFor(abs));
|
|
1696
|
+
// Restore any collapsed pastes into the model's copy only (the chat keeps chips).
|
|
1697
|
+
return { content: expandPastes(modelText), displayText, notes, images };
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Hand the running turn whatever was typed at it, at a step boundary.
|
|
1701
|
+
*
|
|
1702
|
+
* Called by the engine, not by us, and only at the one moment a user message may be
|
|
1703
|
+
* appended without malforming the conversation. The queue is drained SYNCHRONOUSLY
|
|
1704
|
+
* first and resolved after, so a ↑ that pulls the queue back mid-resolution takes the
|
|
1705
|
+
* messages that are still queued rather than racing the ones already on their way.
|
|
1706
|
+
*
|
|
1707
|
+
* Each message gets its own chat line, queued through the reveal pacer like every
|
|
1708
|
+
* other row so it lands in the order it happened instead of jumping ahead of the tool
|
|
1709
|
+
* rows around it.
|
|
1710
|
+
*/
|
|
1711
|
+
async function steerRunningTurn(s) {
|
|
1712
|
+
const { send, rest } = takeSteerable(queueRef.current);
|
|
1713
|
+
if (send.length === 0)
|
|
1714
|
+
return [];
|
|
1715
|
+
queueRef.current = rest;
|
|
1716
|
+
setQueued(rest);
|
|
1717
|
+
const out = [];
|
|
1718
|
+
for (const { text } of send) {
|
|
1719
|
+
// No history write here: `onSend` records every message the moment it is typed,
|
|
1720
|
+
// queued or not, so ↑ walks them in the order they were written rather than the
|
|
1721
|
+
// order they happened to go out. Recording again on the way out appended each one
|
|
1722
|
+
// a second time.
|
|
1723
|
+
const { content, displayText, notes, images } = await prepareMessage(s, text);
|
|
1724
|
+
enqueueReveal({ type: "user", text: displayText });
|
|
1725
|
+
for (const n of notes)
|
|
1726
|
+
enqueueReveal({ type: "note", text: n });
|
|
1727
|
+
out.push({ content, ...(images.length > 0 ? { images } : {}) });
|
|
1728
|
+
}
|
|
1729
|
+
return out;
|
|
1730
|
+
}
|
|
1049
1731
|
async function streamRespond(s) {
|
|
1050
1732
|
startTurn();
|
|
1051
1733
|
// Pick up an edit the model made to MINDWEAVE.md, but only if one actually happened
|
|
@@ -1057,8 +1739,18 @@ export function App({ resumeSessionId }) {
|
|
|
1057
1739
|
lastRevealAt.current = 0;
|
|
1058
1740
|
streamDone.current = false;
|
|
1059
1741
|
flush.current = false;
|
|
1742
|
+
// A hold belongs to one turn. Left standing, its deadline fires into the next one and
|
|
1743
|
+
// pumps a queue that has nothing to do with it.
|
|
1744
|
+
heldStart.current = null;
|
|
1745
|
+
if (holdTimer.current) {
|
|
1746
|
+
clearTimeout(holdTimer.current);
|
|
1747
|
+
holdTimer.current = null;
|
|
1748
|
+
}
|
|
1060
1749
|
try {
|
|
1061
1750
|
await respond(s, {
|
|
1751
|
+
// Messages typed while this turn runs reach it here, at each step boundary,
|
|
1752
|
+
// rather than waiting for it to end and starting another one.
|
|
1753
|
+
steer: () => steerRunningTurn(s),
|
|
1062
1754
|
onActivity: (line, opts) => enqueueReveal(opts?.context ? { type: "context", text: line } : opts?.error ? { type: "error", text: line } : { type: "note", text: line }),
|
|
1063
1755
|
// An AUTOMATIC compaction, mid-turn. Queued like everything else so it appears
|
|
1064
1756
|
// in the order it happened, rather than jumping ahead of the rows around it.
|
|
@@ -1089,6 +1781,11 @@ export function App({ resumeSessionId }) {
|
|
|
1089
1781
|
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
1782
|
}
|
|
1091
1783
|
}
|
|
1784
|
+
else if (e.type === "tool" && e.phase === "progress") {
|
|
1785
|
+
// A worker's own calls fold into its rail, which has no room for output.
|
|
1786
|
+
if (!e.agent)
|
|
1787
|
+
enqueueReveal({ type: "toolProgress", toolId: e.id, text: e.text });
|
|
1788
|
+
}
|
|
1092
1789
|
else if (e.type === "tool" && e.phase === "end") {
|
|
1093
1790
|
if (e.name === "spawn_subagent")
|
|
1094
1791
|
return;
|
|
@@ -1296,10 +1993,11 @@ export function App({ resumeSessionId }) {
|
|
|
1296
1993
|
// the choice for this project, and confirm.
|
|
1297
1994
|
async function applyModel(index) {
|
|
1298
1995
|
const s = session.current;
|
|
1299
|
-
// Index into the SAME list the picker rendered — the current provider's models
|
|
1300
|
-
// not every model everywhere. Indexing
|
|
1301
|
-
// a different model than the one on screen, and would
|
|
1302
|
-
|
|
1996
|
+
// Index into the SAME list the picker rendered — the current provider's models in
|
|
1997
|
+
// display order, not every model everywhere. Indexing a differently-ordered list
|
|
1998
|
+
// here would silently select a different model than the one on screen, and would
|
|
1999
|
+
// type-check perfectly.
|
|
2000
|
+
const choice = s ? orderedModelList(s.modelConfig.model)[index] : undefined;
|
|
1303
2001
|
if (!s || !choice)
|
|
1304
2002
|
return;
|
|
1305
2003
|
s.modelConfig = withModel(s.modelConfig, choice.id);
|
|
@@ -1314,7 +2012,8 @@ export function App({ resumeSessionId }) {
|
|
|
1314
2012
|
*/
|
|
1315
2013
|
async function applyProvider(index) {
|
|
1316
2014
|
const s = session.current;
|
|
1317
|
-
|
|
2015
|
+
// The display-ordered list, matching what the picker rendered and its cursor.
|
|
2016
|
+
const provider = orderedProviderList()[index];
|
|
1318
2017
|
if (!s || !provider)
|
|
1319
2018
|
return;
|
|
1320
2019
|
if (providerOf(s.modelConfig.model).id === provider.id) {
|
|
@@ -1361,6 +2060,27 @@ export function App({ resumeSessionId }) {
|
|
|
1361
2060
|
}
|
|
1362
2061
|
// Route a Picker selection/cancel back to whatever opened the overlay. Picking a
|
|
1363
2062
|
// session opens the second step — the three resume choices.
|
|
2063
|
+
/**
|
|
2064
|
+
* Move to a shell, from either route into `/screen` — the chooser or a named argument.
|
|
2065
|
+
*
|
|
2066
|
+
* One function because the two routes must not drift: they save the same preference,
|
|
2067
|
+
* announce the same line, and both have to leave the terminal work to the effect that
|
|
2068
|
+
* watches `shell`. Two copies is how one of them ends up switching without remembering.
|
|
2069
|
+
*/
|
|
2070
|
+
function applyScreen(next) {
|
|
2071
|
+
if (next === shell) {
|
|
2072
|
+
note(`already ${screenNotice(next)}`);
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
// The terminal is moved by the effect that watches this, not from here: the escapes
|
|
2076
|
+
// must not go out in the middle of handling a keypress, with a render still to come.
|
|
2077
|
+
setShell(next);
|
|
2078
|
+
// Remembered for the project, so the choice is made once rather than at the start of
|
|
2079
|
+
// every session. Best-effort and not awaited: the switch has already happened, and a
|
|
2080
|
+
// preference that failed to save is not worth holding the UI for.
|
|
2081
|
+
void saveScreenMode(session.current?.cwd ?? process.cwd(), next);
|
|
2082
|
+
note(screenNotice(next));
|
|
2083
|
+
}
|
|
1364
2084
|
function onOverlaySelect(index) {
|
|
1365
2085
|
const o = overlay;
|
|
1366
2086
|
if (!o)
|
|
@@ -1382,6 +2102,11 @@ export function App({ resumeSessionId }) {
|
|
|
1382
2102
|
void applyModel(index);
|
|
1383
2103
|
else if (o.kind === "think")
|
|
1384
2104
|
void applyThink(index);
|
|
2105
|
+
else if (o.kind === "screen") {
|
|
2106
|
+
const picked = screenChoices(shell)[index];
|
|
2107
|
+
if (picked)
|
|
2108
|
+
applyScreen(picked.mode);
|
|
2109
|
+
}
|
|
1385
2110
|
else if (o.kind === "shells") {
|
|
1386
2111
|
const sh = o.items[index];
|
|
1387
2112
|
if (sh && sh.status === "running" && session.current?.toolContext.backgroundShells?.kill(sh.id, "user")) {
|
|
@@ -1750,7 +2475,7 @@ export function App({ resumeSessionId }) {
|
|
|
1750
2475
|
if (name === "/model") {
|
|
1751
2476
|
await refreshModels();
|
|
1752
2477
|
if (arg) {
|
|
1753
|
-
const picked = resolveChoice(arg,
|
|
2478
|
+
const picked = resolveChoice(arg, orderedModelList(s.modelConfig.model), "model");
|
|
1754
2479
|
if (picked.kind === "error")
|
|
1755
2480
|
return say(picked.message);
|
|
1756
2481
|
await applyModel(picked.index);
|
|
@@ -1759,6 +2484,26 @@ export function App({ resumeSessionId }) {
|
|
|
1759
2484
|
setOverlay({ kind: "model" });
|
|
1760
2485
|
return;
|
|
1761
2486
|
}
|
|
2487
|
+
if (name === "/screen") {
|
|
2488
|
+
// Bare `/screen` now CHOOSES rather than swaps. Swapping was fine while the two
|
|
2489
|
+
// shells were equals; they are not, and a toggle gives no room to say so. The two
|
|
2490
|
+
// differ in what they take from the terminal — the inline one takes the mouse, so
|
|
2491
|
+
// the scrollbar and text selection stop working — and that is worth reading before
|
|
2492
|
+
// picking rather than discovering afterwards.
|
|
2493
|
+
//
|
|
2494
|
+
// Naming a mode still applies it directly: `/screen inline` is someone who already
|
|
2495
|
+
// knows which they want, and making them confirm through a list would be the
|
|
2496
|
+
// command asking a question it was just given the answer to.
|
|
2497
|
+
if (!arg?.trim()) {
|
|
2498
|
+
setOverlay({ kind: "screen" });
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
const next = parseScreenArg(arg);
|
|
2502
|
+
if (!next)
|
|
2503
|
+
return say("`/screen` takes `fullscreen` or `inline`, or nothing at all to choose.");
|
|
2504
|
+
applyScreen(next);
|
|
2505
|
+
return;
|
|
2506
|
+
}
|
|
1762
2507
|
if (name === "/think") {
|
|
1763
2508
|
if (arg) {
|
|
1764
2509
|
const picked = resolveChoice(arg, thinkLevels(s.modelConfig.model), "reasoning level");
|
|
@@ -1977,7 +2722,7 @@ export function App({ resumeSessionId }) {
|
|
|
1977
2722
|
}
|
|
1978
2723
|
say(unknownCommandMessage(name));
|
|
1979
2724
|
}
|
|
1980
|
-
async function handleSubmit(value) {
|
|
2725
|
+
async function handleSubmit(value, opts = {}) {
|
|
1981
2726
|
const trimmed = value.trim();
|
|
1982
2727
|
if (trimmed.length === 0 || busy || !ready)
|
|
1983
2728
|
return;
|
|
@@ -2008,18 +2753,17 @@ export function App({ resumeSessionId }) {
|
|
|
2008
2753
|
// file path collapses to just its name — never the file dump. The model gets
|
|
2009
2754
|
// the full content via resolved <attached_file> blocks, and each attachment
|
|
2010
2755
|
// 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);
|
|
2756
|
+
const { content, displayText, notes, images } = await prepareMessage(s, trimmed);
|
|
2016
2757
|
dispatch({ type: "user", text: displayText });
|
|
2017
2758
|
for (const n of notes)
|
|
2018
2759
|
note(n);
|
|
2019
|
-
// Restore any collapsed pastes into the model's copy only (the chat keeps chips).
|
|
2020
2760
|
s.transcript.push({
|
|
2021
2761
|
role: "user",
|
|
2022
|
-
content
|
|
2762
|
+
content,
|
|
2763
|
+
// Sent straight after an Esc. The model is told the work was cut off on purpose,
|
|
2764
|
+
// or a message landing after a half-finished round of tools reads as if it had
|
|
2765
|
+
// always been the request and it picks up where it was stopped.
|
|
2766
|
+
...(opts.arrival ? { arrival: opts.arrival } : {}),
|
|
2023
2767
|
...(images.length > 0 ? { images } : {}),
|
|
2024
2768
|
});
|
|
2025
2769
|
await streamRespond(s);
|
|
@@ -2087,7 +2831,7 @@ export function App({ resumeSessionId }) {
|
|
|
2087
2831
|
setScrollUp(0);
|
|
2088
2832
|
setHistory((h) => (h[h.length - 1] === text ? h : [...h, text]));
|
|
2089
2833
|
if (busy) {
|
|
2090
|
-
queueRef.current.push(text);
|
|
2834
|
+
queueRef.current.push(queueMessage(text, { interrupting: interrupting.current }));
|
|
2091
2835
|
setQueued([...queueRef.current]);
|
|
2092
2836
|
return;
|
|
2093
2837
|
}
|
|
@@ -2153,7 +2897,7 @@ export function App({ resumeSessionId }) {
|
|
|
2153
2897
|
label: sessionTitle(m),
|
|
2154
2898
|
description: `${timeAgo(m.updatedAt)} · ${m.entryCount} msg${m.entryCount === 1 ? "" : "s"}`,
|
|
2155
2899
|
}));
|
|
2156
|
-
return (_jsx(Picker, { title: "Continue which session?", items: items, width: width, maxRows: maxRows, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
|
|
2900
|
+
return (_jsx(Picker, { title: "Continue which session?", items: items, width: width, maxRows: maxRows, rightAlignDescription: true, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
|
|
2157
2901
|
}
|
|
2158
2902
|
if (overlay.kind === "resumeMode") {
|
|
2159
2903
|
return (_jsx(Picker, { title: `Continue “${sessionTitle(overlay.meta)}” — how?`, items: RESUME_MODES, width: width, maxRows: maxRows, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
|
|
@@ -2180,7 +2924,7 @@ export function App({ resumeSessionId }) {
|
|
|
2180
2924
|
// provider you are already on otherwise says no more than any other row, leaving the
|
|
2181
2925
|
// one thing you came to check — what you are on right now — off the screen.
|
|
2182
2926
|
const activeLabel = modelLabel(activeModel);
|
|
2183
|
-
const providers =
|
|
2927
|
+
const providers = orderedProviderList();
|
|
2184
2928
|
const items = providers.map((p) => {
|
|
2185
2929
|
const n = modelsOf(p).length;
|
|
2186
2930
|
const models = `${n} model${n === 1 ? "" : "s"}`;
|
|
@@ -2194,8 +2938,9 @@ export function App({ resumeSessionId }) {
|
|
|
2194
2938
|
}
|
|
2195
2939
|
if (overlay.kind === "model") {
|
|
2196
2940
|
const id = cur?.modelConfig.model ?? DEFAULT_MODEL_CONFIG.model;
|
|
2197
|
-
// Only the current provider's models
|
|
2198
|
-
|
|
2941
|
+
// Only the current provider's models, in display order (default first, rest A→Z).
|
|
2942
|
+
// Switching provider is /provider's job.
|
|
2943
|
+
const models = orderedModelList(id);
|
|
2199
2944
|
// The two facts that decide the choice and are nowhere else on the screen: how much
|
|
2200
2945
|
// it can hold, and whether it can see an image you attach. They lead the description
|
|
2201
2946
|
// because the row truncates from the RIGHT — put behind the prose they would be the
|
|
@@ -2218,6 +2963,16 @@ export function App({ resumeSessionId }) {
|
|
|
2218
2963
|
const items = levels.map((l) => ({ label: l.label + (l.label === curLabel ? " ✓" : ""), description: l.description }));
|
|
2219
2964
|
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
2965
|
}
|
|
2966
|
+
if (overlay.kind === "screen") {
|
|
2967
|
+
const choices = screenChoices(shell);
|
|
2968
|
+
return (_jsx(Picker, { title: "Which shell?", items: choices.map((c) => ({ label: c.label, description: c.description })), width: width, maxRows: maxRows,
|
|
2969
|
+
// The shell descriptions explain a real trade-off, so they are shown in full
|
|
2970
|
+
// below the list — wrapping down rather than truncating on the row.
|
|
2971
|
+
describeSelection: true,
|
|
2972
|
+
// Opens on the one in use, so Enter alone changes nothing. A chooser that opens
|
|
2973
|
+
// somewhere else turns a glance at the options into an accidental switch.
|
|
2974
|
+
initialIndex: Math.max(0, choices.findIndex((c) => c.mode === shell)), onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
|
|
2975
|
+
}
|
|
2221
2976
|
// approval — a plan, a Sentinel action, a forbidden-path lift. It interrupts the user's
|
|
2222
2977
|
// work and the answer commits them to something, so it reads as a stop; it renders in
|
|
2223
2978
|
// the same fixed menu box as everything else, the answers always visible.
|
|
@@ -2251,12 +3006,23 @@ export function App({ resumeSessionId }) {
|
|
|
2251
3006
|
const committed = stateRef.current.committed;
|
|
2252
3007
|
const tail = stateRef.current.tail;
|
|
2253
3008
|
const allBlocks = [...committed, ...tail];
|
|
2254
|
-
//
|
|
2255
|
-
//
|
|
2256
|
-
//
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
3009
|
+
// The terminal's height RIGHT NOW, by live syscall — see liveTerminalSize. The SOLE
|
|
3010
|
+
// authority for the frame: no min or max against the polled state, which can lag a
|
|
3011
|
+
// resize, and a live syscall never can.
|
|
3012
|
+
const liveRows = liveTerminalSize(stdout).rows;
|
|
3013
|
+
// The FULL height, so the footer sits on the very last row with nothing below it.
|
|
3014
|
+
//
|
|
3015
|
+
// This was `liveRows - 1` for a real reason that no longer applies. Ink's own renderer
|
|
3016
|
+
// switches, at a frame as tall as the terminal, from erasing and redrawing to clearing
|
|
3017
|
+
// the whole screen — and that clear desynchronises its line bookkeeping, so the next
|
|
3018
|
+
// ordinary frame erases the wrong count and old text is left behind under new. The one
|
|
3019
|
+
// row held back kept the frame under that threshold. But the framebuffer replaces that
|
|
3020
|
+
// renderer entirely in the full-screen shell: it addresses cells absolutely and diffs
|
|
3021
|
+
// its own model, never leaning on Ink's erase-and-redraw, so the threshold is not
|
|
3022
|
+
// reached and the row is pure dead space at the bottom edge. Verified by driving a
|
|
3023
|
+
// full-height frame and a change through the real framebuffer — the footer lands on the
|
|
3024
|
+
// last row and nothing is left behind.
|
|
3025
|
+
const frameHeight = Math.max(3, liveRows);
|
|
2260
3026
|
// The chat's HEIGHT is no longer computed here — Yoga is given the job instead
|
|
2261
3027
|
// (the viewport below is flexGrow:1 beside a flexShrink:0 footer), and this
|
|
2262
3028
|
// measurement is now only read for the SCROLL maths.
|
|
@@ -2281,7 +3047,17 @@ export function App({ resumeSessionId }) {
|
|
|
2281
3047
|
// border/title/hint. What's left becomes item rows, floored at a usable
|
|
2282
3048
|
// minimum and capped so a huge terminal doesn't show an ungainly wall.
|
|
2283
3049
|
const menuBudget = frameHeight - BANNER_ROWS - MIN_CHAT_ROWS - FOOTER_BASE_ROWS - MENU_CHROME_ROWS;
|
|
2284
|
-
|
|
3050
|
+
//
|
|
3051
|
+
// The inline shell gets a much smaller window, and the reason is not taste. There is no
|
|
3052
|
+
// frame to shrink there: every row the palette adds makes the live region taller than
|
|
3053
|
+
// the room below it, so the TERMINAL scrolls to fit — and that scroll is one-way. Twelve
|
|
3054
|
+
// rows is twelve rows of the conversation gone up past the top edge, for a list nobody
|
|
3055
|
+
// reads twelve of. Three plus the hint is about one line of visible movement, and the
|
|
3056
|
+
// list is not shortened by it: `SuggestionMenu` windows around the selection, so the
|
|
3057
|
+
// whole catalog is still reachable with the arrows, three at a time.
|
|
3058
|
+
const maxMenuItems = shell === "inline"
|
|
3059
|
+
? Math.max(2, Math.min(INLINE_MENU_ROWS, menuBudget))
|
|
3060
|
+
: Math.max(3, Math.min(12, menuBudget));
|
|
2285
3061
|
// Built here, after maxMenuItems, so a picker's contents respect the same row budget as
|
|
2286
3062
|
// the command menu and can never grow the footer past the screen. EVERY interactive
|
|
2287
3063
|
// surface — the pickers, the key manager, and the approval prompt — is content-only and
|
|
@@ -2293,7 +3069,34 @@ export function App({ resumeSessionId }) {
|
|
|
2293
3069
|
// to `chatAnchor.ts` so the rule is unit-tested rather than eyeballed — see there
|
|
2294
3070
|
// for why a short transcript now rests ON the input box instead of stranding
|
|
2295
3071
|
// itself at the top of the screen, and why that cannot disturb a scrolled frame.
|
|
2296
|
-
const { marginTop: chatOffset, restsOnFooter } = chatLayout(contentHeight, chatRows, scrollUp);
|
|
3072
|
+
const { marginTop: chatOffset, restsOnFooter, maxScroll, scrolled } = chatLayout(contentHeight, chatRows, scrollUp);
|
|
3073
|
+
// Published for the scroll handlers, which run between frames and cannot compute it.
|
|
3074
|
+
maxScrollRef.current = maxScroll;
|
|
3075
|
+
// The chip that says the view is not at the bottom. `scrolled` and not `scrollUp`:
|
|
3076
|
+
// the clamped number is the one that is zero whenever the whole transcript already
|
|
3077
|
+
// fits, which is exactly when there is nothing to jump to. See scrollPill.ts.
|
|
3078
|
+
const pill = scrollPill({
|
|
3079
|
+
scrolled,
|
|
3080
|
+
newReplies: countNewReplies(allBlocks, scrollMark.current),
|
|
3081
|
+
overlayOpen: overlay !== null,
|
|
3082
|
+
width,
|
|
3083
|
+
});
|
|
3084
|
+
/**
|
|
3085
|
+
* The chip's cells in SCREEN coordinates, so a click can be tested against them.
|
|
3086
|
+
*
|
|
3087
|
+
* The layout reports the chip's row within the LIVE REGION, and a mouse report gives a
|
|
3088
|
+
* row on the screen. Those are the same number in the full-screen shell, where the app
|
|
3089
|
+
* owns every row and Ink draws from the top — and they are NOT in the inline shell,
|
|
3090
|
+
* where the live region is the last `frameHeight` rows of a terminal full of
|
|
3091
|
+
* scrollback. Confusing the two is the same mistake that once had the cursor parked in
|
|
3092
|
+
* the middle of the conversation; the offset is applied once, here, rather than being
|
|
3093
|
+
* rediscovered by whatever reads this.
|
|
3094
|
+
*
|
|
3095
|
+
* Published during the render, because a pointer handler runs BETWEEN frames and can
|
|
3096
|
+
* measure nothing for itself.
|
|
3097
|
+
*/
|
|
3098
|
+
const frameTop = readingInline ? Math.max(0, rows - frameHeight) : 0;
|
|
3099
|
+
pillHit.current = pill !== null && pillRow.current !== null ? pillBounds(pill, width, frameTop + pillRow.current) : null;
|
|
2297
3100
|
// Only the blocks that can still be reached are worth laying out. Yoga lays
|
|
2298
3101
|
// out every child on every render — including one caused by a keystroke — so
|
|
2299
3102
|
// an unbounded transcript makes typing slower the longer you have been
|
|
@@ -2305,9 +3108,36 @@ export function App({ resumeSessionId }) {
|
|
|
2305
3108
|
// above, and the whole scroll mechanism — is bit-for-bit what it was when every
|
|
2306
3109
|
// block was laid out in full. See `virtualWindow.ts`.
|
|
2307
3110
|
if (heightsWidth.current !== width) {
|
|
2308
|
-
// Every recorded height was measured at a different width and is now
|
|
2309
|
-
//
|
|
2310
|
-
|
|
3111
|
+
// Every recorded height was measured at a different width and is now wrong. RESCALED
|
|
3112
|
+
// rather than thrown away, and the difference is the whole cost of a resize.
|
|
3113
|
+
//
|
|
3114
|
+
// Discarding leaves nothing measured, and a block with no height cannot become a
|
|
3115
|
+
// spacer — so the very next frame lays out the entire scrollback at once. Measured
|
|
3116
|
+
// on this machine, idle: about 1.7ms per block, 253ms for a full window, in one
|
|
3117
|
+
// synchronous commit. That is the freeze, and it happened on every resize.
|
|
3118
|
+
//
|
|
3119
|
+
// Narrower text wraps to more rows and wider to fewer, in roughly that proportion,
|
|
3120
|
+
// so the old height times old/new width is close enough to keep the scroll maths
|
|
3121
|
+
// sane for the frame it takes to measure the blocks that are actually on screen.
|
|
3122
|
+
//
|
|
3123
|
+
// Only ROUGHLY, and the gap is why every scaled entry is marked. A paragraph that
|
|
3124
|
+
// wrapped to one row at the old width does not become 1.4 rows at a narrower one, it
|
|
3125
|
+
// becomes two; the error is worst exactly where the terminal is narrowest, and it
|
|
3126
|
+
// does not average out, because each block is rounded on its own. Left as the final
|
|
3127
|
+
// answer those estimates size every spacer in the virtual window, and blocks land
|
|
3128
|
+
// one or two rows from where they belong — text that will not settle.
|
|
3129
|
+
//
|
|
3130
|
+
// `scaled` is what stops that being permanent. The entry stays usable, so the window
|
|
3131
|
+
// is still virtualized and no resize lays out the whole scrollback at once; but it no
|
|
3132
|
+
// longer counts as measured, so the blocks that get RENDERED are laid out again and
|
|
3133
|
+
// replace their estimate with a fact. Bounded by what is on screen, not by the
|
|
3134
|
+
// length of the session.
|
|
3135
|
+
const ratio = heightsWidth.current > 0 ? heightsWidth.current / width : 1;
|
|
3136
|
+
for (const entry of blockHeights.current.values()) {
|
|
3137
|
+
if (ratio !== 1)
|
|
3138
|
+
entry.height = Math.max(1, Math.round(entry.height * ratio));
|
|
3139
|
+
entry.scaled = true;
|
|
3140
|
+
}
|
|
2311
3141
|
heightsWidth.current = width;
|
|
2312
3142
|
// Remember WHERE the reader was, as a proportion of the scrollable range, before
|
|
2313
3143
|
// the re-wrap changes what a line means. `scrollUp` is a line count, and a line is
|
|
@@ -2324,10 +3154,13 @@ export function App({ resumeSessionId }) {
|
|
|
2324
3154
|
let known = 0;
|
|
2325
3155
|
const knownHeights = [];
|
|
2326
3156
|
while (known < rendered.length) {
|
|
2327
|
-
const
|
|
2328
|
-
|
|
3157
|
+
const block = rendered[known];
|
|
3158
|
+
const entry = blockHeights.current.get(block.id);
|
|
3159
|
+
// The identity check IS the invalidation: a block that changed is a new object, so
|
|
3160
|
+
// its recorded height belongs to the version before the change.
|
|
3161
|
+
if (entry === undefined || entry.block !== block)
|
|
2329
3162
|
break;
|
|
2330
|
-
knownHeights.push(
|
|
3163
|
+
knownHeights.push(entry.height);
|
|
2331
3164
|
known++;
|
|
2332
3165
|
}
|
|
2333
3166
|
const win = virtualWindow(knownHeights, -chatOffset, chatRows);
|
|
@@ -2342,28 +3175,232 @@ export function App({ resumeSessionId }) {
|
|
|
2342
3175
|
// Rows between the end of the window and the first unmeasured block. Rendering the
|
|
2343
3176
|
// unmeasured tail is not optional — it is how those blocks get measured at all.
|
|
2344
3177
|
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
|
-
|
|
3178
|
+
// The status line, the queued bar, the input and whatever sits under it. Built once
|
|
3179
|
+
// and rendered by BOTH shells: the only thing that differs between them is where it
|
|
3180
|
+
// ends up — pinned to the bottom of a frame we own, or simply the last thing printed.
|
|
3181
|
+
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) => {
|
|
3182
|
+
caretClick.current = place;
|
|
3183
|
+
}, registerTextSelect: (select) => {
|
|
3184
|
+
textSelect.current = select;
|
|
3185
|
+
}, 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 ? (
|
|
3186
|
+
// A compaction is near. This takes the line over the tip and the background bar:
|
|
3187
|
+
// it is the one thing here the user needs BEFORE it happens, since the summarizing
|
|
3188
|
+
// pass rewrites the conversation. Dim as it approaches, then a plain warning colour
|
|
3189
|
+
// with the manual way out once it is about to fire on its own.
|
|
3190
|
+
_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] }))] }));
|
|
3191
|
+
// The transcript viewport: the scrolled window, its measurements, and the chip.
|
|
3192
|
+
//
|
|
3193
|
+
// Built once and rendered by BOTH shells — the full-screen frame, and the inline
|
|
3194
|
+
// shell's reading view. The two want exactly the same thing there: a clipped box that
|
|
3195
|
+
// Yoga sizes against a pinned footer, with the transcript offset inside it. Kept as
|
|
3196
|
+
// two copies they would drift apart the first time either was touched, and the drift
|
|
3197
|
+
// would show as scrolling behaving differently in one shell than in the other.
|
|
3198
|
+
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) => {
|
|
3199
|
+
const idx = win.start + i;
|
|
3200
|
+
return (
|
|
3201
|
+
// flexShrink:0 is load-bearing — without it Yoga compresses an
|
|
3202
|
+
// overfull column and silently drops rows out of the middle.
|
|
3203
|
+
_jsx(Box, { ref: (node) => {
|
|
3204
|
+
if (node && needsMeasure(blockHeights.current.get(b.id), b))
|
|
3205
|
+
toMeasure.current.set(b, node);
|
|
3206
|
+
}, flexShrink: 0, flexDirection: "column", children: _jsx(BlockView, { block: b, columns: width, tightTop: isTight(allBlocks, offset + idx) }) }, b.id));
|
|
3207
|
+
}), padMiddle > 0 ? _jsx(Box, { flexShrink: 0, height: padMiddle }) : null, rendered.slice(known).map((b, i) => {
|
|
3208
|
+
const idx = known + i;
|
|
3209
|
+
return (_jsx(Box, { ref: (node) => {
|
|
3210
|
+
if (node && needsMeasure(blockHeights.current.get(b.id), b))
|
|
3211
|
+
toMeasure.current.set(b, node);
|
|
3212
|
+
}, flexShrink: 0, flexDirection: "column", children: _jsx(BlockView, { block: b, columns: width, tightTop: isTight(allBlocks, offset + idx) }) }, b.id));
|
|
3213
|
+
})] }) }), pill ? (_jsx(Box
|
|
3214
|
+
// Measured so a CLICK can find it. The row is the only part of the chip's
|
|
3215
|
+
// position the layout owns — the columns fall out of centring, which
|
|
3216
|
+
// `pillBounds` reproduces — and a pointer handler runs between frames, where
|
|
3217
|
+
// it could not measure anything for itself.
|
|
3218
|
+
, {
|
|
3219
|
+
// Measured so a CLICK can find it. The row is the only part of the chip's
|
|
3220
|
+
// position the layout owns — the columns fall out of centring, which
|
|
3221
|
+
// `pillBounds` reproduces — and a pointer handler runs between frames, where
|
|
3222
|
+
// it could not measure anything for itself.
|
|
3223
|
+
ref: (node) => {
|
|
3224
|
+
pillRow.current = node ? measureElement(node).y : null;
|
|
3225
|
+
}, position: "absolute", bottom: 0, left: 0, right: 0, justifyContent: "center", children: _jsx(Text, { inverse: true, children: pill }) })) : null] }));
|
|
3226
|
+
/**
|
|
3227
|
+
* Refilling the rows the reading frame leaves behind — IN THE SAME COMMIT.
|
|
3228
|
+
*
|
|
3229
|
+
* Closing the reading view shrinks the live region from a near-full-height frame back
|
|
3230
|
+
* to a couple of rows, and a terminal cannot un-scroll. Measured on the real renderer,
|
|
3231
|
+
* the shrink emits `eraseLine` twenty-four times and then writes two lines: twenty-two
|
|
3232
|
+
* rows erased with nothing put back, which is the band of empty screen below the
|
|
3233
|
+
* prompt. Ink is right to do this — it is how every live region shrinks — and it is
|
|
3234
|
+
* ordinarily invisible because a shrink normally happens when a block DRAINS into
|
|
3235
|
+
* <Static>, which prints the same rows permanently on its way past. Nothing drains
|
|
3236
|
+
* when a viewport closes, so nothing refills them.
|
|
3237
|
+
*
|
|
3238
|
+
* So the close reprints, and the reprint has to be part of the SAME frame as the
|
|
3239
|
+
* shrink. Driven from an effect it was one render late, and that one render is a real
|
|
3240
|
+
* frame the terminal paints: the erased band flashed empty and was then filled — the
|
|
3241
|
+
* blink reported on the way back to the bottom. Refs mutated during render are what
|
|
3242
|
+
* put both in one commit, the same way `maxScrollRef` above is published to handlers
|
|
3243
|
+
* that run between frames.
|
|
3244
|
+
*
|
|
3245
|
+
* The header is suppressed on this one, because unlike the shell switch this replaces
|
|
3246
|
+
* nothing — the original is still in scrollback a screen up, and a second copy in the
|
|
3247
|
+
* middle of the conversation reads as the session having restarted.
|
|
3248
|
+
*/
|
|
3249
|
+
/**
|
|
3250
|
+
* The startup fill, decided during the RENDER — which is the only moment it can be.
|
|
3251
|
+
*
|
|
3252
|
+
* `<Static>` prints each item ONCE, in the render that first sees it, and the fill is
|
|
3253
|
+
* one of its items. An effect runs after that render has already been committed and
|
|
3254
|
+
* written, so a height an effect assigns arrives too late by construction: the item has
|
|
3255
|
+
* been printed at whatever the ref held during the render, and Static will never render
|
|
3256
|
+
* it again. Set from an effect, the fill was therefore printed as ZERO rows on first
|
|
3257
|
+
* mount, every time — verified by rendering the same shape and counting the rows it
|
|
3258
|
+
* emitted before the first item.
|
|
3259
|
+
*
|
|
3260
|
+
* The visible cost was the whole reason the fill exists going unpaid: the first screen
|
|
3261
|
+
* sat at the TOP of the terminal, with the prompt part-way up and empty rows below it,
|
|
3262
|
+
* because a terminal prints from wherever the cursor happens to be.
|
|
3263
|
+
*
|
|
3264
|
+
* Only for the FIRST print. Every later remount of `<Static>` — a shell switch, a
|
|
3265
|
+
* width-change reprint, leaving the reading view — sets the fill for its own reasons
|
|
3266
|
+
* before bumping the key, and those must not be overwritten here.
|
|
3267
|
+
*/
|
|
3268
|
+
/**
|
|
3269
|
+
* The startup fill, GROWN whenever a void opens below the footer.
|
|
3270
|
+
*
|
|
3271
|
+
* The fill is a run of blank rows printed above the first screen so a short
|
|
3272
|
+
* conversation lands at the BOTTOM of the terminal rather than floating part-way up —
|
|
3273
|
+
* a terminal prints from wherever the cursor is, and prints nothing below. `<Static>`
|
|
3274
|
+
* emits each item once, so the fill's height is whatever `rows` held the render it was
|
|
3275
|
+
* first printed on, and can never change for that mount.
|
|
3276
|
+
*
|
|
3277
|
+
* That is the whole bug behind the void. The first render often runs before the real
|
|
3278
|
+
* terminal height is known — the size hook re-reads a moment later — so the fill was
|
|
3279
|
+
* frozen at a stale, small number: blank rows at the top, the conversation, and then a
|
|
3280
|
+
* band of empty screen all the way to the bottom edge that nothing ever filled.
|
|
3281
|
+
*
|
|
3282
|
+
* `fillBasis` is the height the current fill was sized for. When the terminal turns
|
|
3283
|
+
* out to be TALLER than that — the settle after a wrong first read, or the window
|
|
3284
|
+
* genuinely dragged bigger — the fill is regrown and `<Static>` remounted, which
|
|
3285
|
+
* reprints the recent conversation with the footer back at the edge. Only ever grown,
|
|
3286
|
+
* never shrunk: once the conversation is long enough to overflow the screen the footer
|
|
3287
|
+
* sits at the bottom on its own, and shrinking the fill then would reprint on every
|
|
3288
|
+
* small drag for a void that is not there.
|
|
3289
|
+
*/
|
|
3290
|
+
if (shell === "inline") {
|
|
3291
|
+
const grown = growFill(fillState.current, rows);
|
|
3292
|
+
fillState.current = { fill: grown.fill, basis: grown.basis };
|
|
3293
|
+
startFill.current = grown.fill;
|
|
3294
|
+
// A remount reprints the recent conversation with the footer back at the edge —
|
|
3295
|
+
// wanted when a void has opened, skipped on the very first sizing (nothing on screen
|
|
3296
|
+
// yet). See startupFill.ts.
|
|
3297
|
+
if (grown.remount)
|
|
3298
|
+
closeEpoch.current += 1;
|
|
3299
|
+
}
|
|
3300
|
+
/**
|
|
3301
|
+
* Where `<Static>` was allowed to reach when the reading view opened, or null while
|
|
3302
|
+
* the view is closed.
|
|
3303
|
+
*
|
|
3304
|
+
* A SCROLLBACK PRINTER AND A VIEWPORT CANNOT BOTH BE WRITING. Every finished block
|
|
3305
|
+
* normally goes to `<Static>`, which prints it into the terminal permanently and
|
|
3306
|
+
* scrolls everything up to make room. That is exactly right when the live region below
|
|
3307
|
+
* it is two rows of prompt. It is destructive when the live region is a near
|
|
3308
|
+
* full-height viewport, because each print scrolls the terminal under a frame that Ink
|
|
3309
|
+
* then has to erase and lay down again from its new position — measured on the real
|
|
3310
|
+
* renderer, three blocks arriving during an open viewport erased seventy-two rows of a
|
|
3311
|
+
* twenty-four row terminal. What that looks like on screen is bands of blank where a
|
|
3312
|
+
* block is about to appear, which is the reported glitch, and it only happens while a
|
|
3313
|
+
* turn is still running because that is the only time new blocks arrive.
|
|
3314
|
+
*
|
|
3315
|
+
* The conflict only exists because this shell has BOTH. A scrolling viewport normally
|
|
3316
|
+
* holds the whole transcript itself and prints nothing permanently; a shell built on
|
|
3317
|
+
* `<Static>` has no viewport for a print to collide with. Running the two together is
|
|
3318
|
+
* this shell's own doing, so the rule it needs has to be stated here: while the
|
|
3319
|
+
* viewport is open, the printer holds.
|
|
3320
|
+
*
|
|
3321
|
+
* Nothing is lost by holding. The close already reprints from `reprintFrom`, which is
|
|
3322
|
+
* behind everything that arrived while the view was open, so those blocks reach the
|
|
3323
|
+
* terminal on the way out — in one frame, with the refill that fills the viewport's
|
|
3324
|
+
* rows, rather than a print at a time underneath it.
|
|
3325
|
+
*/
|
|
3326
|
+
if (readingInline && frozenStatic.current === null)
|
|
3327
|
+
frozenStatic.current = committed.length;
|
|
3328
|
+
if (wasReadingInline.current && !readingInline) {
|
|
3329
|
+
reprintFrom.current = Math.max(0, committed.length - INLINE_REPRINT_BLOCKS);
|
|
3330
|
+
startFill.current = 0;
|
|
3331
|
+
showHeader.current = false;
|
|
3332
|
+
closeEpoch.current += 1;
|
|
3333
|
+
frozenStatic.current = null;
|
|
3334
|
+
}
|
|
3335
|
+
wasReadingInline.current = readingInline;
|
|
3336
|
+
// ── the inline shell ──────────────────────────────────────────────────────
|
|
3337
|
+
//
|
|
3338
|
+
// Finished blocks go to <Static>, which Ink prints ONCE and never renders again:
|
|
3339
|
+
// they become the terminal's own scrollback. Only the live tail and the footer are
|
|
3340
|
+
// re-rendered, so a frame costs what is happening now rather than what has happened
|
|
3341
|
+
// all session — a four-hour conversation renders exactly as fast as a four-minute one.
|
|
3342
|
+
//
|
|
3343
|
+
// Everything the fullscreen shell exists to do is simply absent here, on purpose.
|
|
3344
|
+
// There is no frame height, because we are not claiming the screen; no virtual window,
|
|
3345
|
+
// because nothing off screen is being re-laid-out; no scroll offset, because scrolling
|
|
3346
|
+
// is the terminal's scrollbar; and no selection layer, because selecting is the
|
|
3347
|
+
// terminal's selection. Each of those is faster than what we would write, and behaves
|
|
3348
|
+
// the way the rest of the user's terminal already does.
|
|
3349
|
+
//
|
|
3350
|
+
// The banner rides as a sentinel item rather than sitting above the list, so it prints
|
|
3351
|
+
// exactly once and scrolls away with the conversation instead of being reprinted at
|
|
3352
|
+
// the top of every frame.
|
|
3353
|
+
if (shell === "inline") {
|
|
3354
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static
|
|
3355
|
+
// Two counters, because the two remounts happen at different MOMENTS: the
|
|
3356
|
+
// shell switch can settle in an effect, the reading-view close has to land in
|
|
3357
|
+
// the frame that shrinks the live region.
|
|
3358
|
+
, {
|
|
3359
|
+
// `frozenStatic` caps the list while the reading viewport is open — see above.
|
|
3360
|
+
// `slice(from, undefined)` is `slice(from)`, so the closed case is unchanged.
|
|
3361
|
+
items: [
|
|
3362
|
+
FILL_ITEM,
|
|
3363
|
+
BANNER_ITEM,
|
|
3364
|
+
...committed.slice(reprintFrom.current, frozenStatic.current ?? undefined),
|
|
3365
|
+
], children: (item, index) => {
|
|
3366
|
+
if (item === FILL_ITEM)
|
|
3367
|
+
return _jsx(Box, { height: startFill.current }, "fill");
|
|
3368
|
+
// Kept as an ITEM even when it renders nothing, so the `index - 2` the
|
|
3369
|
+
// blocks below count back is the same either way.
|
|
3370
|
+
if (item === BANNER_ITEM)
|
|
3371
|
+
return showHeader.current ? _jsx(InlineHeader, {}, "banner") : null;
|
|
3372
|
+
return (_jsx(BlockView, { block: item, columns: width, tightTop: isTight(allBlocks, reprintFrom.current + index - 2) }, item.id));
|
|
3373
|
+
} }, `${staticEpoch}:${closeEpoch.current}`), _jsxs(Box, { ref: liveRef, flexDirection: "column", flexShrink: 0,
|
|
3374
|
+
// `frameHeight` and the clip are what makes reading a VIEWPORT rather than a
|
|
3375
|
+
// wall of text — see the shared `chatView` above. Applied to the SAME element
|
|
3376
|
+
// in both states, not one that only exists in one of them.
|
|
3377
|
+
height: readingInline ? frameHeight : undefined, overflow: readingInline ? "hidden" : "visible", children: [readingInline ? (
|
|
3378
|
+
// <Static> stays mounted above and prints nothing new: it has already
|
|
3379
|
+
// emitted every item it holds, and unmounting it would make the next mount
|
|
3380
|
+
// reprint the whole conversation underneath this frame.
|
|
3381
|
+
chatView) : (tail.map((b, i) => (_jsx(BlockView, { block: b, columns: width, tightTop: isTight(allBlocks, committed.length + i) }, b.id)))), footerView] })] }));
|
|
3382
|
+
}
|
|
3383
|
+
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
3384
|
}
|
|
3385
|
+
/**
|
|
3386
|
+
* The inline shell's header, printed once at the top of the conversation.
|
|
3387
|
+
*
|
|
3388
|
+
* Deliberately NOT the fullscreen banner. That one is a live status bar — mode, model,
|
|
3389
|
+
* whether a turn is running — and <Static> prints a thing once and never touches it
|
|
3390
|
+
* again, so all three would be frozen at whatever they happened to be when the session
|
|
3391
|
+
* opened. A header that quietly lies about which model is answering is worse than no
|
|
3392
|
+
* header. What belongs in scrollback is the part that cannot go stale.
|
|
3393
|
+
*/
|
|
3394
|
+
function InlineHeader() {
|
|
3395
|
+
return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "yellow", children: "Mindweave" }), _jsxs(Text, { dimColor: true, children: [" ", versionLabel()] })] }));
|
|
3396
|
+
}
|
|
3397
|
+
/** A sentinel <Static> item: the one-time header, printed with the transcript so it
|
|
3398
|
+
* scrolls away rather than being redrawn above every frame. */
|
|
3399
|
+
const BANNER_ITEM = "__banner__";
|
|
3400
|
+
/** A sentinel for the blank rows that push the first screen of conversation down to the
|
|
3401
|
+
* bottom of the window. In the list rather than above it so it is printed exactly once,
|
|
3402
|
+
* the same as the header. */
|
|
3403
|
+
const FILL_ITEM = "__fill__";
|
|
2367
3404
|
// The banner's own rows: the title line, the rule under it, and its bottom
|
|
2368
3405
|
// margin. Subtracted from the frame so the chat gets exactly what's left —
|
|
2369
3406
|
// see the layout comment at the render site.
|
|
@@ -2375,6 +3412,9 @@ const MIN_CHAT_ROWS = 3;
|
|
|
2375
3412
|
const FOOTER_BASE_ROWS = 7;
|
|
2376
3413
|
/** The palette's own chrome: title, the "Tab completes" hint, top+bottom border. */
|
|
2377
3414
|
const MENU_CHROME_ROWS = 4;
|
|
3415
|
+
/** Item rows the command palette shows in the INLINE shell. Small on purpose — see the
|
|
3416
|
+
* note where it is used: there, rows are paid for in terminal scroll. */
|
|
3417
|
+
const INLINE_MENU_ROWS = 3;
|
|
2378
3418
|
/** Lines PageUp/PageDown move per press. */
|
|
2379
3419
|
const PAGE_LINES = 10;
|
|
2380
3420
|
/** Lines one wheel notch moves. Three is the usual terminal step, and a flick
|
|
@@ -2383,6 +3423,15 @@ const WHEEL_LINES = 3;
|
|
|
2383
3423
|
/** How much of the transcript stays scrollable. Every rendered block is laid out
|
|
2384
3424
|
* on every render, so this bounds what typing costs in a long conversation. */
|
|
2385
3425
|
const SCROLLBACK_BLOCKS = 150;
|
|
3426
|
+
/** Blocks reprinted when the inline shell is entered. Two screens or so: enough to look
|
|
3427
|
+
* back over, few enough that the reprint is not a visible pause. */
|
|
3428
|
+
/** How long the INLINE shell waits for a drag to settle before re-reading the size.
|
|
3429
|
+
* The full-screen shell does not wait — see useTerminalSize. */
|
|
3430
|
+
const RESIZE_SETTLE_MS = 150;
|
|
3431
|
+
/** How often the size is polled, for Windows consoles where the resize event may never
|
|
3432
|
+
* fire at all (nodejs/node#13197). Two integer reads; an unchanged size costs nothing. */
|
|
3433
|
+
const RESIZE_POLL_MS = 250;
|
|
3434
|
+
const INLINE_REPRINT_BLOCKS = 40;
|
|
2386
3435
|
/**
|
|
2387
3436
|
* The loom shuttle that runs beside the name while a turn is working.
|
|
2388
3437
|
*
|
|
@@ -2466,14 +3515,42 @@ export function Banner({ width, mode, modelConfig, busy }) {
|
|
|
2466
3515
|
* whatever the header/footer don't use), so a stale row count leaves dead
|
|
2467
3516
|
* space at the bottom instead of the frame reaching the terminal's actual edge.
|
|
2468
3517
|
*/
|
|
2469
|
-
|
|
3518
|
+
/**
|
|
3519
|
+
* The terminal's size RIGHT NOW, by live syscall where the platform offers one.
|
|
3520
|
+
*
|
|
3521
|
+
* `stdout.columns` / `stdout.rows` are getters that, on Windows, can hand back a value
|
|
3522
|
+
* cached at the last `resize` event — and that event frequently never fires there
|
|
3523
|
+
* (nodejs/node#13197). So a window dragged taller leaves those properties reporting the
|
|
3524
|
+
* old height forever, and everything sized from them, the full-screen frame included,
|
|
3525
|
+
* stops short of the real bottom edge with dead space below it.
|
|
3526
|
+
*
|
|
3527
|
+
* `getWindowSize()` asks the OS for the size on the spot (`uv_tty_get_winsize`), which is
|
|
3528
|
+
* not cached and not tied to the event. Preferred when present; the plain getters are the
|
|
3529
|
+
* fallback for a stream that has no `getWindowSize` (a pipe, a test double).
|
|
3530
|
+
*/
|
|
3531
|
+
export function liveTerminalSize(stream) {
|
|
3532
|
+
const win = stream?.getWindowSize?.();
|
|
3533
|
+
if (win)
|
|
3534
|
+
return { columns: win[0], rows: win[1] };
|
|
3535
|
+
return { columns: stream?.columns ?? 80, rows: stream?.rows ?? 24 };
|
|
3536
|
+
}
|
|
3537
|
+
export function useTerminalSize(defer) {
|
|
2470
3538
|
const { stdout } = useStdout();
|
|
2471
|
-
const [size, setSize] = useState(
|
|
3539
|
+
const [size, setSize] = useState(() => liveTerminalSize(stdout));
|
|
3540
|
+
// Read live so the listeners below never have to be torn down and rebuilt when the
|
|
3541
|
+
// shell changes — resubscribing mid-drag would drop the very events being handled.
|
|
3542
|
+
const deferRef = useRef(defer);
|
|
3543
|
+
deferRef.current = defer;
|
|
2472
3544
|
useEffect(() => {
|
|
2473
3545
|
if (!stdout)
|
|
2474
3546
|
return;
|
|
3547
|
+
// Identical sizes end here, and that matters more than it looks: terminals emit two
|
|
3548
|
+
// or more resize events for a single user action as the window settles, and each one
|
|
3549
|
+
// that reached state would be a re-layout of the whole frame for no change at all.
|
|
2475
3550
|
const read = () => setSize((prev) => {
|
|
2476
|
-
|
|
3551
|
+
// A LIVE query, so a Windows window dragged bigger is detected even though the
|
|
3552
|
+
// resize event never fired and the cached getters still report the old size.
|
|
3553
|
+
const next = liveTerminalSize(stdout);
|
|
2477
3554
|
return next.columns === prev.columns && next.rows === prev.rows ? prev : next;
|
|
2478
3555
|
});
|
|
2479
3556
|
// The 'resize' event is NOT reliable on native Windows consoles — Node has a
|
|
@@ -2484,13 +3561,37 @@ function useTerminalSize() {
|
|
|
2484
3561
|
// integer reads, ~4x/sec) and it's the standard workaround for that exact gap,
|
|
2485
3562
|
// not a hack: it's what a resize event is supposed to give us, gotten a
|
|
2486
3563
|
// different way when the event can't be trusted to arrive at all.
|
|
3564
|
+
// NOT debounced when the app owns the screen, and the debounce that used to be here
|
|
3565
|
+
// unconditionally is what made a drag look broken.
|
|
3566
|
+
//
|
|
3567
|
+
// A debounce opens a window where the terminal has already resized but this app still
|
|
3568
|
+
// believes the old size. Anything that renders during it — the spinner, the clock, a
|
|
3569
|
+
// streaming delta — lays a frame out at dimensions the terminal no longer has, and
|
|
3570
|
+
// the result is the half-drawn shapes that appear while dragging and tidy themselves
|
|
3571
|
+
// up the moment the drag stops. The frame is not settling late; it is being drawn
|
|
3572
|
+
// wrong and then drawn again. Handling the event as it arrives keeps the app's idea
|
|
3573
|
+
// of the size and the terminal's the same at every instant, which is the only state
|
|
3574
|
+
// in which a frame can be right.
|
|
3575
|
+
//
|
|
3576
|
+
// The INLINE shell still defers, and for a reason that does not apply to the other
|
|
3577
|
+
// one. There the transcript is the terminal's own scrollback, printed once; a
|
|
3578
|
+
// re-render mid-drag leaves a stale copy of the live region behind it, so a slow drag
|
|
3579
|
+
// left a ladder of half-drawn input boxes down the screen. Nothing is printed
|
|
3580
|
+
// permanently in the full-screen shell, so nothing can be left behind.
|
|
2487
3581
|
let debounce;
|
|
2488
3582
|
const onResize = () => {
|
|
2489
3583
|
clearTimeout(debounce);
|
|
2490
|
-
|
|
3584
|
+
if (!deferRef.current) {
|
|
3585
|
+
read();
|
|
3586
|
+
return;
|
|
3587
|
+
}
|
|
3588
|
+
debounce = setTimeout(read, RESIZE_SETTLE_MS);
|
|
2491
3589
|
};
|
|
2492
3590
|
stdout.on("resize", onResize);
|
|
2493
|
-
|
|
3591
|
+
// The poll exists for Windows, where the event cannot be relied on at all. It goes
|
|
3592
|
+
// through the same handler, so it inherits whichever policy the shell is using, and
|
|
3593
|
+
// the identical-size check above makes a poll that finds nothing free.
|
|
3594
|
+
const poll = setInterval(onResize, RESIZE_POLL_MS);
|
|
2494
3595
|
// The size read at THIS exact instant can be stale too: entering alt-screen
|
|
2495
3596
|
// (a raw escape code written before Ink even mounts, see altScreen.ts) makes
|
|
2496
3597
|
// the terminal reconfigure its buffer, and querying dimensions mid-reconfigure
|
|
@@ -2673,14 +3774,6 @@ function BackgroundBar({ shells }) {
|
|
|
2673
3774
|
// mode/model/thinking readout already lives in the header, so this slot is
|
|
2674
3775
|
// free for the shift-tab hint (nothing else states it anymore) and a few of
|
|
2675
3776
|
// 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
3777
|
/**
|
|
2685
3778
|
* Messages typed while Mindweave is working, waiting to be sent when the turn ends.
|
|
2686
3779
|
*
|
|
@@ -2697,7 +3790,7 @@ function QueuedBar({ queued }) {
|
|
|
2697
3790
|
if (queued.length === 0)
|
|
2698
3791
|
return null;
|
|
2699
3792
|
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"}` })] }));
|
|
3793
|
+
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
3794
|
}
|
|
2702
3795
|
/**
|
|
2703
3796
|
* Whether some OTHER installed provider has a key, so `/provider` is worth
|