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"
|
|
|
|
)
|
|
|
|
|
2024-03-29 18:41:12 -06:00
|
|
|
type appState int
|
|
|
|
|
|
|
|
const (
|
|
|
|
stateConversation = iota
|
2024-03-30 19:02:07 -06:00
|
|
|
stateConversationList
|
2024-03-29 18:41:12 -06:00
|
|
|
//stateModelSelect // stateOptions?
|
|
|
|
//stateHelp
|
|
|
|
)
|
|
|
|
|
2024-03-12 11:50:10 -06:00
|
|
|
type focusState int
|
|
|
|
|
|
|
|
const (
|
|
|
|
focusInput focusState = iota
|
|
|
|
focusMessages
|
|
|
|
)
|
|
|
|
|
2024-03-16 15:56:45 -06:00
|
|
|
type editorTarget int
|
|
|
|
|
|
|
|
const (
|
|
|
|
input editorTarget = iota
|
|
|
|
selectedMessage
|
|
|
|
)
|
|
|
|
|
2024-03-30 20:03:53 -06:00
|
|
|
// we populate these fields as part of Update(), and let View() be
|
|
|
|
// responsible for returning the final composition of elements
|
|
|
|
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
|
2024-03-29 18:41:12 -06:00
|
|
|
state appState
|
2024-03-30 19:02:07 -06:00
|
|
|
conversations []models.Conversation
|
2024-03-30 20:03:53 -06:00
|
|
|
lastReplies []models.Message
|
2024-03-12 12:33:57 -06:00
|
|
|
conversation *models.Conversation
|
|
|
|
messages []models.Message
|
2024-03-29 18:41:12 -06:00
|
|
|
selectedMessage int
|
2024-03-12 12:33:57 -06:00
|
|
|
waitingForReply bool
|
2024-03-16 15:56:45 -06:00
|
|
|
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
|
2024-03-16 18:25:42 -06:00
|
|
|
wrap bool // whether message content is wrapped to viewport width
|
2024-03-15 23:49:04 -06:00
|
|
|
status string // a general status message
|
2024-03-26 01:59:39 -06:00
|
|
|
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
|
|
|
}
|
|
|
|
|
2024-03-30 20:03:53 -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
|
|
|
|
2024-03-30 20:03:53 -06:00
|
|
|
stopSignal: make(chan interface{}),
|
|
|
|
replyChan: make(chan models.Message),
|
|
|
|
replyChunkChan: make(chan string),
|
2024-03-29 14:43:19 -06:00
|
|
|
|
2024-03-30 20:03:53 -06:00
|
|
|
wrap: true,
|
|
|
|
selectedMessage: -1,
|
2024-03-29 14:43:19 -06:00
|
|
|
|
2024-03-30 20:03:53 -06:00
|
|
|
views: &views{},
|
|
|
|
}
|
2024-03-29 14:43:19 -06:00
|
|
|
|
2024-03-30 20:03:53 -06:00
|
|
|
m.state = stateConversation
|
2024-03-29 14:43:19 -06:00
|
|
|
|
2024-03-30 20:03:53 -06:00
|
|
|
m.content = viewport.New(0, 0)
|
2024-03-29 14:43:19 -06:00
|
|
|
|
2024-03-30 20:03:53 -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
|
|
|
|
2024-03-30 20:03:53 -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
|
|
|
|
2024-03-30 20:03:53 -06:00
|
|
|
m.spinner = spinner.New(spinner.WithSpinner(
|
|
|
|
spinner.Spinner{
|
|
|
|
Frames: []string{
|
|
|
|
". ",
|
|
|
|
".. ",
|
|
|
|
"...",
|
|
|
|
".. ",
|
|
|
|
". ",
|
|
|
|
" ",
|
|
|
|
},
|
|
|
|
FPS: time.Second / 3,
|
|
|
|
},
|
|
|
|
))
|
2024-03-12 01:10:54 -06:00
|
|
|
|
2024-03-30 20:03:53 -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 {
|
2024-03-30 19:02:07 -06:00
|
|
|
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(),
|
2024-03-30 19:02:07 -06:00
|
|
|
}
|
|
|
|
switch m.state {
|
|
|
|
case stateConversation:
|
|
|
|
if m.convShortname != "" {
|
|
|
|
cmds = append(cmds, m.loadConversation(m.convShortname))
|
|
|
|
}
|
|
|
|
case stateConversationList:
|
|
|
|
cmds = append(cmds, m.loadConversations())
|
|
|
|
}
|
|
|
|
return tea.Batch(cmds...)
|
2024-03-12 01:10:54 -06:00
|
|
|
}
|
|
|
|
|
2024-03-29 18:41:12 -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 stateConversation:
|
|
|
|
return m.handleConversationInput(msg)
|
2024-03-30 19:02:07 -06:00
|
|
|
case stateConversationList:
|
|
|
|
return m.handleConversationListInput(msg)
|
2024-03-29 18:41:12 -06:00
|
|
|
}
|
|
|
|
}
|
|
|
|
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 {
|
2024-03-30 19:02:07 -06:00
|
|
|
case stateConversationList:
|
|
|
|
cmds = append(cmds, m.handleConversationListUpdate(msg)...)
|
2024-03-29 18:41:12 -06:00
|
|
|
case stateConversation:
|
|
|
|
cmds = append(cmds, m.handleConversationUpdate(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 {
|
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 19:02:07 -06:00
|
|
|
case stateConversationList:
|
|
|
|
sections = append(sections, m.views.content)
|
|
|
|
if m.views.error != "" {
|
|
|
|
sections = append(sections, m.views.error)
|
|
|
|
}
|
2024-03-29 18:41:12 -06:00
|
|
|
case stateConversation:
|
|
|
|
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-13 13:14:03 -06:00
|
|
|
func (m *model) headerView() string {
|
2024-03-29 09:48:50 -06:00
|
|
|
titleStyle := lipgloss.NewStyle().Bold(true)
|
2024-03-30 19:02:07 -06:00
|
|
|
var header string
|
|
|
|
switch m.state {
|
|
|
|
case stateConversation:
|
|
|
|
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 stateConversationList:
|
|
|
|
header = titleStyle.Render("Conversations")
|
2024-03-13 13:14:03 -06:00
|
|
|
}
|
2024-03-30 19:02:07 -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().
|
2024-03-13 21:07:41 -06:00
|
|
|
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)
|
2024-03-13 13:52:41 -06:00
|
|
|
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()
|
|
|
|
}
|
|
|
|
|
2024-03-13 13:52:41 -06:00
|
|
|
leftSegments := []string{
|
2024-03-13 15:20:03 -06:00
|
|
|
saving,
|
2024-03-14 11:55:31 -06:00
|
|
|
segmentStyle.Render(status),
|
2024-03-13 13:52:41 -06:00
|
|
|
}
|
|
|
|
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:52:41 -06:00
|
|
|
|
2024-03-13 13:14:03 -06:00
|
|
|
var padding string
|
2024-03-13 13:52:41 -06:00
|
|
|
if remaining > 0 {
|
|
|
|
padding = strings.Repeat(" ", remaining)
|
2024-03-13 13:14:03 -06:00
|
|
|
}
|
|
|
|
|
2024-03-13 13:52:41 -06:00
|
|
|
footer := left + padding + right
|
|
|
|
if remaining < 0 {
|
2024-03-29 09:48:50 -06:00
|
|
|
footer = truncateToCellWidth(footer, m.width, "...")
|
2024-03-13 13:52:41 -06:00
|
|
|
}
|
2024-03-14 00:39:25 -06:00
|
|
|
return footerStyle.Width(m.width).Render(footer)
|
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 {
|
|
|
|
p := tea.NewProgram(initialModel(ctx, convShortname), tea.WithAltScreen())
|
|
|
|
if _, err := p.Run(); err != nil {
|
|
|
|
return fmt.Errorf("Error running program: %v", err)
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|