fix: preserve Ink composer cursor across agent monitors
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user