Added command list
This commit is contained in:
+164
-66
@@ -26,23 +26,25 @@ const (
|
||||
)
|
||||
|
||||
type uiModel struct {
|
||||
logger slog.Logger
|
||||
viewport viewport.Model
|
||||
conversation model.Conversation
|
||||
textarea textarea.Model
|
||||
focus focusState
|
||||
picker picker
|
||||
senderStyle lipgloss.Style
|
||||
agentStyle lipgloss.Style
|
||||
errorStyle lipgloss.Style
|
||||
client llm.ChatClient
|
||||
errorMessage string
|
||||
history history.DbClient
|
||||
ctx context.Context
|
||||
width int
|
||||
height int
|
||||
waiting bool
|
||||
err error
|
||||
logger slog.Logger
|
||||
viewport viewport.Model
|
||||
conversation model.Conversation
|
||||
textarea textarea.Model
|
||||
focus focusState
|
||||
picker picker
|
||||
senderStyle lipgloss.Style
|
||||
agentStyle lipgloss.Style
|
||||
errorStyle lipgloss.Style
|
||||
statusStyle lipgloss.Style
|
||||
client llm.ChatClient
|
||||
statusMessage string
|
||||
commands commandList
|
||||
history history.DbClient
|
||||
ctx context.Context
|
||||
width int
|
||||
height int
|
||||
waiting bool
|
||||
err error
|
||||
}
|
||||
|
||||
type agentResponseMessage struct {
|
||||
@@ -55,6 +57,12 @@ type conversationSavedMessage struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type conversationRenamedMessage struct {
|
||||
title string
|
||||
summary string
|
||||
err error
|
||||
}
|
||||
|
||||
func InitialModel(ctx context.Context, config config.Config, client history.DbClient) uiModel {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = "Send a message..."
|
||||
@@ -80,7 +88,7 @@ func InitialModel(ctx context.Context, config config.Config, client history.DbCl
|
||||
|
||||
ta.KeyMap.InsertNewline.SetEnabled(false)
|
||||
|
||||
model := uiModel{
|
||||
ui := uiModel{
|
||||
textarea: ta,
|
||||
conversation: model.Conversation{},
|
||||
viewport: vp,
|
||||
@@ -94,8 +102,10 @@ func InitialModel(ctx context.Context, config config.Config, client history.DbCl
|
||||
logger: *slog.Default().With("Component", "TUI"),
|
||||
err: nil,
|
||||
}
|
||||
model.picker = newPicker()
|
||||
return model
|
||||
ui.picker = newPicker()
|
||||
ui.commands = newCommandList(30)
|
||||
ui.commands.setAvailable(ui.buildCommands())
|
||||
return ui
|
||||
}
|
||||
|
||||
func (m uiModel) Init() tea.Cmd {
|
||||
@@ -113,6 +123,10 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.picker.SetSize(msg.Width, msg.Height)
|
||||
m.commands.list.SetSize(msg.Width*80/100, commandDropdownHeight)
|
||||
if m.commands.open {
|
||||
m.viewport.SetHeight(m.viewport.Height() - commandDropdownHeight)
|
||||
}
|
||||
|
||||
var messages = m.renderMessages()
|
||||
|
||||
@@ -134,6 +148,10 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
if m.commands.open {
|
||||
m.closeCommands()
|
||||
return m, nil
|
||||
}
|
||||
if m.focus != focusChat {
|
||||
m.focus = focusChat
|
||||
}
|
||||
@@ -144,40 +162,75 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case "shift+enter", "ctrl+j", "alt+enter":
|
||||
m.textarea.InsertRune('\n')
|
||||
return m, nil
|
||||
case "enter":
|
||||
m.clearErrorMessage()
|
||||
input := m.textarea.Value()
|
||||
m.textarea.SetValue("")
|
||||
m.logger.Debug("Received enter. Creating new message", "message", input)
|
||||
switch input {
|
||||
case "/history":
|
||||
m.logger.Debug("Received history call")
|
||||
m.focus = focusHistory
|
||||
return m, m.getHistory()
|
||||
case "/models":
|
||||
m.logger.Debug("Received models sequence")
|
||||
m.focus = focusModels
|
||||
return m, m.getModels()
|
||||
case "/exit":
|
||||
m.logger.Debug("Received exit sequence. Quiting")
|
||||
fmt.Println(m.textarea.Value())
|
||||
return m, tea.Quit
|
||||
default:
|
||||
m.conversation.Messages = append(m.conversation.Messages, model.Message{
|
||||
Content: input,
|
||||
Role: "user",
|
||||
Seq: len(m.conversation.Messages) + 1,
|
||||
})
|
||||
var messages = m.renderMessages()
|
||||
m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(messages))
|
||||
m.textarea.Reset()
|
||||
m.viewport.GotoBottom()
|
||||
m.waiting = true
|
||||
return m, tea.Batch(m.callAgent(input, m.conversation.Messages), m.saveConversation())
|
||||
case "up", "down":
|
||||
if m.commands.open {
|
||||
var cmd tea.Cmd
|
||||
m.commands.list, cmd = m.commands.list.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
case "tab":
|
||||
if m.commands.open {
|
||||
if sel, ok := m.commands.selectedItem(); ok {
|
||||
m.textarea.SetValue("/" + sel.name + " ")
|
||||
}
|
||||
m.closeCommands()
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
case "enter":
|
||||
m.setStatusMessage("")
|
||||
input := m.textarea.Value()
|
||||
|
||||
if m.commands.open {
|
||||
if sel, ok := m.commands.selectedItem(); ok {
|
||||
m.textarea.SetValue("")
|
||||
m.closeCommands()
|
||||
mm, cmd := sel.handler(m)
|
||||
return mm, cmd
|
||||
}
|
||||
}
|
||||
|
||||
if after, ok :=strings.CutPrefix(input, "/"); ok {
|
||||
name := after
|
||||
for _, c := range m.commands.available {
|
||||
if c.name == name {
|
||||
m.textarea.SetValue("")
|
||||
m.closeCommands()
|
||||
mm, cmd := c.handler(m)
|
||||
return mm, cmd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m.textarea.SetValue("")
|
||||
m.closeCommands()
|
||||
m.logger.Debug("Received enter. Creating new message", "message", input)
|
||||
|
||||
m.conversation.Messages = append(m.conversation.Messages, model.Message{
|
||||
Content: input,
|
||||
Role: "user",
|
||||
Seq: len(m.conversation.Messages) + 1,
|
||||
})
|
||||
var messages = m.renderMessages()
|
||||
m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(messages))
|
||||
m.textarea.Reset()
|
||||
m.viewport.GotoBottom()
|
||||
m.waiting = true
|
||||
return m, tea.Batch(m.callAgent(input, m.conversation.Messages), m.saveConversation())
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
v := m.textarea.Value()
|
||||
if strings.HasPrefix(v, "/") {
|
||||
m.openCommands()
|
||||
} else {
|
||||
m.closeCommands()
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
@@ -187,6 +240,18 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
case conversationRenamedMessage:
|
||||
m.logger.Debug("Received messageRenamedMessage")
|
||||
m.waiting = false
|
||||
if msg.err != nil {
|
||||
m.logger.Error("There was an error", "error", msg.err)
|
||||
m.setErrorMessage(msg.err.Error())
|
||||
}
|
||||
m.setStatusMessage("Conversation renamed to: " + msg.title)
|
||||
m.conversation.Title = msg.title
|
||||
m.conversation.Summary = msg.summary
|
||||
return m, nil
|
||||
|
||||
case agentResponseMessage:
|
||||
m.logger.Debug("Received agentResponseMessage")
|
||||
m.waiting = false
|
||||
@@ -259,10 +324,12 @@ func (m uiModel) View() tea.View {
|
||||
}
|
||||
|
||||
parts := []string{m.viewport.View()}
|
||||
if m.errorMessage != "" {
|
||||
parts = append(parts, m.errorStyle.Render(m.errorMessage))
|
||||
if m.statusMessage != "" {
|
||||
parts = append(parts, m.statusMessage)
|
||||
}
|
||||
if m.commands.open {
|
||||
parts = append(parts, m.commands.view())
|
||||
}
|
||||
parts = append(parts, "model: "+m.client.Cfg.Llm.Model+"; url: "+m.client.Cfg.Api.Url)
|
||||
parts = append(parts, m.textarea.View())
|
||||
ui := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
@@ -270,10 +337,13 @@ func (m uiModel) View() tea.View {
|
||||
|
||||
c := m.textarea.Cursor()
|
||||
if c != nil {
|
||||
c.Y += lipgloss.Height(m.viewport.View()) + 1
|
||||
if m.errorMessage != "" {
|
||||
c.Y += lipgloss.Height(m.viewport.View())
|
||||
if m.statusMessage != "" {
|
||||
c.Y++
|
||||
}
|
||||
if m.commands.open {
|
||||
c.Y += lipgloss.Height(m.commands.view())
|
||||
}
|
||||
gap := m.width - lipgloss.Width(ui)
|
||||
c.X += max(gap/2, 0)
|
||||
}
|
||||
@@ -286,7 +356,7 @@ func (m uiModel) View() tea.View {
|
||||
func (m uiModel) callAgent(input string, messages []model.Message) tea.Cmd {
|
||||
m.logger.Debug("Calling agent", "query", input, "messages", messages)
|
||||
return func() tea.Msg {
|
||||
resp, err := m.client.Call(input, messages)
|
||||
resp, err := m.client.Query(input, messages)
|
||||
return agentResponseMessage{response: resp, err: err}
|
||||
}
|
||||
}
|
||||
@@ -299,6 +369,21 @@ func (m uiModel) saveConversation() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func (m uiModel) renameConversation() tea.Cmd {
|
||||
m.logger.Debug("Renaming conversation", "conversation", m.conversation)
|
||||
return func() tea.Msg {
|
||||
meta, err := m.client.Rename(m.conversation.Messages)
|
||||
if err != nil {
|
||||
return conversationRenamedMessage{title: meta.Title, summary: meta.Summary, err: err}
|
||||
}
|
||||
m.conversation.Title = meta.Title
|
||||
m.conversation.Summary = meta.Summary
|
||||
m.conversation, err = m.history.SaveConversation(m.ctx, m.conversation)
|
||||
|
||||
return conversationRenamedMessage{title: m.conversation.Title, summary: m.conversation.Summary, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m uiModel) getModels() tea.Cmd {
|
||||
m.logger.Debug("Querying models")
|
||||
return func() tea.Msg {
|
||||
@@ -326,8 +411,8 @@ func (m uiModel) getHistory() tea.Cmd {
|
||||
items := []modelItem{}
|
||||
for _, conv := range resp {
|
||||
name := conv.Id
|
||||
if conv.Name != "" {
|
||||
name = conv.Name
|
||||
if conv.Title != "" {
|
||||
name = conv.Title
|
||||
}
|
||||
items = append(items, modelItem{name: name, description: conv.Summary})
|
||||
}
|
||||
@@ -372,15 +457,28 @@ func (m *uiModel) renderMessages() string {
|
||||
}
|
||||
|
||||
func (m *uiModel) setErrorMessage(message string) {
|
||||
if m.errorMessage == "" {
|
||||
m.viewport.SetHeight(m.viewport.Height() - 1)
|
||||
}
|
||||
m.errorMessage = message
|
||||
m.statusMessage = m.errorStyle.Render(message)
|
||||
}
|
||||
|
||||
func (m *uiModel) clearErrorMessage() {
|
||||
if m.errorMessage != "" {
|
||||
m.viewport.SetHeight(m.viewport.Height() + 1)
|
||||
m.errorMessage = ""
|
||||
func (m *uiModel) setStatusMessage(message string) {
|
||||
if message == "" {
|
||||
m.statusMessage = m.statusStyle.Render("model: " + m.client.Cfg.Llm.Model + " url: " + m.client.Cfg.Api.Url)
|
||||
return
|
||||
}
|
||||
m.statusMessage = m.statusStyle.Render(message)
|
||||
}
|
||||
|
||||
func (m *uiModel) openCommands() {
|
||||
if !m.commands.open {
|
||||
m.commands.open = true
|
||||
m.viewport.SetHeight(m.viewport.Height() - commandDropdownHeight)
|
||||
}
|
||||
m.commands.filter(strings.TrimPrefix(m.textarea.Value(), "/"))
|
||||
}
|
||||
|
||||
func (m *uiModel) closeCommands() {
|
||||
if m.commands.open {
|
||||
m.commands.open = false
|
||||
m.viewport.SetHeight(m.viewport.Height() + commandDropdownHeight)
|
||||
}
|
||||
}
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/list"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/sahilm/fuzzy"
|
||||
)
|
||||
|
||||
const commandDropdownHeight = 8
|
||||
|
||||
var (
|
||||
dropdownStyle = lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("236")).
|
||||
Padding(0, 1)
|
||||
|
||||
inlineTitleStyle = lipgloss.NewStyle().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("86")).Bold(true)
|
||||
inlineTitleSelStyle = lipgloss.NewStyle().Background(lipgloss.Color("60")).Foreground(lipgloss.Color("86")).Bold(true)
|
||||
inlineDescStyle = lipgloss.NewStyle().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("245"))
|
||||
inlineDescSelStyle = lipgloss.NewStyle().Background(lipgloss.Color("60")).Foreground(lipgloss.Color("255"))
|
||||
inlinePadStyle = lipgloss.NewStyle().Background(lipgloss.Color("236"))
|
||||
inlinePadSelStyle = lipgloss.NewStyle().Background(lipgloss.Color("60"))
|
||||
)
|
||||
|
||||
type inlineDelegate struct{}
|
||||
|
||||
func (inlineDelegate) Height() int { return 1 }
|
||||
func (inlineDelegate) Spacing() int { return 0 }
|
||||
func (inlineDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
|
||||
func (inlineDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
|
||||
c, ok := item.(command)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if m.Width() <= 0 {
|
||||
return
|
||||
}
|
||||
title := "/" + c.name
|
||||
desc := c.description
|
||||
|
||||
titleStyle := inlineTitleStyle
|
||||
descStyle := inlineDescStyle
|
||||
padStyle := inlinePadStyle
|
||||
if index == m.Index() {
|
||||
titleStyle = inlineTitleSelStyle
|
||||
descStyle = inlineDescSelStyle
|
||||
padStyle = inlinePadSelStyle
|
||||
}
|
||||
|
||||
renderedTitle := titleStyle.Render(title)
|
||||
renderedDesc := descStyle.Render(desc)
|
||||
pad := max(m.Width()-1-lipgloss.Width(renderedTitle)-lipgloss.Width(renderedDesc), 1)
|
||||
fmt.Fprintf(w, "%s%s%s%s", renderedTitle, padStyle.Render(" "), renderedDesc, padStyle.Render(strings.Repeat(" ", pad)))
|
||||
}
|
||||
|
||||
type command struct {
|
||||
name string
|
||||
description string
|
||||
handler func(m uiModel) (uiModel, tea.Cmd)
|
||||
}
|
||||
|
||||
func (c command) FilterValue() string { return c.name }
|
||||
func (c command) Title() string { return "/" + c.name }
|
||||
func (c command) Description() string { return c.description }
|
||||
|
||||
type commandList struct {
|
||||
list list.Model
|
||||
open bool
|
||||
available []command
|
||||
}
|
||||
|
||||
func newCommandList(width int) commandList {
|
||||
l := list.New(nil, inlineDelegate{}, width, 1)
|
||||
l.Title = "Commands"
|
||||
l.SetShowFilter(false)
|
||||
l.SetFilteringEnabled(false)
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetShowPagination(false)
|
||||
l.SetShowHelp(false)
|
||||
l.DisableQuitKeybindings()
|
||||
return commandList{list: l}
|
||||
}
|
||||
|
||||
func (m uiModel) buildCommands() []command {
|
||||
return []command{
|
||||
{
|
||||
name: "exit",
|
||||
description: "Quit the application",
|
||||
handler: func(m uiModel) (uiModel, tea.Cmd) {
|
||||
m.logger.Debug("Received exit sequence. Quiting")
|
||||
return m, tea.Quit
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "history",
|
||||
description: "Open conversation history",
|
||||
handler: func(m uiModel) (uiModel, tea.Cmd) {
|
||||
m.logger.Debug("Received history call")
|
||||
m.focus = focusHistory
|
||||
return m, m.getHistory()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "models",
|
||||
description: "Choose an LLM model",
|
||||
handler: func(m uiModel) (uiModel, tea.Cmd) {
|
||||
m.logger.Debug("Received models sequence")
|
||||
m.focus = focusModels
|
||||
return m, m.getModels()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rename",
|
||||
description: "Rename the current conversation",
|
||||
handler: func(m uiModel) (uiModel, tea.Cmd) {
|
||||
m.logger.Debug("Received rename request")
|
||||
m.waiting = true
|
||||
return m, m.renameConversation()
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (cl *commandList) setAvailable(cmds []command) {
|
||||
cl.available = cmds
|
||||
items := make([]list.Item, len(cmds))
|
||||
for i, c := range cmds {
|
||||
items[i] = c
|
||||
}
|
||||
cl.list.SetItems(items)
|
||||
cl.list.SetHeight(len(items) + 1)
|
||||
}
|
||||
|
||||
func (cl *commandList) filter(query string) {
|
||||
var matches []list.Item
|
||||
if query == "" {
|
||||
matches = make([]list.Item, len(cl.available))
|
||||
for i, c := range cl.available {
|
||||
matches[i] = c
|
||||
}
|
||||
} else {
|
||||
names := make([]string, len(cl.available))
|
||||
for i, c := range cl.available {
|
||||
names[i] = c.name
|
||||
}
|
||||
result := fuzzy.Find(query, names)
|
||||
matches = make([]list.Item, len(result))
|
||||
for i, r := range result {
|
||||
matches[i] = cl.available[r.Index]
|
||||
}
|
||||
}
|
||||
cl.list.SetItems(matches)
|
||||
cl.list.ResetSelected()
|
||||
}
|
||||
|
||||
func (cl commandList) view() string {
|
||||
return dropdownStyle.Render(cl.list.View())
|
||||
}
|
||||
|
||||
func (cl commandList) selectedItem() (command, bool) {
|
||||
c, ok := cl.list.SelectedItem().(command)
|
||||
return c, ok
|
||||
}
|
||||
+1
-9
@@ -38,19 +38,11 @@ const Wordmark = `
|
||||
\__ \ (_) | |_| |\ V / __/ | | | | |
|
||||
|___/\___/ \__,_| \_/ \___|_| |_|_|_|`
|
||||
|
||||
const Commands = `
|
||||
/history
|
||||
/models
|
||||
/exit
|
||||
`
|
||||
|
||||
func renderLanding(width int, height int) string {
|
||||
return lipgloss.PlaceVertical(height, 0.7,
|
||||
lipgloss.JoinVertical(lipgloss.Center,
|
||||
lipgloss.PlaceHorizontal(width,
|
||||
lipgloss.Center,
|
||||
lipgloss.JoinHorizontal(lipgloss.Center, Logo, Wordmark)),
|
||||
lipgloss.PlaceHorizontal(width,
|
||||
lipgloss.Center,
|
||||
Commands)))
|
||||
))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user