203 lines
4.0 KiB
Go
203 lines
4.0 KiB
Go
package tui
|
|
|
|
// The terminal UI for lmcli, launched from the `lmcli chat` command
|
|
// TODO:
|
|
// - conversation list view
|
|
// - change model
|
|
// - rename conversation
|
|
// - set system prompt
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"git.mlow.ca/mlow/lmcli/pkg/lmcli"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
type state int
|
|
|
|
const (
|
|
stateChat = iota
|
|
stateConversations
|
|
//stateModelSelect // stateOptions?
|
|
//stateHelp
|
|
)
|
|
|
|
// this struct holds the final rendered content of various UI components, and
|
|
// gets populated in the application's Update() method. View() simply composes
|
|
// these elements into the final output
|
|
type views struct {
|
|
header string
|
|
content string
|
|
error string
|
|
input string
|
|
footer string
|
|
}
|
|
|
|
type (
|
|
// send to change the current app state
|
|
msgChangeState state
|
|
// sent when an error occurs
|
|
msgError error
|
|
)
|
|
|
|
type Options struct {
|
|
convShortname string
|
|
}
|
|
|
|
type basemodel struct {
|
|
opts *Options
|
|
ctx *lmcli.Context
|
|
views *views
|
|
err error
|
|
width int
|
|
height int
|
|
}
|
|
|
|
type model struct {
|
|
basemodel
|
|
|
|
state state
|
|
chat chatModel
|
|
conversations conversationsModel
|
|
}
|
|
|
|
func initialModel(ctx *lmcli.Context, opts Options) model {
|
|
m := model{
|
|
basemodel: basemodel{
|
|
opts: &opts,
|
|
ctx: ctx,
|
|
views: &views{},
|
|
},
|
|
}
|
|
m.state = stateChat
|
|
m.chat = newChatModel(&m)
|
|
m.conversations = newConversationsModel(&m)
|
|
return m
|
|
}
|
|
|
|
func (m model) Init() tea.Cmd {
|
|
return func() tea.Msg {
|
|
return msgChangeState(m.state)
|
|
}
|
|
}
|
|
|
|
func (m *model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
if m.chat.waitingForReply {
|
|
m.chat.stopSignal <- struct{}{}
|
|
return nil
|
|
} else {
|
|
return tea.Quit
|
|
}
|
|
case "q":
|
|
if m.chat.focus != focusInput {
|
|
return tea.Quit
|
|
}
|
|
default:
|
|
switch m.state {
|
|
case stateChat:
|
|
return m.chat.handleInput(msg)
|
|
case stateConversations:
|
|
return m.conversations.handleInput(msg)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var cmds []tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
cmd := m.handleGlobalInput(msg)
|
|
if cmd != nil {
|
|
return m, cmd
|
|
}
|
|
case msgChangeState:
|
|
m.state = state(msg)
|
|
case tea.WindowSizeMsg:
|
|
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
|
|
case msgError:
|
|
m.err = msg
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
switch m.state {
|
|
case stateConversations:
|
|
m.conversations, cmd = m.conversations.Update(msg)
|
|
case 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.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.views.header != "" {
|
|
sections = append(sections, m.views.header)
|
|
}
|
|
|
|
switch m.state {
|
|
case stateConversations:
|
|
sections = append(sections, m.views.content)
|
|
if m.views.error != "" {
|
|
sections = append(sections, m.views.error)
|
|
}
|
|
case stateChat:
|
|
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)
|
|
}
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, sections...)
|
|
}
|
|
|
|
func errorBanner(err error, width int) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
return lipgloss.NewStyle().
|
|
Width(width).
|
|
AlignHorizontal(lipgloss.Center).
|
|
Bold(true).
|
|
Foreground(lipgloss.Color("1")).
|
|
Render(fmt.Sprintf("%s", err))
|
|
}
|
|
|
|
func wrapError(err error) tea.Cmd {
|
|
return func() tea.Msg {
|
|
return msgError(err)
|
|
}
|
|
}
|
|
|
|
func Launch(ctx *lmcli.Context, convShortname string) error {
|
|
p := tea.NewProgram(initialModel(ctx, Options{convShortname}), tea.WithAltScreen())
|
|
if _, err := p.Run(); err != nil {
|
|
return fmt.Errorf("Error running program: %v", err)
|
|
}
|
|
return nil
|
|
}
|