From fe18cbde8ff0e8bcfa9cc30cbda9b90d7f8c4237 Mon Sep 17 00:00:00 2001 From: Netanel Draiman Date: Sun, 28 Dec 2025 09:54:38 +0200 Subject: [PATCH 1/3] feat(tui): add double-click word selection support Implement double-click detection and word boundary finding in the terminal UI to enable selecting entire words with a double-click gesture. This feature respects the experimental flag for copy-on-select functionality and integrates with the existing selection APIs. The implementation includes: - Double-click detection based on timing and position thresholds - Word boundary detection using character code analysis - Integration with renderer's startSelection and updateSelection methods - Proper state management to prevent triple-clicks being misdetected --- packages/opencode/src/cli/cmd/tui/app.tsx | 74 ++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 5214b0c1a9a..41d72228464 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,6 +1,6 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" -import { TextAttributes } from "@opentui/core" +import { TextAttributes, type MouseEvent } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" import { Installation } from "@/installation" @@ -575,11 +575,83 @@ function App() { }) }) + // Double-click detection state + let lastClickTime = 0 + let lastClickX = -1 + let lastClickY = -1 + const DOUBLE_CLICK_THRESHOLD = 400 // ms + + // Check if a character is a word character (alphanumeric, underscore, or common code chars) + const isWordChar = (charCode: number): boolean => { + if (charCode === 0 || charCode === 32) return false // null or space + const char = String.fromCodePoint(charCode) + // Word characters: letters, digits, underscore, hyphen, and common programming chars + return /[\w\-]/.test(char) + } + + // Find word boundaries at a given position in the render buffer + const findWordBoundaries = (x: number, y: number): { startX: number; endX: number } | null => { + const buffer = renderer.currentRenderBuffer + const width = buffer.width + const charBuffer = buffer.buffers.char + + // Get the character at the click position + const index = y * width + x + if (index < 0 || index >= charBuffer.length) return null + + const clickedChar = charBuffer[index] + if (!isWordChar(clickedChar)) return null + + // Find start of word (scan left) + let startX = x + while (startX > 0) { + const prevIndex = y * width + (startX - 1) + if (!isWordChar(charBuffer[prevIndex])) break + startX-- + } + + // Find end of word (scan right) + let endX = x + while (endX < width - 1) { + const nextIndex = y * width + (endX + 1) + if (!isWordChar(charBuffer[nextIndex])) break + endX++ + } + + return { startX, endX } + } + + const handleMouseDown = (evt: MouseEvent) => { + if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return + + const now = Date.now() + const isDoubleClick = now - lastClickTime < DOUBLE_CLICK_THRESHOLD && evt.x === lastClickX && evt.y === lastClickY + + if (isDoubleClick && evt.target) { + // Find word boundaries at click position + const boundaries = findWordBoundaries(evt.x, evt.y) + if (boundaries) { + // Create selection for the word using the same APIs as drag selection + renderer.startSelection(evt.target, boundaries.startX, evt.y) + renderer.updateSelection(evt.target, boundaries.endX + 1, evt.y) + } + // Reset to prevent triple-click being detected as another double-click + lastClickTime = 0 + lastClickX = -1 + lastClickY = -1 + } else { + lastClickTime = now + lastClickX = evt.x + lastClickY = evt.y + } + } + return ( { if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) { renderer.clearSelection() From e39e684ef80ba8b9fe96c53060726ff07c5cffee Mon Sep 17 00:00:00 2001 From: Netanel Draiman Date: Sun, 28 Dec 2025 10:09:45 +0200 Subject: [PATCH 2/3] refactor(tui): simplify double-click word selection logic Refactored the double-click detection and word selection implementation to be more concise and maintainable. Consolidated multiple state variables into a single click object, inlined helper functions, and simplified the word boundary detection logic while preserving functionality. --- packages/opencode/src/cli/cmd/tui/app.tsx | 87 ++++++++--------------- 1 file changed, 28 insertions(+), 59 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 41d72228464..8ae6322cb43 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -575,75 +575,44 @@ function App() { }) }) - // Double-click detection state - let lastClickTime = 0 - let lastClickX = -1 - let lastClickY = -1 - const DOUBLE_CLICK_THRESHOLD = 400 // ms - - // Check if a character is a word character (alphanumeric, underscore, or common code chars) - const isWordChar = (charCode: number): boolean => { - if (charCode === 0 || charCode === 32) return false // null or space - const char = String.fromCodePoint(charCode) - // Word characters: letters, digits, underscore, hyphen, and common programming chars - return /[\w\-]/.test(char) - } + // Double-click word selection + let click = { time: 0, x: -1, y: -1 } + + const handleMouseDown = (evt: MouseEvent) => { + if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return + + const now = Date.now() + const isDouble = now - click.time < 400 && evt.x === click.x && evt.y === click.y + + click = { time: now, x: evt.x, y: evt.y } + + if (!isDouble || !evt.target) return + click.time = 0 // Reset to prevent triple-click - // Find word boundaries at a given position in the render buffer - const findWordBoundaries = (x: number, y: number): { startX: number; endX: number } | null => { + // Find word boundaries from render buffer const buffer = renderer.currentRenderBuffer const width = buffer.width - const charBuffer = buffer.buffers.char + const chars = buffer.buffers.char + const idx = evt.y * width + evt.x - // Get the character at the click position - const index = y * width + x - if (index < 0 || index >= charBuffer.length) return null + if (idx < 0 || idx >= chars.length) return - const clickedChar = charBuffer[index] - if (!isWordChar(clickedChar)) return null - - // Find start of word (scan left) - let startX = x - while (startX > 0) { - const prevIndex = y * width + (startX - 1) - if (!isWordChar(charBuffer[prevIndex])) break - startX-- + const isWordChar = (i: number) => { + const c = chars[i] + if (c === 0 || c === 32) return false + return /[\w\-]/.test(String.fromCodePoint(c)) } - // Find end of word (scan right) - let endX = x - while (endX < width - 1) { - const nextIndex = y * width + (endX + 1) - if (!isWordChar(charBuffer[nextIndex])) break - endX++ - } + if (!isWordChar(idx)) return - return { startX, endX } - } + let start = evt.x + while (start > 0 && isWordChar(evt.y * width + start - 1)) start-- - const handleMouseDown = (evt: MouseEvent) => { - if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return + let end = evt.x + while (end < width - 1 && isWordChar(evt.y * width + end + 1)) end++ - const now = Date.now() - const isDoubleClick = now - lastClickTime < DOUBLE_CLICK_THRESHOLD && evt.x === lastClickX && evt.y === lastClickY - - if (isDoubleClick && evt.target) { - // Find word boundaries at click position - const boundaries = findWordBoundaries(evt.x, evt.y) - if (boundaries) { - // Create selection for the word using the same APIs as drag selection - renderer.startSelection(evt.target, boundaries.startX, evt.y) - renderer.updateSelection(evt.target, boundaries.endX + 1, evt.y) - } - // Reset to prevent triple-click being detected as another double-click - lastClickTime = 0 - lastClickX = -1 - lastClickY = -1 - } else { - lastClickTime = now - lastClickX = evt.x - lastClickY = evt.y - } + renderer.startSelection(evt.target, start, evt.y) + renderer.updateSelection(evt.target, end + 1, evt.y) } return ( From 46fd22cd054a6e8d27dfbd6b7bf13b2af7113377 Mon Sep 17 00:00:00 2001 From: Netanel Draiman Date: Sun, 28 Dec 2025 10:33:33 +0200 Subject: [PATCH 3/3] refactor(tui): extract row calculation to reduce repeated computation The row offset calculation (evt.y * width) was being computed multiple times in the double-click word selection logic. Extract it to a variable to improve readability and avoid redundant calculations. --- packages/opencode/src/cli/cmd/tui/app.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8ae6322cb43..ca3d48b2f69 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -593,7 +593,8 @@ function App() { const buffer = renderer.currentRenderBuffer const width = buffer.width const chars = buffer.buffers.char - const idx = evt.y * width + evt.x + const row = evt.y * width + const idx = row + evt.x if (idx < 0 || idx >= chars.length) return @@ -606,10 +607,10 @@ function App() { if (!isWordChar(idx)) return let start = evt.x - while (start > 0 && isWordChar(evt.y * width + start - 1)) start-- + while (start > 0 && isWordChar(row + start - 1)) start-- let end = evt.x - while (end < width - 1 && isWordChar(evt.y * width + end + 1)) end++ + while (end < width - 1 && isWordChar(row + end + 1)) end++ renderer.startSelection(evt.target, start, evt.y) renderer.updateSelection(evt.target, end + 1, evt.y)