Skip to content

feat(ui): add mouse-drag text selection and clipboard copy#306

Open
raulk wants to merge 2 commits into
modem-dev:mainfrom
raulk:feat/copy-selection-decorations
Open

feat(ui): add mouse-drag text selection and clipboard copy#306
raulk wants to merge 2 commits into
modem-dev:mainfrom
raulk:feat/copy-selection-decorations

Conversation

@raulk
Copy link
Copy Markdown

@raulk raulk commented May 13, 2026

Hunk is very cool, but it's missing some basic features. Chipping in this PR to add selections via OpenTUI's OSC 52 support.

Concretely, this adds:

Mouse-drag text selection. Click anywhere on a diff row and drag to select a range of text. The selection spans multiple rows when you drag across them, and a character-level visual highlight follows the pointer. Releasing the drag copies the selected text to your clipboard via OSC 52.

Double-click and triple-click. Double-click expands the selection to word boundaries within the clicked line. Triple-click copies the entire line.

Split mode awareness. Triple-click and drag selection automatically stay within the side you started on. When copy decorations are enabled, column offsets are computed relative to the correct pane, so what you see highlighted is exactly what lands on the clipboard.

Copy decorations toggle. A View > Copy decorations menu item (off by default, or set copy_decorations in config) controls whether copied text includes diff rails, line numbers, gutters, and file headers. When off, only the changed code text is copied.

- Drag-selection model (copySelection.ts) supports single-row, multi-row,
  double-click word expansion, and triple-click line selection, with
  column-level precision for both stack and split layouts
- Selection highlight rendered at character-level granularity across
  diff rows, scoped to the active split side (left = A, right = B)
- Triple-click in split mode automatically scopes to the clicked
  pane instead of selecting across both panes
- Decorated copy preserves diff rails, line numbers, gutters, and
  file headers; code-only copy strips all decoration
- B-side copy with decorations uses correct pane-local column
  offsets so selection, highlight, and clipboard agree
- View > Copy decorations menu toggle (default off), controlled by
  copy_decorations config key
- Pinned-header click extends selection into the stream body
- Clipboard output via OSC 52 with transient status feedback
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 13, 2026

Greptile Summary

This PR adds mouse-drag text selection to Hunk's diff views, with OSC 52 clipboard copy on drag release, double/triple-click word and line selection, split-pane side awareness, and a View > Copy decorations toggle that controls whether the clipboard payload includes diff rails, gutters and line numbers or only the changed code text.

  • copySelection.ts + renderRows.tsx: New 560-line module handles hit-testing mouse events to selection points, text rendering (decorated vs. code-only), column-range computation for per-character visual highlights, and double/triple-click expansion. renderInlineSpans is significantly refactored to split spans at selection boundaries.
  • DiffPane.tsx + App.tsx: Wire drag-start/move/end handlers, expose a cancelCopySelectionRef for cross-pane cancellation, route OSC 52 copy through the renderer, and surface a transient status-bar notice for unsupported terminals.
  • Config / types plumbing: copy_decorations added throughout CommonOptions, PersistedViewPreferences, AppBootstrap, and loadAppBootstrap. Well-covered by new unit tests in copySelection.test.ts and additions to pierre.test.ts.

Confidence Score: 3/5

The copy path works end-to-end and is well tested, but the default value for copyDecorations is set to true in DEFAULT_VIEW_PREFERENCES while the PR description and menu label say the feature ships as off by default; merging as-is means new users receive decorated clipboard output without enabling any option.

The default value in config.ts contradicts the documented and intended out-of-box behaviour. It is a single-character fix, but without it the feature ships with the opposite default from what was designed and communicated. The rest of the implementation—hit-testing, rendering, split-side awareness, OSC 52 wiring—is thorough and backed by good test coverage.

src/core/config.ts — the copyDecorations default in DEFAULT_VIEW_PREFERENCES needs to flip from true to false.

Important Files Changed

