2024-03-12 01:10:54 -06:00
|
|
|
package tui
|
|
|
|
|
|
|
|
// The terminal UI for lmcli, launched from the `lmcli chat` command
|
|
|
|
// TODO:
|
2024-03-14 00:01:16 -06:00
|
|
|
// - change model
|
|
|
|
// - rename conversation
|
|
|
|
// - set system prompt
|
2024-03-12 01:10:54 -06:00
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
|
|
|
|
"git.mlow.ca/mlow/lmcli/pkg/lmcli"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
)
|
|
|
|
|
2024-03-31 17:51:45 -06:00
|
|
|
type state int
|
2024-03-29 18:41:12 -06:00
|
|
|
|
|
|
|
const (
|
2024-03-30 20:29:35 -06:00
|
|
|
stateChat = iota
|
|
|
|
stateConversations
|
2024-03-29 18:41:12 -06:00
|
|
|
//stateModelSelect // stateOptions?
|
|
|
|
//stateHelp
|
|
|
|
)
|
|
|
|
|
2024-03-30 20:29:35 -06:00
|
|
|
// this struct holds the final rendered content of various UI components, and
|
|
|
|
// gets populated in the application's Update() method. View() simply composes
|
2024-03-31 17:51:45 -06:00
|
|
|
// these elements into the final output
|
2024-03-30 20:03:53 -06:00
|
|
|
type views struct {
|
|
|
|
header string
|
|
|
|
content string
|
|
|
|
error string
|
|
|
|
input string
|
|
|
|
footer string
|
|
|
|
}
|
|
|
|
|
|
|
|
type (
|
2024-03-31 17:51:45 -06:00
|
|
|
// send to change the current app state
|
|
|
|
msgChangeState state
|
2024-03-30 20:03:53 -06:00
|
|
|
// sent when an error occurs
|
|
|
|
msgError error
|
|
|
|
)
|
|
|
|
|
2024-03-31 17:51:45 -06:00
|
|
|
type Options struct {
|
2024-03-12 01:10:54 -06:00
|
|
|
convShortname string
|
2024-03-31 17:51:45 -06:00
|
|
|
}
|
2024-03-12 01:10:54 -06:00
|
|
|
|
2024-03-31 17:51:45 -06:00
|
|
|
type basemodel struct {
|
|
|
|
opts *Options
|
|
|
|
ctx *lmcli.Context
|
|
|
|
views *views
|
|
|
|
err error
|
|
|
|
width int
|
|
|
|
height int
|
|
|
|
}
|
2024-03-12 01:10:54 -06:00
|
|
|
|
2024-03-31 17:51:45 -06:00
|
|
|
type model struct {
|
|
|
|
basemodel
|
2024-03-12 01:10:54 -06:00
|
|
|
|
2024-03-31 17:51:45 -06:00
|
|
|
state state
|
|
|
|
chat chatModel
|
|
|
|
conversations conversationsModel
|
2024-03-12 01:10:54 -06:00
|
|
|
}
|
|
|
|
|
2024-03-31 17:51:45 -06:00
|
|
|
func initialModel(ctx *lmcli.Context, opts Options) model {
|
2024-03-30 20:03:53 -06:00
|
|
|
m := model{
|
2024-03-31 17:51:45 -06:00
|
|
|
basemodel: basemodel{
|
|
|
|
opts: &opts,
|
|
|
|
ctx: ctx,
|
|
|
|
views: &views{},
|
|
|
|
},
|
2024-03-30 20:03:53 -06:00
|
|
|
}
|
2024-03-30 20:29:35 -06:00
|
|
|
m.state = stateChat
|
2024-03-31 17:51:45 -06:00
|
|
|
m.chat = newChatModel(&m)
|
|
|
|
m.conversations = newConversationsModel(&m)
|
2024-03-30 20:03:53 -06:00
|
|
|
return m
|
2024-03-29 14:43:19 -06:00
|
|
|
}
|
2024-03-12 01:10:54 -06:00
|
|
|
|
|
|
|
func (m model) Init() tea.Cmd {
|
2024-04-01 11:03:49 -06:00
|
|
|
return tea.Batch(
|
|
|
|
m.conversations.Init(),
|
|
|
|
m.chat.Init(),
|
|
|
|
func() tea.Msg {
|
|
|
|
return msgChangeState(m.state)
|
|
|
|
},
|
|
|
|
)
|
2024-03-12 01:10:54 -06:00
|
|
|
}
|
|
|
|
|
2024-03-31 19:06:13 -06:00
|
|
|
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 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...)
|
2024-03-29 18:41:12 -06:00
|
|
|
}
|
2024-03-31 19:06:13 -06:00
|
|
|
case 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...)
|
2024-03-29 18:41:12 -06:00
|
|
|
}
|
|
|
|
}
|
2024-03-31 19:06:13 -06:00
|
|
|
switch msg.String() {
|
|
|
|
case "ctrl+c", "ctrl+q":
|
|
|
|
return true, tea.Quit
|
|
|
|
}
|
|
|
|
return false, nil
|
2024-03-29 18:41:12 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
|
|
var cmds []tea.Cmd
|
|
|
|
|
|
|
|
switch msg := msg.(type) {
|
|
|
|
case tea.KeyMsg:
|
2024-03-31 19:06:13 -06:00
|
|
|
handled, cmd := m.handleGlobalInput(msg)
|
|
|
|
if handled {
|
2024-03-29 18:41:12 -06:00
|
|
|
return m, cmd
|
|
|
|
}
|
2024-03-31 17:51:45 -06:00
|
|
|
case msgChangeState:
|
|
|
|
m.state = state(msg)
|
2024-03-29 18:41:12 -06:00
|
|
|
case tea.WindowSizeMsg:
|
2024-03-31 17:51:45 -06:00
|
|
|
w, h := msg.Width, msg.Height
|
|
|
|
m.width, m.height = w, h
|
|
|
|
m.chat.width, m.chat.height = w, h
|
|
|
|
m.conversations.width, m.conversations.height = w, h
|
2024-03-29 18:41:12 -06:00
|
|
|
case msgError:
|
|
|
|
m.err = msg
|
|
|
|
}
|
|
|
|
|
2024-03-31 17:51:45 -06:00
|
|
|
var cmd tea.Cmd
|
2024-03-29 18:41:12 -06:00
|
|
|
switch m.state {
|
2024-03-30 20:29:35 -06:00
|
|
|
case stateConversations:
|
2024-03-31 17:51:45 -06:00
|
|
|
m.conversations, cmd = m.conversations.Update(msg)
|
2024-03-30 20:29:35 -06:00
|
|
|
case stateChat:
|
2024-03-31 17:51:45 -06:00
|
|
|
m.chat, cmd = m.chat.Update(msg)
|
|
|
|
}
|
|
|
|
if cmd != nil {
|
|
|
|
cmds = append(cmds, cmd)
|
2024-03-29 18:41:12 -06:00
|
|
|
}
|
|
|
|
|
2024-03-13 23:58:31 -06:00
|
|
|
return m, tea.Batch(cmds...)
|
2024-03-12 01:10:54 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
func (m model) View() string {
|
2024-03-14 11:55:21 -06:00
|
|
|
if m.width == 0 {
|
2024-03-13 13:52:41 -06:00
|
|
|
// 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...
|
2024-03-14 00:39:25 -06:00
|
|
|
// without this, the m.*View() functions may crash
|
2024-03-13 13:52:41 -06:00
|
|
|
return ""
|
|
|
|
}
|
2024-03-13 20:54:25 -06:00
|
|
|
sections := make([]string, 0, 6)
|
2024-03-29 18:41:12 -06:00
|
|
|
|
|
|
|
if m.views.header != "" {
|
|
|
|
sections = append(sections, m.views.header)
|
|
|
|
}
|
|
|
|
|
|
|
|
switch m.state {
|
2024-03-30 20:29:35 -06:00
|
|
|
case stateConversations:
|
2024-03-30 19:02:07 -06:00
|
|
|
sections = append(sections, m.views.content)
|
|
|
|
if m.views.error != "" {
|
|
|
|
sections = append(sections, m.views.error)
|
|
|
|
}
|
2024-03-30 20:29:35 -06:00
|
|
|
case stateChat:
|
2024-03-29 18:41:12 -06:00
|
|
|
sections = append(sections, m.views.content)
|
|
|
|
if m.views.error != "" {
|
|
|
|
sections = append(sections, m.views.error)
|
|
|
|
}
|
|
|
|
sections = append(sections, m.views.input)
|
|
|
|
}
|
|
|
|
|
|
|
|
if m.views.footer != "" {
|
|
|
|
sections = append(sections, m.views.footer)
|
2024-03-13 20:54:25 -06:00
|
|
|
}
|
|
|
|
|
2024-03-30 19:02:07 -06:00
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, sections...)
|
2024-03-12 01:10:54 -06:00
|
|
|
}
|
|
|
|
|
2024-03-31 17:51:45 -06:00
|
|
|
func errorBanner(err error, width int) string {
|
|
|
|
if err == nil {
|
2024-03-13 20:54:25 -06:00
|
|
|
return ""
|
|
|
|
}
|
|
|
|
return lipgloss.NewStyle().
|
2024-03-31 17:51:45 -06:00
|
|
|
Width(width).
|
2024-03-13 21:07:41 -06:00
|
|
|
AlignHorizontal(lipgloss.Center).
|
2024-03-13 20:54:25 -06:00
|
|
|
Bold(true).
|
|
|
|
Foreground(lipgloss.Color("1")).
|
2024-03-31 17:51:45 -06:00
|
|
|
Render(fmt.Sprintf("%s", err))
|
2024-03-13 13:14:03 -06:00
|
|
|
}
|
|
|
|
|
2024-03-30 20:03:53 -06:00
|
|
|
func wrapError(err error) tea.Cmd {
|
2024-03-12 01:10:54 -06:00
|
|
|
return func() tea.Msg {
|
2024-03-30 20:03:53 -06:00
|
|
|
return msgError(err)
|
2024-03-12 01:10:54 -06:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func Launch(ctx *lmcli.Context, convShortname string) error {
|
2024-03-31 17:51:45 -06:00
|
|
|
p := tea.NewProgram(initialModel(ctx, Options{convShortname}), tea.WithAltScreen())
|
2024-03-12 01:10:54 -06:00
|
|
|
if _, err := p.Run(); err != nil {
|
|
|
|
return fmt.Errorf("Error running program: %v", err)
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|