From e7091bb3261fe0d2eb67a6090cf9e7aa6c7c9462 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Mon, 27 Apr 2026 16:43:48 -0700 Subject: [PATCH] fix(tui): mouse + keyboard text selection in the composer (#16732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tui): auto copy-on-select for transcript text Drag in the transcript already highlighted but you had to press Cmd+C to land it on the clipboard, and the highlight cleared on copy — most users never realised selection existed. Now drag-release fires copySelectionNoClear so the text is on the clipboard immediately while the highlight stays put, matching iTerm2's "Copy to pasteboard on selection" default. Esc clears. Behaviour: - Single click in the input still positions the cursor (TextInput onClick). - Single click in the transcript still does nothing destructive. - Double / triple click select word / line, then drag extends. - /copyselect [on|off|toggle] (alias /cos) flips the setting at runtime, HERMES_TUI_DISABLE_COPY_ON_SELECT=1 disables at startup, persists via display.tui_copy_on_select in config.yaml. Help overlay now lists drag-select, multi-click, and click-to-position so the gestures are discoverable. Made-with: Cursor * fix(tui): support prompt text selection gestures Add mouse drag selection and Shift+Arrow/Home/End extension inside the TUI composer so prompt text behaves like a normal editable field while keeping click-to-position and right-click paste intact. Made-with: Cursor * Revert "feat(tui): auto copy-on-select for transcript text" This reverts commit 6701288fe07a53af873e1ef53855a9618d733327. * fix(tui): allow composer selection from prompt whitespace Give the composer a one-cell mouse capture pad before the editable text. The prompt glyph/gutter still does not become selectable, but dragging from the edge now anchors at input offset 0 so users do not need to hit the first character precisely. Made-with: Cursor * fix(tui): clear selections from blank composer space Clicking blank space in the transcript or composer now clears active TUI/input selections like a normal text surface. TextInput clicks stop bubbling so cursor placement and selection gestures keep their local behavior. Made-with: Cursor * fix(tui): delegate prompt gutter drags to composer text The prompt gutter is now an input gesture region, not selectable content. Dragging from the whitespace or prompt area anchors the composer selection at offset 0, while selection highlight/copy remains limited to actual input text. Made-with: Cursor * fix(tui): move composer cursor to end on selection clear External clear actions now collapse the composer selection to the end of the input, matching normal text-field behavior after dismissing a selection. Made-with: Cursor * fix(tui): capture composer padding before prompt Add an explicit mouse capture cell over the left padding before the prompt glyph. Drags starting there now delegate to the composer input at offset 0 instead of starting terminal-level selection over the prompt chrome. Made-with: Cursor * fix(tui): avoid npm install on lockfile mtime churn Compare package-lock.json against npm's hidden node_modules lock by content instead of mtimes. Git checkouts and npm lock rewrites can make the root lockfile newer even when installed dependencies already match, causing hermes --tui to print Installing TUI dependencies on every launch. Made-with: Cursor * fix(tui): include prompt leading cell in gesture region Use the prompt box's real layout region to cover the leading whitespace cell before the glyph. The cell now participates in mouse hit testing and delegates to composer selection instead of starting terminal-level selection. Made-with: Cursor * fix(tui): widen prompt-side gesture capture band Capture a wider left-side band around the composer prompt row so drags starting in terminal gutter/padding cells are consumed and delegated to input selection, instead of triggering terminal-level selection chrome. Made-with: Cursor * fix(tui): make pre-prompt spacer non-selectable content Replace the sticky-prompt fallback `Text(' ')` with an empty spacer box so the visual gap remains but no literal space character is rendered/copyable before the composer prompt. Made-with: Cursor * fix(tui): capture pre-prompt spacer without shifting prompt layout Revert the widened negative-margin prompt capture band and instead capture drags on the dedicated spacer row above the prompt. This keeps prompt/text alignment stable while still delegating whitespace-start drags to composer selection. Made-with: Cursor * fix(tui): align prompt with status bar and capture full input row Drop the leading prompt column from 3 to 2 so the input first character lines up with the status bar text. Wrap the prompt+input row in a single mouse-capture box and stop event propagation from TextInput's own handlers so any drag in that row delegates to composer selection without leaking to terminal-level selection. Made-with: Cursor * fix(tui): anchor hardware cursor during composer selection When a composer selection covers a row exactly the column width, the rendered text fills the row and the terminal auto-wraps the hardware cursor to col 0 of the next row, leaving a ghost block beneath the prompt. Park the cursor at the start of the input box during selection so it can't escape the input region. Made-with: Cursor * fix(tui): hide hardware cursor during composer selection Stop fighting auto-wrap by hiding the hardware cursor outright while the composer has an active selection. This prevents both the ghost block under the prompt (cursor wrapping past the last cell) and the parked-cursor block on the first selected character. The cursor restores as soon as the selection clears or focus changes. Made-with: Cursor * chore(tui): /clean — drop dead capture-pad path, dedupe gutter handlers - TextInput: remove unused leftCaptureColumns prop and capture-pad math, drop unused mouseApi.startAt, fold mouse offset into a single offsetAt helper, share a MouseEventLite type across the four handlers. - appLayout: hoist a GutterMouseEvent type and an endInputDrag callback so the spacer/prompt/input rows share one shape. - _tui_need_npm_install: lift the runtime-only key set to a module constant, collapse nested isinstance checks, and document the mtime fallback. Made-with: Cursor * fix(tui): address copilot review on PR #16732 - Split InputSelection.clear() into clear() (cursor-preserving) and collapseToEnd() (clear + jump to end). Cmd+C copy paths keep using clear() so the cursor stays put; the blank-area click in useMainApp switches to collapseToEnd() to match the requested UX. - Spacer-row drags now force row=0 when forwarding into the input, since the spacer's vertical origin doesn't align with the input box and Ink mouse-capture keeps dispatching motion to the original target. Prompt+input row drag keeps localRow because origins match. Made-with: Cursor * fix(tui): give TextInput Box an explicit width After the /clean pass dropped the unused capture-pad math, the wrapping Box also lost its explicit width and started sizing to its rendered content. Clicks past the last character missed TextInput and fell through to the parent prompt-row Box, which collapsed the cursor to offset 0. Pin the Box back to `columns` so the input owns its full column span regardless of value length. Made-with: Cursor * feat(tui): double-click select-all + hide cursor on terminal blur - Track click time/offset in TextInput so a quick second click on the same offset triggers select-all. Ink's screen-level multi-click is bypassed once our onMouseDown captures, so the gesture has to be detected locally. - Extend the cursor-hide effect to also fire when the terminal loses focus, so the hollow-rect ghost most terminals draw at the parked cursor position disappears too. Made-with: Cursor * chore(tui): /clean — extract isMultiClickAt helper Pull the click-recurrence math out of TextInput's onMouseDown into a small isMultiClickAt(offset) helper so the handler reads as the gesture list it actually is (multi-click → select-all, otherwise start). Drop the redundant length>0 guard now that selectAll() already noops on an empty value. Made-with: Cursor * docs(tui): explain _tui_need_npm_install content-vs-mtime comparison Expand the docstring so future readers understand why we parse the lockfiles instead of comparing mtimes, what the optional/peer skip covers, how stale hidden-lock entries are handled, and when we fall back to mtime. --- hermes_cli/main.py | 53 +++++- tests/hermes_cli/test_tui_npm_install.py | 32 +++- ui-tui/src/app/inputSelectionStore.ts | 1 + ui-tui/src/app/interfaces.ts | 1 + ui-tui/src/app/useMainApp.ts | 10 +- ui-tui/src/components/appLayout.tsx | 80 ++++++++- ui-tui/src/components/textInput.tsx | 201 ++++++++++++++++++++--- 7 files changed, 341 insertions(+), 37 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 375561ad6d..cbfdec5a37 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -829,8 +829,29 @@ def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Opti ) +_NPM_LOCK_RUNTIME_KEYS = frozenset({"ideallyInert"}) + + def _tui_need_npm_install(root: Path) -> bool: - """True when @hermes/ink is missing or node_modules is behind package-lock.json (post-pull).""" + """True when @hermes/ink is missing or node_modules is behind package-lock.json. + + Compares ``package-lock.json`` against ``node_modules/.package-lock.json`` + (npm's hidden lockfile) by **content**, not mtime: git checkouts and npm + rewrites can bump the root lockfile's timestamp even when installed deps + already match, which used to trigger a spurious "Installing TUI + dependencies" on every launch. + + For each entry in the root lock's ``packages`` map: + - missing from hidden lock → reinstall (unless the entry is marked + ``optional`` or ``peer``, which npm may intentionally skip per platform) + - present but with differing fields (excluding npm-written runtime + annotations like ``ideallyInert``) → reinstall + + Extra entries that exist only in the hidden lock are ignored — stale + transitives left over from a removed dependency don't break runtime and + we'd rather not force a reinstall for them. Falls back to mtime + comparison if either lockfile is unparseable. + """ ink = root / "node_modules" / "@hermes" / "ink" / "package.json" if not ink.is_file(): return True @@ -840,7 +861,35 @@ def _tui_need_npm_install(root: Path) -> bool: marker = root / "node_modules" / ".package-lock.json" if not marker.is_file(): return True - return lock.stat().st_mtime > marker.stat().st_mtime + + # Compare lockfile contents, not mtimes: git checkouts and npm rewrites + # can bump the root lockfile timestamp even when installed deps already + # match. Fall back to mtime when either file is unparseable. + try: + wanted = json.loads(lock.read_text(encoding="utf-8")).get("packages") or {} + installed = json.loads(marker.read_text(encoding="utf-8")).get("packages") or {} + except (OSError, json.JSONDecodeError): + return lock.stat().st_mtime > marker.stat().st_mtime + + def comparable(pkg: dict) -> dict: + return {k: v for k, v in pkg.items() if k not in _NPM_LOCK_RUNTIME_KEYS} + + for name, pkg in wanted.items(): + if not name: + continue + + if not isinstance(pkg, dict): + continue + + if name not in installed: + if pkg.get("optional") or pkg.get("peer"): + continue + return True + + if isinstance(installed[name], dict) and comparable(pkg) != comparable(installed[name]): + return True + + return False def _find_bundled_tui(tui_dir: Path) -> Optional[Path]: diff --git a/tests/hermes_cli/test_tui_npm_install.py b/tests/hermes_cli/test_tui_npm_install.py index bceaf9de0b..e56196e07e 100644 --- a/tests/hermes_cli/test_tui_npm_install.py +++ b/tests/hermes_cli/test_tui_npm_install.py @@ -1,4 +1,4 @@ -"""_tui_need_npm_install: auto npm when lockfile ahead of node_modules.""" +"""_tui_need_npm_install: auto npm when node_modules is behind the lockfile.""" import os from pathlib import Path @@ -36,15 +36,39 @@ def test_need_install_when_ink_missing(tmp_path: Path, main_mod) -> None: assert main_mod._tui_need_npm_install(tmp_path) is True -def test_need_install_when_lock_newer_than_marker(tmp_path: Path, main_mod) -> None: +def test_no_install_when_lock_newer_but_hidden_lock_matches(tmp_path: Path, main_mod) -> None: _touch_ink(tmp_path) - (tmp_path / "package-lock.json").write_text("{}") - (tmp_path / "node_modules" / ".package-lock.json").write_text("{}") + (tmp_path / "package-lock.json").write_text('{"packages":{"node_modules/foo":{"version":"1.0.0"}}}') + (tmp_path / "node_modules" / ".package-lock.json").write_text( + '{"packages":{"node_modules/foo":{"version":"1.0.0","ideallyInert":true}}}' + ) os.utime(tmp_path / "package-lock.json", (200, 200)) os.utime(tmp_path / "node_modules" / ".package-lock.json", (100, 100)) + assert main_mod._tui_need_npm_install(tmp_path) is False + + +def test_need_install_when_required_package_missing_from_hidden_lock(tmp_path: Path, main_mod) -> None: + _touch_ink(tmp_path) + (tmp_path / "package-lock.json").write_text( + '{"packages":{"node_modules/foo":{"version":"1.0.0"},"node_modules/bar":{"version":"1.0.0"}}}' + ) + (tmp_path / "node_modules" / ".package-lock.json").write_text( + '{"packages":{"node_modules/foo":{"version":"1.0.0"}}}' + ) assert main_mod._tui_need_npm_install(tmp_path) is True +def test_no_install_when_only_optional_peer_package_missing_from_hidden_lock(tmp_path: Path, main_mod) -> None: + _touch_ink(tmp_path) + (tmp_path / "package-lock.json").write_text( + '{"packages":{"node_modules/foo":{"version":"1.0.0"},"node_modules/optional":{"version":"1.0.0","optional":true,"peer":true}}}' + ) + (tmp_path / "node_modules" / ".package-lock.json").write_text( + '{"packages":{"node_modules/foo":{"version":"1.0.0"}}}' + ) + assert main_mod._tui_need_npm_install(tmp_path) is False + + def test_no_install_when_lock_older_than_marker(tmp_path: Path, main_mod) -> None: _touch_ink(tmp_path) (tmp_path / "package-lock.json").write_text("{}") diff --git a/ui-tui/src/app/inputSelectionStore.ts b/ui-tui/src/app/inputSelectionStore.ts index 25b67c4283..c01e11861f 100644 --- a/ui-tui/src/app/inputSelectionStore.ts +++ b/ui-tui/src/app/inputSelectionStore.ts @@ -2,6 +2,7 @@ import { atom } from 'nanostores' export interface InputSelection { clear: () => void + collapseToEnd: () => void end: number start: number value: string diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 1d7cdaead0..9b987f87d3 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -285,6 +285,7 @@ export interface AppLayoutActions { answerClarify: (answer: string) => void answerSecret: (value: string) => void answerSudo: (pw: string) => void + clearSelection: () => void onModelSelect: (value: string) => void resumeById: (id: string) => void setStickyPrompt: (value: string) => void diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 9f2cae78d6..c18deb47bb 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -25,6 +25,7 @@ import type { Msg, PanelSection, SlashCatalog } from '../types.js' import { createGatewayEventHandler } from './createGatewayEventHandler.js' import { createSlashHandler } from './createSlashHandler.js' +import { getInputSelection } from './inputSelectionStore.js' import { type GatewayRpc, type TranscriptRow } from './interfaces.js' import { $overlayState, patchOverlayState } from './overlayStore.js' import { scrollWithSelectionBy } from './scroll.js' @@ -147,6 +148,11 @@ export function useMainApp(gw: GatewayClient) { selection.setSelectionBgColor(ui.theme.color.selectionBg) }, [selection, ui.theme.color.selectionBg]) + const clearSelection = useCallback(() => { + selection.clearSelection() + getInputSelection()?.collapseToEnd() + }, [selection]) + const composer = useComposerState({ gw, onClipboardPaste: quiet => clipboardPasteRef.current(quiet), @@ -519,6 +525,7 @@ export function useMainApp(gw: GatewayClient) { [ appendMessage, bellOnComplete, + clearSelection, composerActions.setInput, gateway, panel, @@ -691,11 +698,12 @@ export function useMainApp(gw: GatewayClient) { answerClarify, answerSecret, answerSudo, + clearSelection, onModelSelect, resumeById: session.resumeById, setStickyPrompt }), - [answerApproval, answerClarify, answerSecret, answerSudo, onModelSelect, session.resumeById] + [answerApproval, answerClarify, answerSecret, answerSudo, clearSelection, onModelSelect, session.resumeById] ) const appComposer = useMemo( diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index d8a9d0f5f2..7d39d571c3 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -1,6 +1,6 @@ import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' -import { Fragment, memo, useMemo } from 'react' +import { Fragment, memo, useMemo, useRef } from 'react' import { useGateway } from '../app/gatewayContext.js' import type { AppLayoutProps } from '../app/interfaces.js' @@ -20,7 +20,7 @@ import { FpsOverlay } from './fpsOverlay.js' import { MessageLine } from './messageLine.js' import { QueuedMessages } from './queuedMessages.js' import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js' -import { TextInput } from './textInput.js' +import { TextInput, type TextInputMouseApi } from './textInput.js' const TranscriptPane = memo(function TranscriptPane({ actions, @@ -47,7 +47,18 @@ const TranscriptPane = memo(function TranscriptPane({ return ( <> - + { + if (e.cellIsBlank) { + actions.clearSelection() + } + }} + ref={transcript.scrollRef} + stickyScroll + > {transcript.virtualHistory.topSpacer > 0 ? : null} @@ -113,12 +124,57 @@ const ComposerPane = memo(function ComposerPane({ const ui = useStore($uiState) const isBlocked = useStore($isBlocked) const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!') - const pw = sh ? 2 : 3 + const pw = 2 const inputColumns = stableComposerColumns(composer.cols, pw) const inputHeight = inputVisualHeight(composer.input, inputColumns) + const inputMouseRef = useRef(null) + + const captureInputDrag = (e: GutterMouseEvent) => { + if (e.button !== 0) { + return + } + + e.stopImmediatePropagation?.() + inputMouseRef.current?.startAtBeginning() + } + + // Drag origin matches the input box's top-left, so localRow / localCol + // map directly into TextInput coords (after backing out the prompt cell). + const dragFromPromptRow = (e: GutterMouseEvent) => { + if (e.button !== 0) { + return + } + + e.stopImmediatePropagation?.() + inputMouseRef.current?.dragAt(e.localRow ?? 0, (e.localCol ?? 0) - pw) + } + + // Spacer rows live on a different vertical origin; only the column is + // parent-aligned with the input. Force row=0 so vertical drags can't + // jump the cursor to the wrong wrapped line. + const dragFromSpacer = (e: GutterMouseEvent) => { + if (e.button !== 0) { + return + } + + e.stopImmediatePropagation?.() + inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - pw) + } + + const endInputDrag = () => inputMouseRef.current?.end() return ( - + { + if (e.cellIsBlank) { + actions.clearSelection() + } + }} + paddingX={1} + > ) : ( - + )} @@ -158,7 +214,7 @@ const ComposerPane = memo(function ComposerPane({ <> {composer.inputBuf.map((line, i) => ( - + {i === 0 ? `${ui.theme.brand.prompt} ` : ' '} @@ -166,7 +222,7 @@ const ComposerPane = memo(function ComposerPane({ ))} - + {sh ? ( $ @@ -181,6 +237,7 @@ const ComposerPane = memo(function ComposerPane({ {/* Reserve the transcript scrollbar gutter too so typing never rewraps when the scrollbar column repaints. */} ) }) + +type GutterMouseEvent = { + button: number + localCol?: number + localRow?: number + stopImmediatePropagation?: () => void +} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 1bff1d6756..9ef0284abb 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -1,6 +1,6 @@ import type { InputEvent, Key } from '@hermes/ink' import * as Ink from '@hermes/ink' -import { useEffect, useMemo, useRef, useState } from 'react' +import { type MutableRefObject, useEffect, useMemo, useRef, useState } from 'react' import { setInputSelection } from '../app/inputSelectionStore.js' import { readClipboardText, writeClipboardText } from '../lib/clipboard.js' @@ -25,6 +25,7 @@ const DIM_OFF = `${ESC}[22m` const FWD_DEL_RE = new RegExp(`${ESC}\\[3(?:[~$^]|;)`) const PRINTABLE = /^[ -~\u00a0-\uffff]+$/ const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g') +const MULTI_CLICK_MS = 500 const invert = (s: string) => INV + s + INV_OFF const dim = (s: string) => DIM + s + DIM_OFF @@ -287,6 +288,7 @@ export function TextInput({ onPaste, onSubmit, mask, + mouseApiRef, placeholder = '', focus = true }: TextInputProps) { @@ -309,6 +311,8 @@ export function TextInput({ const pendingParentValue = useRef(null) const localRenderTimer = useRef | null>(null) const lineWidthRef = useRef(stringWidth(value.includes('\n') ? value.slice(value.lastIndexOf('\n') + 1) : value)) + const mouseAnchorRef = useRef(null) + const lastClickRef = useRef<{ at: number; offset: number }>({ at: 0, offset: -1 }) const undo = useRef<{ cursor: number; value: string }[]>([]) const redo = useRef<{ cursor: number; value: string }[]>([]) @@ -336,6 +340,24 @@ export function TextInput({ active: focus && termFocus && !selected }) + // Hide the hardware cursor while a selection is active (prevents + // auto-wrap onto the next row when inverted text fills the column + // exactly) or when the terminal loses focus (suppresses the hollow-rect + // ghost most terminals draw at the parked position). + const hideHardwareCursor = focus && !!stdout?.isTTY && (!!selected || !termFocus) + + useEffect(() => { + if (!hideHardwareCursor || !stdout) { + return + } + + stdout.write('\x1b[?25l') + + return () => { + stdout.write('\x1b[?25h') + } + }, [hideHardwareCursor, stdout]) + const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY const rendered = useMemo(() => { @@ -374,12 +396,21 @@ export function TextInput({ return } + const dropSel = () => { + if (!selRef.current) { + return + } + + selRef.current = null + setSel(null) + } + setInputSelection({ - clear: () => { - if (selRef.current) { - selRef.current = null - setSel(null) - } + clear: dropSel, + collapseToEnd: () => { + dropSel() + setCur(vRef.current.length) + curRef.current = vRef.current.length }, end: selected?.end ?? curRef.current, start: selected?.start ?? curRef.current, @@ -605,6 +636,22 @@ export function TextInput({ curRef.current = end } + const moveCursor = (next: number, extend = false) => { + const c = snapPos(vRef.current, next) + + if (extend) { + const anchor = selRef.current?.start ?? curRef.current + const nextSel = { end: c, start: anchor } + selRef.current = nextSel + setSel(nextSel) + } else { + clearSel() + } + + setCur(c) + curRef.current = c + } + const selRange = () => { const range = selRef.current @@ -633,6 +680,59 @@ export function TextInput({ commit(nextValue, nextCursor) } + const startMouseSelection = (next: number) => { + const c = snapPos(vRef.current, next) + + mouseAnchorRef.current = c + selRef.current = { end: c, start: c } + setSel(null) + setCur(c) + curRef.current = c + } + + const dragMouseSelection = (next: number) => { + if (mouseAnchorRef.current === null) { + return + } + + const c = snapPos(vRef.current, next) + const range = { end: c, start: mouseAnchorRef.current } + selRef.current = range + setSel(range.start === range.end ? null : range) + setCur(c) + curRef.current = c + } + + const endMouseSelection = () => { + mouseAnchorRef.current = null + + const range = selRef.current + + if (range && range.start === range.end) { + selRef.current = null + setSel(null) + } + } + + const offsetAt = (e: { localCol?: number; localRow?: number }) => + offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns) + + const isMultiClickAt = (offset: number) => { + const now = Date.now() + const last = lastClickRef.current + lastClickRef.current = { at: now, offset } + + return now - last.at < MULTI_CLICK_MS && offset === last.offset + } + + if (mouseApiRef) { + mouseApiRef.current = { + dragAt: (row, col) => dragMouseSelection(offsetFromPosition(display, row, col, columns)), + end: endMouseSelection, + startAtBeginning: () => startMouseSelection(0) + } + } + useInput( (inp: string, k: Key, event: InputEvent) => { const eventRaw = event.keypress.raw @@ -674,9 +774,7 @@ export function TextInput({ const next = lineNav(vRef.current, curRef.current, k.upArrow ? -1 : 1) if (next !== null) { - clearSel() - setCur(next) - curRef.current = next + moveCursor(next, k.shift) return } @@ -737,27 +835,37 @@ export function TextInput({ } if (actionHome) { - clearSel() c = 0 + moveCursor(c, k.shift) + + return } else if (actionEnd) { - clearSel() c = v.length + moveCursor(c, k.shift) + + return } else if (k.leftArrow) { - if (range && !wordMod) { + if (range && !wordMod && !k.shift) { clearSel() c = range.start } else { - clearSel() c = wordMod ? wordLeft(v, c) : prevPos(v, c) } + + moveCursor(c, k.shift) + + return } else if (k.rightArrow) { - if (range && !wordMod) { + if (range && !wordMod && !k.shift) { clearSel() c = range.end } else { - clearSel() c = wordMod ? wordRight(v, c) : nextPos(v, c) } + + moveCursor(c, k.shift) + + return } else if (wordMod && inp === 'b') { clearSel() c = wordLeft(v, c) @@ -883,32 +991,74 @@ export function TextInput({ return ( { + onClick={(e: MouseEventLite) => { if (!focus) { return } + e.stopImmediatePropagation?.() clearSel() - const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns) + const next = offsetAt(e) setCur(next) curRef.current = next }} - onMouseDown={(e: { button: number }) => { - // Right-click to paste: route through the same hotkey path as - // Alt+V so the composer's clipboard RPC (text or image) handles it. - if (!focus || e.button !== 2) { + onMouseDown={(e: MouseEventLite) => { + if (!focus) { return } - emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) + // Right-click → route through the same path as Alt+V so the composer + // clipboard RPC (text or image) handles it. + if (e.button === 2) { + e.stopImmediatePropagation?.() + emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) + + return + } + + if (e.button !== 0) { + return + } + + e.stopImmediatePropagation?.() + const offset = offsetAt(e) + + if (isMultiClickAt(offset)) { + mouseAnchorRef.current = null + selectAll() + + return + } + + startMouseSelection(offset) + }} + onMouseDrag={(e: MouseEventLite) => { + if (!focus || e.button !== 0 || mouseAnchorRef.current === null) { + return + } + + e.stopImmediatePropagation?.() + dragMouseSelection(offsetAt(e)) + }} + onMouseUp={(e: MouseEventLite) => { + e.stopImmediatePropagation?.() + endMouseSelection() }} ref={boxRef} + width={columns} > {rendered} ) } +type MouseEventLite = { + button?: number + localCol?: number + localRow?: number + stopImmediatePropagation?: () => void +} + export interface PasteEvent { bracketed?: boolean cursor: number @@ -921,6 +1071,7 @@ interface TextInputProps { columns?: number focus?: boolean mask?: string + mouseApiRef?: MutableRefObject onChange: (v: string) => void onPaste?: ( e: PasteEvent @@ -929,3 +1080,9 @@ interface TextInputProps { placeholder?: string value: string } + +export interface TextInputMouseApi { + dragAt: (row: number, col: number) => void + end: () => void + startAtBeginning: () => void +}