TUI view management and input handling cleanup

This commit is contained in:
Matt Low 2024-09-16 15:40:04 +00:00
parent 24b5cdbbf6
commit 463ca9ef40
7 changed files with 169 additions and 154 deletions

View File

@ -40,7 +40,7 @@ func ChatCmd(ctx *lmcli.Context) *cobra.Command {
}
if list {
opts = append(opts, tui.WithInitialView(shared.StateConversations))
opts = append(opts, tui.WithInitialView(shared.ViewConversations))
}
err = tui.Launch(ctx, opts...)

View File

@ -4,13 +4,31 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
type Shared struct {
// An analogue to tea.Model with support for checking if the model has been
// initialized before
type ViewModel interface {
Init() tea.Cmd
Update(tea.Msg) (ViewModel, tea.Cmd)
View() string
Initialized() bool // Return whether this view is initialized
}
type ViewState struct {
Initialized bool
Width int
Height int
Err error
}
type View int
const (
ViewChat View = iota
ViewConversations
//StateSettings
//StateHelp
)
// a convenience struct for holding rendered content for indiviudal UI
// elements
type Sections struct {
@ -28,6 +46,8 @@ type (
MsgViewEnter struct{}
// sent when an error occurs
MsgError error
// sent when the view has handled a key input
MsgKeyHandled tea.KeyMsg
)
func ViewEnter() tea.Cmd {
@ -36,17 +56,14 @@ func ViewEnter() tea.Cmd {
}
}
func KeyHandled(key tea.KeyMsg) tea.Cmd {
return func() tea.Msg {
return MsgKeyHandled(key)
}
}
func WrapError(err error) tea.Cmd {
return func() tea.Msg {
return MsgError(err)
}
}
type View int
const (
StateChat View = iota
StateConversations
//StateSettings
//StateHelp
)

View File

@ -1,11 +1,5 @@
package tui
// The terminal UI for lmcli, launched from the `lmcli chat` command
// TODO:
// - change model
// - rename conversation
// - set system prompt
import (
"fmt"
@ -25,116 +19,80 @@ type LaunchOptions struct {
type Model struct {
App *model.AppModel
view shared.View
// views
chat chat.Model
conversations conversations.Model
activeView shared.View
views map[shared.View]shared.ViewModel
}
func initialModel(ctx *lmcli.Context, opts LaunchOptions) Model {
m := Model{
App: &model.AppModel{
sharedData := shared.ViewState{}
app := &model.AppModel{
Ctx: ctx,
Conversation: opts.InitialConversation,
},
view: opts.InitialView,
}
sharedData := shared.Shared{}
m := Model{
App: app,
activeView: opts.InitialView,
views: map[shared.View]shared.ViewModel{
shared.ViewChat: chat.Chat(app, sharedData),
shared.ViewConversations: conversations.Conversations(app, sharedData),
},
}
m.chat = chat.Chat(m.App, sharedData)
m.conversations = conversations.Conversations(m.App, sharedData)
return m
}
func (m Model) Init() tea.Cmd {
return tea.Batch(
func() tea.Msg {
return shared.MsgViewChange(m.view)
return shared.MsgViewChange(m.activeView)
},
)
}
func (m *Model) handleGlobalInput(msg tea.KeyMsg) (bool, tea.Cmd) {
var cmds []tea.Cmd
switch m.view {
case shared.StateChat:
handled, cmd := m.chat.HandleInput(msg)
cmds = append(cmds, cmd)
if handled {
m.chat, cmd = m.chat.Update(nil)
cmds = append(cmds, cmd)
return true, tea.Batch(cmds...)
}
case shared.StateConversations:
handled, cmd := m.conversations.HandleInput(msg)
cmds = append(cmds, cmd)
if handled {
m.conversations, cmd = m.conversations.Update(nil)
cmds = append(cmds, cmd)
return true, tea.Batch(cmds...)
}
func (m *Model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
view, cmd := m.views[m.activeView].Update(msg)
m.views[m.activeView] = view
if cmd != nil {
return cmd
}
switch msg.String() {
case "ctrl+c", "ctrl+q":
return true, tea.Quit
return tea.Quit
}
return false, nil
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
handled, cmd := m.handleGlobalInput(msg)
if handled {
cmd := m.handleGlobalInput(msg)
if cmd != nil {
return m, cmd
}
case shared.MsgViewChange:
m.view = shared.View(msg)
m.activeView = shared.View(msg)
view := m.views[m.activeView]
var cmds []tea.Cmd
switch m.view {
case shared.StateConversations:
if !m.conversations.Initialized {
cmds = append(cmds, m.conversations.Init())
m.conversations.Initialized = true
}
case shared.StateChat:
if !m.chat.Initialized {
cmds = append(cmds, m.chat.Init())
m.chat.Initialized = true
}
if !view.Initialized() {
cmds = append(cmds, view.Init())
}
cmds = append(cmds, tea.WindowSize(), shared.ViewEnter())
return m, tea.Batch(cmds...)
}
var cmd tea.Cmd
switch m.view {
case shared.StateConversations:
m.conversations, cmd = m.conversations.Update(msg)
case shared.StateChat:
m.chat, cmd = m.chat.Update(msg)
}
if cmd != nil {
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
view, cmd := m.views[m.activeView].Update(msg)
m.views[m.activeView] = view
return m, cmd
}
func (m Model) View() string {
switch m.view {
case shared.StateConversations:
return m.conversations.View()
case shared.StateChat:
return m.chat.View()
}
return ""
return m.views[m.activeView].View()
}
type LaunchOption func(*LaunchOptions)
@ -153,7 +111,7 @@ func WithInitialView(view shared.View) LaunchOption {
func Launch(ctx *lmcli.Context, options ...LaunchOption) error {
opts := &LaunchOptions{
InitialView: shared.StateChat,
InitialView: shared.ViewChat,
}
for _, opt := range options {
opt(opts)

View File

@ -74,7 +74,7 @@ const (
)
type Model struct {
shared.Shared
*shared.ViewState
shared.Sections
// App state
@ -108,10 +108,14 @@ type Model struct {
elapsed time.Duration
}
func Chat(app *model.AppModel, shared shared.Shared) Model {
func (m Model) Initialized() bool {
return m.ViewState.Initialized
}
func Chat(app *model.AppModel, shared shared.ViewState) shared.ViewModel {
m := Model{
App: app,
Shared: shared,
ViewState: &shared,
state: idle,
persistence: true,
@ -169,6 +173,7 @@ func Chat(app *model.AppModel, shared shared.Shared) Model {
}
func (m Model) Init() tea.Cmd {
m.ViewState.Initialized = true
return tea.Batch(
m.waitForResponseChunk(),
)

View File

@ -11,17 +11,17 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
func (m *Model) HandleInput(msg tea.KeyMsg) (bool, tea.Cmd) {
func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd {
switch m.focus {
case focusInput:
consumed, cmd := m.handleInputKey(msg)
if consumed {
return true, cmd
cmd := m.handleInputKey(msg)
if cmd != nil {
return cmd
}
case focusMessages:
consumed, cmd := m.handleMessagesKey(msg)
if consumed {
return true, cmd
cmd := m.handleMessagesKey(msg)
if cmd != nil {
return cmd
}
}
@ -29,51 +29,51 @@ func (m *Model) HandleInput(msg tea.KeyMsg) (bool, tea.Cmd) {
case "esc":
if m.state == pendingResponse {
m.stopSignal <- struct{}{}
return true, nil
return shared.KeyHandled(msg)
}
return true, func() tea.Msg {
return shared.MsgViewChange(shared.StateConversations)
return func() tea.Msg {
return shared.MsgViewChange(shared.ViewConversations)
}
case "ctrl+c":
if m.state == pendingResponse {
m.stopSignal <- struct{}{}
return true, nil
return shared.KeyHandled(msg)
}
case "ctrl+p":
m.persistence = !m.persistence
return true, nil
return shared.KeyHandled(msg)
case "ctrl+t":
m.showToolResults = !m.showToolResults
m.rebuildMessageCache()
m.updateContent()
return true, nil
return shared.KeyHandled(msg)
case "ctrl+w":
m.wrap = !m.wrap
m.rebuildMessageCache()
m.updateContent()
return true, nil
return shared.KeyHandled(msg)
}
return false, nil
return nil
}
// handleMessagesKey handles input when the messages pane is focused
func (m *Model) handleMessagesKey(msg tea.KeyMsg) (bool, tea.Cmd) {
func (m *Model) handleMessagesKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "tab", "enter":
m.focus = focusInput
m.updateContent()
m.input.Focus()
return true, nil
return shared.KeyHandled(msg)
case "e":
if m.selectedMessage < len(m.App.Messages) {
m.editorTarget = selectedMessage
return true, tuiutil.OpenTempfileEditor(
return tuiutil.OpenTempfileEditor(
"message.*.md",
m.App.Messages[m.selectedMessage].Content,
"# Edit the message below\n",
)
}
return false, nil
return nil
case "ctrl+k":
if m.selectedMessage > 0 && len(m.App.Messages) == len(m.messageOffsets) {
m.selectedMessage--
@ -81,7 +81,7 @@ func (m *Model) handleMessagesKey(msg tea.KeyMsg) (bool, tea.Cmd) {
offset := m.messageOffsets[m.selectedMessage]
tuiutil.ScrollIntoView(&m.content, offset, m.content.Height/2)
}
return true, nil
return shared.KeyHandled(msg)
case "ctrl+j":
if m.selectedMessage < len(m.App.Messages)-1 && len(m.App.Messages) == len(m.messageOffsets) {
m.selectedMessage++
@ -89,7 +89,7 @@ func (m *Model) handleMessagesKey(msg tea.KeyMsg) (bool, tea.Cmd) {
offset := m.messageOffsets[m.selectedMessage]
tuiutil.ScrollIntoView(&m.content, offset, m.content.Height/2)
}
return true, nil
return shared.KeyHandled(msg)
case "ctrl+h", "ctrl+l":
dir := model.CyclePrev
if msg.String() == "ctrl+l" {
@ -102,8 +102,7 @@ func (m *Model) handleMessagesKey(msg tea.KeyMsg) (bool, tea.Cmd) {
} else if m.selectedMessage > 0 {
cmd = m.cycleSelectedReply(&m.App.Messages[m.selectedMessage-1], dir)
}
return cmd != nil, cmd
return cmd
case "ctrl+r":
// resubmit the conversation with all messages up until and including the selected message
if m.state == idle && m.selectedMessage < len(m.App.Messages) {
@ -112,14 +111,14 @@ func (m *Model) handleMessagesKey(msg tea.KeyMsg) (bool, tea.Cmd) {
cmd := m.promptLLM()
m.updateContent()
m.content.GotoBottom()
return true, cmd
return cmd
}
}
return false, nil
return nil
}
// handleInputKey handles input when the input textarea is focused
func (m *Model) handleInputKey(msg tea.KeyMsg) (bool, tea.Cmd) {
func (m *Model) handleInputKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "esc":
m.focus = focusMessages
@ -132,20 +131,20 @@ func (m *Model) handleInputKey(msg tea.KeyMsg) (bool, tea.Cmd) {
}
m.updateContent()
m.input.Blur()
return true, nil
return shared.KeyHandled(msg)
case "ctrl+s":
// TODO: call a "handleSend" function which returns a tea.Cmd
if m.state != idle {
return false, nil
return nil
}
input := strings.TrimSpace(m.input.Value())
if input == "" {
return true, nil
return shared.KeyHandled(msg)
}
if len(m.App.Messages) > 0 && m.App.Messages[len(m.App.Messages)-1].Role == api.MessageRoleUser {
return true, shared.WrapError(fmt.Errorf("Can't reply to a user message"))
return shared.WrapError(fmt.Errorf("Can't reply to a user message"))
}
m.addMessage(api.Message{
@ -164,11 +163,11 @@ func (m *Model) handleInputKey(msg tea.KeyMsg) (bool, tea.Cmd) {
m.updateContent()
m.content.GotoBottom()
return true, tea.Batch(cmds...)
return tea.Batch(cmds...)
case "ctrl+e":
cmd := tuiutil.OpenTempfileEditor("message.*.md", m.input.Value(), "# Edit your input below\n")
m.editorTarget = input
return true, cmd
return cmd
}
return false, nil
return nil
}

View File

@ -47,9 +47,17 @@ func (m *Model) updateContent() {
}
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
inputHandled := false
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
cmd := m.handleInput(msg)
if cmd != nil {
inputHandled = true
cmds = append(cmds, cmd)
}
case tea.WindowSizeMsg:
m.Width, m.Height = msg.Width, msg.Height
m.content.Width = msg.Width
@ -167,7 +175,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.updateContent()
case msgChatResponseError:
m.state = idle
m.Shared.Err = error(msg)
m.ViewState.Err = error(msg)
m.updateContent()
case msgToolResults:
last := len(m.App.Messages) - 1
@ -231,14 +239,16 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
}
prevInputLineCnt := m.input.LineCount()
inputCaptured := false
if !inputHandled {
m.input, cmd = m.input.Update(msg)
if cmd != nil {
inputCaptured = true
inputHandled = true
cmds = append(cmds, cmd)
}
}
if !inputCaptured {
if !inputHandled {
m.content, cmd = m.content.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
@ -285,5 +295,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
}
}
if len(cmds) > 0 {
return m, tea.Batch(cmds...)
}
return m, nil
}

View File

@ -25,8 +25,9 @@ type (
// sent when a conversation is deleted
msgConversationDeleted struct{}
)
type Model struct {
shared.Shared
*shared.ViewState
shared.Sections
App *model.AppModel
@ -38,21 +39,25 @@ type Model struct {
confirmPrompt bubbles.ConfirmPrompt
}
func Conversations(app *model.AppModel, shared shared.Shared) Model {
func Conversations(app *model.AppModel, shared shared.ViewState) Model {
m := Model{
App: app,
Shared: shared,
ViewState: &shared,
content: viewport.New(0, 0),
}
return m
}
func (m *Model) HandleInput(msg tea.KeyMsg) (bool, tea.Cmd) {
func (m Model) Initialized() bool {
return m.ViewState.Initialized
}
func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd {
if m.confirmPrompt.Focused() {
var cmd tea.Cmd
m.confirmPrompt, cmd = m.confirmPrompt.Update(msg)
if cmd != nil {
return true, cmd
return cmd
}
}
@ -61,8 +66,8 @@ func (m *Model) HandleInput(msg tea.KeyMsg) (bool, tea.Cmd) {
if len(m.App.Conversations) > 0 && m.cursor < len(m.App.Conversations) {
m.App.Conversation = &m.App.Conversations[m.cursor].Conv
m.App.Messages = []api.Message{}
return true, func() tea.Msg {
return shared.MsgViewChange(shared.StateChat)
return func() tea.Msg {
return shared.MsgViewChange(shared.ViewChat)
}
}
case "j", "down":
@ -81,7 +86,7 @@ func (m *Model) HandleInput(msg tea.KeyMsg) (bool, tea.Cmd) {
m.cursor = len(m.App.Conversations) - 1
m.content.GotoBottom()
}
return true, nil
return shared.KeyHandled(msg)
case "k", "up":
if m.cursor > 0 {
m.cursor--
@ -95,7 +100,7 @@ func (m *Model) HandleInput(msg tea.KeyMsg) (bool, tea.Cmd) {
m.cursor = 0
m.content.GotoTop()
}
return true, nil
return shared.KeyHandled(msg)
case "n":
// new conversation
case "d":
@ -111,7 +116,7 @@ func (m *Model) HandleInput(msg tea.KeyMsg) (bool, tea.Cmd) {
m.confirmPrompt.Style = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("3"))
return true, nil
return shared.KeyHandled(msg)
}
case "c":
// copy/clone conversation
@ -120,16 +125,27 @@ func (m *Model) HandleInput(msg tea.KeyMsg) (bool, tea.Cmd) {
case "shift+r":
// show prompt to generate name for conversation
}
return false, nil
return nil
}
func (m Model) Init() tea.Cmd {
m.ViewState.Initialized = true
return m.loadConversations()
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
isInput := false
inputHandled := false
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
isInput = true
cmd := m.handleInput(msg)
if cmd != nil {
inputHandled = true
cmds = append(cmds, cmd)
}
case shared.MsgViewEnter:
cmds = append(cmds, m.loadConversations())
m.content.SetContent(m.renderConversationList())
@ -153,11 +169,13 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
cmds = append(cmds, m.loadConversations())
}
if !isInput || !inputHandled {
var cmd tea.Cmd
m.content, cmd = m.content.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
if m.Width > 0 {
wrap := lipgloss.NewStyle().Width(m.Width)
@ -171,9 +189,14 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.content.Height = m.Height - fixedHeight
m.Content = m.content.View()
}
if len(cmds) > 0 {
return m, tea.Batch(cmds...)
}
return m, nil
}
func (m *Model) loadConversations() tea.Cmd {
return func() tea.Msg {
err, conversations := m.App.LoadConversations()
@ -289,7 +312,7 @@ func (m *Model) renderConversationList() string {
sb.WriteRune('\n')
}
tStyle := titleStyle.Copy()
tStyle := titleStyle
if c.Conv.Title == "" {
tStyle = tStyle.Inherit(untitledStyle).SetString("(untitled)")
}