ethagent 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/Select.tsx CHANGED
@@ -73,7 +73,7 @@ export function Select<T>({
73
73
  else if (key.return) {
74
74
  const selected = options[index]
75
75
  if (isSelectableOption(selected)) onSubmit(selected.value)
76
- } else if (key.escape) {
76
+ } else if (key.escape || (key.ctrl && input === 'c')) {
77
77
  onCancel?.()
78
78
  }
79
79
  })
@@ -27,7 +27,7 @@ export const Surface: React.FC<SurfaceProps> = ({
27
27
  }) => {
28
28
  const borderColor = toneColor[tone]
29
29
  return (
30
- <Box flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={2} paddingY={0}>
30
+ <Box flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={2} paddingY={0} width="100%">
31
31
  <Box flexDirection="column">
32
32
  <Text color={borderColor} bold>{title}</Text>
33
33
  {subtitle ? (
@@ -1,7 +1,15 @@
1
- import React, { useState } from 'react'
2
- import { Box, Text } from 'ink'
1
+ import React, { useState, useRef, useEffect } from 'react'
2
+ import { Box, Text, useStdout } from 'ink'
3
3
  import { theme } from './theme.js'
4
4
  import { useAppInput } from '../app/input/AppInputProvider.js'
5
+ import { moveVerticalVisual } from '../chat/chatInputState.js'
6
+ import {
7
+ getVisualLineIndex,
8
+ getVisualLines,
9
+ } from '../chat/textCursor.js'
10
+
11
+ // ConversationStack padding=1 (2) + Surface border (2) + Surface paddingX=2 (4) + '> ' prefix (2) = 10
12
+ const DEFAULT_CHROME_WIDTH = 10
5
13
 
6
14
  type TextInputProps = {
7
15
  label?: string
@@ -9,62 +17,116 @@ type TextInputProps = {
9
17
  isSecret?: boolean
10
18
  initialValue?: string
11
19
  allowEmpty?: boolean
20
+ multiline?: boolean
21
+ chromeWidth?: number
12
22
  maxLength?: number
13
23
  validate?: (value: string) => string | null
14
24
  onSubmit: (value: string) => void
15
25
  onCancel?: () => void
16
26
  }
17
27
 
28
+ type RenderedTextInputLine = {
29
+ visualLineIndex: number
30
+ node: React.ReactNode
31
+ }
32
+
18
33
  export function TextInput({
19
34
  label,
20
35
  placeholder,
21
36
  isSecret,
22
37
  initialValue = '',
23
38
  allowEmpty = false,
39
+ multiline = false,
40
+ chromeWidth = DEFAULT_CHROME_WIDTH,
24
41
  maxLength = 4096,
25
42
  validate,
26
43
  onSubmit,
27
44
  onCancel,
28
45
  }: TextInputProps) {
46
+ const { stdout } = useStdout()
29
47
  const [value, setValue] = useState(initialValue)
48
+ const [cursor, setCursor] = useState(initialValue.length)
49
+ const [preferredColumn, setPreferredColumn] = useState<number | null>(null)
30
50
  const [error, setError] = useState<string | null>(null)
31
51
 
52
+ // Keep a columns state updated via resize, matching ChatInput's pattern exactly
53
+ const [columns, setColumns] = useState<number>(() => Math.floor(stdout?.columns ?? 80))
54
+ useEffect(() => {
55
+ if (!stdout) return
56
+ const handleResize = () => setColumns(Math.floor(stdout.columns ?? 80))
57
+ stdout.on('resize', handleResize)
58
+ return () => { stdout.off('resize', handleResize) }
59
+ }, [stdout])
60
+
61
+ const wrapWidth = textInputWrapWidth(columns, chromeWidth)
62
+
63
+ // Sync refs during render so the input handler always reads fresh values,
64
+ // even if AppInputProvider fires before the next useEffect cycle updates handlerRef.
65
+ const stateRef = useRef({ value, cursor, preferredColumn, wrapWidth })
66
+ stateRef.current = { value, cursor, preferredColumn, wrapWidth }
67
+
32
68
  useAppInput((input, key) => {
69
+ const { value: val, cursor: cur, preferredColumn: prefCol, wrapWidth: ww } = stateRef.current
70
+
33
71
  if (key.return) {
34
- if (!allowEmpty && value.trim().length === 0) {
72
+ if (!allowEmpty && val.trim().length === 0) {
35
73
  setError('value cannot be empty')
36
74
  return
37
75
  }
38
- const validationError = validate?.(value) ?? null
76
+ const validationError = validate?.(val) ?? null
39
77
  if (validationError) {
40
78
  setError(validationError)
41
79
  return
42
80
  }
43
81
  setError(null)
44
- onSubmit(value)
82
+ onSubmit(val)
45
83
  return
46
84
  }
47
- if (key.escape) {
85
+ if (key.escape || (key.ctrl && input === 'c')) {
48
86
  onCancel?.()
49
87
  return
50
88
  }
89
+ if (key.leftArrow) {
90
+ setCursor(Math.max(0, cur - 1))
91
+ setPreferredColumn(null)
92
+ return
93
+ }
94
+ if (key.rightArrow) {
95
+ setCursor(Math.min(val.length, cur + 1))
96
+ setPreferredColumn(null)
97
+ return
98
+ }
99
+ if (multiline && (key.upArrow || key.downArrow)) {
100
+ const result = moveVerticalVisual(val, cur, key.upArrow ? -1 : 1, ww, prefCol)
101
+ if (result.kind === 'moved') setCursor(result.cursor)
102
+ setPreferredColumn(result.preferredColumn)
103
+ return
104
+ }
51
105
  if (key.backspace || key.delete) {
52
- setValue(v => v.slice(0, -1))
106
+ if (cur === 0) return
107
+ setValue(val.slice(0, cur - 1) + val.slice(cur))
108
+ setCursor(cur - 1)
109
+ setPreferredColumn(null)
53
110
  if (error) setError(null)
54
111
  return
55
112
  }
56
113
  if (key.ctrl && input === 'u') {
57
114
  setValue('')
115
+ setCursor(0)
116
+ setPreferredColumn(null)
58
117
  if (error) setError(null)
59
118
  return
60
119
  }
61
- if (key.ctrl || key.meta || key.leftArrow || key.rightArrow || key.upArrow || key.downArrow || key.tab) {
120
+ if (key.ctrl || key.meta || key.upArrow || key.downArrow || key.tab) {
62
121
  return
63
122
  }
64
123
  if (input) {
65
124
  const clean = input.replace(/[\r\n]/g, '')
66
125
  if (clean) {
67
- setValue(v => (v + clean).slice(0, maxLength))
126
+ const next = (val.slice(0, cur) + clean + val.slice(cur)).slice(0, maxLength)
127
+ setValue(next)
128
+ setCursor(Math.min(cur + clean.length, maxLength))
129
+ setPreferredColumn(null)
68
130
  if (error) setError(null)
69
131
  }
70
132
  }
@@ -72,26 +134,83 @@ export function TextInput({
72
134
 
73
135
  const display = isSecret ? '*'.repeat(value.length) : value
74
136
  const showPlaceholder = value.length === 0 && placeholder
137
+ const renderedLines = multiline
138
+ ? renderTextInputLines(display, cursor, true, wrapWidth)
139
+ : []
75
140
 
76
141
  return (
77
142
  <Box flexDirection="column">
78
143
  {label ? <Text color={theme.dim}>{label}</Text> : null}
79
- <Box flexDirection="row">
80
- <Text color={theme.accentPrimary}>{'> '}</Text>
81
- {showPlaceholder ? (
82
- <>
83
- <Text color={theme.accentPrimary}>|</Text>
84
- <Text color={theme.dim}>{placeholder}</Text>
85
- </>
86
- ) : (
87
- <>
88
- <Text color={theme.text}>{display}</Text>
89
- <Text color={theme.accentPrimary}>|</Text>
90
- </>
91
- )}
92
- </Box>
144
+ {multiline && !showPlaceholder ? (
145
+ <Box flexDirection="column">
146
+ {renderedLines.map(line => (
147
+ <Box key={line.visualLineIndex} flexDirection="row">
148
+ <Text color={line.visualLineIndex === 0 ? theme.accentPrimary : theme.dim}>
149
+ {line.visualLineIndex === 0 ? '> ' : ' '}
150
+ </Text>
151
+ <Box width={wrapWidth}>{line.node}</Box>
152
+ </Box>
153
+ ))}
154
+ </Box>
155
+ ) : (
156
+ <Box flexDirection="row">
157
+ <Text color={theme.accentPrimary}>{'> '}</Text>
158
+ <Box width={wrapWidth}>
159
+ {showPlaceholder ? (
160
+ <Text wrap={multiline ? 'wrap' : 'truncate-end'}>
161
+ <Text backgroundColor={theme.accentMint} color="#08110c">{' '}</Text>
162
+ <Text color={theme.dim}>{placeholder}</Text>
163
+ </Text>
164
+ ) : (
165
+ <Text color={theme.text} wrap="truncate-end">
166
+ {display.slice(0, cursor)}
167
+ <Text backgroundColor={theme.accentMint} color="#08110c">{display[cursor] ?? ' '}</Text>
168
+ {display.slice(cursor + 1)}
169
+ </Text>
170
+ )}
171
+ </Box>
172
+ </Box>
173
+ )}
93
174
  {error ? <Text color="#e87070">{error}</Text> : null}
94
175
  </Box>
95
176
  )
96
177
  }
97
178
 
179
+ export function textInputWrapWidth(columns: number, chromeWidth = DEFAULT_CHROME_WIDTH): number {
180
+ return Math.max(1, Math.floor(columns) - Math.max(0, Math.floor(chromeWidth)))
181
+ }
182
+
183
+ export function renderTextInputLines(
184
+ value: string,
185
+ cursor: number,
186
+ showCursor: boolean,
187
+ wrapWidth: number,
188
+ ): RenderedTextInputLine[] {
189
+ const lines = getVisualLines(value, wrapWidth)
190
+ const cursorLine = getVisualLineIndex(lines, cursor)
191
+
192
+ return lines.map((line, visualLineIndex) => {
193
+ const text = value.slice(line.start, line.end)
194
+ if (!showCursor || visualLineIndex !== cursorLine) {
195
+ return {
196
+ visualLineIndex,
197
+ node: <Text color={theme.text} wrap="wrap">{text || ' '}</Text>,
198
+ }
199
+ }
200
+
201
+ const column = Math.max(0, Math.min(cursor - line.start, text.length))
202
+ const before = text.slice(0, column)
203
+ const atChar = text[column] ?? ' '
204
+ const after = text.slice(column + 1)
205
+ return {
206
+ visualLineIndex,
207
+ node: (
208
+ <Text color={theme.text} wrap="wrap">
209
+ {before}
210
+ <Text backgroundColor={theme.accentMint} color="#08110c">{atChar}</Text>
211
+ {after}
212
+ </Text>
213
+ ),
214
+ }
215
+ })
216
+ }