diff --git a/hermes_cli/main.py b/hermes_cli/main.py index b2b181ac68..ba975ce3d0 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -829,6 +829,9 @@ 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.""" ink = root / "node_modules" / "@hermes" / "ink" / "package.json" @@ -841,29 +844,32 @@ def _tui_need_npm_install(root: Path) -> bool: if not marker.is_file(): return True + # 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 - ignored = {"ideallyInert"} - def comparable(pkg: dict) -> dict: - return {k: v for k, v in pkg.items() if k not in ignored} + return {k: v for k, v in pkg.items() if k not in _NPM_LOCK_RUNTIME_KEYS} for name, pkg in wanted.items(): - if name == "": + if not name: + continue + + if not isinstance(pkg, dict): continue if name not in installed: - if isinstance(pkg, dict) and (pkg.get("optional") or pkg.get("peer")): + if pkg.get("optional") or pkg.get("peer"): continue return True - if isinstance(pkg, dict) and isinstance(installed[name], dict): - if comparable(pkg) != comparable(installed[name]): - return True + if isinstance(installed[name], dict) and comparable(pkg) != comparable(installed[name]): + return True return False diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index ee24289d0c..86c7510c1a 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -129,7 +129,7 @@ const ComposerPane = memo(function ComposerPane({ const inputHeight = inputVisualHeight(composer.input, inputColumns) const inputMouseRef = useRef(null) - const captureInputDrag = (e: { button: number; localCol?: number; localRow?: number; stopImmediatePropagation?: () => void }) => { + const captureInputDrag = (e: GutterMouseEvent) => { if (e.button !== 0) { return } @@ -138,7 +138,7 @@ const ComposerPane = memo(function ComposerPane({ inputMouseRef.current?.startAtBeginning() } - const dragIntoInput = (e: { button: number; localCol?: number; localRow?: number; stopImmediatePropagation?: () => void }) => { + const dragIntoInput = (e: GutterMouseEvent) => { if (e.button !== 0) { return } @@ -147,6 +147,8 @@ const ComposerPane = memo(function ComposerPane({ inputMouseRef.current?.dragAt(e.localRow ?? 0, (e.localCol ?? 0) - pw) } + const endInputDrag = () => inputMouseRef.current?.end() + return ( ) : ( - inputMouseRef.current?.end()} - /> + )} @@ -211,12 +208,7 @@ const ComposerPane = memo(function ComposerPane({ ))} - inputMouseRef.current?.end()} - position="relative" - > + {sh ? ( $ @@ -362,3 +354,10 @@ export const AppLayout = memo(function AppLayout({ ) }) + +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 88aabd0856..9979274e95 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -282,7 +282,6 @@ const isPasteResultPromise = ( export function TextInput({ columns = 80, - leftCaptureColumns = 0, value, onChange, onPaste, @@ -332,25 +331,18 @@ export function TextInput({ ) const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display]) - const capturePad = Math.max(0, leftCaptureColumns) const boxRef = useDeclaredCursor({ line: layout.line, - column: layout.column + capturePad, + column: layout.column, active: focus && termFocus && !selected }) - // Hide the hardware cursor while an input selection is active so it can't - // auto-wrap below the prompt when the rendered (inverted) text exactly - // fills the row, and so it doesn't paint a ghost block on the first - // selected cell when we re-park it. Restore on unmount or when selection - // clears so the cursor reappears as soon as the user resumes typing. + // Hide the hardware cursor during a selection: prevents auto-wrap into + // the row below when inverted text exactly fills the column width, and + // avoids parking a ghost block on the first selected cell. useEffect(() => { - if (!focus || !stdout?.isTTY) { - return - } - - if (!selected) { + if (!focus || !selected || !stdout?.isTTY) { return } @@ -677,11 +669,6 @@ export function TextInput({ commit(nextValue, nextCursor) } - const mouseOffset = (e: { localCol?: number; localRow?: number }) => ({ - col: (e.localCol ?? 0) - capturePad, - row: e.localRow ?? 0 - }) - const startMouseSelection = (next: number) => { const c = snapPos(vRef.current, next) @@ -716,11 +703,13 @@ export function TextInput({ } } + const offsetAt = (e: { localCol?: number; localRow?: number }) => + offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns) + if (mouseApiRef) { mouseApiRef.current = { dragAt: (row, col) => dragMouseSelection(offsetFromPosition(display, row, col, columns)), end: endMouseSelection, - startAt: (row, col) => startMouseSelection(offsetFromPosition(display, row, col, columns)), startAtBeginning: () => startMouseSelection(0) } } @@ -983,30 +972,24 @@ export function TextInput({ return ( void }) => { + onClick={(e: MouseEventLite) => { if (!focus) { return } e.stopImmediatePropagation?.() clearSel() - const pos = mouseOffset(e) - const next = offsetFromPosition(display, pos.row, pos.col, columns) + const next = offsetAt(e) setCur(next) curRef.current = next }} - onMouseDown={(e: { - button: number - localCol?: number - localRow?: number - stopImmediatePropagation?: () => void - }) => { + onMouseDown={(e: MouseEventLite) => { if (!focus) { return } - // Right-click to paste: route through the same hotkey path as - // Alt+V so the composer's clipboard RPC (text or image) handles it. + // 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 }) @@ -1019,39 +1002,34 @@ export function TextInput({ } e.stopImmediatePropagation?.() - const pos = mouseOffset(e) - const next = offsetFromPosition(display, pos.row, pos.col, columns) - startMouseSelection(next) + startMouseSelection(offsetAt(e)) }} - onMouseDrag={(e: { - button: number - localCol?: number - localRow?: number - stopImmediatePropagation?: () => void - }) => { + onMouseDrag={(e: MouseEventLite) => { if (!focus || e.button !== 0 || mouseAnchorRef.current === null) { return } e.stopImmediatePropagation?.() - const pos = mouseOffset(e) - const next = offsetFromPosition(display, pos.row, pos.col, columns) - dragMouseSelection(next) + dragMouseSelection(offsetAt(e)) }} - onMouseUp={(e: { stopImmediatePropagation?: () => void }) => { + onMouseUp={(e: MouseEventLite) => { e.stopImmediatePropagation?.() endMouseSelection() }} - marginLeft={capturePad ? -capturePad : undefined} - paddingLeft={capturePad || undefined} ref={boxRef} - width={columns + capturePad} > {rendered} ) } +type MouseEventLite = { + button?: number + localCol?: number + localRow?: number + stopImmediatePropagation?: () => void +} + export interface PasteEvent { bracketed?: boolean cursor: number @@ -1063,7 +1041,6 @@ export interface PasteEvent { interface TextInputProps { columns?: number focus?: boolean - leftCaptureColumns?: number mask?: string mouseApiRef?: MutableRefObject onChange: (v: string) => void @@ -1078,6 +1055,5 @@ interface TextInputProps { export interface TextInputMouseApi { dragAt: (row: number, col: number) => void end: () => void - startAt: (row: number, col: number) => void startAtBeginning: () => void }