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.
Files changed (129) hide show
  1. package/README.md +26 -43
  2. package/dist/alternator/chassis/lsp.js +67 -0
  3. package/dist/alternator/chassis/lsp.js.map +1 -1
  4. package/dist/cli/App.js +1201 -108
  5. package/dist/cli/App.js.map +1 -1
  6. package/dist/cli/altScreen.js +80 -10
  7. package/dist/cli/altScreen.js.map +1 -1
  8. package/dist/cli/appIdentity.js +54 -0
  9. package/dist/cli/appIdentity.js.map +1 -0
  10. package/dist/cli/attachments.js +27 -3
  11. package/dist/cli/attachments.js.map +1 -1
  12. package/dist/cli/blockHeights.js +58 -0
  13. package/dist/cli/blockHeights.js.map +1 -0
  14. package/dist/cli/caretPark.js +94 -0
  15. package/dist/cli/caretPark.js.map +1 -0
  16. package/dist/cli/clipboard.js +68 -0
  17. package/dist/cli/clipboard.js.map +1 -0
  18. package/dist/cli/commandLabel.js +19 -2
  19. package/dist/cli/commandLabel.js.map +1 -1
  20. package/dist/cli/commands.js +1 -0
  21. package/dist/cli/commands.js.map +1 -1
  22. package/dist/cli/components/BlockView.js +1 -1
  23. package/dist/cli/components/BlockView.js.map +1 -1
  24. package/dist/cli/components/KeyManager.js +11 -5
  25. package/dist/cli/components/KeyManager.js.map +1 -1
  26. package/dist/cli/components/Picker.js +18 -3
  27. package/dist/cli/components/Picker.js.map +1 -1
  28. package/dist/cli/components/PromptInput.js +300 -74
  29. package/dist/cli/components/PromptInput.js.map +1 -1
  30. package/dist/cli/components/TipLine.js +33 -0
  31. package/dist/cli/components/TipLine.js.map +1 -0
  32. package/dist/cli/components/ToolLine.js +30 -3
  33. package/dist/cli/components/ToolLine.js.map +1 -1
  34. package/dist/cli/dropHandles.js +76 -0
  35. package/dist/cli/dropHandles.js.map +1 -0
  36. package/dist/cli/exitCursor.js +56 -0
  37. package/dist/cli/exitCursor.js.map +1 -0
  38. package/dist/cli/framebuffer/overlay.js +42 -0
  39. package/dist/cli/framebuffer/overlay.js.map +1 -0
  40. package/dist/cli/framebuffer/paint.js +8 -1
  41. package/dist/cli/framebuffer/paint.js.map +1 -1
  42. package/dist/cli/framebuffer/parse.js +54 -4
  43. package/dist/cli/framebuffer/parse.js.map +1 -1
  44. package/dist/cli/framebuffer/writer.js +357 -6
  45. package/dist/cli/framebuffer/writer.js.map +1 -1
  46. package/dist/cli/groupReveal.js +29 -3
  47. package/dist/cli/groupReveal.js.map +1 -1
  48. package/dist/cli/inputView.js +45 -0
  49. package/dist/cli/inputView.js.map +1 -1
  50. package/dist/cli/keyManager.js +12 -2
  51. package/dist/cli/keyManager.js.map +1 -1
  52. package/dist/cli/livePad.js +53 -0
  53. package/dist/cli/livePad.js.map +1 -0
  54. package/dist/cli/memoryTuning.js +31 -0
  55. package/dist/cli/memoryTuning.js.map +1 -0
  56. package/dist/cli/messageQueue.js +76 -7
  57. package/dist/cli/messageQueue.js.map +1 -1
  58. package/dist/cli/mouse.js +40 -7
  59. package/dist/cli/mouse.js.map +1 -1
  60. package/dist/cli/pickerOrder.js +49 -0
  61. package/dist/cli/pickerOrder.js.map +1 -0
  62. package/dist/cli/quietConsole.js +50 -0
  63. package/dist/cli/quietConsole.js.map +1 -0
  64. package/dist/cli/screenMode.js +118 -0
  65. package/dist/cli/screenMode.js.map +1 -0
  66. package/dist/cli/screenShell.js +67 -0
  67. package/dist/cli/screenShell.js.map +1 -0
  68. package/dist/cli/screenStore.js +46 -0
  69. package/dist/cli/screenStore.js.map +1 -0
  70. package/dist/cli/scrollPill.js +101 -0
  71. package/dist/cli/scrollPill.js.map +1 -0
  72. package/dist/cli/selection.js +130 -0
  73. package/dist/cli/selection.js.map +1 -0
  74. package/dist/cli/startupFill.js +48 -0
  75. package/dist/cli/startupFill.js.map +1 -0
  76. package/dist/cli/terminalRestore.js +1 -1
  77. package/dist/cli/terminalRestore.js.map +1 -1
  78. package/dist/cli/toolDisplay.js +11 -2
  79. package/dist/cli/toolDisplay.js.map +1 -1
  80. package/dist/cli/toolItems.js +1 -0
  81. package/dist/cli/toolItems.js.map +1 -1
  82. package/dist/cli/transcript.js +54 -3
  83. package/dist/cli/transcript.js.map +1 -1
  84. package/dist/cli/wordEdit.js +95 -0
  85. package/dist/cli/wordEdit.js.map +1 -0
  86. package/dist/drivers/deepseek/manifest.js +54 -56
  87. package/dist/drivers/deepseek/manifest.js.map +1 -1
  88. package/dist/drivers/openai/manifest.js +4 -1
  89. package/dist/drivers/openai/manifest.js.map +1 -1
  90. package/dist/drivers/registry.js +7 -1
  91. package/dist/drivers/registry.js.map +1 -1
  92. package/dist/dynamo/engine.js +126 -20
  93. package/dist/dynamo/engine.js.map +1 -1
  94. package/dist/index.js +42 -3
  95. package/dist/index.js.map +1 -1
  96. package/dist/memory/pastedText.js +37 -0
  97. package/dist/memory/pastedText.js.map +1 -0
  98. package/dist/memory/presence.js +47 -0
  99. package/dist/memory/presence.js.map +1 -1
  100. package/dist/memory/session.js +78 -4
  101. package/dist/memory/session.js.map +1 -1
  102. package/dist/memory/store.js +22 -1
  103. package/dist/memory/store.js.map +1 -1
  104. package/dist/memory/types.js.map +1 -1
  105. package/dist/tools/backgroundShells.js +105 -18
  106. package/dist/tools/backgroundShells.js.map +1 -1
  107. package/dist/tools/commandOutput.js +207 -0
  108. package/dist/tools/commandOutput.js.map +1 -0
  109. package/dist/tools/detail.js +2 -2
  110. package/dist/tools/detail.js.map +1 -1
  111. package/dist/tools/openDefault.js +150 -0
  112. package/dist/tools/openDefault.js.map +1 -0
  113. package/dist/tools/outputShape.js +31 -6
  114. package/dist/tools/outputShape.js.map +1 -1
  115. package/dist/tools/pathList.js +32 -0
  116. package/dist/tools/pathList.js.map +1 -0
  117. package/dist/tools/readFile.js +3 -15
  118. package/dist/tools/readFile.js.map +1 -1
  119. package/dist/tools/registry.js +3 -1
  120. package/dist/tools/registry.js.map +1 -1
  121. package/dist/tools/runCommand.js +154 -58
  122. package/dist/tools/runCommand.js.map +1 -1
  123. package/dist/tools/screenshot.js +35 -3
  124. package/dist/tools/screenshot.js.map +1 -1
  125. package/dist/tools/viewImage.js +88 -0
  126. package/dist/tools/viewImage.js.map +1 -0
  127. package/dist/tools/writeFile.js +9 -1
  128. package/dist/tools/writeFile.js.map +1 -1
  129. 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
