Skip to content

Commit 4c02389

Browse files
fix: Add copy/paste functionality to chat input field (#30)
Fixes #29 This PR adds copy/paste functionality to the chat input field: - **Ctrl+C**: Copies input text to clipboard (when text exists) - **Ctrl+V**: Pastes clipboard content to input field - **Ctrl+X**: Cuts input text (copy and clear) - **Ctrl+W**: Delete word - Smart Ctrl+C behavior: copy when input has text, quit when empty - Added cross-platform clipboard support using `github.com/atotto/clipboard` - Refactored input handling code for better maintainability Generated with [Claude Code](https://claude.ai/code) --------- Signed-off-by: Eden Reich <[email protected]> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Eden Reich <[email protected]>
1 parent 364121c commit 4c02389

File tree

5 files changed

+154
-52
lines changed

5 files changed

+154
-52
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/inference-gateway/cli
33
go 1.24.5
44

55
require (
6+
github.com/atotto/clipboard v0.1.4
67
github.com/charmbracelet/bubbles v0.21.0
78
github.com/charmbracelet/bubbletea v1.3.6
89
github.com/charmbracelet/lipgloss v1.1.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
2+
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
13
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
24
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
35
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=

internal/app/chat_application.go

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,14 @@ func (app *ChatApplication) handleChatView(msg tea.Msg) []tea.Cmd {
162162
return cmds
163163
}
164164

165+
key := keyMsg.String()
166+
if key == "ctrl+v" || key == "alt+v" || key == "ctrl+x" || key == "ctrl+shift+c" {
167+
if cmd := app.handleFocusedComponentKeys(keyMsg); cmd != nil {
168+
cmds = append(cmds, cmd)
169+
}
170+
return cmds
171+
}
172+
165173
if cmd := app.handleGlobalKeys(keyMsg); cmd != nil {
166174
cmds = append(cmds, cmd)
167175
}
@@ -256,11 +264,11 @@ func (app *ChatApplication) renderChatInterface() string {
256264
statusContent := app.statusView.Render()
257265
if statusContent != "" {
258266
b.WriteString(statusContent)
259-
b.WriteString("\n")
267+
b.WriteString("\n\n")
260268
}
261269

262270
b.WriteString(app.inputView.Render())
263-
b.WriteString("\n")
271+
b.WriteString("\n\n")
264272

265273
b.WriteString(app.renderHelpText())
266274

@@ -414,7 +422,6 @@ func (app *ChatApplication) renderApproval() string {
414422
options := []string{
415423
"✅ Approve and execute",
416424
"❌ Deny and cancel",
417-
"👁️ View full response",
418425
}
419426

420427
b.WriteString("Please select an action:\n\n")
@@ -453,7 +460,7 @@ func (app *ChatApplication) renderHelp() string {
453460

454461
func (app *ChatApplication) renderHelpText() string {
455462
theme := app.services.GetTheme()
456-
helpText := "Press Ctrl+D to send message, Ctrl+C to exit • Type @ for files, / for commands"
463+
helpText := "Press Ctrl+D to send message, Ctrl+C to exit, Ctrl+Shift+C to copy, Ctrl+V to paste • Type @ for files, / for commands"
457464
return theme.GetDimColor() + helpText + "\033[0m"
458465
}
459466

@@ -669,7 +676,7 @@ func (app *ChatApplication) handleApprovalKeys(keyMsg tea.KeyMsg) tea.Cmd {
669676
return nil
670677

671678
case "down", "j":
672-
if selectedIndex < int(domain.ApprovalView) {
679+
if selectedIndex < int(domain.ApprovalReject) {
673680
selectedIndex++
674681
}
675682
app.state.Data["approvalSelectedIndex"] = selectedIndex
@@ -681,16 +688,6 @@ func (app *ChatApplication) handleApprovalKeys(keyMsg tea.KeyMsg) tea.Cmd {
681688
return app.approveToolCall()
682689
case domain.ApprovalReject:
683690
return app.denyToolCall()
684-
case domain.ApprovalView:
685-
if response, ok := app.state.Data["toolCallResponse"].(string); ok {
686-
return func() tea.Msg {
687-
return ui.ShowErrorMsg{
688-
Error: fmt.Sprintf("Full response: %s", response),
689-
Sticky: true,
690-
}
691-
}
692-
}
693-
return nil
694691
}
695692
return nil
696693

internal/domain/interfaces.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ type ApprovalAction int
2929
const (
3030
ApprovalApprove ApprovalAction = iota // Approve and execute
3131
ApprovalReject // Deny and cancel
32-
ApprovalView // View full response
3332
)
3433

3534
// ConversationRepository handles conversation storage and retrieval

internal/ui/components.go

Lines changed: 139 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"strings"
66
"time"
77

8+
"github.com/atotto/clipboard"
89
"github.com/charmbracelet/bubbles/spinner"
910
"github.com/charmbracelet/bubbletea"
1011
"github.com/charmbracelet/lipgloss"
@@ -208,8 +209,9 @@ func (cv *ConversationViewImpl) renderEntry(entry domain.ConversationEntry) stri
208209

209210
content := entry.Message.Content
210211
resetColor := "\033[0m"
212+
message := fmt.Sprintf("%s%s:%s %s", color, role, resetColor, content)
211213

212-
return fmt.Sprintf("%s%s:%s %s", color, role, resetColor, content)
214+
return message + "\n"
213215
}
214216

215217
func (cv *ConversationViewImpl) GetID() string { return "conversation" }
@@ -304,7 +306,6 @@ func (iv *InputViewImpl) Render() string {
304306
result.WriteString(borderedInput)
305307
result.WriteString("\n")
306308

307-
// Add autocomplete suggestions if visible
308309
if iv.autocomplete.IsVisible() {
309310
autocompleteContent := iv.autocomplete.Render()
310311
if autocompleteContent != "" {
@@ -344,20 +345,28 @@ func (iv *InputViewImpl) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
344345
}
345346

346347
func (iv *InputViewImpl) HandleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
347-
// First, check if autocomplete should handle the key
348348
if handled, completion := iv.autocomplete.HandleKey(key); handled {
349-
if completion != "" {
350-
// Replace the current input with the selected completion
351-
iv.text = completion
352-
iv.cursor = len(completion)
353-
iv.autocomplete.Hide()
354-
}
355-
// Update autocomplete state after any changes
356-
iv.autocomplete.Update(iv.text, iv.cursor)
357-
return iv, nil
349+
return iv.handleAutocomplete(completion)
350+
}
351+
352+
cmd := iv.handleSpecificKeys(key)
353+
iv.autocomplete.Update(iv.text, iv.cursor)
354+
return iv, cmd
355+
}
356+
357+
func (iv *InputViewImpl) handleAutocomplete(completion string) (tea.Model, tea.Cmd) {
358+
if completion != "" {
359+
iv.text = completion
360+
iv.cursor = len(completion)
361+
iv.autocomplete.Hide()
358362
}
363+
iv.autocomplete.Update(iv.text, iv.cursor)
364+
return iv, nil
365+
}
359366

360-
switch key.String() {
367+
func (iv *InputViewImpl) handleSpecificKeys(key tea.KeyMsg) tea.Cmd {
368+
keyStr := key.String()
369+
switch keyStr {
361370
case "left":
362371
if iv.cursor > 0 {
363372
iv.cursor--
@@ -367,43 +376,137 @@ func (iv *InputViewImpl) HandleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
367376
iv.cursor++
368377
}
369378
case "backspace":
370-
if iv.cursor > 0 {
371-
iv.text = iv.text[:iv.cursor-1] + iv.text[iv.cursor:]
372-
iv.cursor--
373-
}
374-
case "ctrl+d":
375-
if iv.text != "" {
376-
input := iv.text
377-
iv.ClearInput()
378-
iv.autocomplete.Hide()
379-
return iv, func() tea.Msg {
380-
return UserInputMsg{Content: input}
379+
if key.Alt {
380+
iv.deleteWordBackward()
381+
} else {
382+
if iv.cursor > 0 {
383+
iv.text = iv.text[:iv.cursor-1] + iv.text[iv.cursor:]
384+
iv.cursor--
381385
}
382386
}
387+
case "ctrl+u":
388+
iv.deleteWordBackward()
389+
case "ctrl+w":
390+
iv.deleteWordBackward()
391+
case "ctrl+d":
392+
return iv.handleSubmit()
393+
case "ctrl+shift+c":
394+
iv.handleCopy()
395+
case "ctrl+v", "alt+v":
396+
iv.handlePaste()
397+
case "ctrl+x":
398+
iv.handleCut()
399+
case "ctrl+a":
400+
iv.cursor = 0
401+
case "ctrl+e":
402+
iv.cursor = len(iv.text)
383403
default:
384-
if len(key.String()) == 1 && key.String()[0] >= 32 {
385-
char := key.String()
386-
iv.text = iv.text[:iv.cursor] + char + iv.text[iv.cursor:]
387-
iv.cursor++
404+
return iv.handleCharacterInput(key)
405+
}
406+
return nil
407+
}
388408

389-
if char == "@" {
390-
return iv, func() tea.Msg {
391-
return FileSelectionRequestMsg{}
392-
}
393-
}
409+
func (iv *InputViewImpl) handleSubmit() tea.Cmd {
410+
if iv.text != "" {
411+
input := iv.text
412+
iv.ClearInput()
413+
iv.autocomplete.Hide()
414+
return func() tea.Msg {
415+
return UserInputMsg{Content: input}
394416
}
395417
}
418+
return nil
419+
}
396420

397-
// Update autocomplete after any text changes
398-
iv.autocomplete.Update(iv.text, iv.cursor)
421+
func (iv *InputViewImpl) handleCopy() {
422+
if iv.text != "" {
423+
_ = clipboard.WriteAll(iv.text) // Ignore error for clipboard operations
424+
}
425+
}
399426

400-
return iv, nil
427+
func (iv *InputViewImpl) handlePaste() {
428+
clipboardText, err := clipboard.ReadAll()
429+
if err != nil {
430+
return
431+
}
432+
433+
if clipboardText == "" {
434+
return
435+
}
436+
437+
cleanText := strings.ReplaceAll(clipboardText, "\n", " ")
438+
cleanText = strings.ReplaceAll(cleanText, "\r", " ")
439+
cleanText = strings.ReplaceAll(cleanText, "\t", " ")
440+
441+
if cleanText != "" {
442+
iv.text = iv.text[:iv.cursor] + cleanText + iv.text[iv.cursor:]
443+
iv.cursor += len(cleanText)
444+
}
445+
}
446+
447+
func (iv *InputViewImpl) handleCut() {
448+
if iv.text != "" {
449+
_ = clipboard.WriteAll(iv.text)
450+
iv.text = ""
451+
iv.cursor = 0
452+
}
453+
}
454+
455+
func (iv *InputViewImpl) handleCharacterInput(key tea.KeyMsg) tea.Cmd {
456+
keyStr := key.String()
457+
458+
if len(keyStr) > 1 && key.Type == tea.KeyRunes {
459+
cleanText := strings.ReplaceAll(keyStr, "\n", " ")
460+
cleanText = strings.ReplaceAll(cleanText, "\r", " ")
461+
cleanText = strings.ReplaceAll(cleanText, "\t", " ")
462+
463+
if strings.HasPrefix(cleanText, "[") && strings.HasSuffix(cleanText, "]") {
464+
cleanText = cleanText[1 : len(cleanText)-1]
465+
}
466+
467+
if cleanText != "" {
468+
iv.text = iv.text[:iv.cursor] + cleanText + iv.text[iv.cursor:]
469+
iv.cursor += len(cleanText)
470+
}
471+
return nil
472+
}
473+
474+
if len(keyStr) == 1 && keyStr[0] >= 32 {
475+
char := keyStr
476+
iv.text = iv.text[:iv.cursor] + char + iv.text[iv.cursor:]
477+
iv.cursor++
478+
479+
if char == "@" {
480+
return func() tea.Msg {
481+
return FileSelectionRequestMsg{}
482+
}
483+
}
484+
}
485+
return nil
401486
}
402487

403488
func (iv *InputViewImpl) CanHandle(key tea.KeyMsg) bool {
404489
return true
405490
}
406491

492+
// deleteWordBackward deletes the word before the cursor
493+
func (iv *InputViewImpl) deleteWordBackward() {
494+
if iv.cursor > 0 {
495+
start := iv.cursor
496+
497+
for start > 0 && (iv.text[start-1] == ' ' || iv.text[start-1] == '\t') {
498+
start--
499+
}
500+
501+
for start > 0 && iv.text[start-1] != ' ' && iv.text[start-1] != '\t' {
502+
start--
503+
}
504+
505+
iv.text = iv.text[:start] + iv.text[iv.cursor:]
506+
iv.cursor = start
507+
}
508+
}
509+
407510
// StatusViewImpl implements StatusComponent
408511
type StatusViewImpl struct {
409512
message string

0 commit comments

Comments
 (0)