feat(frame): add FrameModel interface and modelAdapter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-02-22 21:02:53 +00:00
parent 762eadd736
commit aa5cfc312d
No known key found for this signature in database
GPG key ID: AF404715446AEB41
2 changed files with 82 additions and 0 deletions

31
pkg/cli/frame_model.go Normal file
View file

@ -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 }

View file

@ -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 {