- // Picked once per session, rendered inside the input box (PromptInput's `tip` prop).
155
- const [tip] = useState(() => TIPS[Math.floor(Math.random() * TIPS.length)]);
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
- // Keyed by the BLOCK OBJECT, not its id, and that is load-bearing rather than
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 cached height, is rendered in full, and
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. Weak, so blocks dropped
177
- // past the scrollback cap do not pin their heights in memory forever.
178
- const blockHeights = useRef(new WeakMap());
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
- // Every height is only true for the width it was measured at, so a resize throws
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
- // Messages typed while Mindweave is working — queued, then sent in order when the
267
- // turn ends; the input stays live while busy.
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
- const { columns: width, rows } = useTerminalSize();
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
- if (blockHeights.current.has(block))
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
- if (learned)
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
- // The wheel. Read straight off stdin rather than through useInput, because a
753
- // mouse report is not a keypress and Ink's key parser has no notion of one.
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
- const off = enableMouse();
758
- const stdin = process.stdin;
759
- const onData = (chunk) => {
760
- for (const dir of readWheel(chunk.toString("utf8"))) {
761
- scrollBy(dir === "up" ? WHEEL_LINES : -WHEEL_LINES);
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
- stdin.on("data", onData);
765
- return () => {
766
- stdin.off("data", onData);
767
- off();
768
- };
769
- }, [ready, scrollBy]);
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 if (!(resultQueued(front.toolId, revealQ.current) || flush.current || streamDone.current)) {
992
- return;
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 the global list here would silently select
1301
- // a different model than the one on screen, and would type-check perfectly.
1302
- const choice = s ? modelsOfProvider(s.modelConfig.model)[index] : undefined;
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
- const provider = allProviders()[index];
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, modelsOfProvider(s.modelConfig.model), "model");
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
- // Whether an attached image is sent or merely named depends on the running model,
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: expandPastes(modelText),
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 = allProviders();
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. Switching provider is /provider's job.
2198
- const models = modelsOfProvider(id);
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
- // Never render a frame as tall as the terminal (constraint 1). `stdout.rows`
2255
- // is read live as well as from state, because during a resize the polled
2256
- // state can briefly lag the real terminal, and being one row too TALL is the
2257
- // failure mode that corrupts the screen.
2258
- const liveRows = stdout?.rows ?? rows;
2259
- const frameHeight = Math.max(3, Math.min(rows, liveRows) - 1);
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
- const maxMenuItems = Math.max(3, Math.min(12, menuBudget));
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 a lie. Throw
2309
- // the table away; the frame below renders in full and re-measures.
2310
- blockHeights.current = new WeakMap();
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 h = blockHeights.current.get(rendered[known]);
2328
- if (h === undefined)
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(h);
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
- 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 }) }), _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) => {
2346
- const idx = win.start + i;
2347
- return (
2348
- // flexShrink:0 is load-bearing — without it Yoga compresses an
2349
- // overfull column and silently drops rows out of the middle.
2350
- _jsx(Box, { ref: (node) => {
2351
- if (node && !blockHeights.current.has(b))
2352
- toMeasure.current.set(b, node);
2353
- }, flexShrink: 0, flexDirection: "column", children: _jsx(BlockView, { block: b, columns: width, tightTop: isTight(allBlocks, offset + idx) }) }, b.id));
2354
- }), padMiddle > 0 ? _jsx(Box, { flexShrink: 0, height: padMiddle }) : null, rendered.slice(known).map((b, i) => {
2355
- const idx = known + i;
2356
- return (_jsx(Box, { ref: (node) => {
2357
- if (node && !blockHeights.current.has(b))
2358
- toMeasure.current.set(b, node);
2359
- }, flexShrink: 0, flexDirection: "column", children: _jsx(BlockView, { block: b, columns: width, tightTop: isTight(allBlocks, offset + idx) }) }, b.id));
2360
- })] }) })] }), _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, maxMenuRows: maxMenuItems, onMenuChange: onMenuChange, onQueuePop: popQueue, overlay: overlayView })) : (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "starting\u2026" }) })) }), ctxWarn ? (
2361
- // A compaction is near. This takes the line over the tip and the background bar:
2362
- // it is the one thing here the user needs BEFORE it happens, since the summarizing
2363
- // pass rewrites the conversation. Dim as it approaches, then a plain warning colour
2364
- // with the manual way out once it is about to fire on its own.
2365
- _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(Box, { flexShrink: 0, children: _jsxs(Text, { dimColor: true, children: [" tip: ", tip] }) }))] })] }));
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
- function useTerminalSize() {
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({ columns: stdout?.columns ?? 80, rows: stdout?.rows ?? 24 });
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
- const next = { columns: stdout.columns ?? 80, rows: stdout.rows ?? 24 };
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
- debounce = setTimeout(read, 150);
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
- const poll = setInterval(read, 250);
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