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 ( require (
github.com/alecthomas/chroma/v2 v2.14.0 github.com/alecthomas/chroma/v2 v2.14.0
github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.1.1 github.com/charmbracelet/bubbletea v1.2.4
github.com/charmbracelet/lipgloss v0.13.0 github.com/charmbracelet/lipgloss v1.0.0
github.com/muesli/reflow v0.3.0 github.com/muesli/reflow v0.3.0
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/sqids/sqids-go v0.4.1 github.com/sqids/sqids-go v0.4.1
gopkg.in/yaml.v3 v3.0.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 gorm.io/gorm v1.25.12
) )
require ( require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/x/ansi v0.3.1 // indirect github.com/charmbracelet/x/ansi v0.6.0 // indirect
github.com/charmbracelet/x/term v0.2.0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // 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-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // 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/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.2 // indirect github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/sync v0.8.0 // indirect golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.25.0 // indirect golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.18.0 // indirect golang.org/x/text v0.21.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // 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/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 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= 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.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE=
github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM=
github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
github.com/charmbracelet/x/ansi v0.3.1 h1:CRO6lc/6HCx2/D6S/GZ87jDvRvk6GtPyFP+IljkNtqI= github.com/charmbracelet/x/ansi v0.6.0 h1:qOznutrb93gx9oMiGf7caF7bqqubh6YIM0SWKyA08pA=
github.com/charmbracelet/x/ansi v0.3.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/ansi v0.6.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q=
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= 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/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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= 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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 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-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.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 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= 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/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 h1:eQKYzmAZbLlRwHeHYPF35QhgxwZHLnlmVj9AkIj/rrw=
github.com/sqids/sqids-go v0.4.1/go.mod h1:EMwHuPQgSNFS0A49jESTfIQS+066XQTVhukrzEPScl8= 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.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 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 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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= 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 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= 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) { func fetchMessages(db *gorm.DB) ([]Message, error) {
var messages []Message 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) 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 { 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 // Create a map to store replies by their parent ID
repliesMap := make(map[uint][]Message) repliesMap := make(map[uint][]Message)
for i, message := range messages { for _, message := range messages {
if messages[i].ParentID != nil { if message.ParentID != nil {
repliesMap[*messages[i].ParentID] = append(repliesMap[*messages[i].ParentID], message) repliesMap[*message.ParentID] = append(repliesMap[*message.ParentID], message)
} }
} }
// Assign replies, parent, and selected reply to each message // Assign replies, parent, and selected reply to each message
for i := range messages { for i := range messages {
if replies, exists := repliesMap[messages[i].ID]; exists { if replies, exists := repliesMap[messages[i].ID]; exists {
messages[i].Replies = make([]Message, len(replies)) messages[i].Replies = replies
for j, m := range replies {
messages[i].Replies[j] = m
}
} }
if messages[i].ParentID != nil { if messages[i].ParentID != nil {
if parent, exists := messageMap[*messages[i].ParentID]; exists { if parent, exists := messageMap[*messages[i].ParentID]; exists {
messages[i].Parent = &parent messages[i].Parent = parent
} }
} }
if messages[i].SelectedReplyID != nil { if messages[i].SelectedReplyID != nil {
if selectedReply, exists := messageMap[*messages[i].SelectedReplyID]; exists { 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] 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 // Build the path
var path []Message var path []Message
nextID := &message.ID nextID := &message.ID

View File

@ -13,7 +13,7 @@ type ViewModel interface {
// View methods // View methods
Header(width int) string Header(width int) string
// Render the view's main content into a container of the given dimensions // 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 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 { 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) view, cmd := m.views[m.activeView].Update(msg)
m.views[m.activeView] = view m.views[m.activeView] = view
if cmd != nil { if cmd != nil {
@ -108,10 +116,16 @@ func (m *Model) View() string {
errBanners := make([]string, len(m.errs)) errBanners := make([]string, len(m.errs))
for idx, err := range m.errs { for idx, err := range m.errs {
errBanners[idx] = tuiutil.ErrorBanner(err, m.width) 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) sections := make([]string, 0, 4)
if header != "" { if header != "" {
@ -123,9 +137,6 @@ func (m *Model) View() string {
if footer != "" { if footer != "" {
sections = append(sections, footer) sections = append(sections, footer)
} }
for _, errBanner := range errBanners {
sections = append(sections, errBanner)
}
return lipgloss.JoinVertical(lipgloss.Left, sections...) return lipgloss.JoinVertical(lipgloss.Left, sections...)
} }

View File

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

View File

@ -5,6 +5,7 @@ import (
"git.mlow.ca/mlow/lmcli/pkg/api" "git.mlow.ca/mlow/lmcli/pkg/api"
"git.mlow.ca/mlow/lmcli/pkg/conversation" "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/model"
"git.mlow.ca/mlow/lmcli/pkg/tui/shared" "git.mlow.ca/mlow/lmcli/pkg/tui/shared"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@ -127,6 +128,15 @@ func (m *Model) promptLLM() tea.Cmd {
m.spinner.Tick, m.spinner.Tick,
func() tea.Msg { func() tea.Msg {
resp, err := m.App.Prompt(m.App.Messages, m.chatReplyChunks, m.stopSignal) 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 { if err != nil {
return msgChatResponseError{Err: err} return msgChatResponseError{Err: err}
} }

View File

@ -53,6 +53,13 @@ func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd {
return shared.KeyHandled(msg) return shared.KeyHandled(msg)
case "ctrl+t": case "ctrl+t":
m.showDetails = !m.showDetails 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.rebuildMessageCache()
m.updateContent() m.updateContent()
return shared.KeyHandled(msg) return shared.KeyHandled(msg)
@ -157,6 +164,9 @@ func (m *Model) handleInputKey(msg tea.KeyMsg) tea.Cmd {
} }
offset := m.messageOffsets[m.selectedMessage] offset := m.messageOffsets[m.selectedMessage]
tuiutil.ScrollIntoView(&m.content, offset, m.content.Height/2) tuiutil.ScrollIntoView(&m.content, offset, m.content.Height/2)
} else {
m.selectedMessage = -1
m.content.GotoTop()
} }
m.updateContent() m.updateContent()
m.input.Blur() 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 { if m.App.Conversation.ID > 0 {
// (re)load conversation contents // (re)load conversation contents
cmds = append(cmds, m.loadConversationMessages()) cmds = append(cmds, m.loadConversationMessages())
} else {
if len(m.App.Messages) > 0 && m.showDetails {
m.selectedMessage = 0
} else {
m.selectedMessage = -1
}
} }
case tuiutil.MsgTempfileEditorClosed: case tuiutil.MsgTempfileEditorClosed:
contents := string(msg) contents := string(msg)
@ -89,6 +95,7 @@ func (m *Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
if toEdit.Content != contents { if toEdit.Content != contents {
toEdit.Content = contents toEdit.Content = contents
m.setMessage(m.selectedMessage, toEdit) m.setMessage(m.selectedMessage, toEdit)
m.updateContent()
if m.persistence && toEdit.ID > 0 { if m.persistence && toEdit.ID > 0 {
// create clone of message with its new contents // create clone of message with its new contents
cmds = append(cmds, m.cloneMessage(toEdit, true)) cmds = append(cmds, m.cloneMessage(toEdit, true))
@ -97,17 +104,14 @@ func (m *Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
} }
case msgConversationMessagesLoaded: case msgConversationMessagesLoaded:
m.App.Messages = msg.messages m.App.Messages = msg.messages
if m.selectedMessage == -1 { m.selectedMessage = len(msg.messages) - 1
m.selectedMessage = len(msg.messages) - 1
} else {
m.selectedMessage = min(m.selectedMessage, len(m.App.Messages))
}
m.rebuildMessageCache() m.rebuildMessageCache()
m.updateContent() m.updateContent()
case msgChatResponseChunk: case msgChatResponseChunk:
cmds = append(cmds, m.waitForResponseChunk()) // wait for the next chunk cmds = append(cmds, m.waitForResponseChunk()) // wait for the next chunk
if msg.Content == "" { if msg.Content == "" {
// skip empty chunks
break break
} }

View File

@ -245,7 +245,7 @@ func (m *Model) conversationMessagesView() string {
return sb.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 // calculate clamped input height to accomodate input text
// minimum 4 lines, maximum half of content area // minimum 4 lines, maximum half of content area
inputHeight := max(4, min(height/2, m.input.LineCount())) 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 // remaining height towards content
m.content.Width, m.content.Height = width, height-tuiutil.Height(input) m.content.Width, m.content.Height = width, height-tuiutil.Height(input)
content := m.content.View() 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 { 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) 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 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 { 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) 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() // TODO: see Header()
currentModel := " Active model: " + m.App.ActiveModel(lipgloss.NewStyle()) currentModel := " Active model: " + m.App.ActiveModel(lipgloss.NewStyle())
m.modelList.Width, m.modelList.Height = width, height - 2 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 { func (m *Model) Footer(width int) string {