Filename Overview
src/core/config.ts Adds copyDecorations to CommonOptions, PersistedViewPreferences, config resolution and merge logic. The default value is true (on) which contradicts the PR description's "off by default" intent.
src/ui/components/panes/copySelection.ts New 563-line module implementing the full copy-selection logic: hit-testing mouse events to selection points, text rendering for both decorated and code-only modes, column-range computation for visual highlights, and double/triple-click expansion. Minor edge case: double-click on whitespace returns an inverted column range before normalization.
src/ui/components/panes/DiffPane.tsx Core integration: adds drag-start/update/end handlers, OSC 52 copy path, cancelCopySelectionRef exposure for cross-pane cancellation, and passes selection ranges down to per-file views. First drag event stale-closure means native selection may flash briefly.
src/ui/diff/renderRows.tsx Large refactor of renderInlineSpans to support character-level selection highlights by splitting spans at selection boundaries. Adds renderDecoratedPlannedRowText and renderCodeOnlyPlannedRowText for clipboard rendering. Logic is complex but well-covered by tests.
src/ui/App.tsx Wires copyDecorations state, toggleCopyDecorations, showTransientNotice, and cancelCopySelectionRef into the component tree. showTransientNotice schedules a bare setTimeout without cleanup.
src/ui/lib/diffSectionGeometry.ts Extends DiffSectionGeometry to carry lineNumberDigits and plannedRows so the clipboard renderer can re-use the already-measured plan without rebuilding it; also extracts buildPlannedSectionRows helper.
src/ui/diff/rowStyle.ts Adds selectionHighlightBg blending helper and splitGutterText extractor (shared between TUI and clipboard renderers). Theme selectedHunk colours bumped for better contrast under the new blend ratio.
src/ui/components/panes/copySelection.test.ts New 518-line test file covering all public functions in copySelection.ts, including split/stack layout, side filtering, pinned-header selection, and row-key building. Good coverage.
src/ui/lib/appMenus.ts Adds copyDecorations / toggleCopyDecorations to BuildAppMenusOptions and inserts the "Copy decorations" menu item into the View menu. Clean.

Sequence Diagram

sequenceDiagram
    participant User
    participant DiffPane
    participant copySelection
    participant renderRows
    participant OSC52

    User->>DiffPane: onMouseDown (drag start)
    DiffPane->>copySelection: resolveCopySelectionPoint(event)
    copySelection-->>DiffPane: CopySelectionPoint (anchor)
    DiffPane->>DiffPane: "setCopySelectionDrag({anchor, focus, moved:false})"

    User->>DiffPane: onMouseDrag (pointer moves)
    DiffPane->>copySelection: resolveCopySelectionPoint(event)
    copySelection-->>DiffPane: CopySelectionPoint (focus)
    DiffPane->>DiffPane: "setCopySelectionDrag({anchor, focus, moved:true})"
    DiffPane->>renderRows: buildCopySelectedRowKeys (visual highlight)

    User->>DiffPane: onMouseUp / onMouseDragEnd
    DiffPane->>copySelection: normalizeCopySelectionRange(anchor, focus)
    copySelection-->>DiffPane: "{start, end}"
    DiffPane->>renderRows: renderCopySelectionText(context, start, end, side)
    renderRows-->>DiffPane: clipboard text string
    DiffPane->>OSC52: copyToClipboardOSC52(text)
    DiffPane->>App: onCopyFeedback("Copied selection to clipboard")
    App->>User: StatusBar transient notice
Loading

