Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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();
unmount();
});

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.
Resolved
}
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
19 changes: 17 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,26 @@ 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)).toBeNull();
});
});
31 changes: 22 additions & 9 deletions packages/cli/src/ui/selection/selection-span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ function isWordCell(value: string): boolean {
return value !== '' && value !== ' ' && !/^\s$/u.test(value);
}

/** Trailing column of the last non-space cell on a row, or -1 if blank. */
function lastContentColumn(row: ReadonlyFrame['cells'][number]): number {
for (let x = row.length - 1; x >= 0; x--) {
/** Trailing column of the last non-space cell in a row range, or -1 if blank. */
function lastContentColumn(
row: ReadonlyFrame['cells'][number],
start: number,
end: number,
): number {
for (let x = end; x >= start; x--) {
if (row[x].value !== '' && row[x].value !== ' ') {
return x;
}
Expand Down Expand Up @@ -61,20 +65,29 @@ export function wordSpanAt(
return { sx, sy: y, ex, ey: y };
}

/** Whole visual line span (first column to last non-space), or null if blank. */
/** Contiguous selectable line span around a click, or null if blank. */
Comment thread
DragonnZhang marked this conversation as resolved.
Resolved
export function lineSpanAt(
frame: ReadonlyFrame | null,
x: number,
y: number,
): NormalizedSelection | null {
const row = frame?.cells[y];
if (!row) {
if (!row?.[x]?.selectable) {
return null;
}
const end = lastContentColumn(row);
if (end < 0) {
let start = x;
while (start > 0 && row[start - 1].selectable) {
start--;
}
let runEnd = x;
while (runEnd < row.length - 1 && row[runEnd + 1].selectable) {
runEnd++;
}
const contentEnd = lastContentColumn(row, start, runEnd);
if (contentEnd < start) {
return null;
}
return { sx: 0, sy: y, ex: end, ey: y };
return { sx: start, sy: y, ex: contentEnd, ey: y };
}

/** Resolve the span at a point for a word/line selection mode. */
Expand All @@ -85,5 +98,5 @@ export function spanAtForMode(
): NormalizedSelection | null {
return mode === 'word'
? wordSpanAt(frame, point.x, point.y)
: lineSpanAt(frame, point.y);
: lineSpanAt(frame, point.x, point.y);
}
11 changes: 11 additions & 0 deletions packages/cli/src/ui/selection/selection-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ describe('getSelectedText', () => {
expect(getSelectedText(frame, { sx: 0, sy: 0, ex: 4, ey: 0 })).toBe('hi');
});

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

expect(getSelectedText(frame, { sx: 0, sy: 0, ex: 12, ey: 0 })).toBe(
'status 42%',
);
});

it('replaces a soft visual break with its source joiner', () => {
const frame = frameFromLines(['hello', 'world']);
setBoundary(frame, 0, 'soft', ' ');
Expand Down
20 changes: 18 additions & 2 deletions packages/cli/src/ui/selection/selection-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,27 @@ export function getSelectedText(
}
const startX = y === sy ? sx : 0;
const endX = y === ey ? ex : row.length - 1;
let rowText = '';
let skippedLayoutGap = false;
for (let x = Math.max(0, startX); x <= endX && x < row.length; x++) {
if (row[x].selectable) {
text += row[x].value;
const cell = row[x];
if (cell.selectable) {
if (
skippedLayoutGap &&
rowText.length > 0 &&
cell.value !== '' &&
!/\s$/u.test(rowText) &&
!/^\s/u.test(cell.value)
) {
rowText += ' ';
}
rowText += cell.value;
skippedLayoutGap = false;
} else if (rowText.length > 0) {
skippedLayoutGap = true;
}
}
text += rowText;
if (y < ey) {
text += boundaryJoiner(frame, selection, y);
}
Expand Down
Loading
Loading