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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like Ctrl+V still won't paste into the command input.

In Bubbles v2.1.0, the result of the clipboard read is returned as the unexported textinput.pasteMsg, rather than tea.PasteMsg.

Since this branch only forwards tea.KeyPressMsg and tea.PasteMsg to commandInput.Update, textinput.pasteMsg falls through to the current view and never reaches the command input.

One concrete way to address this would be to add a clipboard-read tea.Cmd under internal/clipboard that returns a public tea.PasteMsg on success, and intercept Ctrl+V in CommandInput.Update before delegating to textInput.Update. This would reuse the existing handleCommandModeMsg branch without depending on Bubbles' unexported message type.

It would also be helpful to add a test that executes the command returned by Ctrl+V and feeds its result back into App.Update.

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
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))
}
}
23 changes: 18 additions & 5 deletions internal/view/log_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,11 @@ 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.
if v.filterActive {
return v.updateFilterInput(msg)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mouse-wheel scrolling no longer works while the filter is active.

tea.MouseWheelMsg also reaches this branch and returns through filterInput.Update before it can reach the viewport. Before this change, mouse-wheel messages were passed to v.vp.Model.Update.

Could you route mouse-wheel messages to the viewport before this fallback? A regression test for scrolling while the filter is active would also be helpful.

}

if v.vp.Ready {
var cmd tea.Cmd
v.vp.Model, cmd = v.vp.Model.Update(msg)
Expand Down Expand Up @@ -438,17 +443,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
19 changes: 19 additions & 0 deletions internal/view/multi_selector.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,25 @@ func (m *MultiSelector[T]) HandleUpdate(msg tea.Msg) (tea.Cmd, SelectorKeyResult
}
}

// Route paste and other textinput-bound messages (tea.PasteMsg from
// bracketed paste, the textinput's internal clipboard-read results) to
// the active filter, or pasting into the filter is silently dropped.
if m.filterActive {
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
if value := m.filterInput.Value(); value != m.filterText {
m.filterText = value
m.applyFilter()
m.clampCursor()
m.updateViewport()
return cmd, KeyHandled
}
if cmd != nil {
return cmd, KeyHandled
}
return nil, KeyNotHandled
}

var cmd tea.Cmd
m.vp.Model, cmd = m.vp.Model.Update(msg)
if cmd != nil {
Expand Down
6 changes: 6 additions & 0 deletions internal/view/resource_browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,12 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if model, cmd := r.handleMouseClickMsg(msg); cmd != nil {
return model, cmd
}

default:
// Route paste and other textinput-bound messages to the active filter.
if r.filterActive {
return r.updateFilterInput(msg)
}
}

// Check if we should load more pages (infinite scroll)
Expand Down
18 changes: 14 additions & 4 deletions internal/view/resource_browser_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,23 @@ func (r *ResourceBrowser) handleFilterInput(msg tea.KeyPressMsg) (tea.Model, tea
r.buildTable()
return r, nil
default:
var cmd tea.Cmd
r.filterInput, cmd = r.filterInput.Update(msg)
r.filterText = r.filterInput.Value()
return r.updateFilterInput(msg)
}
}

// updateFilterInput forwards msg to the filter text input and re-applies the
// filter 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 (r *ResourceBrowser) updateFilterInput(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
r.filterInput, cmd = r.filterInput.Update(msg)
if value := r.filterInput.Value(); value != r.filterText {
r.filterText = value
r.applyFilter()
r.buildTable()
return r, cmd
}
return r, cmd
}

func (r *ResourceBrowser) handleRefresh() (tea.Model, tea.Cmd) {
Expand Down
27 changes: 22 additions & 5 deletions internal/view/service_browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,12 @@ func (s *ServiceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s.selectCurrentService()
}
}

default:
// Route paste and other textinput-bound messages to the active filter.
if s.filterActive {
return s.updateFilterInput(msg)
}
}

return s, nil
Expand Down Expand Up @@ -263,13 +269,24 @@ func (s *ServiceBrowser) handleFilterInput(msg tea.KeyPressMsg) (tea.Model, tea.
return s, nil
}

return s.updateFilterInput(msg)
}

// updateFilterInput forwards msg to the filter text input and re-applies the
// filter 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 (s *ServiceBrowser) updateFilterInput(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
s.filterInput, cmd = s.filterInput.Update(msg)
s.filterText = s.filterInput.Value()
s.rebuildFlatItems()
s.cursor = 0
s.updateViewport()
return s, tea.Batch(cmd, tea.ClearScreen)
if value := s.filterInput.Value(); value != s.filterText {
s.filterText = value
s.rebuildFlatItems()
s.cursor = 0
s.updateViewport()
return s, tea.Batch(cmd, tea.ClearScreen)
}
return s, cmd
}

func (s *ServiceBrowser) handleNavigation(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
Expand Down
24 changes: 20 additions & 4 deletions internal/view/tag_search_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,12 @@ func (v *TagSearchView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return v.handleFilterKey(msg)
}
return v.handleKeyPress(msg)

default:
// Route paste and other textinput-bound messages to the active filter.
if v.filterActive {
return v.updateFilterInput(msg)
}
}

return v, nil
Expand Down Expand Up @@ -377,13 +383,23 @@ func (v *TagSearchView) handleFilterKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd
v.buildTable()
return v, nil
default:
var cmd tea.Cmd
v.filterInput, cmd = v.filterInput.Update(msg)
v.filterText = v.filterInput.Value()
return v.updateFilterInput(msg)
}
}

// updateFilterInput forwards msg to the filter text input and re-applies the
// filter 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 *TagSearchView) 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
v.applyFilter()
v.buildTable()
return v, cmd
}
return v, cmd
}

func (v *TagSearchView) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
Expand Down