lmcli/pkg/tui/tui.go

168 lines
4.1 KiB
Go

package tui
// The terminal UI for lmcli, launched from the `lmcli chat` command
// TODO:
// - change model
// - rename conversation
// - set system prompt
import (
"fmt"
"git.mlow.ca/mlow/lmcli/pkg/lmcli"
"git.mlow.ca/mlow/lmcli/pkg/tui/shared"
"git.mlow.ca/mlow/lmcli/pkg/tui/views/chat"
"git.mlow.ca/mlow/lmcli/pkg/tui/views/conversations"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Application model
type Model struct {
shared.State
state shared.View
chat chat.Model
conversations conversations.Model
}
func initialModel(ctx *lmcli.Context, values shared.Values) Model {
m := Model{
State: shared.State{
Ctx: ctx,
Values: &values,
Views: &shared.Views{},
},
}
m.state = shared.StateChat
m.chat = chat.Chat(m.State)
m.conversations = conversations.Conversations(m.State)
return m
}
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.conversations.Init(),
m.chat.Init(),
func() tea.Msg {
return shared.MsgViewChange(m.state)
},
)
}
func (m *Model) handleGlobalInput(msg tea.KeyMsg) (bool, tea.Cmd) {
// delegate input to the active child state first, only handling it at the
// global level if the child state does not
var cmds []tea.Cmd
switch m.state {
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...)
}
}
switch msg.String() {
case "ctrl+c", "ctrl+q":
return true, tea.Quit
}
return false, 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 {
return m, cmd
}
case shared.MsgViewChange:
m.state = shared.View(msg)
switch m.state {
case shared.StateChat:
m.chat.HandleResize(m.State.Width, m.State.Height)
case shared.StateConversations:
m.conversations.HandleResize(m.State.Width, m.State.Height)
}
return m, func() tea.Msg { return shared.MsgViewEnter(struct{}{}) }
case conversations.MsgConversationSelected:
// passed up through conversation list model
m.State.Values.ConvShortname = msg.ShortName.String
cmds = append(cmds, func() tea.Msg {
return shared.MsgViewChange(shared.StateChat)
})
case tea.WindowSizeMsg:
m.State.Width, m.State.Height = msg.Width, msg.Height
case shared.MsgError:
m.State.Err = msg
}
var cmd tea.Cmd
switch m.state {
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...)
}
func (m Model) View() string {
if m.State.Width == 0 {
// this is the case upon initial startup, but it's also a safe bet that
// we can just skip rendering if the terminal is really 0 width...
// without this, the m.*View() functions may crash
return ""
}
sections := make([]string, 0, 6)
if m.State.Views.Header != "" {
sections = append(sections, m.State.Views.Header)
}
switch m.state {
case shared.StateConversations:
sections = append(sections, m.State.Views.Content)
if m.State.Views.Error != "" {
sections = append(sections, m.State.Views.Error)
}
case shared.StateChat:
sections = append(sections, m.State.Views.Content)
if m.State.Views.Error != "" {
sections = append(sections, m.State.Views.Error)
}
sections = append(sections, m.State.Views.Input)
}
if m.State.Views.Footer != "" {
sections = append(sections, m.State.Views.Footer)
}
return lipgloss.JoinVertical(lipgloss.Left, sections...)
}
func Launch(ctx *lmcli.Context, convShortname string) error {
p := tea.NewProgram(initialModel(ctx, shared.Values{ConvShortname: convShortname}), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
return fmt.Errorf("Error running program: %v", err)
}
return nil
}