Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/keybindings.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ claws で使用できるすべてのキーボードショートカットのリ
| `:pulse` | ダッシュボードに移動します |
| `:services` | サービスブラウザに移動します |
| `/` | フィルターモード(あいまい検索) |
| `Ctrl+v` / ターミナルの貼り付け | クリップボードをフィルター/コマンド入力に貼り付け |
| `A` | AIチャット(Bedrock) |
| `Ctrl+E` | コンパクトヘッダーを切り替えます |
| `?` | ヘルプを表示します |
Expand Down
1 change: 1 addition & 0 deletions docs/keybindings.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ claws의 모든 키보드 단축키에 대한 전체 참조입니다.
| `:pulse` | 대시보드로 이동 |
| `:services` | 서비스 브라우저로 이동 |
| `/` | 필터 모드 (퍼지 검색) |
| `Ctrl+v` / 터미널 붙여넣기 | 클립보드를 필터/명령 입력에 붙여넣기 |
| `A` | AI 채팅 (Bedrock) |
| `Ctrl+E` | 컴팩트 헤더 전환 |
| `?` | 도움말 표시 |
Expand Down
1 change: 1 addition & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Complete reference for all keyboard shortcuts in claws.
| `:pulse` | Go to dashboard |
| `:services` | Go to service browser |
| `/` | Filter mode (fuzzy search) |
| `Ctrl+v` / terminal paste | Paste clipboard into filter/command input |
| `A` | AI Chat (Bedrock) |
| `Ctrl+E` | Toggle compact header |
| `?` | Show help |
Expand Down
1 change: 1 addition & 0 deletions docs/keybindings.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ claws 所有键盘快捷键的完整参考。
| `:pulse` | 前往仪表盘 |
| `:services` | 前往服务浏览器 |
| `/` | 筛选模式(模糊搜索) |
| `Ctrl+v` / 终端粘贴 | 将剪贴板内容粘贴到筛选/命令输入框 |
| `A` | AI 对话(Bedrock) |
| `Ctrl+E` | 切换紧凑标题栏 |
| `?` | 显示帮助 |
Expand Down
7 changes: 4 additions & 3 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,11 +288,12 @@ func (a *App) handleCommandModeMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) {
if !a.commandMode {
return nil, nil, false
}
keyMsg, ok := msg.(tea.KeyPressMsg)
if !ok {
switch msg.(type) {
case tea.KeyPressMsg, tea.PasteMsg:
Comment thread
nick4eva marked this conversation as resolved.
default:
return nil, nil, false
}
cmd, nav := a.commandInput.Update(keyMsg)
cmd, nav := a.commandInput.Update(msg)
if !a.commandInput.IsActive() {
a.commandMode = false
}
Expand Down
29 changes: 29 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,35 @@ func TestCommandModeActivation(t *testing.T) {
}
}

func TestCommandModePaste(t *testing.T) {
app := newTestApp(t)
app.currentView = &MockView{name: "Dashboard"}

app.Update(tea.KeyPressMsg{Code: 0, Text: ":"})
if !app.commandMode {
t.Fatal("Expected commandMode=true after ':' key")
}

// Ctrl+V is intercepted by the command input, which replies with the
// clipboard-read command instead of bubbles' unexported paste message.
_, cmd := app.Update(tea.KeyPressMsg{Code: 'v', Mod: tea.ModCtrl})
if cmd == nil {
t.Fatal("Expected ctrl+v in command mode to return the clipboard-read command")
}

// On success that command yields a tea.PasteMsg with the clipboard
// content (covered by internal/clipboard tests); feed the result back
// through App.Update and verify it reaches the command input.
app.Update(tea.PasteMsg{Content: "ec2/instances"})

if got := app.commandInput.Value(); got != "ec2/instances" {
t.Errorf("Command input value = %q, want %q", got, "ec2/instances")
}
if !app.commandMode {
t.Error("Expected command mode to stay active after paste")
}
}

func TestUnhandledKeyDelegatesToCurrentView(t *testing.T) {
app := newTestApp(t)
dashboard := &MockView{name: "Dashboard"}
Expand Down
18 changes: 18 additions & 0 deletions internal/clipboard/clipboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,26 @@ type CopyFailedMsg struct {
var (
terminalClipboardWriter io.StringWriter = os.Stdout
nativeClipboardWrite = clipboard.WriteAll
nativeClipboardRead = clipboard.ReadAll
)

// Paste reads the system clipboard and delivers its content as a tea.PasteMsg,
// the same message bracketed paste produces, so it can be routed to any focused
// text input. Bubbles' built-in ctrl+v returns the clipboard read as an
// unexported message type that message routing outside the input's own view
// cannot forward; this command exists so callers get a public type instead.
// The returned command is a no-op when the clipboard is unavailable.
func Paste() tea.Cmd {
return func() tea.Msg {
value, err := nativeClipboardRead()
if err != nil {
log.Debug("native clipboard read failed", "error", err)
return nil
}
return tea.PasteMsg{Content: value}
}
}

// Copy copies the given value to the clipboard and returns a tea.Cmd that sends a CopiedMsg.
// It writes to both OSC52 (terminal clipboard) and native system clipboard for maximum compatibility.
func Copy(label, value string) tea.Cmd {
Expand Down
38 changes: 38 additions & 0 deletions internal/clipboard/clipboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,19 @@ import (
"errors"
"strings"
"testing"

tea "charm.land/bubbletea/v2"
)

func withClipboardReader(t *testing.T, nativeReader func() (string, error)) {
t.Helper()
originalNativeRead := nativeClipboardRead
nativeClipboardRead = nativeReader
t.Cleanup(func() {
nativeClipboardRead = originalNativeRead
})
}

type failingStringWriter struct{}

func (failingStringWriter) WriteString(string) (int, error) {
Expand Down Expand Up @@ -130,6 +141,33 @@ func TestCopyARN(t *testing.T) {
}
}

func TestPasteReturnsPasteMsg(t *testing.T) {
withClipboardReader(t, func() (string, error) { return "i-1234567890abcdef0", nil })

cmd := Paste()
if cmd == nil {
t.Fatal("Paste should return a non-nil command")
}

msg := cmd()
pasteMsg, ok := msg.(tea.PasteMsg)
if !ok {
t.Fatalf("expected tea.PasteMsg, got %T", msg)
}
if pasteMsg.Content != "i-1234567890abcdef0" {
t.Errorf("expected Content 'i-1234567890abcdef0', got %q", pasteMsg.Content)
}
}

func TestPasteReturnsNilWhenClipboardUnavailable(t *testing.T) {
withClipboardReader(t, func() (string, error) { return "", errors.New("native clipboard unavailable") })

msg := Paste()()
if msg != nil {
t.Fatalf("expected nil msg when clipboard read fails, got %T", msg)
}
}

func TestNoARN(t *testing.T) {
cmd := NoARN()
if cmd == nil {
Expand Down
13 changes: 13 additions & 0 deletions internal/view/command_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"charm.land/lipgloss/v2"

"github.com/clawscli/claws/internal/action"
"github.com/clawscli/claws/internal/clipboard"
"github.com/clawscli/claws/internal/config"
navmsg "github.com/clawscli/claws/internal/msg"
"github.com/clawscli/claws/internal/registry"
Expand Down Expand Up @@ -117,6 +118,11 @@ func (c *CommandInput) IsActive() bool {
return c.active
}

// Value returns the current input text.
func (c *CommandInput) Value() string {
return c.textInput.Value()
}

// Update handles input updates
func (c *CommandInput) Update(msg tea.Msg) (tea.Cmd, *NavigateMsg) {
switch msg := msg.(type) {
Expand All @@ -126,6 +132,13 @@ func (c *CommandInput) Update(msg tea.Msg) (tea.Cmd, *NavigateMsg) {
c.Deactivate()
return nil, nil

case "ctrl+v":
// Intercept before textInput.Update: its built-in paste replies
// with an unexported message type that command-mode routing in
// the app cannot forward back here. clipboard.Paste re-enters
// as a tea.PasteMsg, which routes to this input like any key.
return clipboard.Paste(), nil

case "enter":
cmd, nav := c.executeCommand()
c.Deactivate()
Expand Down
151 changes: 151 additions & 0 deletions internal/view/filter_paste_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package view

import (
"context"
"testing"
"time"

tea "charm.land/bubbletea/v2"

"github.com/clawscli/claws/internal/dao"
"github.com/clawscli/claws/internal/registry"
)

func TestResourceBrowserFilterPaste(t *testing.T) {
ctx := context.Background()
reg := registry.New()

browser := NewResourceBrowser(ctx, reg, "ec2")
browser.SetSize(100, 50)
browser.renderer = &mockRenderer{detail: "test"}
browser.resources = []dao.Resource{
&mockResource{id: "i-0123456789abcdef0", name: "web-server"},
&mockResource{id: "i-0fedcba9876543210", name: "db-server"},
}
browser.applyFilter()
browser.buildTable()

// Paste while the filter is inactive should be ignored
browser.Update(tea.PasteMsg{Content: "i-0123456789abcdef0"})
if browser.filterText != "" {
t.Fatalf("Expected paste to be ignored when filter is inactive, got filterText %q", browser.filterText)
}

browser.filterActive = true
browser.filterInput.Focus()

// Bracketed paste (Ctrl+Shift+V / tmux paste) arrives as tea.PasteMsg
browser.Update(tea.PasteMsg{Content: "i-0123456789abcdef0"})

if browser.filterText != "i-0123456789abcdef0" {
t.Errorf("filterText = %q, want %q", browser.filterText, "i-0123456789abcdef0")
}
if len(browser.filtered) != 1 || browser.filtered[0].GetID() != "i-0123456789abcdef0" {
t.Errorf("Expected filter to narrow to the pasted instance ID, got %d resources", len(browser.filtered))
}

// Ctrl+V must produce the textinput's clipboard-read command
_, cmd := browser.Update(tea.KeyPressMsg{Code: 'v', Mod: tea.ModCtrl})
if cmd == nil {
t.Error("Expected ctrl+v to return the clipboard paste command")
}
}

func TestServiceBrowserFilterPaste(t *testing.T) {
ctx := context.Background()
reg := registry.New()
reg.RegisterCustom("ec2", "instances", registry.Entry{})
reg.RegisterCustom("s3", "buckets", registry.Entry{})

browser := NewServiceBrowser(ctx, reg)
browser.Update(browser.Init()())

browser.filterActive = true
browser.filterInput.Focus()

browser.Update(tea.PasteMsg{Content: "ec2"})

if browser.filterText != "ec2" {
t.Errorf("filterText = %q, want %q", browser.filterText, "ec2")
}
if len(browser.flatItems) != 1 {
t.Errorf("Expected 1 service after paste, got %d", len(browser.flatItems))
}
}

func TestLogViewFilterPaste(t *testing.T) {
ctx := context.Background()
lv := NewLogView(ctx, "/aws/test")
lv.SetSize(80, 24)
lv.loading = false
lv.logs = []logEntry{
{timestamp: time.Now(), message: "error in handler"},
{timestamp: time.Now(), message: "request completed"},
}

lv.filterActive = true
lv.filterInput.Focus()

lv.Update(tea.PasteMsg{Content: "error"})

if lv.filterText != "error" {
t.Errorf("filterText = %q, want %q", lv.filterText, "error")
}
}

func TestTagSearchViewFilterPaste(t *testing.T) {
ctx := context.Background()
reg := registry.New()

v := NewTagSearchView(ctx, reg, "")
v.SetSize(100, 50)
v.resources = []taggedARN{
{RawARN: "arn:aws:ec2:us-east-1:123456789012:instance/i-0123456789abcdef0"},
{RawARN: "arn:aws:s3:::my-bucket"},
}
v.applyFilter()
v.buildTable()

v.filterActive = true
v.filterInput.Focus()

v.Update(tea.PasteMsg{Content: "i-0123456789abcdef0"})

if v.filterText != "i-0123456789abcdef0" {
t.Errorf("filterText = %q, want %q", v.filterText, "i-0123456789abcdef0")
}
if len(v.filtered) != 1 {
t.Errorf("Expected 1 resource after paste, got %d", len(v.filtered))
}
}

type selectorMockItem struct {
id string
label string
}

func (s selectorMockItem) GetID() string { return s.id }
func (s selectorMockItem) GetLabel() string { return s.label }

func TestMultiSelectorFilterPaste(t *testing.T) {
m := NewMultiSelector[selectorMockItem]("Select", nil)
m.SetItems([]selectorMockItem{
{id: "i-1", label: "web-server"},
{id: "i-2", label: "db-server"},
})

m.filterActive = true
m.filterInput.Focus()

_, result := m.HandleUpdate(tea.PasteMsg{Content: "db"})

if result != KeyHandled {
t.Errorf("Expected paste to be handled, got %v", result)
}
if m.filterText != "db" {
t.Errorf("filterText = %q, want %q", m.filterText, "db")
}
if len(m.filtered) != 1 || m.filtered[0].GetID() != "i-2" {
t.Errorf("Expected filter to narrow to the pasted text, got %d items", len(m.filtered))
}
}
25 changes: 20 additions & 5 deletions internal/view/log_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,13 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return v, nil
}

// Route paste and other textinput-bound messages to the active filter.
// Mouse events skip the filter so the viewport keeps scrolling while
// the filter is open.
if _, isMouse := msg.(tea.MouseMsg); v.filterActive && !isMouse {
return v.updateFilterInput(msg)
Comment thread
nick4eva marked this conversation as resolved.
}

if v.vp.Ready {
var cmd tea.Cmd
v.vp.Model, cmd = v.vp.Model.Update(msg)
Expand Down Expand Up @@ -438,17 +445,25 @@ func (v *LogView) handleFilterInput(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
return v, nil
default:
var cmd tea.Cmd
v.filterInput, cmd = v.filterInput.Update(msg)
return v.updateFilterInput(msg)
}
}

// Apply filter in real-time as user types
v.filterText = v.filterInput.Value()
// updateFilterInput forwards msg to the filter text input and re-applies the
// filter in real-time when the input value changed. Besides key presses this
// must receive tea.PasteMsg (bracketed paste) and the textinput's internal
// clipboard-read results, or pasting into the filter is silently dropped.
func (v *LogView) updateFilterInput(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
v.filterInput, cmd = v.filterInput.Update(msg)
if value := v.filterInput.Value(); value != v.filterText {
v.filterText = value
if v.vp.Ready {
v.updateViewportContent()
}

return v, tea.Batch(cmd, tea.ClearScreen)
}
return v, cmd
}

func (v *LogView) ViewString() string {
Expand Down
Loading