tui: revamp footer (some more)

Simplified layout logic, reorganized elements
This commit is contained in:
Matt Low 2024-10-01 03:38:15 +00:00
parent bb48bc9abd
commit 93c2fb3d1e
6 changed files with 148 additions and 110 deletions

View File

@ -26,6 +26,7 @@ type AppModel struct {
Model string Model string
ProviderName string ProviderName string
Provider provider.ChatCompletionProvider Provider provider.ChatCompletionProvider
Agent *lmcli.Agent
} }
func NewAppModel(ctx *lmcli.Context, initialConversation *api.Conversation) *AppModel { func NewAppModel(ctx *lmcli.Context, initialConversation *api.Conversation) *AppModel {
@ -42,6 +43,7 @@ func NewAppModel(ctx *lmcli.Context, initialConversation *api.Conversation) *App
model, provider, _, _ := ctx.GetModelProvider(*ctx.Config.Defaults.Model, "") model, provider, _, _ := ctx.GetModelProvider(*ctx.Config.Defaults.Model, "")
app.Model = model app.Model = model
app.ProviderName = provider app.ProviderName = provider
app.Agent = ctx.GetAgent(ctx.Config.Defaults.Agent)
return app return app
} }
@ -264,9 +266,8 @@ func (a *AppModel) Prompt(
Temperature: *a.Ctx.Config.Defaults.Temperature, Temperature: *a.Ctx.Config.Defaults.Temperature,
} }
agent := a.Ctx.GetAgent(a.Ctx.Config.Defaults.Agent) if a.Agent != nil {
if agent != nil { params.Toolbox = a.Agent.Toolbox
params.Toolbox = agent.Toolbox
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())

View File

@ -61,22 +61,52 @@ func Width(str string) int {
return ansi.PrintableRuneWidth(str) return ansi.PrintableRuneWidth(str)
} }
// truncate a string until its rendered cell width + the provided tail fits func TruncateRightToCellWidth(str string, width int, tail string) string {
// within the given width cellWidth := ansi.PrintableRuneWidth(str)
func TruncateToCellWidth(str string, width int, tail string) string { if cellWidth <= width {
cellWidth := ansi.PrintableRuneWidth(str) return str
if cellWidth <= width { }
return str
} tailWidth := ansi.PrintableRuneWidth(tail)
tailWidth := ansi.PrintableRuneWidth(tail) if width <= tailWidth {
for { return tail[:width]
str = str[:len(str)-((cellWidth+tailWidth)-width)] }
cellWidth = ansi.PrintableRuneWidth(str)
if cellWidth+tailWidth <= max(width, 0) { targetWidth := width - tailWidth
break runes := []rune(str)
}
} for i := len(runes) - 1; i >= 0; i-- {
return str + tail str = string(runes[:i])
if ansi.PrintableRuneWidth(str) <= targetWidth {
return str + tail
}
}
return tail
}
func TruncateLeftToCellWidth(str string, width int, tail string) string {
cellWidth := ansi.PrintableRuneWidth(str)
if cellWidth <= width {
return str
}
tailWidth := ansi.PrintableRuneWidth(tail)
if width <= tailWidth {
return tail[:width]
}
targetWidth := width - tailWidth
runes := []rune(str)
for i := 0; i < len(runes); i++ {
str = string(runes[i:])
if ansi.PrintableRuneWidth(str) <= targetWidth {
return tail + str
}
}
return tail
} }
func ScrollIntoView(vp *viewport.Model, offset int, edge int) { func ScrollIntoView(vp *viewport.Model, offset int, edge int) {
@ -105,4 +135,3 @@ func ErrorBanner(err error, width int) string {
Foreground(lipgloss.Color("1")). Foreground(lipgloss.Color("1")).
Render(fmt.Sprintf("%s", err)) Render(fmt.Sprintf("%s", err))
} }

View File

@ -107,6 +107,28 @@ type Model struct {
elapsed time.Duration elapsed time.Duration
} }
func getSpinner() spinner.Model {
return spinner.New(spinner.WithSpinner(
spinner.Spinner{
Frames: []string{
"∙∙∙",
"●∙∙",
"●●∙",
"●●●",
"∙●●",
"∙∙●",
"∙∙∙",
"∙∙●",
"∙●●",
"●●●",
"●●∙",
"●∙∙",
},
FPS: 440 * time.Millisecond,
},
))
}
func Chat(app *model.AppModel) *Model { func Chat(app *model.AppModel) *Model {
m := Model{ m := Model{
App: app, App: app,
@ -121,21 +143,9 @@ func Chat(app *model.AppModel) *Model {
wrap: true, wrap: true,
selectedMessage: -1, selectedMessage: -1,
content: viewport.New(0, 0), content: viewport.New(0, 0),
input: textarea.New(), input: textarea.New(),
spinner: spinner.New(spinner.WithSpinner( spinner: getSpinner(),
spinner.Spinner{
Frames: []string{
". ",
".. ",
"...",
".. ",
". ",
" ",
},
FPS: time.Second / 3,
},
)),
replyCursor: cursor.New(), replyCursor: cursor.New(),
} }

View File

@ -122,17 +122,21 @@ func (m *Model) executeToolCalls(toolCalls []api.ToolCall) tea.Cmd {
func (m *Model) promptLLM() tea.Cmd { func (m *Model) promptLLM() tea.Cmd {
m.state = pendingResponse m.state = pendingResponse
m.spinner = getSpinner()
m.replyCursor.Blink = false m.replyCursor.Blink = false
m.startTime = time.Now() m.startTime = time.Now()
m.elapsed = 0 m.elapsed = 0
m.tokenCount = 0 m.tokenCount = 0
return func() tea.Msg { return tea.Batch(
resp, err := m.App.Prompt(m.App.Messages, m.chatReplyChunks, m.stopSignal) m.spinner.Tick,
if err != nil { func() tea.Msg {
return msgChatResponseError{ Err: err } resp, err := m.App.Prompt(m.App.Messages, m.chatReplyChunks, m.stopSignal)
} if err != nil {
return msgChatResponse(resp) return msgChatResponseError{Err: err}
} }
return msgChatResponse(resp)
},
)
} }

View File

@ -85,9 +85,9 @@ func (m *Model) scrollSelection(dir int) {
if newIdx != m.selectedMessage { if newIdx != m.selectedMessage {
m.selectedMessage = newIdx m.selectedMessage = newIdx
m.updateContent() m.updateContent()
yOffset := m.messageOffsets[m.selectedMessage]
tuiutil.ScrollIntoView(&m.content, yOffset, m.content.Height/2)
} }
yOffset := m.messageOffsets[m.selectedMessage]
tuiutil.ScrollIntoView(&m.content, yOffset, m.content.Height/2)
} }
// handleMessagesKey handles input when the messages pane is focused // handleMessagesKey handles input when the messages pane is focused

View File

@ -41,7 +41,7 @@ var (
Faint(true). Faint(true).
Border(lipgloss.RoundedBorder(), true, true, true, false) Border(lipgloss.RoundedBorder(), true, true, true, false)
footerStyle = lipgloss.NewStyle() footerStyle = lipgloss.NewStyle().Padding(0, 1)
) )
func (m *Model) renderMessageHeading(i int, message *api.Message) string { func (m *Model) renderMessageHeading(i int, message *api.Message) string {
@ -232,7 +232,7 @@ func (m *Model) conversationMessagesView() string {
if m.state == pendingResponse && m.App.Messages[len(m.App.Messages)-1].Role != api.MessageRoleAssistant { if m.state == pendingResponse && m.App.Messages[len(m.App.Messages)-1].Role != api.MessageRoleAssistant {
heading := m.renderMessageHeading(-1, &api.Message{ heading := m.renderMessageHeading(-1, &api.Message{
Role: api.MessageRoleAssistant, Role: api.MessageRoleAssistant,
Metadata: api.MessageMeta { Metadata: api.MessageMeta{
GenerationModel: &m.App.Model, GenerationModel: &m.App.Model,
}, },
}) })
@ -265,105 +265,99 @@ func (m *Model) Header(width int) string {
} else { } else {
title = "Untitled" title = "Untitled"
} }
title = tuiutil.TruncateToCellWidth(title, width-styles.Header.GetHorizontalPadding(), "...") title = tuiutil.TruncateRightToCellWidth(title, width-styles.Header.GetHorizontalPadding(), "...")
header := titleStyle.Render(title) header := titleStyle.Render(title)
return styles.Header.Width(width).Render(header) return styles.Header.Width(width).Render(header)
} }
func (m *Model) Footer(width int) string { func (m *Model) Footer(width int) string {
segmentStyle := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1).Faint(true) segmentStyle := lipgloss.NewStyle().Faint(true)
segmentSeparator := "|" segmentSeparator := segmentStyle.Render(" | ")
// Left segments // Left segments
leftSegments := make([]string, 0, 4)
leftSegments := []string{} if m.state == pendingResponse {
leftSegments = append(leftSegments, segmentStyle.Render(m.spinner.View()))
} else {
leftSegments = append(leftSegments, segmentStyle.Render("∙∙∙"))
}
if m.elapsed > 0 && m.tokenCount > 0 {
throughput := fmt.Sprintf("%.0f t/sec", float64(m.tokenCount)/m.elapsed.Seconds())
leftSegments = append(leftSegments, segmentStyle.Render(throughput))
}
// var status string
// switch m.state {
// case pendingResponse:
// status = "Press ctrl+c to cancel"
// default:
// status = "Press ctrl+s to send"
// }
// leftSegments = append(leftSegments, segmentStyle.Render(status))
// Right segments
rightSegments := make([]string, 0, 8)
if m.App.Agent != nil {
rightSegments = append(rightSegments, segmentStyle.Render(m.App.Agent.Name))
}
model := segmentStyle.Render(m.App.ActiveModel(lipgloss.NewStyle()))
rightSegments = append(rightSegments, model)
savingStyle := segmentStyle.Bold(true) savingStyle := segmentStyle.Bold(true)
saving := "" saving := ""
if m.persistence { if m.persistence {
saving = savingStyle.Foreground(lipgloss.Color("2")).Render("✅💾") saving = savingStyle.Foreground(lipgloss.Color("2")).Render("💾")
} else { } else {
saving = savingStyle.Foreground(lipgloss.Color("1")).Render("❌💾") saving = savingStyle.Foreground(lipgloss.Color("1")).Render("💾")
} }
leftSegments = append(leftSegments, saving) rightSegments = append(rightSegments, saving)
// Right segments return m.layoutFooter(width, leftSegments, rightSegments, segmentSeparator)
rightSegments := []string{}
if m.elapsed > 0 && m.tokenCount > 0 {
throughput := fmt.Sprintf("%.0f t/sec", float64(m.tokenCount)/m.elapsed.Seconds())
rightSegments = append(rightSegments, segmentStyle.Render(throughput))
}
model := segmentStyle.Render(m.App.ActiveModel(lipgloss.NewStyle()))
rightSegments = append(rightSegments, model)
// Status
var status string
switch m.state {
case pendingResponse:
status = "Press ctrl+c to cancel" + m.spinner.View()
default:
status = "Press ctrl+s to send"
}
return m.layoutFooter(width, leftSegments, status, rightSegments, segmentStyle, segmentSeparator)
} }
func (m *Model) layoutFooter( func (m *Model) layoutFooter(
width int, width int,
leftSegments []string, leftSegments []string,
status string,
rightSegments []string, rightSegments []string,
segmentStyle lipgloss.Style,
segmentSeparator string, segmentSeparator string,
) string { ) string {
truncate := func(s string, w int) string {
return tuiutil.TruncateToCellWidth(s, w, "...")
}
padWidth := segmentStyle.GetHorizontalPadding()
left := strings.Join(leftSegments, segmentSeparator) left := strings.Join(leftSegments, segmentSeparator)
right := strings.Join(rightSegments, segmentSeparator) right := strings.Join(rightSegments, segmentSeparator)
leftWidth := tuiutil.Width(left) leftWidth := tuiutil.Width(left)
rightWidth := tuiutil.Width(right) rightWidth := tuiutil.Width(right)
sepWidth := tuiutil.Width(segmentSeparator)
frameWidth := footerStyle.GetHorizontalFrameSize()
availableWidth := width - leftWidth - rightWidth - tuiutil.Width(segmentSeparator) availableWidth := width - frameWidth - leftWidth - rightWidth
statusWidth := tuiutil.Width(status) if availableWidth >= sepWidth {
if availableWidth >= statusWidth+padWidth {
// Everything fits // Everything fits
availableWidth -= statusWidth + padWidth padding := strings.Repeat(" ", availableWidth)
padding := "" return footerStyle.Render(left + padding + right)
if availableWidth > 0 {
padding = strings.Repeat(" ", availableWidth)
}
return footerStyle.Render(left + segmentSeparator + segmentStyle.Render(status) + padding + right)
} }
if availableWidth > 4 { // Insert between left and right segments when they're being truncated
// There is some space left for a truncated status truncSeparator := "..."
truncatedStatus := truncate(status, availableWidth-padWidth)
return footerStyle.Width(width).Render(left + segmentSeparator + segmentStyle.Render(truncatedStatus) + right) totalAvailableWidth := width - frameWidth - len(truncSeparator)
minVisibleLength := 3
if totalAvailableWidth < 2*minVisibleLength {
minVisibleLength = totalAvailableWidth / 2
} }
if availableWidth >= 0 { leftProportion := float64(leftWidth) / float64(leftWidth+rightWidth)
// Draw some dots...
dots := ""
if availableWidth == 1 {
dots = " "
} else if availableWidth > 1 {
dots = " " + strings.Repeat(".", availableWidth-1)
dots = lipgloss.NewStyle().Faint(true).Render(dots)
}
return footerStyle.Width(width).Render(left + segmentSeparator + dots + right) newLeftWidth := int(max(float64(minVisibleLength), leftProportion*float64(totalAvailableWidth)))
} newRightWidth := totalAvailableWidth - newLeftWidth
// Trucate right segment so it fits truncatedLeft := faintStyle.Render(tuiutil.TruncateRightToCellWidth(left, newLeftWidth, ""))
right = truncate(right, tuiutil.Width(right)+availableWidth-1) truncatedRight := faintStyle.Render(tuiutil.TruncateLeftToCellWidth(right, newRightWidth, "..."))
padding := ""
return footerStyle.Width(width).Render(left + segmentSeparator + padding + right) return footerStyle.Width(width).Render(truncatedLeft + truncatedRight)
} }