Comments Outside Diff (2)

  1. src/ui/components/panes/copySelection.ts, line 1774-1791 (link)

    P2 Double-click on whitespace produces an inverted column range

    When the user double-clicks on a space or tab character, wordStart and wordEnd both remain at localCol (neither loop advances), giving startCol = localCol + globalContentStart and endCol = localCol - 1 + globalContentStart. endCol < startCol. normalizeCopySelectionRange corrects the ordering, but the resulting selection highlights only the character immediately before the clicked space rather than the space itself or nothing—a confusing UX that mismatches what other editors do on a whitespace double-click.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/ui/components/panes/copySelection.ts
    Line: 1774-1791
    
    Comment:
    **Double-click on whitespace produces an inverted column range**
    
    When the user double-clicks on a space or tab character, `wordStart` and `wordEnd` both remain at `localCol` (neither loop advances), giving `startCol = localCol + globalContentStart` and `endCol = localCol - 1 + globalContentStart`. `endCol < startCol`. `normalizeCopySelectionRange` corrects the ordering, but the resulting selection highlights only the character immediately before the clicked space rather than the space itself or nothing—a confusing UX that mismatches what other editors do on a whitespace double-click.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. src/ui/App.tsx, line 148-153 (link)

    P2 setTimeout without cleanup can fire after unmount

    showTransientNotice schedules a bare setTimeout with no cleanup path. If the App component is unmounted before the timer fires (e.g. in tests or hot-reload) the callback will try to call setTransientNoticeText on an unmounted component. This is low-severity in production (there is only one App instance), but it can produce spurious React state-update warnings in tests. The timer's id should be stored in a ref and cleared in a companion useEffect.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/ui/App.tsx
    Line: 148-153
    
    Comment:
    **`setTimeout` without cleanup can fire after unmount**
    
    `showTransientNotice` schedules a bare `setTimeout` with no cleanup path. If the `App` component is unmounted before the timer fires (e.g. in tests or hot-reload) the callback will try to call `setTransientNoticeText` on an unmounted component. This is low-severity in production (there is only one `App` instance), but it can produce spurious React state-update warnings in tests. The timer's `id` should be stored in a ref and cleared in a companion `useEffect`.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 5 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 5
src/core/config.ts:15
**Default value contradicts documented behavior**

The PR description and UI label both say "Copy decorations" is _off by default_, but `DEFAULT_VIEW_PREFERENCES.copyDecorations: true` initialises it as on. This flows through `resolveConfiguredCliInput``loadAppBootstrap``App`, so new users without a config file will start with decorations enabled, copying diff rails, line numbers and gutters instead of bare code text. The value should be `false` to match the documented default.

### Issue 2 of 5
src/core/config.ts:17-19
Set `copyDecorations` to `false` so the out-of-the-box experience copies only code text, matching the PR description and menu label ("off by default").

```suggestion
  showAgentNotes: false,
  copyDecorations: false,
};
```

### Issue 3 of 5
src/ui/components/panes/copySelection.ts:1774-1791
**Double-click on whitespace produces an inverted column range**

When the user double-clicks on a space or tab character, `wordStart` and `wordEnd` both remain at `localCol` (neither loop advances), giving `startCol = localCol + globalContentStart` and `endCol = localCol - 1 + globalContentStart`. `endCol < startCol`. `normalizeCopySelectionRange` corrects the ordering, but the resulting selection highlights only the character immediately before the clicked space rather than the space itself or nothing—a confusing UX that mismatches what other editors do on a whitespace double-click.

### Issue 4 of 5
src/ui/App.tsx:148-153
**`setTimeout` without cleanup can fire after unmount**

`showTransientNotice` schedules a bare `setTimeout` with no cleanup path. If the `App` component is unmounted before the timer fires (e.g. in tests or hot-reload) the callback will try to call `setTransientNoticeText` on an unmounted component. This is low-severity in production (there is only one `App` instance), but it can produce spurious React state-update warnings in tests. The timer's `id` should be stored in a ref and cleared in a companion `useEffect`.

### Issue 5 of 5
src/ui/components/panes/DiffPane.tsx:596-601
**Stale `copySelectionDrag` on the first drag event**

Inside `updateCopySelection`, `event.preventDefault()` / `event.stopPropagation()` are gated on the closed-over `copySelectionDrag` value. On the very first `onMouseDrag` event that fires immediately after `beginCopySelection` sets the drag state, React has not yet re-rendered, so `copySelectionDrag` is still `null` and the two calls are skipped. The state-setter path (functional update inside `setCopySelectionDrag`) correctly sees the latest value, but the native-selection suppression fires a render cycle late. The consequence is a brief native-selection flash on drag start.

