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