From 8d5821988feb3cc67dcd101f20b934f4ec7c84f0 Mon Sep 17 00:00:00 2001 From: Michael Sprauer Date: Wed, 23 Sep 2026 11:24:17 +0200 Subject: [PATCH 1/2] fix(tui): paste support and stray-t placeholder fix Two bugs in the search-as-you-type bar: 1. Paste (CMD+V / bracketed paste): Model.Update never handled tea.PasteMsg (the event terminals emit on CMD+V when bracketed-paste mode is active), so the message fell through to return m, nil and was silently dropped. Fix: add an explicit case tea.PasteMsg that forwards to m.input.Update and re-filters the list. 2. Stray 't' when search expression is empty: bubbles/v2 placeholderView() renders the first rune of the placeholder ('t' from "type to filter...") as a virtualCursor character. In terminals that fall back to printing the cursor char as text this appeared as a literal 't' prefix on the input line. Fix: clear the textinput Placeholder and instead append a dim "type to filter..." hint *after* m.input.View() only when the query is empty, rendering it as plain styled text. Both fixes apply to Model (main switcher TUI) and listModel (RunList). 7 new unit tests cover both bugs and key edge-cases. Signed-off-by: Michael Sprauer --- pkg/tui/list.go | 9 +- pkg/tui/model.go | 19 +++- pkg/tui/model_test.go | 204 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 pkg/tui/model_test.go diff --git a/pkg/tui/list.go b/pkg/tui/list.go index 2a27cffe..043cba39 100644 --- a/pkg/tui/list.go +++ b/pkg/tui/list.go @@ -51,7 +51,7 @@ type listModel struct { func newListModel(items []string) listModel { ti := textinput.New() - ti.Placeholder = "type to filter..." + ti.Placeholder = "" ti.Focus() ti.Prompt = "> " s := ti.Styles() @@ -115,6 +115,7 @@ func (m listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + // Delegate all other messages to textinput (handles ctrl+v paste, bracketed paste, chars) var cmd tea.Cmd m.input, cmd = m.input.Update(msg) if v := m.input.Value(); v != m.query { @@ -152,7 +153,11 @@ func (m listModel) View() tea.View { sep := styleBorder.Render(strings.Repeat("─", m.width)) countStr := styleCount.Render(fmt.Sprintf("%d/%d", len(m.filtered), len(m.items))) - inputLine := m.input.View() + " " + countStr + inputView := m.input.View() + if m.query == "" { + inputView += styleDim.Render("type to filter...") + } + inputLine := inputView + " " + countStr content := strings.Join(rows, "\n") + "\n" + sep + "\n" + inputLine v := tea.NewView(content) diff --git a/pkg/tui/model.go b/pkg/tui/model.go index d4ef89ac..8f760d3d 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -114,7 +114,7 @@ var ( // NewModel creates an initial TUI model. func NewModel(stores map[string]storetypes.KubeconfigStore, showPreview bool) Model { ti := textinput.New() - ti.Placeholder = "type to filter..." + ti.Placeholder = "" ti.Focus() ti.Prompt = "> " s := ti.Styles() @@ -165,6 +165,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m = m.applyExpandResult(msg) return m, m.fetchPreviewCmd() + case tea.PasteMsg: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + newQuery := m.input.Value() + if newQuery != m.query { + m.query = newQuery + m.refilter() + return m, tea.Batch(cmd, m.fetchPreviewCmd()) + } + return m, cmd + case tea.KeyPressMsg: // Navigation keys take priority over textinput switch msg.String() { @@ -365,7 +376,11 @@ func (m Model) renderLeft(width int) string { if m.loading { loadStr = styleLoading.Render(" …") } - inputLine := m.input.View() + " " + countStr + loadStr + inputView := m.input.View() + if m.query == "" { + inputView += styleDim.Render("type to filter...") + } + inputLine := inputView + " " + countStr + loadStr return strings.Join(rows, "\n") + "\n" + sep + "\n" + inputLine } diff --git a/pkg/tui/model_test.go b/pkg/tui/model_test.go new file mode 100644 index 00000000..25d116ff --- /dev/null +++ b/pkg/tui/model_test.go @@ -0,0 +1,204 @@ +// Copyright 2026 The Kswitch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +// newTestModel returns a Model pre-sized so View() does not return a blank screen. +func newTestModel(items ...string) Model { + m := NewModel(nil, false) + m.width = 80 + m.height = 24 + for _, name := range items { + m.allItems = append(m.allItems, item{displayName: name, contextName: name}) + } + m.filtered = filterItems("", m.allItems) + return m +} + +// sendKey is a convenience helper that feeds a synthetic KeyPressMsg into the +// model and returns the updated model. +func sendKey(m Model, keyStr string) Model { + // Build a KeyPressMsg whose String() returns keyStr. + // For printable single-character keys we set Code + Text; for everything + // else (multi-rune names like "ctrl+v") String() looks at Code+Mod but + // the simplest approach is to use a rune code that matches the string. + msg := tea.KeyPressMsg{} + switch keyStr { + case "ctrl+u": + msg.Code = 'u' + msg.Mod = tea.ModCtrl + case "ctrl+v": + msg.Code = 'v' + msg.Mod = tea.ModCtrl + case "ctrl+w": + msg.Code = 'w' + msg.Mod = tea.ModCtrl + case "enter": + msg.Code = tea.KeyEnter + case "esc": + msg.Code = tea.KeyEsc + default: + // single printable character + r := []rune(keyStr) + if len(r) == 1 { + msg.Code = r[0] + msg.Text = keyStr + } + } + updated, _ := m.Update(msg) + return updated.(Model) +} + +// ----- stray-"t" fix: placeholder must not appear in View output ------------ + +// TestNoStrayT_EmptyQuery checks that when the search expression is empty the +// rendered input line does NOT show "t" as the first visible character of the +// cursor/placeholder (the old bug: bubbles rendered the first rune of the +// placeholder text as a cursor character that appeared as a literal "t" in +// some terminals). +func TestNoStrayT_EmptyQuery(t *testing.T) { + m := newTestModel("prod", "staging", "dev") + view := m.renderLeft(80) + + // The input line is the last line of renderLeft's output. + lines := strings.Split(view, "\n") + inputLine := lines[len(lines)-1] + + // The input line should start with the styled prompt ("> "), NOT with "t". + // Strip ANSI escapes for a clean check. + plain := stripANSI(inputLine) + if strings.HasPrefix(plain, "t") { + t.Errorf("input line starts with 't' (stray placeholder char): %q", plain) + } +} + +// TestHintTextAppended verifies that when the query is empty the hint +// "type to filter..." appears in the input line (rendered as dim text, not as +// the textinput placeholder). +func TestHintTextAppended_EmptyQuery(t *testing.T) { + m := newTestModel("prod") + view := m.renderLeft(80) + if !strings.Contains(stripANSI(view), "type to filter...") { + t.Error("expected 'type to filter...' hint in view when query is empty") + } +} + +// TestHintTextGone_NonEmptyQuery verifies the hint disappears once the user +// types something (to avoid cluttering the line with both typed text and the hint). +func TestHintTextGone_NonEmptyQuery(t *testing.T) { + m := newTestModel("prod", "staging") + m = sendKey(m, "p") + view := m.renderLeft(80) + if strings.Contains(stripANSI(view), "type to filter...") { + t.Error("hint 'type to filter...' should not appear after user has typed") + } +} + +// ----- paste fix: tea.PasteMsg must reach the textinput --------------------- + +// TestPasteMsg_UpdatesQuery verifies that a tea.PasteMsg (terminal +// bracketed-paste event, e.g. CMD+V on macOS) inserts its content into the +// search query and triggers re-filtering. +func TestPasteMsg_UpdatesQuery(t *testing.T) { + m := newTestModel("production", "staging", "dev") + + pasteText := "prod" + updated, _ := m.Update(tea.PasteMsg{Content: pasteText}) + m = updated.(Model) + + if m.query != pasteText { + t.Errorf("query after paste: got %q, want %q", m.query, pasteText) + } +} + +// TestPasteMsg_TriggersRefilter verifies that after a bracketed paste the +// filtered list reflects the new query. +func TestPasteMsg_TriggersRefilter(t *testing.T) { + m := newTestModel("production", "staging", "dev") + + updated, _ := m.Update(tea.PasteMsg{Content: "prod"}) + m = updated.(Model) + + for _, it := range m.filtered { + if !strings.Contains(strings.ToLower(it.displayName), "prod") { + // fuzzy match — just ensure "staging" alone (no "prod") is absent + if it.displayName == "staging" { + t.Errorf("'staging' should not match query 'prod', filtered list: %+v", m.filtered) + } + } + } + if len(m.filtered) == 0 { + t.Error("expected at least one match after pasting 'prod'") + } +} + +// TestPasteMsg_EmptyContent is a no-op guard: pasting an empty string must +// not crash and must leave the query unchanged. +func TestPasteMsg_EmptyContent(t *testing.T) { + m := newTestModel("prod") + updated, _ := m.Update(tea.PasteMsg{Content: ""}) + m = updated.(Model) + if m.query != "" { + t.Errorf("empty paste should not change query, got %q", m.query) + } +} + +// TestPasteMsg_AppendsToExistingQuery verifies that pasting while there is +// already typed text appends to it (the textinput handles the concatenation). +func TestPasteMsg_AppendsToExistingQuery(t *testing.T) { + m := newTestModel("production", "staging") + // First type "pro" manually + m = sendKey(m, "p") + m = sendKey(m, "r") + m = sendKey(m, "o") + // Then paste "d" + updated, _ := m.Update(tea.PasteMsg{Content: "d"}) + m = updated.(Model) + + if !strings.HasPrefix(m.query, "pro") { + t.Errorf("expected query to start with 'pro' after typing then pasting, got %q", m.query) + } + if !strings.Contains(m.query, "d") { + t.Errorf("expected pasted 'd' in query, got %q", m.query) + } +} + +// stripANSI removes ANSI escape sequences from s so test assertions are +// independent of terminal colour codes. +func stripANSI(s string) string { + var out strings.Builder + inEscape := false + for i := 0; i < len(s); i++ { + if s[i] == '\x1b' { + inEscape = true + continue + } + if inEscape { + // ESC sequences end at a letter in the range 0x40–0x7E + if s[i] >= 0x40 && s[i] <= 0x7E { + inEscape = false + } + continue + } + out.WriteByte(s[i]) + } + return out.String() +} From 6a4287eea0447a3abc0406aa4699e2adb806fd72 Mon Sep 17 00:00:00 2001 From: Michael Sprauer Date: Wed, 23 Sep 2026 11:41:40 +0200 Subject: [PATCH 2/2] fix(tui): checked type assertions in tests (forcetypeassert) Signed-off-by: Michael Sprauer --- pkg/tui/model_test.go | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/pkg/tui/model_test.go b/pkg/tui/model_test.go index 25d116ff..91203146 100644 --- a/pkg/tui/model_test.go +++ b/pkg/tui/model_test.go @@ -35,7 +35,8 @@ func newTestModel(items ...string) Model { // sendKey is a convenience helper that feeds a synthetic KeyPressMsg into the // model and returns the updated model. -func sendKey(m Model, keyStr string) Model { +func sendKey(t *testing.T, m Model, keyStr string) Model { + t.Helper() // Build a KeyPressMsg whose String() returns keyStr. // For printable single-character keys we set Code + Text; for everything // else (multi-rune names like "ctrl+v") String() looks at Code+Mod but @@ -64,7 +65,11 @@ func sendKey(m Model, keyStr string) Model { } } updated, _ := m.Update(msg) - return updated.(Model) + m2, ok := updated.(Model) + if !ok { + t.Fatalf("Update returned unexpected type %T", updated) + } + return m2 } // ----- stray-"t" fix: placeholder must not appear in View output ------------ @@ -105,7 +110,7 @@ func TestHintTextAppended_EmptyQuery(t *testing.T) { // types something (to avoid cluttering the line with both typed text and the hint). func TestHintTextGone_NonEmptyQuery(t *testing.T) { m := newTestModel("prod", "staging") - m = sendKey(m, "p") + m = sendKey(t, m, "p") view := m.renderLeft(80) if strings.Contains(stripANSI(view), "type to filter...") { t.Error("hint 'type to filter...' should not appear after user has typed") @@ -122,7 +127,11 @@ func TestPasteMsg_UpdatesQuery(t *testing.T) { pasteText := "prod" updated, _ := m.Update(tea.PasteMsg{Content: pasteText}) - m = updated.(Model) + m2, ok := updated.(Model) + if !ok { + t.Fatalf("Update returned unexpected type %T", updated) + } + m = m2 if m.query != pasteText { t.Errorf("query after paste: got %q, want %q", m.query, pasteText) @@ -135,7 +144,11 @@ func TestPasteMsg_TriggersRefilter(t *testing.T) { m := newTestModel("production", "staging", "dev") updated, _ := m.Update(tea.PasteMsg{Content: "prod"}) - m = updated.(Model) + m2, ok := updated.(Model) + if !ok { + t.Fatalf("Update returned unexpected type %T", updated) + } + m = m2 for _, it := range m.filtered { if !strings.Contains(strings.ToLower(it.displayName), "prod") { @@ -155,7 +168,11 @@ func TestPasteMsg_TriggersRefilter(t *testing.T) { func TestPasteMsg_EmptyContent(t *testing.T) { m := newTestModel("prod") updated, _ := m.Update(tea.PasteMsg{Content: ""}) - m = updated.(Model) + m2, ok := updated.(Model) + if !ok { + t.Fatalf("Update returned unexpected type %T", updated) + } + m = m2 if m.query != "" { t.Errorf("empty paste should not change query, got %q", m.query) } @@ -166,12 +183,16 @@ func TestPasteMsg_EmptyContent(t *testing.T) { func TestPasteMsg_AppendsToExistingQuery(t *testing.T) { m := newTestModel("production", "staging") // First type "pro" manually - m = sendKey(m, "p") - m = sendKey(m, "r") - m = sendKey(m, "o") + m = sendKey(t, m, "p") + m = sendKey(t, m, "r") + m = sendKey(t, m, "o") // Then paste "d" updated, _ := m.Update(tea.PasteMsg{Content: "d"}) - m = updated.(Model) + m2, ok := updated.(Model) + if !ok { + t.Fatalf("Update returned unexpected type %T", updated) + } + m = m2 if !strings.HasPrefix(m.query, "pro") { t.Errorf("expected query to start with 'pro' after typing then pasting, got %q", m.query)