Files
hermes-agent/ui-tui/src/components/textInput.tsx

1089 lines
26 KiB
TypeScript
Raw Normal View History

import type { InputEvent, Key } from '@hermes/ink'
import * as Ink from '@hermes/ink'
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
import { type MutableRefObject, useEffect, useMemo, useRef, useState } from 'react'
2026-04-11 11:29:08 -05:00
import { setInputSelection } from '../app/inputSelectionStore.js'
2026-04-19 13:54:18 +05:30
import { readClipboardText, writeClipboardText } from '../lib/clipboard.js'
import { cursorLayout } from '../lib/inputMetrics.js'
import { isActionMod, isMac, isMacActionFallback } from '../lib/platform.js'
2026-04-11 11:29:08 -05:00
type InkExt = typeof Ink & {
stringWidth: (s: string) => number
useDeclaredCursor: (a: { line: number; column: number; active: boolean }) => (el: any) => void
useStdout: () => { stdout?: NodeJS.WriteStream }
2026-04-11 11:29:08 -05:00
useTerminalFocus: () => boolean
}
const ink = Ink as unknown as InkExt
const { Box, Text, useStdin, useInput, useStdout, stringWidth, useDeclaredCursor, useTerminalFocus } = ink
2026-04-11 11:29:08 -05:00
const ESC = '\x1b'
const INV = `${ESC}[7m`
const INV_OFF = `${ESC}[27m`
const DIM = `${ESC}[2m`
const DIM_OFF = `${ESC}[22m`
2026-04-12 16:31:30 -05:00
const FWD_DEL_RE = new RegExp(`${ESC}\\[3(?:[~$^]|;)`)
2026-04-11 11:29:08 -05:00
const PRINTABLE = /^[ -~\u00a0-\uffff]+$/
const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g')
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
const MULTI_CLICK_MS = 500
2026-04-11 11:29:08 -05:00
const invert = (s: string) => INV + s + INV_OFF
const dim = (s: string) => DIM + s + DIM_OFF
let _seg: Intl.Segmenter | null = null
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
2026-04-15 14:14:01 -05:00
const STOP_CACHE_MAX = 32
const stopCache = new Map<string, number[]>()
2026-04-11 11:29:08 -05:00
function graphemeStops(s: string) {
2026-04-15 14:14:01 -05:00
const hit = stopCache.get(s)
if (hit) {
return hit
}
const stops = [0]
for (const { index } of seg().segment(s)) {
if (index > 0) {
stops.push(index)
}
}
if (stops.at(-1) !== s.length) {
stops.push(s.length)
}
2026-04-15 14:14:01 -05:00
stopCache.set(s, stops)
if (stopCache.size > STOP_CACHE_MAX) {
const oldest = stopCache.keys().next().value
if (oldest !== undefined) {
stopCache.delete(oldest)
}
}
return stops
}
function snapPos(s: string, p: number) {
const pos = Math.max(0, Math.min(p, s.length))
let last = 0
for (const stop of graphemeStops(s)) {
if (stop > pos) {
break
}
last = stop
}
return last
}
function prevPos(s: string, p: number) {
const pos = snapPos(s, p)
let prev = 0
for (const stop of graphemeStops(s)) {
if (stop >= pos) {
return prev
}
prev = stop
}
return prev
}
function nextPos(s: string, p: number) {
const pos = snapPos(s, p)
for (const stop of graphemeStops(s)) {
if (stop > pos) {
return stop
}
}
return s.length
}
2026-04-08 14:18:37 -05:00
function wordLeft(s: string, p: number) {
let i = snapPos(s, p) - 1
2026-04-07 20:30:22 -05:00
2026-04-09 14:17:45 -05:00
while (i > 0 && /\s/.test(s[i]!)) {
i--
}
2026-04-07 20:30:22 -05:00
2026-04-09 14:17:45 -05:00
while (i > 0 && !/\s/.test(s[i - 1]!)) {
i--
}
2026-04-07 20:30:22 -05:00
2026-04-07 20:10:33 -05:00
return Math.max(0, i)
}
2026-04-08 14:18:37 -05:00
function wordRight(s: string, p: number) {
let i = snapPos(s, p)
2026-04-07 20:30:22 -05:00
2026-04-09 14:17:45 -05:00
while (i < s.length && !/\s/.test(s[i]!)) {
i++
}
2026-04-07 20:30:22 -05:00
2026-04-09 14:17:45 -05:00
while (i < s.length && /\s/.test(s[i]!)) {
i++
}
2026-04-07 20:30:22 -05:00
2026-04-07 20:10:33 -05:00
return i
}
/**
* Move cursor one logical line up or down inside `s` while preserving the
* column offset from the current line's start. Returns `null` when the cursor
* is already on the first line (up) or last line (down) callers use that
* signal to fall through to history cycling instead of eating the arrow key.
*/
export function lineNav(s: string, p: number, dir: -1 | 1): null | number {
const pos = snapPos(s, p)
const curStart = s.lastIndexOf('\n', pos - 1) + 1
const col = pos - curStart
if (dir < 0) {
if (curStart === 0) {
return null
}
const prevStart = s.lastIndexOf('\n', curStart - 2) + 1
return snapPos(s, Math.min(prevStart + col, curStart - 1))
}
const nextBreak = s.indexOf('\n', pos)
if (nextBreak < 0) {
return null
}
const nextEnd = s.indexOf('\n', nextBreak + 1)
const lineEnd = nextEnd < 0 ? s.length : nextEnd
return snapPos(s, Math.min(nextBreak + 1 + col, lineEnd))
}
export function offsetFromPosition(value: string, row: number, col: number, cols: number) {
2026-04-13 21:20:55 -05:00
if (!value.length) {
return 0
}
const targetRow = Math.max(0, Math.floor(row))
const targetCol = Math.max(0, Math.floor(col))
const w = Math.max(1, cols)
2026-04-13 21:20:55 -05:00
let line = 0
let column = 0
let lastOffset = 0
for (const { segment, index } of seg().segment(value)) {
lastOffset = index
if (segment === '\n') {
if (line === targetRow) {
return index
}
2026-04-14 11:49:32 -05:00
2026-04-13 21:20:55 -05:00
line++
column = 0
2026-04-14 11:49:32 -05:00
2026-04-13 21:20:55 -05:00
continue
}
const sw = Math.max(1, stringWidth(segment))
if (column + sw > w) {
if (line === targetRow) {
return index
}
2026-04-14 11:49:32 -05:00
2026-04-13 21:20:55 -05:00
line++
column = 0
}
if (line === targetRow && targetCol <= column + Math.max(0, sw - 1)) {
return index
}
column += sw
}
if (targetRow >= line) {
return value.length
}
return lastOffset
}
2026-04-11 11:29:08 -05:00
function renderWithCursor(value: string, cursor: number) {
const pos = Math.max(0, Math.min(cursor, value.length))
let out = '',
done = false
for (const { segment, index } of seg().segment(value)) {
if (!done && index >= pos) {
out += invert(index === pos && segment !== '\n' ? segment : ' ')
done = true
if (index === pos && segment !== '\n') {
continue
}
}
out += segment
}
return done ? out : out + invert(' ')
}
function renderWithSelection(value: string, start: number, end: number) {
if (start >= end) {
return value
}
return value.slice(0, start) + invert(value.slice(start, end) || ' ') + value.slice(end)
}
2026-04-12 16:31:30 -05:00
function useFwdDelete(active: boolean) {
const ref = useRef(false)
const { inputEmitter: ee } = useStdin()
useEffect(() => {
if (!active) {
return
}
const h = (d: string) => {
ref.current = FWD_DEL_RE.test(d)
}
ee.prependListener('input', h)
return () => {
ee.removeListener('input', h)
}
}, [active, ee])
return ref
}
type PasteResult = { cursor: number; value: string } | null
const isPasteResultPromise = (
value: PasteResult | Promise<PasteResult> | null | undefined
): value is Promise<PasteResult> => !!value && typeof (value as PromiseLike<PasteResult>).then === 'function'
2026-04-12 16:31:30 -05:00
export function TextInput({
columns = 80,
value,
onChange,
onPaste,
onSubmit,
mask,
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
mouseApiRef,
2026-04-12 16:31:30 -05:00
placeholder = '',
focus = true
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
}: TextInputProps) {
2026-04-07 20:10:33 -05:00
const [cur, setCur] = useState(value.length)
const [sel, setSel] = useState<null | { end: number; start: number }>(null)
2026-04-12 16:31:30 -05:00
const fwdDel = useFwdDelete(focus)
2026-04-11 11:29:08 -05:00
const termFocus = useTerminalFocus()
const { stdout } = useStdout()
const curRef = useRef(cur)
const selRef = useRef<null | { end: number; start: number }>(null)
2026-04-07 20:10:33 -05:00
const vRef = useRef(value)
2026-04-11 11:29:08 -05:00
const self = useRef(false)
2026-04-07 20:10:33 -05:00
const pasteBuf = useRef('')
const pasteEnd = useRef<null | number>(null)
2026-04-07 20:10:33 -05:00
const pasteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pastePos = useRef(0)
const editVersionRef = useRef(0)
const parentChangeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingParentValue = useRef<string | null>(null)
const localRenderTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
2026-04-26 03:22:50 -05:00
const lineWidthRef = useRef(stringWidth(value.includes('\n') ? value.slice(value.lastIndexOf('\n') + 1) : value))
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
const mouseAnchorRef = useRef<null | number>(null)
const lastClickRef = useRef<{ at: number; offset: number }>({ at: 0, offset: -1 })
2026-04-11 11:29:08 -05:00
const undo = useRef<{ cursor: number; value: string }[]>([])
const redo = useRef<{ cursor: number; value: string }[]>([])
const cbChange = useRef(onChange)
const cbSubmit = useRef(onSubmit)
const cbPaste = useRef(onPaste)
cbChange.current = onChange
cbSubmit.current = onSubmit
cbPaste.current = onPaste
2026-04-12 16:31:30 -05:00
const raw = self.current ? vRef.current : value
const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw
2026-04-11 11:29:08 -05:00
const selected = useMemo(
() =>
sel && sel.start !== sel.end ? { end: Math.max(sel.start, sel.end), start: Math.min(sel.start, sel.end) } : null,
[sel]
)
2026-04-11 11:29:08 -05:00
const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display])
const boxRef = useDeclaredCursor({
line: layout.line,
column: layout.column,
active: focus && termFocus && !selected
2026-04-11 11:29:08 -05:00
})
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
// 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
2026-04-11 11:29:08 -05:00
const rendered = useMemo(() => {
if (!focus) {
return display || dim(placeholder)
}
if (!display && placeholder) {
return nativeCursor ? dim(placeholder) : invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1))
2026-04-11 11:29:08 -05:00
}
if (selected) {
return renderWithSelection(display, selected.start, selected.end)
}
return nativeCursor ? display || ' ' : renderWithCursor(display, cur)
}, [cur, display, focus, nativeCursor, placeholder, selected])
2026-04-07 20:10:33 -05:00
useEffect(() => {
2026-04-11 11:29:08 -05:00
if (self.current) {
self.current = false
2026-04-07 20:30:22 -05:00
} else {
setCur(value.length)
setSel(null)
curRef.current = value.length
selRef.current = null
vRef.current = value
2026-04-26 03:22:50 -05:00
lineWidthRef.current = stringWidth(value.includes('\n') ? value.slice(value.lastIndexOf('\n') + 1) : value)
2026-04-11 11:29:08 -05:00
undo.current = []
redo.current = []
2026-04-07 20:30:22 -05:00
}
2026-04-07 20:10:33 -05:00
}, [value])
useEffect(() => {
if (!focus) {
return
}
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
const dropSel = () => {
if (!selRef.current) {
return
}
selRef.current = null
setSel(null)
}
setInputSelection({
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
clear: dropSel,
collapseToEnd: () => {
dropSel()
setCur(vRef.current.length)
curRef.current = vRef.current.length
},
end: selected?.end ?? curRef.current,
start: selected?.start ?? curRef.current,
value: vRef.current
})
return () => setInputSelection(null)
}, [cur, focus, selected])
2026-04-09 14:17:45 -05:00
useEffect(
() => () => {
if (pasteTimer.current) {
clearTimeout(pasteTimer.current)
}
if (parentChangeTimer.current) {
clearTimeout(parentChangeTimer.current)
}
if (localRenderTimer.current) {
clearTimeout(localRenderTimer.current)
}
2026-04-09 14:17:45 -05:00
},
[]
)
const flushParentChange = () => {
if (parentChangeTimer.current) {
clearTimeout(parentChangeTimer.current)
parentChangeTimer.current = null
}
const next = pendingParentValue.current
pendingParentValue.current = null
if (next !== null) {
self.current = true
cbChange.current(next)
}
}
const scheduleParentChange = (next: string) => {
pendingParentValue.current = next
if (parentChangeTimer.current) {
return
}
parentChangeTimer.current = setTimeout(flushParentChange, 16)
}
const cancelLocalRender = () => {
if (localRenderTimer.current) {
clearTimeout(localRenderTimer.current)
localRenderTimer.current = null
}
}
const scheduleLocalRender = () => {
if (localRenderTimer.current) {
return
}
localRenderTimer.current = setTimeout(() => {
localRenderTimer.current = null
setCur(curRef.current)
}, 16)
}
2026-04-26 03:22:50 -05:00
const canFastEchoBase = () => focus && termFocus && !selected && !mask && !!stdout?.isTTY
const canFastAppend = (current: string, cursor: number, text: string) => {
const sw = stringWidth(text)
return (
canFastEchoBase() &&
cursor === current.length &&
current.length > 0 &&
!current.includes('\n') &&
sw === text.length &&
lineWidthRef.current + sw < Math.max(1, columns)
)
}
const canFastBackspace = (current: string, cursor: number) => {
if (!canFastEchoBase() || cursor !== current.length || cursor <= 0 || current.includes('\n')) {
return false
}
return stringWidth(current.slice(prevPos(current, cursor), cursor)) === 1
2026-04-26 03:22:50 -05:00
}
2026-04-26 13:43:08 -05:00
const commit = (
next: string,
nextCur: number,
track = true,
syncParent = true,
syncLocal = true,
nextLineWidth?: number
) => {
const prev = vRef.current
const c = snapPos(next, nextCur)
editVersionRef.current += 1
if (selRef.current) {
selRef.current = null
setSel(null)
}
if (track && next !== prev) {
2026-04-11 11:29:08 -05:00
undo.current.push({ cursor: curRef.current, value: prev })
2026-04-11 11:29:08 -05:00
if (undo.current.length > 200) {
undo.current.shift()
2026-04-09 14:17:45 -05:00
}
2026-04-11 11:29:08 -05:00
redo.current = []
}
if (syncLocal) {
cancelLocalRender()
setCur(c)
} else {
scheduleLocalRender()
}
curRef.current = c
vRef.current = next
lineWidthRef.current =
nextLineWidth ?? stringWidth(next.includes('\n') ? next.slice(next.lastIndexOf('\n') + 1) : next)
2026-04-08 14:18:37 -05:00
if (next !== prev) {
if (syncParent) {
flushParentChange()
self.current = true
cbChange.current(next)
} else {
2026-04-26 13:43:08 -05:00
self.current = true
scheduleParentChange(next)
}
2026-04-08 14:18:37 -05:00
}
}
2026-04-11 11:29:08 -05:00
const swap = (from: typeof undo, to: typeof redo) => {
const entry = from.current.pop()
2026-04-09 14:17:45 -05:00
if (!entry) {
return
}
to.current.push({ cursor: curRef.current, value: vRef.current })
commit(entry.value, entry.cursor, false)
}
2026-04-09 13:45:23 -05:00
const emitPaste = (e: PasteEvent) => {
const startVersion = editVersionRef.current
2026-04-11 11:29:08 -05:00
const h = cbPaste.current?.(e)
2026-04-09 13:45:23 -05:00
if (isPasteResultPromise(h)) {
const fallbackText = e.text
void h
.then(result => {
if (result && editVersionRef.current === startVersion) {
commit(result.value, result.cursor)
} else if (result && fallbackText && PRINTABLE.test(fallbackText)) {
// User typed while async paste was in-flight — fall back to raw text insert
// so the pasted content is not silently lost.
const cur = curRef.current
const v = vRef.current
commit(v.slice(0, cur) + fallbackText + v.slice(cur), cur + fallbackText.length)
}
})
.catch(() => {})
return true
}
2026-04-11 11:29:08 -05:00
if (h) {
commit(h.value, h.cursor)
2026-04-09 14:17:45 -05:00
}
2026-04-09 13:45:23 -05:00
2026-04-11 11:29:08 -05:00
return !!h
2026-04-09 13:45:23 -05:00
}
2026-04-07 20:10:33 -05:00
const flushPaste = () => {
2026-04-09 13:45:23 -05:00
const text = pasteBuf.current
2026-04-07 20:10:33 -05:00
const at = pastePos.current
const end = pasteEnd.current ?? at
2026-04-07 20:10:33 -05:00
pasteBuf.current = ''
pasteEnd.current = null
2026-04-07 20:10:33 -05:00
pasteTimer.current = null
2026-04-07 20:30:22 -05:00
2026-04-09 14:17:45 -05:00
if (!text) {
return
}
2026-04-07 20:30:22 -05:00
2026-04-09 13:45:23 -05:00
if (!emitPaste({ cursor: at, text, value: vRef.current }) && PRINTABLE.test(text)) {
commit(vRef.current.slice(0, at) + text + vRef.current.slice(end), at + text.length)
2026-04-07 20:10:33 -05:00
}
}
const clearSel = () => {
if (!selRef.current) {
return
}
selRef.current = null
setSel(null)
}
const selectAll = () => {
const end = vRef.current.length
if (!end) {
return
}
const next = { end, start: 0 }
selRef.current = next
setSel(next)
setCur(end)
curRef.current = end
}
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
const moveCursor = (next: number, extend = false) => {
const c = snapPos(vRef.current, next)
const anchor = selRef.current?.start ?? curRef.current
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
if (!extend || anchor === c) {
clearSel()
} else {
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
const nextSel = { end: c, start: anchor }
selRef.current = nextSel
setSel(nextSel)
}
setCur(c)
curRef.current = c
}
const selRange = () => {
const range = selRef.current
return range && range.start !== range.end
? { end: Math.max(range.start, range.end), start: Math.min(range.start, range.end) }
: null
}
2026-04-11 11:29:08 -05:00
const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c)
2026-04-09 13:45:23 -05:00
const pastePlainText = (text: string) => {
const cleaned = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
if (!cleaned) {
return
}
const range = selRange()
const nextValue = range
? vRef.current.slice(0, range.start) + cleaned + vRef.current.slice(range.end)
: vRef.current.slice(0, curRef.current) + cleaned + vRef.current.slice(curRef.current)
const nextCursor = range ? range.start + cleaned.length : curRef.current + cleaned.length
commit(nextValue, nextCursor)
}
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
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)
}
}
2026-04-07 20:10:33 -05:00
useInput(
(inp: string, k: Key, event: InputEvent) => {
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
const eventRaw = event.keypress.raw
if (
eventRaw === '\x1bv' ||
eventRaw === '\x1bV' ||
eventRaw === '\x16' ||
(isMac && isActionMod(k) && inp.toLowerCase() === 'v')
) {
if (cbPaste.current) {
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
}
if (isMac) {
2026-04-19 12:48:02 +05:30
void readClipboardText().then(text => {
if (text) {
pastePlainText(text)
}
})
}
return
}
if (isMac && isActionMod(k) && inp.toLowerCase() === 'c') {
const range = selRange()
if (range) {
2026-04-19 13:54:18 +05:30
const text = vRef.current.slice(range.start, range.end)
void writeClipboardText(text)
}
return
2026-04-09 13:45:23 -05:00
}
if (k.upArrow || k.downArrow) {
const next = lineNav(vRef.current, curRef.current, k.upArrow ? -1 : 1)
if (next !== null) {
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
moveCursor(next, k.shift)
return
}
return
}
// Ctrl chords claimed by useInputHandlers — pass through instead of
// letting them fall into readline-style nav or a literal char insert.
// Ctrl+B = voice toggle, Ctrl+X = delete queued message while editing.
if (
(k.ctrl && inp === 'c') ||
(k.ctrl && inp === 'b') ||
(k.ctrl && inp === 'x') ||
k.tab ||
(k.shift && k.tab) ||
k.pageUp ||
k.pageDown ||
k.escape
) {
2026-04-09 14:17:45 -05:00
return
}
2026-04-07 20:10:33 -05:00
2026-04-07 20:30:22 -05:00
if (k.return) {
if (k.shift || k.ctrl || (isMac ? isActionMod(k) : k.meta)) {
flushParentChange()
commit(ins(vRef.current, curRef.current, '\n'), curRef.current + 1)
} else {
flushParentChange()
cbSubmit.current?.(vRef.current)
}
2026-04-07 20:10:33 -05:00
2026-04-07 20:30:22 -05:00
return
2026-04-07 20:10:33 -05:00
}
2026-04-07 20:30:22 -05:00
let c = curRef.current
let v = vRef.current
2026-04-19 12:48:02 +05:30
const mod = isActionMod(k)
const wordMod = mod || k.meta
const actionHome = k.home || (!isMac && mod && inp === 'a') || isMacActionFallback(k, inp, 'a')
const actionEnd = k.end || (mod && inp === 'e') || isMacActionFallback(k, inp, 'e')
const actionDeleteToStart = (mod && inp === 'u') || isMacActionFallback(k, inp, 'u')
const actionKillToEnd = (mod && inp === 'k') || isMacActionFallback(k, inp, 'k')
const actionDeleteWord = (mod && inp === 'w') || isMacActionFallback(k, inp, 'w')
const range = selRange()
const delFwd = k.delete || fwdDel.current
2026-04-07 20:30:22 -05:00
2026-04-19 12:48:02 +05:30
if (mod && inp === 'z') {
2026-04-11 11:29:08 -05:00
return swap(undo, redo)
2026-04-09 14:17:45 -05:00
}
2026-04-19 12:48:02 +05:30
if ((mod && inp === 'y') || (mod && k.shift && inp === 'z')) {
2026-04-11 11:29:08 -05:00
return swap(redo, undo)
2026-04-09 13:45:23 -05:00
}
2026-04-09 14:17:45 -05:00
if (isMac && mod && inp === 'a') {
return selectAll()
}
if (actionHome) {
2026-04-09 14:17:45 -05:00
c = 0
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
moveCursor(c, k.shift)
return
} else if (actionEnd) {
2026-04-09 14:17:45 -05:00
c = v.length
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
moveCursor(c, k.shift)
return
2026-04-09 14:17:45 -05:00
} else if (k.leftArrow) {
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
if (range && !wordMod && !k.shift) {
clearSel()
c = range.start
} else {
c = wordMod ? wordLeft(v, c) : prevPos(v, c)
}
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
moveCursor(c, k.shift)
return
2026-04-09 14:17:45 -05:00
} else if (k.rightArrow) {
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
if (range && !wordMod && !k.shift) {
clearSel()
c = range.end
} else {
c = wordMod ? wordRight(v, c) : nextPos(v, c)
}
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
moveCursor(c, k.shift)
return
} else if (wordMod && inp === 'b') {
clearSel()
2026-04-11 11:29:08 -05:00
c = wordLeft(v, c)
} else if (wordMod && inp === 'f') {
clearSel()
2026-04-11 11:29:08 -05:00
c = wordRight(v, c)
} else if (range && (k.backspace || delFwd)) {
v = v.slice(0, range.start) + v.slice(range.end)
c = range.start
} else if (k.backspace && c > 0) {
if (wordMod) {
2026-04-09 14:17:45 -05:00
const t = wordLeft(v, c)
v = v.slice(0, t) + v.slice(c)
c = t
2026-04-26 03:22:50 -05:00
} else if (canFastBackspace(v, c)) {
const t = prevPos(v, c)
v = v.slice(0, t) + v.slice(c)
c = t
stdout!.write('\b \b')
2026-04-26 13:43:08 -05:00
commit(v, c, true, false, false, Math.max(0, lineWidthRef.current - 1))
2026-04-26 03:22:50 -05:00
return
2026-04-09 14:17:45 -05:00
} else {
const t = prevPos(v, c)
v = v.slice(0, t) + v.slice(c)
c = t
2026-04-09 14:17:45 -05:00
}
} else if (delFwd && c < v.length) {
if (wordMod) {
2026-04-09 19:53:55 -04:00
const t = wordRight(v, c)
v = v.slice(0, c) + v.slice(t)
} else {
v = v.slice(0, c) + v.slice(nextPos(v, c))
2026-04-09 19:53:55 -04:00
}
} else if (actionDeleteWord) {
if (range) {
v = v.slice(0, range.start) + v.slice(range.end)
c = range.start
} else if (c > 0) {
clearSel()
const t = wordLeft(v, c)
v = v.slice(0, t) + v.slice(c)
c = t
} else {
return
}
} else if (actionDeleteToStart) {
if (range) {
v = v.slice(0, range.start) + v.slice(range.end)
c = range.start
} else {
v = v.slice(c)
c = 0
}
} else if (actionKillToEnd) {
if (range) {
v = v.slice(0, range.start) + v.slice(range.end)
c = range.start
} else {
v = v.slice(0, c)
}
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
} else if (inp.length > 0) {
2026-04-09 13:45:23 -05:00
const bracketed = inp.includes('[200~')
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
const text = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
2026-04-07 20:30:22 -05:00
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
if (bracketed && emitPaste({ bracketed: true, cursor: c, text, value: v })) {
2026-04-09 14:17:45 -05:00
return
}
2026-04-07 20:10:33 -05:00
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
if (!text) {
2026-04-09 14:17:45 -05:00
return
}
2026-04-09 13:45:23 -05:00
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
if (text === '\n') {
2026-04-11 11:29:08 -05:00
return commit(ins(v, c, '\n'), c + 1)
2026-04-09 14:17:45 -05:00
}
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
if (text.length > 1 || text.includes('\n')) {
2026-04-09 14:17:45 -05:00
if (!pasteBuf.current) {
pastePos.current = range ? range.start : c
pasteEnd.current = range ? range.end : pastePos.current
2026-04-09 14:17:45 -05:00
}
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
pasteBuf.current += text
2026-04-07 20:30:22 -05:00
2026-04-09 14:17:45 -05:00
if (pasteTimer.current) {
clearTimeout(pasteTimer.current)
}
2026-04-07 20:10:33 -05:00
pasteTimer.current = setTimeout(flushPaste, 50)
2026-04-07 20:30:22 -05:00
2026-04-07 20:10:33 -05:00
return
}
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
if (PRINTABLE.test(text)) {
if (range) {
v = v.slice(0, range.start) + text + v.slice(range.end)
c = range.start + text.length
} else {
2026-04-26 03:22:50 -05:00
const simpleAppend = canFastAppend(v, c, text)
v = v.slice(0, c) + text + v.slice(c)
c += text.length
if (simpleAppend) {
stdout!.write(text)
2026-04-26 13:43:08 -05:00
commit(v, c, true, false, false, lineWidthRef.current + stringWidth(text))
return
}
}
2026-04-09 14:17:45 -05:00
} else {
return
}
} else {
return
}
2026-04-07 20:10:33 -05:00
2026-04-08 14:18:37 -05:00
commit(v, c)
2026-04-07 20:10:33 -05:00
},
{ isActive: focus }
)
2026-04-09 13:45:23 -05:00
return (
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
<Box
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
onClick={(e: MouseEventLite) => {
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
if (!focus) {
return
}
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
e.stopImmediatePropagation?.()
clearSel()
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
const next = offsetAt(e)
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
setCur(next)
curRef.current = next
}}
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
onMouseDown={(e: MouseEventLite) => {
if (!focus) {
2026-04-20 08:47:46 -04:00
return
}
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
// 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()
2026-04-20 08:47:46 -04:00
}}
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
ref={boxRef}
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
width={columns}
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
>
<Text wrap="wrap-char">{rendered}</Text>
2026-04-11 11:29:08 -05:00
</Box>
2026-04-09 13:45:23 -05:00
)
2026-04-07 20:10:33 -05:00
}
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
type MouseEventLite = {
button?: number
localCol?: number
localRow?: number
stopImmediatePropagation?: () => void
}
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
export interface PasteEvent {
bracketed?: boolean
cursor: number
hotkey?: boolean
text: string
value: string
}
interface TextInputProps {
columns?: number
focus?: boolean
mask?: string
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
mouseApiRef?: MutableRefObject<null | TextInputMouseApi>
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
onChange: (v: string) => void
onPaste?: (
e: PasteEvent
) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
onSubmit?: (v: string) => void
placeholder?: string
value: string
}
fix(tui): mouse + keyboard text selection in the composer (#16732) * 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.
2026-04-27 16:43:48 -07:00
export interface TextInputMouseApi {
dragAt: (row: number, col: number) => void
end: () => void
startAtBeginning: () => void
}