diff --git a/e2e/console-selection.spec.ts b/e2e/console-selection.spec.ts
new file mode 100644
index 000000000..cfec2c291
--- /dev/null
+++ b/e2e/console-selection.spec.ts
@@ -0,0 +1,84 @@
+import { expect, test, type Page } from "@playwright/test";
+
+/** Mount the real console with enough output to recycle rows while selecting. */
+async function consoleOutput(page: Page, follow = false) {
+ await page.goto("/");
+ await page.evaluate(async (follow) => {
+ const reactPath = "/node_modules/.vite/deps/react.js";
+ const domPath = "/node_modules/.vite/deps/react-dom_client.js";
+ const viewPath = "/src/features/run/ConsoleView.tsx";
+ const modelPath = "/src/features/run/console.ts";
+ const react = await import(reactPath);
+ const dom = await import(domPath);
+ const { createElement, useState } = react.default ?? react;
+ const { createRoot } = dom.default ?? dom;
+ const { ConsoleView } = await import(viewPath);
+ const { ConsoleModel } = await import(modelPath);
+ const model = new ConsoleModel();
+ model.append({ kind: "stdout", text: Array.from({ length: 500 }, (_, i) => `output ${i}`).join("\n") });
+ const host = document.createElement("div");
+ host.style.cssText = "position:fixed;inset:0;z-index:99999;display:flex;height:300px;background:white";
+ document.body.append(host);
+ function Console() {
+ const [following, setFollowing] = useState(follow);
+ return createElement(ConsoleView, {
+ model,
+ version: 1,
+ softWrap: false,
+ follow: following,
+ onFollowChange: setFollowing,
+ cwd: "/",
+ });
+ }
+ createRoot(host).render(createElement(Console));
+ }, follow);
+ const log = page.getByRole("log", { name: "Console", exact: true });
+ await expect(log.locator("[data-index]").first()).toBeVisible();
+ return log;
+}
+
+test("keeps a console selection intact when scrolling beyond the virtual window", async ({ page }) => {
+ const log = await consoleOutput(page);
+ await log.evaluate((el) => {
+ el.scrollTop = 200 * 20;
+ });
+ const start = log.locator('[data-index="205"]');
+ await expect(start).toBeVisible();
+ await start.evaluate((el) => {
+ const selection = window.getSelection()!;
+ selection.setBaseAndExtent(el.firstChild!.firstChild!, 2, el.nextElementSibling!.firstChild!.firstChild!, 5);
+ });
+ await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("tput 205\noutpu");
+ await log.evaluate((el) => {
+ el.scrollTop = 350 * 20;
+ });
+ await expect(log.locator('[data-index="350"]')).toBeVisible();
+ await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("tput 205\noutpu");
+ await page.evaluate(() => window.getSelection()?.removeAllRanges());
+ await expect(start).toHaveCount(0);
+});
+
+test("preserves all lines in a drag selection across virtual windows", async ({ page }) => {
+ const log = await consoleOutput(page);
+ await log.evaluate((el) => {
+ el.scrollTop = 200 * 20;
+ });
+ const start = log.locator('[data-index="205"]');
+ await expect(start).toBeVisible();
+ const box = (await start.boundingBox())!;
+ await page.mouse.move(box.x + 20, box.y + 10);
+ await page.mouse.down();
+ await page.mouse.move(box.x + 60, box.y + 30, { steps: 5 });
+ await log.evaluate((el) => {
+ el.scrollTop = 280 * 20;
+ });
+ const end = log.locator('[data-index="285"]');
+ await expect(end).toBeVisible();
+ const endBox = (await end.boundingBox())!;
+ await page.mouse.move(endBox.x + 60, endBox.y + 10, { steps: 5 });
+ await page.mouse.up();
+ const text = await page.evaluate(() => window.getSelection()?.toString());
+ expect(text).toContain("output 206\noutput 207");
+ expect(text).toContain("output 250\noutput 251");
+ expect(text).toContain("output 284");
+});
diff --git a/src/features/run/ConsoleView.tsx b/src/features/run/ConsoleView.tsx
index 8997d4196..24cc2287d 100644
--- a/src/features/run/ConsoleView.tsx
+++ b/src/features/run/ConsoleView.tsx
@@ -21,6 +21,7 @@ import { findOutputLinks, type OutputLink } from "./links";
import { openTarget, openUrl } from "./navigate";
import { segmentLine, type SegmentMark } from "./segments";
import styles from "./Run.module.css";
+import { useConsoleSelection } from "./useConsoleSelection";
import { hostBinding, formatBinding, hasPrimaryModifier, registerKeyClaim } from "@/platform/keymap";
import { tx } from "@/platform/i18n";
import { ConsoleFindBar } from "./FindBar";
@@ -211,7 +212,9 @@ export function ConsoleView({
});
const programmatic = useRef(false);
+ const { rangeExtractor, selecting } = useConsoleSelection(scrollRef, () => onFollowChange(false));
const virtualizer = useVirtualizer({
+ rangeExtractor,
count,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW,
@@ -241,6 +244,7 @@ export function ConsoleView({
const el = scrollRef.current;
if (!el || programmatic.current) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < ROW;
+ if (atBottom && selecting) return;
if (atBottom !== follow) onFollowChange(atBottom);
};
@@ -537,6 +541,7 @@ export function ConsoleView({
{row.fold && foldButton(row.fold, true)}
{inputInline && item.index === rows.length - 1 && pendingInput}
+ {"\n"}
);
})}
diff --git a/src/features/run/useConsoleSelection.test.tsx b/src/features/run/useConsoleSelection.test.tsx
new file mode 100644
index 000000000..9e0058cf9
--- /dev/null
+++ b/src/features/run/useConsoleSelection.test.tsx
@@ -0,0 +1,94 @@
+import { act, fireEvent, renderHook } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { useConsoleSelection } from "./useConsoleSelection";
+
+const viewport = { startIndex: 100, endIndex: 110, overscan: 2, count: 500 };
+
+afterEach(() => {
+ window.getSelection()?.removeAllRanges();
+ document.body.replaceChildren();
+});
+
+function setup() {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const rows = [5, 6, 7].map((index) => {
+ const row = document.createElement("div");
+ row.dataset.index = String(index);
+ row.textContent = `output ${index}`;
+ host.append(row);
+ return row;
+ });
+ const ref = { current: host };
+ const pause = vi.fn();
+ const hook = renderHook(({ pause }) => useConsoleSelection(ref, pause), { initialProps: { pause } });
+ const select = (backward = false) => {
+ act(() => {
+ const [anchor, focus] = backward ? [rows[2]!, rows[0]!] : [rows[0]!, rows[2]!];
+ window.getSelection()!.setBaseAndExtent(anchor.firstChild!, 1, focus.firstChild!, 3);
+ document.dispatchEvent(new Event("selectionchange"));
+ });
+ };
+ return { ...hook, host, rows, pause, select };
+}
+
+describe("console selection retention", () => {
+ it.each([false, true])("keeps the full selected interval mounted (backward=%s)", (backward) => {
+ const { result, select, pause } = setup();
+ select(backward);
+ const indices = result.current.rangeExtractor(viewport);
+ expect(indices.slice(0, 3)).toEqual([5, 6, 7]);
+ expect(indices).not.toContain(50);
+ expect(indices).toContain(100);
+ expect(pause).toHaveBeenCalled();
+ });
+
+ it("renders the intervening text when dragging beyond the viewport", () => {
+ const { result, rows, select } = setup();
+ fireEvent.mouseDown(rows[0]!, { button: 0 });
+ select();
+ const indices = result.current.rangeExtractor(viewport);
+ expect(indices[0]).toBe(5);
+ expect(indices).toHaveLength(108);
+ fireEvent.mouseUp(document);
+ expect(result.current.rangeExtractor(viewport)).not.toContain(50);
+ expect(result.current.rangeExtractor(viewport)).toContain(5);
+ });
+
+ it("releases pinned rows when the selection is cleared or moved outside the console", () => {
+ const { result, select } = setup();
+ select();
+ act(() => {
+ window.getSelection()!.removeAllRanges();
+ document.dispatchEvent(new Event("selectionchange"));
+ });
+ expect(result.current.selecting).toBe(false);
+ expect(result.current.rangeExtractor(viewport)[0]).toBe(98);
+ select();
+ const other = document.createTextNode("other output");
+ document.body.append(other);
+ act(() => {
+ window.getSelection()!.setBaseAndExtent(other, 0, other, 5);
+ document.dispatchEvent(new Event("selectionchange"));
+ });
+ expect(result.current.selecting).toBe(false);
+ expect(result.current.rangeExtractor(viewport)[0]).toBe(98);
+ });
+
+ it("keeps a drag active when incoming output rerenders the parent", () => {
+ const { result, rows, select, rerender } = setup();
+ fireEvent.mouseDown(rows[0]!, { button: 0 });
+ const pause = vi.fn();
+ rerender({ pause });
+ select();
+ expect(result.current.rangeExtractor(viewport)).toContain(50);
+ expect(pause).toHaveBeenCalled();
+ });
+
+ it("does not pin rows or pause output for a context click", () => {
+ const { result, rows, pause } = setup();
+ fireEvent.mouseDown(rows[0]!, { button: 2 });
+ expect(result.current.selecting).toBe(false);
+ expect(pause).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/features/run/useConsoleSelection.ts b/src/features/run/useConsoleSelection.ts
new file mode 100644
index 000000000..6fa897240
--- /dev/null
+++ b/src/features/run/useConsoleSelection.ts
@@ -0,0 +1,73 @@
+import { useCallback, useEffect, useEffectEvent, useState, type RefObject } from "react";
+import { defaultRangeExtractor, type Range } from "@tanstack/react-virtual";
+
+type SelectedRows = { start: number; end: number; dragging: boolean };
+
+/** Keep the browser's selection endpoints, and the text between them, mounted. */
+export function useConsoleSelection(host: RefObject, pauseFollow: () => void) {
+ const pause = useEffectEvent(pauseFollow);
+ const [selected, setSelected] = useState(null);
+
+ useEffect(() => {
+ const el = host.current;
+ if (!el) return;
+ let dragging = false;
+ const indexOf = (node: Node | null) => {
+ const element = node instanceof Element ? node : node?.parentElement;
+ const row = element?.closest("[data-index]");
+ return row && el.contains(row) ? Number(row.dataset.index) : null;
+ };
+ const update = () => {
+ const selection = window.getSelection();
+ const anchor = indexOf(selection?.anchorNode ?? null);
+ const focus = indexOf(selection?.focusNode ?? null);
+ const next =
+ selection && (!selection.isCollapsed || dragging) && anchor !== null && focus !== null
+ ? { start: Math.min(anchor, focus), end: Math.max(anchor, focus), dragging }
+ : null;
+ setSelected((prev) =>
+ prev?.start === next?.start && prev?.end === next?.end && prev?.dragging === next?.dragging ? prev : next,
+ );
+ if (next && !selection!.isCollapsed) pause();
+ };
+ const down = (event: MouseEvent) => {
+ if (event.button !== 0) return;
+ const index = indexOf(event.target as Node);
+ if (index === null) return;
+ dragging = true;
+ // Pin the anchor before a scroll can recycle it, even before selectionchange fires.
+ setSelected({ start: index, end: index, dragging: true });
+ pause();
+ };
+ const up = () => {
+ dragging = false;
+ update();
+ };
+ el.addEventListener("mousedown", down);
+ document.addEventListener("mouseup", up);
+ document.addEventListener("selectionchange", update);
+ return () => {
+ el.removeEventListener("mousedown", down);
+ document.removeEventListener("mouseup", up);
+ document.removeEventListener("selectionchange", update);
+ };
+ }, [host]);
+
+ const rangeExtractor = useCallback(
+ (range: Range) => {
+ const visible = defaultRangeExtractor(range);
+ if (!selected || !visible.length) return visible;
+ // During a drag, scrolling must also render every line up to the pointer.
+ const start = Math.max(0, selected.dragging ? Math.min(selected.start, visible[0]!) : selected.start);
+ const end = Math.min(
+ range.count - 1,
+ selected.dragging ? Math.max(selected.end, visible[visible.length - 1]!) : selected.end,
+ );
+ const indices = new Set(visible);
+ for (let i = start; i <= end; i++) indices.add(i);
+ return [...indices].sort((a, b) => a - b);
+ },
+ [selected],
+ );
+ return { rangeExtractor, selecting: selected !== null };
+}