diff --git a/ui-tui/src/__tests__/agentsCompact.test.tsx b/ui-tui/src/__tests__/agentsCompact.test.tsx index f4cb1350fe..0507f37805 100644 --- a/ui-tui/src/__tests__/agentsCompact.test.tsx +++ b/ui-tui/src/__tests__/agentsCompact.test.tsx @@ -8,9 +8,11 @@ import { expect, it, vi } from 'vitest' import { renderToScreen } from '../../packages/hermes-ink/src/ink/render-to-screen.js' import { cellAtIndex } from '../../packages/hermes-ink/src/ink/screen.js' import { applyAgentSnapshot } from '../app/agentRoster.js' +import { getInputSelection } from '../app/inputSelectionStore.js' import { patchUiState, resetUiState } from '../app/uiStore.js' import { AgentsOverlay } from '../components/agentsOverlay.js' import { AgentsPanelView } from '../components/agentsPanel.js' +import { TextInput } from '../components/textInput.js' import type { GatewayClient } from '../gatewayClient.js' import { buildAgentRows } from '../lib/agentRows.js' import { DEFAULT_THEME } from '../theme.js' @@ -61,6 +63,17 @@ it('opens the selected live transcript on Enter while details remain independent output = '' stdin.write('t') await vi.waitFor(() => expect(output).toContain('CHILD_TOOL_OUTPUT')) + const cursorSnapshotRef = { current: null } + const onChange = vi.fn() + view.rerender() + await vi.waitFor(() => expect(getInputSelection()?.value).toBe('draft')) + stdin.write('\x1b[D') + await vi.waitFor(() => expect(getInputSelection()?.start).toBe(4)) + view.rerender() + view.rerender() + await vi.waitFor(() => expect(getInputSelection()?.start).toBe(4)) + stdin.write('!') + await vi.waitFor(() => expect(onChange).toHaveBeenCalledWith('draf!t')) } finally { view.unmount() view.cleanup() diff --git a/ui-tui/src/components/agentsOverlay.tsx b/ui-tui/src/components/agentsOverlay.tsx index e61c32f758..e0467dc699 100644 --- a/ui-tui/src/components/agentsOverlay.tsx +++ b/ui-tui/src/components/agentsOverlay.tsx @@ -979,7 +979,7 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent )} - + {replayMode ? 'Enter/d detail' : 'Enter/t tail · d detail'} · e steer · x stop · Esc back {flash ? {flash} : null} diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 0bbe6c92bf..79e3eb3faf 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -3,7 +3,7 @@ import '../sdk/apps/index.js' import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' -import { Fragment, memo, useEffect, useMemo, useRef } from 'react' +import { Fragment, memo, type MutableRefObject, useEffect, useMemo, useRef } from 'react' import { useGateway } from '../app/gatewayContext.js' import type { AppLayoutProps } from '../app/interfaces.js' @@ -36,7 +36,7 @@ import { MessageLine } from './messageLine.js' import { PetKitty, PetSprite } from './petSprite.js' import { QueuedMessages } from './queuedMessages.js' import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js' -import { TextInput, type TextInputMouseApi } from './textInput.js' +import { type InputCursorSnapshot, TextInput, type TextInputMouseApi } from './textInput.js' // Box geometry, kept here so the transcript's reservation math matches the // rendered overlay exactly. @@ -275,8 +275,9 @@ const TranscriptPane = memo(function TranscriptPane({ const ComposerPane = memo(function ComposerPane({ actions, composer, + cursorSnapshotRef, status -}: Pick) { +}: Pick & { cursorSnapshotRef: MutableRefObject }) { const ui = useStore($uiState) const isBlocked = useStore($isBlocked) const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!') @@ -423,6 +424,7 @@ const ComposerPane = memo(function ComposerPane({ accentColor={ui.theme.color.accent} color={ui.theme.color.text} columns={inputColumns} + cursorSnapshotRef={cursorSnapshotRef} mouseApiRef={inputMouseRef} onChange={composer.updateInput} onPaste={composer.handleTextPaste} @@ -531,6 +533,9 @@ export const AppLayout = memo(function AppLayout({ const overlay = useStore($overlayState) const ui = useStore($uiState) + const cursorSnapshotRef = useRef(null) + useEffect(() => { cursorSnapshotRef.current = null }, [ui.sid]) + // Inline mode skips AlternateScreen so the host terminal's native // scrollback captures rows scrolled off the top; composer + progress // stay anchored via normal flex-column flow. @@ -572,7 +577,7 @@ export const AppLayout = memo(function AppLayout({ - + {SHOW_FPS && ( diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 176b4fa506..29264205a1 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -782,6 +782,7 @@ export function TextInput({ onSubmit, mask, mouseApiRef, + cursorSnapshotRef, voiceRecordKey = DEFAULT_VOICE_RECORD_KEY, placeholder = '', placeholderColor, @@ -789,7 +790,7 @@ export function TextInput({ color, focus = true }: TextInputProps) { - const [cur, setCur] = useState(value.length) + const [cur, setCur] = useState(() => cursorSnapshotRef?.current?.value === value ? cursorSnapshotRef.current.cursor : value.length) const [sel, setSel] = useState(null) const fwdDel = useFwdDelete(focus) const termFocus = useTerminalFocus() @@ -922,7 +923,7 @@ export function TextInput({ const ownEcho = self.current && value === vRef.current self.current = false - if (ownEcho) { + if (ownEcho || value === vRef.current) { return } @@ -936,6 +937,12 @@ export function TextInput({ redo.current = [] }, [value]) + // The composer unmounts while full-screen monitors own input. Keep its + // insertion point with the shell, not with transient steer/secret inputs. + useEffect(() => () => { + if (cursorSnapshotRef) {cursorSnapshotRef.current = { cursor: curRef.current, value: vRef.current }} + }, [cursorSnapshotRef]) + useEffect(() => { if (!focus) { return @@ -1777,12 +1784,18 @@ export interface PasteEvent { value: string } +export interface InputCursorSnapshot { + cursor: number + value: string +} + interface TextInputProps { /** Hex/ansi256 tone for `/skill`, `@ref`, and `[[ token ]]` spans. */ accentColor?: string /** Hex color for typed text (theme text); terminal default when omitted. */ color?: string columns?: number + cursorSnapshotRef?: MutableRefObject focus?: boolean mask?: string mouseApiRef?: MutableRefObject