Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/design/statusline-text-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Statusline text selection

## Problem

Virtualized History enables terminal-wide mouse tracking, so the terminal cannot
provide native text selection. Qwen Code's application-level selection currently
accepts presses only inside the history viewport, leaving the footer/statusline
unselectable.

## Design

Keep one selection controller and give it an ordered list of selectable frame
rectangles. The history viewport remains the primary rectangle. The default
layout passes a ref for the rendered footer through `Composer`, and
`MainContent` supplies its measured rectangle as the second target.

The controller records which rectangle owns a selection when the press starts.
Drag coordinates remain clamped to that rectangle, and frame/layout changes are
compared only within it. Input, dialogs, scrollbars, and other controls remain
outside the selectable targets, so their existing mouse behavior is unchanged.

This applies only to the existing Virtualized History path. Normal-buffer mode
continues to use terminal-native selection.

## Verification

- Dragging within history still highlights and copies history text.
- Dragging within a multi-line footer highlights and copies footer text.
- Presses in the gap between the history and footer do not start a selection.
- Footer selection is cleared when its content or layout changes.
- A live Virtualized History session can copy visible statusline text.
2 changes: 2 additions & 0 deletions docs/design/vp-mouse-selection/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ virtualRow = scrollTop + viewportRow

Before starting a selection, hit-test that `(col, layoutRow)` lies inside the history viewport content region and not in the scrollbar column, composer, or footer; presses elsewhere fall through to the existing scrollbar-drag / click-to-focus handlers. This arbitration is the contract between the new selection subscriber and the existing mouse subscribers.

The issue #8131 follow-up keeps that history-region arbitration but registers the footer as a separate selectable rectangle. A drag remains clamped to the rectangle where it started, so the composer and other controls stay excluded.

Anchors are stored in **virtual-row space** so a selection stays pinned to content, but in PR 1 any non-selection scroll/resize/streaming clears the selection (off-screen content is not cached), so virtual-row anchoring here is just consistent bookkeeping, not cross-screen persistence.

### Copy
Expand Down
12 changes: 8 additions & 4 deletions packages/cli/src/ui/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useCallback, useRef, useState } from 'react';
import { Box, Text, useIsScreenReaderEnabled, type DOMElement } from 'ink';
import { useCallback, useRef, useState, type RefObject } from 'react';
import { LoadingIndicator } from './LoadingIndicator.js';
import { InputPrompt } from './InputPrompt.js';
import { Footer } from './Footer.js';
Expand All @@ -20,7 +20,11 @@ import { StreamingState } from '../types.js';
import { FeedbackDialog } from '../FeedbackDialog.js';
import { t } from '../../i18n/index.js';

