@kirosnn/mosaic 0.0.91 → 0.73.0

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.
Files changed (99) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -6
  3. package/package.json +55 -48
  4. package/src/agent/Agent.ts +353 -131
  5. package/src/agent/context.ts +4 -4
  6. package/src/agent/prompts/systemPrompt.ts +209 -70
  7. package/src/agent/prompts/toolsPrompt.ts +285 -138
  8. package/src/agent/provider/anthropic.ts +109 -105
  9. package/src/agent/provider/google.ts +111 -107
  10. package/src/agent/provider/mistral.ts +95 -95
  11. package/src/agent/provider/ollama.ts +73 -17
  12. package/src/agent/provider/openai.ts +146 -102
  13. package/src/agent/provider/rateLimit.ts +178 -0
  14. package/src/agent/provider/reasoning.ts +29 -0
  15. package/src/agent/provider/xai.ts +108 -104
  16. package/src/agent/tools/definitions.ts +15 -1
  17. package/src/agent/tools/executor.ts +717 -98
  18. package/src/agent/tools/exploreExecutor.ts +20 -22
  19. package/src/agent/tools/fetch.ts +58 -0
  20. package/src/agent/tools/glob.ts +20 -4
  21. package/src/agent/tools/grep.ts +64 -9
  22. package/src/agent/tools/plan.ts +27 -0
  23. package/src/agent/tools/question.ts +7 -1
  24. package/src/agent/tools/read.ts +2 -0
  25. package/src/agent/types.ts +15 -14
  26. package/src/components/App.tsx +50 -8
  27. package/src/components/CustomInput.tsx +461 -77
  28. package/src/components/Main.tsx +1459 -1112
  29. package/src/components/Setup.tsx +1 -1
  30. package/src/components/ShortcutsModal.tsx +11 -8
  31. package/src/components/Welcome.tsx +1 -1
  32. package/src/components/main/ApprovalPanel.tsx +4 -3
  33. package/src/components/main/ChatPage.tsx +858 -516
  34. package/src/components/main/HomePage.tsx +58 -39
  35. package/src/components/main/QuestionPanel.tsx +52 -7
  36. package/src/components/main/ThinkingIndicator.tsx +13 -2
  37. package/src/components/main/types.ts +11 -10
  38. package/src/index.tsx +53 -25
  39. package/src/mcp/approvalPolicy.ts +148 -0
  40. package/src/mcp/cli/add.ts +185 -0
  41. package/src/mcp/cli/doctor.ts +77 -0
  42. package/src/mcp/cli/index.ts +85 -0
  43. package/src/mcp/cli/list.ts +50 -0
  44. package/src/mcp/cli/logs.ts +24 -0
  45. package/src/mcp/cli/manage.ts +99 -0
  46. package/src/mcp/cli/show.ts +53 -0
  47. package/src/mcp/cli/tools.ts +77 -0
  48. package/src/mcp/config.ts +223 -0
  49. package/src/mcp/index.ts +80 -0
  50. package/src/mcp/processManager.ts +299 -0
  51. package/src/mcp/rateLimiter.ts +50 -0
  52. package/src/mcp/registry.ts +151 -0
  53. package/src/mcp/schemaConverter.ts +100 -0
  54. package/src/mcp/servers/navigation.ts +854 -0
  55. package/src/mcp/toolCatalog.ts +169 -0
  56. package/src/mcp/types.ts +95 -0
  57. package/src/utils/approvalBridge.ts +45 -12
  58. package/src/utils/approvalModeBridge.ts +17 -0
  59. package/src/utils/commands/approvals.ts +48 -0
  60. package/src/utils/commands/compact.ts +30 -0
  61. package/src/utils/commands/echo.ts +1 -1
  62. package/src/utils/commands/image.ts +109 -0
  63. package/src/utils/commands/index.ts +9 -7
  64. package/src/utils/commands/new.ts +15 -0
  65. package/src/utils/commands/types.ts +3 -0
  66. package/src/utils/config.ts +3 -1
  67. package/src/utils/diffRendering.tsx +13 -16
  68. package/src/utils/exploreBridge.ts +10 -0
  69. package/src/utils/history.ts +82 -40
  70. package/src/utils/imageBridge.ts +28 -0
  71. package/src/utils/images.ts +31 -0
  72. package/src/utils/markdown.tsx +163 -99
  73. package/src/utils/models.ts +31 -16
  74. package/src/utils/notificationBridge.ts +23 -0
  75. package/src/utils/questionBridge.ts +36 -1
  76. package/src/utils/tokenEstimator.ts +32 -0
  77. package/src/utils/toolFormatting.ts +428 -48
  78. package/src/web/app.tsx +65 -5
  79. package/src/web/assets/css/ChatPage.css +102 -30
  80. package/src/web/assets/css/MessageItem.css +26 -29
  81. package/src/web/assets/css/ThinkingIndicator.css +44 -6
  82. package/src/web/assets/css/ToolMessage.css +36 -14
  83. package/src/web/components/ChatPage.tsx +228 -105
  84. package/src/web/components/HomePage.tsx +3 -3
  85. package/src/web/components/MessageItem.tsx +80 -81
  86. package/src/web/components/QuestionPanel.tsx +72 -12
  87. package/src/web/components/Setup.tsx +1 -1
  88. package/src/web/components/Sidebar.tsx +1 -3
  89. package/src/web/components/ThinkingIndicator.tsx +41 -21
  90. package/src/web/router.ts +1 -1
  91. package/src/web/server.tsx +894 -662
  92. package/src/web/storage.ts +23 -1
  93. package/src/web/types.ts +7 -6
  94. package/src/utils/commands/redo.ts +0 -74
  95. package/src/utils/commands/sessions.ts +0 -129
  96. package/src/utils/commands/undo.ts +0 -75
  97. package/src/utils/undoRedo.ts +0 -429
  98. package/src/utils/undoRedoBridge.ts +0 -45
  99. package/src/utils/undoRedoDb.ts +0 -338
