mindweave 2.3.1 → 2.4.0

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