export const Composer = () => {
interface ComposerProps {
footerRef?: RefObject<DOMElement | null>;
}

export const Composer = ({ footerRef }: ComposerProps) => {
const config = useConfig();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const uiState = useUIState();
Expand Down Expand Up @@ -152,7 +156,7 @@ export const Composer = () => {
(showShortcuts ? (
<KeyboardShortcuts />
) : (
!isScreenReaderEnabled && <Footer />
!isScreenReaderEnabled && <Footer containerRef={footerRef} />
))}
</Box>
);
Expand Down
20 changes: 18 additions & 2 deletions packages/cli/src/ui/components/Footer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@

import { render } from 'ink-testing-library';
import { render as inkRender } from 'ink';
import type { DOMElement } from 'ink';
import stripAnsi from 'strip-ansi';
import { EventEmitter } from 'node:events';
import { createRef, type RefObject } from 'react';
import { act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Footer } from './Footer.js';
Expand Down Expand Up @@ -200,6 +202,7 @@ const renderAtLayoutWidth = (
columns: number,
uiState: UIState,
backgroundEntries: readonly DialogEntry[] = [],
containerRef?: RefObject<DOMElement | null>,
) => {
useTerminalSizeMock.mockReturnValue({ columns, rows: 24 });
let lastFrame = '';
Expand Down Expand Up @@ -231,10 +234,10 @@ const renderAtLayoutWidth = (
<BackgroundTaskViewStateContext.Provider
value={createBackgroundTaskState(backgroundEntries)}
>
<Footer />
<Footer containerRef={containerRef} />
</BackgroundTaskViewStateContext.Provider>
) : (
<Footer />
<Footer containerRef={containerRef} />
);
const instance = inkRender(
<SettingsContext.Provider value={mockSettings}>
Expand Down Expand Up @@ -280,6 +283,19 @@ describe('<Footer />', () => {
});
});

it('attaches the selectable-region ref to its outer box', () => {
const containerRef = createRef<DOMElement>();
const { unmount } = renderAtLayoutWidth(
80,
createMockUIState(),
[],
containerRef,
);

expect(containerRef.current).not.toBeNull();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-3 (round 1, still stands): the added ref test asserts only that containerRef.current is non-null, which passes no matter which element inside Footer carries the ref — the test name says "outer box" but the body only proves "some box". — Failure scenario: a future edit moves ref={containerRef} from the outer width="100%" Box to an inner section box → measureElementPosition returns a rect covering only that section, so part of the statusline silently becomes unselectable and drags clamp mid-footer, while this test stays green.

Suggested change
expect(containerRef.current).not.toBeNull();
expect(containerRef.current).not.toBeNull();
expect(containerRef.current?.yogaNode?.getComputedWidth()).toBe(80);

(the outer box spans the full 80-column layout width; an inner section box would not)

中文说明

[Suggestion] R1-3(第 1 轮提出,仍然存在):新增的 ref 测试只断言 containerRef.current 非空——无论 ref 挂在 Footer 内哪个元素上都会通过;测试名说的是 "outer box",但测试体只能证明 "某个 box"。— 失败场景:未来某次修改把 ref={containerRef} 从外层 width="100%" Box 移到某个内部区块 box → measureElementPosition 返回的矩形只覆盖该区块,状态栏的一部分会静默变得不可选择、拖拽会在 footer 中间被钳制,而本测试仍然全绿。建议断言几何:外层 box 在 80 列布局下计算宽度为 80,内部区块则不是。

— qwen3.8-max via Qwen Code /review (v0.21.10)

unmount();
Comment on lines +295 to +296

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-3 (round 1, still stands — partially addressed): the added ref test asserts only that containerRef.current is non-null, which passes no matter which element inside Footer carries the ref — probe-verified: moving ref={containerRef} from the outer Box to an inner Box keeps 34/34 Footer tests green while measureElementPosition would then return a partial-footer rect. The remaining wiring hops (DefaultAppLayoutMainContent/ComposerFooter) also still have zero end-to-end coverage (every surrounding test mocks the adjacent component). — Concrete cost: a refactor that moves the ref to the inner left-column box (which already carries statusLineRef), or drops any wiring hop, makes the footer unselectable again — the linked issue silently regresses while every test stays green.

Suggested fix: pin the ref to the outer box — e.g. expect(containerRef.current?.yogaNode.getComputedWidth()).toBe(80) under renderAtLayoutWidth(80, …) (only the outer width="100%" box spans the full layout width) — plus one integration-style test rendering the virtualized layout with a real footerRef, asserting the controller receives a non-empty additional rect once Footer mounts (the MainContent.tsx:471 wiring).

中文说明

R1-3(第 1 轮,仍然存在——仅部分解决):新增的 ref 测试只断言 containerRef.current 非空,无论 ref 挂在 Footer 内哪个元素上都能通过——已用探针验证:把 ref={containerRef} 从外层 Box 移到内层 Box,34/34 个 Footer 测试依然全绿,而 measureElementPosition 届时只会返回部分 footer 的矩形。其余传递链(DefaultAppLayoutMainContent/ComposerFooter)也仍然没有任何端到端覆盖(周边测试都把相邻组件 mock 掉了)。— 具体代价:任何把 ref 移到内层左列 box(已挂 statusLineRef)、或丢掉任一传递跳的重构,都会让 footer 重新变得不可选——关联 issue 会静默回归,而所有测试仍是绿的。建议修复:把断言钉到外层 box——例如在 renderAtLayoutWidth(80, …) 下断言 expect(containerRef.current?.yogaNode.getComputedWidth()).toBe(80)(只有 width="100%" 的外层 box 才有完整布局宽度)——并新增一个集成式测试:以真实 footerRef 渲染虚拟化布局,断言 Footer 挂载后控制器收到非空的附加矩形(即 MainContent.tsx:471 的接线)。

— qwen3.8-max via Qwen Code /review (v0.21.9)

});

it('passes the left-column width after a right pill reserves space', async () => {
const originalSandbox = process.env['SANDBOX'];
process.env['SANDBOX'] = 'qwen-code-docker';
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/ui/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import type React from 'react';
import { useRef } from 'react';
import { type RefObject, useRef } from 'react';
import { type DOMElement, Box, Text, useBoxMetrics } from 'ink';
import { theme } from '../semantic-colors.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
Expand Down Expand Up @@ -49,7 +49,11 @@ const PasteProgressBar: React.FC<{ progress: PasteProgress }> = ({
);
};

export const Footer: React.FC = () => {
interface FooterProps {
containerRef?: RefObject<DOMElement | null>;
}

export const Footer: React.FC<FooterProps> = ({ containerRef }) => {
const uiState = useUIState();
const config = useConfig();
const settings = useSettings();
Expand Down Expand Up @@ -209,6 +213,7 @@ export const Footer: React.FC = () => {
// (bottom), right section has indicators. Status line and hints coexist.
return (
<Box
ref={containerRef}
flexDirection={isNarrow ? 'column' : 'row'}
justifyContent={isNarrow ? 'flex-start' : 'space-between'}
width="100%"
Expand Down
24 changes: 21 additions & 3 deletions packages/cli/src/ui/components/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { Box, Static } from 'ink';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Static, type DOMElement } from 'ink';
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type RefObject,
} from 'react';
import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
import {
isHistoryItemVisibleAfterRestore,
Expand All @@ -32,6 +40,7 @@ import {
type ScrollableListRef,
} from './shared/ScrollableList.js';
import { TextSelectionController } from '../selection/use-text-selection.js';
import { measureElementPosition } from '../utils/measure-element-position.js';

// Limit Gemini messages to a very high number of lines to mitigate performance
// issues in the worst case if we somehow get an enormous response from Gemini.
Expand Down Expand Up @@ -114,7 +123,11 @@ const virtualKeyExtractor = (item: VpItem) =>
const virtualIsStaticItem = (item: VpItem) =>
item.type === 'vp-banner' || item.id > 0;

export const MainContent = () => {
interface MainContentProps {
footerRef?: RefObject<DOMElement | null>;
}

export const MainContent = ({ footerRef }: MainContentProps) => {
const { version } = useAppContext();
const uiState = useUIState();
const { allExpanded: fullDetail } = useThoughtExpanded();
Expand Down Expand Up @@ -460,6 +473,11 @@ export const MainContent = () => {
<TextSelectionController
isActive={!uiState.dialogsVisible}
getViewportRect={() => scrollRef.current?.getViewportRect() ?? null}
getAdditionalSelectableRects={() =>
footerRef?.current
? [measureElementPosition(footerRef.current)]
: []
Comment thread
DragonnZhang marked this conversation as resolved.
}
Comment on lines +476 to +480

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R3-2: the production wiring that computes the footer's selectable rect has no end-to-end test — MainContent.test.tsx stubs TextSelectionController (() => null), Composer.test.tsx mocks Footer (dropping containerRef), DefaultAppLayout.test.tsx mocks both MainContent and Composer, and every controller test hand-supplies rects. Distinct from the round-1 ref-threading thread (answered by the Footer attachment test): that test pins the attachment hop; nothing pins this callback or that the SAME ref object reaches both the measurer and the attacher. — Failure scenario: a refactor that drops or splits footerRef (Composer stops forwarding it; DefaultAppLayout passes distinct refs to the two children) silently disables footer selection or measures a wrong element in the real layout, with the entire suite green.

Suggested fix: add one integration-level test asserting TextSelectionController receives a getAdditionalSelectableRects whose result matches the real Footer's measured position — or, cheaper, assert in Composer.test.tsx that the mocked Footer receives the same ref object passed as footerRef.

中文说明

[Suggestion] R3-2:计算 footer 可选择矩形的生产接线没有任何端到端测试——MainContent.test.tsxTextSelectionController 桩成 () => nullComposer.test.tsx mock 掉 Footer(丢弃 containerRef),DefaultAppLayout.test.tsx 同时 mock MainContentComposer,而所有控制器测试都手工注入矩形。与第 1 轮的 ref 传递线程问题不同(那个已由 Footer 附着测试回应):那个测试钉住的是附着跳点;本回调本身、以及「同一个 ref 对象同时到达测量方与附着方」没有任何测试钉住。— 失败场景:某次重构丢弃或拆分了 footerRef(Composer 不再转发;DefaultAppLayout 给两个子组件传入不同的 ref),真实布局中 footer 选择会被静默禁用或测量到错误元素,而整个测试套件全绿。建议修复:新增一个集成层测试,断言 TextSelectionController 收到的 getAdditionalSelectableRects 结果与真实 Footer 的测量位置一致——或更便宜地,在 Composer.test.tsx 中断言 mock 的 Footer 收到的 ref 对象与传入的 footerRef 是同一个。

— qwen3.8-max via Qwen Code /review (v0.21.10)

getScrollState={() =>
scrollRef.current?.getScrollState() ?? {
scrollTop: 0,
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/src/ui/layouts/DefaultAppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import type React from 'react';
import { useEffect, useRef } from 'react';
import { Box } from 'ink';
import { Box, type DOMElement } from 'ink';
import { MainContent } from '../components/MainContent.js';
import { UpdateNotification } from '../components/UpdateNotification.js';
import { DialogManager } from '../components/DialogManager.js';
Expand All @@ -29,6 +29,7 @@ import { getDialogMaxHeight } from '../utils/layoutUtils.js';

export const DefaultAppLayout: React.FC = () => {
const uiState = useUIState();
const footerRef = useRef<DOMElement>(null);
const { refreshStatic } = useUIActions();
const { activeView, agents } = useAgentViewState();
const { columns: terminalWidth } = useTerminalSize();
Expand Down Expand Up @@ -79,7 +80,7 @@ export const DefaultAppLayout: React.FC = () => {
) : (
<>
{/* Main view: conversation history + main composer / dialogs */}
<MainContent />
<MainContent footerRef={footerRef} />
<Box flexDirection="column" ref={uiState.mainControlsRef}>
{!uiState.dialogsVisible && uiState.updateInfo && (
<UpdateNotification message={uiState.updateInfo.message} />
Expand Down Expand Up @@ -114,7 +115,7 @@ export const DefaultAppLayout: React.FC = () => {
/>
</Box>
)}
<Composer />
<Composer footerRef={footerRef} />
</>
)}
<ExitWarning />
Expand Down
66 changes: 64 additions & 2 deletions packages/cli/src/ui/selection/selection-span.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,73 @@ describe('wordSpanAt', () => {
describe('lineSpanAt', () => {
it('spans from column 0 to the last non-space cell', () => {
const frame = frameFromLines([' hi there ']);
expect(lineSpanAt(frame, 0)).toEqual({ sx: 0, sy: 0, ex: 9, ey: 0 });
expect(lineSpanAt(frame, 4, 0)).toEqual({ sx: 0, sy: 0, ex: 9, ey: 0 });
});

it('returns null for a blank line', () => {
const frame = frameFromLines([' ']);
expect(lineSpanAt(frame, 0)).toBeNull();
expect(lineSpanAt(frame, 2, 0)).toBeNull();
});

it('stops at a non-selectable layout gap', () => {
const frame = frameFromLines(['status 42%']);
for (let x = 6; x < 10; x++) {
(frame.cells[0][x] as FrameCell).selectable = false;
}

expect(lineSpanAt(frame, 2, 0)).toEqual({
sx: 0,
sy: 0,
ex: 5,
ey: 0,
});
expect(lineSpanAt(frame, 7, 0)).toEqual({
sx: 0,
sy: 0,
ex: 5,
ey: 0,
});
expect(lineSpanAt(frame, 9, 0)).toEqual({
sx: 10,
sy: 0,
ex: 12,
ey: 0,
});
});

it('snaps non-selectable history padding and gutters to visible content', () => {
const padded = frameFromLines(['history ']);
for (let x = 7; x < padded.cells[0].length; x++) {
(padded.cells[0][x] as FrameCell).selectable = false;
}
expect(lineSpanAt(padded, 9, 0)).toEqual({
sx: 0,
sy: 0,
ex: 6,
ey: 0,
});

const gutter = frameFromLines([' 1 code']);
for (let x = 0; x < 4; x++) {
(gutter.cells[0][x] as FrameCell).selectable = false;
}
expect(lineSpanAt(gutter, 1, 0)).toEqual({
sx: 4,
sy: 0,
ex: 7,
ey: 0,
});
});

it('snaps a non-selectable wide-character spacer to its glyph', () => {
const frame = frameFromLines(['中']);
(frame.cells[0][1] as FrameCell).selectable = false;

expect(lineSpanAt(frame, 1, 0)).toEqual({
sx: 0,
sy: 0,
ex: 0,
ey: 0,
});
});
});
Loading
Loading