@@ -1,8 +1,14 @@
1
1
  import { useState, useEffect, useRef } from 'react'
2
2
  import { TextAttributes } from "@opentui/core"
3
- import { useKeyboard } from "@opentui/react"
3
+ import { useKeyboard, useRenderer } from "@opentui/react"
4
4
  import { execSync } from 'child_process'
5
+ import { writeFileSync, readFileSync, rmSync } from 'fs'
6
+ import { join } from 'path'
7
+ import { tmpdir } from 'os'
5
8
  import { getInputHistory } from '../utils/history'
9
+ import { emitImageCommand, canUseImages } from '../utils/imageBridge'
10
+ import { notifyNotification } from '../utils/notificationBridge'
11
+ import type { ImageAttachment } from '../utils/images'
6
12
 
7
13
  export interface InputSubmitMeta {
8
14
  isPaste?: boolean
@@ -16,12 +22,16 @@ interface CustomInputProps {
16
22
  focused?: boolean
17
23
  pasteRequestId?: number
18
24
  disableHistory?: boolean
25
+ submitDisabled?: boolean
26
+ maxWidth?: number
19
27
  }
20
28
 
21
- export function CustomInput({ onSubmit, placeholder = '', password = false, focused = true, pasteRequestId = 0, disableHistory = false }: CustomInputProps) {
29
+ export function CustomInput({ onSubmit, placeholder = '', password = false, focused = true, pasteRequestId = 0, disableHistory = false, submitDisabled = false, maxWidth }: CustomInputProps) {
22
30
  const [value, setValue] = useState('')
23
31
  const [cursorPosition, setCursorPosition] = useState(0)
24
32
  const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80)
33
+ const [selectionStart, setSelectionStart] = useState<number | null>(null)
34
+ const [selectionEnd, setSelectionEnd] = useState<number | null>(null)
25
35
  const [pasteBuffer, setPasteBuffer] = useState('')
26
36
  const [inPasteMode, setInPasteMode] = useState(false)
27
37
  const [historyIndex, setHistoryIndex] = useState(-1)
@@ -31,23 +41,178 @@ export function CustomInput({ onSubmit, placeholder = '', password = false, focu
31
41
  const pasteFlagRef = useRef(false)
32
42
  const pastedContentRef = useRef('')
33
43
  const desiredCursorColRef = useRef<number | null>(null)
44
+ const lastBracketedPasteAtRef = useRef<number | null>(null)
45
+ const lastClipboardPasteRef = useRef<{ at: number; text: string } | null>(null)
46
+ const lastClipboardImageRef = useRef<{ at: number; signature: string } | null>(null)
47
+ const lastPasteRequestIdRef = useRef(0)
48
+ const lastPasteUndoRef = useRef<{ prevValue: string; prevCursor: number; nextValue: string; nextCursor: number } | null>(null)
49
+ const valueRef = useRef(value)
50
+ const cursorPositionRef = useRef(cursorPosition)
51
+ const selectionStartRef = useRef<number | null>(selectionStart)
52
+ const selectionEndRef = useRef<number | null>(selectionEnd)
53
+
54
+ const renderer = useRenderer()
34
55
 
35
56
  const normalizePastedText = (text: string) => text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
36
57
 
58
+ const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
59
+
60
+ const buildClipboardImage = (data: string, mimeType: string, size: number): ImageAttachment => {
61
+ const ext = mimeType === 'image/jpeg' ? 'jpg' : (mimeType === 'image/png' ? 'png' : 'bin')
62
+ return {
63
+ id: createId(),
64
+ name: `clipboard-${Date.now()}.${ext}`,
65
+ mimeType,
66
+ data,
67
+ size
68
+ }
69
+ }
70
+
71
+ const isPng = (buffer: Buffer) => buffer.length > 8 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47
72
+ const isJpeg = (buffer: Buffer) => buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff
73
+
74
+ const readClipboardImage = (): { data: string; mimeType: string; size: number } | null => {
75
+ try {
76
+ if (process.platform === 'win32') {
77
+ const script = 'powershell.exe -NoProfile -Command "$img=Get-Clipboard -Format Image -ErrorAction SilentlyContinue; if ($img) { $ms=New-Object System.IO.MemoryStream; $img.Save($ms,[System.Drawing.Imaging.ImageFormat]::Png); [Convert]::ToBase64String($ms.ToArray()) }"'
78
+ const base64 = execSync(script, { encoding: 'utf8', timeout: 2000 }).trim()
79
+ if (!base64) return null
80
+ const size = Buffer.from(base64, 'base64').length
81
+ return { data: base64, mimeType: 'image/png', size }
82
+ }
83
+
84
+ if (process.platform === 'darwin') {
85
+ try {
86
+ const buffer = execSync('pbpaste -Prefer png', { timeout: 2000 }) as Buffer
87
+ if (buffer.length > 0 && isPng(buffer)) {
88
+ return { data: buffer.toString('base64'), mimeType: 'image/png', size: buffer.length }
89
+ }
90
+ } catch {
91
+ }
92
+ try {
93
+ const buffer = execSync('pbpaste -Prefer jpeg', { timeout: 2000 }) as Buffer
94
+ if (buffer.length > 0 && isJpeg(buffer)) {
95
+ return { data: buffer.toString('base64'), mimeType: 'image/jpeg', size: buffer.length }
96
+ }
97
+ } catch {
98
+ }
99
+ return null
100
+ }
101
+
102
+ try {
103
+ const buffer = execSync('xclip -selection clipboard -t image/png -o', { timeout: 2000 }) as Buffer
104
+ if (buffer.length > 0 && isPng(buffer)) {
105
+ return { data: buffer.toString('base64'), mimeType: 'image/png', size: buffer.length }
106
+ }
107
+ } catch {
108
+ }
109
+ try {
110
+ const buffer = execSync('xclip -selection clipboard -t image/jpeg -o', { timeout: 2000 }) as Buffer
111
+ if (buffer.length > 0 && isJpeg(buffer)) {
112
+ return { data: buffer.toString('base64'), mimeType: 'image/jpeg', size: buffer.length }
113
+ }
114
+ } catch {
115
+ }
116
+ } catch {
117
+ }
118
+ return null
119
+ }
120
+
121
+ const tryPasteImage = (): boolean => {
122
+ const image = readClipboardImage()
123
+ if (!image) return false
124
+ const signature = `${image.mimeType}:${image.data.slice(0, 64)}`
125
+ const now = Date.now()
126
+ const last = lastClipboardImageRef.current
127
+ if (last && last.signature === signature && now - last.at < 400) return true
128
+ lastClipboardImageRef.current = { at: now, signature }
129
+
130
+ if (!canUseImages()) {
131
+ notifyNotification('Current model does not support images.', 'warning', 3000)
132
+ return true
133
+ }
134
+
135
+ emitImageCommand({ type: 'add', image: buildClipboardImage(image.data, image.mimeType, image.size) })
136
+ return true
137
+ }
138
+
37
139
  const addPastedBlock = (pastedText: string) => {
38
140
  const normalized = normalizePastedText(pastedText)
39
141
  if (!normalized) return
40
142
 
41
- setValue(prev => prev.slice(0, cursorPosition) + normalized + prev.slice(cursorPosition))
42
- setCursorPosition(prev => prev + normalized.length)
143
+ const prevValue = valueRef.current
144
+ const selStart = selectionStartRef.current
145
+ const selEnd = selectionEndRef.current
146
+ const hasSelection = typeof selStart === 'number' && typeof selEnd === 'number' && selEnd > selStart
147
+ const insertAt = hasSelection ? selStart! : cursorPositionRef.current
148
+ const deleteUntil = hasSelection ? selEnd! : insertAt
149
+ const nextValue = prevValue.slice(0, insertAt) + normalized + prevValue.slice(deleteUntil)
150
+ lastPasteUndoRef.current = {
151
+ prevValue,
152
+ prevCursor: insertAt,
153
+ nextValue,
154
+ nextCursor: insertAt + normalized.length
155
+ }
156
+ setValue(nextValue)
157
+ setCursorPosition(insertAt + normalized.length)
158
+ if (hasSelection) {
159
+ setSelectionStart(null)
160
+ setSelectionEnd(null)
161
+ }
43
162
  pasteFlagRef.current = true
44
163
  pastedContentRef.current = normalized
45
164
  }
46
165
 
166
+ const openExternalEditor = () => {
167
+ const filePath = join(tmpdir(), `mosaic-input-${Date.now()}-${process.pid}.txt`)
168
+ try {
169
+ writeFileSync(filePath, value, 'utf-8')
170
+ if (process.platform === 'win32') {
171
+ const escaped = filePath.replace(/'/g, "''")
172
+ execSync(`powershell.exe -NoProfile -Command "Start-Process notepad.exe -ArgumentList '${escaped}' -Wait"`, { stdio: 'ignore' })
173
+ } else if (process.platform === 'darwin') {
174
+ execSync(`open -W -a TextEdit "${filePath}"`, { stdio: 'ignore' })
175
+ } else {
176
+ const editor = process.env.EDITOR || 'nano'
177
+ execSync(`${editor} "${filePath}"`, { stdio: 'inherit' })
178
+ }
179
+ const updated = readFileSync(filePath, 'utf-8')
180
+ const normalized = normalizePastedText(updated)
181
+ setValue(normalized)
182
+ setCursorPosition(normalized.length)
183
+ setHistoryIndex(-1)
184
+ desiredCursorColRef.current = null
185
+ setSelectionStart(null)
186
+ setSelectionEnd(null)
187
+ } catch (error) {
188
+ } finally {
189
+ try {
190
+ rmSync(filePath)
191
+ } catch (error) {
192
+ }
193
+ }
194
+ }
195
+
47
196
  useEffect(() => {
48
197
  setInputHistory(getInputHistory())
49
198
  }, [])
50
199
 
200
+ useEffect(() => {
201
+ valueRef.current = value
202
+ }, [value])
203
+
204
+ useEffect(() => {
205
+ cursorPositionRef.current = cursorPosition
206
+ }, [cursorPosition])
207
+
208
+ useEffect(() => {
209
+ selectionStartRef.current = selectionStart
210
+ }, [selectionStart])
211
+
212
+ useEffect(() => {
213
+ selectionEndRef.current = selectionEnd
214
+ }, [selectionEnd])
215
+
51
216
  useEffect(() => {
52
217
  const handleResize = () => {
53
218
  setTerminalWidth(process.stdout.columns || 80)
@@ -70,6 +235,7 @@ export function CustomInput({ onSubmit, placeholder = '', password = false, focu
70
235
 
71
236
  const pasteFromClipboard = () => {
72
237
  try {
238
+ if (tryPasteImage()) return
73
239
  let clipboardText = ''
74
240
  if (process.platform === 'win32') {
75
241
  clipboardText = execSync('powershell.exe -command "Get-Clipboard"', { encoding: 'utf8', timeout: 2000 })
@@ -78,9 +244,13 @@ export function CustomInput({ onSubmit, placeholder = '', password = false, focu
78
244
  } else {
79
245
  clipboardText = execSync('xclip -selection clipboard -o', { encoding: 'utf8', timeout: 2000 })
80
246
  }
81
- if (clipboardText) {
82
- addPastedBlock(clipboardText)
83
- }
247
+ const normalized = normalizePastedText(clipboardText || '')
248
+ if (!normalized) return
249
+ const now = Date.now()
250
+ const last = lastClipboardPasteRef.current
251
+ if (last && last.text === normalized && now - last.at < 400) return
252
+ lastClipboardPasteRef.current = { at: now, text: normalized }
253
+ addPastedBlock(normalized)
84
254
  } catch (error) {
85
255
  }
86
256
  }
@@ -88,26 +258,93 @@ export function CustomInput({ onSubmit, placeholder = '', password = false, focu
88
258
  useEffect(() => {
89
259
  if (!focused) return
90
260
  if (!pasteRequestId) return
261
+ if (pasteRequestId === lastPasteRequestIdRef.current) return
262
+ lastPasteRequestIdRef.current = pasteRequestId
263
+ if (inPasteMode) return
264
+ const now = Date.now()
265
+ const lastBracketed = lastBracketedPasteAtRef.current
266
+ if (lastBracketed && now - lastBracketed < 250) return
91
267
  pasteFromClipboard()
92
- }, [pasteRequestId, focused])
268
+ }, [pasteRequestId, focused, inPasteMode])
93
269
 
94
- useKeyboard((key) => {
270
+ useEffect(() => {
95
271
  if (!focused) return
96
272
 
97
- const typedDisplay = value.replace(/\n/g, ' ')
98
- const displayValueRaw = typedDisplay
99
- const displayCursorPos = cursorPosition
100
- const lineWidth = Math.max(10, terminalWidth - 4)
101
- const displayLines = displayValueRaw.length > 0
102
- ? Array.from({ length: Math.ceil(displayValueRaw.length / lineWidth) }, (_, i) => displayValueRaw.slice(i * lineWidth, (i + 1) * lineWidth))
103
- : ['']
104
- const boundedDisplayCursorPos = Math.max(0, Math.min(displayValueRaw.length, displayCursorPos))
105
- const currentCursorLine = displayValueRaw.length === 0 ? 0 : Math.min(displayLines.length - 1, Math.floor(boundedDisplayCursorPos / lineWidth))
106
- const currentCursorCol = boundedDisplayCursorPos >= displayValueRaw.length
107
- ? displayLines[Math.max(0, displayLines.length - 1)]!.length
108
- : (boundedDisplayCursorPos % lineWidth)
273
+ const handlePaste = (event: { text?: string } | string) => {
274
+ const text = typeof event === 'string' ? event : (event?.text || '')
275
+ const normalized = normalizePastedText(text)
276
+ if (!normalized) return
277
+ const now = Date.now()
278
+ const last = lastClipboardPasteRef.current
279
+ if (last && last.text === normalized && now - last.at < 400) return
280
+ lastClipboardPasteRef.current = { at: now, text: normalized }
281
+ addPastedBlock(normalized)
282
+ setHistoryIndex(-1)
283
+ desiredCursorColRef.current = null
284
+ lastBracketedPasteAtRef.current = now
285
+ }
286
+
287
+ renderer.keyInput.on('paste', handlePaste as any)
288
+ return () => {
289
+ renderer.keyInput.off('paste', handlePaste as any)
290
+ }
291
+ }, [focused, renderer.keyInput])
292
+
293
+ const wrapTextWithCursor = (text: string, cursorPos: number, maxWidth: number): { lineStarts: number[], lineLengths: number[], cursorLine: number, cursorCol: number } => {
294
+ const safeCursorPos = Math.max(0, Math.min(text.length, cursorPos))
295
+ const lineStarts: number[] = [0]
296
+ const lineLengths: number[] = [0]
297
+ let lineIndex = 0
298
+ let col = 0
299
+ let cursorLine = 0
300
+ let cursorCol = 0
301
+
302
+ for (let i = 0; i <= text.length; i += 1) {
303
+ if (col >= maxWidth) {
304
+ lineStarts.push(i)
305
+ lineLengths.push(0)
306
+ lineIndex += 1
307
+ col = 0
308
+ }
309
+
310
+ if (i === safeCursorPos) {
311
+ cursorLine = lineIndex
312
+ cursorCol = col
313
+ }
314
+
315
+ if (i === text.length) break
316
+
317
+ const ch = text[i]!
318
+ if (ch === '\n') {
319
+ lineStarts.push(i + 1)
320
+ lineLengths.push(0)
321
+ lineIndex += 1
322
+ col = 0
323
+ continue
324
+ }
325
+
326
+ lineLengths[lineIndex] = (lineLengths[lineIndex] || 0) + 1
327
+ col += 1
328
+ }
329
+
330
+ return { lineStarts, lineLengths, cursorLine, cursorCol }
331
+ }
332
+
333
+ const buildDisplayLines = (displayText: string, lineStarts: number[], lineLengths: number[]) => {
334
+ const lines: string[] = []
335
+ for (let i = 0; i < lineStarts.length; i += 1) {
336
+ const start = lineStarts[i] ?? 0
337
+ const len = lineLengths[i] ?? 0
338
+ lines.push(displayText.slice(start, start + len))
339
+ }
340
+ return lines
341
+ }
342
+
343
+ useKeyboard((key) => {
344
+ if (!focused) return
109
345
 
110
346
  if (key.sequence && key.sequence.includes('\x1b[200~')) {
347
+ lastBracketedPasteAtRef.current = Date.now()
111
348
  setInPasteMode(true)
112
349
  setPasteBuffer('')
113
350
  return
@@ -116,8 +353,14 @@ export function CustomInput({ onSubmit, placeholder = '', password = false, focu
116
353
  if (key.sequence && key.sequence.includes('\x1b[201~')) {
117
354
  setInPasteMode(false)
118
355
  if (pasteBuffer) {
119
- addPastedBlock(pasteBuffer)
356
+ const now = Date.now()
357
+ const normalized = normalizePastedText(pasteBuffer)
358
+ const last = lastClipboardPasteRef.current
359
+ if (!last || last.text !== normalized || now - last.at >= 400) {
360
+ addPastedBlock(pasteBuffer)
361
+ }
120
362
  setPasteBuffer('')
363
+ lastBracketedPasteAtRef.current = now
121
364
  }
122
365
  return
123
366
  }
@@ -127,6 +370,46 @@ export function CustomInput({ onSubmit, placeholder = '', password = false, focu
127
370
  return
128
371
  }
129
372
 
373
+ if ((key.name === 'z' && key.ctrl) || key.sequence === '\x1a') {
374
+ const lastPaste = lastPasteUndoRef.current
375
+ if (lastPaste && valueRef.current === lastPaste.nextValue) {
376
+ setValue(lastPaste.prevValue)
377
+ setCursorPosition(lastPaste.prevCursor)
378
+ lastPasteUndoRef.current = null
379
+ pasteFlagRef.current = false
380
+ pastedContentRef.current = ''
381
+ setSelectionStart(null)
382
+ setSelectionEnd(null)
383
+ }
384
+ return
385
+ }
386
+
387
+ if (key.name === 'a' && (key.ctrl || key.meta)) {
388
+ if (value.length > 0) {
389
+ const selStart = selectionStartRef.current
390
+ const selEnd = selectionEndRef.current
391
+ const hasSelection = typeof selStart === 'number' && typeof selEnd === 'number' && selEnd > selStart
392
+ const fullSelection = hasSelection && selStart === 0 && selEnd === value.length
393
+ if (fullSelection) {
394
+ setSelectionStart(null)
395
+ setSelectionEnd(null)
396
+ setCursorPosition(value.length)
397
+ } else {
398
+ setSelectionStart(0)
399
+ setSelectionEnd(value.length)
400
+ setCursorPosition(value.length)
401
+ }
402
+ }
403
+ return
404
+ }
405
+
406
+ if ((key.name === 'v' && key.ctrl) || key.sequence === '\x16') {
407
+ pasteFromClipboard()
408
+ setHistoryIndex(-1)
409
+ desiredCursorColRef.current = null
410
+ return
411
+ }
412
+
130
413
  if (key.name === 'k' && (key.ctrl || key.meta || (key as any).alt)) {
131
414
  setValue('')
132
415
  setCursorPosition(0)
@@ -135,10 +418,18 @@ export function CustomInput({ onSubmit, placeholder = '', password = false, focu
135
418
  pasteFlagRef.current = false
136
419
  pastedContentRef.current = ''
137
420
  desiredCursorColRef.current = null
421
+ setSelectionStart(null)
422
+ setSelectionEnd(null)
423
+ return
424
+ }
425
+
426
+ if (key.name === 'g' && (key.ctrl || key.meta)) {
427
+ openExternalEditor()
138
428
  return
139
429
  }
140
430
 
141
431
  if (key.name === 'return') {
432
+ if (submitDisabled) return
142
433
  const meta: InputSubmitMeta | undefined = pasteFlagRef.current
143
434
  ? { isPaste: true, pastedContent: pastedContentRef.current }
144
435
  : undefined
@@ -151,32 +442,63 @@ export function CustomInput({ onSubmit, placeholder = '', password = false, focu
151
442
  pasteFlagRef.current = false
152
443
  pastedContentRef.current = ''
153
444
  desiredCursorColRef.current = null
445
+ setSelectionStart(null)
446
+ setSelectionEnd(null)
154
447
  } else if (key.name === 'backspace') {
155
448
  desiredCursorColRef.current = null
449
+ const selStart = selectionStartRef.current
450
+ const selEnd = selectionEndRef.current
451
+ if (typeof selStart === 'number' && typeof selEnd === 'number' && selEnd > selStart) {
452
+ const prev = valueRef.current
453
+ const nextValue = prev.slice(0, selStart) + prev.slice(selEnd)
454
+ setValue(nextValue)
455
+ setCursorPosition(selStart)
456
+ setSelectionStart(null)
457
+ setSelectionEnd(null)
458
+ return
459
+ }
156
460
  if (cursorPosition > 0) {
157
461
  setValue(prev => prev.slice(0, cursorPosition - 1) + prev.slice(cursorPosition))
158
462
  setCursorPosition(prev => prev - 1)
159
463
  }
160
464
  } else if (key.name === 'delete') {
161
465
  desiredCursorColRef.current = null
466
+ const selStart = selectionStartRef.current
467
+ const selEnd = selectionEndRef.current
468
+ if (typeof selStart === 'number' && typeof selEnd === 'number' && selEnd > selStart) {
469
+ const prev = valueRef.current
470
+ const nextValue = prev.slice(0, selStart) + prev.slice(selEnd)
471
+ setValue(nextValue)
472
+ setCursorPosition(selStart)
473
+ setSelectionStart(null)
474
+ setSelectionEnd(null)
475
+ return
476
+ }
162
477
  if (key.ctrl || key.meta) {
163
478
  setValue('')
164
479
  setCursorPosition(0)
165
480
  pasteFlagRef.current = false
166
481
  pastedContentRef.current = ''
482
+ setSelectionStart(null)
483
+ setSelectionEnd(null)
167
484
  } else if (cursorPosition < value.length) {
168
485
  setValue(prev => prev.slice(0, cursorPosition) + prev.slice(cursorPosition + 1))
169
486
  }
170
487
  } else if (key.name === 'up') {
488
+ const lineWidth = Math.max(10, terminalWidth - 4)
489
+ const { lineLengths, cursorLine: currentCursorLine, cursorCol: currentCursorCol, lineStarts } = wrapTextWithCursor(value, cursorPosition, lineWidth)
171
490
  if (currentCursorLine > 0) {
172
491
  if (desiredCursorColRef.current === null) {
173
492
  desiredCursorColRef.current = currentCursorCol
174
493
  }
175
494
  const targetLine = currentCursorLine - 1
176
495
  const targetCol = desiredCursorColRef.current
177
- const targetLineLen = displayLines[targetLine]!.length
178
- const newDisplayPos = (targetLine * lineWidth) + Math.min(targetCol, targetLineLen)
179
- setCursorPosition(Math.min(value.length, newDisplayPos))
496
+ const targetLineLen = lineLengths[targetLine] ?? 0
497
+ const lineStart = lineStarts[targetLine] ?? 0
498
+ const newCursorPos = lineStart + Math.min(targetCol, targetLineLen)
499
+ setCursorPosition(Math.min(value.length, newCursorPos))
500
+ setSelectionStart(null)
501
+ setSelectionEnd(null)
180
502
  return
181
503
  }
182
504
 
@@ -191,22 +513,31 @@ export function CustomInput({ onSubmit, placeholder = '', password = false, focu
191
513
  setHistoryIndex(newIndex)
192
514
  setValue(inputHistory[newIndex]!)
193
515
  setCursorPosition(inputHistory[newIndex]!.length)
516
+ setSelectionStart(null)
517
+ setSelectionEnd(null)
194
518
  } else if (historyIndex > 0) {
195
519
  const newIndex = historyIndex - 1
196
520
  setHistoryIndex(newIndex)
197
521
  setValue(inputHistory[newIndex]!)
198
522
  setCursorPosition(inputHistory[newIndex]!.length)
523
+ setSelectionStart(null)
524
+ setSelectionEnd(null)
199
525
  }
200
526
  } else if (key.name === 'down') {
201
- if (currentCursorLine < displayLines.length - 1) {
527
+ const lineWidth = Math.max(10, terminalWidth - 4)
528
+ const { lineLengths, cursorLine: currentCursorLine, cursorCol: currentCursorCol, lineStarts } = wrapTextWithCursor(value, cursorPosition, lineWidth)
529
+ if (currentCursorLine < lineStarts.length - 1) {
202
530
  if (desiredCursorColRef.current === null) {
203
531
  desiredCursorColRef.current = currentCursorCol
204
532
  }
205
533
  const targetLine = currentCursorLine + 1
206
534
  const targetCol = desiredCursorColRef.current
207
- const targetLineLen = displayLines[targetLine]!.length
208
- const newDisplayPos = (targetLine * lineWidth) + Math.min(targetCol, targetLineLen)
209
- setCursorPosition(Math.min(value.length, newDisplayPos))
535
+ const targetLineLen = lineLengths[targetLine] ?? 0
536
+ const lineStart = lineStarts[targetLine] ?? value.length
537
+ const newCursorPos = lineStart + Math.min(targetCol, targetLineLen)
538
+ setCursorPosition(Math.min(value.length, newCursorPos))
539
+ setSelectionStart(null)
540
+ setSelectionEnd(null)
210
541
  return
211
542
  }
212
543
 
@@ -220,109 +551,162 @@ export function CustomInput({ onSubmit, placeholder = '', password = false, focu
220
551
  setHistoryIndex(newIndex)
221
552
  setValue(inputHistory[newIndex]!)
222
553
  setCursorPosition(inputHistory[newIndex]!.length)
554
+ setSelectionStart(null)
555
+ setSelectionEnd(null)
223
556
  } else {
224
557
  setHistoryIndex(-1)
225
558
  setValue(currentInput)
226
559
  setCursorPosition(currentInput.length)
560
+ setSelectionStart(null)
561
+ setSelectionEnd(null)
227
562
  }
228
563
  } else if (key.name === 'left') {
229
564
  desiredCursorColRef.current = null
230
565
  setCursorPosition(prev => Math.max(0, prev - 1))
566
+ setSelectionStart(null)
567
+ setSelectionEnd(null)
231
568
  } else if (key.name === 'right') {
232
569
  desiredCursorColRef.current = null
233
570
  setCursorPosition(prev => Math.min(value.length, prev + 1))
571
+ setSelectionStart(null)
572
+ setSelectionEnd(null)
234
573
  } else if (key.name === 'home') {
235
574
  desiredCursorColRef.current = null
236
575
  setCursorPosition(0)
576
+ setSelectionStart(null)
577
+ setSelectionEnd(null)
237
578
  } else if (key.name === 'end') {
238
579
  desiredCursorColRef.current = null
239
580
  setCursorPosition(value.length)
581
+ setSelectionStart(null)
582
+ setSelectionEnd(null)
240
583
  } else if (key.sequence && key.sequence.length > 1 && !key.ctrl && !key.meta && !key.name) {
241
584
  addPastedBlock(key.sequence)
242
585
  setHistoryIndex(-1)
243
586
  desiredCursorColRef.current = null
587
+ setSelectionStart(null)
588
+ setSelectionEnd(null)
244
589
  } else if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
245
590
  const char = key.sequence
246
- setValue(prev => prev.slice(0, cursorPosition) + char + prev.slice(cursorPosition))
247
- setCursorPosition(prev => prev + char.length)
591
+ const selStart = selectionStartRef.current
592
+ const selEnd = selectionEndRef.current
593
+ if (typeof selStart === 'number' && typeof selEnd === 'number' && selEnd > selStart) {
594
+ const prev = valueRef.current
595
+ const nextValue = prev.slice(0, selStart) + char + prev.slice(selEnd)
596
+ setValue(nextValue)
597
+ setCursorPosition(selStart + char.length)
598
+ setSelectionStart(null)
599
+ setSelectionEnd(null)
600
+ } else {
601
+ setValue(prev => prev.slice(0, cursorPosition) + char + prev.slice(cursorPosition))
602
+ setCursorPosition(prev => prev + char.length)
603
+ }
248
604
  setHistoryIndex(-1)
249
605
  desiredCursorColRef.current = null
250
606
  }
251
607
  })
252
608
 
253
- const typedDisplay = value.replace(/\n/g, ' ')
254
- const displayValue = password && value ? '•'.repeat(value.length) : typedDisplay
255
- const cursorChar = '█'
609
+ const displayValue = password && value ? Array.from(value, (char) => (char === '\n' ? '\n' : '•')).join('') : value
256
610
  const isEmpty = value.length === 0
257
611
 
258
- const lineWidth = Math.max(10, terminalWidth - 4)
259
-
260
- const wrapTextWithCursor = (text: string, cursorPos: number, maxWidth: number): { lines: string[], cursorLine: number, cursorCol: number } => {
261
- if (text.length === 0) {
262
- return { lines: [''], cursorLine: 0, cursorCol: 0 }
263
- }
264
-
265
- const safeCursorPos = Math.max(0, Math.min(text.length, cursorPos))
266
- const lines: string[] = []
267
- for (let i = 0; i < text.length; i += maxWidth) {
268
- lines.push(text.slice(i, i + maxWidth))
269
- }
270
-
271
- let cursorLine: number
272
- let cursorCol: number
273
-
274
- if (safeCursorPos >= text.length) {
275
- cursorLine = lines.length - 1
276
- cursorCol = lines[cursorLine]!.length
277
- } else {
278
- cursorLine = Math.floor(safeCursorPos / maxWidth)
279
- cursorCol = safeCursorPos % maxWidth
280
- }
281
-
282
- cursorLine = Math.max(0, Math.min(lines.length - 1, cursorLine))
283
- cursorCol = Math.max(0, Math.min(lines[cursorLine]!.length, cursorCol))
284
-
285
- return { lines, cursorLine, cursorCol }
286
- }
612
+ const lineWidth = Math.max(10, maxWidth ?? (terminalWidth - 4))
287
613
 
288
614
  if (isEmpty) {
289
615
  if (!placeholder) {
290
616
  return (
291
617
  <box flexDirection="column" flexGrow={1} width="100%">
292
618
  <box flexDirection="row">
293
- <text>{cursorChar}</text>
619
+ <text fg="black" bg="white"> </text>
294
620
  </box>
295
621
  </box>
296
622
  )
297
623
  }
624
+ const firstChar = placeholder[0] || ' '
298
625
  return (
299
626
  <box flexDirection="column" flexGrow={1} width="100%">
300
627
  <box flexDirection="row">
301
- <text>{cursorChar}</text>
302
- {placeholder.slice(1) && <text attributes={TextAttributes.DIM}>{placeholder.slice(1)}</text>}
628
+ <text fg="gray" bg="white" attributes={TextAttributes.DIM}>{firstChar}</text>
629
+ {placeholder.slice(1) && <text fg="gray" attributes={TextAttributes.DIM}>{placeholder.slice(1)}</text>}
303
630
  </box>
304
631
  </box>
305
632
  )
306
633
  }
307
634
 
308
- const { lines, cursorLine, cursorCol } = wrapTextWithCursor(displayValue, cursorPosition, lineWidth)
635
+ const { lineStarts, lineLengths, cursorLine, cursorCol } = wrapTextWithCursor(value, cursorPosition, lineWidth)
636
+ const lines = buildDisplayLines(displayValue, lineStarts, lineLengths)
637
+ const selectionRange = (() => {
638
+ if (typeof selectionStart === 'number' && typeof selectionEnd === 'number') {
639
+ const start = Math.max(0, Math.min(selectionStart, selectionEnd))
640
+ const end = Math.max(0, Math.max(selectionStart, selectionEnd))
641
+ if (end > start) return { start, end }
642
+ }
643
+ return null
644
+ })()
309
645
 
310
- const renderedLines = lines.map((line, lineIndex) => {
311
- if (lineIndex === cursorLine) {
312
- const beforeCursor = line.slice(0, cursorCol)
313
- const afterCursor = line.slice(cursorCol)
314
- return beforeCursor + cursorChar + afterCursor
646
+ const buildSelectionSegments = (text: string, absStart: number) => {
647
+ if (!selectionRange || text.length === 0) {
648
+ return [{ text, selected: false }]
315
649
  }
316
- return line || ' '
317
- })
650
+ const segStart = absStart
651
+ const segEnd = absStart + text.length
652
+ if (selectionRange.end <= segStart || selectionRange.start >= segEnd) {
653
+ return [{ text, selected: false }]
654
+ }
655
+ const parts: Array<{ text: string; selected: boolean }> = []
656
+ if (selectionRange.start > segStart) {
657
+ parts.push({ text: text.slice(0, selectionRange.start - segStart), selected: false })
658
+ }
659
+ const selFrom = Math.max(selectionRange.start, segStart) - segStart
660
+ const selTo = Math.min(selectionRange.end, segEnd) - segStart
661
+ parts.push({ text: text.slice(selFrom, selTo), selected: true })
662
+ if (selectionRange.end < segEnd) {
663
+ parts.push({ text: text.slice(selTo), selected: false })
664
+ }
665
+ return parts.filter(p => p.text.length > 0)
666
+ }
318
667
 
319
668
  return (
320
669
  <box flexDirection="column" flexGrow={1} width="100%">
321
- {renderedLines.map((renderedLine, lineIndex) => (
670
+ {lines.map((line, lineIndex) => (
322
671
  <box key={lineIndex} flexDirection="row">
323
- <text>{renderedLine}</text>
672
+ {(() => {
673
+ const lineStart = lineStarts[lineIndex] ?? 0
674
+ if (lineIndex === cursorLine) {
675
+ const safeCursorCol = Math.max(0, Math.min(line.length, cursorCol))
676
+ const before = line.slice(0, safeCursorCol)
677
+ const cursorChar = line[safeCursorCol] || ' '
678
+ const after = line.slice(safeCursorCol + 1)
679
+ const beforeSegs = buildSelectionSegments(before, lineStart)
680
+ const afterSegs = buildSelectionSegments(after, lineStart + safeCursorCol + 1)
681
+ return (
682
+ <>
683
+ {beforeSegs.map((seg, i) => (
684
+ <text key={`b-${i}`} fg="white" bg={seg.selected ? "#3a3a3a" : undefined}>
685
+ {seg.text}
686
+ </text>
687
+ ))}
688
+ <text fg="black" bg="white">{cursorChar}</text>
689
+ {afterSegs.map((seg, i) => (
690
+ <text key={`a-${i}`} fg="white" bg={seg.selected ? "#3a3a3a" : undefined}>
691
+ {seg.text}
692
+ </text>
693
+ ))}
694
+ </>
695
+ )
696
+ }
697
+ const segs = buildSelectionSegments(line || ' ', lineStart)
698
+ return (
699
+ <>
700
+ {segs.map((seg, i) => (
701
+ <text key={`l-${i}`} fg="white" bg={seg.selected ? "#3a3a3a" : undefined}>
702
+ {seg.text}
703
+ </text>
704
+ ))}
705
+ </>
706
+ )
707
+ })()}
324
708
  </box>
325
709
  ))}
326
710
  </box>
327
711
  )
328
- }
712
+ }