diff --git a/pkg/tui/model/model.go b/pkg/tui/model/model.go index a657b28..198220c 100644 --- a/pkg/tui/model/model.go +++ b/pkg/tui/model/model.go @@ -26,6 +26,7 @@ type AppModel struct { Model string ProviderName string Provider provider.ChatCompletionProvider + Agent *lmcli.Agent } 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, "") app.Model = model app.ProviderName = provider + app.Agent = ctx.GetAgent(ctx.Config.Defaults.Agent) return app } @@ -264,9 +266,8 @@ func (a *AppModel) Prompt( Temperature: *a.Ctx.Config.Defaults.Temperature, } - agent := a.Ctx.GetAgent(a.Ctx.Config.Defaults.Agent) - if agent != nil { - params.Toolbox = agent.Toolbox + if a.Agent != nil { + params.Toolbox = a.Agent.Toolbox } ctx, cancel := context.WithCancel(context.Background()) diff --git a/pkg/tui/util/util.go b/pkg/tui/util/util.go index b807ae7..99262fb 100644 --- a/pkg/tui/util/util.go +++ b/pkg/tui/util/util.go @@ -61,22 +61,52 @@ func Width(str string) int { return ansi.PrintableRuneWidth(str) } -// truncate a string until its rendered cell width + the provided tail fits -// within the given width -func TruncateToCellWidth(str string, width int, tail string) string { - cellWidth := ansi.PrintableRuneWidth(str) - if cellWidth <= width { - return str - } - tailWidth := ansi.PrintableRuneWidth(tail) - for { - str = str[:len(str)-((cellWidth+tailWidth)-width)] - cellWidth = ansi.PrintableRuneWidth(str) - if cellWidth+tailWidth <= max(width, 0) { - break - } - } - return str + tail +func TruncateRightToCellWidth(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 := len(runes) - 1; i >= 0; i-- { + 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) { @@ -105,4 +135,3 @@ func ErrorBanner(err error, width int) string { Foreground(lipgloss.Color("1")). Render(fmt.Sprintf("%s", err)) } - diff --git a/pkg/tui/views/chat/chat.go b/pkg/tui/views/chat/chat.go index 97d316c..41ba187 100644 --- a/pkg/tui/views/chat/chat.go +++ b/pkg/tui/views/chat/chat.go @@ -107,6 +107,28 @@ type Model struct { 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 { m := Model{ App: app, @@ -121,21 +143,9 @@ func Chat(app *model.AppModel) *Model { wrap: true, selectedMessage: -1, - content: viewport.New(0, 0), - input: textarea.New(), - spinner: spinner.New(spinner.WithSpinner( - spinner.Spinner{ - Frames: []string{ - ". ", - ".. ", - "...", - ".. ", - ". ", - " ", - }, - FPS: time.Second / 3, - }, - )), + content: viewport.New(0, 0), + input: textarea.New(), + spinner: getSpinner(), replyCursor: cursor.New(), } diff --git a/pkg/tui/views/chat/cmds.go b/pkg/tui/views/chat/cmds.go index 175703f..078c4c6 100644 --- a/pkg/tui/views/chat/cmds.go +++ b/pkg/tui/views/chat/cmds.go @@ -122,17 +122,21 @@ func (m *Model) executeToolCalls(toolCalls []api.ToolCall) tea.Cmd { func (m *Model) promptLLM() tea.Cmd { m.state = pendingResponse + m.spinner = getSpinner() m.replyCursor.Blink = false m.startTime = time.Now() m.elapsed = 0 m.tokenCount = 0 - return func() tea.Msg { - resp, err := m.App.Prompt(m.App.Messages, m.chatReplyChunks, m.stopSignal) - if err != nil { - return msgChatResponseError{ Err: err } - } - return msgChatResponse(resp) - } + return tea.Batch( + m.spinner.Tick, + func() tea.Msg { + resp, err := m.App.Prompt(m.App.Messages, m.chatReplyChunks, m.stopSignal) + if err != nil { + return msgChatResponseError{Err: err} + } + return msgChatResponse(resp) + }, + ) } diff --git a/pkg/tui/views/chat/input.go b/pkg/tui/views/chat/input.go index b139f4e..9f2ca91 100644 --- a/pkg/tui/views/chat/input.go +++ b/pkg/tui/views/chat/input.go @@ -85,9 +85,9 @@ func (m *Model) scrollSelection(dir int) { if newIdx != m.selectedMessage { m.selectedMessage = newIdx 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 diff --git a/pkg/tui/views/chat/view.go b/pkg/tui/views/chat/view.go index 14c46c4..5540c24 100644 --- a/pkg/tui/views/chat/view.go +++ b/pkg/tui/views/chat/view.go @@ -41,7 +41,7 @@ var ( Faint(true). 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 { @@ -232,7 +232,7 @@ func (m *Model) conversationMessagesView() string { if m.state == pendingResponse && m.App.Messages[len(m.App.Messages)-1].Role != api.MessageRoleAssistant { heading := m.renderMessageHeading(-1, &api.Message{ Role: api.MessageRoleAssistant, - Metadata: api.MessageMeta { + Metadata: api.MessageMeta{ GenerationModel: &m.App.Model, }, }) @@ -265,105 +265,99 @@ func (m *Model) Header(width int) string { } else { title = "Untitled" } - title = tuiutil.TruncateToCellWidth(title, width-styles.Header.GetHorizontalPadding(), "...") + title = tuiutil.TruncateRightToCellWidth(title, width-styles.Header.GetHorizontalPadding(), "...") header := titleStyle.Render(title) return styles.Header.Width(width).Render(header) } func (m *Model) Footer(width int) string { - segmentStyle := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1).Faint(true) - segmentSeparator := "|" + segmentStyle := lipgloss.NewStyle().Faint(true) + segmentSeparator := segmentStyle.Render(" | ") // 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) saving := "" if m.persistence { - saving = savingStyle.Foreground(lipgloss.Color("2")).Render("✅💾") + saving = savingStyle.Foreground(lipgloss.Color("2")).Render("💾✅") } 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 - - 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) + return m.layoutFooter(width, leftSegments, rightSegments, segmentSeparator) } func (m *Model) layoutFooter( width int, leftSegments []string, - status string, rightSegments []string, - segmentStyle lipgloss.Style, segmentSeparator string, ) string { - truncate := func(s string, w int) string { - return tuiutil.TruncateToCellWidth(s, w, "...") - } - padWidth := segmentStyle.GetHorizontalPadding() - left := strings.Join(leftSegments, segmentSeparator) right := strings.Join(rightSegments, segmentSeparator) leftWidth := tuiutil.Width(left) 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 >= statusWidth+padWidth { + if availableWidth >= sepWidth { // Everything fits - availableWidth -= statusWidth + padWidth - padding := "" - if availableWidth > 0 { - padding = strings.Repeat(" ", availableWidth) - } - return footerStyle.Render(left + segmentSeparator + segmentStyle.Render(status) + padding + right) + padding := strings.Repeat(" ", availableWidth) + return footerStyle.Render(left + padding + right) } - if availableWidth > 4 { - // There is some space left for a truncated status - truncatedStatus := truncate(status, availableWidth-padWidth) - return footerStyle.Width(width).Render(left + segmentSeparator + segmentStyle.Render(truncatedStatus) + right) + // Insert between left and right segments when they're being truncated + truncSeparator := "..." + + totalAvailableWidth := width - frameWidth - len(truncSeparator) + + minVisibleLength := 3 + if totalAvailableWidth < 2*minVisibleLength { + minVisibleLength = totalAvailableWidth / 2 } - if availableWidth >= 0 { - // Draw some dots... - dots := "" - if availableWidth == 1 { - dots = " " - } else if availableWidth > 1 { - dots = " " + strings.Repeat(".", availableWidth-1) - dots = lipgloss.NewStyle().Faint(true).Render(dots) - } + leftProportion := float64(leftWidth) / float64(leftWidth+rightWidth) - 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 - right = truncate(right, tuiutil.Width(right)+availableWidth-1) - padding := "" - return footerStyle.Width(width).Render(left + segmentSeparator + padding + right) + truncatedLeft := faintStyle.Render(tuiutil.TruncateRightToCellWidth(left, newLeftWidth, "")) + truncatedRight := faintStyle.Render(tuiutil.TruncateLeftToCellWidth(right, newRightWidth, "...")) + + return footerStyle.Width(width).Render(truncatedLeft + truncatedRight) }