tui: split model up into chat/conversations
This commit is contained in:
237
pkg/tui/tui.go
237
pkg/tui/tui.go
@@ -9,19 +9,13 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mlow.ca/mlow/lmcli/pkg/lmcli"
|
||||
models "git.mlow.ca/mlow/lmcli/pkg/lmcli/model"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type appState int
|
||||
type state int
|
||||
|
||||
const (
|
||||
stateChat = iota
|
||||
@@ -32,7 +26,7 @@ const (
|
||||
|
||||
// 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
|
||||
// these elements into the final output
|
||||
type views struct {
|
||||
header string
|
||||
content string
|
||||
@@ -42,135 +36,72 @@ type views struct {
|
||||
}
|
||||
|
||||
type (
|
||||
// send to change the current app state
|
||||
msgChangeState state
|
||||
// sent when an error occurs
|
||||
msgError error
|
||||
)
|
||||
|
||||
type model struct {
|
||||
width int
|
||||
height int
|
||||
|
||||
ctx *lmcli.Context
|
||||
type Options struct {
|
||||
convShortname string
|
||||
|
||||
// application state
|
||||
state appState
|
||||
conversations []models.Conversation
|
||||
lastReplies []models.Message
|
||||
conversation *models.Conversation
|
||||
messages []models.Message
|
||||
selectedMessage int
|
||||
waitingForReply bool
|
||||
editorTarget editorTarget
|
||||
stopSignal chan interface{}
|
||||
replyChan chan models.Message
|
||||
replyChunkChan chan string
|
||||
persistence bool // whether we will save new messages in the conversation
|
||||
err error
|
||||
|
||||
// ui state
|
||||
focus focusState
|
||||
wrap bool // whether message content is wrapped to viewport width
|
||||
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
|
||||
messageOffsets []int
|
||||
|
||||
// ui elements
|
||||
content viewport.Model
|
||||
input textarea.Model
|
||||
spinner spinner.Model
|
||||
views *views
|
||||
}
|
||||
|
||||
func initialModel(ctx *lmcli.Context, convShortname string) model {
|
||||
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{
|
||||
ctx: ctx,
|
||||
convShortname: convShortname,
|
||||
conversation: &models.Conversation{},
|
||||
persistence: true,
|
||||
|
||||
stopSignal: make(chan interface{}),
|
||||
replyChan: make(chan models.Message),
|
||||
replyChunkChan: make(chan string),
|
||||
|
||||
wrap: true,
|
||||
selectedMessage: -1,
|
||||
|
||||
views: &views{},
|
||||
}
|
||||
|
||||
m.state = stateChat
|
||||
|
||||
m.content = viewport.New(0, 0)
|
||||
|
||||
m.input = textarea.New()
|
||||
m.input.MaxHeight = 0
|
||||
m.input.CharLimit = 0
|
||||
m.input.Placeholder = "Enter a message"
|
||||
|
||||
m.input.Focus()
|
||||
m.input.FocusedStyle.CursorLine = lipgloss.NewStyle()
|
||||
m.input.FocusedStyle.Base = inputFocusedStyle
|
||||
m.input.BlurredStyle.Base = inputBlurredStyle
|
||||
m.input.ShowLineNumbers = false
|
||||
|
||||
m.spinner = spinner.New(spinner.WithSpinner(
|
||||
spinner.Spinner{
|
||||
Frames: []string{
|
||||
". ",
|
||||
".. ",
|
||||
"...",
|
||||
".. ",
|
||||
". ",
|
||||
" ",
|
||||
},
|
||||
FPS: time.Second / 3,
|
||||
basemodel: basemodel{
|
||||
opts: &opts,
|
||||
ctx: ctx,
|
||||
views: &views{},
|
||||
},
|
||||
))
|
||||
|
||||
m.waitingForReply = false
|
||||
m.status = "Press ctrl+s to send"
|
||||
}
|
||||
m.state = stateChat
|
||||
m.chat = newChatModel(&m)
|
||||
m.conversations = newConversationsModel(&m)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
cmds := []tea.Cmd{
|
||||
textarea.Blink,
|
||||
m.spinner.Tick,
|
||||
m.waitForChunk(),
|
||||
m.waitForReply(),
|
||||
return func() tea.Msg {
|
||||
return msgChangeState(m.state)
|
||||
}
|
||||
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...)
|
||||
}
|
||||
|
||||
func (m *model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
if m.waitingForReply {
|
||||
m.stopSignal <- ""
|
||||
if m.chat.waitingForReply {
|
||||
m.chat.stopSignal <- struct{}{}
|
||||
return nil
|
||||
} else {
|
||||
return tea.Quit
|
||||
}
|
||||
case "q":
|
||||
if m.focus != focusInput {
|
||||
if m.chat.focus != focusInput {
|
||||
return tea.Quit
|
||||
}
|
||||
default:
|
||||
switch m.state {
|
||||
case stateChat:
|
||||
return m.handleChatInput(msg)
|
||||
return m.chat.handleInput(msg)
|
||||
case stateConversations:
|
||||
return m.handleConversationsInput(msg)
|
||||
return m.conversations.handleInput(msg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -185,22 +116,26 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if cmd != nil {
|
||||
return m, cmd
|
||||
}
|
||||
case msgChangeState:
|
||||
m.state = state(msg)
|
||||
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
|
||||
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:
|
||||
cmds = append(cmds, m.handleConversationsUpdate(msg)...)
|
||||
m.conversations, cmd = m.conversations.Update(msg)
|
||||
case stateChat:
|
||||
cmds = append(cmds, m.handleChatUpdate(msg)...)
|
||||
m.chat, cmd = m.chat.Update(msg)
|
||||
}
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
@@ -240,78 +175,16 @@ func (m model) View() string {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, sections...)
|
||||
}
|
||||
|
||||
func (m *model) headerView() string {
|
||||
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")
|
||||
}
|
||||
return headerStyle.Width(m.width).Render(header)
|
||||
}
|
||||
|
||||
func (m *model) errorView() string {
|
||||
if m.err == nil {
|
||||
func errorBanner(err error, width int) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return lipgloss.NewStyle().
|
||||
Width(m.width).
|
||||
Width(width).
|
||||
AlignHorizontal(lipgloss.Center).
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("1")).
|
||||
Render(fmt.Sprintf("%s", m.err))
|
||||
}
|
||||
|
||||
func (m *model) footerView() string {
|
||||
segmentStyle := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1).Faint(true)
|
||||
segmentSeparator := "|"
|
||||
|
||||
savingStyle := segmentStyle.Copy().Bold(true)
|
||||
saving := ""
|
||||
if m.persistence {
|
||||
saving = savingStyle.Foreground(lipgloss.Color("2")).Render("✅💾")
|
||||
} else {
|
||||
saving = savingStyle.Foreground(lipgloss.Color("1")).Render("❌💾")
|
||||
}
|
||||
|
||||
status := m.status
|
||||
if m.waitingForReply {
|
||||
status += m.spinner.View()
|
||||
}
|
||||
|
||||
leftSegments := []string{
|
||||
saving,
|
||||
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)
|
||||
|
||||
totalWidth := lipgloss.Width(left) + lipgloss.Width(right)
|
||||
remaining := m.width - totalWidth
|
||||
|
||||
var padding string
|
||||
if remaining > 0 {
|
||||
padding = strings.Repeat(" ", remaining)
|
||||
}
|
||||
|
||||
footer := left + padding + right
|
||||
if remaining < 0 {
|
||||
footer = truncateToCellWidth(footer, m.width, "...")
|
||||
}
|
||||
return footerStyle.Width(m.width).Render(footer)
|
||||
Render(fmt.Sprintf("%s", err))
|
||||
}
|
||||
|
||||
func wrapError(err error) tea.Cmd {
|
||||
@@ -321,7 +194,7 @@ func wrapError(err error) tea.Cmd {
|
||||
}
|
||||
|
||||
func Launch(ctx *lmcli.Context, convShortname string) error {
|
||||
p := tea.NewProgram(initialModel(ctx, convShortname), tea.WithAltScreen())
|
||||
p := tea.NewProgram(initialModel(ctx, Options{convShortname}), tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
return fmt.Errorf("Error running program: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user