lmcli/pkg/tui/tui.go

330 lines
7.4 KiB
Go
Raw Normal View History

2024-03-12 01:10:54 -06:00
package tui
// The terminal UI for lmcli, launched from the `lmcli chat` command
// TODO:
2024-03-12 23:15:50 -06:00
// - conversation list view
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"
"strings"
2024-03-13 23:58:31 -06:00
"time"
2024-03-12 01:10:54 -06:00
"git.mlow.ca/mlow/lmcli/pkg/lmcli"
models "git.mlow.ca/mlow/lmcli/pkg/lmcli/model"
2024-03-13 23:58:31 -06:00
"github.com/charmbracelet/bubbles/spinner"
2024-03-12 01:10:54 -06:00
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type appState 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 view
type views struct {
header string
content string
error string
input string
footer string
}
type (
// sent when an error occurs
msgError error
)
2024-03-12 01:10:54 -06:00
type model struct {
2024-03-13 20:54:25 -06:00
width int
height int
2024-03-12 01:10:54 -06:00
ctx *lmcli.Context
convShortname string
// application state
state appState
conversations []models.Conversation
lastReplies []models.Message
conversation *models.Conversation
messages []models.Message
selectedMessage int
waitingForReply bool
editorTarget editorTarget
2024-03-15 00:44:42 -06:00
stopSignal chan interface{}
2024-03-12 20:05:48 -06:00
replyChan chan models.Message
replyChunkChan chan string
2024-03-13 15:20:03 -06:00
persistence bool // whether we will save new messages in the conversation
2024-03-15 23:49:04 -06:00
err error
2024-03-12 01:10:54 -06:00
// ui state
2024-03-15 23:49:04 -06:00
focus focusState
wrap bool // whether message content is wrapped to viewport width
2024-03-15 23:49:04 -06:00
status string // a general status message
showToolResults bool // whether tool calls and results are shown
messageCache []string // cache of syntax highlighted and wrapped message content
2024-03-15 23:49:04 -06:00
messageOffsets []int
2024-03-12 01:10:54 -06:00
// ui elements
content viewport.Model
input textarea.Model
2024-03-13 23:58:31 -06:00
spinner spinner.Model
2024-03-29 14:43:19 -06:00
views *views
2024-03-12 01:10:54 -06:00
}
func initialModel(ctx *lmcli.Context, convShortname string) model {
m := model{
ctx: ctx,
convShortname: convShortname,
conversation: &models.Conversation{},
persistence: true,
2024-03-29 14:43:19 -06:00
stopSignal: make(chan interface{}),
replyChan: make(chan models.Message),
replyChunkChan: make(chan string),
2024-03-29 14:43:19 -06:00
wrap: true,
selectedMessage: -1,
2024-03-29 14:43:19 -06:00
views: &views{},
}
2024-03-29 14:43:19 -06:00
m.state = stateChat
2024-03-29 14:43:19 -06:00
m.content = viewport.New(0, 0)
2024-03-29 14:43:19 -06:00
m.input = textarea.New()
m.input.MaxHeight = 0
m.input.CharLimit = 0
m.input.Placeholder = "Enter a message"
2024-03-29 14:43:19 -06:00
m.input.Focus()
m.input.FocusedStyle.CursorLine = lipgloss.NewStyle()
m.input.FocusedStyle.Base = inputFocusedStyle
m.input.BlurredStyle.Base = inputBlurredStyle
m.input.ShowLineNumbers = false
2024-03-29 14:43:19 -06:00
m.spinner = spinner.New(spinner.WithSpinner(
spinner.Spinner{
Frames: []string{
". ",
".. ",
"...",
".. ",
". ",
" ",
},
FPS: time.Second / 3,
},
))
2024-03-12 01:10:54 -06:00
m.waitingForReply = false
m.status = "Press ctrl+s to send"
return m
2024-03-29 14:43:19 -06:00
}
2024-03-12 01:10:54 -06:00
func (m model) Init() tea.Cmd {
cmds := []tea.Cmd{
2024-03-12 01:10:54 -06:00
textarea.Blink,
2024-03-13 23:58:31 -06:00
m.spinner.Tick,
2024-03-12 20:05:48 -06:00
m.waitForChunk(),
m.waitForReply(),
}
switch m.state {
case stateChat:
if m.convShortname != "" {
cmds = append(cmds, m.loadConversation(m.convShortname))
}
case stateConversations:
cmds = append(cmds, m.loadConversations())
}
return tea.Batch(cmds...)
2024-03-12 01:10:54 -06:00
}
func (m *model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "ctrl+c":
if m.waitingForReply {
m.stopSignal <- ""
return nil
} else {
return tea.Quit
}
case "q":
if m.focus != focusInput {
return tea.Quit
}
default:
switch m.state {
case stateChat:
return m.handleChatInput(msg)
case stateConversations:
return m.handleConversationsInput(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 tea.WindowSizeMsg:
m.content.Width = msg.Width
m.input.SetWidth(msg.Width - m.input.FocusedStyle.Base.GetHorizontalBorderSize())
m.rebuildMessageCache()
m.updateContent()
m.width = msg.Width
m.height = msg.Height
case msgError:
m.err = msg
}
switch m.state {
case stateConversations:
cmds = append(cmds, m.handleConversationsUpdate(msg)...)
case stateChat:
cmds = append(cmds, m.handleChatUpdate(msg)...)
}
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 {
// 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
return ""
}
2024-03-13 20:54:25 -06:00
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)
2024-03-13 20:54:25 -06:00
}
return lipgloss.JoinVertical(lipgloss.Left, sections...)
2024-03-12 01:10:54 -06:00
}
2024-03-13 13:14:03 -06:00
func (m *model) headerView() string {
2024-03-29 09:48:50 -06:00
titleStyle := lipgloss.NewStyle().Bold(true)
var header string
switch m.state {
case stateChat:
var title string
if m.conversation != nil && m.conversation.Title != "" {
title = m.conversation.Title
} else {
title = "Untitled"
}
title = truncateToCellWidth(title, m.width-headerStyle.GetHorizontalPadding(), "...")
header = titleStyle.Render(title)
case stateConversations:
header = titleStyle.Render("Conversations")
2024-03-13 13:14:03 -06:00
}
return headerStyle.Width(m.width).Render(header)
2024-03-13 13:14:03 -06:00
}
2024-03-13 20:54:25 -06:00
func (m *model) errorView() string {
if m.err == nil {
return ""
}
return lipgloss.NewStyle().
Width(m.width).
AlignHorizontal(lipgloss.Center).
2024-03-13 20:54:25 -06:00
Bold(true).
Foreground(lipgloss.Color("1")).
Render(fmt.Sprintf("%s", m.err))
}
2024-03-13 13:14:03 -06:00
func (m *model) footerView() string {
2024-03-13 15:20:03 -06:00
segmentStyle := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1).Faint(true)
segmentSeparator := "|"
2024-03-14 00:39:25 -06:00
savingStyle := segmentStyle.Copy().Bold(true)
2024-03-13 15:20:03 -06:00
saving := ""
if m.persistence {
2024-03-14 00:39:25 -06:00
saving = savingStyle.Foreground(lipgloss.Color("2")).Render("✅💾")
2024-03-13 15:20:03 -06:00
} else {
2024-03-14 00:39:25 -06:00
saving = savingStyle.Foreground(lipgloss.Color("1")).Render("❌💾")
2024-03-13 15:20:03 -06:00
}
2024-03-13 23:58:31 -06:00
status := m.status
if m.waitingForReply {
status += m.spinner.View()
}
leftSegments := []string{
2024-03-13 15:20:03 -06:00
saving,
2024-03-14 11:55:31 -06:00
segmentStyle.Render(status),
}
rightSegments := []string{
segmentStyle.Render(fmt.Sprintf("Model: %s", *m.ctx.Config.Defaults.Model)),
}
left := strings.Join(leftSegments, segmentSeparator)
right := strings.Join(rightSegments, segmentSeparator)
2024-03-13 13:14:03 -06:00
2024-03-13 20:54:25 -06:00
totalWidth := lipgloss.Width(left) + lipgloss.Width(right)
remaining := m.width - totalWidth
2024-03-13 13:14:03 -06:00
var padding string
if remaining > 0 {
padding = strings.Repeat(" ", remaining)
2024-03-13 13:14:03 -06:00
}
footer := left + padding + right
if remaining < 0 {
2024-03-29 09:48:50 -06:00
footer = truncateToCellWidth(footer, m.width, "...")
}
2024-03-14 00:39:25 -06:00
return footerStyle.Width(m.width).Render(footer)
2024-03-13 13:14:03 -06:00
}
func wrapError(err error) tea.Cmd {
2024-03-12 01:10:54 -06:00
return func() tea.Msg {
return msgError(err)
2024-03-12 01:10:54 -06:00
}
}
func Launch(ctx *lmcli.Context, convShortname string) error {
p := tea.NewProgram(initialModel(ctx, convShortname), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
return fmt.Errorf("Error running program: %v", err)
}
return nil
}