From 463ca9ef40700183fceeb1eadb727c2714e31553 Mon Sep 17 00:00:00 2001 From: Matt Low Date: Mon, 16 Sep 2024 15:40:04 +0000 Subject: [PATCH] TUI view management and input handling cleanup --- pkg/cmd/chat.go | 2 +- pkg/tui/shared/shared.go | 37 ++++-- pkg/tui/tui.go | 114 ++++++------------- pkg/tui/views/chat/chat.go | 11 +- pkg/tui/views/chat/input.go | 65 ++++++----- pkg/tui/views/chat/update.go | 31 +++-- pkg/tui/views/conversations/conversations.go | 63 ++++++---- 7 files changed, 169 insertions(+), 154 deletions(-) diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go index 8f6ecf1..4d02f2a 100644 --- a/pkg/cmd/chat.go +++ b/pkg/cmd/chat.go @@ -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...) diff --git a/pkg/tui/shared/shared.go b/pkg/tui/shared/shared.go index 673bdf8..3a63978 100644 --- a/pkg/tui/shared/shared.go +++ b/pkg/tui/shared/shared.go @@ -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 -) diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 09d766f..9384b3e 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -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" @@ -24,117 +18,81 @@ type LaunchOptions struct { } type Model struct { - App *model.AppModel - view shared.View + App *model.AppModel - // 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{ - Ctx: ctx, - Conversation: opts.InitialConversation, - }, - view: opts.InitialView, + sharedData := shared.ViewState{} + + app := &model.AppModel{ + Ctx: ctx, + Conversation: opts.InitialConversation, } - 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) diff --git a/pkg/tui/views/chat/chat.go b/pkg/tui/views/chat/chat.go index 09a5872..699c4f2 100644 --- a/pkg/tui/views/chat/chat.go +++ b/pkg/tui/views/chat/chat.go @@ -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(), ) diff --git a/pkg/tui/views/chat/input.go b/pkg/tui/views/chat/input.go index a3c1f11..398ee25 100644 --- a/pkg/tui/views/chat/input.go +++ b/pkg/tui/views/chat/input.go @@ -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 } diff --git a/pkg/tui/views/chat/update.go b/pkg/tui/views/chat/update.go index a8bd0ac..87ff9da 100644 --- a/pkg/tui/views/chat/update.go +++ b/pkg/tui/views/chat/update.go @@ -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 - m.input, cmd = m.input.Update(msg) - if cmd != nil { - inputCaptured = true - cmds = append(cmds, cmd) + + if !inputHandled { + m.input, cmd = m.input.Update(msg) + if cmd != nil { + 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) { } } - return m, tea.Batch(cmds...) + if len(cmds) > 0 { + return m, tea.Batch(cmds...) + } + return m, nil } diff --git a/pkg/tui/views/conversations/conversations.go b/pkg/tui/views/conversations/conversations.go index f3beab1..55f07a7 100644 --- a/pkg/tui/views/conversations/conversations.go +++ b/pkg/tui/views/conversations/conversations.go @@ -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, - content: viewport.New(0, 0), + App: app, + 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,10 +169,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { cmds = append(cmds, m.loadConversations()) } - var cmd tea.Cmd - m.content, cmd = m.content.Update(msg) - if cmd != nil { - cmds = append(cmds, cmd) + 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 { @@ -171,7 +189,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.content.Height = m.Height - fixedHeight m.Content = m.content.View() } - return m, tea.Batch(cmds...) + + if len(cmds) > 0 { + return m, tea.Batch(cmds...) + } + + return m, nil } func (m *Model) loadConversations() tea.Cmd { @@ -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)") }