Reviews (1): Last reviewed commit: "feat(ui): add mouse-drag text selection ..." | Re-trigger Greptile

Comment thread src/core/config.ts
@@ -15,6 +15,7 @@ const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = {
wrapLines: false,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Default value contradicts documented behavior

The PR description and UI label both say "Copy decorations" is off by default, but DEFAULT_VIEW_PREFERENCES.copyDecorations: true initialises it as on. This flows through resolveConfiguredCliInputloadAppBootstrapApp, so new users without a config file will start with decorations enabled, copying diff rails, line numbers and gutters instead of bare code text. The value should be false to match the documented default.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/config.ts
Line: 15

Comment:
**Default value contradicts documented behavior**

The PR description and UI label both say "Copy decorations" is _off by default_, but `DEFAULT_VIEW_PREFERENCES.copyDecorations: true` initialises it as on. This flows through `resolveConfiguredCliInput``loadAppBootstrap``App`, so new users without a config file will start with decorations enabled, copying diff rails, line numbers and gutters instead of bare code text. The value should be `false` to match the documented default.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/core/config.ts
Comment on lines 17 to 19
showAgentNotes: false,
copyDecorations: true,
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Set copyDecorations to false so the out-of-the-box experience copies only code text, matching the PR description and menu label ("off by default").

Suggested change
showAgentNotes: false,
copyDecorations: true,
};
showAgentNotes: false,
copyDecorations: false,
};
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/config.ts
Line: 17-19

Comment:
Set `copyDecorations` to `false` so the out-of-the-box experience copies only code text, matching the PR description and menu label ("off by default").

```suggestion
  showAgentNotes: false,
  copyDecorations: false,
};
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +596 to +601
buildCopySelectedRowKeys({
drag: copySelectionDrag,
fileSectionLayouts,
sectionGeometry,
width: diffContentWidth,
}),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Stale copySelectionDrag on the first drag event

Inside updateCopySelection, event.preventDefault() / event.stopPropagation() are gated on the closed-over copySelectionDrag value. On the very first onMouseDrag event that fires immediately after beginCopySelection sets the drag state, React has not yet re-rendered, so copySelectionDrag is still null and the two calls are skipped. The state-setter path (functional update inside setCopySelectionDrag) correctly sees the latest value, but the native-selection suppression fires a render cycle late. The consequence is a brief native-selection flash on drag start.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ui/components/panes/DiffPane.tsx
Line: 596-601

Comment:
**Stale `copySelectionDrag` on the first drag event**

Inside `updateCopySelection`, `event.preventDefault()` / `event.stopPropagation()` are gated on the closed-over `copySelectionDrag` value. On the very first `onMouseDrag` event that fires immediately after `beginCopySelection` sets the drag state, React has not yet re-rendered, so `copySelectionDrag` is still `null` and the two calls are skipped. The state-setter path (functional update inside `setCopySelectionDrag`) correctly sees the latest value, but the native-selection suppression fires a render cycle late. The consequence is a brief native-selection flash on drag start.

How can I resolve this? If you propose a fix, please make it concise.

@raulk raulk force-pushed the feat/copy-selection-decorations branch from bbb0aa5 to c6f6740 Compare May 14, 2026 19:29
- Fix copyDecorations defaulting to true instead of false so new users
  get code-only clipboard output by default (P1)
- Fix double-click on whitespace producing inverted column range by
  selecting the whitespace character itself (P2)
- Fix stale copySelectionDrag closure on first drag event causing brief
  native-selection flash by mirroring drag state in a ref (P2)
- Fix bare setTimeout in showTransientNotice that could fire after
  unmount by tracking the timer in a ref and clearing on unmount (P2)
- Add test coverage for whitespace double-click edge case
@raulk raulk force-pushed the feat/copy-selection-decorations branch from c6f6740 to eb8441c Compare May 14, 2026 19:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant