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 }