diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 375561ad6d..cbfdec5a37 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -829,8 +829,29 @@ def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Opti ) +_NPM_LOCK_RUNTIME_KEYS = frozenset({"ideallyInert"}) + + def _tui_need_npm_install(root: Path) -> bool: - """True when @hermes/ink is missing or node_modules is behind package-lock.json (post-pull).""" + """True when @hermes/ink is missing or node_modules is behind package-lock.json. + + Compares ``package-lock.json`` against ``node_modules/.package-lock.json`` + (npm's hidden lockfile) by **content**, not mtime: git checkouts and npm + rewrites can bump the root lockfile's timestamp even when installed deps + already match, which used to trigger a spurious "Installing TUI + dependencies" on every launch. + + For each entry in the root lock's ``packages`` map: + - missing from hidden lock → reinstall (unless the entry is marked + ``optional`` or ``peer``, which npm may intentionally skip per platform) + - present but with differing fields (excluding npm-written runtime + annotations like ``ideallyInert``) → reinstall + + Extra entries that exist only in the hidden lock are ignored — stale + transitives left over from a removed dependency don't break runtime and + we'd rather not force a reinstall for them. Falls back to mtime + comparison if either lockfile is unparseable. + """ ink = root / "node_modules" / "@hermes" / "ink" / "package.json" if not ink.is_file(): return True @@ -840,7 +861,35 @@ def _tui_need_npm_install(root: Path) -> bool: marker = root / "node_modules" / ".package-lock.json" if not marker.is_file(): return True - return lock.stat().st_mtime > marker.stat().st_mtime + + # Compare lockfile contents, not mtimes: git checkouts and npm rewrites + # can bump the root lockfile timestamp even when installed deps already + # match. Fall back to mtime when either file is unparseable. + try: + wanted = json.loads(lock.read_text(encoding="utf-8")).get("packages") or {} + installed = json.loads(marker.read_text(encoding="utf-8")).get("packages") or {} + except (OSError, json.JSONDecodeError): + return lock.stat().st_mtime > marker.stat().st_mtime + + def comparable(pkg: dict) -> dict: + return {k: v for k, v in pkg.items() if k not in _NPM_LOCK_RUNTIME_KEYS} + + for name, pkg in wanted.items(): + if not name: + continue + + if not isinstance(pkg, dict): + continue + + if name not in installed: + if pkg.get("optional") or pkg.get("peer"): + continue + return True + + if isinstance(installed[name], dict) and comparable(pkg) != comparable(installed[name]): + return True + + return False def _find_bundled_tui(tui_dir: Path) -> Optional[Path]: diff --git a/tests/hermes_cli/test_tui_npm_install.py b/tests/hermes_cli/test_tui_npm_install.py index bceaf9de0b..e56196e07e 100644 --- a/tests/hermes_cli/test_tui_npm_install.py +++ b/tests/hermes_cli/test_tui_npm_install.py @@ -1,4 +1,4 @@ -"""_tui_need_npm_install: auto npm when lockfile ahead of node_modules.""" +"""_tui_need_npm_install: auto npm when node_modules is behind the lockfile.""" import os from pathlib import Path @@ -36,15 +36,39 @@ def test_need_install_when_ink_missing(tmp_path: Path, main_mod) -> None: assert main_mod._tui_need_npm_install(tmp_path) is True -def test_need_install_when_lock_newer_than_marker(tmp_path: Path, main_mod) -> None: +def test_no_install_when_lock_newer_but_hidden_lock_matches(tmp_path: Path, main_mod) -> None: _touch_ink(tmp_path) - (tmp_path / "package-lock.json").write_text("{}") - (tmp_path / "node_modules" / ".package-lock.json").write_text("{}") + (tmp_path / "package-lock.json").write_text('{"packages":{"node_modules/foo":{"version":"1.0.0"}}}') + (tmp_path / "node_modules" / ".package-lock.json").write_text( + '{"packages":{"node_modules/foo":{"version":"1.0.0","ideallyInert":true}}}' + ) os.utime(tmp_path / "package-lock.json", (200, 200)) os.utime(tmp_path / "node_modules" / ".package-lock.json", (100, 100)) + assert main_mod._tui_need_npm_install(tmp_path) is False + + +def test_need_install_when_required_package_missing_from_hidden_lock(tmp_path: Path, main_mod) -> None: + _touch_ink(tmp_path) + (tmp_path / "package-lock.json").write_text( + '{"packages":{"node_modules/foo":{"version":"1.0.0"},"node_modules/bar":{"version":"1.0.0"}}}' + ) + (tmp_path / "node_modules" / ".package-lock.json").write_text( + '{"packages":{"node_modules/foo":{"version":"1.0.0"}}}' + ) assert main_mod._tui_need_npm_install(tmp_path) is True +def test_no_install_when_only_optional_peer_package_missing_from_hidden_lock(tmp_path: Path, main_mod) -> None: + _touch_ink(tmp_path) + (tmp_path / "package-lock.json").write_text( + '{"packages":{"node_modules/foo":{"version":"1.0.0"},"node_modules/optional":{"version":"1.0.0","optional":true,"peer":true}}}' + ) + (tmp_path / "node_modules" / ".package-lock.json").write_text( + '{"packages":{"node_modules/foo":{"version":"1.0.0"}}}' + ) + assert main_mod._tui_need_npm_install(tmp_path) is False + + def test_no_install_when_lock_older_than_marker(tmp_path: Path, main_mod) -> None: _touch_ink(tmp_path) (tmp_path / "package-lock.json").write_text("{}") diff --git a/ui-tui/src/app/inputSelectionStore.ts b/ui-tui/src/app/inputSelectionStore.ts index 25b67c4283..c01e11861f 100644 --- a/ui-tui/src/app/inputSelectionStore.ts +++ b/ui-tui/src/app/inputSelectionStore.ts @@ -2,6 +2,7 @@ import { atom } from 'nanostores' export interface InputSelection { clear: () => void + collapseToEnd: () => void end: number start: number value: string diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 1d7cdaead0..9b987f87d3 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -285,6 +285,7 @@ export interface AppLayoutActions { answerClarify: (answer: string) => void answerSecret: (value: string) => void answerSudo: (pw: string) => void + clearSelection: () => void onModelSelect: (value: string) => void resumeById: (id: string) => void setStickyPrompt: (value: string) => void diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 9f2cae78d6..c18deb47bb 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -25,6 +25,7 @@ import type { Msg, PanelSection, SlashCatalog } from '../types.js' import { createGatewayEventHandler } from './createGatewayEventHandler.js' import { createSlashHandler } from './createSlashHandler.js' +import { getInputSelection } from './inputSelectionStore.js' import { type GatewayRpc, type TranscriptRow } from './interfaces.js' import { $overlayState, patchOverlayState } from './overlayStore.js' import { scrollWithSelectionBy } from './scroll.js' @@ -147,6 +148,11 @@ export function useMainApp(gw: GatewayClient) { selection.setSelectionBgColor(ui.theme.color.selectionBg) }, [selection, ui.theme.color.selectionBg]) + const clearSelection = useCallback(() => { + selection.clearSelection() + getInputSelection()?.collapseToEnd() + }, [selection]) + const composer = useComposerState({ gw, onClipboardPaste: quiet => clipboardPasteRef.current(quiet), @@ -519,6 +525,7 @@ export function useMainApp(gw: GatewayClient) { [ appendMessage, bellOnComplete, + clearSelection, composerActions.setInput, gateway, panel, @@ -691,11 +698,12 @@ export function useMainApp(gw: GatewayClient) { answerClarify, answerSecret, answerSudo, + clearSelection, onModelSelect, resumeById: session.resumeById, setStickyPrompt }), - [answerApproval, answerClarify, answerSecret, answerSudo, onModelSelect, session.resumeById] + [answerApproval, answerClarify, answerSecret, answerSudo, clearSelection, onModelSelect, session.resumeById] ) const appComposer = useMemo( diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index d8a9d0f5f2..7d39d571c3 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -1,6 +1,6 @@ import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' -import { Fragment, memo, useMemo } from 'react' +import { Fragment, memo, useMemo, useRef } from 'react' import { useGateway } from '../app/gatewayContext.js' import type { AppLayoutProps } from '../app/interfaces.js' @@ -20,7 +20,7 @@ import { FpsOverlay } from './fpsOverlay.js' import { MessageLine } from './messageLine.js' import { QueuedMessages } from './queuedMessages.js' import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js' -import { TextInput } from './textInput.js' +import { TextInput, type TextInputMouseApi } from './textInput.js' const TranscriptPane = memo(function TranscriptPane({ actions, @@ -47,7 +47,18 @@ const TranscriptPane = memo(function TranscriptPane({ return ( <> - + { + if (e.cellIsBlank) { + actions.clearSelection() + } + }} + ref={transcript.scrollRef} + stickyScroll + > {transcript.virtualHistory.topSpacer > 0 ? : null} @@ -113,12 +124,57 @@ const ComposerPane = memo(function ComposerPane({ const ui = useStore($uiState) const isBlocked = useStore($isBlocked) const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!') - const pw = sh ? 2 : 3 + const pw = 2 const inputColumns = stableComposerColumns(composer.cols, pw) const inputHeight = inputVisualHeight(composer.input, inputColumns) + const inputMouseRef = useRef(null) + + const captureInputDrag = (e: GutterMouseEvent) => { + if (e.button !== 0) { + return + } + + e.stopImmediatePropagation?.() + inputMouseRef.current?.startAtBeginning() + } + + // Drag origin matches the input box's top-left, so localRow / localCol + // map directly into TextInput coords (after backing out the prompt cell). + const dragFromPromptRow = (e: GutterMouseEvent) => { + if (e.button !== 0) { + return + } + + e.stopImmediatePropagation?.() + inputMouseRef.current?.dragAt(e.localRow ?? 0, (e.localCol ?? 0) - pw) + } + + // Spacer rows live on a different vertical origin; only the column is + // parent-aligned with the input. Force row=0 so vertical drags can't + // jump the cursor to the wrong wrapped line. + const dragFromSpacer = (e: GutterMouseEvent) => { + if (e.button !== 0) { + return + } + + e.stopImmediatePropagation?.() + inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - pw) + } + + const endInputDrag = () => inputMouseRef.current?.end() return ( - + { + if (e.cellIsBlank) { + actions.clearSelection() + } + }} + paddingX={1} + > ) : ( - + )} @@ -158,7 +214,7 @@ const ComposerPane = memo(function ComposerPane({ <> {composer.inputBuf.map((line, i) => ( - + {i === 0 ? `${ui.theme.brand.prompt} ` : ' '} @@ -166,7 +222,7 @@ const ComposerPane = memo(function ComposerPane({ ))} - + {sh ? ( $ @@ -181,6 +237,7 @@ const ComposerPane = memo(function ComposerPane({ {/* Reserve the transcript scrollbar gutter too so typing never rewraps when the scrollbar column repaints. */} ) }) + +type GutterMouseEvent = { + button: number + localCol?: number + localRow?: number + stopImmediatePropagation?: () => void +} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 1bff1d6756..9ef0284abb 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -1,6 +1,6 @@ import type { InputEvent, Key } from '@hermes/ink' import * as Ink from '@hermes/ink' -import { useEffect, useMemo, useRef, useState } from 'react' +import { type MutableRefObject, useEffect, useMemo, useRef, useState } from 'react' import { setInputSelection } from '../app/inputSelectionStore.js' import { readClipboardText, writeClipboardText } from '../lib/clipboard.js' @@ -25,6 +25,7 @@ const DIM_OFF = `${ESC}[22m` const FWD_DEL_RE = new RegExp(`${ESC}\\[3(?:[~$^]|;)`) const PRINTABLE = /^[ -~\u00a0-\uffff]+$/ const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g') +const MULTI_CLICK_MS = 500 const invert = (s: string) => INV + s + INV_OFF const dim = (s: string) => DIM + s + DIM_OFF @@ -287,6 +288,7 @@ export function TextInput({ onPaste, onSubmit, mask, + mouseApiRef, placeholder = '', focus = true }: TextInputProps) { @@ -309,6 +311,8 @@ export function TextInput({ const pendingParentValue = useRef(null) const localRenderTimer = useRef | null>(null) const lineWidthRef = useRef(stringWidth(value.includes('\n') ? value.slice(value.lastIndexOf('\n') + 1) : value)) + const mouseAnchorRef = useRef(null) + const lastClickRef = useRef<{ at: number; offset: number }>({ at: 0, offset: -1 }) const undo = useRef<{ cursor: number; value: string }[]>([]) const redo = useRef<{ cursor: number; value: string }[]>([]) @@ -336,6 +340,24 @@ export function TextInput({ active: focus && termFocus && !selected }) + // Hide the hardware cursor while a selection is active (prevents + // auto-wrap onto the next row when inverted text fills the column + // exactly) or when the terminal loses focus (suppresses the hollow-rect + // ghost most terminals draw at the parked position). + const hideHardwareCursor = focus && !!stdout?.isTTY && (!!selected || !termFocus) + + useEffect(() => { + if (!hideHardwareCursor || !stdout) { + return + } + + stdout.write('\x1b[?25l') + + return () => { + stdout.write('\x1b[?25h') + } + }, [hideHardwareCursor, stdout]) + const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY const rendered = useMemo(() => { @@ -374,12 +396,21 @@ export function TextInput({ return } + const dropSel = () => { + if (!selRef.current) { + return + } + + selRef.current = null + setSel(null) + } + setInputSelection({ - clear: () => { - if (selRef.current) { - selRef.current = null - setSel(null) - } + clear: dropSel, + collapseToEnd: () => { + dropSel() + setCur(vRef.current.length) + curRef.current = vRef.current.length }, end: selected?.end ?? curRef.current, start: selected?.start ?? curRef.current, @@ -605,6 +636,22 @@ export function TextInput({ curRef.current = end } + const moveCursor = (next: number, extend = false) => { + const c = snapPos(vRef.current, next) + + if (extend) { + const anchor = selRef.current?.start ?? curRef.current + const nextSel = { end: c, start: anchor } + selRef.current = nextSel + setSel(nextSel) + } else { + clearSel() + } + + setCur(c) + curRef.current = c + } + const selRange = () => { const range = selRef.current @@ -633,6 +680,59 @@ export function TextInput({ commit(nextValue, nextCursor) } + const startMouseSelection = (next: number) => { + const c = snapPos(vRef.current, next) + + mouseAnchorRef.current = c + selRef.current = { end: c, start: c } + setSel(null) + setCur(c) + curRef.current = c + } + + const dragMouseSelection = (next: number) => { + if (mouseAnchorRef.current === null) { + return + } + + const c = snapPos(vRef.current, next) + const range = { end: c, start: mouseAnchorRef.current } + selRef.current = range + setSel(range.start === range.end ? null : range) + setCur(c) + curRef.current = c + } + + const endMouseSelection = () => { + mouseAnchorRef.current = null + + const range = selRef.current + + if (range && range.start === range.end) { + selRef.current = null + setSel(null) + } + } + + const offsetAt = (e: { localCol?: number; localRow?: number }) => + offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns) + + const isMultiClickAt = (offset: number) => { + const now = Date.now() + const last = lastClickRef.current + lastClickRef.current = { at: now, offset } + + return now - last.at < MULTI_CLICK_MS && offset === last.offset + } + + if (mouseApiRef) { + mouseApiRef.current = { + dragAt: (row, col) => dragMouseSelection(offsetFromPosition(display, row, col, columns)), + end: endMouseSelection, + startAtBeginning: () => startMouseSelection(0) + } + } + useInput( (inp: string, k: Key, event: InputEvent) => { const eventRaw = event.keypress.raw @@ -674,9 +774,7 @@ export function TextInput({ const next = lineNav(vRef.current, curRef.current, k.upArrow ? -1 : 1) if (next !== null) { - clearSel() - setCur(next) - curRef.current = next + moveCursor(next, k.shift) return } @@ -737,27 +835,37 @@ export function TextInput({ } if (actionHome) { - clearSel() c = 0 + moveCursor(c, k.shift) + + return } else if (actionEnd) { - clearSel() c = v.length + moveCursor(c, k.shift) + + return } else if (k.leftArrow) { - if (range && !wordMod) { + if (range && !wordMod && !k.shift) { clearSel() c = range.start } else { - clearSel() c = wordMod ? wordLeft(v, c) : prevPos(v, c) } + + moveCursor(c, k.shift) + + return } else if (k.rightArrow) { - if (range && !wordMod) { + if (range && !wordMod && !k.shift) { clearSel() c = range.end } else { - clearSel() c = wordMod ? wordRight(v, c) : nextPos(v, c) } + + moveCursor(c, k.shift) + + return } else if (wordMod && inp === 'b') { clearSel() c = wordLeft(v, c) @@ -883,32 +991,74 @@ export function TextInput({ return ( { + onClick={(e: MouseEventLite) => { if (!focus) { return } + e.stopImmediatePropagation?.() clearSel() - const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns) + const next = offsetAt(e) setCur(next) curRef.current = next }} - onMouseDown={(e: { button: number }) => { - // Right-click to paste: route through the same hotkey path as - // Alt+V so the composer's clipboard RPC (text or image) handles it. - if (!focus || e.button !== 2) { + onMouseDown={(e: MouseEventLite) => { + if (!focus) { return } - emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) + // Right-click → route through the same path as Alt+V so the composer + // clipboard RPC (text or image) handles it. + if (e.button === 2) { + e.stopImmediatePropagation?.() + emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) + + return + } + + if (e.button !== 0) { + return + } + + e.stopImmediatePropagation?.() + const offset = offsetAt(e) + + if (isMultiClickAt(offset)) { + mouseAnchorRef.current = null + selectAll() + + return + } + + startMouseSelection(offset) + }} + onMouseDrag={(e: MouseEventLite) => { + if (!focus || e.button !== 0 || mouseAnchorRef.current === null) { + return + } + + e.stopImmediatePropagation?.() + dragMouseSelection(offsetAt(e)) + }} + onMouseUp={(e: MouseEventLite) => { + e.stopImmediatePropagation?.() + endMouseSelection() }} ref={boxRef} + width={columns} > {rendered} ) } +type MouseEventLite = { + button?: number + localCol?: number + localRow?: number + stopImmediatePropagation?: () => void +} + export interface PasteEvent { bracketed?: boolean cursor: number @@ -921,6 +1071,7 @@ interface TextInputProps { columns?: number focus?: boolean mask?: string + mouseApiRef?: MutableRefObject onChange: (v: string) => void onPaste?: ( e: PasteEvent @@ -929,3 +1080,9 @@ interface TextInputProps { placeholder?: string value: string } + +export interface TextInputMouseApi { + dragAt: (row: number, col: number) => void + end: () => void + startAtBeginning: () => void +}