Compare commits

...

6 Commits

Author SHA1 Message Date
02228d65ac Update tui error handling
- Allow each view to position error banners where they choose
- Add global 'esc' key handler to dismiss errors
2024-12-12 07:10:50 +00:00
d9d1b02ef3 Update dependencies 2024-12-11 16:01:41 +00:00
8c7864fdf6 Show edited message content immediately 2024-12-11 16:01:27 +00:00
d2ce8edad8 Fix reply ordering 2024-12-11 15:56:43 +00:00
c261fdadf5 Updates/fixes to selectedMessage handling in chat TUI view 2024-12-11 15:54:57 +00:00
1996300c40 Hopeful fix to race condition in tui's streamed response handling 2024-12-11 07:17:53 +00:00
12 changed files with 106 additions and 80 deletions

18
go.mod
View File

@ -5,21 +5,21 @@ go 1.21
require (
github.com/alecthomas/chroma/v2 v2.14.0
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.1.1
github.com/charmbracelet/lipgloss v0.13.0
github.com/charmbracelet/bubbletea v1.2.4
github.com/charmbracelet/lipgloss v1.0.0
github.com/muesli/reflow v0.3.0
github.com/spf13/cobra v1.8.1
github.com/sqids/sqids-go v0.4.1
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.5.6
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/x/ansi v0.3.1 // indirect
github.com/charmbracelet/x/term v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.6.0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@ -30,14 +30,14 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.23 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
)

36
go.sum
View File

@ -12,14 +12,14 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbletea v1.1.1 h1:KJ2/DnmpfqFtDNVTvYZ6zpPFL9iRCRr0qqKOCvppbPY=
github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
github.com/charmbracelet/x/ansi v0.3.1 h1:CRO6lc/6HCx2/D6S/GZ87jDvRvk6GtPyFP+IljkNtqI=
github.com/charmbracelet/x/ansi v0.3.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE=
github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM=
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
github.com/charmbracelet/x/ansi v0.6.0 h1:qOznutrb93gx9oMiGf7caF7bqqubh6YIM0SWKyA08pA=
github.com/charmbracelet/x/ansi v0.6.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
@ -47,8 +47,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@ -71,20 +71,20 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/sqids/sqids-go v0.4.1 h1:eQKYzmAZbLlRwHeHYPF35QhgxwZHLnlmVj9AkIj/rrw=
github.com/sqids/sqids-go v0.4.1/go.mod h1:EMwHuPQgSNFS0A49jESTfIQS+066XQTVhukrzEPScl8=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=

View File

