feat(cli): preserve semantic text when copying VP selections (#7286)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run

* docs(cli): define semantic copy fidelity scope

* docs(cli): address semantic frame review gaps

* docs(cli): preserve soft-wrap source separators

* feat(cli): preserve semantic selection copy

* fix(cli): address semantic copy review findings

* fix(cli): preserve clipped semantic boundaries

* fix(cli): limit separator carrier joiner to visible width in wrap metadata

The greedy /\s+/ match in wrapTextWithMetadata could capture more
source whitespace than the separator carrier row actually consumed
(e.g. a tab following a space), causing duplicated whitespace in
semantic copy. Limit the match to visibleLine.length characters and
add a mixed space/tab regression test.

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
This commit is contained in:
ChiGao 2026-07-22 22:06:03 +08:00 committed by GitHub
parent ce40e33a65
commit d064bd7dcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1423 additions and 112 deletions

View file

@ -8,8 +8,8 @@ This document proposes an application-level text selection and copy system for V
The feature is delivered in **two PRs** with a deliberately conservative first release:
- **PR 1 (this branch, #6937)** — the design followed by the Ink frame-buffer foundation and a **visible-region visual selection** MVP: drag-select within the currently visible viewport, word/line selection, and copy-on-select. Copy returns the **visual cells** as shown on screen; it does not yet rejoin soft-wrapped lines or exclude gutters. Selection clears on scroll/resize/streaming.
- **PR 2 (follow-up)**completion: cross-screen selection (off-screen accumulation, edge auto-scroll, reverse drag) and **semantic copy fidelity** (soft-wrap rejoin, gutter/decoration exclusion), which require renderer-level semantic metadata rather than a raw grid.
- **PR 1 (#6937, merged)** — the Ink frame-buffer foundation and a **visible-region visual selection** MVP: drag-select within the currently visible viewport, word/line selection, and copy-on-select. Copy returns the **visual cells** as shown on screen; it does not yet rejoin soft-wrapped lines or exclude gutters. Selection clears on scroll/resize/streaming.
- **PR 2 (follow-up)****semantic copy fidelity**: soft-wrap rejoin and gutter/decoration exclusion, which require renderer-level semantic metadata rather than a raw grid. Cross-screen selection remains outside this two-PR sequence.
This split follows the "Simplicity First" principle: PR 1 solves the core problem (mouse select-and-copy of what's on screen) with a small, well-scoped Ink patch and no per-renderer text serialization; the deeper renderer semantic extension is isolated in PR 2 so it can be reviewed against its own cost.
@ -25,10 +25,11 @@ The design was revised after an implementation-feasibility audit against the Ink
- Copy delivers the visual cells as displayed, across local (pbcopy/xclip/xsel/clip), remote (OSC 52), and multiplexer (tmux/screen passthrough) environments, reusing existing clipboard infrastructure. Wide characters (CJK/emoji) and their spacer cells are handled; per-line trailing padding is trimmed.
- Selection clears deterministically on any non-selection scroll, resize, or streaming/layout change.
### PR 2 — completion (follow-up)
### PR 2 — semantic copy fidelity (follow-up)
- Cross-screen selection: dragging past a viewport edge auto-scrolls and accumulates off-screen rows so a long selection copies in full; reverse drag works.
- Semantic copy fidelity: soft-wrapped logical lines are rejoined (true hard newlines preserved); gutter/decoration cells (line-number gutters, scrollbar column, and confirmed decorations) are excluded, per per-renderer copy rules for diff/table/markdown/tool output.
- Soft-wrapped logical lines are rejoined while true hard newlines are preserved.
- The VP scrollbar and code/diff line-number gutters are excluded.
- Diff markers, Markdown markers, table borders, and tool-output prefixes remain copyable content.
## Non-goals
@ -36,6 +37,7 @@ The design was revised after an implementation-feasibility audit against the Ink
- Reading text from the system clipboard (paste). Text paste continues to flow through terminal bracketed-paste; only image paste reads the clipboard today, and that path is untouched.
- Selection inside the input composer beyond what already exists; the composer already maps clicks to cursor offsets.
- Rich-copy (HTML/ANSI). We copy plain text.
- Cross-screen selection, edge auto-scroll, and off-screen row accumulation.
## Background: why native selection breaks in VP mode
@ -71,7 +73,7 @@ The audit confirmed the direction but corrected a category error in the earlier
- **Soft vs hard wrap is unrecoverable from the grid.** `render-node-to-output.ts` calls `wrapText()` and only then passes newline-joined strings to `Output.write()`; `Output.get()` sees `text.split('\n')` and cannot tell a soft wrap from a hard newline. A hard break that exactly fills the wrap width is byte-identical in the grid to a soft wrap. "Line width + absence of intervening content" does not disambiguate it.
- **Selectability has no data source in the grid.** Gutters, the scrollbar column, and decorations cannot be reliably identified by column region and style across markdown/diff/table/tool output.
- **The grid is one frame, not the virtual document.** `VirtualizedList` mounts only the visible range; off-screen items have no cells. Cross-screen selection therefore needs off-screen snapshotting and a content/layout-version lifecycle, not a bigger grid.
- **The grid is one frame, not the virtual document.** `VirtualizedList` mounts only the visible range; off-screen items have no cells. Cross-screen selection would require off-screen snapshotting and a content/layout-version lifecycle, so it remains separate from semantic copy fidelity.
Therefore:
@ -153,25 +155,76 @@ viewport and is active whenever `ui.useTerminalBuffer` is enabled.
## Follow-up: semantic copy fidelity (PR 2)
To rejoin soft wraps and exclude decorations without per-renderer serialization, the frame is extended with semantics produced where they still exist — at wrap time — and propagated through compositing:
To rejoin soft wraps and exclude decorations without per-renderer serialization, semantics are produced wherever wrapping still has source context and propagated through compositing. A break is a boundary between visual rows, not a property of an arbitrary character cell:
```ts
type SelectableCell = {
value: string;
width: 1 | 2;
sourceId?: string;
logicalOffset?: number;
type SemanticFrameCell = FrameCell & {
selectable: boolean;
breakAfter: 'none' | 'soft' | 'hard';
flowId: number | null;
};
type RowBoundary = {
kind: 'soft' | 'hard';
flowId: number;
selectable: boolean;
joiner: string;
};
type SemanticFrame = {
cells: ReadonlyArray<ReadonlyArray<SemanticFrameCell>>;
// boundaries[y][x] describes the transition from row y to row y + 1
boundaries: ReadonlyArray<ReadonlyArray<RowBoundary | null>>;
};
```
- `breakAfter` is produced when `wrapText()` splits a line (soft) vs. when a source newline is present (hard); it cannot be inferred in `Output.get()`.
- `selectable` is set by the app/renderer for gutters, the scrollbar column, and confirmed decorations — not guessed from style.
- The compositor still determines final cell coverage, so visual coordinates stay uniform across renderers.
- Selection extraction then rejoins soft-continuation runs while preserving true hard newlines, and skips `selectable: false` cells.
- A `flowId` identifies one logical text flow within a frame. Direct `Text` node identity and opaque flow keys supplied by pre-Ink producers are mapped through one per-render allocator, so the numeric IDs cannot collide. A producer gives every visual fragment of its logical row the same key. IDs only need to survive one published frame because PR 1 already invalidates selection when content changes.
- `boundaries[y][x]` is a compositor-aligned boundary grid. A writer paints a boundary over its horizontal layout interval, independently of whether that row emitted a character. A zero-width empty flow claims one slot at its clamped layout X coordinate. This represents empty and whitespace-only hard lines without inventing an owner cell.
- Ink emits `soft` when its own width wrapping continues a flow and `hard` for an explicit source newline. A visual row assembled from sibling `Text` nodes has no implicit boundary claim.
- `joiner` is the exact plain-text source separator omitted at that boundary. A character-level wrap uses `''`; a word wrap that removes one space, several spaces, or a tab stores that exact substring. A separator still represented by selectable source cells is not duplicated in `joiner`. Hard boundaries normalize CRLF to `joiner: '\n'`.
- `selectable` defaults to `true`. A `selectable={false}` `Text` subtree marks both its cells and boundary claims false; the renderer never guesses from glyph, column, or style.
- Clipping trims boundary intervals using the same clip rectangle as cells. Every later overlapping write paints either its boundary or an explicit `null` over its covered columns, so stale claims cannot survive underneath the final composition. A clipped character cannot erase a still-visible row boundary because the boundary is stored independently.
Per-renderer copy rules must be decided as product rules, not by auto-excluding anything decoration-like: scrollbar excluded; diff line-number gutter excludable; diff `+/-` likely kept (users may want the full patch); markdown list/quote markers usually kept (content); table borders and tool-output prefix/status glyphs decided case by case. PR 2 also carries the cross-screen work (off-screen row snapshot/cache, edge auto-scroll, reverse drag) and the content/layout-version invalidation rules that let a selection survive scrolling.
Selection extraction remains conservative. It skips non-selectable cells and boundary claims. For each selected visual-row transition it finds the selectable flows represented by selected source cells on both sides, then considers the non-null boundary claims for those flows; null slots outside a flow's painted interval are irrelevant. It replaces the visual newline with `joiner` only when there is one common flow and all of its claims agree on `soft` and the same joiner. A `hard` claim emits `\n`; no common flow, a missing claim for a common flow, multiple common flows, or conflicting kind/joiner claims retain one visual newline. The joiner is emitted only when the selection includes source content on both sides, so starting or ending on a continuation row does not pull in an unselected separator. Consecutive empty hard rows occupy distinct grid transitions and therefore preserve every newline.
Source whitespace remains selectable content, including whitespace-only hard rows. One Ink-specific carrier case is reconstructed instead: `wrap-ansi` can expand an omitted separator such as a tab into a whitespace-only soft visual row, so that carrier row is non-selectable and the exact source separator lives in the preceding soft boundary's `joiner`. The extractor no longer calls `trimEnd()` on semantic rows; it drops only cells explicitly marked non-selectable by the renderer. Ink layout padding and `MaxSizedBox` continuation-gutter padding are also non-selectable, while table padding remains selectable under the table product rule below. This prevents fidelity metadata from preserving terminal layout spaces, copying an expanded tab instead of its source byte, or discarding real trailing spaces.
### Pre-Ink wrap producers
Some application renderers destroy hard/soft information before `render-node-to-output`, so S0 includes them explicitly:
| Producer | Contract |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `MaxSizedBox` | Its internal line model becomes `{segments, flowKey, breakAfter, joiner}`. Splitting an input `\n` or moving between input row `Box` elements emits `hard` with `joiner: '\n'`; character wrapping emits `soft` with `joiner: ''`; a whitespace token discarded or relocated by word wrapping becomes the exact soft joiner. All fragments of that input row keep one flow key. Synthesized continuation-gutter padding is non-selectable. |
| `CodeColorizer` and `DiffRenderer` | Their source-line `Box` boundaries remain hard through `MaxSizedBox`; width continuations are soft. Code/diff line-number segments opt out, while content and diff markers remain in the row's selectable flow. |
| `ToolMessage.sliceTextForMaxHeight` | It returns semantic visual-line descriptors instead of a newline-joined string, preserving whether each retained transition came from source `\n` or width slicing and preserving any omitted separator as `joiner`. `MaxSizedBox` consumes those descriptors without reclassifying them. |
| `AnsiOutput` | Each input `AnsiLine` is a real tool-output row and stays hard. ANSI token boundaries remain cells in that row, and `MaxSizedBox` truncation does not create a soft claim. |
| `TableRenderer` | Table copy intentionally preserves the displayed plain-text table. Its pre-rendered newline-joined rows are treated as hard visual boundaries, including cell wrapping; it does not claim soft continuations in this PR. |
| Ink `Text` wrapping | The patched wrap helper retains the ANSI-stripped source line while wrapping and maps each wrapped segment forward from the prior source offset. It records exact omitted whitespace in `joiner`, leaves joiner empty when whitespace remains in source cells, marks whitespace-only separator carrier rows non-selectable, and leaves hard whitespace-only source rows selectable. |
Any new pre-Ink wrapper must either return the same semantic line descriptors or explicitly preserve its generated rows as hard visual boundaries. Inferring semantics later from rendered width remains forbidden.
The remaining width helpers are not hidden producers for this contract. `CompactToolGroupDisplay` and `ToolConfirmationMessage` use `wrapAnsi` only to estimate height; Ink still performs their visible wrapping. The collapsed streaming Thought preview produced by `tailVisualLines` is deliberately visual-only because it is a lossy tail window and streaming already invalidates selection; its generated rows remain hard. `sliceTextByVisualHeight` currently has no production caller.
The copy rules are deliberately narrow:
| Surface | Rule | Reason |
| ----------------------------------- | ------- | ----------------------------------------------------------------------------------------------------- |
| VP scrollbar | Exclude | Navigation chrome, never conversation content. |
| Ink and continuation layout padding | Exclude | Renderer-inserted spacing is not source text; source whitespace remains selectable. |
| Code and diff line-number gutters | Exclude | Positional display aids; copied code and patches should not gain synthetic line numbers. |
| Diff `+` / `-` markers | Keep | They are required to copy a usable patch. |
| Markdown list and quote markers | Keep | They carry Markdown meaning. |
| Table borders, padding, and rows | Keep | This PR preserves the displayed plain-text table; structured cell export would be a separate feature. |
| Tool prefixes and status glyphs | Keep | They identify the displayed output; no existing category has a confirmed non-content rule. |
No other renderer may opt out cells in this PR without adding a product rule and a copy snapshot here. This prevents style-based heuristics from silently dropping user content.
### PR 2 milestones
- **S0 — semantic line contract.** Add the independent boundary grid with exact joiners and preserve it through direct Ink wrapping, `MaxSizedBox`, `Output.write`, clipping, overlap, and the published frame. Prove exact-width and empty hard newlines, whitespace-only rows, single/multiple-space and tab joiners, ANSI, CJK/emoji, clipped boundaries, and sibling conflicts.
- **S1 — Semantic extraction.** Replace unambiguous soft visual breaks with their joiner, retain hard/ambiguous newlines, preserve source whitespace, skip non-selectable layout cells, and keep PR 1 highlight behavior unchanged.
- **S2 — Confirmed exclusions and producer coverage.** Mark the VP scrollbar, synthesized continuation padding, and code/diff line-number gutters `selectable={false}`. Add direct `MaxSizedBox` code/diff/tool/ANSI fixtures plus Before/After fixtures for table and Markdown output.
- **S3 — E2E and compatibility.** Verify real terminal/tmux clipboard payloads and run the existing VP selection regression set.
## Edge cases and risks
@ -187,10 +240,13 @@ Per-renderer copy rules must be decided as product rules, not by auto-excluding
| Performance | Large history + per-drag repaint | Highlight is one immutable transform; repaint bounded by Ink `maxFps` (~30) |
| Non-VP mode | Enabling ownership would hijack native selection | Strictly VP-gated |
| Streaming under selection | Content shifts beneath a selection | PR 1: clear on scroll/resize/streaming (visible-region only) |
| Pre-Ink wrapping | App renderers erase source break semantics | Semantic line descriptors in `MaxSizedBox`; table rows explicitly stay visual |
| Boundary ambiguity | Empty, clipped, or sibling rows have no owner cell | Independent boundary grid; only unambiguous single-flow soft boundaries rejoin |
| Separator loss | Wrapping drops or relocates spaces and tabs | Exact soft-boundary joiner; source whitespace distinguished from layout padding |
## Milestones
PR 1 (this branch) — each milestone a separate, reviewable commit; no semantic-fidelity work mixed in:
PR 1 (#6937, merged) — each milestone was a separate, reviewable commit with no semantic-fidelity work mixed in:
- **M0 — Ink frame-buffer foundation (go/no-go).** The `FrameController` (getFrame/setSelection/subscribe), the pre-serialization immutable highlight transform, and throttled invalidation, with a minimal real consumer. This is the feasibility gate.
- **M1 — Selection state machine + coordinate mapping (visible region).** press/drag/release → visual-cell range → `getSelectedText` → copy. No highlight yet.
@ -198,7 +254,7 @@ PR 1 (this branch) — each milestone a separate, reviewable commit; no semantic
- **M3 — Copy interaction.** Copy-on-select through the existing clipboard utility.
- **M4 — Word/visual-line selection + settings + invalidation + docs.**
PR 2 (follow-up) — cross-screen selection and semantic copy fidelity, as described above.
PR 2 (follow-up) — semantic copy fidelity, as described above.
## Merge gate (PR 1)
@ -213,11 +269,23 @@ PR 2 (follow-up) — cross-screen selection and semantic copy fidelity, as descr
- Repaint frequency respects Ink's default `maxFps` (~30, ~33 ms); the throttle is Ink's, not an app-side 16 ms timer.
- Real terminal/tmux E2E covers drag-select, highlight, and clipboard payload.
## Merge gate (PR 2)
- Soft wraps and explicit newlines—including exact-width, empty, and whitespace-only lines—remain distinguishable.
- ANSI, CJK/emoji, and multiple sibling `Text` nodes on one visual row do not leak flow, break, or selectability metadata.
- Clipping and overlap publish final visible cell metadata and independently composited boundary slots; clipping a cell never drops a still-visible boundary.
- Conflicting sibling flows conservatively retain a visual newline; only an unambiguous single-flow soft boundary is rejoined.
- Direct Ink and `MaxSizedBox` fixtures preserve one space, multiple spaces, and tabs across word wrapping, emit no duplicate when whitespace remains in cells, and add no joiner outside the selected endpoints.
- `MaxSizedBox` directly proves hard/soft boundaries and non-selectable continuation padding, and code, diff, tool, and ANSI consumers each have an integration fixture.
- Every `selectable={false}` site matches a product rule above and has a copy snapshot.
- Diff, table, Markdown, and tool output have Before/After extraction fixtures.
- Existing PR 1 highlighting, invalidation, copy, and repaint-frequency tests remain green.
## Testing strategy
- **Unit** — coordinate mapping (wide chars, scroll offset, frame anchor); `selection-text.ts` visual extraction; mouse arbitration.
- **Snapshot**`getSelectedText` (visual) over markdown, a diff, a table, and tool output, asserting extracted text matches the _visible_ text.
- **Patch guard** — asserts the exposed frame's shape/dimensions so an Ink upgrade breaks loudly.
- **Unit**PR 1 coordinate/mouse coverage plus semantic extraction over exact-width, empty, whitespace-only, joiner, endpoint, clipped, overlapping, and conflicting-flow boundaries.
- **Producer fixtures** — direct `MaxSizedBox` wrapping plus code, diff, tool, ANSI, Markdown, and table Before/After copy snapshots.
- **Patch guard** — asserts the exposed cell and boundary grids, direct Ink hard/soft wrapping, and compositing behavior so an Ink upgrade breaks loudly.
- **E2E** — interactive tmux harness: drag-select across wrapped visual lines and multiple items, confirm clipboard/OSC 52 payload. Follows the `.qwen/e2e-tests/` workflow.
## Rollout
@ -226,5 +294,5 @@ The feature is VP-only, and VP mode is itself opt-in (`ui.useTerminalBuffer` def
## Open questions
- Is deepening the Ink patch to a frame controller acceptable to maintainers for PR 1, and the semantic-frame extension for PR 2? PR 2 is a renderer semantic change and should be evaluated at that risk level.
- For PR 2's `selectable`/copy rules: confirm the per-renderer product decisions (diff `+/-`, table borders, tool-output glyphs) before implementation.
- Is the semantic-frame extension acceptable to maintainers? PR 2 changes both the Ink patch and the application-level pre-wrap contract and should be evaluated at that risk level.
- Should cross-screen selection be pursued after semantic fidelity proves stable? It needs a separate virtual-document design and is not part of PR 2.

View file

@ -7,6 +7,8 @@
import { render } from 'ink-testing-library';
import { AnsiOutputText } from './AnsiOutput.js';
import type { AnsiOutput, AnsiToken } from '@qwen-code/qwen-code-core';
import { getScreenBuffer } from '../selection/screen-buffer.js';
import { getSelectedText } from '../selection/selection-text.js';
// Helper to create a valid AnsiToken with default values
const createAnsiToken = (overrides: Partial<AnsiToken>): AnsiToken => ({
@ -80,6 +82,26 @@ describe('<AnsiOutputText />', () => {
expect(lines[2]).toBe('Third line');
});
it('copies styled rows with hard line boundaries', () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'first', bold: true })],
[createAnsiToken({ text: 'second', fg: '#ff0000' })],
];
const { stdout } = render(<AnsiOutputText data={data} maxWidth={80} />);
const frame = getScreenBuffer(
stdout as unknown as NodeJS.WriteStream,
)!.frame!;
expect(
getSelectedText(frame, {
sx: 0,
sy: 0,
ex: frame.width - 1,
ey: frame.height - 1,
}),
).toBe('first\nsecond');
});
it('respects the availableTerminalHeight prop and slices the lines correctly', () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'Line 1' })],

View file

@ -10,6 +10,8 @@ import { DiffRenderer } from './DiffRenderer.js';
import * as CodeColorizer from '../../utils/CodeColorizer.js';
import { vi } from 'vitest';
import type { LoadedSettings } from '../../../config/settings.js';
import { getScreenBuffer } from '../../selection/screen-buffer.js';
import { getSelectedText } from '../../selection/selection-text.js';
const mockSettings: LoadedSettings = {
merged: {
@ -442,6 +444,32 @@ index 0000001..0000002 100644
expect(output).toContain('2 ');
});
it('excludes line numbers but keeps diff markers when copied', () => {
const { stdout } = render(
<OverflowProvider>
<DiffRenderer
diffContent={diffContent}
filename="test.txt"
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
const frame = getScreenBuffer(
stdout as unknown as NodeJS.WriteStream,
)!.frame!;
const copied = getSelectedText(frame, {
sx: 0,
sy: 0,
ex: frame.width - 1,
ey: frame.height - 1,
});
expect(copied).toContain('- old line 1');
expect(copied).toContain('+ new line 1');
expect(copied).not.toMatch(/^\d+ [-+]/mu);
});
it('should hide line numbers when showLineNumbers is false', () => {
const mockSettings = {
merged: {

View file

@ -308,6 +308,7 @@ const renderDiffContent = (
<Box key={lineKey} flexDirection="row">
{showLineNumbers && (
<Text
selectable={false}
color={semanticTheme.text.secondary}
backgroundColor={
line.type === 'add'

View file

@ -18,6 +18,8 @@ import type {
Config,
} from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../../config/settings.js';
import { getScreenBuffer } from '../../selection/screen-buffer.js';
import { getSelectedText } from '../../selection/selection-text.js';
// Global compact mode was removed (#5666); type-based tool rendering no longer
// consumes a compact-mode context.
@ -468,7 +470,7 @@ describe('<ToolMessage />', () => {
// The C0 strip regex intentionally skips \x09 (TAB) and \x0a (LF) so
// multi-line / column-aligned file output still renders. Lock that in:
// stripping them would collapse the segments together.
const { lastFrame } = renderWithContext(
const { lastFrame, stdout } = renderWithContext(
<ToolMessage
{...baseProps}
name="ReadFile"
@ -489,6 +491,17 @@ describe('<ToolMessage />', () => {
expect(output).toContain('row2B');
expect(output).not.toContain('colAcolB');
expect(output).not.toContain('colBrow2A');
const frame = getScreenBuffer(
stdout as unknown as NodeJS.WriteStream,
)!.frame!;
expect(
getSelectedText(frame, {
sx: 0,
sy: 0,
ex: frame.width - 1,
ey: frame.height - 1,
}),
).toContain('colA\tcolB\nrow2A\trow2B');
});
it('keeps the summary when forced but NOT in fullDetail mode (main-view force)', () => {

View file

@ -95,7 +95,11 @@ function sliceTextForMaxHeight(
text: string,
maxHeight: number | undefined,
maxWidth: number,
): { text: string; hiddenLinesCount: number } {
): {
text: string;
hiddenLinesCount: number;
sourceBoundaries?: Array<{ kind: 'soft' | 'hard'; joiner: string }>;
} {
if (maxHeight === undefined) {
return { text, hiddenLinesCount: 0 };
}
@ -103,41 +107,49 @@ function sliceTextForMaxHeight(
const targetMaxHeight = Math.max(Math.round(maxHeight), MINIMUM_MAX_HEIGHT);
const visibleContentHeight = targetMaxHeight - 1;
const visualWidth = Math.max(1, Math.floor(maxWidth));
const visibleLines: string[] = [];
const visibleLines: Array<{
text: string;
breakAfter: { kind: 'soft' | 'hard'; joiner: string } | null;
}> = [];
let visualLineCount = 0;
let currentLine = '';
let currentLineWidth = 0;
const appendVisibleLine = (line: string) => {
const appendVisibleLine = (
line: string,
breakAfter: { kind: 'soft' | 'hard'; joiner: string } | null,
) => {
visualLineCount += 1;
visibleLines.push(line);
visibleLines.push({ text: line, breakAfter });
if (visibleLines.length > visibleContentHeight) {
visibleLines.shift();
}
};
const flushCurrentLine = () => {
appendVisibleLine(currentLine);
const flushCurrentLine = (
breakAfter: { kind: 'soft' | 'hard'; joiner: string } | null,
) => {
appendVisibleLine(currentLine, breakAfter);
currentLine = '';
currentLineWidth = 0;
};
for (const char of toCodePoints(text)) {
if (char === '\n') {
flushCurrentLine();
flushCurrentLine({ kind: 'hard', joiner: '\n' });
continue;
}
const charWidth = Math.max(getCachedStringWidth(char), 1);
if (currentLineWidth > 0 && currentLineWidth + charWidth > visualWidth) {
flushCurrentLine();
flushCurrentLine({ kind: 'soft', joiner: '' });
}
currentLine += char;
currentLineWidth += charWidth;
}
flushCurrentLine();
flushCurrentLine(null);
if (visualLineCount <= targetMaxHeight) {
return { text, hiddenLinesCount: 0 };
@ -145,8 +157,11 @@ function sliceTextForMaxHeight(
const hiddenLinesCount = visualLineCount - visibleContentHeight;
return {
text: visibleLines.join('\n'),
text: visibleLines.map((line) => line.text).join('\n'),
hiddenLinesCount,
sourceBoundaries: visibleLines
.slice(0, -1)
.map((line) => line.breakAfter ?? { kind: 'hard', joiner: '\n' }),
};
}
@ -580,6 +595,7 @@ const StringResultRenderer: React.FC<{
maxHeight={availableHeight}
maxWidth={childWidth}
additionalHiddenLinesCount={sliced.hiddenLinesCount}
sourceBoundaries={sliced.sourceBoundaries}
>
<Box>
<Text wrap="wrap" color={theme.text.primary}>

View file

@ -9,6 +9,18 @@ import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { MaxSizedBox, setMaxSizedBoxDebugging } from './MaxSizedBox.js';
import { Box, Text } from 'ink';
import { describe, it, expect } from 'vitest';
import { getScreenBuffer } from '../../selection/screen-buffer.js';
import { getSelectedText } from '../../selection/selection-text.js';
function selectedFrameText(stdout: NodeJS.WriteStream): string {
const frame = getScreenBuffer(stdout)!.frame!;
return getSelectedText(frame, {
sx: 0,
sy: 0,
ex: frame.width - 1,
ey: frame.height - 1,
});
}
describe('<MaxSizedBox />', () => {
// Make sure MaxSizedBox logs errors on invalid configurations.
@ -86,6 +98,79 @@ long line
of text`);
});
it('rejoins soft wraps while preserving hard row boundaries', () => {
const { stdout } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={5} maxHeight={10}>
<Box>
<Text wrap="wrap">hello world</Text>
</Box>
<Box>
<Text>next</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(selectedFrameText(stdout as unknown as NodeJS.WriteStream)).toBe(
'hello world\nnext',
);
});
it('does not join clipped content to the bottom overflow banner', () => {
const { stdout, lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={5} maxHeight={2} overflowDirection="bottom">
<Box>
<Text wrap="wrap">abcdefghijklm</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(selectedFrameText(stdout as unknown as NodeJS.WriteStream)).toBe(
lastFrame(),
);
});
it.each([
['multiple spaces', 'hello world'],
['a tab', 'hello\tworld'],
])('preserves %s when wrapping', (_name, source) => {
const { stdout } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={5} maxHeight={10}>
<Box>
<Text wrap="wrap">{source}</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(selectedFrameText(stdout as unknown as NodeJS.WriteStream)).toBe(
source,
);
});
it('excludes a gutter and synthesized continuation padding', () => {
const { stdout } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={8} maxHeight={10}>
<Box>
<Text wrap="truncate-end" selectable={false}>
{'1 '}
</Text>
<Text wrap="wrap">long content</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(selectedFrameText(stdout as unknown as NodeJS.WriteStream)).toBe(
'long content',
);
});
it('handles mixed wrapping and non-wrapping segments', () => {
const multilineText = `This part will wrap around.
And has a line break.

View file

@ -60,6 +60,10 @@ interface MaxSizedBoxProps {
maxHeight: number | undefined;
overflowDirection?: 'top' | 'bottom';
additionalHiddenLinesCount?: number;
sourceBoundaries?: ReadonlyArray<{
kind: 'soft' | 'hard';
joiner: string;
}>;
}
/**
@ -107,11 +111,15 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
maxHeight,
overflowDirection = 'top',
additionalHiddenLinesCount = 0,
sourceBoundaries,
}) => {
const id = useId();
const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {};
const laidOutStyledText: StyledText[][] = [];
const lineMetadata = new Map<StyledText[], LineMetadata>();
let rowIndex = 0;
let sourceBoundaryIndex = 0;
const targetMaxHeight = Math.max(
Math.round(maxHeight ?? Number.MAX_SAFE_INTEGER),
MINIMUM_MAX_HEIGHT,
@ -131,7 +139,14 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
}
if (element.type === Box) {
layoutInkElementAsStyledText(element, maxWidth!, laidOutStyledText);
layoutInkElementAsStyledText(
element,
maxWidth!,
laidOutStyledText,
lineMetadata,
`${id}:${rowIndex++}`,
() => sourceBoundaries?.[sourceBoundaryIndex++],
);
return;
}
@ -179,19 +194,35 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
// region maxHeight backstop) Yoga compresses the rows and stacks several at
// the same Y, leaving only every Nth line visible (#6809). flexShrink={0}
// keeps rows full-height and sequential under a clamped ancestor.
const visibleLines = visibleStyledText.map((line, index) => (
<Box key={index} flexShrink={0}>
{line.length > 0 ? (
line.map((segment, segIndex) => (
<Text key={segIndex} {...segment.props}>
{segment.text}
const visibleLines = visibleStyledText.map((line, index) => {
const metadata = lineMetadata.get(line)!;
return (
<Box key={index} flexShrink={0}>
{line.length > 0 ? (
line.map((segment, segIndex) => (
<Text
key={segIndex}
{...segment.props}
selectionFlow={metadata.flowKey}
selectionBreakAfter={metadata.breakAfter}
selectionJoiner={metadata.joiner}
>
{segment.text}
</Text>
))
) : (
<Text
selectable={false}
selectionFlow={metadata.flowKey}
selectionBreakAfter={metadata.breakAfter}
selectionJoiner={metadata.joiner}
>
{' '}
</Text>
))
) : (
<Text> </Text>
)}
</Box>
));
)}
</Box>
);
});
return (
<Box flexDirection="column" width={maxWidth} flexShrink={0}>
@ -218,6 +249,12 @@ interface StyledText {
props: Record<string, unknown>;
}
interface LineMetadata {
flowKey: string;
breakAfter: 'soft' | 'hard';
joiner: string;
}
/**
* Single row of content within the MaxSizedBox.
*
@ -390,11 +427,24 @@ function layoutInkElementAsStyledText(
element: React.ReactElement,
maxWidth: number,
output: StyledText[][],
lineMetadata: Map<StyledText[], LineMetadata>,
flowKey: string,
nextSourceBoundary: () =>
| { kind: 'soft' | 'hard'; joiner: string }
| undefined,
) {
const pushOutput = (
line: StyledText[],
breakAfter: 'soft' | 'hard',
joiner: string,
) => {
output.push(line);
lineMetadata.set(line, { flowKey, breakAfter, joiner });
};
const row = visitBoxRow(element);
if (row.segments.length === 0 && row.noWrapSegments.length === 0) {
// Return a single empty line if there are no segments to display
output.push([]);
pushOutput([], 'hard', '\n');
return;
}
@ -433,7 +483,7 @@ function layoutInkElementAsStyledText(
lines.push(currentLine);
}
for (const line of lines) {
output.push(line);
pushOutput(line, 'hard', '\n');
}
return;
}
@ -518,7 +568,7 @@ function layoutInkElementAsStyledText(
}
for (const line of lines) {
output.push(line);
pushOutput(line, 'hard', '\n');
}
return;
}
@ -527,19 +577,27 @@ function layoutInkElementAsStyledText(
let wrappingPart: StyledText[] = [];
let wrappingPartWidth = 0;
function addWrappingPartToLines() {
function addWrappingPartToLines(breakAfter: 'soft' | 'hard', joiner: string) {
let line: StyledText[];
if (lines.length === 0) {
lines.push([...nonWrappingContent, ...wrappingPart]);
line = [...nonWrappingContent, ...wrappingPart];
} else {
if (noWrappingWidth > 0) {
lines.push([
...[{ text: ' '.repeat(noWrappingWidth), props: {} }],
line = [
...[
{
text: ' '.repeat(noWrappingWidth),
props: { selectable: false },
},
],
...wrappingPart,
]);
];
} else {
lines.push(wrappingPart);
line = wrappingPart;
}
}
lines.push(line);
lineMetadata.set(line, { flowKey, breakAfter, joiner });
wrappingPart = [];
wrappingPartWidth = 0;
}
@ -560,7 +618,11 @@ function layoutInkElementAsStyledText(
linesFromSegment.forEach((lineText, lineIndex) => {
if (lineIndex > 0) {
addWrappingPartToLines();
const boundary = nextSourceBoundary() ?? {
kind: 'hard' as const,
joiner: '\n',
};
addWrappingPartToLines(boundary.kind, boundary.joiner);
}
const words = lineText.split(/(\s+)/); // Split by whitespace
@ -573,7 +635,7 @@ function layoutInkElementAsStyledText(
wrappingPartWidth + wordWidth > availableWidth &&
wrappingPartWidth > 0
) {
addWrappingPartToLines();
addWrappingPartToLines('soft', /^\s+$/u.test(word) ? word : '');
if (/^\s+$/.test(word)) {
return;
}
@ -609,7 +671,7 @@ function layoutInkElementAsStyledText(
}
if (remainingWordAsCodePoints.length > 0) {
addWrappingPartToLines();
addWrappingPartToLines('soft', '');
}
}
} else {
@ -620,12 +682,12 @@ function layoutInkElementAsStyledText(
});
// Split omits a trailing newline, so we need to handle it here
if (segment.text.endsWith('\n')) {
addWrappingPartToLines();
addWrappingPartToLines('hard', '\n');
}
});
if (wrappingPart.length > 0) {
addWrappingPartToLines();
addWrappingPartToLines('hard', '\n');
}
for (const line of lines) {
output.push(line);

View file

@ -849,10 +849,14 @@ function VirtualizedList<T>(
// the viewport never reflows (which would force a per-item
// re-measure + visible jitter).
if (!scrollbarThumbActive) {
return <Text key={i}> </Text>;
return (
<Text key={i} selectable={false}>
{' '}
</Text>
);
}
return (
<Text key={i} dimColor={!inThumb}>
<Text key={i} dimColor={!inThumb} selectable={false}>
{inThumb ? '█' : '│'}
</Text>
);

View file

@ -5,9 +5,10 @@
*/
import { describe, it, expect, afterEach } from 'vitest';
import { Text } from 'ink';
import { Box, Text } from 'ink';
import { render } from 'ink-testing-library';
import { getScreenBuffer } from './screen-buffer.js';
import { getSelectedText } from './selection-text.js';
/** The background escape the patched renderer appends to selected cells. */
const SELECTION_BG = '';
@ -100,4 +101,315 @@ describe('ScreenBuffer (Ink frame-controller M0)', () => {
buffer.setSelection({ sx: 0, sy: 0, ex: 4, ey: 0 });
expect(publishes).toBe(2);
});
it('publishes soft boundaries and preserves omitted whitespace', () => {
current = render(
<Box width={5}>
<Text>hello world</Text>
</Box>,
);
const frame = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
)!.frame!;
expect(
frame.cells.map((row) =>
row
.map((cell) => cell.value)
.join('')
.trimEnd(),
),
).toEqual(['hello', '', 'world']);
expect(frame.cells[1].every((cell) => !cell.selectable)).toBe(true);
expect(frame.boundaries[0].find(Boolean)).toMatchObject({
kind: 'soft',
joiner: ' ',
});
expect(frame.boundaries[1].find(Boolean)).toMatchObject({
kind: 'soft',
joiner: '',
});
expect(
getSelectedText(frame, {
sx: 0,
sy: 0,
ex: frame.width - 1,
ey: frame.height - 1,
}),
).toBe('hello world');
});
it.each([
['multiple spaces', 'hello world'],
['a tab', 'hello\tworld'],
])('preserves %s across a direct Ink word wrap', (_name, source) => {
current = render(
<Box width={5}>
<Text>{source}</Text>
</Box>,
);
const frame = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
)!.frame!;
expect(
getSelectedText(frame, {
sx: 0,
sy: 0,
ex: frame.width - 1,
ey: frame.height - 1,
}),
).toBe(source);
});
it.each([
['leading indentation', ' hello'],
['whitespace-only content', ' '],
])('preserves wrapped %s', (_name, source) => {
current = render(
<Box width={2}>
<Text>{source}</Text>
</Box>,
);
const frame = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
)!.frame!;
expect(
getSelectedText(frame, {
sx: 0,
sy: 0,
ex: frame.width - 1,
ey: frame.height - 1,
}),
).toBe(source);
});
it('keeps renderer background fill out of selected text', () => {
current = render(
<Box width={5} backgroundColor="blue">
<Text>hi</Text>
</Box>,
);
const frame = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
)!.frame!;
expect(
getSelectedText(frame, {
sx: 0,
sy: 0,
ex: frame.width - 1,
ey: frame.height - 1,
}),
).toBe('hi');
});
it('distinguishes explicit hard newlines from exact-width wraps', () => {
current = render(
<Box width={5}>
<Text>{'hello\nworld'}</Text>
</Box>,
);
const frame = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
)!.frame!;
expect(frame.boundaries[0].find(Boolean)).toMatchObject({
kind: 'hard',
joiner: '\n',
});
expect(
getSelectedText(frame, {
sx: 0,
sy: 0,
ex: frame.width - 1,
ey: frame.height - 1,
}),
).toBe('hello\nworld');
});
it('preserves empty and whitespace-only hard lines', () => {
const source = 'alpha\n\n \nomega';
current = render(<Text>{source}</Text>);
const frame = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
)!.frame!;
expect(
getSelectedText(frame, {
sx: 0,
sy: 0,
ex: frame.width - 1,
ey: frame.height - 1,
}),
).toBe(source);
});
it('rejoins styled CJK and emoji text without duplicating wide cells', () => {
const source = '你好 世界🙂';
current = render(
<Box width={5}>
<Text color="cyan">{source}</Text>
</Box>,
);
const frame = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
)!.frame!;
expect(
getSelectedText(frame, {
sx: 0,
sy: 0,
ex: frame.width - 1,
ey: frame.height - 1,
}),
).toBe(source);
});
it('keeps a newline when sibling flows make a boundary ambiguous', () => {
current = render(
<Box flexDirection="column">
<Box>
<Text selectionFlow="a" selectionBreakAfter="soft">
A
</Text>
<Text selectionFlow="b" selectionBreakAfter="soft">
B
</Text>
</Box>
<Box>
<Text selectionFlow="a">C</Text>
<Text selectionFlow="b">D</Text>
</Box>
</Box>,
);
const frame = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
)!.frame!;
expect(
getSelectedText(frame, {
sx: 0,
sy: 0,
ex: frame.width - 1,
ey: frame.height - 1,
}),
).toBe('AB\nCD');
});
it('clears a boundary claim when a later write overlaps it', () => {
current = render(
<Box width={3} height={1}>
<Text selectionBreakAfter="soft">abc</Text>
<Box position="absolute">
<Text selectable={false}>xyz</Text>
</Box>
</Box>,
);
const frame = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
)!.frame!;
expect(
frame.cells[0]
.map((cell) => cell.value)
.join('')
.trimEnd(),
).toBe('xyz');
expect(frame.boundaries[0].every((claim) => claim === null)).toBe(true);
});
it('does not publish a boundary outside a clipping rectangle', () => {
current = render(
<Box width={3} height={1} overflow="hidden">
<Box position="absolute" marginLeft={3}>
<Text selectionBreakAfter="soft">abc</Text>
</Box>
</Box>,
);
const frame = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
)!.frame!;
expect(frame.boundaries[0].every((claim) => claim === null)).toBe(true);
});
it('covers visible text after left clipping with boundary claims', () => {
current = render(
<Box width={3} height={2} flexDirection="column">
<Box width={3} height={1} overflow="hidden">
<Box position="absolute" marginLeft={-2}>
<Text
selectionFlow="flow"
selectionBreakAfter="soft"
selectionJoiner=" "
>
abcde
</Text>
</Box>
<Box position="absolute">
<Text selectable={false}>X</Text>
</Box>
</Box>
<Text selectionFlow="flow">fgh</Text>
</Box>,
);
const frame = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
)!.frame!;
expect(frame.boundaries[0].slice(0, 3).map(Boolean)).toEqual([
false,
true,
true,
]);
expect(
getSelectedText(frame, {
sx: 0,
sy: 0,
ex: frame.width - 1,
ey: frame.height - 1,
}),
).toBe('de fgh');
});
it('limits boundary claims to the rendered text width', () => {
current = render(
<Box width={5}>
<Text selectionBreakAfter="soft">hi</Text>
</Box>,
);
const frame = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
)!.frame!;
expect(frame.boundaries[0].filter(Boolean)).toHaveLength(2);
});
it('propagates selectability and producer-supplied boundaries', () => {
current = render(
<Box flexDirection="column">
<Text
selectionFlow="flow"
selectionBreakAfter="soft"
selectionJoiner=" "
>
hello
</Text>
<Text selectionFlow="flow">world</Text>
<Text selectable={false}> gutter</Text>
</Box>,
);
const frame = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
)!.frame!;
expect(frame.boundaries[0].find(Boolean)).toMatchObject({
kind: 'soft',
joiner: ' ',
});
expect(frame.cells[0][0].flowId).toBe(frame.cells[1][0].flowId);
expect(frame.cells[2].every((cell) => !cell.selectable)).toBe(true);
});
});

View file

@ -13,6 +13,8 @@ const cell = (value: string, fullWidth = false): FrameCell => ({
value,
fullWidth,
styles: [],
selectable: true,
flowId: 1,
});
function frameFromLines(lines: string[]): ReadonlyFrame {
@ -29,7 +31,12 @@ function frameFromLines(lines: string[]): ReadonlyFrame {
return row;
});
const width = Math.max(0, ...cells.map((r) => r.length));
return { width, height: cells.length, cells };
return {
width,
height: cells.length,
cells,
boundaries: cells.map(() => Array.from({ length: width }, () => null)),
};
}
describe('wordSpanAt', () => {

View file

@ -14,6 +14,8 @@ const cell = (value: string, fullWidth = false): FrameCell => ({
value,
fullWidth,
styles: [],
selectable: true,
flowId: 1,
});
/** Build a frame from plain strings, expanding wide glyphs into cell + spacer. */
@ -32,7 +34,26 @@ function frameFromLines(lines: string[]): ReadonlyFrame {
return row;
});
const width = Math.max(0, ...cells.map((r) => r.length));
return { width, height: cells.length, cells };
return {
width,
height: cells.length,
cells,
boundaries: cells.map(() => Array.from({ length: width }, () => null)),
};
}
function setBoundary(
frame: ReadonlyFrame,
y: number,
kind: 'soft' | 'hard',
joiner: string,
): void {
const row = frame.boundaries[y] as Array<
ReadonlyFrame['boundaries'][number][number]
>;
for (let x = 0; x < row.length; x++) {
row[x] = { kind, joiner, selectable: true, flowId: 1 };
}
}
describe('SelectionState', () => {
@ -90,14 +111,81 @@ describe('getSelectedText', () => {
expect(getSelectedText(frame, { sx: 1, sy: 0, ex: 2, ey: 0 })).toBe('中');
});
it('trims trailing whitespace per line', () => {
it('preserves selectable source whitespace', () => {
const frame = frameFromLines(['hi ', 'bye']);
expect(getSelectedText(frame, { sx: 0, sy: 0, ex: 4, ey: 1 })).toBe(
'hi\nbye',
'hi \nbye',
);
});
it('skips non-selectable layout padding', () => {
const frame = frameFromLines(['hi ']);
for (let x = 2; x < 5; x++) {
(frame.cells[0][x] as FrameCell).selectable = false;
}
expect(getSelectedText(frame, { sx: 0, sy: 0, ex: 4, ey: 0 })).toBe('hi');
});
it('replaces a soft visual break with its source joiner', () => {
const frame = frameFromLines(['hello', 'world']);
setBoundary(frame, 0, 'soft', ' ');
expect(getSelectedText(frame, { sx: 0, sy: 0, ex: 4, ey: 1 })).toBe(
'hello world',
);
});
it('does not use boundary claims to override selected row content', () => {
const frame = frameFromLines(['abc', 'xyz']);
setBoundary(frame, 0, 'soft', '');
for (const currentCell of frame.cells[1]) {
(currentCell as FrameCell).flowId = 2;
}
expect(getSelectedText(frame, { sx: 0, sy: 0, ex: 2, ey: 1 })).toBe(
'abc\nxyz',
);
});
it('preserves a newline for a one-sided partial flow overlap', () => {
const frame = frameFromLines(['abc', 'def']);
setBoundary(frame, 0, 'soft', '');
(frame.cells[0][1] as FrameCell).flowId = 2;
expect(getSelectedText(frame, { sx: 0, sy: 0, ex: 2, ey: 1 })).toBe(
'abc\ndef',
);
});
it('preserves hard and ambiguous visual breaks', () => {
const hard = frameFromLines(['hello', 'world']);
setBoundary(hard, 0, 'hard', '\n');
expect(getSelectedText(hard, { sx: 0, sy: 0, ex: 4, ey: 1 })).toBe(
'hello\nworld',
);
const ambiguous = frameFromLines(['hello', 'world']);
expect(getSelectedText(ambiguous, { sx: 0, sy: 0, ex: 4, ey: 1 })).toBe(
'hello\nworld',
);
});
it('returns empty string for a null frame', () => {
expect(getSelectedText(null, { sx: 0, sy: 0, ex: 1, ey: 0 })).toBe('');
});
it('does not duplicate whitespace across a separator carrier row', () => {
// Simulates wrapping 'hello \tworld' at width 5: the separator carrier
// row (non-selectable spaces) must use a joiner limited to the source
// bytes the carrier actually consumed — not the full \s+ run.
const frame = frameFromLines(['hello', ' ', 'world']);
for (let x = 0; x < 3; x++) {
(frame.cells[1][x] as FrameCell).selectable = false;
}
setBoundary(frame, 0, 'soft', ' ');
setBoundary(frame, 1, 'soft', '');
expect(getSelectedText(frame, { sx: 0, sy: 0, ex: 4, ey: 2 })).toBe(
'hello world',
);
});
});

View file

@ -4,17 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ReadonlyFrame } from 'ink';
import type { FrameBoundary, ReadonlyFrame } from 'ink';
import type { NormalizedSelection } from './selection-state.js';
/**
* Extracts the visual text of a selection from a composited frame.
*
* B1 fidelity: the text is exactly what is displayed. Wide-character spacer
* cells carry an empty value and contribute nothing, so a wide glyph appears
* once. Per-line trailing whitespace is trimmed. Soft-wrapped logical lines are
* NOT rejoined and decoration cells are NOT excluded that is PR 2 (semantic
* fidelity), which needs renderer metadata not present in the raw frame.
* Wide-character spacer cells carry an empty value and contribute nothing, so
* a wide glyph appears once. Non-selectable layout cells are skipped. An
* unambiguous soft boundary contributes its source joiner instead of a visual
* newline; hard or ambiguous boundaries retain the newline.
*/
export function getSelectedText(
frame: ReadonlyFrame | null,
@ -24,20 +23,95 @@ export function getSelectedText(
return '';
}
const { sx, sy, ex, ey } = selection;
const lines: string[] = [];
let text = '';
for (let y = sy; y <= ey; y++) {
const row = frame.cells[y];
if (!row) {
lines.push('');
if (y < ey) {
text += '\n';
}
continue;
}
const startX = y === sy ? sx : 0;
const endX = y === ey ? ex : row.length - 1;
let text = '';
for (let x = Math.max(0, startX); x <= endX && x < row.length; x++) {
text += row[x].value;
if (row[x].selectable) {
text += row[x].value;
}
}
if (y < ey) {
text += boundaryJoiner(frame, selection, y);
}
lines.push(text.replace(/\s+$/u, ''));
}
return lines.join('\n');
return text;
}
function selectedFlows(
frame: ReadonlyFrame,
selection: NormalizedSelection,
y: number,
): Set<number> {
const row = frame.cells[y] ?? [];
const startX = y === selection.sy ? selection.sx : 0;
const endX = y === selection.ey ? selection.ex : row.length - 1;
const flows = new Set<number>();
for (let x = Math.max(0, startX); x <= endX && x < row.length; x++) {
const cell = row[x];
if (cell.selectable && cell.flowId !== null) {
flows.add(cell.flowId);
}
}
if (flows.size > 0) {
return flows;
}
for (const boundaryY of [y - 1, y]) {
const boundaryRow = frame.boundaries[boundaryY] ?? [];
for (
let x = Math.max(0, startX);
x <= endX && x < boundaryRow.length;
x++
) {
const claim = boundaryRow[x];
if (claim?.selectable) {
flows.add(claim.flowId);
}
}
}
return flows;
}
function boundaryJoiner(
frame: ReadonlyFrame,
selection: NormalizedSelection,
y: number,
): string {
const currentFlows = selectedFlows(frame, selection, y);
const nextFlows = selectedFlows(frame, selection, y + 1);
const [currentFlow] = currentFlows;
const [nextFlow] = nextFlows;
if (
currentFlows.size !== 1 ||
nextFlows.size !== 1 ||
currentFlow !== nextFlow
) {
return '\n';
}
const flowId = currentFlow!;
const claims = (frame.boundaries[y] ?? []).filter(
(claim): claim is FrameBoundary =>
claim !== null && claim.selectable && claim.flowId === flowId,
);
if (claims.length === 0) {
return '\n';
}
const first = claims[0];
if (
claims.some(
(claim) => claim.kind !== first.kind || claim.joiner !== first.joiner,
)
) {
return '\n';
}
return first.kind === 'soft' ? first.joiner : '\n';
}

View file

@ -48,14 +48,21 @@ const makeFrame = (text: string): ReadonlyFrame => ({
value,
fullWidth: false,
styles: [],
selectable: true,
flowId: 1,
})),
],
boundaries: [Array.from({ length: text.length }, () => null)],
});
const makeTwoLineFrame = (first: string, second: string): ReadonlyFrame => ({
width: Math.max(first.length, second.length),
height: 2,
cells: [makeFrame(first).cells[0], makeFrame(second).cells[0]],
boundaries: [
Array.from({ length: Math.max(first.length, second.length) }, () => null),
Array.from({ length: Math.max(first.length, second.length) }, () => null),
],
});
const makeWideFrame = (): ReadonlyFrame => ({
@ -63,12 +70,41 @@ const makeWideFrame = (): ReadonlyFrame => ({
height: 1,
cells: [
[
{ type: 'char', value: 'a', fullWidth: false, styles: [] },
{ type: 'char', value: '中', fullWidth: true, styles: [] },
{ type: 'char', value: '', fullWidth: false, styles: [] },
{ type: 'char', value: 'b', fullWidth: false, styles: [] },
{
type: 'char',
value: 'a',
fullWidth: false,
styles: [],
selectable: true,
flowId: 1,
},
{
type: 'char',
value: '中',
fullWidth: true,
styles: [],
selectable: true,
flowId: 1,
},
{
type: 'char',
value: '',
fullWidth: false,
styles: [],
selectable: true,
flowId: 1,
},
{
type: 'char',
value: 'b',
fullWidth: false,
styles: [],
selectable: true,
flowId: 1,
},
],
],
boundaries: [Array.from({ length: 4 }, () => null)],
});
const makeEvent = (

View file

@ -0,0 +1,43 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import type { LoadedSettings } from '../../config/settings.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { getScreenBuffer } from '../selection/screen-buffer.js';
import { getSelectedText } from '../selection/selection-text.js';
import { colorizeCode } from './CodeColorizer.js';
it('excludes code line numbers from copied text', () => {
const settings = {
merged: { ui: { showLineNumbers: true } },
} as LoadedSettings;
const { stdout } = render(
<OverflowProvider>
{colorizeCode(
'const value = 1;\nreturn value;',
'javascript',
undefined,
20,
{
settings,
},
)}
</OverflowProvider>,
);
const frame = getScreenBuffer(
stdout as unknown as NodeJS.WriteStream,
)!.frame!;
expect(
getSelectedText(frame, {
sx: 0,
sy: 0,
ex: frame.width - 1,
ey: frame.height - 1,
}),
).toBe('const value = 1;\nreturn value;');
});

View file

@ -295,7 +295,7 @@ export function colorizeCode(
return (
<Box key={index}>
{showLineNumbers && (
<Text color={activeTheme.colors.Gray}>
<Text color={activeTheme.colors.Gray} selectable={false}>
{`${String(
index + firstLineNumber + hiddenLinesCount,
).padStart(padWidth, ' ')} `}
@ -327,7 +327,7 @@ export function colorizeCode(
{lines.map((line, index) => (
<Box key={index}>
{showLineNumbers && (
<Text color={activeTheme.defaultColor}>
<Text color={activeTheme.defaultColor} selectable={false}>
{`${String(index + firstLineNumber).padStart(padWidth, ' ')} `}
</Text>
)}

View file

@ -11,6 +11,18 @@ import { LoadedSettings } from '../../config/settings.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { renderMermaidVisual } from './mermaidVisualRenderer.js';
import { RenderModeProvider } from '../contexts/RenderModeContext.js';
import { getScreenBuffer } from '../selection/screen-buffer.js';
import { getSelectedText } from '../selection/selection-text.js';
function copiedFrame(stdout: NodeJS.WriteStream): string {
const frame = getScreenBuffer(stdout)!.frame!;
return getSelectedText(frame, {
sx: 0,
sy: 0,
ex: frame.width - 1,
ey: frame.height - 1,
});
}
describe('<MarkdownDisplay />', () => {
const baseProps = {
@ -375,10 +387,13 @@ describe('<MarkdownDisplay />', () => {
* item B
+ item C
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
const { lastFrame, stdout } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
expect(copiedFrame(stdout as unknown as NodeJS.WriteStream)).toContain(
'- item A\n* item B\n+ item C',
);
});
it('renders nested unordered lists', () => {
@ -425,10 +440,13 @@ Test
| Cell 1 | Cell 2 |
| Cell 3 | Cell 4 |
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
const { lastFrame, stdout } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
expect(copiedFrame(stdout as unknown as NodeJS.WriteStream)).toContain(
'│ Cell 1 │ Cell 2 │',
);
});
it('handles a table at the end of the input', () => {

View file

@ -1,9 +1,49 @@
diff --git a/node_modules/ink/build/components/Text.d.ts b/node_modules/ink/build/components/Text.d.ts
index 1d7bf6b..29401a6 100644
--- a/node_modules/ink/build/components/Text.d.ts
+++ b/node_modules/ink/build/components/Text.d.ts
@@ -47,9 +47,13 @@ export type Props = {
This property tells Ink to wrap or truncate text if its width is larger than the container. If `wrap` is passed (the default), Ink will wrap text and split it into multiple lines. If `hard` is passed, Ink will fill each line to the full column width, breaking words as necessary. If `truncate-*` is passed, Ink will truncate text instead, resulting in one line of text with the rest cut off.
*/
readonly wrap?: Styles['textWrap'];
+ readonly selectable?: boolean;
+ readonly selectionFlow?: string;
+ readonly selectionBreakAfter?: 'soft' | 'hard';
+ readonly selectionJoiner?: string;
readonly children?: ReactNode;
};
/**
This component can display text and change its style to make it bold, underlined, italic, or strikethrough.
*/
-export default function Text({ color, backgroundColor, dimColor, bold, italic, underline, strikethrough, inverse, wrap, children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden, }: Props): React.JSX.Element | null;
+export default function Text({ color, backgroundColor, dimColor, bold, italic, underline, strikethrough, inverse, wrap, selectable, selectionFlow, selectionBreakAfter, selectionJoiner, children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden, }: Props): React.JSX.Element | null;
diff --git a/node_modules/ink/build/components/Text.js b/node_modules/ink/build/components/Text.js
index 2e4b9ff..1ec2ae6 100644
--- a/node_modules/ink/build/components/Text.js
+++ b/node_modules/ink/build/components/Text.js
@@ -6,7 +6,7 @@ import { backgroundContext } from './BackgroundContext.js';
/**
This component can display text and change its style to make it bold, underlined, italic, or strikethrough.
*/
-export default function Text({ color, backgroundColor, dimColor = false, bold = false, italic = false, underline = false, strikethrough = false, inverse = false, wrap = 'wrap', children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden = false, }) {
+export default function Text({ color, backgroundColor, dimColor = false, bold = false, italic = false, underline = false, strikethrough = false, inverse = false, wrap = 'wrap', selectable = true, selectionFlow, selectionBreakAfter, selectionJoiner = '', children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden = false, }) {
const { isScreenReaderEnabled } = useContext(accessibilityContext);
const inheritedBackgroundColor = useContext(backgroundContext);
const childrenOrAriaLabel = isScreenReaderEnabled && ariaLabel ? ariaLabel : children;
@@ -45,6 +45,6 @@ export default function Text({ color, backgroundColor, dimColor = false, bold =
if (isScreenReaderEnabled && ariaHidden) {
return null;
}
- return (React.createElement("ink-text", { style: { flexGrow: 0, flexShrink: 1, flexDirection: 'row', textWrap: wrap }, internal_transform: transform }, childrenOrAriaLabel));
+ return (React.createElement("ink-text", { style: { flexGrow: 0, flexShrink: 1, flexDirection: 'row', textWrap: wrap }, internal_transform: transform, selectable: selectable, selectionFlow: selectionFlow, selectionBreakAfter: selectionBreakAfter, selectionJoiner: selectionJoiner }, childrenOrAriaLabel));
}
//# sourceMappingURL=Text.js.map
diff --git a/node_modules/ink/build/frame-controller.d.ts b/node_modules/ink/build/frame-controller.d.ts
new file mode 100644
index 0000000..fe4821e
index 0000000..c294036
--- /dev/null
+++ b/node_modules/ink/build/frame-controller.d.ts
@@ -0,0 +1,27 @@
@@ -0,0 +1,36 @@
+/// <reference types="node" />
+export type ScreenSelection = {
+ sx: number;
@ -16,11 +56,20 @@ index 0000000..fe4821e
+ value: string;
+ fullWidth: boolean;
+ styles: unknown[];
+ selectable: boolean;
+ flowId: number | null;
+};
+export type FrameBoundary = {
+ kind: 'soft' | 'hard';
+ joiner: string;
+ selectable: boolean;
+ flowId: number;
+};
+export type ReadonlyFrame = {
+ width: number;
+ height: number;
+ cells: ReadonlyArray<ReadonlyArray<FrameCell>>;
+ boundaries: ReadonlyArray<ReadonlyArray<FrameBoundary | null>>;
+};
+export type FrameController = {
+ getFrame(): ReadonlyFrame | null;
@ -83,7 +132,7 @@ index 0000000..745a4ba
+export const getFrameController = (stdout) => instances.get(stdout)?.frameController;
+//# sourceMappingURL=frame-controller.js.map
diff --git a/node_modules/ink/build/index.d.ts b/node_modules/ink/build/index.d.ts
index f3e7349..6107fd2 100644
index f3e7349..cce56fe 100644
--- a/node_modules/ink/build/index.d.ts
+++ b/node_modules/ink/build/index.d.ts
@@ -39,3 +39,5 @@ export { default as measureElement } from './measure-element.js';
@ -91,7 +140,7 @@ index f3e7349..6107fd2 100644
export { kittyFlags, kittyModifiers } from './kitty-keyboard.js';
export type { KittyKeyboardOptions, KittyFlagName } from './kitty-keyboard.js';
+export { getFrameController } from './frame-controller.js';
+export type { FrameController, ReadonlyFrame, ScreenSelection, FrameCell, } from './frame-controller.js';
+export type { FrameController, ReadonlyFrame, ScreenSelection, FrameCell, FrameBoundary, } from './frame-controller.js';
diff --git a/node_modules/ink/build/index.js b/node_modules/ink/build/index.js
index cc849f0..25d6b33 100644
--- a/node_modules/ink/build/index.js
@ -104,7 +153,7 @@ index cc849f0..25d6b33 100644
//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/node_modules/ink/build/ink.js b/node_modules/ink/build/ink.js
index ef659f3..cbdb068 100644
index ef659f3..c3fad13 100644
--- a/node_modules/ink/build/ink.js
+++ b/node_modules/ink/build/ink.js
@@ -17,6 +17,7 @@ import { hideCursorEscape, showCursorEscape } from './cursor-helpers.js';
@ -136,34 +185,54 @@ index ef659f3..cbdb068 100644
this.rootNode.onStaticChange = this.handleStaticChange;
this.log = logUpdate.create(options.stdout, {
incremental: options.incrementalRendering,
@@ -317,7 +325,15 @@ export default class Ink {
@@ -317,7 +325,16 @@ export default class Ink {
this.nextRenderCommit = undefined;
}
const startTime = performance.now();
- const { output, outputHeight, staticOutput } = render(this.rootNode, this.isScreenReaderEnabled);
+ const selection = this.frameController?.getSelection() ?? null;
+ const { output, outputHeight, staticOutput, cells } = render(this.rootNode, this.isScreenReaderEnabled, selection);
+ const { output, outputHeight, staticOutput, cells, boundaries } = render(this.rootNode, this.isScreenReaderEnabled, selection);
+ if (cells && this.frameController) {
+ this.frameController.publishFrame({
+ width: cells[0]?.length ?? 0,
+ width: cells.reduce((width, row) => Math.max(width, row.length), 0),
+ height: cells.length,
+ cells,
+ boundaries: boundaries ?? [],
+ });
+ }
this.options.onRender?.({ renderTime: performance.now() - startTime });
// If <Static> output isn't empty, it means new children have been added to it
const hasStaticOutput = staticOutput && staticOutput !== '\n';
@@ -642,6 +659,7 @@ export default class Ink {
this.alternateScreen = this.resolveAlternateScreenOption(enabled, this.interactive);
if (this.alternateScreen) {
this.writeBestEffort(this.options.stdout, ansiEscapes.enterAlternativeScreen);
+ this.writeBestEffort(this.options.stdout, ansiEscapes.clearTerminal);
this.writeBestEffort(this.options.stdout, hideCursorEscape);
}
}
diff --git a/node_modules/ink/build/output.d.ts b/node_modules/ink/build/output.d.ts
index 0f4523f..1ceda6a 100644
index 0f4523f..a3ef634 100644
--- a/node_modules/ink/build/output.d.ts
+++ b/node_modules/ink/build/output.d.ts
@@ -1,4 +1,5 @@
@@ -1,4 +1,6 @@
import { type OutputTransformer } from './render-node-to-output.js';
+import { type ScreenSelection, type FrameCell } from './frame-controller.js';
+import { type ScreenSelection, type FrameCell, type FrameBoundary } from './frame-controller.js';
+import { type TextBoundary } from './wrap-text.js';
/**
"Virtual" output class
@@ -27,9 +28,10 @@ export default class Output {
@@ -24,12 +26,21 @@ export default class Output {
constructor(options: Options);
write(x: number, y: number, text: string, options: {
transformers: OutputTransformer[];
+ selectable?: boolean;
+ semantic?: {
+ flowId: number;
+ selectable: boolean;
+ selectableRows: boolean[];
+ boundaries: Array<TextBoundary | null>;
+ };
}): void;
clip(clip: Clip): void;
unclip(): void;
@ -172,11 +241,12 @@ index 0f4523f..1ceda6a 100644
output: string;
height: number;
+ cells: FrameCell[][];
+ boundaries: Array<Array<FrameBoundary | null>>;
};
}
export {};
diff --git a/node_modules/ink/build/output.js b/node_modules/ink/build/output.js
index c763b77..608f529 100644
index c763b77..ac81f2e 100644
--- a/node_modules/ink/build/output.js
+++ b/node_modules/ink/build/output.js
@@ -1,6 +1,26 @@
@ -206,7 +276,25 @@ index c763b77..608f529 100644
class OutputCaches {
widths = new Map();
blockWidths = new Map();
@@ -68,7 +88,7 @@ export default class Output {
@@ -45,7 +65,7 @@ export default class Output {
this.height = height;
}
write(x, y, text, options) {
- const { transformers } = options;
+ const { transformers, selectable, semantic } = options;
if (!text) {
return;
}
@@ -55,6 +75,8 @@ export default class Output {
y,
text,
transformers,
+ selectable,
+ semantic,
});
}
clip(clip) {
@@ -68,7 +90,7 @@ export default class Output {
type: 'unclip',
});
}
@ -215,7 +303,107 @@ index c763b77..608f529 100644
// Initialize output array with a specific set of rows, so that margin/padding at the bottom is preserved
const output = [];
for (let y = 0; y < this.height; y++) {
@@ -192,6 +212,30 @@ export default class Output {
@@ -79,10 +101,13 @@ export default class Output {
value: ' ',
fullWidth: false,
styles: [],
+ selectable: false,
+ flowId: null,
});
}
output.push(row);
}
+ const boundaries = output.map(() => Array.from({ length: this.width }, () => null));
const clips = [];
for (const operation of this.operations) {
if (operation.type === 'clip') {
@@ -92,9 +117,10 @@ export default class Output {
clips.pop();
}
if (operation.type === 'write') {
- const { text, transformers } = operation;
+ const { text, transformers, selectable, semantic } = operation;
let { x, y } = operation;
let lines = text.split('\n');
+ let firstLineIndex = 0;
const clip = clips.at(-1);
if (clip) {
const clipHorizontally = typeof clip?.x1 === 'number' && typeof clip?.x2 === 'number';
@@ -129,6 +155,7 @@ export default class Output {
const height = lines.length;
const to = y + height > clip.y2 ? clip.y2 - y : height;
lines = lines.slice(from, to);
+ firstLineIndex = from;
if (y < clip.y1) {
y = clip.y1;
}
@@ -144,8 +171,34 @@ export default class Output {
for (const transformer of transformers) {
line = transformer(line, index);
}
+ const boundaryRow = boundaries[y + offsetY];
+ if (boundaryRow) {
+ const boundaryWidth = semantic
+ ? Math.max(1, this.caches.getStringWidth(line))
+ : this.caches.getStringWidth(line);
+ const boundaryOrigin = x;
+ const startX = Math.max(0, boundaryOrigin, clip?.x1 ?? 0);
+ const endX = Math.min(this.width, boundaryOrigin + boundaryWidth, clip?.x2 ?? this.width);
+ const sourceBoundary = semantic?.boundaries[firstLineIndex + index] ?? null;
+ const boundary = sourceBoundary && semantic
+ ? {
+ ...sourceBoundary,
+ flowId: semantic.flowId,
+ selectable: semantic.selectable,
+ }
+ : null;
+ for (let boundaryX = startX; boundaryX < endX; boundaryX++) {
+ if (boundaryX < boundaryRow.length) {
+ boundaryRow[boundaryX] = boundary;
+ }
+ }
+ }
const characters = this.caches.getStyledChars(line);
let offsetX = x;
+ const rowSelectable = semantic
+ ? semantic.selectable &&
+ (semantic.selectableRows[firstLineIndex + index] ?? true)
+ : selectable ?? true;
// Nothing to write (e.g. line was clipped away).
if (characters.length === 0) {
offsetY++;
@@ -156,6 +209,8 @@ export default class Output {
value: ' ',
fullWidth: false,
styles: [],
+ selectable: false,
+ flowId: null,
};
// Wide characters (e.g. CJK) occupy two cells: a leading
// cell with the character and a trailing placeholder with
@@ -169,7 +224,11 @@ export default class Output {
currentLine[offsetX - 1] = spaceCell;
}
for (const character of characters) {
- currentLine[offsetX] = character;
+ currentLine[offsetX] = {
+ ...character,
+ selectable: rowSelectable,
+ flowId: semantic?.flowId ?? null,
+ };
// Determine printed width using string-width to align with measurement
const characterWidth = Math.max(1, this.caches.getStringWidth(character.value));
// For multi-column characters, clear following cells to avoid stray spaces/artifacts
@@ -180,6 +239,8 @@ export default class Output {
value: '',
fullWidth: false,
styles: character.styles,
+ selectable: rowSelectable,
+ flowId: semantic?.flowId ?? null,
};
}
}
@@ -192,6 +253,30 @@ export default class Output {
}
}
}
@ -246,32 +434,180 @@ index c763b77..608f529 100644
const generatedOutput = output
.map(line => {
// See https://github.com/vadimdemedes/ink/pull/564#issuecomment-1637022742
@@ -202,6 +246,7 @@ export default class Output {
@@ -202,6 +287,8 @@ export default class Output {
return {
output: generatedOutput,
height: output.length,
+ cells: output,
+ boundaries,
};
}
}
diff --git a/node_modules/ink/build/render-background.js b/node_modules/ink/build/render-background.js
index db5c807..bb54771 100644
--- a/node_modules/ink/build/render-background.js
+++ b/node_modules/ink/build/render-background.js
@@ -18,7 +18,10 @@ const renderBackground = (x, y, node, output) => {
// Create background fill for each row
const backgroundLine = colorize(' '.repeat(contentWidth), node.style.backgroundColor, 'background');
for (let row = 0; row < contentHeight; row++) {
- output.write(x + leftBorderWidth, y + topBorderHeight + row, backgroundLine, { transformers: [] });
+ output.write(x + leftBorderWidth, y + topBorderHeight + row, backgroundLine, {
+ transformers: [],
+ selectable: false,
+ });
}
};
export default renderBackground;
diff --git a/node_modules/ink/build/render-node-to-output.d.ts b/node_modules/ink/build/render-node-to-output.d.ts
index 127c620..6d5a005 100644
--- a/node_modules/ink/build/render-node-to-output.d.ts
+++ b/node_modules/ink/build/render-node-to-output.d.ts
@@ -10,5 +10,9 @@ declare const renderNodeToOutput: (node: DOMElement, output: Output, options: {
offsetY?: number;
transformers?: OutputTransformer[];
skipStaticElements: boolean;
+ flowIds: Map<unknown, number>;
+ nextFlowId: {
+ value: number;
+ };
}) => void;
export default renderNodeToOutput;
diff --git a/node_modules/ink/build/render-node-to-output.js b/node_modules/ink/build/render-node-to-output.js
index f00a278..07a98d0 100644
--- a/node_modules/ink/build/render-node-to-output.js
+++ b/node_modules/ink/build/render-node-to-output.js
@@ -1,7 +1,6 @@
-import widestLine from 'widest-line';
-import indentString from 'indent-string';
import Yoga from 'yoga-layout';
-import wrapText from './wrap-text.js';
+import widestLine from 'widest-line';
+import { wrapTextWithMetadata } from './wrap-text.js';
import getMaxWidth from './get-max-width.js';
import squashTextNodes from './squash-text-nodes.js';
import renderBorder from './render-border.js';
@@ -12,14 +11,15 @@ import renderBackground from './render-background.js';
// and use it as offset for the rest of the nodes
// Only first node is taken into account, because other text nodes can't have margin or padding,
// so their coordinates will be relative to the first node anyway
-const applyPaddingToText = (node, text) => {
+const getTextOffset = (node) => {
const yogaNode = node.childNodes[0]?.yogaNode;
if (yogaNode) {
- const offsetX = yogaNode.getComputedLeft();
- const offsetY = yogaNode.getComputedTop();
- text = '\n'.repeat(offsetY) + indentString(text, offsetX);
+ return {
+ x: yogaNode.getComputedLeft(),
+ y: yogaNode.getComputedTop(),
+ };
}
- return text;
+ return { x: 0, y: 0 };
};
export const renderNodeToScreenReaderOutput = (node, options = {}) => {
if (options.skipStaticElements && node.internal_static) {
@@ -69,7 +69,7 @@ export const renderNodeToScreenReaderOutput = (node, options = {}) => {
};
// After nodes are laid out, render each to output object, which later gets rendered to terminal
const renderNodeToOutput = (node, output, options) => {
- const { offsetX = 0, offsetY = 0, transformers = [], skipStaticElements, } = options;
+ const { offsetX = 0, offsetY = 0, transformers = [], skipStaticElements, flowIds, nextFlowId, } = options;
if (skipStaticElements && node.internal_static) {
return;
}
@@ -88,16 +88,54 @@ const renderNodeToOutput = (node, output, options) => {
newTransformers = [node.internal_transform, ...transformers];
}
if (node.nodeName === 'ink-text') {
- let text = squashTextNodes(node);
- if (text.length > 0) {
- const currentWidth = widestLine(text);
+ const sourceText = squashTextNodes(node);
+ if (sourceText.length > 0) {
const maxWidth = getMaxWidth(yogaNode);
- if (currentWidth > maxWidth) {
- const textWrap = node.style.textWrap ?? 'wrap';
- text = wrapText(text, maxWidth, textWrap);
+ const textWrap = node.style.textWrap ?? 'wrap';
+ const wrapped = wrapTextWithMetadata(sourceText, maxWidth, textWrap, widestLine(sourceText) > maxWidth);
+ const flowKey = node.attributes.selectionFlow ?? node;
+ let flowId = flowIds.get(flowKey);
+ if (flowId === undefined) {
+ flowId = nextFlowId.value++;
+ flowIds.set(flowKey, flowId);
+ }
+ const boundaries = [...wrapped.boundaries];
+ const breakAfter = node.attributes.selectionBreakAfter;
+ boundaries.push(breakAfter === 'soft' || breakAfter === 'hard'
+ ? {
+ kind: breakAfter,
+ joiner: typeof node.attributes.selectionJoiner === 'string'
+ ? node.attributes.selectionJoiner
+ : breakAfter === 'hard'
+ ? '\n'
+ : '',
+ }
+ : null);
+ const textOffset = getTextOffset(node);
+ if (textOffset.x > 0) {
+ const padding = wrapped.text
+ .split('\n')
+ .map(line => line.length > 0 ? ' '.repeat(textOffset.x) : '')
+ .join('\n');
+ output.write(x, y + textOffset.y, padding, {
+ transformers: newTransformers,
+ semantic: {
+ flowId,
+ selectable: false,
+ selectableRows: wrapped.selectableRows.map(() => false),
+ boundaries: wrapped.boundaries.map(() => null),
+ },
+ });
}
- text = applyPaddingToText(node, text);
- output.write(x, y, text, { transformers: newTransformers });
+ output.write(x + textOffset.x, y + textOffset.y, wrapped.text, {
+ transformers: newTransformers,
+ semantic: {
+ flowId,
+ selectable: node.attributes.selectable !== false,
+ selectableRows: wrapped.selectableRows,
+ boundaries,
+ },
+ });
}
return;
}
@@ -135,6 +173,8 @@ const renderNodeToOutput = (node, output, options) => {
offsetY: y,
transformers: newTransformers,
skipStaticElements,
+ flowIds,
+ nextFlowId,
});
}
if (clipped) {
diff --git a/node_modules/ink/build/renderer.d.ts b/node_modules/ink/build/renderer.d.ts
index 8c35aea..10d2f54 100644
index 8c35aea..26e3196 100644
--- a/node_modules/ink/build/renderer.d.ts
+++ b/node_modules/ink/build/renderer.d.ts
@@ -1,8 +1,10 @@
@@ -1,8 +1,11 @@
import { type DOMElement } from './dom.js';
+import { type ScreenSelection, type FrameCell } from './frame-controller.js';
+import { type ScreenSelection, type FrameCell, type FrameBoundary } from './frame-controller.js';
type Result = {
output: string;
outputHeight: number;
staticOutput: string;
+ cells?: FrameCell[][];
+ boundaries?: Array<Array<FrameBoundary | null>>;
};
-declare const renderer: (node: DOMElement, isScreenReaderEnabled: boolean) => Result;
+declare const renderer: (node: DOMElement, isScreenReaderEnabled: boolean, selection?: ScreenSelection | null) => Result;
export default renderer;
diff --git a/node_modules/ink/build/renderer.js b/node_modules/ink/build/renderer.js
index bf23246..dd0565d 100644
index bf23246..81d72e9 100644
--- a/node_modules/ink/build/renderer.js
+++ b/node_modules/ink/build/renderer.js
@@ -1,6 +1,6 @@
@ -282,12 +618,29 @@ index bf23246..dd0565d 100644
if (node.yogaNode) {
if (isScreenReaderEnabled) {
const output = renderNodeToScreenReaderOutput(node, {
@@ -36,13 +36,14 @@ const renderer = (node, isScreenReaderEnabled) => {
@@ -23,8 +23,12 @@ const renderer = (node, isScreenReaderEnabled) => {
width: node.yogaNode.getComputedWidth(),
height: node.yogaNode.getComputedHeight(),
});
+ const flowIds = new Map();
+ const nextFlowId = { value: 1 };
renderNodeToOutput(node, output, {
skipStaticElements: true,
+ flowIds,
+ nextFlowId,
});
let staticOutput;
if (node.staticNode?.yogaNode) {
@@ -34,15 +38,19 @@ const renderer = (node, isScreenReaderEnabled) => {
});
renderNodeToOutput(node.staticNode, staticOutput, {
skipStaticElements: false,
+ flowIds,
+ nextFlowId,
});
}
- const { output: generatedOutput, height: outputHeight } = output.get();
+ const { output: generatedOutput, height: outputHeight, cells, } = output.get(selection);
+ const { output: generatedOutput, height: outputHeight, cells, boundaries, } = output.get(selection);
return {
output: generatedOutput,
outputHeight,
@ -295,6 +648,87 @@ index bf23246..dd0565d 100644
// interactive output will override last line of static output
staticOutput: staticOutput ? `${staticOutput.get().output}\n` : '',
+ cells,
+ boundaries,
};
}
return {
diff --git a/node_modules/ink/build/wrap-text.d.ts b/node_modules/ink/build/wrap-text.d.ts
index 935cfb8..3dddced 100644
--- a/node_modules/ink/build/wrap-text.d.ts
+++ b/node_modules/ink/build/wrap-text.d.ts
@@ -1,3 +1,12 @@
import { type Styles } from './styles.js';
declare const wrapText: (text: string, maxWidth: number, wrapType: Styles["textWrap"]) => string;
+export type TextBoundary = {
+ kind: 'soft' | 'hard';
+ joiner: string;
+};
+export declare const wrapTextWithMetadata: (text: string, maxWidth: number, wrapType: Styles["textWrap"], shouldWrap?: boolean) => {
+ text: string;
+ boundaries: TextBoundary[];
+ selectableRows: boolean[];
+};
export default wrapText;
diff --git a/node_modules/ink/build/wrap-text.js b/node_modules/ink/build/wrap-text.js
index 51f696c..d96d438 100644
--- a/node_modules/ink/build/wrap-text.js
+++ b/node_modules/ink/build/wrap-text.js
@@ -1,5 +1,6 @@
import wrapAnsi from 'wrap-ansi';
import cliTruncate from 'cli-truncate';
+import stripAnsi from 'strip-ansi';
const cache = {};
const wrapText = (text, maxWidth, wrapType) => {
const cacheKey = text + String(maxWidth) + String(wrapType);
@@ -34,5 +35,51 @@ const wrapText = (text, maxWidth, wrapType) => {
cache[cacheKey] = wrappedText;
return wrappedText;
};
+export const wrapTextWithMetadata = (text, maxWidth, wrapType, shouldWrap = true) => {
+ const sourceLines = text.replaceAll('\r\n', '\n').split('\n');
+ const lines = [];
+ const boundaries = [];
+ const selectableRows = [];
+ for (const [sourceLineIndex, sourceLine] of sourceLines.entries()) {
+ const wrappedLines = (shouldWrap
+ ? wrapText(sourceLine, maxWidth, wrapType)
+ : sourceLine).split('\n');
+ const visibleSource = stripAnsi(sourceLine);
+ let sourceOffset = 0;
+ for (const [wrappedLineIndex, wrappedLine] of wrappedLines.entries()) {
+ const visibleLine = stripAnsi(wrappedLine);
+ const separatorRow = sourceOffset > 0 &&
+ visibleLine.length > 0 &&
+ /^\s+$/u.test(visibleLine) &&
+ /\S/u.test(visibleSource.slice(sourceOffset));
+ const sourceSeparator = separatorRow
+ ? (/^\s+/u.exec(visibleSource.slice(sourceOffset))?.[0] ?? '').slice(0, visibleLine.length)
+ : '';
+ const lineOffset = separatorRow
+ ? sourceOffset
+ : visibleSource.indexOf(visibleLine, sourceOffset);
+ const resolvedOffset = lineOffset === -1 ? sourceOffset : lineOffset;
+ if (lines.length > 0) {
+ boundaries.push({
+ kind: wrappedLineIndex === 0 ? 'hard' : 'soft',
+ joiner: wrappedLineIndex === 0
+ ? '\n'
+ : separatorRow
+ ? sourceSeparator
+ : visibleSource.slice(sourceOffset, resolvedOffset),
+ });
+ }
+ lines.push(wrappedLine);
+ selectableRows.push(!separatorRow);
+ sourceOffset = separatorRow
+ ? sourceOffset + sourceSeparator.length
+ : resolvedOffset + visibleLine.length;
+ }
+ if (sourceLineIndex < sourceLines.length - 1 && wrappedLines.length === 0) {
+ lines.push('');
+ }
+ }
+ return { text: lines.join('\n'), boundaries, selectableRows };
+};
export default wrapText;
//# sourceMappingURL=wrap-text.js.map