fix: preserve Ink composer cursor across agent monitors

This commit is contained in:
Teknium
2026-09-08 03:26:53 -07:00
parent f7fe32bfde
commit 0a3b7fdce2
4 changed files with 38 additions and 7 deletions

View File

@@ -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(<TextInput cursorSnapshotRef={cursorSnapshotRef} onChange={onChange} value="draft" />)
await vi.waitFor(() => expect(getInputSelection()?.value).toBe('draft'))
stdin.write('\x1b[D')
await vi.waitFor(() => expect(getInputSelection()?.start).toBe(4))
view.rerender(<Box />)
view.rerender(<TextInput cursorSnapshotRef={cursorSnapshotRef} onChange={onChange} value="draft" />)
await vi.waitFor(() => expect(getInputSelection()?.start).toBe(4))
stdin.write('!')
await vi.waitFor(() => expect(onChange).toHaveBeenCalledWith('draf!t'))
} finally {
view.unmount()
view.cleanup()

View File

@@ -979,7 +979,7 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
</Box>
)}
<Box flexDirection="column" marginTop={1}>
<Box flexDirection="column" flexShrink={0} marginTop={1}>
<Text color={t.color.accent} wrap="truncate-end">{replayMode ? 'Enter/d detail' : 'Enter/t tail · d detail'} · e steer · x stop · Esc back</Text>
{flash ? <Text color={t.color.accent} wrap="truncate-end">{flash}</Text> : null}

View File

@@ -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<AppLayoutProps, 'actions' | 'composer' | 'status'>) {
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'status'> & { cursorSnapshotRef: MutableRefObject<InputCursorSnapshot | null> }) {
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<InputCursorSnapshot | null>(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({
</PerfPane>
<PerfPane id="composer">
<ComposerPane actions={actions} composer={composer} status={status} />
<ComposerPane actions={actions} composer={composer} cursorSnapshotRef={cursorSnapshotRef} status={status} />
</PerfPane>
{SHOW_FPS && (

View File

@@ -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 | { end: number; start: number }>(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<InputCursorSnapshot | null>
focus?: boolean
mask?: string
mouseApiRef?: MutableRefObject<null | TextInputMouseApi>