@ -298,39 +298,36 @@ func (s *repo) CloneBranch(messageToClone Message) (*Message, uint, error) {
func fetchMessages(db *gorm.DB) ([]Message, error) {
var messages []Message
if err := db.Preload("Conversation").Find(&messages).Error; err != nil {
if err := db.Preload("Conversation").Order("id ASC").Find(&messages).Error; err != nil {
return nil, fmt.Errorf("Could not fetch messages: %v", err)
}
messageMap := make(map[uint]Message)
messageMap := make(map[uint]*Message)
for i, message := range messages {
messageMap[messages[i].ID] = message
messageMap[message.ID] = &messages[i]
}
// Create a map to store replies by their parent ID
repliesMap := make(map[uint][]Message)
for i, message := range messages {
if messages[i].ParentID != nil {
repliesMap[*messages[i].ParentID] = append(repliesMap[*messages[i].ParentID], message)
for _, message := range messages {
if message.ParentID != nil {
repliesMap[*message.ParentID] = append(repliesMap[*message.ParentID], message)
}
}
// Assign replies, parent, and selected reply to each message
for i := range messages {
if replies, exists := repliesMap[messages[i].ID]; exists {
messages[i].Replies = make([]Message, len(replies))
for j, m := range replies {
messages[i].Replies[j] = m
}
messages[i].Replies = replies
}
if messages[i].ParentID != nil {
if parent, exists := messageMap[*messages[i].ParentID]; exists {
messages[i].Parent = &parent
messages[i].Parent = parent
}
}
if messages[i].SelectedReplyID != nil {
if selectedReply, exists := messageMap[*messages[i].SelectedReplyID]; exists {
messages[i].SelectedReply = &selectedReply
messages[i].SelectedReply = selectedReply
}
}
}
@ -359,27 +356,6 @@ func (s *repo) buildPath(message *Message, getNext func(*Message) *uint) ([]Mess
messageMap[messages[i].ID] = &messages[i]
}
// Construct Replies
repliesMap := make(map[uint][]*Message, len(messages))
for _, m := range messageMap {
if m.ParentID == nil {
continue
}
if p, ok := messageMap[*m.ParentID]; ok {
repliesMap[p.ID] = append(repliesMap[p.ID], m)
}
}
// Add replies to messages
for _, m := range messageMap {
if replies, ok := repliesMap[m.ID]; ok {
m.Replies = make([]Message, len(replies))
for idx, reply := range replies {
m.Replies[idx] = *reply
}
}
}
// Build the path
var path []Message
nextID := &message.ID

View File

@ -13,7 +13,7 @@ type ViewModel interface {
// View methods
Header(width int) string
// Render the view's main content into a container of the given dimensions
Content(width, height int) string
Content(width, height int, errors string) string
Footer(width int) string
}

View File

@ -60,6 +60,14 @@ func (m *Model) Init() tea.Cmd {
}
func (m *Model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "esc":
if len(m.errs) > 0 {
m.errs = m.errs[1:]
return shared.KeyHandled(msg)
}
}
view, cmd := m.views[m.activeView].Update(msg)
m.views[m.activeView] = view
if cmd != nil {
@ -108,10 +116,16 @@ func (m *Model) View() string {
errBanners := make([]string, len(m.errs))
for idx, err := range m.errs {
errBanners[idx] = tuiutil.ErrorBanner(err, m.width)
fixedUIHeight += tuiutil.Height(errBanners[idx])
}
var errors string
if len(errBanners) > 0 {
errors = lipgloss.JoinVertical(lipgloss.Left, errBanners...)
} else {
errors = ""
}
fixedUIHeight += tuiutil.Height(errors)
content := m.views[m.activeView].Content(m.width, m.height-fixedUIHeight)
content := m.views[m.activeView].Content(m.width, m.height-fixedUIHeight, errors)
sections := make([]string, 0, 4)
if header != "" {
@ -123,9 +137,6 @@ func (m *Model) View() string {
if footer != "" {
sections = append(sections, footer)
}
for _, errBanner := range errBanners {
sections = append(sections, errBanner)
}
return lipgloss.JoinVertical(lipgloss.Left, sections...)
}

View File

@ -81,9 +81,9 @@ type Model struct {
selectedMessage int
editorTarget editorTarget
stopSignal chan struct{}
replyChan chan conversation.Message
chatReplyChunks chan provider.Chunk
persistence bool // whether we will save new messages in the conversation
promptCaching bool // whether prompt caching is enabled
// UI state
focus focusState
@ -134,8 +134,7 @@ func Chat(app *model.AppModel) *Model {
persistence: true,
stopSignal: make(chan struct{}),
replyChan: make(chan conversation.Message),
chatReplyChunks: make(chan provider.Chunk),
chatReplyChunks: make(chan provider.Chunk, 1),
wrap: true,
selectedMessage: -1,

View File

@ -5,6 +5,7 @@ import (
"git.mlow.ca/mlow/lmcli/pkg/api"
"git.mlow.ca/mlow/lmcli/pkg/conversation"
"git.mlow.ca/mlow/lmcli/pkg/provider"
"git.mlow.ca/mlow/lmcli/pkg/tui/model"
"git.mlow.ca/mlow/lmcli/pkg/tui/shared"
tea "github.com/charmbracelet/bubbletea"
@ -127,6 +128,15 @@ func (m *Model) promptLLM() tea.Cmd {
m.spinner.Tick,
func() tea.Msg {
resp, err := m.App.Prompt(m.App.Messages, m.chatReplyChunks, m.stopSignal)
// These empty chunk sends prevent a race condition where a final
// chunk may be received on m.chatReplyChunks after the
// msgChatResponse message is handled, resulting in that chunk
// appearing twice at the end of the final output
// One send reduces the frequency of the race, two seems to
// eliminate it
m.chatReplyChunks <- provider.Chunk{}
m.chatReplyChunks <- provider.Chunk{}
if err != nil {
return msgChatResponseError{Err: err}
}

View File

@ -53,6 +53,13 @@ func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd {
return shared.KeyHandled(msg)
case "ctrl+t":
m.showDetails = !m.showDetails
if !m.showDetails && m.selectedMessage == 0 {
if len(m.App.Messages) > 1 {
m.selectedMessage = 1
} else {
m.selectedMessage = -1
}
}
m.rebuildMessageCache()
m.updateContent()
return shared.KeyHandled(msg)
@ -157,6 +164,9 @@ func (m *Model) handleInputKey(msg tea.KeyMsg) tea.Cmd {
}
offset := m.messageOffsets[m.selectedMessage]
tuiutil.ScrollIntoView(&m.content, offset, m.content.Height/2)
} else {
m.selectedMessage = -1
m.content.GotoTop()
}
m.updateContent()
m.input.Blur()

View File

@ -78,6 +78,12 @@ func (m *Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
if m.App.Conversation.ID > 0 {
// (re)load conversation contents
cmds = append(cmds, m.loadConversationMessages())
} else {
if len(m.App.Messages) > 0 && m.showDetails {
m.selectedMessage = 0
} else {
m.selectedMessage = -1
}
}
case tuiutil.MsgTempfileEditorClosed:
contents := string(msg)
@ -89,6 +95,7 @@ func (m *Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
if toEdit.Content != contents {
toEdit.Content = contents
m.setMessage(m.selectedMessage, toEdit)
m.updateContent()
if m.persistence && toEdit.ID > 0 {
// create clone of message with its new contents
cmds = append(cmds, m.cloneMessage(toEdit, true))
@ -97,17 +104,14 @@ func (m *Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
}
case msgConversationMessagesLoaded:
m.App.Messages = msg.messages
if m.selectedMessage == -1 {
m.selectedMessage = len(msg.messages) - 1
} else {
m.selectedMessage = min(m.selectedMessage, len(m.App.Messages))
}
m.selectedMessage = len(msg.messages) - 1
m.rebuildMessageCache()
m.updateContent()
case msgChatResponseChunk:
cmds = append(cmds, m.waitForResponseChunk()) // wait for the next chunk
if msg.Content == "" {
// skip empty chunks
break
}

View File

@ -245,7 +245,7 @@ func (m *Model) conversationMessagesView() string {
return sb.String()
}
func (m *Model) Content(width, height int) string {
func (m *Model) Content(width, height int, errors string) string {
// calculate clamped input height to accomodate input text
// minimum 4 lines, maximum half of content area
inputHeight := max(4, min(height/2, m.input.LineCount()))
@ -255,7 +255,15 @@ func (m *Model) Content(width, height int) string {
// remaining height towards content
m.content.Width, m.content.Height = width, height-tuiutil.Height(input)
content := m.content.View()
return lipgloss.JoinVertical(lipgloss.Left, content, input)
var sections []string
if errors != "" {
sections = []string{content, errors, input}
} else {
sections = []string{content, input}
}
return lipgloss.JoinVertical(lipgloss.Left, sections...)
}
func (m *Model) Header(width int) string {

View File

@ -218,9 +218,13 @@ func (m *Model) Header(width int) string {
return styles.Header.Width(width).Render(header)
}
func (m *Model) Content(width int, height int) string {
func (m *Model) Content(width int, height int, errors string) string {
m.content.Width, m.content.Height = width, height
return m.content.View()
content := m.content.View()
if errors != "" {
content += errors
}
return content
}
func (m *Model) Footer(width int) string {

View File

@ -121,11 +121,15 @@ func (m *Model) Header(width int) string {
return styles.Header.Width(width).Render(header)
}
func (m *Model) Content(width, height int) string {
func (m *Model) Content(width, height int, errors string) string {
// TODO: see Header()
currentModel := " Active model: " + m.App.ActiveModel(lipgloss.NewStyle())
m.modelList.Width, m.modelList.Height = width, height - 2
return "\n" + currentModel + "\n" + m.modelList.View()
content := "\n" + currentModel + "\n" + m.modelList.View()
if errors != "" {
content += errors
}
return content
}
func (m *Model) Footer(width int) string {