From aa5cfc312d8d8ac406415e93983f02b98238b5f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 21:02:53 +0000 Subject: [PATCH] feat(frame): add FrameModel interface and modelAdapter Co-Authored-By: Claude Opus 4.6 --- pkg/cli/frame_model.go | 31 +++++++++++++++++++++++++ pkg/cli/frame_test.go | 51 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 pkg/cli/frame_model.go diff --git a/pkg/cli/frame_model.go b/pkg/cli/frame_model.go new file mode 100644 index 00000000..d0130688 --- /dev/null +++ b/pkg/cli/frame_model.go @@ -0,0 +1,31 @@ +package cli + +import tea "github.com/charmbracelet/bubbletea" + +// FrameModel extends Model with bubbletea lifecycle methods. +// Use this for interactive components that handle input. +// Plain Model components work unchanged — Frame wraps them automatically. +type FrameModel interface { + Model + Init() tea.Cmd + Update(tea.Msg) (FrameModel, tea.Cmd) +} + +// adaptModel wraps a plain Model as a FrameModel via modelAdapter. +// If the model already implements FrameModel, it is returned as-is. +func adaptModel(m Model) FrameModel { + if fm, ok := m.(FrameModel); ok { + return fm + } + return &modelAdapter{m: m} +} + +// modelAdapter wraps a plain Model to satisfy FrameModel. +// Init returns nil, Update is a no-op, View delegates to the wrapped Model. +type modelAdapter struct { + m Model +} + +func (a *modelAdapter) View(w, h int) string { return a.m.View(w, h) } +func (a *modelAdapter) Init() tea.Cmd { return nil } +func (a *modelAdapter) Update(tea.Msg) (FrameModel, tea.Cmd) { return a, nil } diff --git a/pkg/cli/frame_test.go b/pkg/cli/frame_test.go index c6dfd73b..87f3c16d 100644 --- a/pkg/cli/frame_test.go +++ b/pkg/cli/frame_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -196,6 +197,56 @@ func TestStaticModel_Good(t *testing.T) { assert.Equal(t, "hello", m.View(80, 24)) } +func TestFrameModel_Good(t *testing.T) { + t.Run("modelAdapter wraps plain Model", func(t *testing.T) { + m := StaticModel("hello") + adapted := adaptModel(m) + + // Should return nil cmd from Init + cmd := adapted.Init() + assert.Nil(t, cmd) + + // Should return itself from Update + updated, cmd := adapted.Update(nil) + assert.Equal(t, adapted, updated) + assert.Nil(t, cmd) + + // Should delegate View to wrapped model + assert.Equal(t, "hello", adapted.View(80, 24)) + }) + + t.Run("FrameModel passes through without wrapping", func(t *testing.T) { + fm := &testFrameModel{viewText: "interactive"} + adapted := adaptModel(fm) + + // Should be the same object, not wrapped + _, ok := adapted.(*testFrameModel) + assert.True(t, ok, "FrameModel should not be wrapped") + assert.Equal(t, "interactive", adapted.View(80, 24)) + }) +} + +// testFrameModel is a mock FrameModel for testing. +type testFrameModel struct { + viewText string + initCalled bool + updateCalled bool + lastMsg tea.Msg +} + +func (m *testFrameModel) View(w, h int) string { return m.viewText } + +func (m *testFrameModel) Init() tea.Cmd { + m.initCalled = true + return nil +} + +func (m *testFrameModel) Update(msg tea.Msg) (FrameModel, tea.Cmd) { + m.updateCalled = true + m.lastMsg = msg + return m, nil +} + // indexOf returns the position of substr in s, or -1 if not found. func indexOf(s, substr string) int { for i := range len(s) - len(substr) + 1 {