tui: revamp footer (some more)
Simplified layout logic, reorganized elements
This commit is contained in:
parent
bb48bc9abd
commit
93c2fb3d1e
@ -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())
|
||||||
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user