Skip to content

Commit 9b83be8

Browse files
committed
Improve TUI paste handling and input shortcuts
1 parent 35a8105 commit 9b83be8

File tree

5 files changed

+154
-29
lines changed

5 files changed

+154
-29
lines changed

cli/src/components/multiline-input.tsx

Lines changed: 101 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
1-
import { useKeyboard, /* usePaste */ } from '@opentui/react'
1+
import { TextAttributes } from '@opentui/core'
2+
import { useKeyboard } from '@opentui/react'
23
import { useCallback, useState, useEffect, useMemo, useRef } from 'react'
34

4-
import { TextAttributes, type ScrollBoxRenderable } from '@opentui/core'
5+
import { useOpentuiPaste } from '../hooks/use-opentui-paste'
56

6-
import { logger } from '../utils/logger'
7+
import type { PasteEvent, ScrollBoxRenderable } from '@opentui/core'
78

8-
const mixColors = (foreground: string, background: string, alpha = 0.4): string => {
9+
const mixColors = (
10+
foreground: string,
11+
background: string,
12+
alpha = 0.4,
13+
): string => {
914
const parseHex = (hex: string) => {
1015
const normalized = hex.trim().replace('#', '')
11-
const full = normalized.length === 3
12-
? normalized.split('').map((ch) => ch + ch).join('')
13-
: normalized
16+
const full =
17+
normalized.length === 3
18+
? normalized
19+
.split('')
20+
.map((ch) => ch + ch)
21+
.join('')
22+
: normalized
1423
const value = parseInt(full, 16)
1524
return {
1625
r: (value >> 16) & 0xff,
@@ -38,7 +47,6 @@ const mixColors = (foreground: string, background: string, alpha = 0.4): string
3847
}
3948
}
4049

41-
4250
// Helper functions for text manipulation
4351
function findLineStart(text: string, cursor: number): number {
4452
let pos = Math.max(0, Math.min(cursor, text.length))
@@ -138,13 +146,14 @@ export function MultilineInput({
138146
}
139147
}, [value.length, cursorPosition])
140148

141-
/*
142-
usePaste(
149+
useOpentuiPaste(
143150
useCallback(
144-
(event) => {
151+
(event: PasteEvent) => {
145152
if (!focused) return
146-
147-
const text = event.text
153+
154+
const text = event.text ?? ''
155+
if (!text) return
156+
148157
const newValue =
149158
value.slice(0, cursorPosition) + text + value.slice(cursorPosition)
150159
onChange(newValue)
@@ -153,7 +162,6 @@ export function MultilineInput({
153162
[focused, value, cursorPosition, onChange],
154163
),
155164
)
156-
*/
157165

158166
// Auto-scroll to bottom when content changes
159167
useEffect(() => {
@@ -217,9 +225,9 @@ export function MultilineInput({
217225
!hasEscapePrefix &&
218226
key.sequence === '\r'
219227
const isShiftEnter =
220-
isEnterKey &&
221-
(Boolean(key.shift) || key.sequence === '\n')
222-
const isOptionEnter = isEnterKey && (isAltLikeModifier || hasEscapePrefix)
228+
isEnterKey && (Boolean(key.shift) || key.sequence === '\n')
229+
const isOptionEnter =
230+
isEnterKey && (isAltLikeModifier || hasEscapePrefix)
223231
const isCtrlJ =
224232
key.ctrl &&
225233
!key.meta &&
@@ -259,9 +267,6 @@ export function MultilineInput({
259267
} catch {
260268
// ignore property introspection errors
261269
}
262-
logger.info('[input-debug] keypress', {
263-
...snapshot,
264-
})
265270
}
266271

267272
const shouldInsertNewline = isShiftEnter || isOptionEnter || isCtrlJ
@@ -292,10 +297,34 @@ export function MultilineInput({
292297
// Ctrl+U: Delete to line start (also triggered by Cmd+Delete on macOS)
293298
if (key.ctrl && lowerKeyName === 'u' && !key.meta && !key.option) {
294299
if ('preventDefault' in key) (key as any).preventDefault()
295-
const newValue =
296-
value.slice(0, lineStart) + value.slice(cursorPosition)
300+
301+
const originalValue = value
302+
let newValue = originalValue
303+
let nextCursor = cursorPosition
304+
305+
if (cursorPosition > lineStart) {
306+
newValue = value.slice(0, lineStart) + value.slice(cursorPosition)
307+
nextCursor = lineStart
308+
} else if (
309+
cursorPosition === lineStart &&
310+
cursorPosition > 0 &&
311+
value[cursorPosition - 1] === '\n'
312+
) {
313+
newValue =
314+
value.slice(0, cursorPosition - 1) + value.slice(cursorPosition)
315+
nextCursor = cursorPosition - 1
316+
} else if (cursorPosition > 0) {
317+
newValue =
318+
value.slice(0, cursorPosition - 1) + value.slice(cursorPosition)
319+
nextCursor = cursorPosition - 1
320+
}
321+
322+
if (newValue === originalValue) {
323+
return
324+
}
325+
297326
onChange(newValue)
298-
setCursorPosition(lineStart)
327+
setCursorPosition(Math.max(0, nextCursor))
299328
return
300329
}
301330

@@ -310,12 +339,40 @@ export function MultilineInput({
310339
onChange(newValue)
311340
setCursorPosition(wordStart)
312341
return
313-
} // Cmd+Delete: Delete everything before cursor
342+
} // Cmd+Delete: Delete to line start; fallback to single delete if nothing changes
314343
if (key.name === 'delete' && key.meta && !isAltLikeModifier) {
315344
if ('preventDefault' in key) (key as any).preventDefault()
316-
const newValue = value.slice(cursorPosition)
345+
346+
const originalValue = value
347+
let newValue = originalValue
348+
let nextCursor = cursorPosition
349+
350+
if (cursorPosition > 0) {
351+
if (
352+
cursorPosition === lineStart &&
353+
value[cursorPosition - 1] === '\n'
354+
) {
355+
newValue =
356+
value.slice(0, cursorPosition - 1) + value.slice(cursorPosition)
357+
nextCursor = cursorPosition - 1
358+
} else {
359+
newValue = value.slice(0, lineStart) + value.slice(cursorPosition)
360+
nextCursor = lineStart
361+
}
362+
}
363+
364+
if (newValue === originalValue && cursorPosition > 0) {
365+
newValue =
366+
value.slice(0, cursorPosition - 1) + value.slice(cursorPosition)
367+
nextCursor = cursorPosition - 1
368+
}
369+
370+
if (newValue === originalValue) {
371+
return
372+
}
373+
317374
onChange(newValue)
318-
setCursorPosition(0)
375+
setCursorPosition(Math.max(0, nextCursor))
319376
return
320377
} // Alt+Delete: Delete word forward
321378
if (key.name === 'delete' && isAltLikeModifier) {
@@ -500,8 +557,16 @@ export function MultilineInput({
500557
const beforeCursor = showCursor ? displayValue.slice(0, cursorPosition) : ''
501558
const afterCursor = showCursor ? displayValue.slice(cursorPosition) : ''
502559
const activeChar = afterCursor.charAt(0) || ' '
503-
const highlightBg = mixColors(theme.cursor, isPlaceholder ? theme.inputBg : theme.inputFocusedBg, 0.4)
504-
const shouldHighlight = showCursor && !isPlaceholder && cursorPosition > 0 && cursorPosition < displayValue.length
560+
const highlightBg = mixColors(
561+
theme.cursor,
562+
isPlaceholder ? theme.inputBg : theme.inputFocusedBg,
563+
0.4,
564+
)
565+
const shouldHighlight =
566+
showCursor &&
567+
!isPlaceholder &&
568+
cursorPosition > 0 &&
569+
cursorPosition < displayValue.length
505570

506571
const height = useMemo(() => {
507572
const maxCharsPerLine = Math.max(1, width - 4)
@@ -521,7 +586,14 @@ export function MultilineInput({
521586
}
522587
}
523588
return Math.max(1, Math.min(totalLineCount, maxHeight))
524-
}, [displayValue, cursorPosition, showCursor, shouldHighlight, width, maxHeight])
589+
}, [
590+
displayValue,
591+
cursorPosition,
592+
showCursor,
593+
shouldHighlight,
594+
width,
595+
maxHeight,
596+
])
525597

526598
return (
527599
<scrollbox

cli/src/hooks/use-opentui-paste.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useAppContext } from '@opentui/react'
2+
import { useEffect, useRef } from 'react'
3+
4+
import type { PasteEvent } from '@opentui/core'
5+
6+
type PasteHandler = (event: PasteEvent) => void
7+
8+
/**
9+
* Subscribe to the OpenTUI key handler paste events.
10+
* Allows React components to react to bracketed paste sequences.
11+
*/
12+
export const useOpentuiPaste = (handler: PasteHandler | null | undefined) => {
13+
const { keyHandler } = useAppContext()
14+
const handlerRef = useRef<PasteHandler | null | undefined>(handler)
15+
16+
useEffect(() => {
17+
handlerRef.current = handler
18+
}, [handler])
19+
20+
useEffect(() => {
21+
if (!keyHandler) return
22+
23+
const listener = (event: PasteEvent) => {
24+
handlerRef.current?.(event)
25+
}
26+
27+
keyHandler.on('paste', listener)
28+
29+
return () => {
30+
keyHandler.off('paste', listener)
31+
}
32+
}, [keyHandler])
33+
}

cli/src/hooks/use-send-message.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ const hiddenToolNames = new Set<ToolName | 'spawn_agent_inline'>([
2525
'spawn_agents',
2626
])
2727

28+
const yieldToEventLoop = () =>
29+
new Promise<void>((resolve) => {
30+
setTimeout(resolve, 0)
31+
})
32+
2833
// Helper function to recursively update blocks
2934
const updateBlocksRecursively = (
3035
blocks: ContentBlock[],
@@ -272,6 +277,8 @@ export const useSendMessage = ({
272277
}
273278
return newMessages
274279
})
280+
await yieldToEventLoop()
281+
275282
setFocusedAgentId(null)
276283
setInputFocused(true)
277284
inputRef.current?.focus()

cli/src/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#!/usr/bin/env node
2+
import './polyfills/bun-strip-ansi'
23
import { render } from '@opentui/react'
34
import React from 'react'
45

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { stripAnsi } from '@codebuff/common/util/string'
2+
3+
// Bun 1.2 removed Bun.stripANSI; provide a fallback for libraries that still call it.
4+
const bunGlobal = globalThis as typeof globalThis & {
5+
Bun?: {
6+
stripANSI?: (input: string) => string
7+
}
8+
}
9+
10+
if (bunGlobal.Bun && typeof bunGlobal.Bun.stripANSI !== 'function') {
11+
bunGlobal.Bun.stripANSI = stripAnsi
12+
}

0 commit comments

Comments
 (0)