33 * Copyright 2025 Autohand AI LLC
44 * SPDX-License-Identifier: Apache-2.0
55 */
6- import React , { useState , useEffect , memo , useMemo } from 'react' ;
7- import { Box , Text , useInput , useApp , Static } from 'ink' ;
6+ import React , { useState , useEffect , memo , useMemo , useRef , useCallback } from 'react' ;
7+ import { Box , Text , useInput , useApp , Static , type Key as InkKey } from 'ink' ;
88import { StatusLine } from './StatusLine.js' ;
99import { ToolOutputStatic , type ToolOutputEntry } from './ToolOutput.js' ;
1010import { InputLine } from './InputLine.js' ;
1111import { ThinkingOutput } from './ThinkingOutput.js' ;
1212import { useTheme } from '../theme/ThemeContext.js' ;
1313import { useTranslation } from '../i18n/index.js' ;
1414import { getPlanModeManager } from '../../commands/plan.js' ;
15+ import { TextBuffer } from '../textBuffer.js' ;
16+ import { handleTextBufferKey , type KeyHandlerResult } from '../textBufferKeyHandler.js' ;
17+ import { getPromptBlockWidth , isShiftEnterResidualSequence } from '../inputPrompt.js' ;
1518
1619export interface AgentUIState {
1720 isWorking : boolean ;
@@ -40,6 +43,81 @@ export interface AgentUIProps {
4043 enableQueueInput ?: boolean ;
4144}
4245
46+ interface TextBufferKeyInfo {
47+ name ?: string ;
48+ ctrl ?: boolean ;
49+ meta ?: boolean ;
50+ shift ?: boolean ;
51+ sequence ?: string ;
52+ }
53+
54+ const INK_TEXTBUFFER_VIEWPORT_HEIGHT = 10 ;
55+
56+ function getInkTextBufferViewportWidth ( columns : number | undefined ) : number {
57+ return Math . max ( 1 , getPromptBlockWidth ( columns ) - 4 ) ;
58+ }
59+
60+ function mapInkKeyToTextBufferKey ( input : string , key : InkKey ) : TextBufferKeyInfo {
61+ let name : string | undefined ;
62+
63+ if ( key . leftArrow ) {
64+ name = 'left' ;
65+ } else if ( key . rightArrow ) {
66+ name = 'right' ;
67+ } else if ( key . upArrow ) {
68+ name = 'up' ;
69+ } else if ( key . downArrow ) {
70+ name = 'down' ;
71+ } else if ( key . return ) {
72+ name = 'return' ;
73+ } else if ( key . backspace ) {
74+ name = 'backspace' ;
75+ } else if ( key . delete ) {
76+ name = 'delete' ;
77+ } else if ( key . tab ) {
78+ name = 'tab' ;
79+ } else if ( key . ctrl && input === 'a' ) {
80+ name = 'a' ;
81+ } else if ( key . ctrl && input === 'e' ) {
82+ name = 'e' ;
83+ }
84+
85+ return {
86+ name,
87+ ctrl : key . ctrl ,
88+ meta : key . meta ,
89+ shift : key . shift ,
90+ sequence : input ,
91+ } ;
92+ }
93+
94+ export function getTextBufferCursorOffset ( buffer : TextBuffer ) : number {
95+ const lines = buffer . getLines ( ) ;
96+ const row = buffer . getCursorRow ( ) ;
97+ const col = buffer . getCursorCol ( ) ;
98+ let offset = 0 ;
99+
100+ for ( let i = 0 ; i < row ; i ++ ) {
101+ offset += lines [ i ] ?. length ?? 0 ;
102+ offset += 1 ;
103+ }
104+
105+ return offset + col ;
106+ }
107+
108+ export function handleInkTextBufferInput (
109+ buffer : TextBuffer ,
110+ input : string ,
111+ key : InkKey
112+ ) : KeyHandlerResult {
113+ if ( isShiftEnterResidualSequence ( input ) ) {
114+ buffer . insert ( '\n' ) ;
115+ return 'handled' ;
116+ }
117+
118+ return handleTextBufferKey ( buffer , input , mapInkKeyToTextBufferKey ( input , key ) ) ;
119+ }
120+
43121export function AgentUI ( {
44122 state,
45123 onInstruction,
@@ -51,11 +129,31 @@ export function AgentUI({
51129 const { exit } = useApp ( ) ;
52130 const { colors } = useTheme ( ) ;
53131 const { t } = useTranslation ( ) ;
54- // Initialize input from state.currentInput (preserved across pause/resume)
55132 const [ input , setInput ] = useState ( state . currentInput || '' ) ;
133+ const [ cursorOffset , setCursorOffset ] = useState ( ( state . currentInput || '' ) . length ) ;
56134 const [ ctrlCCount , setCtrlCCount ] = useState ( 0 ) ;
57135 const [ planModeIndicator , setPlanModeIndicator ] = useState ( '' ) ;
58136 const [ planModeStatusKey , setPlanModeStatusKey ] = useState ( '' ) ;
137+ const textBufferRef = useRef < TextBuffer > (
138+ new TextBuffer (
139+ getInkTextBufferViewportWidth ( process . stdout . columns ) ,
140+ INK_TEXTBUFFER_VIEWPORT_HEIGHT ,
141+ state . currentInput || undefined
142+ )
143+ ) ;
144+
145+ const syncInputFromBuffer = useCallback ( ( ) => {
146+ const buffer = textBufferRef . current ;
147+ setInput ( buffer . getText ( ) ) ;
148+ setCursorOffset ( getTextBufferCursorOffset ( buffer ) ) ;
149+ } , [ ] ) ;
150+
151+ const syncBufferViewport = useCallback ( ( ) => {
152+ textBufferRef . current . setViewport (
153+ getInkTextBufferViewportWidth ( process . stdout . columns ) ,
154+ INK_TEXTBUFFER_VIEWPORT_HEIGHT
155+ ) ;
156+ } , [ ] ) ;
59157
60158 // Subscribe to plan mode changes
61159 useEffect ( ( ) => {
@@ -84,6 +182,18 @@ export function AgentUI({
84182 onInputChange ?.( input ) ;
85183 } , [ input , onInputChange ] ) ;
86184
185+ useEffect ( ( ) => {
186+ syncBufferViewport ( ) ;
187+ } ) ;
188+
189+ useEffect ( ( ) => {
190+ const buffer = textBufferRef . current ;
191+ if ( state . currentInput !== buffer . getText ( ) ) {
192+ buffer . setText ( state . currentInput || '' ) ;
193+ syncInputFromBuffer ( ) ;
194+ }
195+ } , [ state . currentInput , syncInputFromBuffer ] ) ;
196+
87197 // Reset ctrl+c count after 2 seconds
88198 useEffect ( ( ) => {
89199 if ( ctrlCCount > 0 ) {
@@ -93,6 +203,8 @@ export function AgentUI({
93203 } , [ ctrlCCount ] ) ;
94204
95205 useInput ( ( char , key ) => {
206+ syncBufferViewport ( ) ;
207+
96208 // Handle Shift+Tab for plan mode toggle
97209 if ( key . tab && key . shift ) {
98210 const planModeManager = getPlanModeManager ( ) ;
@@ -122,25 +234,27 @@ export function AgentUI({
122234 return ;
123235 }
124236
125- // Handle Enter - queue instruction
126- if ( key . return && input . trim ( ) ) {
127- onInstruction ( input . trim ( ) ) ;
128- setInput ( '' ) ;
237+ if ( key . tab ) {
129238 return ;
130239 }
131240
132- // Handle Backspace
133- if ( key . backspace || key . delete ) {
134- setInput ( prev => prev . slice ( 0 , - 1 ) ) ;
241+ const buffer = textBufferRef . current ;
242+ const result = handleInkTextBufferInput ( buffer , char , key ) ;
243+
244+ if ( result === 'submit' ) {
245+ const text = buffer . getText ( ) . trim ( ) ;
246+ if ( ! text ) {
247+ return ;
248+ }
249+ onInstruction ( text ) ;
250+ buffer . setText ( '' ) ;
251+ syncInputFromBuffer ( ) ;
135252 return ;
136253 }
137254
138- // Handle printable characters
139- if ( char && ! key . ctrl && ! key . meta ) {
140- const printable = char . replace ( / [ \x00 - \x1F \x7F ] / g, '' ) ;
141- if ( printable ) {
142- setInput ( prev => prev + printable ) ;
143- }
255+ if ( result === 'handled' ) {
256+ syncInputFromBuffer ( ) ;
257+ return ;
144258 }
145259 } ) ;
146260
@@ -185,6 +299,7 @@ export function AgentUI({
185299 completionStats = { state . completionStats }
186300 enableQueueInput = { enableQueueInput }
187301 input = { input }
302+ cursorOffset = { cursorOffset }
188303 ctrlCCount = { ctrlCCount }
189304 contextPercent = { state . contextPercent }
190305 />
@@ -237,6 +352,7 @@ interface FixedBottomProps {
237352 completionStats : { elapsed : string ; tokens : string } | null ;
238353 enableQueueInput : boolean ;
239354 input : string ;
355+ cursorOffset : number ;
240356 ctrlCCount : number ;
241357 contextPercent ?: number ;
242358}
@@ -250,6 +366,7 @@ const FixedBottom = memo(function FixedBottom({
250366 completionStats,
251367 enableQueueInput,
252368 input,
369+ cursorOffset,
253370 ctrlCCount,
254371 contextPercent
255372} : FixedBottomProps ) {
@@ -300,6 +417,7 @@ const FixedBottom = memo(function FixedBottom({
300417 { enableQueueInput && (
301418 < InputLine
302419 value = { input }
420+ cursorOffset = { cursorOffset }
303421 isActive = { isWorking }
304422 />
305423 ) }
0 commit comments